Skip to content

fix(rest): a sandboxed crash outranks the declared-code arm and answers the sanitised 500 UNCLASSIFIED_FAULT (#15071) - #17228

Merged
os-project-manager merged 5 commits into
mainfrom
claude/issue-15071-sandbox-crash-outranks-declared-code
Sep 10, 2026
Merged

fix(rest): a sandboxed crash outranks the declared-code arm and answers the sanitised 500 UNCLASSIFIED_FAULT (#15071)#17228
os-project-manager merged 5 commits into
mainfrom
claude/issue-15071-sandbox-crash-outranks-declared-code

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #15071

Clause-②: yes

Executes the maintainer ruling recorded at 5535780469 — 2026-09-04, decision
batch #27, verbatim 「同意」 on option B: a sandboxed crash outranks the
declared-code arm and reaches the unwrap door's sanitised 500 UNCLASSIFIED_FAULT whatever code it declares. ⛔ Nothing here re-opens or
re-argues that ruling.

The change, in one edit

classifyDataError's crash terminal moved — it is asked at the top of the
function instead of inside the sandbox unwrap door, so it now outranks the
code-gated arms that are consulted before that door. One named predicate,
isSandboxCrash = isSandboxOrigin AND isScriptFaultMessage, the two reads
sandboxBusinessMessage already makes, asked from the other side.

⛔ The terminal is not duplicated: there is still exactly one
isScriptFaultMessage gate on this path, and a pin asserts that (option C was
refused for adding a mechanism, so the fix must not smuggle one in).

producer before after
sandboxed body crashes, declares an arm's code that arm's status + hook 'guard' threw: TypeError: … 500 INTERNAL_ERROR, sanitised
sandboxed body crashes, no declared code 500 INTERNAL_ERROR, sanitised unchanged — the positive control
sandboxed body reports a refusal carrying a code the arm's status + the business sentence unchanged — the negative control
non-sandbox producer carrying the same code the arm's status + error.message unchanged

The arm list, RE-DERIVED from the tree

⛔ Not copied from #14704. Read off structuredCodeAnswer plus the arms in
classifyDataError above the unwrap door, at this PR's head: thirteen
declared-code literals sit above that door. Ten are reachable by a sandboxed
producer and are the rows this PR pins one by one —

DELETE_RESTRICTED · CONCURRENT_UPDATE · ERR_DATASOURCE_UNAVAILABLE ·
VALIDATION_FAILED · FEEDS_DISABLED · FILES_DISABLED ·
ATTACHMENT_PARENT_ACCESS · ATTACHMENT_DELETE_DENIED ·
RECORD_NOT_ACCESSIBLE · PERMISSION_DENIED

— and three are not, each for a reason the source states in the arm itself:
DUPLICATE_RECORD (gated on name === 'DuplicateRecordError', and
SandboxError sets name unconditionally), OBJECT_NOT_FOUND and
INVALID_FIELD (both carry #14541's !isSandboxOrigin clause). The
name-gated limbs (ConcurrentUpdateError, ValidationError,
PermissionDeniedError) and the [Security] Access denied message limb are
unreachable from a sandbox producer for the same name reason.

The re-derivation is now mechanical, not a reading that rots: §4-derivation
scans the source for every declared-code literal above the unwrap door and
fails unless each is either covered per-arm above or named in an
UNREACHABLE_BY_A_SANDBOX_PRODUCER list with its reason.

The pin, flipped

error-response-sandbox-arm-message.test.ts §4 goes from ACCEPTED
DIVERGENCE
to CONVERGED, with the ruling quoted beside it — the same
discipline PR #15065 used on its own §4. ⛔ The section is not deleted: it is
the only thing that would notice the divergence coming back. Three legs:

  1. the flip, per arm, by name (10 rows) — status and code asserted
    together (ADR-0112), plus not.toHaveProperty on every structured field the
    arm used to ship, because the sanitised terminal says status and code and
    nothing else;
  2. the positive control STAYS — the same crash carrying no declared code
    reaches the same sanitised 500. It was the control leg before and it is one
    after;
  3. the negative control, asserted per arm rather than inherited from §1: an
    ordinary declared refusal (a hook that throws a business error carrying a
    code and does not crash) is completely unaffected, and so is a
    non-sandbox producer on the same code — 「only the crash branch moves」.

Evidence

Targeted suite. pnpm --filter @objectstack/rest testTest Files 185 passed (185) · Tests 3108 passed (3108), VERDICT command-exit 0.
pnpm --filter @objectstack/rest typecheckVERDICT command-exit 0
(tsc --noEmit plus check:test-typecheck: 0 files / 0 errors). Dependency
closure built first (pnpm --filter '@objectstack/rest^...' build, exit 0).

Ablation — directions predicted in writing before any leg ran, mutation
proven on disk by occurrence count and blob hash, restore proven by
git checkout HEAD -- PATH + blob equality + empty git diff HEAD + empty
git status --porcelain, whole script under trap … EXIT INT TERM with
absolute paths.

leg mutation predicted measured
1 remove the isSandboxCrash guard reddens the flip legs, the positive control and the derivation leg; leaves the negative control green 14 failed / 106 passed — exactly the 10 flip rows, the positive control, the "asked ONCE" leg and the 2 door cases; 0 negative-control rows failed
2 replace it with the wrong rule the card names — degrade anything carrying a code flip legs stay GREEN; the negative control and §1/§2/§3 redden 83 failed / 37 passed — the 10 flip rows are not among the failures; both negative-control families, §1/§2/§3 and the positive control all fired
3 restore byte-identical blob 9aab3df05173122a3358ab1f85b4f4d58e494db6 = HEAD's, git diff HEAD clean, porcelain empty

Leg 2 is the leg that matters: it is the plausible-but-wrong implementation, it
turns the headline condition green, and the negative control is what catches
it. Leg 1's positive control also reddens, as predicted — the fix MOVED the
only isScriptFaultMessage gate on this path, so removing the guard leaves a
codeless crash unguarded too; ⛔ read leg 1 as "the pin can fail", not as
"pre-fix behaviour".

The pin tests import ./error-response.js — a relative import inside the
package — so the subject resolves through the package's own TypeScript source
and no dist/ sits between the mutation and the run.

Gates. Derived on the final tree with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
(58 commands, no stale-tree warning, change set = the 4 paths of this PR).
Every exit code captured by redirect-then-$?, ⛔ never through a pipe.

Re-verification after the changeset round (head d4f4a329b5). The family
re-derives identical (58 commands, byte-for-byte the same list) on the new
head. The delta from the fully-verified head 84c0bb86b8 is one file
git diff 84c0bb86b8..HEAD --name-only filtered of .changeset/ is EMPTY — so
no source or test byte moved and the source-reading families cannot change
verdict. Declared narrowing rather than a silent one: on the new head I re-ran
every family whose INPUT this commit moved (check-changeset-no-major with the
event payload, check-adr-0087-registration, check-empty-changeset,
check:changeset-gate-self-tests, check:objectui-changeset,
check-changeset-fixed, check:published-files, check:doc-authoring,
check:nul-bytes, and both changeset gates' own --self-test batteries) plus
the repo-wide pnpm lint — all exit 0 — and did not re-run the built-tree
ratchets (check:type-check-debt, check:dual-build-cjs-loads), which read
dist/ and source this commit does not touch and which were green on
84c0bb86b8. CI runs the whole farm on this head regardless.

Docs-drift round — the hand sweep is the round. The bot derived 2 anchors
and found no page naming them, and its own output says that is not a clean bill
of health: this change moves published REST wire behaviour, and any page that
documents the rule states it by its inputs (error codes, refusal shapes),
sharing no identifier with isSandboxCrash. So the anchored zero carries almost
no information here and the sweep below is what the round rests on.

Swept the 195 hand-written pages for a page naming any of the ten arm codes
AND
crash/fault vocabulary (UNCLASSIFIED_FAULT, INTERNAL_ERROR,
TypeError, crash, threw:, SandboxError): 11 pages, each read and
judged. Three are content/docs/releases/** — historical records, ⛔ never
edited in a code PR, and a past release note is not made false by a later
change. Of the eight live pages:

  • content/docs/api/error-catalog.mdx — the page the change makes more TRUE,
    and judged rather than skipped. DELETE_RESTRICTED's stated cause ("other
    records depend on it") is what a crash declaring that code contradicted; the
    crash no longer arrives under it. INTERNAL_ERROR's stated cause ("an
    unexpected server-side error") is exactly a crashed hook. Its crash-vs-rejection
    table is scoped to /api/v1/actions and already says a TypeError is a crash
    (500) — the /data door now agrees with it. No row goes stale, so no edit.
  • content/docs/automation/hook-bodies.mdx — "an error your body lets escape …
    keeps code" is about a deliberate throw or re-throw; a crash is not that,
    and isScriptFaultMessage's accepted cost (A raw TypeError: not a function leaks in the 400 envelope for {"title": 12345} — no code, no fields[] #7543) already governed it. No edit.
  • content/docs/protocol/kernel/error-handling.mdx — documents INTERNAL_ERROR
    as 500 (unchanged) and recommends alerting when its rate exceeds a threshold.
    ⚠️ Named as an operator-visible consequence rather than a staleness: a
    deployment with crashing hooks that previously answered 4xx will see that rate
    rise. The sentence is a monitoring recommendation, not a statement of runtime
    behaviour, and a crashing hook is precisely what it should alert on — editing a
    threshold on the strength of this change would be a guess, so it is reported
    here instead.
  • content/docs/api/client-sdk.mdx (an INTERNAL_ERROR catalog row plus "500
    crash" as an existing category), content/docs/automation/approvals.mdx (a host
    process crash; a request-shape table for a non-sandbox producer),
    content/docs/automation/flows.mdx (sandbox = actionType; crash =
    exactly-once resume), content/docs/protocol/kernel/http-protocol.mdx
    (sandbox = the environment enum) and content/docs/ui/forms.mdx (the public
    form route's own validator refusals) — none states a rule about a sandboxed
    body that declares a code and then crashes. No edit.

docs/ is invisible to the tool on this run and on any run — it walks
content/docs only — so it was swept by hand with the same input-shaped query:
18 files name an arm code, and the only one that also names the fault
terminal is docs/qa/platform-checklist/areas/attachments-storage.json, whose
analysis is about ERR_FILE_CONSTRAINT — a producer with no arm, not a sandbox
origin, reaching the terminal by having no branch and no declared status. My
guard fires only for a sandbox crash, so that reading is unchanged. docs/adr/
is a governed surface and carries nothing about this rule (ADR-0112's
SandboxError limb is the tenant-authored-code 4xx, which a crash was never
part of).

No documentation edit is owed, judged page by page rather than inferred
from the bot's silence.

Single-writer, re-measured here rather than inherited. 23 open PRs
enumerated from the open PR list, each read against its own merge base (the
/pulls/{n}/files diff): 329 changed-file rows, positive control 65
.changeset/ rows so the scan demonstrably reads file lists, and zero rows
name error-response
. The only packages/rest PR is #16950
(import-runner.ts, rest-server-canonical-query-ast.test.ts) — it imports
isEngineDuplicateRecordEnvelope from this file and ⛔ does not modify it.

What this PR deliberately does NOT do — and the residue it names

验收备注

The seven conditions triage transcribed from the ruling block at
5578501363, copied verbatim as instructed (「承接者请照抄进 ## 验收备注」),
each answered. ⛔ None is re-argued.

  1. 一张 S 卡 / 一个 PR,面在 packages/rest/src/error-response.ts 及其 pin。§4 从 ACCEPTED DIVERGENCE 翻成 CONVERGED,并在旁边引出理由(与 PR fix(rest): the by-id /data door stops shipping the QuickJS wrapper out of a declared-code arm #15065 处理它自己 §4 时同一纪律)。

Met. One PR, four files: error-response.ts, its two pin files, and the
changeset. §4 is flipped with the ruling quoted beside it in the section's
docblock, and the file header's §4 line is rewritten to match.

  1. 阳性对照保留不动:同一次崩溃、不带声明码 ⇒ 仍是净化 500。它今天就是对照腿,回归后仍须是对照腿。

Met. The case survives verbatim in behaviour and is re-labelled "the
positive control STAYS". Ablation leg 2 shows it can still fire, so it is a
control and not a decoration.

  1. 阴性对照必测:普通的声明式拒绝(钩子抛出带码的业务错误、未崩溃)必须完全不受影响 —— 裁定块明写「only the crash branch moves」。一个"凡带码即降级为 500"的实现会让第 1 条绿而把整个声明式拒绝面删掉。

Met, and ablated. §4-negative asserts it per arm for both a sandboxed
business refusal and a non-sandbox producer, plus a row proving a crash-SHAPED
sentence from a producer that never went through the sandbox is not a sandbox
crash. Ablation leg 2 implements exactly the "凡带码即降级为 500" mistake: the
flip legs stay green and 83 tests redden.

  1. arm 清单从树上重新推导,⛔ 不要照抄 rest: classifyDataError's structured-code arms ship the QuickJS debug wrapper as error for a sandboxed producer — the mirror of #11588, on the door #11588 did not touch #14704 的十条 —— 裁定块明确要求这一点。

Met. Re-derived above (13 literals, 10 reachable, 3 unreachable with
reasons), and made mechanical by §4-derivation so the next arm cannot slip past
silently. The count coincides with #14704's ten; the list was read off the tree,
not carried over.

  1. developerMessage 通道遵循 A raw TypeError: not a function leaks in the 400 envelope for {"title": 12345} — no code, no fields[] #7543 对故障的既有规则,⛔ 本裁定加宽它。

Met. No developerMessage emission is added or moved; the crash answer is
UNCLASSIFIED_FAULT() unchanged.

  1. changeset 用运维语言,裁定块已给出句子:"a hook that crashes after declaring a code now answers 500 UNCLASSIFIED_FAULT instead of the declared status with the crash text"

Met. .changeset/sandbox-crash-outranks-declared-code-arm.md leads with
that sentence as its title, in operator language, and states what does not
change beside it.

  1. 串行约束:Routes exiting through handleRouteError answer the engine's DUPLICATE_RECORD envelope from resolveErrorResponse's .status passthrough — field and object dropped, mapDataError's structured 409 arms never consulted #14541 / rest/metadata-protocol: one unique conflict, two wire spellings on ONE route — a batch/import ROW reports DUPLICATE_RECORD while the whole-request failure on the same route reports UNIQUE_VIOLATION #14723 同文件(error-response.ts 按其文件头是单写者)。认领前先确认没有在飞的写者。

Met by measurement, with one premise corrected. No open PR writes this file
(23 PRs, each against its own merge base, positive control fires — see
Evidence). ⚠️ The parenthetical does not verify: on this tree
error-response.ts's header carries no single-writer clause — grep -i writer over the whole file returns two hits, both the phrase "envelope
writer", neither a serial-constraint declaration. The constraint was honoured by
re-measuring the board; it is not readable from the file. Noted, not filed.

The changeset level, and the **BREAKING** banner

Check Changeset was red at head 84c0bb86b8 on its level axis (#16055):
this PR declares clause-② yes and graded every package it moves at patch.
Path 1 of the two the gate names — the declaration is right (it is the
maintainer's, 5535780469: "published REST wire behaviour changes on a real
population"
, ⛔ not mine to revisit), so the level was the wrong half.
@objectstack/rest is now minor.

The banner is owed, and is here. The launch-window convention refuses
major while the fixed group versions in lockstep, so the level carries no
breaking-ness signal and the **BREAKING** banner plus the ADR-0087 disposition
are the only ones there are. The act qualifies on the repo's own test: the answer
a published door gives moves for existing inputs — a client branching on
error.code for the affected shape now falls to its 5xx path instead of its
refusal path — while no export, signature or declared type changes. That is the
shape client-get-active-member-names-the-organisation.md marks in the same
words, and PR #17209 marked the same way today. ⛔ Not marking it would be
exactly the erosion #13080 records: a genuinely breaking change nudged away from
the marker because the marker is inconvenient.

The banner makes check-adr-0087-registration.mjs demand a disposition, so the
changeset carries not-required (no-migration-prescription) with the other four
categories closed on facts in its own text — nothing authorable moves, so
objectstack migrate meta has nothing to visit and the changeset ships no
rewrite instructions.

Reproduced locally before pushing, exit codes by redirect-then-$?:

run command exit
before check-changeset-no-major.mjs --base origin/main --event EVENT 1 — the same LEVEL AXIS refusal CI printed
after the same command on d4f4a329b5 0LEVEL AXIS: ✓ … no package … is graded patch
after check-adr-0087-registration.mjs --base origin/main 0 — 1 declared-breaking changeset, disposition read back

A bare local run cannot reproduce this axis at all (LEVEL AXIS: NOT APPLICABLE — no pull_request payload), so the reproduction was driven through
the gate's own offline door, --event with this PR's live payload.

⚠️ One line of that gate's output is known-wrong — filed as #17229, ⛔ not
repaired here.
CI printed carrier: needs:contract-review is not on this PR (0 label(s) read) and called it a disagreement with the Clause-②: yes line. It
is not: a sibling step in the same job enumerated five labels including
needs:contract-review 119 ms earlier, and the local run above — driven from a
payload that carries the labels — prints carrier: needs:contract-review IS on this PR. The red was path 1 (the changeset level) throughout. ⛔ Do not
read that line as an invitation to path 2, which would strip a maintainer-ruled
contract-review requirement off this PR to clear a level error.

Why this PR PARKS

⛔ Draft, ⛔ auto-merge not armed, needs:contract-review carried. Clause-②: yes is the ruling's own words (5535780469: "published REST wire behaviour
changes on a real population … contract-review tier; needs:contract-review on
card and PR"), re-hung on the card by the claim at 5606751087. Waiting outside
the queue is the safe state until a contract-review PASS is on file.


Generated by Claude Code

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 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 — 14 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 470746ae4511db2f7c754d8aa6c247c3ce2120c4packageMentionDocs.

Which tree this was computed on

This run read content/docs from 498f494ebb0ce43e0c83adf8243dd0b40e460919 — the merge of head 5b342a17fd684f199d5e3df540e12918e7b0585d into base 470746ae4511db2f7c754d8aa6c247c3ce2120c4, 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 498f494ebb0ce43e0c83adf8243dd0b40e460919 && git checkout 498f494ebb0ce43e0c83adf8243dd0b40e460919
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 470746ae4511db2f7c754d8aa6c247c3ce2120c4 5b342a17fd684f199d5e3df540e12918e7b0585d && git checkout -B drift-repro 470746ae4511db2f7c754d8aa6c247c3ce2120c4 && git merge --no-ff 5b342a17fd684f199d5e3df540e12918e7b0585d

node scripts/docs-audit/affected-docs.mjs --json 470746ae4511db2f7c754d8aa6c247c3ce2120c4

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

The level axis (#16055) refuses a clause-\xe2\x91\xa1 PR that grades every package
it moves at patch. The declaration is the maintainer's (batch #27), so the
level was the wrong half: @objectstack/rest goes to minor, and the BREAKING
banner carries the breaking-ness the launch window keeps off the level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8

Copy link
Copy Markdown
Collaborator

Contract review at CONTRACT_REVIEW_TIERVerdict: PASS (audit reading; director seat, summon #18 segment 6, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-10T00xxZ)

PR #17228 · head d4f4a329b50744ec67c4012dff338c47ac850183 (re-read at posting 00:04:25Z; unchanged since 19:53Z) · card #15071.

  • Reviewed-by: isolated claude-fable-5-1 subagent, transcript-verified (89 harness model stamps, all claude-fable-5-1, zero residue; positive control 76 assistant / 50 user role tokens), adopted verbatim below.
  • Implemented-by: domain:cli execution PM seat session_015QE8qk46e5CHJxyQEUjbf8 on behalf of its dev, branch claude/issue-15071-sandbox-crash-outranks-declared-code (newest Claim: 5606751087). Distinct sessions ⇒ not a self-review.
  • Reading for the seat: PASS; one comment-only fix (F1, stale docblock in error-response.ts:308-319) is owed on the same claim before the ready flip — no wire byte moves, head check only, no second contract review. F2 follow-up card to be filed at ACCEPT. ⛔ This seat cleared no carrier and touched no PR state at posting.

Verdict

PASS (contract-review tier) — the PR executes the maintainer's ruled option B exactly, on exactly the ruled site, with the envelope pinned (status and code) and both controls present. One comment-only fix (F1, a stale docblock in the same file that now contradicts its own code) is owed on the same claim before the ready flip; it moves no wire byte and needs a head check only, not a second contract review. Open question from the dev report is ruled below (F2): shipped reading is correct; the residue is a new card, not a remainder of this one.

Head reviewed

d4f4a329b50744ec67c4012dff338c47ac850183 (matches the requested prefix; head did not move). Branch claude/issue-15071-sandbox-crash-outranks-declared-code, base main, merge-base bccf3111, 9 ahead / 4 behind origin/main (ae19f5ed), mergeable_state: clean, draft, auto-merge not armed. 4 files, +355/−43: packages/rest/src/error-response.ts, error-response-sandbox-arm-message.test.ts, error-response-structured-arm-door-parity.test.ts, .changeset/sandbox-crash-outranks-declared-code-arm.md. Last commit (d4f4a329b) is changeset-only (git diff 84c0bb86b..d4f4a329b --name-only = the one .changeset file), so source/test bytes equal the fully-verified head 84c0bb86b8.

Clause-② reading · claim match · --pair

  • My reading: yes. Published wire answer of @objectstack/rest's single-record /data door moves for an existing input population (sandboxed body that crashes while carrying one of ten declared codes: <arm status> + wrapper prose500 INTERNAL_ERROR, sanitised). No new export (isSandboxCrash is file-local, error-response.ts:776, no export), no new payload key — so no widening tell, but a real contract move. This is also the ruling's own declaration (5535780469).
  • Claim: Clause-②: yes on the PR body (line 3) and on the newest Claim: (card comment 5606751087). Match.
  • node scripts/pm/check-clause2-carriers.mjs --pair 17228exit 0 ("both carriers agree"). needs:contract-review present on card and PR.

Governed surface / protocol label

  • None of the 4 paths is in docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.md → not governed; Governed Surface Queue Guard success.
  • .github/labeler.yml protocol:* globs are all packages/spec/src/{data,ui,system,ai}/** → no protocol:* label owed; none present. Labels on PR: documentation (from **/*.md — the changeset), size/m, tests, tooling, needs:contract-review.

CI on head d4f4a329b5

39 check-runs: 32 success / 5 skipped / 2 cancelled; combined status success (Vercel "Canceled by Ignored Build Step").

  • The 2 cancelled runs are superseded, not unread. No other open PR may claim the same single-writer path (run 34397722405) and Part-of PR must not also close its card (run 34397722484) were pull_request runs created 19:53:13Z on head_sha d4f4a329b5 and cancelled 19:53:35Z by their workflows' concurrency: … cancel-in-progress: true groups (.github/workflows/single-claim-path-guard.yml:62-64, partof-closing-keyword-guard.yml:45-47) when a second pull_request event on the same head arrived at 19:53:21Z. The successors — runs 34397736089 and 34397736098, same head_sha, conclusion: success — completed 19:53:57Z / 19:53:44Z. Latest-per-name is green for every name.
  • Required set (rules/branches/main): TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Governed Surface Queue Guardall success on this head. Lint & Repo Gates (job 102621914757) and TypeScript Type Check (102623975038) personally read as success. Neither cancelled check is in the required set.
  • Check Changeset success on both of its runs (the earlier red at 84c0bb86b8 was the level axis; fixed by patch → minor).
  • 5 skipped are path-filtered (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in), duplicate Auto Label / Check PR Size rows).
  • No local vitest in this container (no node_modules), so test evidence is CI-side: Test Core (1..6/6) + rollup success on head; the dev's 185 files / 3108 tests was taken on 84c0bb86b8, whose source/test bytes equal this head (verified above).

Findings

F1 — stale docblock in the changed file (fix owed, comment-only). packages/rest/src/error-response.ts:308-319 (armSentence's "## ⛔ What this deliberately does NOT decide") still states: the arm "answers with its own declared status, where the unwrap door's terminal for the same crash is the sanitised 500. That divergence is UNCHANGED by this rule … pinned in error-response-sandbox-arm-message.test.ts §4 … and it carries its own decision card." After this PR that is false on every clause: classifyDataError:1218 now routes the crash to UNCLASSIFIED_FAULT() before any arm, §4 is CONVERGED, and the decision card is #15071, executed here. The block should be rewritten to say the crash question was decided by the batch #27 ruling and point at isSandboxCrash (the isSandboxCrash docblock at 736-775 already carries the ruling; a one-paragraph cross-reference suffices). No wire byte moves. All other in-file narrative checked and consistent: isSandboxOrigin 725-730, the unwrap-door comment 1304-1315, resolveErrorResponse's "measured and NOT repaired" paragraph 2218-2225 (still true, now for a wider population), fiveXxArmDisplacesDeclared4xx 796.

F2 — ruling on the dev's open question ("both doors"): shipped reading is correct; option A as shipped, then C. The ruling's colon expansion binds "both doors" to the card's own two "which door answers" rows — mapDataError's code-gated arms and classifyDataError's unwrap door: "classified as a fault at both doors: mapDataError's code-gated arms (all ten … including the PERMISSION_DENIED arm below the consult) hand a sandboxed crash to the same sanitised terminal classifyDataError's unwrap door already produces". resolveErrorResponse is named nowhere in the ruling or its execution constraints (single PR on error-response.ts and §4 of the single-door pin). The bulk-door crash-with-declared-4xx gap is the #11588-fenced "moving the STATUS the passthrough decided" contract question (rest-hook-refusal-message-parity.test.ts §7, error-response.ts:2218-2225); converging it here would exceed the ruling and the supplied changeset sentence. Correctly pinned as ACCEPTED DIVERGENCE (#15071 widens it) at error-response-structured-arm-door-parity.test.ts:564-582, with the no-status sibling CONVERGED at 548-562. Acceptance action: the dispatch seat files the follow-up card (C) at ACCEPT — my scan of the 57 open domain:cli issues finds no card covering it (search API is unavailable on this session; the dev's 612-issue grep agrees).

F3 — correctness on every face that surfaces a sandboxed crash.

  • Single door: isSandboxCrash = isSandboxOrigin && isScriptFaultMessage(innerMessage) (776-778), asked at classifyDataError:1218 above structuredCodeAnswer (1227) and the PERMISSION_DENIED arm (1232) — the same predicate pair sandboxBusinessMessage (271-273) already uses, so refusal/crash classification has one definition. Exactly one isScriptFaultMessage gate remains on the path (the old one inside the unwrap door was removed; the .message-regex fallback door at 1631-1635 is a different input — innerMessage lost — and is below the arms, unchanged). Every mapDataError caller in rest-server.ts (8576…10737) inherits it.
  • Bulk door: resolveErrorResponse:2072 still declines the consult for a sandbox origin; crash + code + no status → mapDataError → 500 at both doors (pinned). Crash + declared 4xx → passthrough keeps declared status + wrapper (pre-existing §7 shape, now pinned as widened; F2).
  • classifiedRefusalAnswer (2386-2399) / sharing routes: a coded crash without a declared status returns undefined (no envelope, sandboxBusinessMessage undefined) and the 500 arms emit INTERNAL_ERROR_MESSAGE via sharingFaultMessage (rest-server.ts:11790-11793) — consistent. With a declared 4xx it re-dresses resolveErrorResponse's answer — the same passthrough residue as F2, not a separate face.
  • Outside the ruled site, for the follow-up card: packages/runtime/src/domains/actions.ts:905-929 treats any error with innerMessage as deliberate (unexpectedFault requires !inner) and ships err.message, already rewritten to the inner text at 847-852. For a sandboxed body crash the runner sets innerMessage to TypeError: … (quickjs-runner.ts:369-372, 400-402), so the /api/v1/actions door appears to answer a sandboxed TypeError as 400 with the native-error text — which contradicts the catalog's actions table (error-catalog.mdx:495-500, "a TypeError … is a crash (500)"). Not this card's; belongs with the F2 card ("sandboxed crash faces outside mapDataError").
  • Negative control holds: a business refusal fails isScriptFaultMessage (NATIVE_ERROR_NAME_RE 228-229 excludes Error:), a non-sandbox producer fails isSandboxOrigin; developerMessage channel untouched (UNCLASSIFIED_FAULT 178-181 unchanged).

F4 — scope vs ruling. Exact: one PR, error-response.ts + its pins; §4 flipped ACCEPTED DIVERGENCE → CONVERGED with the ruling quoted beside it, not deleted; positive control kept as a control; negative control per arm (sandbox refusal + plain producer + crash-shaped plain producer); arm list re-derived (13 literals above the door, 10 reachable, 3 excused by name with the source's own reason) and held mechanically by §4-derivation. Fixes #15071 is correct — the ruled option is fully executed on the ruled site; the residue is a new card. No content/docs/releases/ edit; no unrelated file.

F5 — changeset. @objectstack/rest (published: no private, files: [dist, README.md, CHANGELOG.md]) at minor with a **BREAKING** banner and an ADR-0087 not-required (no-migration-prescription) disposition — the right form: clause-② yes requires ≥ minor on a package whose src/** moves (batch #35 rule, enforced by Check Changeset), and content/docs/protocol/backward-compatibility.mdx:114/187-194 makes the banner, not the level, the breaking-ness signal under the launch-window lockstep. Operator sentence is the ruling's verbatim. No new or changed error code: the answer is the existing INTERNAL_ERROR (UNCLASSIFIED_FAULT, 178-181), already in content/docs/api/error-catalog.mdx:435 with a cause ("An unexpected server-side error occurred") that fits a crashed hook → no ledger row owed.

F6 — tests pin the envelope with controls. error-response-sandbox-arm-message.test.ts §4: per arm status 500 and code INTERNAL_ERROR, wrapper/TypeError absent, arm status/code absent, every structured field the arm used to ship not.toHaveProperty; positive control (codeless crash) kept; §4-negative per arm for sandbox refusal (status, code, business sentence, kept fields) and plain producer; crash-shaped plain producer stays 409 DELETE_RESTRICTED; derivation legs include a zero-match guard and the "asked ONCE" leg (aboveTheUnwrapDoor() must not contain isScriptFaultMessage(). Door-parity test adds the CONVERGED / ACCEPTED-DIVERGENCE pair. Ablation reported with predicted directions (leg 2 — "anything carrying a code → 500" — leaves the flip legs green and reddens only the negative-control families), which is the discriminating evidence triage asked for.

F7 — docs. No page states the old /data rule, so no edit is owed: error-catalog.mdx crash table is /api/v1/actions-scoped (line 483-500) and INTERNAL_ERROR / DELETE_RESTRICTED rows (435, 365) become more true; hook-bodies.mdx:321 ("an error your body lets escape … keeps code") is an actions-route statement about deliberate throws/re-throws; packages/rest/CHANGELOG.md:2603-2608 records the #14704 divergence as it was at that release (historical, correctly untouched). Docs-drift bot: 2 anchors, no page names them (expected for an emitter-only diff).

F8 — informational, no action. (a) The ruling's serial-constraint clause ("error-response.ts has a single writer per its header") is false on this tree — no such header (grep confirms); the dev honoured it by measuring the open-PR board instead, which is the right order. (b) The crash-with-code population is constructible (REJECT_HANDLER at quickjs-runner.ts:283-289 captures e.code/e.status off any thrown VM value; __error is name: message), but its size was the card's claim, not re-measured — irrelevant to the fix's correctness. (c) #17229 (the Check Changeset gate's false "carrier not on this PR" line) is correctly left unrepaired here.

Acceptance notes


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Clear-and-hold provenance — director seat, summon #18 segment 6 (session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-10T00:17:00Z). Clearing needs:contract-review on both carriers (#17228 + card #15071) on the strength of the contract-review-tier PASS at #17228 (comment) (head d4f4a329b5, unchanged); ② --pair 17228 exit 0. ⛔ Not flipped ready / not enqueued: the verdict owes one comment-only fix before the ready flip — F1, the stale docblock at packages/rest/src/error-response.ts:308-319 ("What this deliberately does NOT decide") now contradicts the code; rewrite it to say the crash question was decided by the batch #27 ruling and point at isSandboxCrash. Patch round on the same claim by the domain:cli seat (session_015QE8qk46e5CHJxyQEUjbf8); on the new head confirm the diff is comment lines only + Lint/Type Check green, re-run --pair 17228, then ready → auto-merge. At ACCEPT file the F2 follow-up card (sandboxed-crash faces outside mapDataError). Open carrier = genuinely awaiting review, which this PR no longer is; the hold is recorded here and on the card, not on the label.


Generated by Claude Code

Contract review F1: the "What this deliberately does NOT decide" block in
`armSentence`'s docblock still described the pre-#15071 world, and after this
PR every clause of it was false. It said a sandboxed CRASH reaches an arm and
is answered at the arm's own declared status, that the divergence against the
unwrap door's sanitised 500 is UNCHANGED, that the pin records it as an
accepted divergence, and that it "carries its own decision card" -- while the
card is this one and it has been executed: the crash terminal now sits above
the code-gated arms, so no crash reaches this function on either door.

Rewritten as a cross-reference rather than a second statement of the ruling.
`isSandboxCrash`'s own docblock carries the maintainer ruling, its fence and
its negative control; a file that states one rule twice is the drift this
finding is made of, so the block now points there and stops.

The second paragraph keeps the surviving divergence visible: what converged is
the no-declared-status case. A crash that DECLARED a 4xx still leaves
`resolveErrorResponse` at that status with the QuickJS wrapper, through a
passthrough this card did not touch -- pinned as an ACCEPTED DIVERGENCE in
`error-response-structured-arm-door-parity.test.ts`. The prose must not read as
"all divergence is gone", because it is not.

Comment lines only -- no executable byte moves. Proven at parser level: both
revisions re-printed with `removeComments: true` hash identically
(sha256 5daab82cdd23e0b93a1dfb420b2a9e3c83786975248504bdbf9907b321005fa7),
with a control leg that flips one identifier and is correctly rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
@os-project-manager
os-project-manager marked this pull request as ready for review September 10, 2026 01:46

Copy link
Copy Markdown
Collaborator

ACCEPT — F1 delivered, armed for the queue

Dispatching seat (domain:cli execution PM, seat post #6024). New head 5b342a17fd684f199d5e3df540e12918e7b0585d (fast-forward from d4f4a329b5, one commit, one file). Ready flip then auto-merge, in that order.

⚠️ This comment also carries the patch round's report. The round was dispatched with an explicit prohibition on every GitHub write, and the dev took the narrow reading and posted nothing — correct under its brief, but it leaves the record owing, so it is discharged here rather than living only in a dispatch transcript.

F1 — the finding the tier held the ready flip for

The contract review's F1: armSentence's 「## ⛔ What this deliberately does NOT decide」 block at error-response.ts:308-319 still said the arm/unwrap-door divergence was UNCHANGED and carried its own decision card — false on every clause after this PR, since classifyDataError:1218 now routes a sandboxed crash to UNCLASSIFIED_FAULT() above every arm, §4 is CONVERGED, and the decision card is #15071, executed here.

Rewritten (now :308-325). Four choices worth naming:

  • The heading survives and is now true on its own terms — what armSentence does not decide is fault classification. That was always the real subject; the stale part was the claim about who decides it and with what outcome.
  • One cross-reference, not a second copy of the ruling. The paragraph points at isSandboxCrash, whose docblock at :736-775 already carries the ruling, its fence and its negative control, and says explicitly that they are stated once, there. A file that states one rule twice is the drift this finding is made of.
  • ⚠️ The surviving divergence is stated as a limit on the word CONVERGED, in the same block, so the two cannot be read apart: a crash that declared a 4xx still leaves resolveErrorResponse at that status, wrapper and all, pinned as an ACCEPTED DIVERGENCE at error-response-structured-arm-door-parity.test.ts:564-582.
  • The "no longer reaches this function at all" claim was verified, not assumed — all eight armSentence call sites are reachable only through the two consults, and both are fenced (classifyDataError:1218 gates above the arms; resolveErrorResponse:2072 declines for a sandbox origin). So it holds on both doors.

Comment-only, proven three ways

git diff --name-only d4f4a329b5 → exactly packages/rest/src/error-response.ts.

  1. Line shape — 26 changed lines under -U0; lines not matching a comment-line pattern: 0.
  2. Parser level — both revisions parsed and re-printed with removeComments: true, then hashed. Identical, 23213 chars each, 5daab82cdd23e0b9… — this compares the syntax tree's own text, so type annotations are in scope, not only runtime code.
  3. Control — one identifier flipped on a scratch copy (isSandboxCrashisSandboxOrigin), on-disk confirmed; the proof correctly failed, naming the line. So the green in leg 2 is a reading, not a detector that matches nothing.

⭐ Method note worth keeping: a first attempt used ts.createScanner and reported a false difference — that scanner is context-free, so a backtick inside a comment opened a template literal that swallowed the following comment. That reading was discarded rather than worked around.

Check readings on the new head

check exit reading
pnpm --filter @objectstack/rest typecheck 0 check:test-typecheck: OK — 0 file(s) / 0 error(s)
pnpm --filter @objectstack/rest test 0 185 files / 3108 tests passed — unchanged from the reviewed reading, which is the property a comment edit owes
pnpm lint (whole repo, not narrowed) 0
check-comment-mask-corpus 0 6442 files, 0 disagree, 0 unparseable
CI on 5b342a17 33 check runs, 0 red, 0 in progress; Lint & Repo Gates success, TypeScript Type Check success, Test Core rollup success

Clause-② — stated rather than re-run

--pair 17228 was exit 0 at d4f4a329b5 and this patch is comment-only: it moves no declaration carrier, so the pairing cannot have changed. That is offered as a derivation, ⛔ not as a claim to have re-executed the script.

⚠️ One convention deviation, in pushed history, deliberately not repaired

The pushed commit's trailer reads Co-Authored-By: Claude Opus 5 before Claude-Session:, where AGENTS.md :440-444 prescribes the model-free pair Claude-Session: then Co-authored-by: Claude — three deviations (model name, casing, order). The cause was a contradiction in the dispatch brief, which handed the dev a model-identifier trailer while also forbidding one; the correction reached the dev after the push. ⛔ Not amended, rebased or force-pushed, and ⛔ no empty commit: rewriting pushed history is forbidden here and a trailer line does not justify it. No gate enforces the trailer spelling — the two scripts mentioning it do so only in fixtures — so no red attaches. The rewritten docblock and the commit body prose carry no model identifier; the deviation is confined to the trailer line.

Owed at merge

The tier's F2 follow-up card — sandboxed-crash faces outside mapDataError: resolveErrorResponse's declared-4xx passthrough (§7 plus the new door-parity §4 pin), and domains/actions.ts:905-929's innerMessage-present read against error-catalog.mdx:495. Filed next and linked from #15071.


Generated by Claude Code

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/m tests tooling

Projects

None yet

3 participants