Uh oh!
There was an error while loading. Please reload this page.
Make delete ctx.input.x in a hook actually remove the field — the flat-input Proxy trap and the sandbox write-back - #12396
Conversation
… hook persists `delete ctx.input.x` in a hook was a no-op on both execution paths while an assignment on the same object in the same call landed. In-process: `installFlatInput`'s flat-record Proxy trapped get/set/has/ownKeys/ getOwnPropertyDescriptor but not `deleteProperty`, so the delete fell through to the WRAPPER one level above `data` and returned true. `defineProperty` had the same gap and the worse shape — the `get` trap's fall-through read the value back, so the read-back CONFIRMED a write the record never received. Both now route into `data`, like `set`. Sandboxed: `applyMutationsToInput` wrote a QuickJS body's mutations home with `Object.assign`, which cannot represent a removal. Keys the VM deleted are now diffed against the entry snapshot, filtered through the same JSON lens the sandbox boundary uses so a key that never crossed cannot be destroyed on its absence. Both halves land together: closing one alone would make the same authored `delete` behave differently in-process than in the sandbox. Card: #12277 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 31 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ef069dba18c434eac0f68c12fa12a2fe8c76ed9d && git checkout ef069dba18c434eac0f68c12fa12a2fe8c76ed9d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 078bdcdcb66adf73e4d6dec9c0613234cf08e6a4 6c568d6bb98badadc39fdea621b909ccc7ba83e3 && git checkout -B drift-repro 078bdcdcb66adf73e4d6dec9c0613234cf08e6a4 && git merge --no-ff 6c568d6bb98badadc39fdea621b909ccc7ba83e3
node scripts/docs-audit/affected-docs.mjs --json 078bdcdcb66adf73e4d6dec9c0613234cf08e6a4
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12277
delete ctx.input.FIELDin a hook did nothing, on both execution paths, while an assignment made two lines above it on the same object in the same call landed normally. Nothing raised and nothing reported it. Both halves are closed here, because closing either one alone would make the same authoreddeletebehave differently depending on whether the body runs in-process or in QuickJS.What was measured, and one correction to the card
Reproduced before touching anything, at
wrapDeclarativeHook/hookBodyRunnerFactorydirectly.In-process (
installFlatInput,packages/objectql/src/hook-wrappers.ts) — the card's mechanism is exactly right, its severity gloss is not. The card says "every read-back confirms the delete succeeded". Measured, that is false here, and the card's own table already showed it: onlydelete's return value lied.delete input.owner_idtruetrue'owner_id' in inputtruefalseinput.owner_id"CALLER-VALUE"undefinedObject.keys(input)['title','owner_id','note']['title','note']{...input}owner_idObject.getOwnPropertyDescriptorundefinedowner_id: "CALLER-VALUE"So an author who checked with anything other than the return value would have seen the no-op. The defect is real and the mechanism is as reported; the "no instrument can catch it" framing belongs to the other two findings below.
Sandboxed (
applyMutationsToInput,packages/runtime/src/sandbox/body-runner.ts) — this is where the confirming read-back actually lives. Note the file has moved since the card's addendum was written (packages/runtime/src/sandbox/, notpackages/objectql/src/); relocated by symbol. Inside QuickJS the delete is real — the body holds a JSON snapshot. What was lost is the trip home:Object.assigncopies own enumerable properties and cannot represent a removal.Every instrument reachable from inside the body confirms the removal, the assignment in the same call lands, and the field is stored anyway.
The discriminator held on both paths. Assign a key and then delete it, and the assigned value survived pre-fix (
"ASSIGNED-THEN-DELETED"where the caller sent"CALLER-VALUE"), which rules out a{...callerData, ...hookInput}merge. Post-fix the key is simply absent, which rules it out from the other side.The trap set, enumerated — including what is not fixed here
installFlatInput's Proxy implementedget/set/has/ownKeys/getOwnPropertyDescriptor. Every mutation JS offers has to land indata—datais the object the engine persists — and onlysetdid.deleteProperty— missing. Fixed. The card.defineProperty— missing, same class, worse shape. Fixed in the same stroke (bounded, and named here with its evidence rather than slipped in). It defined on the wrapper, and thegettrap's fall-through to the wrapper then read the value straight back:Object.defineProperty(input, 'defined_key', ...)followed byinput.defined_keyreturned'DEFINED'whileObject.keys(input)denied it anddatanever received it. That is the read-back-confirms-a-write-that-never-happened shape, unreported, in the same six lines as the reported one. Its correct form is pinned byset's existing precedent, no other claim is on the file, and it adds no gate family.configurable: falsedescriptor its target does not carry, soObject.defineProperty(input, 'x', { value: 1, configurable: false })now throws aTypeErrorwhere it used to define, silently and uselessly, on the wrapper. Omittingconfigurable— the common spelling, and what spread andObject.assignproduce — is unaffected.getOwnPropertyDescriptor— present, and NOT changed. Reported, not fixed (filed as [finding] The flat-input Proxy'sgetOwnPropertyDescriptorsynthesises a descriptor instead of mirroringdata's — now reachable, since #12277 routeddefinePropertyintodata#12397). For a key ondatait synthesises{ configurable: true, enumerable: true, writable: true, value }rather than mirroringdata's real descriptor.configurable: trueis forced by a proxy invariant (the target does not carry the key), so it cannot simply mirror; and for every key created by assignment the synthesised descriptor is already the true one. The residual is narrow — a key defined ondatawith non-default attributes is described inaccurately — and widening the trap to chase it is a separate change with its own accessor-descriptor questions.set— checked for the same class, and it is clean. It returnstrueunconditionally, but the write it reports on is a strict-mode assignment intodatathat throws rather than failing quietly, so there is no silent-success limb.installFlatInputis the onlynew Proxyinpackages/objectql/src.ctx.previousis not proxied at all; it reaches a handler as the plain payloadpickPreviousPayloadbuilds. So there is no second surface for this fix to cover.Clause ②: who is relying on the no-op today
Nobody in this repo.
delete ctx.input.FIELD/delete input.FIELDreturns zero hits across every file type (--exclude-dir=node_modules --exclude-dir=.git --exclude-dir=dist), as does a sweep ofexamples/**hook modules and of authored hook bodies. Positive control for that search: the same walk fordelete IDENT.MEMBERmatches 39 files (e.g.delete process.env.NODE_PATHinpackages/types/src/node.test.ts), so the zero is a measurement rather than a broken grep.The only measured consumers are external: the guest-intake app behind the card stripped staff-only fields with fifteen
deletestatements, every one inert, and its unit tests stayed green because they drive the handler with a plain object wheredeletegenuinely works. That app-side repair is already done and does not depend on this.The direction the sandbox write-back deliberately does not overreach in
Absence from the exit snapshot is the only evidence a deletion leaves, and alone it is ambiguous: a key whose host value is
undefined(or a function, or a symbol) never survivedJSON.stringifyinto the VM either, so it is missing from the dump without anyone having deleted it. The diff is filtered through the same JSON lens the boundary uses, so such a key is left alone, and every failure mode of that probe is conservative — an unprobeable key is simply not deletable. Losing a delete is recoverable; destroying a field on evidence that was never there is not. One residual miss is named in the code rather than left to be discovered: abigint-valued key crosses into the VM as a string but is dropped by the probe, so deleting one is still lost.Verification
All of the below at
6c568d6bb9, working tree clean, foreground, exit codes captured before any pipe.Suites —
pnpm --filter @objectstack/objectql exec vitest run:Test Files 236 passed (236)/Tests 4180 passed (4180).pnpm --filter @objectstack/runtime exec vitest run:Test Files 194 passed (194)/Tests 2856 passed (2856).typecheckgreen for both (tsc --noEmit).Gate union derived, not recalled —
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived after the changeset existed: 19 matched families + the test-file and changeset conventions. All green, each quoting its own verdict line:check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 242.1s, 1843 raw tsc error(s) total, none above its recorded number./surplus: none — every entry sits exactly at its measurement, so any new error is red.(run on the built closure, so the two new test files really were measured)check-nul-bytes: OK (scanned 6855 text file(s) ...)check-engine-double-contract: 397 (file, verb) row(s) held by the RETAINED ledgerwhere-matcher conformance holds: 302 matcher(s) discovered, 302 answer the combinator battery correctly or refuse itquery-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none newcheck-test-source-alias OK — 72 packages with tests scannedcross-package-test-inputs,ci-filter-parity,durability-log-level,page-declaration-shape,published-files,slot-lookup,type-source-resolution,engine-split-ratio,plugin-teardown-shape,affected-docs,drift-comment,objectui-changeset,changeset-gate-self-tests,changeset-no-major,empty-changeset,release-rehearsal-clone.Repo-wide lint, not narrowed —
pnpm lint(eslint . --no-inline-config) over the whole tree, 62s, exit0recorded before any pipe. No narrowing is claimed and none is owed.Ablation — three legs, direction predicted in writing before the run, implementation committed first. All three mutate a file its own package's pin imports relatively (
./hook-wrappers.js,./body-runner.js), i.e. through vitest's TS source resolution and not throughexports->dist, so no rebuild is owed between mutation and measurement and the reds are evidence about the mutation rather than about a stale artifact. Each leg proved the mutation on disk with single-linegrep -cFanchor counts (1 -> 0) plusgit diff --numstat, and each restore leg proved absence the same way (0 -> 1,git diffempty) and re-ran green. The ablation script carriedtrap ... EXIT INT TERMso a foreground timeout could not leave the tree mutated.deletePropertyexpected true to be falseon'owner_id' in input, plus the stored row and the discriminatordefinePropertyinKeys) — line 163,expect(seen.propertyRead).toBe('DEFINED'), passed'internal_notes' in engineCtx.input) — the four in-VM read-backs above it passedLegs B and C are the ones that matter for the grading: in both, the pin fires only because it asserts the read-back and the stored row together. A pin asserting either half alone would have stayed green on the confirming-read-back defect. (A first attempt at leg C was voided and re-run rather than quietly retried: its cut was structural rather than surgical and the file failed to transform, so
vitestexited 1 onno tests— a red for the wrong reason. Itsgrepanchor also read0before the mutation because a BRE turned[key]into a character class; the re-run usesgrep -cFthroughout.)Changeset
.changeset/hook-input-delete-lands.md, gradedminorfor both@objectstack/objectqland@objectstack/runtimerather thanpatch, and the grade is the argument: this moves data that reaches downstream consumers. Any shipped hook already containingdelete ctx.input.FIELDhas been a no-op and starts taking effect on upgrade. No API is removed and no accept set narrows, so it is not declared breaking — but it must not arrive as a silent patch either.Serial constraints
Surface is
packages/objectql/**andpackages/runtime/**only. It touches none ofpackages/drivers/driver-sql/**,packages/platform-objects/**, orpackages/metadata/**.Generated by Claude Code