Skip to content

[17.0.0-rc] /actions reports business failures as HTTP 200 inside a double envelope — the only route of 12 that does; unify on real status codes before GA #3962

Description

@os-zhuang

Platform decision (maintainer): ObjectStack is a third-party-facing platform; normalized API results are a hard requirement. This issue is the "conscious, documented decision" that #3937's contract test explicitly invited ("flipping it later should be a conscious, documented decision") — the 200-on-failure behavior is classified as a bug, not a contract.

Why it is a bug and not a contract

Provenance, verified:

  1. No requirement source exists. No ADR (of 110+), no issue, no spec document ever specified that /actions failures return 200. Until last week the docs never described the failure shape at all.
  2. It originated as an implementation detail — the route's catch block reused deps.success() as its serializer. Across all 12 dispatcher domains (~85 deps.success call sites), /actions is the only route that wraps its real payload in a second {success, data} envelope.
  3. The "contract" was ratified the same day it was questioned. fix(runtime): carry code/fields across the sandbox boundary so form actions can anchor validation errors (#3918 follow-up) #3937 pinned the 200 in actions-validation-envelope.test.ts as a back-compat measure while fixing fields[] pass-through. Both premises it cited do not hold:

Harm to date — five bugs, one root cause

Bug Mechanism
Console green toast on failed actions (#3913) outer success: true read as success
redirectUrl never fired, opensInNewTab tab spun forever (objectui#2967) payload one level deeper than every reader assumed
Marketplace could report a failed install as installed same missed inner envelope
Client SDK envelope divergence (#3927 exists purely to normalize it) /actions shape ≠ /data shape
Handler crashes invisible to gateways/APM/alerting (fixed for crashes in #3951) 200 carries no failure signal

The inner success flag's only job is to carry the failure signal that the status code was refused. The double envelope and the 200 are two faces of one decision; removing one removes the other.

Target contract (identical to /data)

Outcome HTTP Body
Ran, returned 200 {success: true, data: <handler return value>}single wrap
Ran, rejected (business rule / validation) 400 {success: false, error: {message, code, details: {code?, fields?}}}
Never dispatched — unknown / denied / wrong type / unavailable 404 / 403 / 400 / 503 unchanged (already shipped in #3930/#3951)
Crashed (TypeError, driver error, sandbox timeout) 500 unchanged (#3951)

The crash-vs-rejection discriminator from #3951 (error name) stays — it now selects 400 vs 500 instead of 200 vs 500. A rejected flow regains code: FLOW_FAILED at 400.

Implementation checklist

  • domains/actions.ts: success exits single-wrap (deps.success(result)); deliberate rejections exit via errorFromThrown(err, 400) (carries VALIDATION_FAILED + fields[] into details, same as every other domain since fix(runtime): route every domain catch through errorFromThrown (#3918 follow-up) #3925)
  • Flip actions-validation-envelope.test.ts citing this issue — the documented decision it asked for
  • @objectstack/client: normalizeActionResult reads the single wrap; narrow legacy-shape heuristic keeps a current SDK working against a pre-fix server; surface stays non-throwing
  • objectui interpretActionResponse: same single-wrap read + legacy heuristic; ActionResult.data depth self-heals on new servers
  • Docs: error-catalog.mdx Action Errors table collapses to "status is the signal"; ui/actions.mdx, client-sdk.mdx
  • Changesets with an explicit BREAKING migration note for raw-HTTP third parties: branch on the status code; data unwraps one level less

Migration (third parties using raw HTTP): a failed action is no longer a 200 — read the status; on success, data is the handler's return value directly. Callers going through @objectstack/client need no change.

17.0.0-rc is the window: this is the last cheap moment to fix the wire before GA.

Metadata

Metadata

Assignees

No one assigned

    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