fix(runtime): carry back only the keys a sandboxed hook body wrote - #14826

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-14758-sandbox-hook-write-back-key-set
Sep 3, 2026
Merged

fix(runtime): carry back only the keys a sandboxed hook body wrote#14826
os-project-manager merged 4 commits into
mainfrom
claude/issue-14758-sandbox-hook-write-back-key-set

Conversation

@os-trump

@os-trumpos-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14758

The sandbox hook write-back now carries back the keys the body wrote, not every
key the post-run dump can see — so #14099's per-row divergence refusal is true on the
shipped hook-body path in either driver row order.

Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

The mechanism, and what moved

applyMutationsToInput ended in Object.assign(target, mutatedInput), and
mutatedInput is the readCtxInputJson dump of the wholectx.input. target
is the engine's flat-input Proxy, so each of those assignments is a set the #14088
provenance recorder records. A body that touched nothing therefore reported writing
every payload key.

Under D3 all matched rows share ONE payload, so the noise was order-dependent.
Measured here on this branch by reverting the fix in place (ablation below), with
#14099's own fixture driven through a real QuickJS body:

dispatch orderrow 1 windowrow 2 windowrefused?
already then open{status}{status,completed_at}yes
open then already{status,completed_at}{status,completed_at}NO

In the second order the already-done row inherits completed_at from the
transitioning row's write onto the shared payload, the blanket write-back re-asserts
it as that row's own write, the windows match, and the batch proceeds — landing
#14099's original corruption on a row that never transitioned.

The QuickJS runner now arms an input write-recorder on ctx.input for hook bodies —
the readRecordWritesJson / __recordWrites recorder shape from #4345 — and reports
the keys the body assigned, defined or deleted; the write-back re-asserts only those.

What was deliberately NOT changed

  • The deletion leg at body-runner.ts:555-558 is untouched. It was never
    expressed by the merge: it reads deletion from the ENTRY snapshot as
    absence-from-the-dump, before both merges, and the docblock at :538-541 states
    that ordering deliberately. A key the body never touched is present in the dump and
    so is never deleted. Narrowing the carry-back to keys-the-body-assigned without the
    deletion leg would have dropped a leg that works today — pinned by case 4 below.
  • The empty-array-on-escape branch was NOT copied. For input the branches
    invert: an empty key set is a trustworthy answer (armed, saw no write) and does
    narrow; the untrustworthy answer is an ABSENT key set, and the host falls back to
    today's full Object.assign on it.
  • packages/objectql is untouched. The flat-input proxy and the stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088 recorder
    are correct as written; they were being fed noise. No objectql change is required —
    see the objectql-sites section below.

One leg the recorder alone cannot cover, named rather than hidden

A body that writes THROUGH a value it read — ctx.input.meta.x = 1 — trips no trap
on ctx.input itself, so the recorder cannot list meta, and dropping it would be
exactly the silent loss this card exists to end. carriedInputKeys reads it from the
dump the narrowest way available: an object-valued entry key whose dumped value no
longer matches the entry snapshot
was written through, and is carried. Primitives
need no such leg — a primitive cannot be mutated in place, so every change to one is
an assignment the recorder already saw. That confinement is what keeps this from
re-widening into the value comparison #14099's ruling refused.

File face

packages/runtime/src/sandbox/ only — domain:cli, one lane.

file+/-
packages/runtime/src/sandbox/quickjs-runner.ts+153 / -1
packages/runtime/src/sandbox/body-runner.ts+134 / -1
packages/runtime/src/sandbox/script-runner.ts+28 / -0
packages/runtime/src/sandbox/hook-input-writeback-key-set.integration.test.ts+283 (new)
.changeset/sandbox-hook-input-writeback-key-set.md+50 (new)

origin/main was merged in at e149585290 (a merge, not a rebase). That merge
brought no change to packages/runtime/src/ — only package.json, the new
tsconfig.test.json and the new test-typecheck-debt.json from #14785. The file face
above is unchanged by it.

Measurements

All numbers below are from the merged head e149585290, which is this PR's head,
except the one row explicitly marked pre-merge.

The pin — real ObjectQL + real SqlDriver (better-sqlite3) + real
QuickJSScriptRunner behind hookBodyRunnerFactory, the probe's own shape.
Four
cases; the dispatch order is asserted from inside the bodies rather than assumed:

  1. transition stamp, open then already — refused, MULTI_UPDATE_HOOK_KEY_DIVERGENCE
    with status 400 and the diverging key list naming completed_at, neither row
    written;
  2. transition stamp, already then open — refused, same envelope;
  3. a row-INVARIANT sandboxed body — not refused, and its write lands on every
    matched row (the over-narrowing guard the grading comment made mandatory);
  4. a row-invariant sandboxed delete ctx.input.internal_note — still propagates.

On the merged head: 4 passed.

Ablation (the fix reverted in place, restore proved by hash). The three edited
source files were checked out from origin/main; the mutation was proved on disk by
git hash-object matching the origin/main blob and differing from the HEAD blob,
plus zero occurrences of carriedInputKeys and __inputWrites. No build was involved
— the pin imports these files from src by relative path. Result: 1 failed, 3 passed
of 4, and the failure reports both halves of the finding:

expected undefined to be defined
...no refusal was raised
AssertionError: expected 2026-09-03T09:00:00.000Z to be 2026-01-01T00:00:00.000Z
...and the stamp MOVED on the row that never transitioned

Restored from HEAD, git diff HEAD empty and all three blobs hash-equal to HEAD,
the same four cases read 4 passed.

Suites and gates:

runresult
the four-case pin, merged head4 passed
pnpm --filter @objectstack/runtime exec vitest run src/sandbox/, merged head18 files, 181 passed
pnpm --filter @objectstack/runtime typecheck (now chains check:test-typecheck), merged headexit 0, verdict below
pnpm --filter @objectstack/runtime test (whole package) — pre-merge, carried forward under the declared narrowing below215 files, 3126 passed
pnpm --filter @objectstack/dogfood test (real shipped hook bodies)130 passed, 1 skipped; 1011 tests passed, 3 skipped
pnpm exec eslint . --no-inline-config (whole repo, not narrowed)5779 files, 0 errors, 0 warnings
derived gate families (scripts/pm/dispatch-gates.mjs --commands)32 of 34 green, 2 NOT MEASURED (below)
pnpm check:dual-build-cjs-loads (after the full workspace build)exit 0 — 102 require entry points across 66 packages load; 610 CJS files parse
pnpm check:nul-bytesexit 0 — scanned 8066 text files; no raw ASCII control bytes

Every gate exit code was captured by redirecting to a file first and reading the
status before any pipe, and the verdicts quoted are the gates' own printed lines.

The one declared narrowing, stated so it can be checked rather than trusted. The
whole-package vitest run was measured before the merge, not after. Three facts
close the gap:

  1. the population is read from the tool's own config, not guessed — the package's
    test script is a bare vitest run, whose inputs are
    packages/runtime/vitest.config.ts and packages/runtime/src/**;
  2. git diff --name-only ae0b1d702b HEAD over each of those paths — src, test,
    the package vitest config and both root vitest configs — returns 0 files;
  3. the merge's only three changes inside packages/runtime are package.json (the
    typecheck script, not test), tsconfig.test.json and
    test-typecheck-debt.json. Vitest reads none of them; check:test-typecheck
    reads all three, and it was run separately on the merged head, green.

So the source under test is byte-identical either side of the merge, and the parts
that actually exercise this change were re-run on the merged head anyway (the pin, and
the whole sandbox directory). This was a narrowing rather than a wait because the
shared verify lock was held continuously by another seat for over 20 minutes across
two full queue budgets.

The two NOT MEASURED families are structural, not red:

  • node scripts/check-test-completeness.mjs — the family list names it with no
    argument, and the gate itself prints that the local reading for it is NOT MEASURED
    and is not a red;
  • node scripts/pm/check-half-states.mjs — a PM-board gate that needs an issue
    listing this container cannot make; it prints the same NOT MEASURED refusal and is
    unrelated to this diff.

The test-layer type check, on the instrument that is actually live

The new pin is a 283-line test file, and #14785 changed what reads it. On the
merged tree packages/runtime's typecheck is no longer a bare tsc --noEmit: it
chains check:test-typecheck, which compiles the test layer under the new
packages/runtime/tsconfig.test.json against the EXACT, shrink-only, identity-pinned
packages/runtime/test-typecheck-debt.json. An unledgered file with even one error is
red, and this pin is unledgered.

pnpm --filter @objectstack/runtime typecheck on the merged head, exit 0:

check:test-typecheck: OK — @objectstack/runtime's test layer compiles under
packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned
signature(s) held in test-typecheck-debt.json (shrink-only and identity-pinned)

So the new pin carries zero type errors, and nothing was ledgered: git diff origin/main over test-typecheck-debt.json, tsconfig.test.json, package.json and
scripts/check-type-check-coverage.mjs is empty. No any, no @ts-expect-error, no
disable comment and no weakened assertion was added to reach that.

A green here would say nothing if tsc never read the file, so that was measured rather
than assumed. tsc --noEmit --listFiles -p tsconfig.test.json puts the new pin in the
program (1 hit) along with all three edited source files (3 hits), 913 .ts files in
total; the raw run reports 191 diagnostics over 27 files — matching the ledger
exactly — and zero of them name the new pin.

An earlier revision of this PR body reported "206 diagnostics, exactly the recorded
206" against TEST_DEBT['@objectstack/runtime'] in
scripts/check-type-check-coverage.mjs. That reading was correct on the pre-merge
base and is retired: #14785 graduated that entry out of the per-package ledger and
replaced it with the per-file one above. The paragraph is corrected rather than
deleted because the number moved (206 to 191) and the instrument changed, and a stale
figure in a PR body is what sends the next reader to the wrong gate.

objectql sites, named as the stop condition requires

None needed. The honest fix did not require an objectql change: the flat-input
proxy (hook-wrappers.ts:582-660) and the recorder (hook-write-provenance.ts:163+)
both behave correctly once the write-back stops asserting untouched keys. The only
objectql behaviour this changes is what those two now observe, which is the point.

For the record, the sites this fix reads against and leaves untouched: the
hook-wrappers.tsset trap that routes into data, its deleteProperty trap at
:645, and the hook-write-provenance.tsset and deleteProperty traps.

Card premises

The card's own #12277 premise is inaccurate as written, and the grading comment's
first reading of it was withdrawn; the withdrawal is what this PR implements —
deletion on the sandbox path works today through both traps, and the fix preserves
it. Everything else in the card re-measured true at origin/main.

Draft, not auto-merge: the PM seat lands this.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

`applyMutationsToInput` re-asserted every key of the post-run `ctx.input`
dump onto the engine's flat-input Proxy, whose `set` trap the hook-write
provenance recorder watches. A body that touched nothing therefore "wrote"
every payload key, which made the per-row divergence refusal on a
`multi: true` update blind in one of the two driver row orders.
The QuickJS runner now arms a write recorder on `ctx.input` for hook bodies
and reports the keys the body assigned, defined or deleted; the write-back
re-asserts only those. The absence-from-dump deletion leg is unchanged, a
write made through a value read from the input is still carried from the
dump, and an unavailable recorder falls back to the full assign.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ts the corruption
Without it the ablated run stops at "no refusal was raised" and never reaches
the row-state assertions, so the moved `completed_at` -- the defect itself --
is not reported by the pin that exists for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 8 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via applyMutationsToInput (symbol, a top-level function))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 24 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5a18702f1b33556ace420e8d518df1971232fcda — the merge of head 949a0a7f6be50916358740385927d88e28c66b06 into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, 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 5a18702f1b33556ace420e8d518df1971232fcda && git checkout 5a18702f1b33556ace420e8d518df1971232fcda
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 949a0a7f6be50916358740385927d88e28c66b06 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 949a0a7f6be50916358740385927d88e28c66b06
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM seat — standing down on this PR's only red, because it is not this PR's. Recording the basis rather than asserting it.

The failing check

Test Core (1/6), run 33714253227, job 100520090449, on the merge head e149585290:

[integration] test/run-dev-unbuilt-workspace.e2e.test.ts
> the mirror direction: a reader that is never coming back
> gives up and exits instead of waiting forever
cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design);
this child ran 180103 ms; case 1 measured the same child at 7046 ms on this runner minutes earlier
expected 'SIGKILL' to be null
Test Files 1 failed | 235 passed (236) · Tests 1 failed | 2734 passed (2735)
check-test-completeness: OK (2735 declared, all accounted for)

Why it is not this PR's

Verified against the tree rather than taken from the report:

$ git diff --stat origin/main..e149585290
.changeset/sandbox-hook-input-writeback-key-set.md | 50 ++
packages/runtime/src/sandbox/body-runner.ts | 135 ++-
packages/runtime/src/sandbox/…integration.test.ts | 283 ++
packages/runtime/src/sandbox/quickjs-runner.ts | 154 ++-
packages/runtime/src/sandbox/script-runner.ts | 28 ++

Five files, all packages/runtime/src/sandbox/** plus the changeset. The failing test is in packages/cli, which this diff does not touch, and its subject is os dev over an unbuilt workspace — a surface with no path to the sandbox hook write-back.

⇒ Carded as #14832 (bug · priority:p1 · domain:cli): an intermittent hang in os dev, not a flaky bound. The test's own discriminator settles it — calibration 7046 ms, this run 180103 ms, same runner, same child, minutes apart, against a bound that is now constant with no load-derived term. ⛔ No fix exists to port yet, so there is nothing to bring into this PR; #14832 owns it and names the three tempting non-fixes it forbids (raising the timeout, re-deriving the cap, skipping/quarantining/retrying).

Spending the one re-run allowed for a failure established as not-this-PR's. The hang is intermittent — the same child calibrated at 7 s minutes earlier in the same job — so a re-run is a real reading, not a wish. If it fails again it is still #14832's and this PR is not held for it.

Merge follow-through — verified, and the ledger is untouched

This PR was sent back one round because its branch predated #14785, which replaced the instrument it had measured itself with. That is resolved:

  • e149585290 is a merge of origin/main (⛔ not a rebase), carrying both e6ac0c6fd5 (fix(runtime): wire the test layer into check:test-typecheck, so its 214 test files are type-checked at all (#14504) #14785) and accb9231c7 (test(cli): make the unread-reader ceiling a load-independent constant at RUN_TIMEOUT_MS #14715).
  • pnpm --filter @objectstack/runtime typecheckby name, exit 0, its own verdict: "check:test-typecheck: OK — @objectstack/runtime test layer compiles under packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned signature(s)".
  • ⭐ The new 283-line unledgered pin carries ZERO type errors — so nothing was fixed and, critically, nothing was ledgered. Confirmed independently: git diff origin/main..HEAD over test-typecheck-debt.json, tsconfig.test.json, package.json and scripts/check-type-check-coverage.mjs is empty.
  • Program membership measured, not assumed — a green over a file tsc never read would say nothing: tsc --listFiles -p tsconfig.test.json puts the new pin in the program (1 hit) and all three edited source files too (3 hits), and 0 of the 191 diagnostics name the pin.
  • The body's 206 / TEST_DEBT paragraph is corrected in place rather than deleted — both the instrument and the number moved, and a silently dropped paragraph would leave no trace of which gate was read. Right call.

One declared narrowing, accepted: the whole-package vitest run (215 files / 3126 tests) is the pre-merge measurement, carried forward on three checkable facts — the population comes from the tool's own config; git diff --name-only ae0b1d702b HEAD over src, test and all three vitest configs returns 0 files; and the merge's only three changes inside packages/runtime are package.json, tsconfig.test.json and test-typecheck-debt.json, none of which vitest reads and all of which check:test-typecheck does read — and that ran green on the merged head. ✅ A carried-forward reading with its invariance argued from the tool's own inputs is a measurement; a carried-forward reading with no argument is a guess. This is the former.

It was a narrowing rather than a wait because the shared verify lock was held continuously by another seat for 20+ minutes across two full queue budgets (still holding at 1225 s). Reported as a lock observation, ⛔ not acted on — correct; the lock is not a thing to route around.

Flipping ready and arming.


Generated by Claude Code

@os-project-manager
os-project-manager added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 84199cbSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14758-sandbox-hook-write-back-key-set branch September 3, 2026 10:50
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-trump@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(runtime): carry back only the keys a sandboxed hook body wrote - #14826

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-14758-sandbox-hook-write-back-key-set
Sep 3, 2026
Merged

fix(runtime): carry back only the keys a sandboxed hook body wrote#14826
os-project-manager merged 4 commits into
mainfrom
claude/issue-14758-sandbox-hook-write-back-key-set

Conversation

@os-trump

@os-trumpos-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14758

The sandbox hook write-back now carries back the keys the body wrote, not every
key the post-run dump can see — so #14099's per-row divergence refusal is true on the
shipped hook-body path in either driver row order.

Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

The mechanism, and what moved

applyMutationsToInput ended in Object.assign(target, mutatedInput), and
mutatedInput is the readCtxInputJson dump of the wholectx.input. target
is the engine's flat-input Proxy, so each of those assignments is a set the #14088
provenance recorder records. A body that touched nothing therefore reported writing
every payload key.

Under D3 all matched rows share ONE payload, so the noise was order-dependent.
Measured here on this branch by reverting the fix in place (ablation below), with
#14099's own fixture driven through a real QuickJS body:

dispatch orderrow 1 windowrow 2 windowrefused?
already then open{status}{status,completed_at}yes
open then already{status,completed_at}{status,completed_at}NO

In the second order the already-done row inherits completed_at from the
transitioning row's write onto the shared payload, the blanket write-back re-asserts
it as that row's own write, the windows match, and the batch proceeds — landing
#14099's original corruption on a row that never transitioned.

The QuickJS runner now arms an input write-recorder on ctx.input for hook bodies —
the readRecordWritesJson / __recordWrites recorder shape from #4345 — and reports
the keys the body assigned, defined or deleted; the write-back re-asserts only those.

What was deliberately NOT changed

  • The deletion leg at body-runner.ts:555-558 is untouched. It was never
    expressed by the merge: it reads deletion from the ENTRY snapshot as
    absence-from-the-dump, before both merges, and the docblock at :538-541 states
    that ordering deliberately. A key the body never touched is present in the dump and
    so is never deleted. Narrowing the carry-back to keys-the-body-assigned without the
    deletion leg would have dropped a leg that works today — pinned by case 4 below.
  • The empty-array-on-escape branch was NOT copied. For input the branches
    invert: an empty key set is a trustworthy answer (armed, saw no write) and does
    narrow; the untrustworthy answer is an ABSENT key set, and the host falls back to
    today's full Object.assign on it.
  • packages/objectql is untouched. The flat-input proxy and the stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088 recorder
    are correct as written; they were being fed noise. No objectql change is required —
    see the objectql-sites section below.

One leg the recorder alone cannot cover, named rather than hidden

A body that writes THROUGH a value it read — ctx.input.meta.x = 1 — trips no trap
on ctx.input itself, so the recorder cannot list meta, and dropping it would be
exactly the silent loss this card exists to end. carriedInputKeys reads it from the
dump the narrowest way available: an object-valued entry key whose dumped value no
longer matches the entry snapshot
was written through, and is carried. Primitives
need no such leg — a primitive cannot be mutated in place, so every change to one is
an assignment the recorder already saw. That confinement is what keeps this from
re-widening into the value comparison #14099's ruling refused.

File face

packages/runtime/src/sandbox/ only — domain:cli, one lane.

file+/-
packages/runtime/src/sandbox/quickjs-runner.ts+153 / -1
packages/runtime/src/sandbox/body-runner.ts+134 / -1
packages/runtime/src/sandbox/script-runner.ts+28 / -0
packages/runtime/src/sandbox/hook-input-writeback-key-set.integration.test.ts+283 (new)
.changeset/sandbox-hook-input-writeback-key-set.md+50 (new)

origin/main was merged in at e149585290 (a merge, not a rebase). That merge
brought no change to packages/runtime/src/ — only package.json, the new
tsconfig.test.json and the new test-typecheck-debt.json from #14785. The file face
above is unchanged by it.

Measurements

All numbers below are from the merged head e149585290, which is this PR's head,
except the one row explicitly marked pre-merge.

The pin — real ObjectQL + real SqlDriver (better-sqlite3) + real
QuickJSScriptRunner behind hookBodyRunnerFactory, the probe's own shape.
Four
cases; the dispatch order is asserted from inside the bodies rather than assumed:

  1. transition stamp, open then already — refused, MULTI_UPDATE_HOOK_KEY_DIVERGENCE
    with status 400 and the diverging key list naming completed_at, neither row
    written;
  2. transition stamp, already then open — refused, same envelope;
  3. a row-INVARIANT sandboxed body — not refused, and its write lands on every
    matched row (the over-narrowing guard the grading comment made mandatory);
  4. a row-invariant sandboxed delete ctx.input.internal_note — still propagates.

On the merged head: 4 passed.

Ablation (the fix reverted in place, restore proved by hash). The three edited
source files were checked out from origin/main; the mutation was proved on disk by
git hash-object matching the origin/main blob and differing from the HEAD blob,
plus zero occurrences of carriedInputKeys and __inputWrites. No build was involved
— the pin imports these files from src by relative path. Result: 1 failed, 3 passed
of 4, and the failure reports both halves of the finding:

expected undefined to be defined
...no refusal was raised
AssertionError: expected 2026-09-03T09:00:00.000Z to be 2026-01-01T00:00:00.000Z
...and the stamp MOVED on the row that never transitioned

Restored from HEAD, git diff HEAD empty and all three blobs hash-equal to HEAD,
the same four cases read 4 passed.

Suites and gates:

runresult
the four-case pin, merged head4 passed
pnpm --filter @objectstack/runtime exec vitest run src/sandbox/, merged head18 files, 181 passed
pnpm --filter @objectstack/runtime typecheck (now chains check:test-typecheck), merged headexit 0, verdict below
pnpm --filter @objectstack/runtime test (whole package) — pre-merge, carried forward under the declared narrowing below215 files, 3126 passed
pnpm --filter @objectstack/dogfood test (real shipped hook bodies)130 passed, 1 skipped; 1011 tests passed, 3 skipped
pnpm exec eslint . --no-inline-config (whole repo, not narrowed)5779 files, 0 errors, 0 warnings
derived gate families (scripts/pm/dispatch-gates.mjs --commands)32 of 34 green, 2 NOT MEASURED (below)
pnpm check:dual-build-cjs-loads (after the full workspace build)exit 0 — 102 require entry points across 66 packages load; 610 CJS files parse
pnpm check:nul-bytesexit 0 — scanned 8066 text files; no raw ASCII control bytes

Every gate exit code was captured by redirecting to a file first and reading the
status before any pipe, and the verdicts quoted are the gates' own printed lines.

The one declared narrowing, stated so it can be checked rather than trusted. The
whole-package vitest run was measured before the merge, not after. Three facts
close the gap:

  1. the population is read from the tool's own config, not guessed — the package's
    test script is a bare vitest run, whose inputs are
    packages/runtime/vitest.config.ts and packages/runtime/src/**;
  2. git diff --name-only ae0b1d702b HEAD over each of those paths — src, test,
    the package vitest config and both root vitest configs — returns 0 files;
  3. the merge's only three changes inside packages/runtime are package.json (the
    typecheck script, not test), tsconfig.test.json and
    test-typecheck-debt.json. Vitest reads none of them; check:test-typecheck
    reads all three, and it was run separately on the merged head, green.

So the source under test is byte-identical either side of the merge, and the parts
that actually exercise this change were re-run on the merged head anyway (the pin, and
the whole sandbox directory). This was a narrowing rather than a wait because the
shared verify lock was held continuously by another seat for over 20 minutes across
two full queue budgets.

The two NOT MEASURED families are structural, not red:

  • node scripts/check-test-completeness.mjs — the family list names it with no
    argument, and the gate itself prints that the local reading for it is NOT MEASURED
    and is not a red;
  • node scripts/pm/check-half-states.mjs — a PM-board gate that needs an issue
    listing this container cannot make; it prints the same NOT MEASURED refusal and is
    unrelated to this diff.

The test-layer type check, on the instrument that is actually live

The new pin is a 283-line test file, and #14785 changed what reads it. On the
merged tree packages/runtime's typecheck is no longer a bare tsc --noEmit: it
chains check:test-typecheck, which compiles the test layer under the new
packages/runtime/tsconfig.test.json against the EXACT, shrink-only, identity-pinned
packages/runtime/test-typecheck-debt.json. An unledgered file with even one error is
red, and this pin is unledgered.

pnpm --filter @objectstack/runtime typecheck on the merged head, exit 0:

check:test-typecheck: OK — @objectstack/runtime's test layer compiles under
packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned
signature(s) held in test-typecheck-debt.json (shrink-only and identity-pinned)

So the new pin carries zero type errors, and nothing was ledgered: git diff origin/main over test-typecheck-debt.json, tsconfig.test.json, package.json and
scripts/check-type-check-coverage.mjs is empty. No any, no @ts-expect-error, no
disable comment and no weakened assertion was added to reach that.

A green here would say nothing if tsc never read the file, so that was measured rather
than assumed. tsc --noEmit --listFiles -p tsconfig.test.json puts the new pin in the
program (1 hit) along with all three edited source files (3 hits), 913 .ts files in
total; the raw run reports 191 diagnostics over 27 files — matching the ledger
exactly — and zero of them name the new pin.

An earlier revision of this PR body reported "206 diagnostics, exactly the recorded
206" against TEST_DEBT['@objectstack/runtime'] in
scripts/check-type-check-coverage.mjs. That reading was correct on the pre-merge
base and is retired: #14785 graduated that entry out of the per-package ledger and
replaced it with the per-file one above. The paragraph is corrected rather than
deleted because the number moved (206 to 191) and the instrument changed, and a stale
figure in a PR body is what sends the next reader to the wrong gate.

objectql sites, named as the stop condition requires

None needed. The honest fix did not require an objectql change: the flat-input
proxy (hook-wrappers.ts:582-660) and the recorder (hook-write-provenance.ts:163+)
both behave correctly once the write-back stops asserting untouched keys. The only
objectql behaviour this changes is what those two now observe, which is the point.

For the record, the sites this fix reads against and leaves untouched: the
hook-wrappers.tsset trap that routes into data, its deleteProperty trap at
:645, and the hook-write-provenance.tsset and deleteProperty traps.

Card premises

The card's own #12277 premise is inaccurate as written, and the grading comment's
first reading of it was withdrawn; the withdrawal is what this PR implements —
deletion on the sandbox path works today through both traps, and the fix preserves
it. Everything else in the card re-measured true at origin/main.

Draft, not auto-merge: the PM seat lands this.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

`applyMutationsToInput` re-asserted every key of the post-run `ctx.input`
dump onto the engine's flat-input Proxy, whose `set` trap the hook-write
provenance recorder watches. A body that touched nothing therefore "wrote"
every payload key, which made the per-row divergence refusal on a
`multi: true` update blind in one of the two driver row orders.
The QuickJS runner now arms a write recorder on `ctx.input` for hook bodies
and reports the keys the body assigned, defined or deleted; the write-back
re-asserts only those. The absence-from-dump deletion leg is unchanged, a
write made through a value read from the input is still carried from the
dump, and an unavailable recorder falls back to the full assign.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ts the corruption
Without it the ablated run stops at "no refusal was raised" and never reaches
the row-state assertions, so the moved `completed_at` -- the defect itself --
is not reported by the pin that exists for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 8 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via applyMutationsToInput (symbol, a top-level function))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 24 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5a18702f1b33556ace420e8d518df1971232fcda — the merge of head 949a0a7f6be50916358740385927d88e28c66b06 into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, 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 5a18702f1b33556ace420e8d518df1971232fcda && git checkout 5a18702f1b33556ace420e8d518df1971232fcda
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 949a0a7f6be50916358740385927d88e28c66b06 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 949a0a7f6be50916358740385927d88e28c66b06
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM seat — standing down on this PR's only red, because it is not this PR's. Recording the basis rather than asserting it.

The failing check

Test Core (1/6), run 33714253227, job 100520090449, on the merge head e149585290:

[integration] test/run-dev-unbuilt-workspace.e2e.test.ts
> the mirror direction: a reader that is never coming back
> gives up and exits instead of waiting forever
cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design);
this child ran 180103 ms; case 1 measured the same child at 7046 ms on this runner minutes earlier
expected 'SIGKILL' to be null
Test Files 1 failed | 235 passed (236) · Tests 1 failed | 2734 passed (2735)
check-test-completeness: OK (2735 declared, all accounted for)

Why it is not this PR's

Verified against the tree rather than taken from the report:

$ git diff --stat origin/main..e149585290
.changeset/sandbox-hook-input-writeback-key-set.md | 50 ++
packages/runtime/src/sandbox/body-runner.ts | 135 ++-
packages/runtime/src/sandbox/…integration.test.ts | 283 ++
packages/runtime/src/sandbox/quickjs-runner.ts | 154 ++-
packages/runtime/src/sandbox/script-runner.ts | 28 ++

Five files, all packages/runtime/src/sandbox/** plus the changeset. The failing test is in packages/cli, which this diff does not touch, and its subject is os dev over an unbuilt workspace — a surface with no path to the sandbox hook write-back.

⇒ Carded as #14832 (bug · priority:p1 · domain:cli): an intermittent hang in os dev, not a flaky bound. The test's own discriminator settles it — calibration 7046 ms, this run 180103 ms, same runner, same child, minutes apart, against a bound that is now constant with no load-derived term. ⛔ No fix exists to port yet, so there is nothing to bring into this PR; #14832 owns it and names the three tempting non-fixes it forbids (raising the timeout, re-deriving the cap, skipping/quarantining/retrying).

Spending the one re-run allowed for a failure established as not-this-PR's. The hang is intermittent — the same child calibrated at 7 s minutes earlier in the same job — so a re-run is a real reading, not a wish. If it fails again it is still #14832's and this PR is not held for it.

Merge follow-through — verified, and the ledger is untouched

This PR was sent back one round because its branch predated #14785, which replaced the instrument it had measured itself with. That is resolved:

  • e149585290 is a merge of origin/main (⛔ not a rebase), carrying both e6ac0c6fd5 (fix(runtime): wire the test layer into check:test-typecheck, so its 214 test files are type-checked at all (#14504) #14785) and accb9231c7 (test(cli): make the unread-reader ceiling a load-independent constant at RUN_TIMEOUT_MS #14715).
  • pnpm --filter @objectstack/runtime typecheckby name, exit 0, its own verdict: "check:test-typecheck: OK — @objectstack/runtime test layer compiles under packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned signature(s)".
  • ⭐ The new 283-line unledgered pin carries ZERO type errors — so nothing was fixed and, critically, nothing was ledgered. Confirmed independently: git diff origin/main..HEAD over test-typecheck-debt.json, tsconfig.test.json, package.json and scripts/check-type-check-coverage.mjs is empty.
  • Program membership measured, not assumed — a green over a file tsc never read would say nothing: tsc --listFiles -p tsconfig.test.json puts the new pin in the program (1 hit) and all three edited source files too (3 hits), and 0 of the 191 diagnostics name the pin.
  • The body's 206 / TEST_DEBT paragraph is corrected in place rather than deleted — both the instrument and the number moved, and a silently dropped paragraph would leave no trace of which gate was read. Right call.

One declared narrowing, accepted: the whole-package vitest run (215 files / 3126 tests) is the pre-merge measurement, carried forward on three checkable facts — the population comes from the tool's own config; git diff --name-only ae0b1d702b HEAD over src, test and all three vitest configs returns 0 files; and the merge's only three changes inside packages/runtime are package.json, tsconfig.test.json and test-typecheck-debt.json, none of which vitest reads and all of which check:test-typecheck does read — and that ran green on the merged head. ✅ A carried-forward reading with its invariance argued from the tool's own inputs is a measurement; a carried-forward reading with no argument is a guess. This is the former.

It was a narrowing rather than a wait because the shared verify lock was held continuously by another seat for 20+ minutes across two full queue budgets (still holding at 1225 s). Reported as a lock observation, ⛔ not acted on — correct; the lock is not a thing to route around.

Flipping ready and arming.


Generated by Claude Code

@os-project-manager
os-project-manager added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 84199cbSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14758-sandbox-hook-write-back-key-set branch September 3, 2026 10:50
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-trump@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): carry back only the keys a sandboxed hook body wrote - #14826

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-14758-sandbox-hook-write-back-key-set
Sep 3, 2026
Merged

fix(runtime): carry back only the keys a sandboxed hook body wrote#14826
os-project-manager merged 4 commits into
mainfrom
claude/issue-14758-sandbox-hook-write-back-key-set

Conversation

@os-trump

@os-trumpos-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14758

The sandbox hook write-back now carries back the keys the body wrote, not every
key the post-run dump can see — so #14099's per-row divergence refusal is true on the
shipped hook-body path in either driver row order.

Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

The mechanism, and what moved

applyMutationsToInput ended in Object.assign(target, mutatedInput), and
mutatedInput is the readCtxInputJson dump of the wholectx.input. target
is the engine's flat-input Proxy, so each of those assignments is a set the #14088
provenance recorder records. A body that touched nothing therefore reported writing
every payload key.

Under D3 all matched rows share ONE payload, so the noise was order-dependent.
Measured here on this branch by reverting the fix in place (ablation below), with
#14099's own fixture driven through a real QuickJS body:

dispatch orderrow 1 windowrow 2 windowrefused?
already then open{status}{status,completed_at}yes
open then already{status,completed_at}{status,completed_at}NO

In the second order the already-done row inherits completed_at from the
transitioning row's write onto the shared payload, the blanket write-back re-asserts
it as that row's own write, the windows match, and the batch proceeds — landing
#14099's original corruption on a row that never transitioned.

The QuickJS runner now arms an input write-recorder on ctx.input for hook bodies —
the readRecordWritesJson / __recordWrites recorder shape from #4345 — and reports
the keys the body assigned, defined or deleted; the write-back re-asserts only those.

What was deliberately NOT changed

  • The deletion leg at body-runner.ts:555-558 is untouched. It was never
    expressed by the merge: it reads deletion from the ENTRY snapshot as
    absence-from-the-dump, before both merges, and the docblock at :538-541 states
    that ordering deliberately. A key the body never touched is present in the dump and
    so is never deleted. Narrowing the carry-back to keys-the-body-assigned without the
    deletion leg would have dropped a leg that works today — pinned by case 4 below.
  • The empty-array-on-escape branch was NOT copied. For input the branches
    invert: an empty key set is a trustworthy answer (armed, saw no write) and does
    narrow; the untrustworthy answer is an ABSENT key set, and the host falls back to
    today's full Object.assign on it.
  • packages/objectql is untouched. The flat-input proxy and the stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088 recorder
    are correct as written; they were being fed noise. No objectql change is required —
    see the objectql-sites section below.

One leg the recorder alone cannot cover, named rather than hidden

A body that writes THROUGH a value it read — ctx.input.meta.x = 1 — trips no trap
on ctx.input itself, so the recorder cannot list meta, and dropping it would be
exactly the silent loss this card exists to end. carriedInputKeys reads it from the
dump the narrowest way available: an object-valued entry key whose dumped value no
longer matches the entry snapshot
was written through, and is carried. Primitives
need no such leg — a primitive cannot be mutated in place, so every change to one is
an assignment the recorder already saw. That confinement is what keeps this from
re-widening into the value comparison #14099's ruling refused.

File face

packages/runtime/src/sandbox/ only — domain:cli, one lane.

file+/-
packages/runtime/src/sandbox/quickjs-runner.ts+153 / -1
packages/runtime/src/sandbox/body-runner.ts+134 / -1
packages/runtime/src/sandbox/script-runner.ts+28 / -0
packages/runtime/src/sandbox/hook-input-writeback-key-set.integration.test.ts+283 (new)
.changeset/sandbox-hook-input-writeback-key-set.md+50 (new)

origin/main was merged in at e149585290 (a merge, not a rebase). That merge
brought no change to packages/runtime/src/ — only package.json, the new
tsconfig.test.json and the new test-typecheck-debt.json from #14785. The file face
above is unchanged by it.

Measurements

All numbers below are from the merged head e149585290, which is this PR's head,
except the one row explicitly marked pre-merge.

The pin — real ObjectQL + real SqlDriver (better-sqlite3) + real
QuickJSScriptRunner behind hookBodyRunnerFactory, the probe's own shape.
Four
cases; the dispatch order is asserted from inside the bodies rather than assumed:

  1. transition stamp, open then already — refused, MULTI_UPDATE_HOOK_KEY_DIVERGENCE
    with status 400 and the diverging key list naming completed_at, neither row
    written;
  2. transition stamp, already then open — refused, same envelope;
  3. a row-INVARIANT sandboxed body — not refused, and its write lands on every
    matched row (the over-narrowing guard the grading comment made mandatory);
  4. a row-invariant sandboxed delete ctx.input.internal_note — still propagates.

On the merged head: 4 passed.

Ablation (the fix reverted in place, restore proved by hash). The three edited
source files were checked out from origin/main; the mutation was proved on disk by
git hash-object matching the origin/main blob and differing from the HEAD blob,
plus zero occurrences of carriedInputKeys and __inputWrites. No build was involved
— the pin imports these files from src by relative path. Result: 1 failed, 3 passed
of 4, and the failure reports both halves of the finding:

expected undefined to be defined
...no refusal was raised
AssertionError: expected 2026-09-03T09:00:00.000Z to be 2026-01-01T00:00:00.000Z
...and the stamp MOVED on the row that never transitioned

Restored from HEAD, git diff HEAD empty and all three blobs hash-equal to HEAD,
the same four cases read 4 passed.

Suites and gates:

runresult
the four-case pin, merged head4 passed
pnpm --filter @objectstack/runtime exec vitest run src/sandbox/, merged head18 files, 181 passed
pnpm --filter @objectstack/runtime typecheck (now chains check:test-typecheck), merged headexit 0, verdict below
pnpm --filter @objectstack/runtime test (whole package) — pre-merge, carried forward under the declared narrowing below215 files, 3126 passed
pnpm --filter @objectstack/dogfood test (real shipped hook bodies)130 passed, 1 skipped; 1011 tests passed, 3 skipped
pnpm exec eslint . --no-inline-config (whole repo, not narrowed)5779 files, 0 errors, 0 warnings
derived gate families (scripts/pm/dispatch-gates.mjs --commands)32 of 34 green, 2 NOT MEASURED (below)
pnpm check:dual-build-cjs-loads (after the full workspace build)exit 0 — 102 require entry points across 66 packages load; 610 CJS files parse
pnpm check:nul-bytesexit 0 — scanned 8066 text files; no raw ASCII control bytes

Every gate exit code was captured by redirecting to a file first and reading the
status before any pipe, and the verdicts quoted are the gates' own printed lines.

The one declared narrowing, stated so it can be checked rather than trusted. The
whole-package vitest run was measured before the merge, not after. Three facts
close the gap:

  1. the population is read from the tool's own config, not guessed — the package's
    test script is a bare vitest run, whose inputs are
    packages/runtime/vitest.config.ts and packages/runtime/src/**;
  2. git diff --name-only ae0b1d702b HEAD over each of those paths — src, test,
    the package vitest config and both root vitest configs — returns 0 files;
  3. the merge's only three changes inside packages/runtime are package.json (the
    typecheck script, not test), tsconfig.test.json and
    test-typecheck-debt.json. Vitest reads none of them; check:test-typecheck
    reads all three, and it was run separately on the merged head, green.

So the source under test is byte-identical either side of the merge, and the parts
that actually exercise this change were re-run on the merged head anyway (the pin, and
the whole sandbox directory). This was a narrowing rather than a wait because the
shared verify lock was held continuously by another seat for over 20 minutes across
two full queue budgets.

The two NOT MEASURED families are structural, not red:

  • node scripts/check-test-completeness.mjs — the family list names it with no
    argument, and the gate itself prints that the local reading for it is NOT MEASURED
    and is not a red;
  • node scripts/pm/check-half-states.mjs — a PM-board gate that needs an issue
    listing this container cannot make; it prints the same NOT MEASURED refusal and is
    unrelated to this diff.

The test-layer type check, on the instrument that is actually live

The new pin is a 283-line test file, and #14785 changed what reads it. On the
merged tree packages/runtime's typecheck is no longer a bare tsc --noEmit: it
chains check:test-typecheck, which compiles the test layer under the new
packages/runtime/tsconfig.test.json against the EXACT, shrink-only, identity-pinned
packages/runtime/test-typecheck-debt.json. An unledgered file with even one error is
red, and this pin is unledgered.

pnpm --filter @objectstack/runtime typecheck on the merged head, exit 0:

check:test-typecheck: OK — @objectstack/runtime's test layer compiles under
packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned
signature(s) held in test-typecheck-debt.json (shrink-only and identity-pinned)

So the new pin carries zero type errors, and nothing was ledgered: git diff origin/main over test-typecheck-debt.json, tsconfig.test.json, package.json and
scripts/check-type-check-coverage.mjs is empty. No any, no @ts-expect-error, no
disable comment and no weakened assertion was added to reach that.

A green here would say nothing if tsc never read the file, so that was measured rather
than assumed. tsc --noEmit --listFiles -p tsconfig.test.json puts the new pin in the
program (1 hit) along with all three edited source files (3 hits), 913 .ts files in
total; the raw run reports 191 diagnostics over 27 files — matching the ledger
exactly — and zero of them name the new pin.

An earlier revision of this PR body reported "206 diagnostics, exactly the recorded
206" against TEST_DEBT['@objectstack/runtime'] in
scripts/check-type-check-coverage.mjs. That reading was correct on the pre-merge
base and is retired: #14785 graduated that entry out of the per-package ledger and
replaced it with the per-file one above. The paragraph is corrected rather than
deleted because the number moved (206 to 191) and the instrument changed, and a stale
figure in a PR body is what sends the next reader to the wrong gate.

objectql sites, named as the stop condition requires

None needed. The honest fix did not require an objectql change: the flat-input
proxy (hook-wrappers.ts:582-660) and the recorder (hook-write-provenance.ts:163+)
both behave correctly once the write-back stops asserting untouched keys. The only
objectql behaviour this changes is what those two now observe, which is the point.

For the record, the sites this fix reads against and leaves untouched: the
hook-wrappers.tsset trap that routes into data, its deleteProperty trap at
:645, and the hook-write-provenance.tsset and deleteProperty traps.

Card premises

The card's own #12277 premise is inaccurate as written, and the grading comment's
first reading of it was withdrawn; the withdrawal is what this PR implements —
deletion on the sandbox path works today through both traps, and the fix preserves
it. Everything else in the card re-measured true at origin/main.

Draft, not auto-merge: the PM seat lands this.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

`applyMutationsToInput` re-asserted every key of the post-run `ctx.input`
dump onto the engine's flat-input Proxy, whose `set` trap the hook-write
provenance recorder watches. A body that touched nothing therefore "wrote"
every payload key, which made the per-row divergence refusal on a
`multi: true` update blind in one of the two driver row orders.
The QuickJS runner now arms a write recorder on `ctx.input` for hook bodies
and reports the keys the body assigned, defined or deleted; the write-back
re-asserts only those. The absence-from-dump deletion leg is unchanged, a
write made through a value read from the input is still carried from the
dump, and an unavailable recorder falls back to the full assign.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ts the corruption
Without it the ablated run stops at "no refusal was raised" and never reaches
the row-state assertions, so the moved `completed_at` -- the defect itself --
is not reported by the pin that exists for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 8 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via applyMutationsToInput (symbol, a top-level function))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 24 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5a18702f1b33556ace420e8d518df1971232fcda — the merge of head 949a0a7f6be50916358740385927d88e28c66b06 into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, 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 5a18702f1b33556ace420e8d518df1971232fcda && git checkout 5a18702f1b33556ace420e8d518df1971232fcda
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 949a0a7f6be50916358740385927d88e28c66b06 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 949a0a7f6be50916358740385927d88e28c66b06
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM seat — standing down on this PR's only red, because it is not this PR's. Recording the basis rather than asserting it.

The failing check

Test Core (1/6), run 33714253227, job 100520090449, on the merge head e149585290:

[integration] test/run-dev-unbuilt-workspace.e2e.test.ts
> the mirror direction: a reader that is never coming back
> gives up and exits instead of waiting forever
cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design);
this child ran 180103 ms; case 1 measured the same child at 7046 ms on this runner minutes earlier
expected 'SIGKILL' to be null
Test Files 1 failed | 235 passed (236) · Tests 1 failed | 2734 passed (2735)
check-test-completeness: OK (2735 declared, all accounted for)

Why it is not this PR's

Verified against the tree rather than taken from the report:

$ git diff --stat origin/main..e149585290
.changeset/sandbox-hook-input-writeback-key-set.md | 50 ++
packages/runtime/src/sandbox/body-runner.ts | 135 ++-
packages/runtime/src/sandbox/…integration.test.ts | 283 ++
packages/runtime/src/sandbox/quickjs-runner.ts | 154 ++-
packages/runtime/src/sandbox/script-runner.ts | 28 ++

Five files, all packages/runtime/src/sandbox/** plus the changeset. The failing test is in packages/cli, which this diff does not touch, and its subject is os dev over an unbuilt workspace — a surface with no path to the sandbox hook write-back.

⇒ Carded as #14832 (bug · priority:p1 · domain:cli): an intermittent hang in os dev, not a flaky bound. The test's own discriminator settles it — calibration 7046 ms, this run 180103 ms, same runner, same child, minutes apart, against a bound that is now constant with no load-derived term. ⛔ No fix exists to port yet, so there is nothing to bring into this PR; #14832 owns it and names the three tempting non-fixes it forbids (raising the timeout, re-deriving the cap, skipping/quarantining/retrying).

Spending the one re-run allowed for a failure established as not-this-PR's. The hang is intermittent — the same child calibrated at 7 s minutes earlier in the same job — so a re-run is a real reading, not a wish. If it fails again it is still #14832's and this PR is not held for it.

Merge follow-through — verified, and the ledger is untouched

This PR was sent back one round because its branch predated #14785, which replaced the instrument it had measured itself with. That is resolved:

  • e149585290 is a merge of origin/main (⛔ not a rebase), carrying both e6ac0c6fd5 (fix(runtime): wire the test layer into check:test-typecheck, so its 214 test files are type-checked at all (#14504) #14785) and accb9231c7 (test(cli): make the unread-reader ceiling a load-independent constant at RUN_TIMEOUT_MS #14715).
  • pnpm --filter @objectstack/runtime typecheckby name, exit 0, its own verdict: "check:test-typecheck: OK — @objectstack/runtime test layer compiles under packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned signature(s)".
  • ⭐ The new 283-line unledgered pin carries ZERO type errors — so nothing was fixed and, critically, nothing was ledgered. Confirmed independently: git diff origin/main..HEAD over test-typecheck-debt.json, tsconfig.test.json, package.json and scripts/check-type-check-coverage.mjs is empty.
  • Program membership measured, not assumed — a green over a file tsc never read would say nothing: tsc --listFiles -p tsconfig.test.json puts the new pin in the program (1 hit) and all three edited source files too (3 hits), and 0 of the 191 diagnostics name the pin.
  • The body's 206 / TEST_DEBT paragraph is corrected in place rather than deleted — both the instrument and the number moved, and a silently dropped paragraph would leave no trace of which gate was read. Right call.

One declared narrowing, accepted: the whole-package vitest run (215 files / 3126 tests) is the pre-merge measurement, carried forward on three checkable facts — the population comes from the tool's own config; git diff --name-only ae0b1d702b HEAD over src, test and all three vitest configs returns 0 files; and the merge's only three changes inside packages/runtime are package.json, tsconfig.test.json and test-typecheck-debt.json, none of which vitest reads and all of which check:test-typecheck does read — and that ran green on the merged head. ✅ A carried-forward reading with its invariance argued from the tool's own inputs is a measurement; a carried-forward reading with no argument is a guess. This is the former.

It was a narrowing rather than a wait because the shared verify lock was held continuously by another seat for 20+ minutes across two full queue budgets (still holding at 1225 s). Reported as a lock observation, ⛔ not acted on — correct; the lock is not a thing to route around.

Flipping ready and arming.


Generated by Claude Code

@os-project-manager
os-project-manager added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 84199cbSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14758-sandbox-hook-write-back-key-set branch September 3, 2026 10:50
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-trump@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): carry back only the keys a sandboxed hook body wrote - #14826

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-14758-sandbox-hook-write-back-key-set
Sep 3, 2026
Merged

fix(runtime): carry back only the keys a sandboxed hook body wrote#14826
os-project-manager merged 4 commits into
mainfrom
claude/issue-14758-sandbox-hook-write-back-key-set

Conversation

@os-trump

@os-trumpos-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14758

The sandbox hook write-back now carries back the keys the body wrote, not every
key the post-run dump can see — so #14099's per-row divergence refusal is true on the
shipped hook-body path in either driver row order.

Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

The mechanism, and what moved

applyMutationsToInput ended in Object.assign(target, mutatedInput), and
mutatedInput is the readCtxInputJson dump of the wholectx.input. target
is the engine's flat-input Proxy, so each of those assignments is a set the #14088
provenance recorder records. A body that touched nothing therefore reported writing
every payload key.

Under D3 all matched rows share ONE payload, so the noise was order-dependent.
Measured here on this branch by reverting the fix in place (ablation below), with
#14099's own fixture driven through a real QuickJS body:

dispatch orderrow 1 windowrow 2 windowrefused?
already then open{status}{status,completed_at}yes
open then already{status,completed_at}{status,completed_at}NO

In the second order the already-done row inherits completed_at from the
transitioning row's write onto the shared payload, the blanket write-back re-asserts
it as that row's own write, the windows match, and the batch proceeds — landing
#14099's original corruption on a row that never transitioned.

The QuickJS runner now arms an input write-recorder on ctx.input for hook bodies —
the readRecordWritesJson / __recordWrites recorder shape from #4345 — and reports
the keys the body assigned, defined or deleted; the write-back re-asserts only those.

What was deliberately NOT changed

  • The deletion leg at body-runner.ts:555-558 is untouched. It was never
    expressed by the merge: it reads deletion from the ENTRY snapshot as
    absence-from-the-dump, before both merges, and the docblock at :538-541 states
    that ordering deliberately. A key the body never touched is present in the dump and
    so is never deleted. Narrowing the carry-back to keys-the-body-assigned without the
    deletion leg would have dropped a leg that works today — pinned by case 4 below.
  • The empty-array-on-escape branch was NOT copied. For input the branches
    invert: an empty key set is a trustworthy answer (armed, saw no write) and does
    narrow; the untrustworthy answer is an ABSENT key set, and the host falls back to
    today's full Object.assign on it.
  • packages/objectql is untouched. The flat-input proxy and the stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088 recorder
    are correct as written; they were being fed noise. No objectql change is required —
    see the objectql-sites section below.

One leg the recorder alone cannot cover, named rather than hidden

A body that writes THROUGH a value it read — ctx.input.meta.x = 1 — trips no trap
on ctx.input itself, so the recorder cannot list meta, and dropping it would be
exactly the silent loss this card exists to end. carriedInputKeys reads it from the
dump the narrowest way available: an object-valued entry key whose dumped value no
longer matches the entry snapshot
was written through, and is carried. Primitives
need no such leg — a primitive cannot be mutated in place, so every change to one is
an assignment the recorder already saw. That confinement is what keeps this from
re-widening into the value comparison #14099's ruling refused.

File face

packages/runtime/src/sandbox/ only — domain:cli, one lane.

file+/-
packages/runtime/src/sandbox/quickjs-runner.ts+153 / -1
packages/runtime/src/sandbox/body-runner.ts+134 / -1
packages/runtime/src/sandbox/script-runner.ts+28 / -0
packages/runtime/src/sandbox/hook-input-writeback-key-set.integration.test.ts+283 (new)
.changeset/sandbox-hook-input-writeback-key-set.md+50 (new)

origin/main was merged in at e149585290 (a merge, not a rebase). That merge
brought no change to packages/runtime/src/ — only package.json, the new
tsconfig.test.json and the new test-typecheck-debt.json from #14785. The file face
above is unchanged by it.

Measurements

All numbers below are from the merged head e149585290, which is this PR's head,
except the one row explicitly marked pre-merge.

The pin — real ObjectQL + real SqlDriver (better-sqlite3) + real
QuickJSScriptRunner behind hookBodyRunnerFactory, the probe's own shape.
Four
cases; the dispatch order is asserted from inside the bodies rather than assumed:

  1. transition stamp, open then already — refused, MULTI_UPDATE_HOOK_KEY_DIVERGENCE
    with status 400 and the diverging key list naming completed_at, neither row
    written;
  2. transition stamp, already then open — refused, same envelope;
  3. a row-INVARIANT sandboxed body — not refused, and its write lands on every
    matched row (the over-narrowing guard the grading comment made mandatory);
  4. a row-invariant sandboxed delete ctx.input.internal_note — still propagates.

On the merged head: 4 passed.

Ablation (the fix reverted in place, restore proved by hash). The three edited
source files were checked out from origin/main; the mutation was proved on disk by
git hash-object matching the origin/main blob and differing from the HEAD blob,
plus zero occurrences of carriedInputKeys and __inputWrites. No build was involved
— the pin imports these files from src by relative path. Result: 1 failed, 3 passed
of 4, and the failure reports both halves of the finding:

expected undefined to be defined
...no refusal was raised
AssertionError: expected 2026-09-03T09:00:00.000Z to be 2026-01-01T00:00:00.000Z
...and the stamp MOVED on the row that never transitioned

Restored from HEAD, git diff HEAD empty and all three blobs hash-equal to HEAD,
the same four cases read 4 passed.

Suites and gates:

runresult
the four-case pin, merged head4 passed
pnpm --filter @objectstack/runtime exec vitest run src/sandbox/, merged head18 files, 181 passed
pnpm --filter @objectstack/runtime typecheck (now chains check:test-typecheck), merged headexit 0, verdict below
pnpm --filter @objectstack/runtime test (whole package) — pre-merge, carried forward under the declared narrowing below215 files, 3126 passed
pnpm --filter @objectstack/dogfood test (real shipped hook bodies)130 passed, 1 skipped; 1011 tests passed, 3 skipped
pnpm exec eslint . --no-inline-config (whole repo, not narrowed)5779 files, 0 errors, 0 warnings
derived gate families (scripts/pm/dispatch-gates.mjs --commands)32 of 34 green, 2 NOT MEASURED (below)
pnpm check:dual-build-cjs-loads (after the full workspace build)exit 0 — 102 require entry points across 66 packages load; 610 CJS files parse
pnpm check:nul-bytesexit 0 — scanned 8066 text files; no raw ASCII control bytes

Every gate exit code was captured by redirecting to a file first and reading the
status before any pipe, and the verdicts quoted are the gates' own printed lines.

The one declared narrowing, stated so it can be checked rather than trusted. The
whole-package vitest run was measured before the merge, not after. Three facts
close the gap:

  1. the population is read from the tool's own config, not guessed — the package's
    test script is a bare vitest run, whose inputs are
    packages/runtime/vitest.config.ts and packages/runtime/src/**;
  2. git diff --name-only ae0b1d702b HEAD over each of those paths — src, test,
    the package vitest config and both root vitest configs — returns 0 files;
  3. the merge's only three changes inside packages/runtime are package.json (the
    typecheck script, not test), tsconfig.test.json and
    test-typecheck-debt.json. Vitest reads none of them; check:test-typecheck
    reads all three, and it was run separately on the merged head, green.

So the source under test is byte-identical either side of the merge, and the parts
that actually exercise this change were re-run on the merged head anyway (the pin, and
the whole sandbox directory). This was a narrowing rather than a wait because the
shared verify lock was held continuously by another seat for over 20 minutes across
two full queue budgets.

The two NOT MEASURED families are structural, not red:

  • node scripts/check-test-completeness.mjs — the family list names it with no
    argument, and the gate itself prints that the local reading for it is NOT MEASURED
    and is not a red;
  • node scripts/pm/check-half-states.mjs — a PM-board gate that needs an issue
    listing this container cannot make; it prints the same NOT MEASURED refusal and is
    unrelated to this diff.

The test-layer type check, on the instrument that is actually live

The new pin is a 283-line test file, and #14785 changed what reads it. On the
merged tree packages/runtime's typecheck is no longer a bare tsc --noEmit: it
chains check:test-typecheck, which compiles the test layer under the new
packages/runtime/tsconfig.test.json against the EXACT, shrink-only, identity-pinned
packages/runtime/test-typecheck-debt.json. An unledgered file with even one error is
red, and this pin is unledgered.

pnpm --filter @objectstack/runtime typecheck on the merged head, exit 0:

check:test-typecheck: OK — @objectstack/runtime's test layer compiles under
packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned
signature(s) held in test-typecheck-debt.json (shrink-only and identity-pinned)

So the new pin carries zero type errors, and nothing was ledgered: git diff origin/main over test-typecheck-debt.json, tsconfig.test.json, package.json and
scripts/check-type-check-coverage.mjs is empty. No any, no @ts-expect-error, no
disable comment and no weakened assertion was added to reach that.

A green here would say nothing if tsc never read the file, so that was measured rather
than assumed. tsc --noEmit --listFiles -p tsconfig.test.json puts the new pin in the
program (1 hit) along with all three edited source files (3 hits), 913 .ts files in
total; the raw run reports 191 diagnostics over 27 files — matching the ledger
exactly — and zero of them name the new pin.

An earlier revision of this PR body reported "206 diagnostics, exactly the recorded
206" against TEST_DEBT['@objectstack/runtime'] in
scripts/check-type-check-coverage.mjs. That reading was correct on the pre-merge
base and is retired: #14785 graduated that entry out of the per-package ledger and
replaced it with the per-file one above. The paragraph is corrected rather than
deleted because the number moved (206 to 191) and the instrument changed, and a stale
figure in a PR body is what sends the next reader to the wrong gate.

objectql sites, named as the stop condition requires

None needed. The honest fix did not require an objectql change: the flat-input
proxy (hook-wrappers.ts:582-660) and the recorder (hook-write-provenance.ts:163+)
both behave correctly once the write-back stops asserting untouched keys. The only
objectql behaviour this changes is what those two now observe, which is the point.

For the record, the sites this fix reads against and leaves untouched: the
hook-wrappers.tsset trap that routes into data, its deleteProperty trap at
:645, and the hook-write-provenance.tsset and deleteProperty traps.

Card premises

The card's own #12277 premise is inaccurate as written, and the grading comment's
first reading of it was withdrawn; the withdrawal is what this PR implements —
deletion on the sandbox path works today through both traps, and the fix preserves
it. Everything else in the card re-measured true at origin/main.

Draft, not auto-merge: the PM seat lands this.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

`applyMutationsToInput` re-asserted every key of the post-run `ctx.input`
dump onto the engine's flat-input Proxy, whose `set` trap the hook-write
provenance recorder watches. A body that touched nothing therefore "wrote"
every payload key, which made the per-row divergence refusal on a
`multi: true` update blind in one of the two driver row orders.
The QuickJS runner now arms a write recorder on `ctx.input` for hook bodies
and reports the keys the body assigned, defined or deleted; the write-back
re-asserts only those. The absence-from-dump deletion leg is unchanged, a
write made through a value read from the input is still carried from the
dump, and an unavailable recorder falls back to the full assign.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ts the corruption
Without it the ablated run stops at "no refusal was raised" and never reaches
the row-state assertions, so the moved `completed_at` -- the defect itself --
is not reported by the pin that exists for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 8 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via applyMutationsToInput (symbol, a top-level function))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 24 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5a18702f1b33556ace420e8d518df1971232fcda — the merge of head 949a0a7f6be50916358740385927d88e28c66b06 into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, 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 5a18702f1b33556ace420e8d518df1971232fcda && git checkout 5a18702f1b33556ace420e8d518df1971232fcda
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 949a0a7f6be50916358740385927d88e28c66b06 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 949a0a7f6be50916358740385927d88e28c66b06
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM seat — standing down on this PR's only red, because it is not this PR's. Recording the basis rather than asserting it.

The failing check

Test Core (1/6), run 33714253227, job 100520090449, on the merge head e149585290:

[integration] test/run-dev-unbuilt-workspace.e2e.test.ts
> the mirror direction: a reader that is never coming back
> gives up and exits instead of waiting forever
cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design);
this child ran 180103 ms; case 1 measured the same child at 7046 ms on this runner minutes earlier
expected 'SIGKILL' to be null
Test Files 1 failed | 235 passed (236) · Tests 1 failed | 2734 passed (2735)
check-test-completeness: OK (2735 declared, all accounted for)

Why it is not this PR's

Verified against the tree rather than taken from the report:

$ git diff --stat origin/main..e149585290
.changeset/sandbox-hook-input-writeback-key-set.md | 50 ++
packages/runtime/src/sandbox/body-runner.ts | 135 ++-
packages/runtime/src/sandbox/…integration.test.ts | 283 ++
packages/runtime/src/sandbox/quickjs-runner.ts | 154 ++-
packages/runtime/src/sandbox/script-runner.ts | 28 ++

Five files, all packages/runtime/src/sandbox/** plus the changeset. The failing test is in packages/cli, which this diff does not touch, and its subject is os dev over an unbuilt workspace — a surface with no path to the sandbox hook write-back.

⇒ Carded as #14832 (bug · priority:p1 · domain:cli): an intermittent hang in os dev, not a flaky bound. The test's own discriminator settles it — calibration 7046 ms, this run 180103 ms, same runner, same child, minutes apart, against a bound that is now constant with no load-derived term. ⛔ No fix exists to port yet, so there is nothing to bring into this PR; #14832 owns it and names the three tempting non-fixes it forbids (raising the timeout, re-deriving the cap, skipping/quarantining/retrying).

Spending the one re-run allowed for a failure established as not-this-PR's. The hang is intermittent — the same child calibrated at 7 s minutes earlier in the same job — so a re-run is a real reading, not a wish. If it fails again it is still #14832's and this PR is not held for it.

Merge follow-through — verified, and the ledger is untouched

This PR was sent back one round because its branch predated #14785, which replaced the instrument it had measured itself with. That is resolved:

  • e149585290 is a merge of origin/main (⛔ not a rebase), carrying both e6ac0c6fd5 (fix(runtime): wire the test layer into check:test-typecheck, so its 214 test files are type-checked at all (#14504) #14785) and accb9231c7 (test(cli): make the unread-reader ceiling a load-independent constant at RUN_TIMEOUT_MS #14715).
  • pnpm --filter @objectstack/runtime typecheckby name, exit 0, its own verdict: "check:test-typecheck: OK — @objectstack/runtime test layer compiles under packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned signature(s)".
  • ⭐ The new 283-line unledgered pin carries ZERO type errors — so nothing was fixed and, critically, nothing was ledgered. Confirmed independently: git diff origin/main..HEAD over test-typecheck-debt.json, tsconfig.test.json, package.json and scripts/check-type-check-coverage.mjs is empty.
  • Program membership measured, not assumed — a green over a file tsc never read would say nothing: tsc --listFiles -p tsconfig.test.json puts the new pin in the program (1 hit) and all three edited source files too (3 hits), and 0 of the 191 diagnostics name the pin.
  • The body's 206 / TEST_DEBT paragraph is corrected in place rather than deleted — both the instrument and the number moved, and a silently dropped paragraph would leave no trace of which gate was read. Right call.

One declared narrowing, accepted: the whole-package vitest run (215 files / 3126 tests) is the pre-merge measurement, carried forward on three checkable facts — the population comes from the tool's own config; git diff --name-only ae0b1d702b HEAD over src, test and all three vitest configs returns 0 files; and the merge's only three changes inside packages/runtime are package.json, tsconfig.test.json and test-typecheck-debt.json, none of which vitest reads and all of which check:test-typecheck does read — and that ran green on the merged head. ✅ A carried-forward reading with its invariance argued from the tool's own inputs is a measurement; a carried-forward reading with no argument is a guess. This is the former.

It was a narrowing rather than a wait because the shared verify lock was held continuously by another seat for 20+ minutes across two full queue budgets (still holding at 1225 s). Reported as a lock observation, ⛔ not acted on — correct; the lock is not a thing to route around.

Flipping ready and arming.


Generated by Claude Code

@os-project-manager
os-project-manager added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 84199cbSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14758-sandbox-hook-write-back-key-set branch September 3, 2026 10:50
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-trump@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(runtime): carry back only the keys a sandboxed hook body wrote - #14826

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-14758-sandbox-hook-write-back-key-set
Sep 3, 2026
Merged

fix(runtime): carry back only the keys a sandboxed hook body wrote#14826
os-project-manager merged 4 commits into
mainfrom
claude/issue-14758-sandbox-hook-write-back-key-set

Conversation

@os-trump

@os-trumpos-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14758

The sandbox hook write-back now carries back the keys the body wrote, not every
key the post-run dump can see — so #14099's per-row divergence refusal is true on the
shipped hook-body path in either driver row order.

Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

The mechanism, and what moved

applyMutationsToInput ended in Object.assign(target, mutatedInput), and
mutatedInput is the readCtxInputJson dump of the wholectx.input. target
is the engine's flat-input Proxy, so each of those assignments is a set the #14088
provenance recorder records. A body that touched nothing therefore reported writing
every payload key.

Under D3 all matched rows share ONE payload, so the noise was order-dependent.
Measured here on this branch by reverting the fix in place (ablation below), with
#14099's own fixture driven through a real QuickJS body:

dispatch orderrow 1 windowrow 2 windowrefused?
already then open{status}{status,completed_at}yes
open then already{status,completed_at}{status,completed_at}NO

In the second order the already-done row inherits completed_at from the
transitioning row's write onto the shared payload, the blanket write-back re-asserts
it as that row's own write, the windows match, and the batch proceeds — landing
#14099's original corruption on a row that never transitioned.

The QuickJS runner now arms an input write-recorder on ctx.input for hook bodies —
the readRecordWritesJson / __recordWrites recorder shape from #4345 — and reports
the keys the body assigned, defined or deleted; the write-back re-asserts only those.

What was deliberately NOT changed

  • The deletion leg at body-runner.ts:555-558 is untouched. It was never
    expressed by the merge: it reads deletion from the ENTRY snapshot as
    absence-from-the-dump, before both merges, and the docblock at :538-541 states
    that ordering deliberately. A key the body never touched is present in the dump and
    so is never deleted. Narrowing the carry-back to keys-the-body-assigned without the
    deletion leg would have dropped a leg that works today — pinned by case 4 below.
  • The empty-array-on-escape branch was NOT copied. For input the branches
    invert: an empty key set is a trustworthy answer (armed, saw no write) and does
    narrow; the untrustworthy answer is an ABSENT key set, and the host falls back to
    today's full Object.assign on it.
  • packages/objectql is untouched. The flat-input proxy and the stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088 recorder
    are correct as written; they were being fed noise. No objectql change is required —
    see the objectql-sites section below.

One leg the recorder alone cannot cover, named rather than hidden

A body that writes THROUGH a value it read — ctx.input.meta.x = 1 — trips no trap
on ctx.input itself, so the recorder cannot list meta, and dropping it would be
exactly the silent loss this card exists to end. carriedInputKeys reads it from the
dump the narrowest way available: an object-valued entry key whose dumped value no
longer matches the entry snapshot
was written through, and is carried. Primitives
need no such leg — a primitive cannot be mutated in place, so every change to one is
an assignment the recorder already saw. That confinement is what keeps this from
re-widening into the value comparison #14099's ruling refused.

File face

packages/runtime/src/sandbox/ only — domain:cli, one lane.

file+/-
packages/runtime/src/sandbox/quickjs-runner.ts+153 / -1
packages/runtime/src/sandbox/body-runner.ts+134 / -1
packages/runtime/src/sandbox/script-runner.ts+28 / -0
packages/runtime/src/sandbox/hook-input-writeback-key-set.integration.test.ts+283 (new)
.changeset/sandbox-hook-input-writeback-key-set.md+50 (new)

origin/main was merged in at e149585290 (a merge, not a rebase). That merge
brought no change to packages/runtime/src/ — only package.json, the new
tsconfig.test.json and the new test-typecheck-debt.json from #14785. The file face
above is unchanged by it.

Measurements

All numbers below are from the merged head e149585290, which is this PR's head,
except the one row explicitly marked pre-merge.

The pin — real ObjectQL + real SqlDriver (better-sqlite3) + real
QuickJSScriptRunner behind hookBodyRunnerFactory, the probe's own shape.
Four
cases; the dispatch order is asserted from inside the bodies rather than assumed:

  1. transition stamp, open then already — refused, MULTI_UPDATE_HOOK_KEY_DIVERGENCE
    with status 400 and the diverging key list naming completed_at, neither row
    written;
  2. transition stamp, already then open — refused, same envelope;
  3. a row-INVARIANT sandboxed body — not refused, and its write lands on every
    matched row (the over-narrowing guard the grading comment made mandatory);
  4. a row-invariant sandboxed delete ctx.input.internal_note — still propagates.

On the merged head: 4 passed.

Ablation (the fix reverted in place, restore proved by hash). The three edited
source files were checked out from origin/main; the mutation was proved on disk by
git hash-object matching the origin/main blob and differing from the HEAD blob,
plus zero occurrences of carriedInputKeys and __inputWrites. No build was involved
— the pin imports these files from src by relative path. Result: 1 failed, 3 passed
of 4, and the failure reports both halves of the finding:

expected undefined to be defined
...no refusal was raised
AssertionError: expected 2026-09-03T09:00:00.000Z to be 2026-01-01T00:00:00.000Z
...and the stamp MOVED on the row that never transitioned

Restored from HEAD, git diff HEAD empty and all three blobs hash-equal to HEAD,
the same four cases read 4 passed.

Suites and gates:

runresult
the four-case pin, merged head4 passed
pnpm --filter @objectstack/runtime exec vitest run src/sandbox/, merged head18 files, 181 passed
pnpm --filter @objectstack/runtime typecheck (now chains check:test-typecheck), merged headexit 0, verdict below
pnpm --filter @objectstack/runtime test (whole package) — pre-merge, carried forward under the declared narrowing below215 files, 3126 passed
pnpm --filter @objectstack/dogfood test (real shipped hook bodies)130 passed, 1 skipped; 1011 tests passed, 3 skipped
pnpm exec eslint . --no-inline-config (whole repo, not narrowed)5779 files, 0 errors, 0 warnings
derived gate families (scripts/pm/dispatch-gates.mjs --commands)32 of 34 green, 2 NOT MEASURED (below)
pnpm check:dual-build-cjs-loads (after the full workspace build)exit 0 — 102 require entry points across 66 packages load; 610 CJS files parse
pnpm check:nul-bytesexit 0 — scanned 8066 text files; no raw ASCII control bytes

Every gate exit code was captured by redirecting to a file first and reading the
status before any pipe, and the verdicts quoted are the gates' own printed lines.

The one declared narrowing, stated so it can be checked rather than trusted. The
whole-package vitest run was measured before the merge, not after. Three facts
close the gap:

  1. the population is read from the tool's own config, not guessed — the package's
    test script is a bare vitest run, whose inputs are
    packages/runtime/vitest.config.ts and packages/runtime/src/**;
  2. git diff --name-only ae0b1d702b HEAD over each of those paths — src, test,
    the package vitest config and both root vitest configs — returns 0 files;
  3. the merge's only three changes inside packages/runtime are package.json (the
    typecheck script, not test), tsconfig.test.json and
    test-typecheck-debt.json. Vitest reads none of them; check:test-typecheck
    reads all three, and it was run separately on the merged head, green.

So the source under test is byte-identical either side of the merge, and the parts
that actually exercise this change were re-run on the merged head anyway (the pin, and
the whole sandbox directory). This was a narrowing rather than a wait because the
shared verify lock was held continuously by another seat for over 20 minutes across
two full queue budgets.

The two NOT MEASURED families are structural, not red:

  • node scripts/check-test-completeness.mjs — the family list names it with no
    argument, and the gate itself prints that the local reading for it is NOT MEASURED
    and is not a red;
  • node scripts/pm/check-half-states.mjs — a PM-board gate that needs an issue
    listing this container cannot make; it prints the same NOT MEASURED refusal and is
    unrelated to this diff.

The test-layer type check, on the instrument that is actually live

The new pin is a 283-line test file, and #14785 changed what reads it. On the
merged tree packages/runtime's typecheck is no longer a bare tsc --noEmit: it
chains check:test-typecheck, which compiles the test layer under the new
packages/runtime/tsconfig.test.json against the EXACT, shrink-only, identity-pinned
packages/runtime/test-typecheck-debt.json. An unledgered file with even one error is
red, and this pin is unledgered.

pnpm --filter @objectstack/runtime typecheck on the merged head, exit 0:

check:test-typecheck: OK — @objectstack/runtime's test layer compiles under
packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned
signature(s) held in test-typecheck-debt.json (shrink-only and identity-pinned)

So the new pin carries zero type errors, and nothing was ledgered: git diff origin/main over test-typecheck-debt.json, tsconfig.test.json, package.json and
scripts/check-type-check-coverage.mjs is empty. No any, no @ts-expect-error, no
disable comment and no weakened assertion was added to reach that.

A green here would say nothing if tsc never read the file, so that was measured rather
than assumed. tsc --noEmit --listFiles -p tsconfig.test.json puts the new pin in the
program (1 hit) along with all three edited source files (3 hits), 913 .ts files in
total; the raw run reports 191 diagnostics over 27 files — matching the ledger
exactly — and zero of them name the new pin.

An earlier revision of this PR body reported "206 diagnostics, exactly the recorded
206" against TEST_DEBT['@objectstack/runtime'] in
scripts/check-type-check-coverage.mjs. That reading was correct on the pre-merge
base and is retired: #14785 graduated that entry out of the per-package ledger and
replaced it with the per-file one above. The paragraph is corrected rather than
deleted because the number moved (206 to 191) and the instrument changed, and a stale
figure in a PR body is what sends the next reader to the wrong gate.

objectql sites, named as the stop condition requires

None needed. The honest fix did not require an objectql change: the flat-input
proxy (hook-wrappers.ts:582-660) and the recorder (hook-write-provenance.ts:163+)
both behave correctly once the write-back stops asserting untouched keys. The only
objectql behaviour this changes is what those two now observe, which is the point.

For the record, the sites this fix reads against and leaves untouched: the
hook-wrappers.tsset trap that routes into data, its deleteProperty trap at
:645, and the hook-write-provenance.tsset and deleteProperty traps.

Card premises

The card's own #12277 premise is inaccurate as written, and the grading comment's
first reading of it was withdrawn; the withdrawal is what this PR implements —
deletion on the sandbox path works today through both traps, and the fix preserves
it. Everything else in the card re-measured true at origin/main.

Draft, not auto-merge: the PM seat lands this.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

`applyMutationsToInput` re-asserted every key of the post-run `ctx.input`
dump onto the engine's flat-input Proxy, whose `set` trap the hook-write
provenance recorder watches. A body that touched nothing therefore "wrote"
every payload key, which made the per-row divergence refusal on a
`multi: true` update blind in one of the two driver row orders.
The QuickJS runner now arms a write recorder on `ctx.input` for hook bodies
and reports the keys the body assigned, defined or deleted; the write-back
re-asserts only those. The absence-from-dump deletion leg is unchanged, a
write made through a value read from the input is still carried from the
dump, and an unavailable recorder falls back to the full assign.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ts the corruption
Without it the ablated run stops at "no refusal was raised" and never reaches
the row-state assertions, so the moved `completed_at` -- the defect itself --
is not reported by the pin that exists for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 8 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via applyMutationsToInput (symbol, a top-level function))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 24 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5a18702f1b33556ace420e8d518df1971232fcda — the merge of head 949a0a7f6be50916358740385927d88e28c66b06 into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, 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 5a18702f1b33556ace420e8d518df1971232fcda && git checkout 5a18702f1b33556ace420e8d518df1971232fcda
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 949a0a7f6be50916358740385927d88e28c66b06 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 949a0a7f6be50916358740385927d88e28c66b06
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM seat — standing down on this PR's only red, because it is not this PR's. Recording the basis rather than asserting it.

The failing check

Test Core (1/6), run 33714253227, job 100520090449, on the merge head e149585290:

[integration] test/run-dev-unbuilt-workspace.e2e.test.ts
> the mirror direction: a reader that is never coming back
> gives up and exits instead of waiting forever
cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design);
this child ran 180103 ms; case 1 measured the same child at 7046 ms on this runner minutes earlier
expected 'SIGKILL' to be null
Test Files 1 failed | 235 passed (236) · Tests 1 failed | 2734 passed (2735)
check-test-completeness: OK (2735 declared, all accounted for)

Why it is not this PR's

Verified against the tree rather than taken from the report:

$ git diff --stat origin/main..e149585290
.changeset/sandbox-hook-input-writeback-key-set.md | 50 ++
packages/runtime/src/sandbox/body-runner.ts | 135 ++-
packages/runtime/src/sandbox/…integration.test.ts | 283 ++
packages/runtime/src/sandbox/quickjs-runner.ts | 154 ++-
packages/runtime/src/sandbox/script-runner.ts | 28 ++

Five files, all packages/runtime/src/sandbox/** plus the changeset. The failing test is in packages/cli, which this diff does not touch, and its subject is os dev over an unbuilt workspace — a surface with no path to the sandbox hook write-back.

⇒ Carded as #14832 (bug · priority:p1 · domain:cli): an intermittent hang in os dev, not a flaky bound. The test's own discriminator settles it — calibration 7046 ms, this run 180103 ms, same runner, same child, minutes apart, against a bound that is now constant with no load-derived term. ⛔ No fix exists to port yet, so there is nothing to bring into this PR; #14832 owns it and names the three tempting non-fixes it forbids (raising the timeout, re-deriving the cap, skipping/quarantining/retrying).

Spending the one re-run allowed for a failure established as not-this-PR's. The hang is intermittent — the same child calibrated at 7 s minutes earlier in the same job — so a re-run is a real reading, not a wish. If it fails again it is still #14832's and this PR is not held for it.

Merge follow-through — verified, and the ledger is untouched

This PR was sent back one round because its branch predated #14785, which replaced the instrument it had measured itself with. That is resolved:

  • e149585290 is a merge of origin/main (⛔ not a rebase), carrying both e6ac0c6fd5 (fix(runtime): wire the test layer into check:test-typecheck, so its 214 test files are type-checked at all (#14504) #14785) and accb9231c7 (test(cli): make the unread-reader ceiling a load-independent constant at RUN_TIMEOUT_MS #14715).
  • pnpm --filter @objectstack/runtime typecheckby name, exit 0, its own verdict: "check:test-typecheck: OK — @objectstack/runtime test layer compiles under packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned signature(s)".
  • ⭐ The new 283-line unledgered pin carries ZERO type errors — so nothing was fixed and, critically, nothing was ledgered. Confirmed independently: git diff origin/main..HEAD over test-typecheck-debt.json, tsconfig.test.json, package.json and scripts/check-type-check-coverage.mjs is empty.
  • Program membership measured, not assumed — a green over a file tsc never read would say nothing: tsc --listFiles -p tsconfig.test.json puts the new pin in the program (1 hit) and all three edited source files too (3 hits), and 0 of the 191 diagnostics name the pin.
  • The body's 206 / TEST_DEBT paragraph is corrected in place rather than deleted — both the instrument and the number moved, and a silently dropped paragraph would leave no trace of which gate was read. Right call.

One declared narrowing, accepted: the whole-package vitest run (215 files / 3126 tests) is the pre-merge measurement, carried forward on three checkable facts — the population comes from the tool's own config; git diff --name-only ae0b1d702b HEAD over src, test and all three vitest configs returns 0 files; and the merge's only three changes inside packages/runtime are package.json, tsconfig.test.json and test-typecheck-debt.json, none of which vitest reads and all of which check:test-typecheck does read — and that ran green on the merged head. ✅ A carried-forward reading with its invariance argued from the tool's own inputs is a measurement; a carried-forward reading with no argument is a guess. This is the former.

It was a narrowing rather than a wait because the shared verify lock was held continuously by another seat for 20+ minutes across two full queue budgets (still holding at 1225 s). Reported as a lock observation, ⛔ not acted on — correct; the lock is not a thing to route around.

Flipping ready and arming.


Generated by Claude Code

@os-project-manager
os-project-manager added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 84199cbSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14758-sandbox-hook-write-back-key-set branch September 3, 2026 10:50
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-trump@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): carry back only the keys a sandboxed hook body wrote - #14826

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-14758-sandbox-hook-write-back-key-set
Sep 3, 2026
Merged

fix(runtime): carry back only the keys a sandboxed hook body wrote#14826
os-project-manager merged 4 commits into
mainfrom
claude/issue-14758-sandbox-hook-write-back-key-set

Conversation

@os-trump

@os-trumpos-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14758

The sandbox hook write-back now carries back the keys the body wrote, not every
key the post-run dump can see — so #14099's per-row divergence refusal is true on the
shipped hook-body path in either driver row order.

Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

The mechanism, and what moved

applyMutationsToInput ended in Object.assign(target, mutatedInput), and
mutatedInput is the readCtxInputJson dump of the wholectx.input. target
is the engine's flat-input Proxy, so each of those assignments is a set the #14088
provenance recorder records. A body that touched nothing therefore reported writing
every payload key.

Under D3 all matched rows share ONE payload, so the noise was order-dependent.
Measured here on this branch by reverting the fix in place (ablation below), with
#14099's own fixture driven through a real QuickJS body:

dispatch orderrow 1 windowrow 2 windowrefused?
already then open{status}{status,completed_at}yes
open then already{status,completed_at}{status,completed_at}NO

In the second order the already-done row inherits completed_at from the
transitioning row's write onto the shared payload, the blanket write-back re-asserts
it as that row's own write, the windows match, and the batch proceeds — landing
#14099's original corruption on a row that never transitioned.

The QuickJS runner now arms an input write-recorder on ctx.input for hook bodies —
the readRecordWritesJson / __recordWrites recorder shape from #4345 — and reports
the keys the body assigned, defined or deleted; the write-back re-asserts only those.

What was deliberately NOT changed

  • The deletion leg at body-runner.ts:555-558 is untouched. It was never
    expressed by the merge: it reads deletion from the ENTRY snapshot as
    absence-from-the-dump, before both merges, and the docblock at :538-541 states
    that ordering deliberately. A key the body never touched is present in the dump and
    so is never deleted. Narrowing the carry-back to keys-the-body-assigned without the
    deletion leg would have dropped a leg that works today — pinned by case 4 below.
  • The empty-array-on-escape branch was NOT copied. For input the branches
    invert: an empty key set is a trustworthy answer (armed, saw no write) and does
    narrow; the untrustworthy answer is an ABSENT key set, and the host falls back to
    today's full Object.assign on it.
  • packages/objectql is untouched. The flat-input proxy and the stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088 recorder
    are correct as written; they were being fed noise. No objectql change is required —
    see the objectql-sites section below.

One leg the recorder alone cannot cover, named rather than hidden

A body that writes THROUGH a value it read — ctx.input.meta.x = 1 — trips no trap
on ctx.input itself, so the recorder cannot list meta, and dropping it would be
exactly the silent loss this card exists to end. carriedInputKeys reads it from the
dump the narrowest way available: an object-valued entry key whose dumped value no
longer matches the entry snapshot
was written through, and is carried. Primitives
need no such leg — a primitive cannot be mutated in place, so every change to one is
an assignment the recorder already saw. That confinement is what keeps this from
re-widening into the value comparison #14099's ruling refused.

File face

packages/runtime/src/sandbox/ only — domain:cli, one lane.

file+/-
packages/runtime/src/sandbox/quickjs-runner.ts+153 / -1
packages/runtime/src/sandbox/body-runner.ts+134 / -1
packages/runtime/src/sandbox/script-runner.ts+28 / -0
packages/runtime/src/sandbox/hook-input-writeback-key-set.integration.test.ts+283 (new)
.changeset/sandbox-hook-input-writeback-key-set.md+50 (new)

origin/main was merged in at e149585290 (a merge, not a rebase). That merge
brought no change to packages/runtime/src/ — only package.json, the new
tsconfig.test.json and the new test-typecheck-debt.json from #14785. The file face
above is unchanged by it.

Measurements

All numbers below are from the merged head e149585290, which is this PR's head,
except the one row explicitly marked pre-merge.

The pin — real ObjectQL + real SqlDriver (better-sqlite3) + real
QuickJSScriptRunner behind hookBodyRunnerFactory, the probe's own shape.
Four
cases; the dispatch order is asserted from inside the bodies rather than assumed:

  1. transition stamp, open then already — refused, MULTI_UPDATE_HOOK_KEY_DIVERGENCE
    with status 400 and the diverging key list naming completed_at, neither row
    written;
  2. transition stamp, already then open — refused, same envelope;
  3. a row-INVARIANT sandboxed body — not refused, and its write lands on every
    matched row (the over-narrowing guard the grading comment made mandatory);
  4. a row-invariant sandboxed delete ctx.input.internal_note — still propagates.

On the merged head: 4 passed.

Ablation (the fix reverted in place, restore proved by hash). The three edited
source files were checked out from origin/main; the mutation was proved on disk by
git hash-object matching the origin/main blob and differing from the HEAD blob,
plus zero occurrences of carriedInputKeys and __inputWrites. No build was involved
— the pin imports these files from src by relative path. Result: 1 failed, 3 passed
of 4, and the failure reports both halves of the finding:

expected undefined to be defined
...no refusal was raised
AssertionError: expected 2026-09-03T09:00:00.000Z to be 2026-01-01T00:00:00.000Z
...and the stamp MOVED on the row that never transitioned

Restored from HEAD, git diff HEAD empty and all three blobs hash-equal to HEAD,
the same four cases read 4 passed.

Suites and gates:

runresult
the four-case pin, merged head4 passed
pnpm --filter @objectstack/runtime exec vitest run src/sandbox/, merged head18 files, 181 passed
pnpm --filter @objectstack/runtime typecheck (now chains check:test-typecheck), merged headexit 0, verdict below
pnpm --filter @objectstack/runtime test (whole package) — pre-merge, carried forward under the declared narrowing below215 files, 3126 passed
pnpm --filter @objectstack/dogfood test (real shipped hook bodies)130 passed, 1 skipped; 1011 tests passed, 3 skipped
pnpm exec eslint . --no-inline-config (whole repo, not narrowed)5779 files, 0 errors, 0 warnings
derived gate families (scripts/pm/dispatch-gates.mjs --commands)32 of 34 green, 2 NOT MEASURED (below)
pnpm check:dual-build-cjs-loads (after the full workspace build)exit 0 — 102 require entry points across 66 packages load; 610 CJS files parse
pnpm check:nul-bytesexit 0 — scanned 8066 text files; no raw ASCII control bytes

Every gate exit code was captured by redirecting to a file first and reading the
status before any pipe, and the verdicts quoted are the gates' own printed lines.

The one declared narrowing, stated so it can be checked rather than trusted. The
whole-package vitest run was measured before the merge, not after. Three facts
close the gap:

  1. the population is read from the tool's own config, not guessed — the package's
    test script is a bare vitest run, whose inputs are
    packages/runtime/vitest.config.ts and packages/runtime/src/**;
  2. git diff --name-only ae0b1d702b HEAD over each of those paths — src, test,
    the package vitest config and both root vitest configs — returns 0 files;
  3. the merge's only three changes inside packages/runtime are package.json (the
    typecheck script, not test), tsconfig.test.json and
    test-typecheck-debt.json. Vitest reads none of them; check:test-typecheck
    reads all three, and it was run separately on the merged head, green.

So the source under test is byte-identical either side of the merge, and the parts
that actually exercise this change were re-run on the merged head anyway (the pin, and
the whole sandbox directory). This was a narrowing rather than a wait because the
shared verify lock was held continuously by another seat for over 20 minutes across
two full queue budgets.

The two NOT MEASURED families are structural, not red:

  • node scripts/check-test-completeness.mjs — the family list names it with no
    argument, and the gate itself prints that the local reading for it is NOT MEASURED
    and is not a red;
  • node scripts/pm/check-half-states.mjs — a PM-board gate that needs an issue
    listing this container cannot make; it prints the same NOT MEASURED refusal and is
    unrelated to this diff.

The test-layer type check, on the instrument that is actually live

The new pin is a 283-line test file, and #14785 changed what reads it. On the
merged tree packages/runtime's typecheck is no longer a bare tsc --noEmit: it
chains check:test-typecheck, which compiles the test layer under the new
packages/runtime/tsconfig.test.json against the EXACT, shrink-only, identity-pinned
packages/runtime/test-typecheck-debt.json. An unledgered file with even one error is
red, and this pin is unledgered.

pnpm --filter @objectstack/runtime typecheck on the merged head, exit 0:

check:test-typecheck: OK — @objectstack/runtime's test layer compiles under
packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned
signature(s) held in test-typecheck-debt.json (shrink-only and identity-pinned)

So the new pin carries zero type errors, and nothing was ledgered: git diff origin/main over test-typecheck-debt.json, tsconfig.test.json, package.json and
scripts/check-type-check-coverage.mjs is empty. No any, no @ts-expect-error, no
disable comment and no weakened assertion was added to reach that.

A green here would say nothing if tsc never read the file, so that was measured rather
than assumed. tsc --noEmit --listFiles -p tsconfig.test.json puts the new pin in the
program (1 hit) along with all three edited source files (3 hits), 913 .ts files in
total; the raw run reports 191 diagnostics over 27 files — matching the ledger
exactly — and zero of them name the new pin.

An earlier revision of this PR body reported "206 diagnostics, exactly the recorded
206" against TEST_DEBT['@objectstack/runtime'] in
scripts/check-type-check-coverage.mjs. That reading was correct on the pre-merge
base and is retired: #14785 graduated that entry out of the per-package ledger and
replaced it with the per-file one above. The paragraph is corrected rather than
deleted because the number moved (206 to 191) and the instrument changed, and a stale
figure in a PR body is what sends the next reader to the wrong gate.

objectql sites, named as the stop condition requires

None needed. The honest fix did not require an objectql change: the flat-input
proxy (hook-wrappers.ts:582-660) and the recorder (hook-write-provenance.ts:163+)
both behave correctly once the write-back stops asserting untouched keys. The only
objectql behaviour this changes is what those two now observe, which is the point.

For the record, the sites this fix reads against and leaves untouched: the
hook-wrappers.tsset trap that routes into data, its deleteProperty trap at
:645, and the hook-write-provenance.tsset and deleteProperty traps.

Card premises

The card's own #12277 premise is inaccurate as written, and the grading comment's
first reading of it was withdrawn; the withdrawal is what this PR implements —
deletion on the sandbox path works today through both traps, and the fix preserves
it. Everything else in the card re-measured true at origin/main.

Draft, not auto-merge: the PM seat lands this.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

`applyMutationsToInput` re-asserted every key of the post-run `ctx.input`
dump onto the engine's flat-input Proxy, whose `set` trap the hook-write
provenance recorder watches. A body that touched nothing therefore "wrote"
every payload key, which made the per-row divergence refusal on a
`multi: true` update blind in one of the two driver row orders.
The QuickJS runner now arms a write recorder on `ctx.input` for hook bodies
and reports the keys the body assigned, defined or deleted; the write-back
re-asserts only those. The absence-from-dump deletion leg is unchanged, a
write made through a value read from the input is still carried from the
dump, and an unavailable recorder falls back to the full assign.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ts the corruption
Without it the ablated run stops at "no refusal was raised" and never reaches
the row-state assertions, so the moved `completed_at` -- the defect itself --
is not reported by the pin that exists for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 8 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via applyMutationsToInput (symbol, a top-level function))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 24 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5a18702f1b33556ace420e8d518df1971232fcda — the merge of head 949a0a7f6be50916358740385927d88e28c66b06 into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, 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 5a18702f1b33556ace420e8d518df1971232fcda && git checkout 5a18702f1b33556ace420e8d518df1971232fcda
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 949a0a7f6be50916358740385927d88e28c66b06 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 949a0a7f6be50916358740385927d88e28c66b06
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM seat — standing down on this PR's only red, because it is not this PR's. Recording the basis rather than asserting it.

The failing check

Test Core (1/6), run 33714253227, job 100520090449, on the merge head e149585290:

[integration] test/run-dev-unbuilt-workspace.e2e.test.ts
> the mirror direction: a reader that is never coming back
> gives up and exits instead of waiting forever
cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design);
this child ran 180103 ms; case 1 measured the same child at 7046 ms on this runner minutes earlier
expected 'SIGKILL' to be null
Test Files 1 failed | 235 passed (236) · Tests 1 failed | 2734 passed (2735)
check-test-completeness: OK (2735 declared, all accounted for)

Why it is not this PR's

Verified against the tree rather than taken from the report:

$ git diff --stat origin/main..e149585290
.changeset/sandbox-hook-input-writeback-key-set.md | 50 ++
packages/runtime/src/sandbox/body-runner.ts | 135 ++-
packages/runtime/src/sandbox/…integration.test.ts | 283 ++
packages/runtime/src/sandbox/quickjs-runner.ts | 154 ++-
packages/runtime/src/sandbox/script-runner.ts | 28 ++

Five files, all packages/runtime/src/sandbox/** plus the changeset. The failing test is in packages/cli, which this diff does not touch, and its subject is os dev over an unbuilt workspace — a surface with no path to the sandbox hook write-back.

⇒ Carded as #14832 (bug · priority:p1 · domain:cli): an intermittent hang in os dev, not a flaky bound. The test's own discriminator settles it — calibration 7046 ms, this run 180103 ms, same runner, same child, minutes apart, against a bound that is now constant with no load-derived term. ⛔ No fix exists to port yet, so there is nothing to bring into this PR; #14832 owns it and names the three tempting non-fixes it forbids (raising the timeout, re-deriving the cap, skipping/quarantining/retrying).

Spending the one re-run allowed for a failure established as not-this-PR's. The hang is intermittent — the same child calibrated at 7 s minutes earlier in the same job — so a re-run is a real reading, not a wish. If it fails again it is still #14832's and this PR is not held for it.

Merge follow-through — verified, and the ledger is untouched

This PR was sent back one round because its branch predated #14785, which replaced the instrument it had measured itself with. That is resolved:

  • e149585290 is a merge of origin/main (⛔ not a rebase), carrying both e6ac0c6fd5 (fix(runtime): wire the test layer into check:test-typecheck, so its 214 test files are type-checked at all (#14504) #14785) and accb9231c7 (test(cli): make the unread-reader ceiling a load-independent constant at RUN_TIMEOUT_MS #14715).
  • pnpm --filter @objectstack/runtime typecheckby name, exit 0, its own verdict: "check:test-typecheck: OK — @objectstack/runtime test layer compiles under packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned signature(s)".
  • ⭐ The new 283-line unledgered pin carries ZERO type errors — so nothing was fixed and, critically, nothing was ledgered. Confirmed independently: git diff origin/main..HEAD over test-typecheck-debt.json, tsconfig.test.json, package.json and scripts/check-type-check-coverage.mjs is empty.
  • Program membership measured, not assumed — a green over a file tsc never read would say nothing: tsc --listFiles -p tsconfig.test.json puts the new pin in the program (1 hit) and all three edited source files too (3 hits), and 0 of the 191 diagnostics name the pin.
  • The body's 206 / TEST_DEBT paragraph is corrected in place rather than deleted — both the instrument and the number moved, and a silently dropped paragraph would leave no trace of which gate was read. Right call.

One declared narrowing, accepted: the whole-package vitest run (215 files / 3126 tests) is the pre-merge measurement, carried forward on three checkable facts — the population comes from the tool's own config; git diff --name-only ae0b1d702b HEAD over src, test and all three vitest configs returns 0 files; and the merge's only three changes inside packages/runtime are package.json, tsconfig.test.json and test-typecheck-debt.json, none of which vitest reads and all of which check:test-typecheck does read — and that ran green on the merged head. ✅ A carried-forward reading with its invariance argued from the tool's own inputs is a measurement; a carried-forward reading with no argument is a guess. This is the former.

It was a narrowing rather than a wait because the shared verify lock was held continuously by another seat for 20+ minutes across two full queue budgets (still holding at 1225 s). Reported as a lock observation, ⛔ not acted on — correct; the lock is not a thing to route around.

Flipping ready and arming.


Generated by Claude Code

@os-project-manager
os-project-manager added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 84199cbSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14758-sandbox-hook-write-back-key-set branch September 3, 2026 10:50
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-trump@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): carry back only the keys a sandboxed hook body wrote - #14826

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-14758-sandbox-hook-write-back-key-set
Sep 3, 2026
Merged

fix(runtime): carry back only the keys a sandboxed hook body wrote#14826
os-project-manager merged 4 commits into
mainfrom
claude/issue-14758-sandbox-hook-write-back-key-set

Conversation

@os-trump

@os-trumpos-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14758

The sandbox hook write-back now carries back the keys the body wrote, not every
key the post-run dump can see — so #14099's per-row divergence refusal is true on the
shipped hook-body path in either driver row order.

Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

The mechanism, and what moved

applyMutationsToInput ended in Object.assign(target, mutatedInput), and
mutatedInput is the readCtxInputJson dump of the wholectx.input. target
is the engine's flat-input Proxy, so each of those assignments is a set the #14088
provenance recorder records. A body that touched nothing therefore reported writing
every payload key.

Under D3 all matched rows share ONE payload, so the noise was order-dependent.
Measured here on this branch by reverting the fix in place (ablation below), with
#14099's own fixture driven through a real QuickJS body:

dispatch orderrow 1 windowrow 2 windowrefused?
already then open{status}{status,completed_at}yes
open then already{status,completed_at}{status,completed_at}NO

In the second order the already-done row inherits completed_at from the
transitioning row's write onto the shared payload, the blanket write-back re-asserts
it as that row's own write, the windows match, and the batch proceeds — landing
#14099's original corruption on a row that never transitioned.

The QuickJS runner now arms an input write-recorder on ctx.input for hook bodies —
the readRecordWritesJson / __recordWrites recorder shape from #4345 — and reports
the keys the body assigned, defined or deleted; the write-back re-asserts only those.

What was deliberately NOT changed

  • The deletion leg at body-runner.ts:555-558 is untouched. It was never
    expressed by the merge: it reads deletion from the ENTRY snapshot as
    absence-from-the-dump, before both merges, and the docblock at :538-541 states
    that ordering deliberately. A key the body never touched is present in the dump and
    so is never deleted. Narrowing the carry-back to keys-the-body-assigned without the
    deletion leg would have dropped a leg that works today — pinned by case 4 below.
  • The empty-array-on-escape branch was NOT copied. For input the branches
    invert: an empty key set is a trustworthy answer (armed, saw no write) and does
    narrow; the untrustworthy answer is an ABSENT key set, and the host falls back to
    today's full Object.assign on it.
  • packages/objectql is untouched. The flat-input proxy and the stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088 recorder
    are correct as written; they were being fed noise. No objectql change is required —
    see the objectql-sites section below.

One leg the recorder alone cannot cover, named rather than hidden

A body that writes THROUGH a value it read — ctx.input.meta.x = 1 — trips no trap
on ctx.input itself, so the recorder cannot list meta, and dropping it would be
exactly the silent loss this card exists to end. carriedInputKeys reads it from the
dump the narrowest way available: an object-valued entry key whose dumped value no
longer matches the entry snapshot
was written through, and is carried. Primitives
need no such leg — a primitive cannot be mutated in place, so every change to one is
an assignment the recorder already saw. That confinement is what keeps this from
re-widening into the value comparison #14099's ruling refused.

File face

packages/runtime/src/sandbox/ only — domain:cli, one lane.

file+/-
packages/runtime/src/sandbox/quickjs-runner.ts+153 / -1
packages/runtime/src/sandbox/body-runner.ts+134 / -1
packages/runtime/src/sandbox/script-runner.ts+28 / -0
packages/runtime/src/sandbox/hook-input-writeback-key-set.integration.test.ts+283 (new)
.changeset/sandbox-hook-input-writeback-key-set.md+50 (new)

origin/main was merged in at e149585290 (a merge, not a rebase). That merge
brought no change to packages/runtime/src/ — only package.json, the new
tsconfig.test.json and the new test-typecheck-debt.json from #14785. The file face
above is unchanged by it.

Measurements

All numbers below are from the merged head e149585290, which is this PR's head,
except the one row explicitly marked pre-merge.

The pin — real ObjectQL + real SqlDriver (better-sqlite3) + real
QuickJSScriptRunner behind hookBodyRunnerFactory, the probe's own shape.
Four
cases; the dispatch order is asserted from inside the bodies rather than assumed:

  1. transition stamp, open then already — refused, MULTI_UPDATE_HOOK_KEY_DIVERGENCE
    with status 400 and the diverging key list naming completed_at, neither row
    written;
  2. transition stamp, already then open — refused, same envelope;
  3. a row-INVARIANT sandboxed body — not refused, and its write lands on every
    matched row (the over-narrowing guard the grading comment made mandatory);
  4. a row-invariant sandboxed delete ctx.input.internal_note — still propagates.

On the merged head: 4 passed.

Ablation (the fix reverted in place, restore proved by hash). The three edited
source files were checked out from origin/main; the mutation was proved on disk by
git hash-object matching the origin/main blob and differing from the HEAD blob,
plus zero occurrences of carriedInputKeys and __inputWrites. No build was involved
— the pin imports these files from src by relative path. Result: 1 failed, 3 passed
of 4, and the failure reports both halves of the finding:

expected undefined to be defined
...no refusal was raised
AssertionError: expected 2026-09-03T09:00:00.000Z to be 2026-01-01T00:00:00.000Z
...and the stamp MOVED on the row that never transitioned

Restored from HEAD, git diff HEAD empty and all three blobs hash-equal to HEAD,
the same four cases read 4 passed.

Suites and gates:

runresult
the four-case pin, merged head4 passed
pnpm --filter @objectstack/runtime exec vitest run src/sandbox/, merged head18 files, 181 passed
pnpm --filter @objectstack/runtime typecheck (now chains check:test-typecheck), merged headexit 0, verdict below
pnpm --filter @objectstack/runtime test (whole package) — pre-merge, carried forward under the declared narrowing below215 files, 3126 passed
pnpm --filter @objectstack/dogfood test (real shipped hook bodies)130 passed, 1 skipped; 1011 tests passed, 3 skipped
pnpm exec eslint . --no-inline-config (whole repo, not narrowed)5779 files, 0 errors, 0 warnings
derived gate families (scripts/pm/dispatch-gates.mjs --commands)32 of 34 green, 2 NOT MEASURED (below)
pnpm check:dual-build-cjs-loads (after the full workspace build)exit 0 — 102 require entry points across 66 packages load; 610 CJS files parse
pnpm check:nul-bytesexit 0 — scanned 8066 text files; no raw ASCII control bytes

Every gate exit code was captured by redirecting to a file first and reading the
status before any pipe, and the verdicts quoted are the gates' own printed lines.

The one declared narrowing, stated so it can be checked rather than trusted. The
whole-package vitest run was measured before the merge, not after. Three facts
close the gap:

  1. the population is read from the tool's own config, not guessed — the package's
    test script is a bare vitest run, whose inputs are
    packages/runtime/vitest.config.ts and packages/runtime/src/**;
  2. git diff --name-only ae0b1d702b HEAD over each of those paths — src, test,
    the package vitest config and both root vitest configs — returns 0 files;
  3. the merge's only three changes inside packages/runtime are package.json (the
    typecheck script, not test), tsconfig.test.json and
    test-typecheck-debt.json. Vitest reads none of them; check:test-typecheck
    reads all three, and it was run separately on the merged head, green.

So the source under test is byte-identical either side of the merge, and the parts
that actually exercise this change were re-run on the merged head anyway (the pin, and
the whole sandbox directory). This was a narrowing rather than a wait because the
shared verify lock was held continuously by another seat for over 20 minutes across
two full queue budgets.

The two NOT MEASURED families are structural, not red:

  • node scripts/check-test-completeness.mjs — the family list names it with no
    argument, and the gate itself prints that the local reading for it is NOT MEASURED
    and is not a red;
  • node scripts/pm/check-half-states.mjs — a PM-board gate that needs an issue
    listing this container cannot make; it prints the same NOT MEASURED refusal and is
    unrelated to this diff.

The test-layer type check, on the instrument that is actually live

The new pin is a 283-line test file, and #14785 changed what reads it. On the
merged tree packages/runtime's typecheck is no longer a bare tsc --noEmit: it
chains check:test-typecheck, which compiles the test layer under the new
packages/runtime/tsconfig.test.json against the EXACT, shrink-only, identity-pinned
packages/runtime/test-typecheck-debt.json. An unledgered file with even one error is
red, and this pin is unledgered.

pnpm --filter @objectstack/runtime typecheck on the merged head, exit 0:

check:test-typecheck: OK — @objectstack/runtime's test layer compiles under
packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned
signature(s) held in test-typecheck-debt.json (shrink-only and identity-pinned)

So the new pin carries zero type errors, and nothing was ledgered: git diff origin/main over test-typecheck-debt.json, tsconfig.test.json, package.json and
scripts/check-type-check-coverage.mjs is empty. No any, no @ts-expect-error, no
disable comment and no weakened assertion was added to reach that.

A green here would say nothing if tsc never read the file, so that was measured rather
than assumed. tsc --noEmit --listFiles -p tsconfig.test.json puts the new pin in the
program (1 hit) along with all three edited source files (3 hits), 913 .ts files in
total; the raw run reports 191 diagnostics over 27 files — matching the ledger
exactly — and zero of them name the new pin.

An earlier revision of this PR body reported "206 diagnostics, exactly the recorded
206" against TEST_DEBT['@objectstack/runtime'] in
scripts/check-type-check-coverage.mjs. That reading was correct on the pre-merge
base and is retired: #14785 graduated that entry out of the per-package ledger and
replaced it with the per-file one above. The paragraph is corrected rather than
deleted because the number moved (206 to 191) and the instrument changed, and a stale
figure in a PR body is what sends the next reader to the wrong gate.

objectql sites, named as the stop condition requires

None needed. The honest fix did not require an objectql change: the flat-input
proxy (hook-wrappers.ts:582-660) and the recorder (hook-write-provenance.ts:163+)
both behave correctly once the write-back stops asserting untouched keys. The only
objectql behaviour this changes is what those two now observe, which is the point.

For the record, the sites this fix reads against and leaves untouched: the
hook-wrappers.tsset trap that routes into data, its deleteProperty trap at
:645, and the hook-write-provenance.tsset and deleteProperty traps.

Card premises

The card's own #12277 premise is inaccurate as written, and the grading comment's
first reading of it was withdrawn; the withdrawal is what this PR implements —
deletion on the sandbox path works today through both traps, and the fix preserves
it. Everything else in the card re-measured true at origin/main.

Draft, not auto-merge: the PM seat lands this.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

`applyMutationsToInput` re-asserted every key of the post-run `ctx.input`
dump onto the engine's flat-input Proxy, whose `set` trap the hook-write
provenance recorder watches. A body that touched nothing therefore "wrote"
every payload key, which made the per-row divergence refusal on a
`multi: true` update blind in one of the two driver row orders.
The QuickJS runner now arms a write recorder on `ctx.input` for hook bodies
and reports the keys the body assigned, defined or deleted; the write-back
re-asserts only those. The absence-from-dump deletion leg is unchanged, a
write made through a value read from the input is still carried from the
dump, and an unavailable recorder falls back to the full assign.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ts the corruption
Without it the ablated run stops at "no refusal was raised" and never reaches
the row-state assertions, so the moved `completed_at` -- the defect itself --
is not reported by the pin that exists for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 8 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via applyMutationsToInput (symbol, a top-level function))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 24 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5a18702f1b33556ace420e8d518df1971232fcda — the merge of head 949a0a7f6be50916358740385927d88e28c66b06 into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, 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 5a18702f1b33556ace420e8d518df1971232fcda && git checkout 5a18702f1b33556ace420e8d518df1971232fcda
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 949a0a7f6be50916358740385927d88e28c66b06 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 949a0a7f6be50916358740385927d88e28c66b06
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM seat — standing down on this PR's only red, because it is not this PR's. Recording the basis rather than asserting it.

The failing check

Test Core (1/6), run 33714253227, job 100520090449, on the merge head e149585290:

[integration] test/run-dev-unbuilt-workspace.e2e.test.ts
> the mirror direction: a reader that is never coming back
> gives up and exits instead of waiting forever
cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design);
this child ran 180103 ms; case 1 measured the same child at 7046 ms on this runner minutes earlier
expected 'SIGKILL' to be null
Test Files 1 failed | 235 passed (236) · Tests 1 failed | 2734 passed (2735)
check-test-completeness: OK (2735 declared, all accounted for)

Why it is not this PR's

Verified against the tree rather than taken from the report:

$ git diff --stat origin/main..e149585290
.changeset/sandbox-hook-input-writeback-key-set.md | 50 ++
packages/runtime/src/sandbox/body-runner.ts | 135 ++-
packages/runtime/src/sandbox/…integration.test.ts | 283 ++
packages/runtime/src/sandbox/quickjs-runner.ts | 154 ++-
packages/runtime/src/sandbox/script-runner.ts | 28 ++

Five files, all packages/runtime/src/sandbox/** plus the changeset. The failing test is in packages/cli, which this diff does not touch, and its subject is os dev over an unbuilt workspace — a surface with no path to the sandbox hook write-back.

⇒ Carded as #14832 (bug · priority:p1 · domain:cli): an intermittent hang in os dev, not a flaky bound. The test's own discriminator settles it — calibration 7046 ms, this run 180103 ms, same runner, same child, minutes apart, against a bound that is now constant with no load-derived term. ⛔ No fix exists to port yet, so there is nothing to bring into this PR; #14832 owns it and names the three tempting non-fixes it forbids (raising the timeout, re-deriving the cap, skipping/quarantining/retrying).

Spending the one re-run allowed for a failure established as not-this-PR's. The hang is intermittent — the same child calibrated at 7 s minutes earlier in the same job — so a re-run is a real reading, not a wish. If it fails again it is still #14832's and this PR is not held for it.

Merge follow-through — verified, and the ledger is untouched

This PR was sent back one round because its branch predated #14785, which replaced the instrument it had measured itself with. That is resolved:

  • e149585290 is a merge of origin/main (⛔ not a rebase), carrying both e6ac0c6fd5 (fix(runtime): wire the test layer into check:test-typecheck, so its 214 test files are type-checked at all (#14504) #14785) and accb9231c7 (test(cli): make the unread-reader ceiling a load-independent constant at RUN_TIMEOUT_MS #14715).
  • pnpm --filter @objectstack/runtime typecheckby name, exit 0, its own verdict: "check:test-typecheck: OK — @objectstack/runtime test layer compiles under packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned signature(s)".
  • ⭐ The new 283-line unledgered pin carries ZERO type errors — so nothing was fixed and, critically, nothing was ledgered. Confirmed independently: git diff origin/main..HEAD over test-typecheck-debt.json, tsconfig.test.json, package.json and scripts/check-type-check-coverage.mjs is empty.
  • Program membership measured, not assumed — a green over a file tsc never read would say nothing: tsc --listFiles -p tsconfig.test.json puts the new pin in the program (1 hit) and all three edited source files too (3 hits), and 0 of the 191 diagnostics name the pin.
  • The body's 206 / TEST_DEBT paragraph is corrected in place rather than deleted — both the instrument and the number moved, and a silently dropped paragraph would leave no trace of which gate was read. Right call.

One declared narrowing, accepted: the whole-package vitest run (215 files / 3126 tests) is the pre-merge measurement, carried forward on three checkable facts — the population comes from the tool's own config; git diff --name-only ae0b1d702b HEAD over src, test and all three vitest configs returns 0 files; and the merge's only three changes inside packages/runtime are package.json, tsconfig.test.json and test-typecheck-debt.json, none of which vitest reads and all of which check:test-typecheck does read — and that ran green on the merged head. ✅ A carried-forward reading with its invariance argued from the tool's own inputs is a measurement; a carried-forward reading with no argument is a guess. This is the former.

It was a narrowing rather than a wait because the shared verify lock was held continuously by another seat for 20+ minutes across two full queue budgets (still holding at 1225 s). Reported as a lock observation, ⛔ not acted on — correct; the lock is not a thing to route around.

Flipping ready and arming.


Generated by Claude Code

@os-project-manager
os-project-manager added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 84199cbSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14758-sandbox-hook-write-back-key-set branch September 3, 2026 10:50
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-trump@os-project-manager@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(runtime): carry back only the keys a sandboxed hook body wrote - #14826

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-14758-sandbox-hook-write-back-key-set
Sep 3, 2026
Merged

fix(runtime): carry back only the keys a sandboxed hook body wrote#14826
os-project-manager merged 4 commits into
mainfrom
claude/issue-14758-sandbox-hook-write-back-key-set

Conversation

@os-trump

@os-trumpos-trump commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14758

The sandbox hook write-back now carries back the keys the body wrote, not every
key the post-run dump can see — so #14099's per-row divergence refusal is true on the
shipped hook-body path in either driver row order.

Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

The mechanism, and what moved

applyMutationsToInput ended in Object.assign(target, mutatedInput), and
mutatedInput is the readCtxInputJson dump of the wholectx.input. target
is the engine's flat-input Proxy, so each of those assignments is a set the #14088
provenance recorder records. A body that touched nothing therefore reported writing
every payload key.

Under D3 all matched rows share ONE payload, so the noise was order-dependent.
Measured here on this branch by reverting the fix in place (ablation below), with
#14099's own fixture driven through a real QuickJS body:

dispatch orderrow 1 windowrow 2 windowrefused?
already then open{status}{status,completed_at}yes
open then already{status,completed_at}{status,completed_at}NO

In the second order the already-done row inherits completed_at from the
transitioning row's write onto the shared payload, the blanket write-back re-asserts
it as that row's own write, the windows match, and the batch proceeds — landing
#14099's original corruption on a row that never transitioned.

The QuickJS runner now arms an input write-recorder on ctx.input for hook bodies —
the readRecordWritesJson / __recordWrites recorder shape from #4345 — and reports
the keys the body assigned, defined or deleted; the write-back re-asserts only those.

What was deliberately NOT changed

  • The deletion leg at body-runner.ts:555-558 is untouched. It was never
    expressed by the merge: it reads deletion from the ENTRY snapshot as
    absence-from-the-dump, before both merges, and the docblock at :538-541 states
    that ordering deliberately. A key the body never touched is present in the dump and
    so is never deleted. Narrowing the carry-back to keys-the-body-assigned without the
    deletion leg would have dropped a leg that works today — pinned by case 4 below.
  • The empty-array-on-escape branch was NOT copied. For input the branches
    invert: an empty key set is a trustworthy answer (armed, saw no write) and does
    narrow; the untrustworthy answer is an ABSENT key set, and the host falls back to
    today's full Object.assign on it.
  • packages/objectql is untouched. The flat-input proxy and the stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088 recorder
    are correct as written; they were being fed noise. No objectql change is required —
    see the objectql-sites section below.

One leg the recorder alone cannot cover, named rather than hidden

A body that writes THROUGH a value it read — ctx.input.meta.x = 1 — trips no trap
on ctx.input itself, so the recorder cannot list meta, and dropping it would be
exactly the silent loss this card exists to end. carriedInputKeys reads it from the
dump the narrowest way available: an object-valued entry key whose dumped value no
longer matches the entry snapshot
was written through, and is carried. Primitives
need no such leg — a primitive cannot be mutated in place, so every change to one is
an assignment the recorder already saw. That confinement is what keeps this from
re-widening into the value comparison #14099's ruling refused.

File face

packages/runtime/src/sandbox/ only — domain:cli, one lane.

file+/-
packages/runtime/src/sandbox/quickjs-runner.ts+153 / -1
packages/runtime/src/sandbox/body-runner.ts+134 / -1
packages/runtime/src/sandbox/script-runner.ts+28 / -0
packages/runtime/src/sandbox/hook-input-writeback-key-set.integration.test.ts+283 (new)
.changeset/sandbox-hook-input-writeback-key-set.md+50 (new)

origin/main was merged in at e149585290 (a merge, not a rebase). That merge
brought no change to packages/runtime/src/ — only package.json, the new
tsconfig.test.json and the new test-typecheck-debt.json from #14785. The file face
above is unchanged by it.

Measurements

All numbers below are from the merged head e149585290, which is this PR's head,
except the one row explicitly marked pre-merge.

The pin — real ObjectQL + real SqlDriver (better-sqlite3) + real
QuickJSScriptRunner behind hookBodyRunnerFactory, the probe's own shape.
Four
cases; the dispatch order is asserted from inside the bodies rather than assumed:

  1. transition stamp, open then already — refused, MULTI_UPDATE_HOOK_KEY_DIVERGENCE
    with status 400 and the diverging key list naming completed_at, neither row
    written;
  2. transition stamp, already then open — refused, same envelope;
  3. a row-INVARIANT sandboxed body — not refused, and its write lands on every
    matched row (the over-narrowing guard the grading comment made mandatory);
  4. a row-invariant sandboxed delete ctx.input.internal_note — still propagates.

On the merged head: 4 passed.

Ablation (the fix reverted in place, restore proved by hash). The three edited
source files were checked out from origin/main; the mutation was proved on disk by
git hash-object matching the origin/main blob and differing from the HEAD blob,
plus zero occurrences of carriedInputKeys and __inputWrites. No build was involved
— the pin imports these files from src by relative path. Result: 1 failed, 3 passed
of 4, and the failure reports both halves of the finding:

expected undefined to be defined
...no refusal was raised
AssertionError: expected 2026-09-03T09:00:00.000Z to be 2026-01-01T00:00:00.000Z
...and the stamp MOVED on the row that never transitioned

Restored from HEAD, git diff HEAD empty and all three blobs hash-equal to HEAD,
the same four cases read 4 passed.

Suites and gates:

runresult
the four-case pin, merged head4 passed
pnpm --filter @objectstack/runtime exec vitest run src/sandbox/, merged head18 files, 181 passed
pnpm --filter @objectstack/runtime typecheck (now chains check:test-typecheck), merged headexit 0, verdict below
pnpm --filter @objectstack/runtime test (whole package) — pre-merge, carried forward under the declared narrowing below215 files, 3126 passed
pnpm --filter @objectstack/dogfood test (real shipped hook bodies)130 passed, 1 skipped; 1011 tests passed, 3 skipped
pnpm exec eslint . --no-inline-config (whole repo, not narrowed)5779 files, 0 errors, 0 warnings
derived gate families (scripts/pm/dispatch-gates.mjs --commands)32 of 34 green, 2 NOT MEASURED (below)
pnpm check:dual-build-cjs-loads (after the full workspace build)exit 0 — 102 require entry points across 66 packages load; 610 CJS files parse
pnpm check:nul-bytesexit 0 — scanned 8066 text files; no raw ASCII control bytes

Every gate exit code was captured by redirecting to a file first and reading the
status before any pipe, and the verdicts quoted are the gates' own printed lines.

The one declared narrowing, stated so it can be checked rather than trusted. The
whole-package vitest run was measured before the merge, not after. Three facts
close the gap:

  1. the population is read from the tool's own config, not guessed — the package's
    test script is a bare vitest run, whose inputs are
    packages/runtime/vitest.config.ts and packages/runtime/src/**;
  2. git diff --name-only ae0b1d702b HEAD over each of those paths — src, test,
    the package vitest config and both root vitest configs — returns 0 files;
  3. the merge's only three changes inside packages/runtime are package.json (the
    typecheck script, not test), tsconfig.test.json and
    test-typecheck-debt.json. Vitest reads none of them; check:test-typecheck
    reads all three, and it was run separately on the merged head, green.

So the source under test is byte-identical either side of the merge, and the parts
that actually exercise this change were re-run on the merged head anyway (the pin, and
the whole sandbox directory). This was a narrowing rather than a wait because the
shared verify lock was held continuously by another seat for over 20 minutes across
two full queue budgets.

The two NOT MEASURED families are structural, not red:

  • node scripts/check-test-completeness.mjs — the family list names it with no
    argument, and the gate itself prints that the local reading for it is NOT MEASURED
    and is not a red;
  • node scripts/pm/check-half-states.mjs — a PM-board gate that needs an issue
    listing this container cannot make; it prints the same NOT MEASURED refusal and is
    unrelated to this diff.

The test-layer type check, on the instrument that is actually live

The new pin is a 283-line test file, and #14785 changed what reads it. On the
merged tree packages/runtime's typecheck is no longer a bare tsc --noEmit: it
chains check:test-typecheck, which compiles the test layer under the new
packages/runtime/tsconfig.test.json against the EXACT, shrink-only, identity-pinned
packages/runtime/test-typecheck-debt.json. An unledgered file with even one error is
red, and this pin is unledgered.

pnpm --filter @objectstack/runtime typecheck on the merged head, exit 0:

check:test-typecheck: OK — @objectstack/runtime's test layer compiles under
packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned
signature(s) held in test-typecheck-debt.json (shrink-only and identity-pinned)

So the new pin carries zero type errors, and nothing was ledgered: git diff origin/main over test-typecheck-debt.json, tsconfig.test.json, package.json and
scripts/check-type-check-coverage.mjs is empty. No any, no @ts-expect-error, no
disable comment and no weakened assertion was added to reach that.

A green here would say nothing if tsc never read the file, so that was measured rather
than assumed. tsc --noEmit --listFiles -p tsconfig.test.json puts the new pin in the
program (1 hit) along with all three edited source files (3 hits), 913 .ts files in
total; the raw run reports 191 diagnostics over 27 files — matching the ledger
exactly — and zero of them name the new pin.

An earlier revision of this PR body reported "206 diagnostics, exactly the recorded
206" against TEST_DEBT['@objectstack/runtime'] in
scripts/check-type-check-coverage.mjs. That reading was correct on the pre-merge
base and is retired: #14785 graduated that entry out of the per-package ledger and
replaced it with the per-file one above. The paragraph is corrected rather than
deleted because the number moved (206 to 191) and the instrument changed, and a stale
figure in a PR body is what sends the next reader to the wrong gate.

objectql sites, named as the stop condition requires

None needed. The honest fix did not require an objectql change: the flat-input
proxy (hook-wrappers.ts:582-660) and the recorder (hook-write-provenance.ts:163+)
both behave correctly once the write-back stops asserting untouched keys. The only
objectql behaviour this changes is what those two now observe, which is the point.

For the record, the sites this fix reads against and leaves untouched: the
hook-wrappers.tsset trap that routes into data, its deleteProperty trap at
:645, and the hook-write-provenance.tsset and deleteProperty traps.

Card premises

The card's own #12277 premise is inaccurate as written, and the grading comment's
first reading of it was withdrawn; the withdrawal is what this PR implements —
deletion on the sandbox path works today through both traps, and the fix preserves
it. Everything else in the card re-measured true at origin/main.

Draft, not auto-merge: the PM seat lands this.

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza

`applyMutationsToInput` re-asserted every key of the post-run `ctx.input`
dump onto the engine's flat-input Proxy, whose `set` trap the hook-write
provenance recorder watches. A body that touched nothing therefore "wrote"
every payload key, which made the per-row divergence refusal on a
`multi: true` update blind in one of the two driver row orders.
The QuickJS runner now arms a write recorder on `ctx.input` for hook bodies
and reports the keys the body assigned, defined or deleted; the write-back
re-asserts only those. The absence-from-dump deletion leg is unchanged, a
write made through a value read from the input is still carried from the
dump, and an unavailable recorder falls back to the full assign.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ts the corruption
Without it the ablated run stops at "no refusal was raised" and never reaches
the row-state assertions, so the moved `completed_at` -- the defect itself --
is not reported by the pin that exists for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 3, 2026
@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 8 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via applyMutationsToInput (symbol, a top-level function))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 24 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 f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5a18702f1b33556ace420e8d518df1971232fcda — the merge of head 949a0a7f6be50916358740385927d88e28c66b06 into base f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8, 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 5a18702f1b33556ace420e8d518df1971232fcda && git checkout 5a18702f1b33556ace420e8d518df1971232fcda
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 949a0a7f6be50916358740385927d88e28c66b06 && git checkout -B drift-repro f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 && git merge --no-ff 949a0a7f6be50916358740385927d88e28c66b06
node scripts/docs-audit/affected-docs.mjs --json f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f116b8f8d1b43ec2bfd64fbf0ebb0cf3c301b1c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-trumpClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM seat — standing down on this PR's only red, because it is not this PR's. Recording the basis rather than asserting it.

The failing check

Test Core (1/6), run 33714253227, job 100520090449, on the merge head e149585290:

[integration] test/run-dev-unbuilt-workspace.e2e.test.ts
> the mirror direction: a reader that is never coming back
> gives up and exits instead of waiting forever
cap 180000 ms (RUN_TIMEOUT_MS, constant and load-independent by design);
this child ran 180103 ms; case 1 measured the same child at 7046 ms on this runner minutes earlier
expected 'SIGKILL' to be null
Test Files 1 failed | 235 passed (236) · Tests 1 failed | 2734 passed (2735)
check-test-completeness: OK (2735 declared, all accounted for)

Why it is not this PR's

Verified against the tree rather than taken from the report:

$ git diff --stat origin/main..e149585290
.changeset/sandbox-hook-input-writeback-key-set.md | 50 ++
packages/runtime/src/sandbox/body-runner.ts | 135 ++-
packages/runtime/src/sandbox/…integration.test.ts | 283 ++
packages/runtime/src/sandbox/quickjs-runner.ts | 154 ++-
packages/runtime/src/sandbox/script-runner.ts | 28 ++

Five files, all packages/runtime/src/sandbox/** plus the changeset. The failing test is in packages/cli, which this diff does not touch, and its subject is os dev over an unbuilt workspace — a surface with no path to the sandbox hook write-back.

⇒ Carded as #14832 (bug · priority:p1 · domain:cli): an intermittent hang in os dev, not a flaky bound. The test's own discriminator settles it — calibration 7046 ms, this run 180103 ms, same runner, same child, minutes apart, against a bound that is now constant with no load-derived term. ⛔ No fix exists to port yet, so there is nothing to bring into this PR; #14832 owns it and names the three tempting non-fixes it forbids (raising the timeout, re-deriving the cap, skipping/quarantining/retrying).

Spending the one re-run allowed for a failure established as not-this-PR's. The hang is intermittent — the same child calibrated at 7 s minutes earlier in the same job — so a re-run is a real reading, not a wish. If it fails again it is still #14832's and this PR is not held for it.

Merge follow-through — verified, and the ledger is untouched

This PR was sent back one round because its branch predated #14785, which replaced the instrument it had measured itself with. That is resolved:

  • e149585290 is a merge of origin/main (⛔ not a rebase), carrying both e6ac0c6fd5 (fix(runtime): wire the test layer into check:test-typecheck, so its 214 test files are type-checked at all (#14504) #14785) and accb9231c7 (test(cli): make the unread-reader ceiling a load-independent constant at RUN_TIMEOUT_MS #14715).
  • pnpm --filter @objectstack/runtime typecheckby name, exit 0, its own verdict: "check:test-typecheck: OK — @objectstack/runtime test layer compiles under packages/runtime/tsconfig.test.json; 27 file(s) / 191 error(s) / 69 pinned signature(s)".
  • ⭐ The new 283-line unledgered pin carries ZERO type errors — so nothing was fixed and, critically, nothing was ledgered. Confirmed independently: git diff origin/main..HEAD over test-typecheck-debt.json, tsconfig.test.json, package.json and scripts/check-type-check-coverage.mjs is empty.
  • Program membership measured, not assumed — a green over a file tsc never read would say nothing: tsc --listFiles -p tsconfig.test.json puts the new pin in the program (1 hit) and all three edited source files too (3 hits), and 0 of the 191 diagnostics name the pin.
  • The body's 206 / TEST_DEBT paragraph is corrected in place rather than deleted — both the instrument and the number moved, and a silently dropped paragraph would leave no trace of which gate was read. Right call.

One declared narrowing, accepted: the whole-package vitest run (215 files / 3126 tests) is the pre-merge measurement, carried forward on three checkable facts — the population comes from the tool's own config; git diff --name-only ae0b1d702b HEAD over src, test and all three vitest configs returns 0 files; and the merge's only three changes inside packages/runtime are package.json, tsconfig.test.json and test-typecheck-debt.json, none of which vitest reads and all of which check:test-typecheck does read — and that ran green on the merged head. ✅ A carried-forward reading with its invariance argued from the tool's own inputs is a measurement; a carried-forward reading with no argument is a guess. This is the former.

It was a narrowing rather than a wait because the shared verify lock was held continuously by another seat for 20+ minutes across two full queue budgets (still holding at 1225 s). Reported as a lock observation, ⛔ not acted on — correct; the lock is not a thing to route around.

Flipping ready and arming.


Generated by Claude Code

@os-project-manager
os-project-manager added this pull request to the merge queueSep 3, 2026
Merged via the queue into main with commit 84199cbSep 3, 2026
34 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-14758-sandbox-hook-write-back-key-set branch September 3, 2026 10:50
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

3 participants

@os-trump@os-project-manager@claude