Uh oh!
There was an error while loading. Please reload this page.
fix(rest): the by-id /data door stops shipping the QuickJS wrapper out of a declared-code arm - #15065
Conversation
…t of a declared-code arm `classifyDataError` surfaces the bespoke structured arms above the sandbox unwrap door on purpose, so the structured fields survive the generic catch-alls. Every arm built its sentence from `error?.message`, which for a sandboxed producer is the `<kind> '<name>' threw: <msg>` debug wrapper, and the unwrap door that would have read `.innerMessage` sits below them and was never reached. One hook refusal therefore came back as two different sentences depending on which route caught it. The arms now ask the two-read rule the unwrap door and the declared-status passthrough already share, named once as `armSentence` rather than re-opined per arm. `resolveErrorResponse` declines the shared consult for a sandbox-origin error, so the bulk / metadata / UI doors answer byte for byte what they answered before and the repair lands on `mapDataError` alone. A sandboxed CRASH carrying a declared code is deliberately unchanged: `sandboxBusinessMessage` declines a crash, so the fallback hands the arm the wrapper exactly as today. The `DUPLICATE_RECORD` arm is untouched — it is gated on the engine's envelope class and `SandboxError` sets `name` unconditionally, so no sandboxed producer can reach it. The parity file's sandboxed-producer case is FLIPPED from ACCEPTED DIVERGENCE to CONVERGED rather than deleted; it is the only thing that would notice the divergence returning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
📓 Docs Drift Check3 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 — 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 46363c297da1bdd1e91c69979afdf2e69929d28d && git checkout 46363c297da1bdd1e91c69979afdf2e69929d28d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 101ad2cc13fafd8a3879ffc4a6ce6133c02ed832 7536bfcaf61d084e7f4f28a65ddd5826ebb29b80 && git checkout -B drift-repro 101ad2cc13fafd8a3879ffc4a6ce6133c02ed832 && git merge --no-ff 7536bfcaf61d084e7f4f28a65ddd5826ebb29b80
node scripts/docs-audit/affected-docs.mjs --json 101ad2cc13fafd8a3879ffc4a6ce6133c02ed832 |
Uh oh!
There was an error while loading. Please reload this page.
…arity Brings in PR #15062 (`os i18n check --help`), which lands in packages/cli while this branch is open — the dispatch named it as the one to merge and re-verify against. Also #15065 (rest), #15063/#15067 (docs) and #15055 (spec). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Fixes#14704
classifyDataError's declared-code structured arms shipped the QuickJS debug wrapper as the caller-facingerrorsentence on the single-record/datadoor — #11588's defect, one door over, with the direction reversed rather than closed.What was measured, on
origin/main@99b4deba49(re-measured after #14713 landed)A
SandboxErrorcarries the caller-addressed sentence on.innerMessageand aKIND 'NAME' threw: MSGdebug wrapper on.message(runtime/src/sandbox/quickjs-runner.ts). The bespoke arms are surfaced above the sandbox unwrap door inclassifyDataErroron purpose — "so the structured fields survive the generic catch-alls" — and every one of them built its sentence fromerror?.message. The unwrap door that would have read.innerMessagesits below them and was never reached. One hook refusal, two sentences, decided by which route caught it:sendThrownError/handleRouteError(bulk, metadata, UI)409—{"error":"Opportunity is closed.","code":"DELETE_RESTRICTED"}mapDataError(single-record/data)409—{"error":"hook 'guard' threw: Error: Opportunity is closed.","code":"DELETE_RESTRICTED","dependentObject":"contact","object":"account"}Located by symbol, not by line: #14713 lifted the arms into the shared
structuredCodeAnswerboth doors ask, so the defect now lives in one function instead of ten inline sites.The change
The arms ask the two-read rule the unwrap door and the declared-status passthrough already share, named once as
armSentence(error) = sandboxBusinessMessage(error) ?? error?.messagerather than re-opined per arm — a third local opinion at this boundary is how the doors came to disagree in the first place.Ten arms converge, nine in
structuredCodeAnswerandPERMISSION_DENIEDjust below the consult:DELETE_RESTRICTED,CONCURRENT_UPDATE,ERR_DATASOURCE_UNAVAILABLE,VALIDATION_FAILED,FEEDS_DISABLED,FILES_DISABLED,ATTACHMENT_PARENT_ACCESS,ATTACHMENT_DELETE_DENIED,RECORD_NOT_ACCESSIBLE,PERMISSION_DENIED.The bulk door does not move, by construction.
resolveErrorResponsedeclines the shared consult outright for a sandbox-origin error (#14541'sisSandboxOriginguard), so this read is unreachable from it. Pinned as a control rather than asserted (§5 of the new file).OBJECT_NOT_FOUNDandINVALID_FIELDare untouched, as triage ruled — both already produce the right answer, the first because it ships a fixed sentence and the second because #14541 fenced it with an explicit!isSandboxOriginclause.Red-first evidence
New pins run against the unmodified source, with the implementation held out of the tree (saved as a patch, source restored with
git checkout HEAD -- ..., restore proven by blob-hash equality against the HEAD blob and an emptygit diff HEAD, not by an editor's exit code):The implementation was then re-applied and proven byte-identical (
git hash-object=9e0787b8b80cda93c2271f7280b2022d38131271, the hash recorded before the restore) before the green run.Which sections stayed green in the red run is the control: §2 (structured fields), §3 (the non-sandbox producer, byte-identical), §4 (the crash divergence) and §5 (the bulk door) all passed against unmodified source — they measure things that were already true, so the 13 reds are attributable to the arms and nothing else.
The §4 pin is FLIPPED, not deleted
error-response-structured-arm-door-parity.test.ts§4 recorded this defect as a named accepted divergence citing #14704. Closing the divergence makes that entry converged, so the case keeps its identity and changes its verdict, with the reason quoted beside it. Deleting it would remove the only thing that would notice the divergence returning.It is labelled
CONVERGED (sentence, #14704)rather than plainCONVERGED, because the bodies are still unequal in one respect and a case claiming convergence has to say where: the arm's structured fields (dependentObject) ride the single door and not the bulk one, which is #14541'sisSandboxOriginexclusion, not this card's. Both halves are now asserted in that case.⛔ Not decided here: the sandboxed CRASH
Triage fenced this out explicitly, and the fence is respected mechanically rather than by intention:
sandboxBusinessMessagedeclines a crash by contract (#7543), so the fallback hands the armerror.messageand the answer is byte-identical to today.The site:
structuredCodeAnswer(and thePERMISSION_DENIEDarm below the consult), reached frommapDataError.The divergence: an arm answers a sandboxed crash carrying a declared code with that arm's status and the wrapper prose —
409 {"error":"hook 'guard' threw: TypeError: x is not a function","code":"DELETE_RESTRICTED"}— whereclassifyDataError's unwrap door answers the same crash with the sanitised500fault terminal. Choosing between those is fault classification, not message sourcing.It is pinned as an ACCEPTED DIVERGENCE in §4 of the new file, with a positive control (the same crash with no declared code does reach the sanitised 500), so deciding it later is a visible edit rather than a drift.
⛔ Not taken: converging the
DUPLICATE_RECORDgate — escalated insteadTriage asked for
DUPLICATE_RECORDto converge on the shared rule "rather than keeping its localname === 'DuplicateRecordError'guard". Re-measured on today'smain, that instruction's premise does not hold, and the two readings lead to different contracts, so it is reported rather than guessed:SandboxErrorsetsthis.name = 'SandboxError'unconditionally (quickjs-runner.ts), so no sandboxed producer can reach this arm at all. Applying the two-read rule inside it while keeping the gate would be a check that evaluates never — a phantom, which AGENTS.md names as a defect in its own right.error.message; this arm replaces it with a curated sentence, re-spells the wire code asUNIQUE_VIOLATION, and moves the producer's sentence intodeveloperMessage. Dropping the gate therefore changes the wire for two producer populations — a sandbox body throwing the registered code, and a plain plugin speaking it — and both are pinned today byrest-duplicate-record-arm.test.ts§5, contract-reviewed on 2026-09-02.That is a decision, not a repair, and it is stated in the report's
open_questionswith the four-axis analysis. The arm is left exactly as it is, and the reason is recorded in the new file's §6 allowlist so the omission is disclosed rather than silent.Verification
All local commands run through
scripts/pm/os-verify-lock.sh; exit codes captured before any pipe; verdicts read from each tool's own verdict line. Everything below is at7536bfcaf6, the final HEAD, which mergesorigin/main(0fb944b4f6).pnpm --filter '@objectstack/rest^...' buildVERDICT command-exit 0pnpm --filter @objectstack/spec check:generated✓ All 15 generated artifacts are up to date.Test Files 2 passed (2) · Tests 85 passed (85)pnpm --filter @objectstack/rest testTest Files 176 passed (176) · Tests 2986 passed (2986)turbo run build --filter='./packages/*' --filter='./packages/*/*'Tasks: 71 successful, 71 totalpnpm --filter @objectstack/rest typechecktsc --noEmit+check:test-typecheck: OK — 0 file(s) / 0 error(s)The
typecheckscript namestsconfig.test.json, andtsc -p tsconfig.test.json --listFileswas checked to contain both edited test files — so "typecheck is clean" is a statement about the new pins and not only aboutsrc/.Gate union, derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackat7536bfcaf6(not from a hand-written path list), harvested with--commandsso neither spelling nor section can be dropped: 41 families. 39 exit 0.check:dual-build-cjs-loadsandcheck:type-check-debtfirst returned exit 3 on an unbuilt worktree; the full workspace build above is their prerequisite, and both were re-run on the built closure and are counted green (check-type-check-coverage --re-measure: OK — 17 ledger entries re-measured, none above its recorded number).Two remain exit 3 · PREREQUISITE NOT MET, reported as NOT MEASURED — ⛔ neither a pass nor a red, and neither reachable from this container:
scripts/check-test-completeness.mjs— parses a vitest run summary; this invocation supplies none, so it exited before reading a single line.scripts/pm/check-half-states.mjs— needs the GitHub API; this session's gate refuses repo-scoped REST withGitHub access is not enabled for this session. (Its siblingpnpm check:pm-half-statesis a different invocation and exits 0.)Changeset
@objectstack/restis public ("files": ["dist", "README.md", "CHANGELOG.md"],version 17.2.0, not private) anderror-response.tscompiles intodist/index.js, so the wire sentence change is user-visible → patch changeset, with the per-code before/after and the three deliberate non-changes. Not declared-breaking: nothing an author can write is removed or renamed, so no ADR-0087 marker is owed (check:adr-0087-registrationexits 0).Scope
Only
packages/rest/src/error-response.ts, its two pin files, and the changeset. ⛔packages/specuntouched. ⛔ Nocontent/docs/releases/edit. ⛔ #14541's ordering fix untouched. Draft, and the merge decision is the PM's.🤖 Generated with Claude Code
https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Generated by Claude Code