Skip to content

fix(runtime): carry code/fields across the sandbox boundary so form actions can anchor validation errors (#3918 follow-up) - #3937

Merged
os-zhuang merged 2 commits into
mainfrom
claude/dispatcher-validation-error-fields-xo6zx9
Jul 29, 2026
Merged

fix(runtime): carry code/fields across the sandbox boundary so form actions can anchor validation errors (#3918 follow-up)#3937
os-zhuang merged 2 commits into
mainfrom
claude/dispatcher-validation-error-fields-xo6zx9

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Follow-up to the #3918 chain (#3920, #3925, #3927). Found by dogfooding the merged chain against a running app, not by reading code — this is the gap that made the original report's symptom survive all three merged PRs.

What dogfooding turned up

Submitting a record that fails validation through a form action came back as:

HTTP 200
{ "success": true, "data": { "success": false,
                             "error": "ValidationError: issued_on is required" } }

No status a client could branch on, no code, no fields[]. The chain's dispatcher fixes could not help here: the field list was already gone before any dispatcher exit ran. It was lost at the QuickJS boundary, twice —

  1. host → VM (quickjs-runner.ts, the ctx.api.object(x).<op>() rejection): vm.newError({ name, message }) dropped every other property, so a body reaching a record ValidationError saw bare prose.
  2. VM → host (the L2 wrapper's reject handler): flattened the error to the string `<name>: <message>` before the host ever saw it.

Reproduced on the showcase app: showcase_submit_signoff sets status: 'sent' on an invoice whose issued_on is empty, and issued_on declares requiredWhen: record.status in ['sent','paid'].

The fix

Both hops now carry an explicit allowlistcode and fields — alongside the message, and SandboxError exposes them as .code / .fields. /actions then surfaces them:

HTTP 200
{ "success": true, "data": { "success": false,
                             "error": "ValidationError: issued_on is required",
                             "code": "VALIDATION_FAILED",
                             "fields": [ { "field": "issued_on", "code": "required", … } ] } }

The allowlist is a security boundary, not a style choice. Host errors routinely hang driver state, connection details or whole record payloads off themselves, and anything crossing INTO the VM is readable by untrusted sandboxed code. Copying the error's own enumerable keys would leak all of it. There's a test asserting a ValidationError decorated with { internal: { dsn }, record: { ssn } } crosses with only name/message/code/fields visible.

The /actions wire contract is deliberately unchanged

Status stays 200, success: false remains the failure signal. That route has always reported business failure in the payload — an action that "fails" is a normal outcome, not a transport error — and every caller branches on data.success. Making it a 4xx would be a break in exchange for a strictly additive fix, so the fix is additive: code and fields are omitted when absent, and a caller that ignores them sees exactly what it saw before. There's a test pinning this so it can't drift silently; flipping it later should be a conscious, documented decision.

Message channels are byte-identical too: .message keeps the `<kind> '<name>' threw:` debug wrapper for server logs, .innerMessage stays the plain toast text. The structured payload rides alongside them, never instead of them.

Also: pinning the chain against the real ValidationError

dispatcher-validation-error.real.test.ts drives both dispatcher exits with the genuine objectql ValidationError instead of a hand-built fixture — including asserting its deliberate absence of .status, which is the assumption the whole #3918 fix rests on.

The existing fixture-based tests restate that contract (deliberately — they prove the duck-typing predicate accepts the shape, including for hand-thrown errors). These check it. Give ValidationError a .status one day, or rename .fields, and every fixture test keeps passing while production quietly regresses; now a test fails instead.

Verification

  • 19 new cases across three files: sandbox host→VM and VM→host, the /actions envelope, and the real-ValidationError pinning.
  • 7 fail against the pre-fix source — verified by reverting quickjs-runner.ts + actions.ts and re-running. (The real-ValidationError file passes both ways by design: it asserts an existing contract.)
  • Full runtime suite green: 798 tests / 58 files (was 764/54). Package builds; eslint clean.

Generated by Claude Code

…ctions can anchor validation errors (#3918 follow-up)

Found by dogfooding the merged #3918 chain against a running app. Submitting a
record that fails validation through a form ACTION came back as

  HTTP 200  { success: true, data: { success: false,
                                     error: "ValidationError: issued_on is required" } }

— no status to branch on, no code, no fields[]. The chain's dispatcher fixes
could not help: the field list was already gone before any dispatcher exit ran.
It was lost at the QuickJS boundary, twice:

1. host → VM: `vm.newError({name, message})` dropped every other property, so a
   body reaching a record ValidationError through `ctx.api.object(x).update(…)`
   saw bare prose.
2. VM → host: the wrapper's reject handler flattened the error to the string
   `<name>: <message>` before the host ever saw it.

Both hops now carry an explicit ALLOWLIST — `code` and `fields` — alongside the
message, and SandboxError exposes them as `.code` / `.fields`. The allowlist is
a security boundary, not a style choice: host errors routinely hang driver
state, connection details or whole record payloads off themselves, and anything
crossing INTO the VM is readable by untrusted sandboxed code.

`/actions` then surfaces them so a form can highlight the offending input. Its
wire contract is deliberately UNCHANGED: status stays 200 and `success: false`
remains the failure signal — that route has always reported business failure in
the payload and every caller branches on `data.success`, so `code`/`fields` are
purely additive and omitted when absent.

Message channels are byte-identical: `.message` keeps the debug wrapper for
server logs, `.innerMessage` stays the toast text.

Also adds dispatcher-validation-error.real.test.ts, pinning both dispatcher
exits against the REAL objectql ValidationError rather than a fixture —
including its deliberate absence of `.status`, the assumption the whole #3918
fix rests on.

Covered by 19 new cases across sandbox, /actions and the dispatcher exits;
7 of them fail on the pre-fix source.

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

Request Review

@github-actions github-actions Bot added size/l 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.

The docs-drift check on #3937 flagged `automation/hook-bodies.mdx`. Nothing
there was made stale by this branch, but the page is the reference for what a
sandboxed body gets from `ctx.api`, and this branch gives it something new: a
rejected call now carries `code` and `fields` alongside `name`/`message`.

Adds a short "Errors from ctx.api" section under nested cross-object writes,
covering the two properties, the worked `VALIDATION_FAILED` case, the fact that
the passthrough is a deliberate ALLOWLIST (body code can read anything on a
rejection, so host errors must not arrive with driver state or record payloads
attached), and that an escaping or re-thrown error keeps the payload on the way
back out to the action's HTTP response.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeZ7DmSKjiKmQzYh8L5CJS
@os-zhuang
os-zhuang merged commit 1d5dc46 into main Jul 29, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/dispatcher-validation-error-fields-xo6zx9 branch July 29, 2026 10:57
os-zhuang pushed a commit that referenced this pull request Jul 29, 2026
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 added a commit that referenced this pull request Jul 29, 2026
…d 404 an action that never dispatched (#3913) (#3930)

Object-less actions register 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.

The not-found exit also called deps.success(), so "no such action" went out as
HTTP 200 {success:true, data:{success:false, error}} and read as a success to
any caller that skipped the inner envelope. Nothing DISPATCHED there, so it is
a 404 now, joining the pre-dispatch answers this route already gives a status
(403 denied, 400 wrong action type, 503 unavailable), and it names the routed
object rather than whichever probe ran last.

A handler that RAN and rejected is unchanged: HTTP 200 with
{success:false, error, code?, fields?} — a business outcome, not a transport
error, per #3937. The line is "did a handler run".

client.actions.invoke/invokeGlobal still do not throw; the added catch is
load-bearing since client.fetch throws on every non-2xx.
os-zhuang added a commit that referenced this pull request Jul 29, 2026
…ccess:false (#3913 follow-up) (#3951)

#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 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.

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. Rejection and 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.
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

Development

Successfully merging this pull request may close these issues.

2 participants