Skip to content

fix(rest): the by-id /data door stops shipping the QuickJS wrapper out of a declared-code arm - #15065

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14704-single-door-sandbox-wrapper
Sep 3, 2026
Merged

fix(rest): the by-id /data door stops shipping the QuickJS wrapper out of a declared-code arm#15065
os-trump merged 2 commits into
mainfrom
claude/issue-14704-single-door-sandbox-wrapper

Conversation

@os-trump

@os-trump os-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14704

classifyDataError's declared-code structured arms shipped the QuickJS debug wrapper as the caller-facing error sentence on the single-record /data door — #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 SandboxError carries the caller-addressed sentence on .innerMessage and a KIND 'NAME' threw: MSG debug wrapper on .message (runtime/src/sandbox/quickjs-runner.ts). The bespoke arms are surfaced above the sandbox unwrap door in classifyDataError on purpose — "so the structured fields survive the generic catch-alls" — and every one of them built its sentence from error?.message. The unwrap door that would have read .innerMessage sits below them and was never reached. One hook refusal, two sentences, decided by which route caught it:

door answer
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 structuredCodeAnswer both 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?.message rather 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 structuredCodeAnswer and PERMISSION_DENIED just 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. resolveErrorResponse declines the shared consult outright for a sandbox-origin error (#14541's isSandboxOrigin guard), so this read is unreachable from it. Pinned as a control rather than asserted (§5 of the new file).

OBJECT_NOT_FOUND and INVALID_FIELD are 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 !isSandboxOrigin clause.

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 empty git diff HEAD, not by an editor's exit code):

Test Files  2 failed (2)
     Tests  13 failed | 72 passed (85)

× DELETE_RESTRICTED answers 409 DELETE_RESTRICTED with the business sentence
× CONCURRENT_UPDATE answers 409 CONCURRENT_UPDATE with the business sentence
× ERR_DATASOURCE_UNAVAILABLE answers 503 ERR_DATASOURCE_UNAVAILABLE with the business sentence
× VALIDATION_FAILED answers 400 VALIDATION_FAILED with the business sentence
× FEEDS_DISABLED / FILES_DISABLED answers 403 … with the business sentence
× ATTACHMENT_PARENT_ACCESS / ATTACHMENT_DELETE_DENIED answers 403 … with the business sentence
× RECORD_NOT_ACCESSIBLE answers 403 RECORD_NOT_ACCESSIBLE with the business sentence
× PERMISSION_DENIED answers 403 PERMISSION_DENIED with the business sentence
× every arm relaying a producer sentence reads `sandboxBusinessMessage` first
× the `PERMISSION_DENIED` arm below the consult reads the shared rule too
× CONVERGED (sentence, #14704): a sandboxed producer — both doors ship the business sentence

AssertionError: expected 'hook \'guard\' threw: Error: Opportun…' to be 'Opportunity is closed.'
Expected: "Opportunity is closed."
Received: "hook 'guard' threw: Error: Opportunity is closed."

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 plain CONVERGED, 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's isSandboxOrigin exclusion, 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: sandboxBusinessMessage declines a crash by contract (#7543), so the fallback hands the arm error.message and the answer is byte-identical to today.

The site: structuredCodeAnswer (and the PERMISSION_DENIED arm below the consult), reached from mapDataError.
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"} — where classifyDataError's unwrap door answers the same crash with the sanitised 500 fault 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_RECORD gate — escalated instead

Triage asked for DUPLICATE_RECORD to converge on the shared rule "rather than keeping its local name === 'DuplicateRecordError' guard". Re-measured on today's main, that instruction's premise does not hold, and the two readings lead to different contracts, so it is reported rather than guessed:

  • SandboxError sets this.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.
  • Dropping the gate is not the sibling arms' repair. They relay error.message; this arm replaces it with a curated sentence, re-spells the wire code as UNIQUE_VIOLATION, and moves the producer's sentence into developerMessage. 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 by rest-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_questions with 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 at 7536bfcaf6, the final HEAD, which merges origin/main (0fb944b4f6).

run result
pnpm --filter '@objectstack/rest^...' build VERDICT command-exit 0
pnpm --filter @objectstack/spec check:generated ✓ All 15 generated artifacts are up to date.
targeted: the two pin files Test Files 2 passed (2) · Tests 85 passed (85)
pnpm --filter @objectstack/rest test Test Files 176 passed (176) · Tests 2986 passed (2986)
turbo run build --filter='./packages/*' --filter='./packages/*/*' Tasks: 71 successful, 71 total
pnpm --filter @objectstack/rest typecheck tsc --noEmit + check:test-typecheck: OK — 0 file(s) / 0 error(s)

The typecheck script names tsconfig.test.json, and tsc -p tsconfig.test.json --listFiles was checked to contain both edited test files — so "typecheck is clean" is a statement about the new pins and not only about src/.

Gate union, derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at 7536bfcaf6 (not from a hand-written path list), harvested with --commands so neither spelling nor section can be dropped: 41 families. 39 exit 0.

check:dual-build-cjs-loads and check:type-check-debt first 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 with GitHub access is not enabled for this session. (Its sibling pnpm check:pm-half-states is a different invocation and exits 0.)

Changeset

@objectstack/rest is public ("files": ["dist", "README.md", "CHANGELOG.md"], version 17.2.0, not private) and error-response.ts compiles into dist/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-registration exits 0).

Scope

Only packages/rest/src/error-response.ts, its two pin files, and the changeset. ⛔ packages/spec untouched. ⛔ No content/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

…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
@github-actions github-actions Bot added the size/l label Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 101ad2cc13fafd8a3879ffc4a6ce6133c02ed832packageMentionDocs.

Which tree this was computed on

This run read content/docs from 46363c297da1bdd1e91c69979afdf2e69929d28d — the merge of head 7536bfcaf61d084e7f4f28a65ddd5826ebb29b80 into base 101ad2cc13fafd8a3879ffc4a6ce6133c02ed832, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@os-trump
os-trump marked this pull request as ready for review September 3, 2026 20:00
@os-trump
os-trump enabled auto-merge September 3, 2026 20:00
@os-trump
os-trump added this pull request to the merge queue Sep 3, 2026
Merged via the queue into main with commit 1c7adc7 Sep 3, 2026
35 checks passed
@os-trump
os-trump deleted the claude/issue-14704-single-door-sandbox-wrapper branch September 3, 2026 20:41
os-trump pushed a commit that referenced this pull request Sep 3, 2026
…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
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

2 participants