Skip to content

fix(actions): reach global actions at their real registration key, and 404 an action that never dispatched (#3913) - #3930

Merged
os-zhuang merged 2 commits into
mainfrom
claude/global-actions-unreachable-rw7hpk
Jul 29, 2026
Merged

fix(actions): reach global actions at their real registration key, and 404 an action that never dispatched (#3913)#3930
os-zhuang merged 2 commits into
mainfrom
claude/global-actions-unreachable-rw7hpk

Conversation

@os-zhuang

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

Copy link
Copy Markdown
Contributor

Fixes #3913.

Rebased onto #3937. This PR originally moved every /actions failure to a real status, which is what #3913 asks for. #3937 landed the opposite decision 40 minutes earlier and asserted it in actions-validation-envelope.test.ts so it could not drift silently. I've merged main and narrowed this PR to the part that does not contradict it — see Where the line is. The wider question is raised in #3913 rather than settled here.

Defect 1 — registration key vs lookup key

Both writers register an objectName-less action under the literal 'global'AppPlugin (action.object || 'global') and ObjectQLPlugin.actionObjectKey. The REST fallback probed '*', and engine.executeAction is an exact-string Map lookup with no wildcard semantics, so that probe could only ever miss:

Action 'log_call' on object '*' not found

POST /api/v1/actions/global/log_call worked by accident (the path segment happened to spell the registration key); POST /api/v1/actions//log_call never worked at all, and neither did falling back from an object-scoped route to a global handler.

  • 'global' is now the canonical key (GLOBAL_ACTION_OBJECT_KEY).
  • One probe order — actionHandlerObjectKeys(objectName)[routed object, 'global', '*'], deduped — serves both the REST route and the MCP run_action bridge, which had the same '*' rotation. The legacy '*' stays last so a handler registered directly against it still resolves.
  • A single-segment path (/actions//:action) routes at 'global' instead of 400-ing.
  • The doc comments that disagreed about which key is canonical are corrected at every site.

Defect 2 — "no such action" was reported as a success

The not-found exit called deps.success(...), which always emits {status: 200, body: {success: true, data}}. A request naming an action that does not exist came back as:

{"success":true,"data":{"success":false,"error":"Action 'log_call' on object '*' not found"}}

Any caller that did not hand-unwrap the INNER envelope read the outer success: true and reported a success that never happened — including the shipped console (fixed on that side in objectstack-ai/objectui#2963).

That exit is a 404 now, and it names the routed object rather than whichever probe ran last.

Where the line is

Did a handler run?

It ran and rejected — body throw, flow rejection, ValidationError HTTP 200, data: {success: false, error, code?, fields?}unchanged, #3937's envelope
It never dispatched — no handler under any key 404 ← the only thing this PR moves
It never dispatched — denied / wrong action type / unavailable 403 / 400 / 503 — already the case before this PR

#3937 is right that a handler which ran and rejected is a business outcome and belongs in the payload. That reasoning does not reach an unregistered action: nothing ran, there is no outcome to report, and it is indistinguishable from a typo'd URL. The route already answers every other pre-dispatch failure with a status, so the not-found exit is joining them rather than opening a new contract.

Reverted from the original version of this PR to honour #3937: the 400 for an action-body throw, the 400 + FLOW_FAILED for a rejected flow, and the errorFromThrown 500 fallback.

Client SDK

client.actions.invoke / invokeGlobal still do not throw — and the catch added here is now load-bearing rather than cosmetic. client.fetch throws on every non-2xx, so without it the routes that just gained a status would start propagating exceptions into callers that only ever checked result.success.

Testing

  • pnpm build green (71 tasks).
  • Suites after merging main: runtime 816, objectql 1163, client 194, rest 419, mcp 83 — all passing, including fix(runtime): carry code/fields across the sandbox boundary so form actions can anchor validation errors (#3918 follow-up) #3937's actions-validation-envelope.test.ts, which this branch previously broke.
  • http-dispatcher.actions-global-key.test.ts (13 cases). The engine double is deliberately faithful on the one point that matters: a real Map keyed <object>:<action> that throws engine.ts's verbatim miss message — a double that resolved any key would have passed while the bug was live. Two of its cases assert the 200 side of the line, so the 404 cannot later be widened into "every action failure is a 4xx" by someone reading only this file.

Migration

A caller that hand-checked data.success is unaffected. A caller that treated any 200 as success now sees a 404 for an action that does not exist — which is the bug being fixed. Callers going through @objectstack/client need no change.

🤖 Generated with Claude Code

https://claude.ai/code/session_011AvZj6cLX7APd7roh2eK4F

…ailures with a real status (#3913)

Two independent defects that compounded into "global actions are unreachable,
and when one fails you are told it succeeded".

1 — registration key vs lookup key. Both writers register an objectName-less
action under the literal 'global' (AppPlugin's `action.object || 'global'`,
ObjectQLPlugin.actionObjectKey), but the REST fallback probed '*' — and
`engine.executeAction` is an exact-string Map lookup with no wildcard
semantics, so that probe could only miss. `/actions/global/:action` worked by
accident (the path segment happened to spell the registration key);
`/actions//:action` never worked, and neither did falling back from an
object-scoped route to a global handler.

'global' is now canonical (GLOBAL_ACTION_OBJECT_KEY). One probe order —
[routed object, 'global', '*'] via actionHandlerObjectKeys — serves both the
REST route and the MCP run_action bridge, with the legacy '*' kept last so a
handler registered directly against it still resolves. A single-segment path
routes at 'global' instead of 400-ing, and the miss now reports the ROUTED
object rather than whichever probe ran last.

2 — every handler failure was wrapped as transport success. Both exits called
deps.success(), which always emits {status: 200, body: {success: true, data}},
so a denial or a thrown handler went out as HTTP 200 {success:true, data:
{success:false, error}} and any caller that skipped the inner envelope
swallowed it. Failures now exit through the dispatcher's error path: 404
unregistered, 400 for a deliberate action-body throw (the mapping
@objectstack/rest's mapDataError already uses for the same SandboxError) and
for a rejected flow (code FLOW_FAILED), the error's own .status when it
carries one, 400 + fields[] for a ValidationError, 500 otherwise. The success
envelope is unchanged.

client.actions.invoke/invokeGlobal still do not throw — they fold the new
non-2xx shape into the same {success, data?, error?} result and keep honouring
the pre-#3913 200-with-inner-failure shape, so a current SDK still talks to an
older server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011AvZj6cLX7APd7roh2eK4F
@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:19am

Request Review

@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/client, @objectstack/objectql, @objectstack/runtime.

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

  • content/docs/ai/skills-reference.mdx (via packages/client)
  • content/docs/api/client-sdk.mdx (via @objectstack/client, packages/runtime)
  • content/docs/api/data-flow.mdx (via @objectstack/client)
  • content/docs/api/environment-routing.mdx (via @objectstack/client)
  • content/docs/api/error-catalog.mdx (via @objectstack/client)
  • 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/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/client, @objectstack/runtime)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/client)
  • content/docs/kernel/runtime-services/index.mdx (via packages/client)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/client, @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/client, @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/realtime-protocol.mdx (via @objectstack/client)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/client, @objectstack/objectql, @objectstack/runtime)
  • content/docs/releases/v16.mdx (via @objectstack/client)
  • 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.

Resolves the `/actions` catch-block conflict with #3937, which landed the
opposite decision to this branch's second half and asserted it in
`actions-validation-envelope.test.ts` so it could not drift silently.

#3937 is right about the case it addressed: a handler that RAN and rejected is
a business outcome, not a transport error, and belongs in the payload. That
reasoning does not extend to a request that never DISPATCHED — an unregistered
action has no outcome to report and is indistinguishable from a typo'd URL.
This route already answers the other pre-dispatch failures with a status (403
denied, 400 wrong action type, 503 unavailable), so the not-found exit joins
them as a 404 and everything below the dispatch line keeps #3937's envelope,
`code`/`fields` included.

Reverted from this branch to honour that: the 400 for an action-body throw, the
400 + FLOW_FAILED for a rejected flow, and the errorFromThrown 500 fallback.
The client-side catch in `actions.invoke` stays and is now load-bearing —
`client.fetch` throws on every non-2xx, so without it the routes that just
gained a status would start propagating exceptions into callers that only ever
checked `result.success`.

The wider 200-vs-4xx question is left open for #3913 rather than settled here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011AvZj6cLX7APd7roh2eK4F
@os-zhuang os-zhuang changed the title fix(actions): reach global actions at their real key, serve handler failures with a real status (#3913) fix(actions): reach global actions at their real registration key, and 404 an action that never dispatched (#3913) Jul 29, 2026
@os-zhuang
os-zhuang merged commit c2bbd97 into main Jul 29, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/global-actions-unreachable-rw7hpk branch July 29, 2026 12:05
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/l tests tooling

Projects

None yet

2 participants