Skip to content

[17.0.0-rc.0] Global actions unreachable: registered under key 'global', REST fallback probes '*' — and handler failures return HTTP 200 {success:true,data:{success:false}} #3913

Description

@yinlianghui

Found upgrading HotCRM to 17.0.0-rc.0 (tag commit fc156fa4a). Upgrade blocker. Two independent defects that compound.

Defect 1 — registration key vs lookup key

Global (objectName-less) actions are registered under the literal key 'global' by both writers:

  • packages/runtime/src/app-plugin.ts:640-644 — action.object || 'global' → ql.registerAction(objectKey, ...)
  • packages/objectql/src/plugin.ts:1345-1350 (actionObjectKey) → 'global', used at :1550

But the REST dispatcher's fallback probes '*' (packages/runtime/src/domains/actions.ts:195-204), and executeAction is an exact-string Map lookup with no wildcard semantics (packages/objectql/src/engine.ts:731-746 — the error template there is verbatim the observed Action 'log_call' on object '*' not found).

Result: POST /api/v1/actions/<any-object>/log_call always fails. POST /api/v1/actions/global/log_call works by accident (path segment matches the registration key). Doc comments disagree with each other about which key is canonical (domains/actions.ts:15/:39 say '*'; objectql/src/plugin.ts:1348 and action-execution.ts:694-698 say 'global').

Defect 2 — every handler failure is wrapped as transport success

Both the success and failure paths call deps.success(...) (domains/actions.ts:205 / :215), and success() always emits {status: 200, body: {success: true, data}} (packages/runtime/src/http-dispatcher.ts:444-449). Wire result for any handler error (including the FORBIDDEN from the ctx.api issue filed separately):

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

The client SDK documents this envelope and deliberately does not throw (packages/client/src/index.ts:2882/:4304) — so every caller that doesn't manually check data.success silently swallows failures. The shipped console does exactly that on its main action path (objectui issue to follow), so global-action failures produce a green success toast.

Suggested fix direction

Pick one canonical key ('global'), make the fallback probe it (or add wildcard semantics in executeAction), and route handler failures through deps.error with a real status code.

Activity

  1. os-zhuang commented on Jul 29, 2026

    @os-zhuang
    Contributor

    #3930 fixes Defect 1 in full and takes only the uncontested half of Defect 2. Flagging why, because this issue's suggested fix and #3937 point in opposite directions and I don't think a merge conflict is the right place to settle that.

    Defect 1 — 'global' is now the canonical key, one probe order ([routed object, 'global', '*']) shared by the REST route and the MCP bridge, /actions//:action routes instead of 400-ing.

    Defect 2 — #3937 landed shortly before #3930 and pinned the opposite contract in actions-validation-envelope.test.ts:

    The status stays 200. /actions has always reported business failure in the payload ({ success: false }), not as a transport error, and every caller branches on data.success. […] That choice is asserted below so it cannot drift silently.

    #3930 now honours that, and moves only the exit where nothing dispatched: an action registered under no key is a 404. That isn't a new contract — the route already answers 403 denied, 400 wrong action type and 503 unavailable with a status. The line is did a handler run: below it the payload, above it the status. That alone fixes the case in this report, since Action 'log_call' … not found was exactly a non-dispatch.

    On the wider question, for whoever settles it

    Two of #3937's four premises look weaker than they read, and I'd rather put that on the record than quietly work around it:

    "/actions has always reported business failure in the payload." Only the catch block does. The route already returns 405, 400 (bad path), 403 (ADR-0066 D4 gate), 400 (wrong action type), 503 (no engine / no automation). The contract is already split at the dispatch boundary — the question is whether that boundary is in the right place, not whether a uniform contract is being broken.

    "Every existing caller branches on data.success." This is the one I'd push back on hardest, because it's checkable and it doesn't hold. Three callers in these two repos did not:

    • useConsoleActionRuntime.serverActionHandler — the console's main action path (list toolbars, row actions, page actions). Checked res.ok and the outer envelope only, so a failure fired the green "completed" toast. This is the symptom in this report; fixed in fix(actions): a failed server action no longer reports as success (green toast) objectui#2963.
    • marketplaceApi.installPackage — same hole, could report a package as installed when it was not.
    • cloud-connection-plugin's install proxy — forwards resp.status verbatim, so it forwarded a 200.

    RecordDetailView did check it — and its comment says why it had to be taught: "otherwise a failed action is mistaken for success and fires the green 'completed' toast while the real error is swallowed." Someone already hit this once. That's the signature of a default whose misuse is silent: the code that forgets looks identical to the code that doesn't, until a user is told an operation succeeded.

    Two more things worth weighing, neither of which is about HTTP purity:

    • The same error has two answers depending on the route. A record ValidationError is 400 + fields[] on /data (mapDataError, always has been) and 200 on /actions. That divergence has already cost something concrete — fix(client): normalize both server error envelopes so err.code / err.fields mean one thing (#3918 follow-up) #3927 exists purely to normalize the two envelopes client-side. And fix(runtime): route every domain catch through errorFromThrown (#3918 follow-up) #3925 just moved every other domain catch onto errorFromThrown; /actions is now the single carve-out, which is where two-envelope problems come from.
    • A 200 is invisible to everything between client and server. Gateways, retry/circuit-breaker policy, error-rate alerting, APM auto-capture, uptime probes, fetch().ok, agent integrations over MCP — all read status. For a platform whose main extension surface is customer-authored script bodies, "customer action bodies are throwing" currently has no signal that doesn't require body-parsing at every hop.

    The costs are asymmetric: treating a business rejection as a failure loses nothing (it was a failure); treating a failure as success ships a green toast over a broken operation.

    None of that makes #3937 wrong about the case it fixed — a form action's ValidationError genuinely is a normal outcome, and code/fields in the payload is a real improvement. It's the scope of the 200 I'd question, and 17.0.0-rc is the moment to question it. Happy to do that as a follow-up PR if there's appetite; #3930 deliberately doesn't presume the answer.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions