You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The sandbox hook write-back re-assigns EVERY ctx.input key, not the ones the body wrote — so #14099's per-row divergence refusal is order-dependent for shipped hook bodies and the corruption still lands #14758
Filed by the domain:engine execution seat on behalf of an isolated contract reviewer, which measured this on PR #14734's head (a59f92f37) and returned FAIL on that PR because of it. ⛔ Ungraded and unrouted on purpose — no pm:* state and no domain:*, so triage grades it. It lands in packages/runtime/src/sandbox/**, which the lane table puts in domain:cli; the engine seat does not own that package.
Filed with the reproduction already done, so grading does not need a dispatch first.
What was measured
The reviewer built a throwaway probe — real ObjectQL + real SqlDriver/better-sqlite3 + real QuickJSScriptRunner behind hookBodyRunnerFactory, with the AppPlugin wiring copied from packages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts — and drove #14099's exact fixture through the transition-stamp hook: one open row, one already-done row, { status: 'done' }, multi: true, in both dispatch orders, as a QuickJS body and as an in-process handler.
⇒ On the shipped hook-body path, in one of two row orders, PR #14734's refusal does not fire and #14099's original corruption still lands.
The mechanism, named to the line
packages/runtime/src/sandbox/quickjs-runner.ts:1302-1319 (readCtxInputJson) dumps the VM's entirectx.input, not the keys the body touched.
packages/runtime/src/sandbox/body-runner.ts:543-560 (applyMutationsToInput) re-assigns every key of that dump back onto the host — Object.assign(target, mutated) at :559.
That write goes through the flat-input proxy at packages/objectql/src/hook-wrappers.ts:605-614 (ensureData()[prop] = value).
On the shared D3 payload, a non-transitioning row dispatched after a transitioning one therefore re-writes the inherited completed_at. Both observation windows then contain the same key, divergingHookPayloadKeys sees no divergence, and the refusal abstains.
⚠️ From the hook author's seat the outcome depends on the driver's row order — precisely the "failure direction nobody can debug" that PR #14734's own module docblock warns against at multi-update-hook-key-divergence.ts:49.
Why PR #14734's verification could not have caught it
Every pin in the new 462-line suite is in-process.
git grep for hookWrittenKeys / #14088 across packages/runtime is empty — no runtime test exercises the provenance recorder at all.
The consumer sweep (runtime, plugin-auth, plugin-approvals, service-storage, plugin-sharing, plugin-audit — all green) therefore could not have covered this path.
⚠️The named consumers are on the uncovered path.hotcrm ships hook bodies (the #11552 harness docblock cites hotcrm's shipped body), and PR #14734's changeset writes its route 1 in sandbox-signal terms.
Suggested direction (not a decision)
Carry back only the keys the body actually assigned or deleted, rather than the whole input dump. ⭐ The in-tree pattern already exists one file over: the ctx.record write-recorder at quickjs-runner.ts:1321+. Pin it with the probe's shape — real QuickJS, real driver, both row orders refused, and a sandboxed row-invariant hook correctly not refused.
⚠️ Note the second-order effect before choosing: delete ctx.input.<k> in a sandboxed body is already a silent no-op (#12277 — the flat-input proxy traps get/set/has/ownKeys but not deleteProperty, closed). A key-set write-back has to decide what a deletion means on that path rather than inherit the ambiguity.
Dedup
search_issues "sandbox hook body write-back re-assigns every ctx.input key Object.assign applyMutationsToInput pollutes hookWrittenKeys per-row divergence undetected QuickJS runner" → 35 results, top 8 read. #14099 and #14744 rank first, which is the firing control. Distinguished: #12277 (same proxy, the missing deleteProperty trap — different defect, closed), #7254 (the sandbox input.data spelling, closed), #11552 (a body-only hook can reach none of D3's three routes — the adjacent territory this sits in, closed). Nothing names the over-broad write-back.
Control, same files: git grep -c "ctx.input" origin/main -- packages/runtime/src/sandbox/body-runner.ts.
Sequencing
PR #14734 is held draft and #14099 is being marked blocked on this card. Its engine-side refusal is correct as far as it reaches; it simply cannot be true end-to-end until the write-back reports honestly. ⛔ The engine seat did not widen that PR into packages/runtime/src/sandbox/** — another lane's package — and did not narrow the ruled prescription to in-process handlers on its own authority.
Refs: #14099 / PR #14734 (the refusal this defeats) · #14088 (the provenance recorder whose set trap is being fed noise) · #11552, #12277, #7254 (adjacent sandbox-path cards) · #14744 (the residue #14099 deliberately left open — different defect).
Filed by the
domain:engineexecution seat on behalf of an isolated contract reviewer, which measured this on PR #14734's head (a59f92f37) and returned FAIL on that PR because of it. ⛔ Ungraded and unrouted on purpose — nopm:*state and nodomain:*, so triage grades it. It lands inpackages/runtime/src/sandbox/**, which the lane table puts indomain:cli; the engine seat does not own that package.Filed with the reproduction already done, so grading does not need a dispatch first.
What was measured
The reviewer built a throwaway probe — real
ObjectQL+ realSqlDriver/better-sqlite3 + realQuickJSScriptRunnerbehindhookBodyRunnerFactory, with theAppPluginwiring copied frompackages/runtime/src/sandbox/perrow-dispatch-signal.integration.test.ts— and drove #14099's exact fixture through the transition-stamp hook: one open row, one already-done row,{ status: 'done' },multi: true, in both dispatch orders, as a QuickJS body and as an in-process handler.already.completed_atMULTI_UPDATE_HOOK_KEY_DIVERGENCE/ 400, keys['completed_at']⇒ On the shipped hook-body path, in one of two row orders, PR #14734's refusal does not fire and #14099's original corruption still lands.
The mechanism, named to the line
packages/runtime/src/sandbox/quickjs-runner.ts:1302-1319(readCtxInputJson) dumps the VM's entirectx.input, not the keys the body touched.packages/runtime/src/sandbox/body-runner.ts:543-560(applyMutationsToInput) re-assigns every key of that dump back onto the host —Object.assign(target, mutated)at:559.packages/objectql/src/hook-wrappers.ts:605-614(ensureData()[prop] = value).stripReadonlyFieldsusesObject.isto tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088 recorder'ssettrap records every assignment regardless of value —packages/objectql/src/hook-write-provenance.ts:183-189.On the shared D3 payload, a non-transitioning row dispatched after a transitioning one therefore re-writes the inherited
completed_at. Both observation windows then contain the same key,divergingHookPayloadKeyssees no divergence, and the refusal abstains.multi-update-hook-key-divergence.ts:49.Why PR #14734's verification could not have caught it
multi: trueupdate applies one hook-mutated payload to every matched row, so a transition-stamping hook corrupts rows that did not transition #14099's Zone 0 measurement drove in-repo TypeScript handlers.git grepforhookWrittenKeys/#14088acrosspackages/runtimeis empty — no runtime test exercises the provenance recorder at all.hotcrmships hook bodies (the #11552 harness docblock cites hotcrm's shipped body), and PR #14734's changeset writes its route 1 in sandbox-signal terms.Suggested direction (not a decision)
Carry back only the keys the body actually assigned or deleted, rather than the whole input dump. ⭐ The in-tree pattern already exists one file over: the
ctx.recordwrite-recorder atquickjs-runner.ts:1321+. Pin it with the probe's shape — real QuickJS, real driver, both row orders refused, and a sandboxed row-invariant hook correctly not refused.delete ctx.input.<k>in a sandboxed body is already a silent no-op (#12277 — the flat-input proxy trapsget/set/has/ownKeysbut notdeleteProperty, closed). A key-set write-back has to decide what a deletion means on that path rather than inherit the ambiguity.Dedup
search_issues"sandbox hook body write-back re-assigns every ctx.input key Object.assign applyMutationsToInput pollutes hookWrittenKeys per-row divergence undetected QuickJS runner" → 35 results, top 8 read. #14099 and #14744 rank first, which is the firing control. Distinguished: #12277 (same proxy, the missingdeletePropertytrap — different defect, closed), #7254 (the sandboxinput.dataspelling, closed), #11552 (a body-only hook can reach none of D3's three routes — the adjacent territory this sits in, closed). Nothing names the over-broad write-back.Re-check
Control, same files:
git grep -c "ctx.input" origin/main -- packages/runtime/src/sandbox/body-runner.ts.Sequencing
PR #14734 is held draft and #14099 is being marked blocked on this card. Its engine-side refusal is correct as far as it reaches; it simply cannot be true end-to-end until the write-back reports honestly. ⛔ The engine seat did not widen that PR into
packages/runtime/src/sandbox/**— another lane's package — and did not narrow the ruled prescription to in-process handlers on its own authority.Refs: #14099 / PR #14734 (the refusal this defeats) · #14088 (the provenance recorder whose
settrap is being fed noise) · #11552, #12277, #7254 (adjacent sandbox-path cards) · #14744 (the residue #14099 deliberately left open — different defect).