fix(runtime): a nested sandboxed hook refusal is a rejection, not a sandbox fault - #17679
Conversation
…andbox fault A `beforeUpdate` hook that refuses a state transition for a business reason, reached through a script action's `ctx.api` write, answered `500 INTERNAL_ERROR` on `POST /actions/:object/:action` — the same refusal `/data` has answered `400` with the sentence verbatim since #11588. `domains/actions.ts` is not the producer. `hostErrorToVm` marked EVERY `SandboxError` crossing into the action body's VM as the sandbox's own fault (#4431) on an `instanceof` test — and a nested sandboxed hook's refusal is a `SandboxError`, wrapped by this same runner one level down. The pump branch that reads the marker then dropped `innerMessage`, `code`, `status` and `fields`, and the classifier correctly read that absence as a crash. The marker now asks the question `/data` asks — `sandboxBusinessMessage` (#11588), spelled in-package as `sandboxRefusalMessage` because `@objectstack/rest` re-exports nothing from `error-response` and importing it would widen that package's published surface. Both of its conditions travel: a capability denial has no business message and stays a fault, and a nested CRASH carries `TypeError: …` and stays a fault too, so neither side of the pinned fault/rejection line moves. Message-neutral by construction: the client-facing sentence is byte-identical to what the 500 already carried, because the flattened `SandboxError: ` name prefix is stripped on the rejection path by the same helper the fault path already used. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8a2d56cc17c9a017c36a0e2e71e2d86593dafdf3 && git checkout 8a2d56cc17c9a017c36a0e2e71e2d86593dafdf3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0fb6f976a07152413428063f389906d870296534 83ea3ce7c53edd3416fb72a54636f30f98c0d043 && git checkout -B drift-repro 0fb6f976a07152413428063f389906d870296534 && git merge --no-ff 83ea3ce7c53edd3416fb72a54636f30f98c0d043
node scripts/docs-audit/affected-docs.mjs --json 0fb6f976a07152413428063f389906d870296534 |
|
ACCEPT — Arming waits on every check, not only the required ones. Everything else is home and green; that job started ~11:05Z and runs ~27 min on this repo. The diagnosis is the strongest thing in this PRPremise 0 was falsified in the card's favour, with a failing test before a line of repair — the dispatch order asked which of three readings was true and the answer is (c). ⭐ And the reading that makes it a real finding rather than a patch:
⇒ the hazard was written down, tested, and the fixture could not reach it. That is the best-shaped thing a dev can bring back, and "the classifier is not wrong — it is being lied to" is the right summary of why no door was edited. Both narrowing controls are present and are the two ways this could have gone wrong: a nested crash still answers 500, and the #4431 in-VM capability denial still answers 500. The ablation predicted RED in advance, proved the mutation on disk before reading the result, and restored by an empty The file-surface correction is accepted, and independently checkedThe dispatch declared Verified rather than assumed, because a surface move changes the serialisation picture: the single-writer-path gate is
|
Fixes #17265
Defect ① only — the status/code. Defect ② (no toast) is
repo:objectui's and is not touched here; triage's question about it is answered below.Premise 0 — falsified. It DOES reproduce on
main, and the producer is outside the catch blockThe dispatch order asked which of three things is true before a line was written. The answer is (c): the 500 comes from a producer outside
domains/actions.tsentirely. Measured onorigin/main@7880c184(the PM's reading was taken at89559950;mainhad moved, so this branch is based on7880c184), with a test written before the repair:The classifier in
domains/actions.tsis not wrong — it is being lied to. Its three exits read the shape they are handed correctly, and neither of the pinned describes inactions-fault-vs-rejection.test.tsmoves. What arrives at it has already had every mark that says "a body reported this on purpose" stripped, one VM hop earlier:beforeUpdatehook refuses — its own runner wraps that as aSandboxErrorwhoseinnerMessageis SET;engine.update()into the ACTION body's host call,hostErrorToVmmarshals it INTO the action's VM — and marked everySandboxErrorreaching it as the sandbox's OWN fault (actions: sandbox capability denial answers 400 as a deliberate rejection, not the 500 the crash contract promises #4431), on aninstanceoftest;SandboxError— noinnerMessage, nocode, nostatus, nofields;errorFromThrown(err, 500)⇒500 INTERNAL_ERROR.The marker's own sibling pin already named this risk in writing — "a marker applied too broadly would turn every failed write into a 500" (
capability-denial-is-a-fault.test.ts) — and measured it with a plainValidationError, which is not aSandboxErrorand so never tripped theinstanceof. A nested sandboxed refusal is.Which repair this copies:
/data's, by the/analyticspatternBoth, and they are two halves of one thing:
/analytics(rest: an UNDECLARED hook refusal answers 500 on/analytics/dataset/querywhere the same refusal answers 400 on/data— the route's fallback arm treats a business refusal as a server fault #11684), whose repair was "read the shared predicate instead of open-coding it" —sandboxBusinessMessage, whose own docblock says it exists so "the analytics face and the/dataface cannot drift into two answers for one refusal". The sandbox runner's fault marker was a fourth local opinion on exactly that question, spelled as a type test;/data(error-response.ts's sandbox unwrap door):const status = declared ?? 400, bodyerror: error.innerMessage,codeonly where the producer declared one. No status was picked here — the card excluded that and this PR honours it.Both of the predicate's conditions travel, because both are load-bearing at this seam: a capability denial carries no business message and stays a fault, and a nested body that crashed carries a native error name and stays a fault too. Dropping the second half would have turned a nested crash into a 400 and moved the
an unexpected FAULT is a 500line, which is a finding to report and not a line to edit.Correction to the declared file surface
The dispatch order declared
packages/runtime/src/domains/actions.ts+ sibling tests. My measurement moves the landing site one directory over, topackages/runtime/src/sandbox/quickjs-runner.ts—domains/actions.tsis unchanged, because it was never the producer. Reported rather than taken silently. Everything declared OUT stayed out:domains/packages.ts(held by PR #17668),packages/rest/src/index.ts,packages/client/src/index.ts,repo:objectui.The order's other correction holds as read:
rest-route-ledger.tscalls the/actions/...route the dispatcher's HTTP surface, andpackages/restis indeed not the landing site.Clause-②staysno— the in-package route was takenClause-②: no — this PR puts no new key on a published payload and widens no accept set.
Added in the machine-readable spelling by the PM seat: the heading above was a near miss, not a declaration — a
#heading is the one prefix this gate does not read — which is whyCheck Changesetrefused rather than ticked. The gate is explicit that this is a refusal and not a failure of the work: “Exiting 0 published the same conclusion for a reading that passed and a reading that never happened.”Verified on the diff before declaring it, ⛔ not asserted to satisfy the gate: zero added
exportlines (positive control — 85 added lines exist and the file carries 4exportlines, so the pattern matches), andSANDBOX_ERROR_PASSTHROUGHappears 0 times in the diff. ⇒ thepatchgrade stands and is correct, and per the gate ⛔ the remedy is the declaration, never regrading the package to quiet the message.@objectstack/restdeclares exactly one export subpath and re-exports nothing fromerror-response, so importingsandboxBusinessMessagewould have widened rest's published surface. The predicate is therefore spelled in-package assandboxRefusalMessage, with its docblock naming rest's original as the authority it copies and@objectstack/objectql'sisScriptCrashas the standing precedent for a local copy. No package's export surface moves; nothing was added toSANDBOX_ERROR_PASSTHROUGH(widening that marshalling allowlist is a declared decision perscript-runner.ts, and this repair does not need it).Message-neutral by construction. The sentence a caller receives is byte-identical to the one the 500 already carried, pinned as such. The flattened
SandboxError:name prefix is stripped on the rejection path by the same helper the fault path already used, which is why that helper is now named for the operation rather than for one of its two callers.Evidence
New pins —
packages/runtime/src/sandbox/nested-hook-refusal-is-a-rejection.test.ts, 5 tests. Both discriminating fields plus the sentence, never "it returned an error":code400VALIDATION_ERROR409409RECORD_LOCKED500INTERNAL_ERROR500Suites run (
pnpm --filter @objectstack/runtime, heavy runs throughscripts/pm/os-verify-lock.sh):capability-denial-is-a-fault.test.ts+actions-fault-vs-rejection.test.ts+actions-validation-envelope.test.ts—VERDICT command-exit 0, 30 passed (30);typecheck—TYPECHECK_EXIT=0(check:test-typecheck: OK, ledger unchanged);test(whole package) —TEST_EXIT=0, 255 files / 3579 tests passed.Ablation, with on-disk mutation proof taken before the result was read. Predicted direction fixed in advance: RED.
The mutation reverts only the marker narrowing. No build hop is involved: the test imports the mutated module by relative path inside its own package, so vitest resolves
src, notdist. Restore is proven by an emptygit diff HEAD, not by an exit code; the mutation script carried atrapwith absolute paths.Gate roster —
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived after the changeset existed (58 commands, up from 51). Every command run with its exit captured before any pipe. 56 pass, 0 failures, 2 NOT MEASURED:pnpm check:dual-build-cjs-loads— exit 3,PREREQUISITE NOT MET: reads built output, 38 packages have nodist/. Needs a whole-repopnpm build; that is CI's run.pnpm check:type-check-debt— exit 3, same class, its own text: "This is NOT a pass and NOT a finding: nothing was measured."Neither is a pass and neither is a failure. Also outside that total, by the tool's own accounting and left to CI: 47 artifact-roster families, 11 wide-population families, 5 workflow-valued families and 5 path-scheduled CI jobs.
pnpm lint— the FULL union, not narrowed.dispatch-gates.mjsdoes not name it, so this lane adds it:eslint . --no-inline-config,LINT_EXIT=0, clean, at83ea3ce7(the final commit).Triage's console question: does ① alone reach the user?
Probably not, and here is the discriminating fact rather than a guess: this repair moves the status and the code, and leaves the message text byte-identical. So nothing new became available for the console to render — only
statusandcodechanged. ① therefore suffices if and only if the console's script-action error path branches on status or code; if it renders from text it already had, or renders only theuserMessagechannel (the producer-side opt-in of the objectui#5210 ruling, which the card's hook does not appear to set), the user is still told nothing.Not measured, and it cannot be from here: this container has no
../objectuicheckout and no builtpackages/console/dist, so the console's rendering was not read. A UI card therefore still looks owed — reporting it for the PM to file, per the hard stop that cross-repo filing is not this seat's.Triage's sweep, and the card's own question
The card asked whether this is a per-route fallback arm that must be taught three times or whether one shared place can answer it once. For this shape: one shared place, and it is the producer, not the doors. Repairing the mark at the sandbox boundary reaches every door that reads it —
/data,/analytics, the record-sharing door and this one — which is why no door was edited.What the sweep found still answering an undeclared sandbox refusal as 500: the doors that hand a throw to
resolveThrownHttpErrorwith a 500 fallback and make no sandbox pre-classification at all. That resolver's precedence (packages/types/src/thrown-http-error.ts) is.status, then.statusCode, then the validation shape, then the caller's fallback — it never consultsinnerMessageorname. The one such call site nameable with a path ispackages/runtime/src/endpoint-executor.ts:602,endpointErrorAnswer(err)at its default 500, the custom-API-endpoint door, whoseexecuteFlowplan can carry a flow script node's sandboxed refusal.domains/mcp.tswas checked and ruled out: itserrorFromThrown(err, 500)is the transport-level catch aroundhandleHttpRequest, not a per-action error exit. One targeted duplicate search found no open card for the endpoint door.Acceptance notes
Noted, not filed — neither is a defect, and each names who would meet it:
packages/rest'sisScriptFaultMessage,packages/objectql'sisScriptCrash, and this one), each with a written reason for being a copy. The consolidation would be one reader in@objectstack/types, which bothrestandruntimealready import for exactly this class of cross-door rule — but that widens a published surface and is a declared decision, not a rider. Whoever meets it: the next card that adds a fourth boundary asking this question.hook NAME threw:wrapper, where/dataanswers the bare sentence — unchanged by this PR and deliberately so, since carrying the nested business message across the VM means wideningSANDBOX_ERROR_PASSTHROUGH, whose own docblock makes widening a declared decision. Whoever meets it: whoever takes the toast card, since this is the text a toast would show.Generated by Claude Code