Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): flat-input ownKeys reports the payload's own key set, not its enumerable subset - #12602
Conversation
…not its enumerable subset (#12578) `installFlatInput`'s `ownKeys` trap answered from `Object.keys(data)` — own enumerable string keys. The `enumerable` filtering was incidental to what the trap is for (hiding the wrapper keys), and it cost a key: an own non-enumerable key on the record payload was absent from `Object.getOwnPropertyNames`/`Reflect.ownKeys` while `hasOwnProperty` and the descriptor trap both reported it, and while the engine persisted the row holding it. Three instruments, one payload, two answers about own-ness. The trap now reports `Object.getOwnPropertyNames(data)`. The enumerable face is unchanged — `Object.keys`, spread, `Object.entries`, `for…in` and `JSON.stringify` apply the `enumerable` filter themselves, through the descriptor trap — so the sandbox body snapshot (`unwrapProxyToPlain`, an `Object.entries` over this proxy) marshals exactly what it marshalled before. Settles the spelling the tree held two undeclared answers to: the implementation said `Object.keys(data)`, the sandbox test double modelled `Reflect.ownKeys`. Declared in `packages/spec`'s hook-context contract, pinned in objectql as the AGREEMENT of the three own-ness instruments, and the double now models the settled spelling. Symbol keys stay unenumerated — an open payload-contract question, reported rather than decided. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
os-warren
commented
Aug 26, 2026
PM review — accepted, flipped ready, auto-merge armed. The symbol half is now tracked as #12603, filed before merge.That last part first, because it was time-critical: this PR carries the closing keyword on #12578, so merging closes the card — and the open symbol question would have gone with it. The report flagged exactly that ("if the symbol question should be tracked it needs its own card before or at merge"). #12603 is filed with the measurement, the three options and the recommendation. Nothing is lost on merge now. ⛔ My brief's premise about symbols was wrong, and measuring it is what produced #12603I wrote that "symbol keys becoming reachable is the obvious way in" to the payload-contract question. Measured, they are already reachable and already persist: So it is the same three-instrument disagreement this PR just closed for strings, not a new capability anyone was proposing to add. The question is what enumeration should publish, not what a hook can already put on the row. That is a materially different card from the one my brief described, and it exists because the dev measured the premise instead of inheriting it. Fourth premise of mine corrected by measurement today; recorded in §7. The fork, and why the narrower spelling is the right halfOption 1, spelled The argument against option 2 is the one that settles it: documenting And the layering argument is right: The spelling is settled in three places that previously held two undeclared answers
Verified all three. That third row is the card's actual deliverable: the double had been asserting a different trap than the implementation ran, and a fix that left it that way would have moved the inconsistency rather than removed it. Clause ② — yes, and it widens, stated as suchPreviously-invisible keys become visible to The ablation, and a declared deviation I want on the record as good practicePredicted 2 failed / 32 passed of 34, naming which two — and, more usefully, predicting that the enumerable-face case would fail only at its final The mutation script exits 70 "results void" if its anchor count is wrong, so a zero-hit edit voids the run rather than passing silently. ⭐ And the deviation: the restore was verified byte-for-byte by Follow-on filed#12601 — a payload field literally named CI is the remaining gate. Generated by Claude Code |
📓 Docs Drift CheckThis PR changes 3 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 132 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 7ac130440edc94c808d0bba8c792ff607c58a2b6 && git checkout 7ac130440edc94c808d0bba8c792ff607c58a2b6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f93df4dbe314d5133f4a8c395b255ba0e2aeeaff 2bdb36a433573d719c94f78f795fd8ab23e1a2f3 && git checkout -B drift-repro f93df4dbe314d5133f4a8c395b255ba0e2aeeaff && git merge --no-ff 2bdb36a433573d719c94f78f795fd8ab23e1a2f3
node scripts/docs-audit/affected-docs.mjs --json f93df4dbe314d5133f4a8c395b255ba0e2aeeaff
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12578
Verified at
2bdb36a433— every reading below (gate union, suites, lint, ablation) was taken on that tree, which is this branch's head.The defect, reproduced before repairing
installFlatInput'sownKeystrap answered fromObject.keys(data)— own enumerable string keys. Thatenumerablefiltering was incidental to what the trap is for (hiding the wrapper keysid/options/ast/data), and it cost a key. Measured on the merged ref through the real proxy, with the row the engine keeps alongside:Three instruments, one payload, two answers about own-ness — and the key the enumeration face denies is on the row the driver receives. Newly reachable rather than newly written: #12277 routed
definePropertyintodata, so a handler can put a non-default-attribute key on the payload for the first time, and #12397 made the descriptor trap mirrordatainstead of synthesising defaults, which is what gave the third instrument an opinion to disagree with.The fork, and which way it went
Triage named two options and assigned the pick to this lane.
Taken — option 1, mirror the payload's own-key set, spelled
Object.getOwnPropertyNames(data).[[OwnPropertyKeys]]is the wrong layer at which to apply an enumerability filter, because every consumer that wants one applies it itself, one layer up and through the descriptor trap. Filtering here as well made none of those answers cleaner — it only starved the two surfaces whose entire job is to report the whole set.Rejected — option 2, declare
ownKeysthe enumerable face and make the other instruments agree with it. MakinghasOwnPropertyand the descriptor trap agree with an enumerable-only enumeration means reporting a key as not own — which reverts #12397's mirror for exactly the case it was built for, and buys a worse lie than the one it removes: the key would then be invisible to all three instruments and still persist. That is the #12277 silent-success shape, whichbody-runner.tsnames as the one with no instrument to catch it.Measured cost of option 1: exactly two lines move
The same probe, before and after, byte-compared:
Everything else is identical, and deliberately so:
Object.keys, spread,Object.entriesandJSON.stringifystill return['subject']/{subject:'help'}, because each applies theenumerablefilter itself through the descriptor trap. Wrapper keys stay hidden, symbols stay unenumerated.That is the answer to the sandbox contract this card had to satisfy.
unwrapProxyToPlain(packages/runtime/src/sandbox/body-runner.ts) documents itself as materialising "only whatinstallFlatInput'sownKeysenumerates", viaObject.entries. BecauseObject.entrieskeeps own enumerable string keys, the marshalled set is unchanged — confirmed by running the sandbox suite against a rebuilt objectql (see Verification). The comment is tightened to sayenumerable string subset, since after this cardownKeysis a strict superset of what that snapshot materialises and the loose wording would have read as equality.⭐ The contract spelling is now settled and declared
The tree held two answers and declared neither: the implementation said
Object.keys(data), and the sandbox test double atbody-runner.test.ts:141modelledReflect.ownKeys. Closing that is this card's deliverable, so all three now say the same thing:packages/spec/src/data/hook.zod.ts, the hook-context contract an app author reads —ownKeysreports the payload's own key set, not its enumerable subset; the three own-ness instruments agreeing is the contract;hook-wrappers.ts;Changed existing assertion — declared
packages/runtime/src/sandbox/body-runner.test.ts:141,ownKeys: (t) => Reflect.ownKeys(t)→Object.getOwnPropertyNames(t), with the reason stated on the line in the test file itself. This double was the tree's second answer; left as it was, this PR would have moved the inconsistency rather than removed it. No assertion in that test changes — its subject is write-back, and it still passes. No other existing assertion is touched.What is NOT decided here — the maintainer floor
Symbol keys stay unenumerated, and this is reported rather than decided. Option 1's full spelling (
Reflect.ownKeys(data)) would additionally publish them. The measurement sharpens what that would mean, and it is not what the card assumed:Symbol keys already reach the payload and already persist. So publishing them is not about making them reachable — it is about what the enumeration face should say concerning what the payload is allowed to hold, which is a payload-contract question and the boundary #12397 drew. It stays open on #12578, it is one word away (
Object.getOwnPropertyNames→Reflect.ownKeys) once answered, and it is pinned in its open state so that answering it changes a recorded fact rather than an unnoticed one. Both the code comment and the test say so explicitly, so the omission cannot later read as a decision.The pin asserts the AGREEMENT, not one trap
packages/objectql/src/hook-input-ownkeys-agreement.test.ts(6 cases) reads all three instruments on one key through one object and compares the triple against itself, plus against the persisted row. Pinning a single trap's output in isolation is what let these halves diverge — on the same trap set, in the same file, in the same week as #12397. The two deliberate exceptions are pinned as exceptions so neither can be mistaken for residue of the defect: wrapper keys (hidden from enumeration by design) and the open symbol half.Clause ②, judged against the diff
This is an observable behaviour change on a shipped surface, and it WIDENS what enumeration exposes —
git diff --statbehind the claim:Of the 64 lines in
hook-wrappers.ts, the executable change is the trap body alone; the rest is the comment stating the contract and its two exceptions. The widening is stated precisely rather than bare: previously-invisible keys become visible toObject.getOwnPropertyNamesandReflect.ownKeysonly, it is bounded to keys the payload genuinely owns and the engine already persists, and it is measured as exactly the two lines shown above — no accept set is narrowed, no rejection path is added or removed, and the enumerable face that every documented idiom uses is byte-identical. No public type or spec shape changes; the spec edit is the contract declaration in prose.Verification
Gate union derived by
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfrom the real changeset (6 paths, three-dot vs merge basef93df4dbe3) — 31 path-matched families plus the convention-triggered set for adding test files. Per-command exits captured before any pipe; each verdict below is the gate's own line, not a$?.@objectstack/objectqlfull suiteTest Files 239 passed (239)·Tests 4203 passed (4203)@objectstack/runtimesrc/sandbox/(after rebuild)Test Files 15 passed (15)·Tests 168 passed (168)typecheckobjectql / runtime / specDone; spec alsocheck:test-typecheck: OKpnpm lint(whole repo,eslint . --no-inline-config)check:nul-bytesOK (scanned 6951 text file(s) … no raw ASCII control bytes)check:authorable-surface✅ Successfully generated 1596 schemas.— no artifact drift (git statusclean after)check:liveness/check:empty-state/check:strictness-ledger/check:variant-docs✓check:engine-double-contractOK — 689 pinned, 134 in the DEBT ledger, 3 exemptcheck:objectql-double-limitObjectQL double limit conformance holds: 280 double(s) gradedcheck:cross-package-test-inputsOK: 20 package(s) read outside themselves, all declaredcheck:test-source-aliasOK — 72 packages with tests scannedcheck:type-check-debt(--re-measure)OK — 31 ledger entr(ies) re-measured in 236.1s, 1687 raw tsc error(s), none above its recorded numbercheck:type-check-coverage,check:where-matcher,check:query-options-erasure,check:durability-log-level,check:page-declaration-shape,check:slot-lookup,check:published-files,check:merge-driver,check:doc-authoring,check:spec-parsed-alias,check:type-source-resolution,check:objectui-changeset,check:changeset-gate-self-testscheck-changeset-no-major,check-empty-changeset,check-adr-0087-registration,check-comment-mask-adoption,check-ci-filter-parity,check-engine-split-ratio,check-plugin-teardown-shape,release-rehearsal-clone --self-test,docs-audit/check-affected-docs,docs-audit/check-drift-commentcheck-dev-prereqs✓ 67 package build artifacts presentafter the full build — it measured the worktree, never the diffAblation
Direction and exact count predicted in writing first, then the mutation proved on disk with anchored
grep -cFcounts before any result was read, all undertrap … EXIT INT TERM.Mutation: the repaired trap line only,
Object.getOwnPropertyNames(target.data)→Object.keys(target.data). Anchors after mutation: injected1, removed0(MUTATION-CONFIRMED-ON-DISK; a zero-hit edit would have voided the run rather than passing silently).Predicted: red, 2 failed / 32 passed of 34 across the four hook-input suites, both failures in the new file, and the enumerable-face assertions staying green under the mutation. Observed, exactly that:
The second failure landing on line 152 is the load-bearing detail: lines 148–151 (
Object.keys, spread,entries, JSON) passed under the mutation, which is what proves that half of the contract genuinely untouched by either spelling.No rebuild owed on this leg, justified by import form rather than assertion: the pin imports
./hook-wrappers.js, a relative specifier inside the same package that vitest resolves to the TypeScript source — no packageexports, nodiston the path. Corroborated empirically, since the before/after measurements differed with no build between them.@objectstack/runtime's sandbox tests do reach objectql throughexports→dist, so that suite was run only afterpnpm --filter '@objectstack/runtime^...' build, with reach proved in the artifact itself:packages/objectql/dist/index.js:3721carriesObject.getOwnPropertyNames(target.data)and the pre-fix spelling is absent (grep -cF= 0).Restore verified byte-for-byte (
cmp→ identical to the pre-ablation copy, anchors back to1/0). Noted for the record: the restore was checked by byte comparison rather than by an emptygit diff, because the ablation ran before the repair was committed —cmpis the stronger check of the two, but it is not the one the standard clause names.Changeset
patchon@objectstack/objectql, the only package whose runtime behaviour changes.@objectstack/runtimeand@objectstack/speccarry comment-only edits plus the test double, so they publish no behaviour change and are deliberately not listed.patchrather thanminoris defended in the changeset body: no declared surface shape changes, the enumerable face every documented idiom uses is byte-identical, and the only newly-listed keys are ones the payload genuinely owns and the engine already persists.Out of scope, filed not fixed
The measurement turned up a distinct defect in the same trap set, filed unassigned as #12601: a payload field literally named
idreads back the wrapper's value through thegettrap ('WRAPPER-ID') while the descriptor trap reports the payload's ('PAYLOAD-ID'), because the two traps order the wrapper and the payload differently. It is not repaired here — which side should win depends on whether a payload may declare a field namedidat all, which is the same maintainer floor. Deliberately untouched by this PR in either direction; the measurement is identical before and after this change. That card is not addressed by this branch.Generated by Claude Code
Generated by Claude Code