Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): flat-input envelope wins consistently across get/getOwnPropertyDescriptor (#12601) - #12752
Conversation
…e-fix Written and run against unmodified origin/main before any fix lands, to serve as the ablation prediction: packages/objectql/src/hook-input-envelope-precedence.test.ts reproduces the get/getOwnPropertyDescriptor disagreement per reserved name (id/options/ast/data) and fails 5 of 7 cases (named set recorded in the dev's scratchpad prediction); the runtime-side consequence pin (packages/runtime/src/sandbox/hook-input-envelope-precedence.integration.test.ts) is already green pre-fix, "by accident" per its own header comment. Part of #12601
…iptor/ownKeys (#12601) installFlatInput's getOwnPropertyDescriptor checked the record payload (`data`) before the reserved wrapper names (`id`/`options`/`ast`/`data`), while `get` always checked the wrapper first — so a payload field sharing one of those four names made a direct read (`input.id`) and a descriptor read (`Object.getOwnPropertyDescriptor(input, 'id').value`) disagree about the same key. Per the maintainer ruling (Option A, envelope wins consistently): the four names are reserved on the flat face. getOwnPropertyDescriptor now checks them first, matching get's order; enumerable still depends on whether `data` also owns the name (unchanged ownKeys, #12578), which is what keeps spread/ Object.entries carrying the envelope's value under the reserved name exactly as before. The payload's own value stays reachable at input.data.<name>. Part of #12601
…angeset (#12601) content/docs/automation/hooks.mdx now states loudly, at the Before Hook flat ctx.input section, that id/options/ast/data are reserved and always resolve to the envelope — a payload field sharing one of those names is reachable only at ctx.input.data.<name>. Changeset (patch, argued in the file): same trap set, same shape, same scope as the two immediately preceding fixes here (#12397, #12578), both patch — no persisted data moves, and the paths that already worked (get, ownKeys, spread, Object.entries) are unaffected; only a direct descriptor-value read on a name that collides with a reserved name changes. Part of #12601
📓 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 — 15 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 4eec8dd897e5edb61a79459f0c5006ba4f0d5027 && git checkout 4eec8dd897e5edb61a79459f0c5006ba4f0d5027
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 168941cea851ca77a42ce5db01b77f1c91273bee 6e1297bc42d05659eaf68fc65de3d38f17141b30 && git checkout -B drift-repro 168941cea851ca77a42ce5db01b77f1c91273bee && git merge --no-ff 6e1297bc42d05659eaf68fc65de3d38f17141b30
node scripts/docs-audit/affected-docs.mjs --json 168941cea851ca77a42ce5db01b77f1c91273bee |
os-zhuang
commented
Aug 27, 2026
PM review — ACCEPT on substance; enqueueing on all-greenReviewer of record: The fence held, and I checked it the way it could failThe dispatch's hard fence was: do not turn Byte-identical to what #12578 landed. #12603 stays genuinely open rather than quietly resolved. The |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12601
What
installFlatInput's (packages/objectql/src/hook-wrappers.ts)gettrap hasalways given
id/options/ast/dataprecedence to the WRAPPER(envelope) — a direct read like
ctx.input.idnever falls through to therecord payload.
getOwnPropertyDescriptorchecked the payload FIRST instead,so for a record whose payload happened to declare a field sharing one of
those four names, a direct read and a descriptor read disagreed about the
identical key:
Per the maintainer ruling (comment
5434984840, Option A — "the envelope wins consistently"):id/options/ast/dataare reserved names on the hook flat-input face.getOwnPropertyDescriptornow checks them first too, matchingget's order,so every instrument that reads one of the four names for a value agrees. The
payload's own field, if it declares one of these names, stays a legal record
field — untouched in storage — reachable at
input.data.NAME, just notthrough the flat face.
enumerableon the reserved-name descriptor still depends on whetherdataalso owns the name — unchanged, this is what keeps
Object.keys/spread/Object.entriescarrying the envelope's value under the reserved name exactlyas they did before this fix (they already agreed with
get, since a valueread goes through
get), rather than silently dropping a keyownKeysstilloffers.
ownKeysis unchanged — per the dispatch fence,Object.getOwnPropertyNames(target.data)stays exactly as
#12578landed it. Whether symbol keys should be exposedremains
#12603's open question, untouched here.Premise re-measurement (on current
origin/main, before this PR's fix)Driven through
wrapDeclarativeHookagainst the real proxy, per the dispatch'srepro script:
Reproduces exactly as the issue and dispatch predicted.
ownKeysand spreadalready agreed with
getbefore this fix (spread reads a value throughget); only a raw descriptor read disagreed. Confirmed the fix closes exactlythat gap — see
testsbelow.Regression coverage — per instrument, per name
packages/objectql/src/hook-input-envelope-precedence.test.ts— a case perreserved name (
id,options,ast, anddataitself, i.e. a payload fieldliterally named
data), each assertingget/getOwnPropertyDescriptor/spread /
Object.entriesall agree on the envelope's value, andinput.data.NAMEstill returns the payload's; an edge case for aninsert-shaped envelope with no wrapper
id(the reserved name then resolvesto absent, not to the payload's value, matching
get's existingunconditional wrapper-only read); a positive control on a non-reserved key;
and the pre-existing non-collision declared-exception case repeated as a
guardrail on this fix specifically.
packages/runtime/src/sandbox/hook-input-envelope-precedence.integration.test.tspins the load-bearing consequence named in the dispatch: a sandboxed hook body
reading a same-named field through
unwrapProxyToPlain'sObject.entrieswalk sees the envelope's value — now by contract (this fix), not by the
accident its own header documents (measured: this file is already green
pre-fix, because
ownKeys+ a coincidentally-enumerable payload descriptor +get's pre-existing precedence happened to line up anyway).Ablation
Prediction written and committed (
test(objectql,runtime): pin #12601 envelope-precedence regression, pre-fix) before the fix commit. Formalablation cycle (commit-based, trap-guarded, absolute paths):
hook-wrappers.tsoverwritten with the pre-fix blob from that commit, anchored greps confirm the
mutation landed both directions, the new regression file re-run —
— exact match to the prediction, by count and by named set (the 4
per-name collision cases + the insert-shaped edge case fail; the positive
control and the non-collision declared-exception case stay green). Positive
control run in the same mutated window: all 4 sibling trap-set suites
(
hook-input-ownkeys-agreement,hook-input-descriptor-mirror,hook-input-mutation-traps,hook-input-shape-contract) — 34/34 green,proving the cut was surgical. Restored via
git checkout HEAD -- PATH;proven via
git diff HEADempty andgit hash-objectequal to the HEAD blob,both times.
Docs
content/docs/automation/hooks.mdx— aCalloutat the flatctx.inputsection states the four reserved names loudly, with the
ctx.input.data.NAMEescape hatch.
Changeset
patchon@objectstack/objectql, argued in the file: same trap set, sameshape, same scope as the two immediately preceding fixes on this proxy
(
#12397,#12578), both shippedpatch— no persisted data moves, andevery path that already worked (
get,ownKeys, spread,Object.entries)is unaffected; only a direct
getOwnPropertyDescriptor(...).valueread on aname colliding with a reserved name changes.
Clause ② — contract accept/reject, surface width
Rejects an inconsistency, does not widen the surface. No field, key,
capability, or export is added or removed. The wrapper-first precedence rule
already existed (in
get, and in the pre-existingownKeys"WRAPPER KEYSremain excluded" design intent) — this PR extends the same existing rule to
getOwnPropertyDescriptorso it stops disagreeing with its siblings. Ifanything the change is a narrowing: a payload field colliding with one of the
four reserved names was PARTIALLY reachable through the flat face before this
fix (via a direct descriptor read only, inconsistently with every other
instrument) and is now reachable through none of the flat face's instruments
— only through
input.data.NAME, which was always the documented route.Gates
Full detail in the structured report on the issue. Summary: dispatch-named
(
check:engine-double-contract,check:where-matcher,check:objectql-double-limit,check:query-options-erasure) green; full@objectstack/objectql(243 files / 4239 tests) and@objectstack/runtime(197 files / 2889 tests) suites green; both packages'
typecheckclean;~37 additional path-derived gates (docs/changeset/cross-package/static-analysis
families) green;
check:type-check-coveragegreen;check:type-check-debt --re-measureNOT MEASURED (its own prerequisiterefusal: needs the full ~80-package workspace closure built, which
lint.ymldoes and this local run did not attempt);
check:pm-half-statesNOTMEASURED (prerequisite refusal: no valid GitHub credential in this
container for that script's own
/rate_limitprobe — unrelated to thisdiff).
Generated by Claude Code