Skip to content

fix(actions): an action that CRASHED is a 500, not a 200 reporting success:false (#3913 follow-up) - #3951

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

fix(actions): an action that CRASHED is a 500, not a 200 reporting success:false (#3913 follow-up)#3951
os-zhuang merged 1 commit into
mainfrom
claude/global-actions-unreachable-rw7hpk

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Follow-up to #3930 / #3913. Implements the one part of the 200-vs-status question I think is uncontested — see the analysis in #3913 for the wider argument, which this PR deliberately does not settle.

What #3937 argued, and what it also covered

#3937 settled that a failed action reports in the payload at HTTP 200:

an action that "fails" is a normal outcome, not a transport error

That is a statement about the action rejecting — a business rule saying no. Correct, and nothing here touches it. But the same exit was also covering a third case it never argued for: a TypeError in a handler, a driver blowing up, a sandbox timeout.

Those are not outcomes the action chose to report. They are the server failing to produce one. Serving them as 200 hid every handler crash from the layers that exist to catch server faults — gateway error rates, retry and circuit-breaker policy, APM auto-capture, alerting, fetch().ok. For a platform whose main extension surface is customer-authored script bodies, "customer action bodies are throwing" had no signal short of body-parsing at every hop.

They are 500 now, through the same errorFromThrown exit every other domain catch has used since #3925. A side benefit: a driver dump finally goes through the internal-error-leak sanitiser (#3867) instead of reaching the client verbatim inside a 200 body.

Nothing #3937 put in the payload moves

Rejection and crash are told apart by the error's name — the signal @objectstack/rest already uses on this exact distinction:

non-default names (TypeError: …) […] signal a genuine script bug rather than a deliberately thrown business rule

Thrown Verdict Wire
new Error(msg) — a registered handler rejecting rejection 200 + payload
SandboxError with innerMessage — a body's deliberate throw rejection 200 + payload
Anything carrying code / fields, or a ValidationError by name rejection 200 + payload
A throw with no name at all not confidently a fault 200 + payload
TypeError / ReferenceError / SqliteError / a driver's class crash 500
SandboxError with no innerMessage — timeout, capability denial crash 500

Deliberately the narrow direction: only what is certainly a fault moves; everything uncertain keeps the 200 it has today. In particular a plain throw new Error('Lead already converted') from a registerAction handler stays a 200 payload — a naive "no sandbox marker ⇒ fault" rule would have broken it, and there is a test pinning that.

One related fix in the same exit

An error carrying its own status / statusCode — a plugin's FORBIDDEN with status: 403 — is now served with it rather than buried in a 200 payload. That status was the one thing the thrower was unambiguous about. Record ValidationErrors deliberately carry no .status (see validation-failure.ts), so #3937's cases never reach that branch.

Testing

Two of my own first-draft assertions were wrong and the code was right; one of them exposed the .status gap above, which is why that fix is in this PR.

Docs

New Action Errors section in api/error-catalog.mdx with the full status table and the two-check pattern a raw fetch caller needs:

if (!res.ok) throw new Error(json.error?.message);             // never dispatched, or crashed
if (json.data?.success === false) showToast(json.data.error);  // ran and rejected

That contract previously lived only in test comments — four in-repo callers got it wrong, so it belongs where callers actually look. ui/actions.mdx updated to match.

🤖 Generated with Claude Code

https://claude.ai/code/session_011AvZj6cLX7APd7roh2eK4F


Generated by Claude Code

…ccess:false (#3913 follow-up)

#3937 settled that a failed action reports in the payload at HTTP 200 — "an
action that fails is a normal outcome, not a transport error". That is a
statement about the action REJECTING: a business rule saying no. The same exit
was also covering a third case it never argued for.

A TypeError in a handler, a driver blowing up, a sandbox timeout — those are
not outcomes the action chose to report, they are the server failing to produce
one. Serving them as 200 hid every handler crash from the layers that exist to
catch server faults: gateway error rates, retry and circuit-breaker policy, APM
auto-capture, alerting, fetch().ok. For a platform whose main extension surface
is customer-authored script bodies, "customer action bodies are throwing" had no
signal short of body-parsing at every hop.

Those are 500 now, through the same errorFromThrown exit every other domain
catch has used since #3925 — which also puts a driver dump behind the
internal-error-leak sanitiser (#3867) instead of letting it reach the client
verbatim in a 200 body.

Nothing #3937 put in the payload moves. A rejection and a crash are told apart
by the error's NAME, the signal @objectstack/rest already uses on this exact
distinction: a plain Error, a SandboxError carrying innerMessage, anything with
code/fields, and a ValidationError by name are rejections; TypeError /
ReferenceError / a driver's own class, and a SandboxError with no innerMessage
(timeout, capability denial) are crashes. A throw with no name at all is not
confidently a fault and keeps its 200 — deliberately the narrow direction.

Also in the same exit: an error carrying its own status/statusCode (a plugin's
FORBIDDEN with status 403) is served with it rather than buried in a 200
payload. Record ValidationErrors deliberately carry no .status, so #3937's
cases never reach that branch.

Documented in api/error-catalog.mdx (new Action Errors section with the full
status table and the two-check pattern a raw fetch caller needs) and
ui/actions.mdx.

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 12:33pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Jul 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime.

18 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/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • 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/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)
  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime)

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.

@os-zhuang
os-zhuang merged commit 6e141bc into main Jul 29, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/global-actions-unreachable-rw7hpk branch July 29, 2026 12:52
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants