feat(runtime,objectql,metadata)!: ADR-0110 — action identity is name; refuse undeclared executables (#3935) - #3958
Merged
Conversation
…`; refuse undeclared executables (#3935) POST /api/v1/actions/:object/:action resolved the DECLARATION from the URL segment as a name but dispatched the HANDLER using that same segment as a registry key. For a target-bound action those differ, so the two documented callers each worked on exactly the half the other broke: the documented curl resolved the declaration then 404ed, while the Console's target-addressed call dispatched fine and resolved no declaration — silently skipping the ADR-0066 D4 capability gate and the ADR-0104 param contract. D1/D2 — identity is the declarative name on every surface; handler-key candidates are derived from the resolved declaration via a rotation now shared with MCP run_action (resolveActionHandlerKeys, executeRegisteredAction). REST previously rotated only the object key. D3 (BREAKING) — declaration resolution is a trichotomy. A genuinely undeclared handler is refused (404) naming the defineAction to add, rather than executed ungated with system privileges; an unreachable metadata plane is a 503 rather than a silent ungating, which MetadataManager.loadDiagnosed makes detectable by telling a clean miss from an outage. OS_ALLOW_UNDECLARED_ACTIONS=1 is the migration valve, warning per invocation, removed in 18. D5 — reconcileActionRegistrations + ObjectQLEngine.listRegisteredActions power a kernel:ready inventory listing registered-but-undeclared handlers and declared script actions bound to no handler: the ADR-0078 converse. D6 — security-gate strictness is opt-out (OS_ALLOW_*), never opt-in. Two superseded tests updated rather than worked around: both asserted that an undeclared action still runs, which is the behaviour D3 rules out. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DTTKgYriDB6RVrkYjxxnG1
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
This was referenced Jul 29, 2026
Resolves the actions.mdx conflict: main's #3913 follow-up reworded the REST failure list from two ways to three (adding the 500 crash case) in the same place ADR-0110 D1 added the name-vs-target paragraph. Both kept. domains/actions.ts auto-merged cleanly and was verified by hand — the two changes are orthogonal axes of the same route: main's covers how a failure is classified AFTER a handler ran (reject -> 200 payload, crash -> 500), ADR-0110 D3 covers whether dispatch is permitted at all (declared -> gate; metadata plane unreachable -> 503; undeclared -> 404). objectql 276, runtime+metadata 1163 green after the merge.
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 29 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…o exist Test Core caught 10 failures in app-plugin.test.ts — `TypeError: ctx.hook is not a function`. The inventory registered its kernel:ready hook unconditionally, while every pre-existing ctx.hook call site was conditional (jobs register one only when the bundle declares jobs), so the shared mock context had never needed the member. Both halves fixed, because both were wrong: - app-plugin.ts — the block promises it is best-effort and 'must never be the reason a kernel fails to boot', then threw when the member was absent. Optional-call so it keeps its own contract for a host with a partial context. - app-plugin.test.ts — hook/trigger are REQUIRED members of PluginContext, so the double was modelling something that cannot exist. Added rather than shrinking the code to fit the double. objectql 1163, metadata 276, runtime 857 — each package run separately and verified by EXIT CODE (the earlier combined run was filtered through grep, which silently dropped the runtime block that carried this failure).
os-zhuang
pushed a commit
that referenced
this pull request
Jul 30, 2026
Integrates two main-side changes that landed mid-flight, both touching the same contract this branch changes: - ADR-0110 (#3958): action identity is `name`, undeclared executables refuse. Its new tests asserted the pre-#3962 double envelope; updated to the single wrap, and its handler-rejection case ("NOT a 404 routing miss") now asserts the 400 — the distinction it exists to pin is 400-vs-404, unchanged. - #3971: the dispatcher's `error.code` is the semantic string and `details.code` is PROMOTED into it. Assertions on `error.details.code` moved to `error.code`; `fields` stay in `details`. Conflicts resolved in ui/actions.mdx (kept main's name-vs-target paragraph + this branch's "failures speak HTTP" contract) and the type-dispatch test (kept main's ADR-0110 undeclared/valve/degraded cases, adjusted the valve run's envelope to the single wrap). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011AvZj6cLX7APd7roh2eK4F
os-zhuang
added a commit
that referenced
this pull request
Jul 30, 2026
… single wrap (#3962) (#3969) Fixes #3962 — the platform decision classifying the 200-on-failure wire as a bug, not a contract. The 200-with-inner-envelope shape was never designed: no ADR or doc specified it, it originated as the catch block reusing deps.success(), and /actions was the only route of 12 that double-wrapped. Five defects traced back to that one extra layer. Contract now, identical to /data: - ran, returned → 200 {success:true, data: <handler return value>} (single wrap) - ran, rejected → 400, semantic code on error.code (VALIDATION_FAILED with fields[] in details; FLOW_FAILED for a rejected flow) - never dispatched → 404 / 403 / 400 / 503 (unchanged, #3930/#3951) - crashed → 500 (unchanged, #3951; name-based discriminator now selects 400 vs 500) actions-validation-envelope.test.ts pinned the 200 "so flipping it later is a conscious, documented break"; #3962 is that decision and the flipped test cites it. Integrates ADR-0110 (#3958/#3987 — name identity, undeclared refusal with no opt-out) and #3971 (semantic error.code, details.code promotion) from main. client.actions.invoke/invokeGlobal still never throw: every failure status folds into {success:false, error}, success reads the single wrap, and a narrow legacy heuristic (boolean success, no foreign keys) keeps a current SDK correct against pre-#3962 servers. Migration (raw-HTTP callers): branch on the status; on 200, data is the handler's return value directly. SDK callers need no change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements all six rulings of ADR-0110 (merged in #3949). Closes #3935.
Ships in lockstep with objectstack-ai/objectui#2970 — merge together. The client half posts
nameinstead oftarget; this half derives the handler key from the declaration so both addressings resolve.The defect
POST /api/v1/actions/:object/:actionresolved the declaration from the URL segment as aname, but dispatched the handler using that same segment as a registry key. For a target-bound action ({ name: 'complete_task', target: 'completeTask' }) those are different strings, so the two documented callers each worked on exactly the half the other broke:name)complete_taskcompleteTasktarget || name)completeTaskcontent/docs/ui/actions.mdxclaims "the Console button and the API call run the same gate and handler". That was false in both directions, and nobody noticed because each caller took the path that worked for it.What changed
D1/D2 — resolve, then address.
resolveActionHandlerKeys+executeRegisteredActionextract the candidate rotation the MCPrun_actionbridge already used; the REST route now calls the same helper. REST previously rotated only the object key (objectName → global → *) and never the handler key — strictly weaker than MCP, and the reason the documented curl 404ed.D3 — resolution is a trichotomy, and only one branch dispatches. The old
try { … } catch { /* no gate to enforce */ }collapsed three states with opposite meanings into one fail-open path:MetadataManager.loadwarn-logs a throwing loader and returnsnull, making an outage indistinguishable from a miss — so a database the metadata plane can't reach silently ungated every action it couldn't see. NewloadDiagnosedreports whether the answer is trustworthy (degradedwhen a loader threw and none answered; a clean miss is not degraded).defineActionto add. An undeclared handler has norequiredPermissionsto enforce, no param contract, and materialises noaction_<name>tool (ADR-0109) — yet executes TRUSTED.D5 — the inventory.
ObjectQLEngine.listRegisteredActions(new) +reconcileActionRegistrationsrun onkernel:ready, logging registered-but-undeclared handlers and declared-script-actions-bound-to-nothing. Reconciliation derives keys through the sameresolveActionHandlerKeysdispatch uses, so the inventory can't disagree with the router.D6 —
OS_ALLOW_UNDECLARED_ACTIONSis opt-out of enforcement (OS_ALLOW_*), warning per invocation, removed in 18.D4 + migration — headless "declare it, then hide it" section in
actions.mdx, av17.mdxbreaking entry alongside its two siblings, ADR status flipped toAccepted — implementedwith evidence.Superseded tests — updated, not worked around
Two existing assertions encoded the behaviour D3 rules out ("still runs an UNDECLARED action through the handler registry", and a standalone-declaration case asserting it "falls through to the registry"). Both now assert the refusal, plus new cases for the valve and the 503.
actions-global-key.test.ts(12 cases) tests the addressing axis with fixtures that declare nothing. Rather than weaken D3, its double now declares whatever name it is asked for — bound to its own name, i.e. the very key the URL segment used to be taken as, so the probe orders those tests pin are unchanged. One new case pins that the object-less route is not exempt from governance.Verification
objectql 276 · runtime + metadata 1163 · eslint clean on all changed files.
One finding, left alone deliberately
The D5 reconciliation flagged real orphans in a shipped example on its first run:
examples/app-todoregistersdeferTaskandsetReminder, but their declarations aretype: 'modal'withtargetnaming the modal, so no declaration addresses those keys. Pre-existing and unreachable through the Console before this change too — not a regression, and not fixed here (Prime Directive #10). It does surface an adjacent contradiction worth its own issue: objectui'smodalActionHandlerdocuments falling through to "the server-side handler ... how a modal action bound toengine.registerAction(...)still runs", while the framework'sheadlessActionTypeErrorrejectstype: 'modal'over REST with a 400. Same "declared ≠ enforced" family, different axis.🤖 Generated with Claude Code
https://claude.ai/code/session_01DTTKgYriDB6RVrkYjxxnG1