Skip to content

feat(runtime,objectql,metadata)!: ADR-0110 — action identity is name; refuse undeclared executables (#3935) - #3958

Merged
os-zhuang merged 3 commits into
mainfrom
claude/rest-actions-type-dispatch-y5vqfj
Jul 30, 2026
Merged

feat(runtime,objectql,metadata)!: ADR-0110 — action identity is name; refuse undeclared executables (#3935)#3958
os-zhuang merged 3 commits into
mainfrom
claude/rest-actions-type-dispatch-y5vqfj

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

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 name instead of target; this half derives the handler key from the declaration so both addressings resolve.

The defect

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 ({ name: 'complete_task', target: 'completeTask' }) those are different strings, so the two documented callers each worked on exactly the half the other broke:

Caller URL segment Handler dispatch D4 gate + 0104 params
Documented curl (name) complete_task ❌ 404 — handler is completeTask ✅ resolved
Console (target || name) completeTask ✅ runs silently skipped

content/docs/ui/actions.mdx claims "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 + executeRegisteredAction extract the candidate rotation the MCP run_action bridge 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:

  • declared → gate against it, then dispatch (unchanged);
  • metadata plane unreachable503. MetadataManager.load warn-logs a throwing loader and returns null, making an outage indistinguishable from a miss — so a database the metadata plane can't reach silently ungated every action it couldn't see. New loadDiagnosed reports whether the answer is trustworthy (degraded when a loader threw and none answered; a clean miss is not degraded).
  • genuinely undeclared404 naming the defineAction to add. An undeclared handler has no requiredPermissions to enforce, no param contract, and materialises no action_<name> tool (ADR-0109) — yet executes TRUSTED.

D5 — the inventory. ObjectQLEngine.listRegisteredActions (new) + reconcileActionRegistrations run on kernel:ready, logging registered-but-undeclared handlers and declared-script-actions-bound-to-nothing. Reconciliation derives keys through the same resolveActionHandlerKeys dispatch uses, so the inventory can't disagree with the router.

D6 — OS_ALLOW_UNDECLARED_ACTIONS is opt-out of enforcement (OS_ALLOW_*), warning per invocation, removed in 18.

D4 + migration — headless "declare it, then hide it" section in actions.mdx, a v17.mdx breaking entry alongside its two siblings, ADR status flipped to Accepted — implemented with 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-todo registers deferTask and setReminder, but their declarations are type: 'modal' with target naming 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's modalActionHandler documents falling through to "the server-side handler ... how a modal action bound to engine.registerAction(...) still runs", while the framework's headlessActionTypeError rejects type: 'modal' over REST with a 400. Same "declared ≠ enforced" family, different axis.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DTTKgYriDB6RVrkYjxxnG1

…`; 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
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 29, 2026 11:59pm

Request Review

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.
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/l labels Jul 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata, @objectstack/objectql, @objectstack/runtime.

29 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via packages/runtime)
  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata, @objectstack/objectql)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql, @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx (via packages/metadata)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/metadata, @objectstack/objectql, @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/metadata-service.mdx (via @objectstack/metadata)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/runtime)
  • content/docs/releases/v12.mdx (via @objectstack/metadata)
  • content/docs/releases/v9.mdx (via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…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).
@github-actions github-actions Bot added size/xl and removed size/l labels Jul 30, 2026
@os-zhuang
os-zhuang merged commit ffb003c into main Jul 30, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/rest-actions-type-dispatch-y5vqfj branch July 30, 2026 00:14
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

REST /actions resolves declarations by name only, so a target-bound action's ADR-0066 D4 gate + ADR-0104 param contract are silently skipped

2 participants