Uh oh!
There was an error while loading. Please reload this page.
fix(rest): serve a sandboxed hook's own refusal sentence on the bulk write and analytics routes, not the QuickJS debug wrapper - #11687
Conversation
…utes, not the QuickJS debug wrapper The declared-status passthrough in `resolveErrorResponse` answered a 4xx from `error.message`, which for a sandboxed hook refusal is the `hook '<name>' threw: Error: <msg>` debug wrapper. Every route reporting through `handleRouteError` — batch, createMany, updateMany, deleteMany, clone — shipped that to the end user, while the single-row `PATCH` on the same object answered with the business message alone. The arm now reads the business text via `sandboxBusinessMessage`, the unwrap door's own two conditions named once. The passthrough keeps deciding the STATUS, so #5437/#5582's 5xx prose withhold does not move: only the sentence the 4xx arm reads for the caller changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
The `userMessage` case was drafted as a §5 positive control and predicted green; it measured RED pre-fix because its fixture is a sandboxed refusal, so its `error` assertion reads the defect like §1 does. Moved to §6 and the claim corrected — the prediction is recorded as wrong rather than re-fitted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
…ndbox-wrapper-bulk-routes
The route builds its `{ code, message }` envelope by hand and read
`error.message` directly, sharing no branch with either door in
`error-response.ts`. It now reads the exported `sandboxBusinessMessage`, so the
analytics face and the /data face cannot answer one refusal two ways.
Scoped to what the client reads: `logError` still receives the whole error and
`looksLikeInternalErrorLeak` still reads the raw text. Neither arm's status
moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR…he analytics half Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
The hand-built request literal is not assignable to IHttpRequest, which added one raw tsc error to @objectstack/rest's TEST_DEBT entry (155 -> 156). Typed `any` like the sibling route helpers in this file, so the ledger is back at its recorded 155. Fixed rather than raising the entry -- the ratchet is shrink-only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 13 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 23953a79d324a08e55dbd65002868979868755ff && git checkout 23953a79d324a08e55dbd65002868979868755ff
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 88b9d749a1eb76b53966b0081006d2c5bccb341d f93aa39e37bf9b38f24ce978b31104fffdd97200 && git checkout -B drift-repro 88b9d749a1eb76b53966b0081006d2c5bccb341d && git merge --no-ff f93aa39e37bf9b38f24ce978b31104fffdd97200
node scripts/docs-audit/affected-docs.mjs --json 88b9d749a1eb76b53966b0081006d2c5bccb341d |
os-zhuang
commented
Aug 24, 2026
ACCEPT — PM seat review ( |
os-zhuang
commented
Aug 24, 2026
Armed at 12:21Z. CI at The one thing your report left unstated — checked, and |
Fixes#11588
What the client used to get
A hook's
throw new Error('删除被阻断…')is a deliberate business rule, andclassifyDataError's sandbox unwrap door exists precisely so the end user sees only that sentence — the<kind> '<name>' threw: <msg>prefix "belongs in server logs", in the door's own words. Seven routes never reached the door. Reproduced against the real route handlers in process, onmain:PATCH /api/v1/data/:obj/:idOpportunity is closed.POST …/batch·…/createMany·…/updateMany·…/deleteMany·…/:id/clonehook 'guard' threw: Error: Opportunity is closed.Opportunity is closed.POST /api/v1/analytics/dataset/queryhook 'guard' threw: Error: lockedlocked⛔ The fork — measured, and the answer is NOT a reorder
The card asked whether moving the unwrap above
resolveErrorResponse's declared-status passthrough is the repair, and flagged that passthrough as load-bearing for #5437/#5582. I read the docblock first and did not reorder anything. Its own argument rules it out, verbatim:So the passthrough stays exactly where it is and keeps deciding the status. Only the sentence its 4xx arm reads for the caller changes. The 5xx arm is untouched: a sandbox refusal declaring a 5xx still answers
INTERNAL_ERROR_MESSAGE, pinned on both spellings.What this restores is an invariant the same docblock already asserts. Its #7525 paragraph says an error declaring
statusCodeinstead falls tomapDataError, "So the two doors already agree on the wire answer." For a sandbox refusal that sentence was false —statusCodefell through and was unwrapped,statuswas answered from the wrapper — which is exactly the two-spellings asymmetry the card named as the real defect. The doors agree again, pinned door-to-door across the whole 4xx band (§4) rather than restated in a comment.That also settles the PM's option (2): normalising the spellings at the gate would have widened the passthrough (routing
statusCodeerrors into an arm that adds noobjectand truncates differently), and #7525 argues thestatus-only read there is deliberate. Fixing the message closes the asymmetry the card named without touching that gate.The change
packages/rest/src/error-response.ts— newsandboxBusinessMessage(error): the unwrap door's own two conditions in the door's own order (a non-empty string.innerMessage; not anisScriptFaultMessagecrash), named once. The 4xx passthrough arm reads it. It is a read of a field the sandbox populated, never a pattern-strip of.message— see the controls below.packages/rest/src/rest-server.ts—/analytics/dataset/querybuilds its{ code, message }envelope by hand and touches neither door, so it needed the same read at its own boundary. It imports the predicate rather than re-deriving it. Both of its client emissions are covered. Deliberately unchanged:logErrorstill receives the whole error, andlooksLikeInternalErrorLeakstill reads the raw text (feeding it the unwrapped text could only make it withhold less).POST …/importandGET …/exportexit throughhandleRouteErrorlike the bulk routes, so they are repaired by the same change — measured, not assumed. The card said these were never exercised; they share the branch.Anti-vacuity
Predictions recorded before running, in the test file header, left as written.
Tests 12 failed | 11 passed (23). The miss is recorded rather than fitted: "a declareduserMessagestill rides" was drafted as a §5 positive control and predicted green, but its fixture is a sandboxed refusal, so itserrorassertion reads the defect like §1 does. It is not a control and never was. Moved to §6 and the claim corrected; the prediction stands as written.Tests 14 failed | 13 passed (27)for the full file against the merged base.1/1 → 0/0, removed0/0 → 1/2), never an editor's exit code. Carriedtrap … EXIT INT TERM, and the restore was verified by re-reading both files from the repository root plus an emptygit status --porcelain— not by trusting the trap.dist/sits between the mutation and the assertion.⭐ Positive controls (§5, §8d — green on both sides, and the reason the fix is a read):
"hook 'guard' threw: Error: locked"is NOT rewritten — no.innerMessage, so nothing about it came from the sandbox;innerMessageofundefined/null/''/42/{}all fall back to.message.A fix that stripped the wrapper by pattern would satisfy every other assertion in the file and redden exactly here.
Also pinned green both sides: the declared-5xx withhold (both spellings), #5423 truncation at 500 chars, the
'Request failed'degrade, the empty-string message served as itself,OBJECT_NOT_FOUNDstill bypassing the passthrough, and the dogfood-pinned undeclared-status 400.Measured and deliberately NOT repaired
Filed as their own issues rather than widened into this PR:
message.startsWith(CODE)and interpolateerror.messageinto a hand-built 500 — a declaredstatus/codeis ignored and the sandbox wrapper reaches the client #11683 — the record-share routes are a third branch again:respondSharingErrorclassifies bymessage.startsWith(CODE)and its fallback interpolateserror.messageinto a hand-built500, ignoring a declaredstatus/codeentirely./analytics/dataset/querywhere the same refusal answers 400 on/data— the route's fallback arm treats a business refusal as a server fault #11684 — on the analytics route an undeclared hook refusal answers500where/dataanswers400. Only the sentence was corrected; the status disagreement is a contract call. Pinned as measured in §8b so it is not rediscovered as new.statusstill answers that status and the wrapper, wheremapDataErrorsanitises it to a500.sandboxBusinessMessagedeclines a script fault (A rawTypeError: not a functionleaks in the 400 envelope for{"title": 12345}— nocode, nofields[]#7543), so this arm's answer is byte-identical to before. Closing it means moving the status this arm decided — the contract question this card was fenced from. Pinned as measured in §7.Clause-②— measured, not inheritedNo. The message text on a served error envelope is user-visible, and that is why the changeset argues its level explicitly. But the accept set does not move: no request is newly accepted or newly refused, the status is unchanged on every arm, the
codeis unchanged, no field is added or removed, and the public TypeScript surface gains only an internal predicate export consumed inside this package.needs:contract-reviewis not applied.Changeset —
patch, argued rather than defaultedNothing about the envelope's contract moves. The delta is that one string loses a debug prefix this boundary already declares must never be on the wire, and that the single-row routes never emitted — so no client could have been reading it uniformly. Keying on it would mean substring-matching prose that is localised and deliberately reworded, the practice ADR-0112's
codevocabulary exists to remove. Full argument in.changeset/bulk-write-refusal-message-parity.md.Verification — all at
f93aa39e, the final commitpnpm --filter @objectstack/rest test→RUN v4.1.10 …/packages/rest,Test Files 143 passed (143),Tests 2301 passed (2301)pnpm --filter @objectstack/rest typecheck→tsc --noEmit, cleanRUN v4.1.10 …/packages/rest,Test Files 1 passed (1),Tests 27 passed (27)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackand re-derived on the final diff (4 paths; the re-derivation addedcheck:authz-resolver, which the first derivation could not name becauserest-server.tswas not yet in the change set). All 17 path-matched families plus the 6 convention-triggered ones run green, each quoted from its own verdict line:check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 294.8s, 1898 raw tsc error(s) total, none above its recorded number.check-nul-bytes: OK (scanned 6523 text file(s) … no raw ASCII control bytes).✓ check:authz-resolver: single shared authorization resolver intact; both entry points delegate.check:route-envelope→✓ Express-style response modules — 4 module(s) discovered and audited …, 2 conformant, 2 ratcheted, 0 exempt, 0 vendor-wire(the two ratcheted arequery-allowlist.ts/query-multiplicity.ts, neither touched here)check:dispatcher-error-vocabulary→door typing (#9098): packages/rest/src/error-response.ts checked for the typed author-side responder …Tasks: 70 successful, 70 totalOne gate went red and was fixed, not waved through.
check:type-check-debtreported@objectstack/rest: TEST_DEBT records 155 raw tsc error(s), tsc --noEmit now reports 156 (+1)— my analytics harness passed a hand-built request literal that is not assignable toIHttpRequest. Repaired at the source (typedany, matching the sibling route helpers in the same file) rather than by raising a shrink-only ratchet; re-measured back to exactly 155.mainwas merged in mid-task after PR #11641 released the hold onrest-server.ts; every reading above was re-run against the merged base rather than carried across it.Generated by Claude Code