Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): mirror data's own descriptor in the flat-input Proxy instead of synthesising one - #12581
Conversation
…12397) `installFlatInput`'s `getOwnPropertyDescriptor` trap answered every key `data` carries with one fixed literal and never read `data`'s real descriptor. Since #12277 routed `defineProperty` into `data`, a hook can put a key on the record payload with non-default attributes, and the synthesis reported the defaults back regardless — `enumerable: false` read back as `enumerable: true` while `Object.keys` correctly omitted the key. The trap now mirrors `data`'s own descriptor, forcing `configurable: true` because the proxy target is the wrapper, which does not carry the key: a verbatim mirror is a proxy-invariant violation and throws `TypeError` on any key held non-configurable, taking `Object.keys` and spread with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 14 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 f23d17ba9383aa10fd94ced6a113001dc2fb04a6 && git checkout f23d17ba9383aa10fd94ced6a113001dc2fb04a6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3f41a21527c611620295632e692bcefd56bd67ed 68122f71b200b85de2c43266bffa550f3f168961 && git checkout -B drift-repro 3f41a21527c611620295632e692bcefd56bd67ed && git merge --no-ff 68122f71b200b85de2c43266bffa550f3f168961
node scripts/docs-audit/affected-docs.mjs --json 3f41a21527c611620295632e692bcefd56bd67ed |
os-warren
commented
Aug 26, 2026
PM review — accepted, flipped ready, auto-merge armedMeasured: 3 files, +337/−2, all in-scope. One source file, one new pin file, one changeset. No drive-by. The fork really was left open — verified in the diff, not taken on the report's wordThis was the one thing the dispatch was strict about, so I read the trap body rather than the summary. The whole repair is two lines: constown=Object.getOwnPropertyDescriptor(data,prop);if(own)return{ ...own,configurable: true};The spread carries Worth naming why the other obvious shape would have been a position: flattening an accessor to Two bugs fell out of "mirror, don't synthesise", and both are realNeither was in the card. Both are consequences of doing it properly:
That second one is a behaviour change in the strict direction (a descriptor becomes The |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12397
installFlatInput'sgetOwnPropertyDescriptortrap answered every keydatacarries with one fixed literal and never readdata's real descriptor. It now mirrors the own descriptor, forcingconfigurable: true.Reproduction, before repairing
The card's case, measured on
origin/mainat5fbd58e0dbeforehook-wrappers.tswas touched — the new pin file run against the unfixed trap, 5 of its 6 cases red:Object.keys(input)was right the whole time, which is what makes the shape bad: two instruments over one payload, disagreeing.The fix, and the one attribute that cannot be mirrored
configurableis forcedtrue. The proxy target is the{ data, options, id? }wrapper, which does not carry the record key, and a proxy may not report a property its target lacks as non-configurable — a verbatim mirror throwsTypeErroron any keydataholds asconfigurable: false, and takesObject.keysand spread down with it, since both reach every listed key through this trap. That leg is pinned explicitly (INVARIANT — a data key held non-configurable …), because a mirror that only works on ordinary keys is the same defect one layer along.Two further consequences, both pinned:
data[prop]to fillvalue; a mirror copiesget/setacross untouched.prop in datais true for the whole prototype chain, so the synthesis answered fortoStringtoo — an own, enumerable, writable data property no payload has ever held. Only an own key has a descriptor to mirror.'toString' in inputand the read itself are unchanged.What this PR does NOT decide
Whether a record payload may carry an accessor at all, and what the engine should do persisting one (it persists a payload by evaluating it), is a contract question about the payload — triage was explicit that it must not be settled inside this card. It is not settled here, and the implementation did not have to take a position to land:
definePropertystill lands whatever it is given indata(delete ctx.input.<field>in a hook is a silent no-op — the flat-input Proxy traps get/set/has/ownKeys but notdeleteProperty, and every read-back agrees the delete worked #12277), and persistence is untouched.{ value: data[prop] }— is a position, and it is the side-effecting synthesis being removed.So the fork stands open, unchanged, and this PR neither widens nor narrows what a payload may hold.
Clause ② — judged, with the diff that supports it
Not an accept-set narrowing. One source file, one trap body. No
packages/spec/**, no*.zod.ts, no exported type, signature or.d.ts-shaping export — nothing a caller passes, and nothing a consumer type-checks or validates against, changed. What changed is what one runtime trap reports.It is still observable behaviour on a shipped surface, and one reported answer gets strictly stricter (own-ness for inherited keys: fabricated descriptor →
undefined), so it is carried as apatchchangeset with the consumer evidence below rather than as a bare internal change.Consumer sweep — downstream direction, stated because the claim is otherwise unreadable.
pnpm --filter '...@objectstack/objectql'(prefix form) is the downstream set. Measured:grep -rn "getOwnPropertyDescriptor" packages/*/src --include=*.ts(non-test) → the only hits are this trap and an unrelated one inpackages/spec/src/shared/lazy-schema.ts. No in-repo consumer reads a descriptor off the flat input.@objectstack/runtimeis the one downstream package that pins this proxy.body-runner.test.ts,perrow-dispatch-signal.integration.test.ts,script-runner.test.ts→ 41 passed (41), run afterpnpm --filter '@objectstack/runtime^...' buildbecause those tests reach objectql through packageexports(itsdist), not a vitest src alias — so this measured the built fix, not the source.ownKeysstill lists exactlydata's own enumerable keys and the mirror reports those as enumerable, soObject.keys, spread,Object.entriesand the sandbox'sunwrapProxyToPlainare byte-identical. The siblingdelete ctx.input.<field>in a hook is a silent no-op — the flat-input Proxy traps get/set/has/ownKeys but notdeleteProperty, and every read-back agrees the delete worked #12277 pin file asserts all three and stays 6/6 green.Declared narrowing: the full downstream closure was not run locally — CI runs the farm.
Verification
Union derived, not recalled:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackover the real changeset. All 26 matched families (21 path-derived + 5 convention-triggered) re-run at the final head68122f71b2, each exit code captured before any pipe, 26/26 RC=0:Suites, at the same head:
pnpm --filter @objectstack/objectql test→ 238 files, 4197 passed (4197)pnpm --filter @objectstack/objectql typecheck→ RC 0check:type-check-debt— measured by a declared narrowing, not skippedThe 26th family (
check:type-check-debt, i.e.check-type-check-coverage.mjs --re-measure) refuses without a built workspace closure, and its throw means NOT MEASURED. Rather than leave it there, the program it would run for the one ledgered package this diff touches was reproduced directly:tsc --noEmitoverpackages/objectqlwith the tests included (they areexcluded from the package's owntsconfig.json, which is exactly why the package carries aTEST_DEBTentry and whypnpm typecheckcannot see this file).Result — 355 errors, of which 0 are in the new file (and 0 in the sibling
hook-input-mutation-traps.test.ts). The composition reproduces the ledger's recorded 354 to the unit — TS2339 x115, TS2554 x113, TS7006 x36, TS2345 x24, TS2749 x14, TS2322 x14, TS6133 x9, TS2550 x8, TS18048 x8, TS2353 x4, TS6196 x1 — plus the singleTS6059rootDir diagnostic thatmeasureTestDebtdrops (dropRootDirDiagnostics: true), which the ledger note names explicitly. The recorded number cannot drift upward from this change, and no other ledger entry is in the diff.That measurement also changed the code: the pin file originally used
Object.hasOwn, which is ES2022 against this workspace'slib: ["ES2020"]— it would have added twoTS2550and reddened the ratchet invisibly topnpm typecheck. It now usesObject.prototype.hasOwnProperty.call, with the reason on the line.Ablation — one leg, predicted in writing first
Prediction, written before the run: reverting the mirror to the synthesis literal turns exactly 5 of 6 cases in the new file red (REPRODUCTION,
writable: false, INVARIANT, INHERITED, accessor), leaves POSITIVE CONTROL green, and leaves the sibling #12277 file 6/6 green — the second half being the point: the existing pins cannot catch this defect, because the synthesis is the truth for ordinary assigned keys.Observed, exactly:
Restore ran from a
trap … EXIT INT TERMand touched only the one mutated path (nevergit checkout HEAD -- .). No rebuild leg is owed on either side: the pin file imports./hook-wrappers.jsrelatively inside its own package, so vitest transformssrcdirectly and nodistsits on the resolution path — evidenced by the pre-fix and post-fix runs differing with no build between them.No existing test assertion was reversed.
Recorded, not ridden along
applyMutationsToInputbigint residual (packages/runtime/src/sandbox/body-runner.ts—JSON.stringifyprobing an entry-snapshot value throws on a bigint and drops the key, so adeleteof a bigint-valued key in a sandboxed body is still lost). The charter admits it only if that file is already open for another reason; it is not — the fix is entirely in objectql — so it stays recorded on [finding] The flat-input Proxy'sgetOwnPropertyDescriptorsynthesises a descriptor instead of mirroringdata's — now reachable, since #12277 routeddefinePropertyintodata#12397.ownKeyslists onlydata's ENUMERABLE keys — an own non-enumerable key is invisible togetOwnPropertyNameswhilehasOwnPropertyand the descriptor trap both report it #12578 — filed from this work, unassigned, observation class:ownKeyslists onlydata's enumerable keys, so an own non-enumerable key is absent fromObject.getOwnPropertyNames(input)whilehasOwnPropertyand (now) the descriptor trap both report it as own. Not repaired here: replacingObject.keys(data)withReflect.ownKeys(data)would newly expose symbol keys and cuts across the sandbox's documented "materialises only whatownKeysenumerates" contract — a behaviour decision, not a mechanical repair, and outside this card's charter.Generated by Claude Code