Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): refuse a symbol key at the flat-input set/defineProperty traps (#12603) - #12801
Conversation
…ty traps (#12603) Maintainer ruling, 2026-08-27, Option C refusal arm: a record payload is a declarable, string-keyed field set -- no metadata schema can declare a symbol field, so a symbol key on ctx.input is a JS-runtime artifact leaking toward storage. installFlatInput's `set` and `defineProperty` traps now throw a TypeError naming the key kind and the surface, before the write ever reaches `data`, instead of routing it through silently the way #12277 routes every other mutation. Measured pre-fix, on origin/main: a symbol-keyed `set` succeeded silently, the value reached `data` and persisted to the row the engine stores, and only `Reflect.ownKeys`/`getOwnPropertySymbols` omitted it from enumeration -- two instruments said "own", enumeration said "no", while the persisted row held it regardless. That is exactly the "hiding what you persist" shape #12277/#12397/ #12578 exist to abolish; this closes it from the write side instead of the enumeration side (Option B, publishing symbols via Reflect.ownKeys, was declined -- it would have made an undeclarable key kind a published contract). `ownKeys` itself is untouched, per the ruling: ownKeys can never observe a symbol key that set/defineProperty never let onto `data`. Inverts the pin `hook-input-ownkeys-agreement.test.ts` carried OPEN since #12578 into a REFUSAL pin, in place -- the same case, turned around, not a second assertion stacked beside the first. Part of #12603 Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
Prediction, written BEFORE mutating, per AGENTS.md ablation discipline. Subject: packages/objectql/src/hook-wrappers.ts, the `set` and `defineProperty` traps' `typeof prop === 'symbol'` refusal guards (refuseSymbolPayloadKey calls). Mutation: remove both guard calls, restoring silent pass-through to the pre-#12603 behaviour (symbol-keyed writes route into `data` unrefused). Predicted result under the mutation: - RED (exactly 1 test): packages/objectql/src/hook-input-ownkeys-agreement.test.ts "[#12603] REFUSAL, not agreement -- a symbol key is rejected before it can ever reach data" -- both `expect(setThrew).toBeInstanceOf(TypeError)` and `expect(definePropertyThrew).toBeInstanceOf(TypeError)` fail because neither write throws under the mutation; `raw.data` ends up holding the symbol key instead of omitting it. - GREEN, unaffected (positive control, mutated in the SAME window): packages/objectql/src/hook-input-mutation-traps.test.ts "POSITIVE CONTROL -- an assignment in the same call still lands" -- an ordinary string-keyed assignment never touches the symbol branch, so removing the guard changes nothing for it. - The other 4 cases in hook-input-ownkeys-agreement.test.ts (REPRODUCTION, enumerable-face, absent-key agreement, positive control, declared exception) stay GREEN -- none of them write a symbol key. Named set: 1 red (the inverted pin), 41 total tests across the 5 hook-input suites measured earlier this run, so 40 green expected under the mutation.
📓 Docs Drift Check3 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 2b43e9098b8e2770b10b8a6f53f7d93f116cfce0 && git checkout 2b43e9098b8e2770b10b8a6f53f7d93f116cfce0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 96dc446c9c19063edfae26ae30ff75143ef0c5b7 90add5560a1ceede5d0228bd51704df422a4c8fc && git checkout -B drift-repro 96dc446c9c19063edfae26ae30ff75143ef0c5b7 && git merge --no-ff 90add5560a1ceede5d0228bd51704df422a4c8fc
node scripts/docs-audit/affected-docs.mjs --json 96dc446c9c19063edfae26ae30ff75143ef0c5b7 |
os-zhuang
commented
Aug 27, 2026
Reviewer-of-record hand-read of the docs, answering the drift check's "nothing to list — not a clean bill of health" above. That bot cannot see a page that states a rule by its inputs while the diff changed the emitter; this diff is exactly that shape, so the null result was a prompt to read, not a clearance. Positive control first: 13 pages under Result: no falsified sentence. One near-miss, reported rather than rewritten.
One hypothesis raised and killed by measurement, which is why the read was worth doing.
Traced instead of assumed. Independently re-verified by content on
Generated by Claude Code |
check-adr-0087-registration flagged the changeset as declaring a breaking
change (the BREAKING-CHANGE-footer regex matched an ordinary hard-wrapped
sentence starting "breaking change does not burn a major version...") with
no adr-0087: disposition marker. Whatever tripped the regex, the underlying
verdict is correct on the merits -- this changeset does argue an accept-set
narrowing -- so the fix is to answer the question, not to reword around it.
Category: no-migration-prescription, verified against the real predicates
(scripts/check-adr-0087-registration.mjs: parseChangeset/hasMigrationPrescription)
rather than assumed:
- hasMigrationPrescription(body) is false both before and after adding the
marker -- the changeset's "**Migration.**" paragraph is prose guidance
("use a string key instead"), not a heading, an arrow rewrite, a FROM/TO
label, or a rewrite table, so it never sets framedSection or matches
REWRITE_RE.
- runtime-interface-only does not apply and was ruled out mechanically, not
just judgment: exportedTypeDeclaration only recognizes an exported
interface/type/class/enum ("const/function are absent on purpose -- this
category is about a TYPE surface"), and every symbol this diff touches
(installFlatInput, refuseSymbolPayloadKey) is a private function; the one
exported function in the file whose behaviour changed (wrapDeclarativeHook)
had no type-declaration change, only a Proxy trap's runtime behaviour, so
naming it would be a category the gate's own predicate cannot verify true.
ADR-0087 addendum (2026-08-13) authorizes the category: "no-migration-
prescription -- REFUSED when the changeset's own body carries a migration
prescription" -- ctx.input carries no spec/Zod field, object definition, or
stored representation for objectstack migrate meta to act on; a symbol key
was never a declarable metadata surface in the first place, so there is
nothing here for a migration to rewrite.
Part of #12603
Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dryos-zhuang
commented
Aug 27, 2026
Fixed the What tripped it: not the quoted Category chosen: both before and after adding the marker -- the changeset's Trap 2 (self-contradiction) did not bite -- confirmed by running the predicate against the body WITH the new marker line included (it scans the whole body), still
Local gate output: Before (HEAD After (HEAD
Also re-ran Pushed sha: Generated by Claude Code Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12603
What changed
installFlatInput'ssetanddefinePropertytraps (packages/objectql/src/hook-wrappers.ts) now refuse a symbol-keyed write with aTypeErrornaming the key kind and the surface, instead of silently routing it into the record payload (data) the way every string-keyed write is routed.ownKeysitself is untouched — stillObject.getOwnPropertyNames(data), exactly as #12578 landed it (per the dispatch brief: do not re-open #12578's own boundary).Why (the ruling)
Maintainer ruling, 2026-08-27, on #12603 — Option C, refusal arm: a record payload is a declarable, string-keyed field set; no metadata schema can declare a symbol field, so a symbol key on
ctx.inputis a JS-runtime artifact leaking toward storage, not a legal payload field. Option B (publish symbols too, viaReflect.ownKeys) was declined — it would have made an undeclarable key kind a published contract instead of closing the question. Hiding a key the engine nonetheless persists is exactly the shape #12277/#12397/#12578 exist to abolish; this closes the gap from the write side rather than the enumeration side.Premise, measured myself before changing anything
Drove
wrapDeclarativeHookdirectly against the real proxy onorigin/main(ead731756, worktree matched by content hash), with a positive control (an ordinary string-key write, confirmed it persists) proving the harness itself was live:setsucceeds silentlyraw.data[sym]setdefinePropertywith a symbol key succeedsraw.data[sym]setObject.keys/spread/ownKeysReflect.ownKeys/Object.keys/spread all omit itdataand is handed onwardObject.getOwnPropertySymbols(raw.data)includes it,raw.datais the plain object the wrapper restores ontoctx.inputafter the hook runs (what the engine's downstreaminput.dataread consumes)All four readings match the ruling's stated rationale exactly — no contradiction, so this proceeded rather than stopping to report.
The pin: inverted, not added
hook-input-ownkeys-agreement.test.tscarried the symbol case OPEN since #12578 (Symbol.for('objectstack.test.12578'), asserting the disagreement — two instruments say "own", enumeration says "no", and the payload the engine persists holds it). Found with a positive control first (searched for "symbol" across the sibling suites; the ownKeys-agreement file's own doc comment and pin surfaced immediately, confirming the search channel was live before concluding anything).That case is turned around in place — same
it(), same file, no second assertion stacked beside it — into a REFUSAL pin: bothsetanddefinePropertynow throw aTypeErrornaming the key kind (/symbol/i) and the surface (/hook input/i), and nothing reachesdata.Ablation
Prediction committed before mutating (
git commit --allow-empty,651b9888e): removing bothtypeof prop === 'symbol'guards should turn exactly 1 of 41 tests across the 5 hook-input suites red (the inverted pin), the other 40 stay green, and the sibling positive control (hook-input-mutation-traps.test.ts— "POSITIVE CONTROL — an assignment in the same call still lands") stays green in the same mutated window.Measured: exact match.
Test Files 1 failed | 4 passed (5),Tests 1 failed | 40 passed (41); the one failure named is precisely the inverted pin (expected undefined to be an instance of TypeError). Mutation confirmed on disk with anchoredgrep -con the guard text (1→0, twice) and the marker text (0→2) before any measurement; theset/definePropertyguard removal maderefuseSymbolPayloadKeytransiently unused, which failed only the DTS half of the rebuild (TS6133) — the ESM/CJS build (what the source-resolving vitest run actually exercises for a same-package relative import) built clean, so the measurement is unaffected. Restore proven:git hash-objecton the restored file equals theHEADblob exactly,git diff HEADis empty, zero marker residue, and a clean rebuild + re-run afterward is green again (5 passed (5),41 passed (41)).Changeset —
minor, arguedEvery sibling in this family (#12277, #12397, #12578, #12601) shipped
patchbecause each closed an instrument disagreement — the accepted set of writes never changed. This card is different in kind: a write that used to succeed now throws, on a surface every hook body touches. That is the same shape8cc8401(@objectstack/objectql17.2.0, "BREAKING (accept-set tightening)") arguedminorfor, under this repo's launch-window convention (scripts/check-changeset-no-major.mjs: breaking changes shipminor, notmajor, while the stack versions in lockstep). Full reasoning in.changeset/hook-input-symbol-key-refusal.md.Docs
Added a Callout block to
content/docs/automation/hooks.mdx(Before Hook section, beside the existing reserved-names callout) documenting the refusal and the accidental-spread scenario the dispatch brief called out.Gates
Derived via
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfrom the actual changed paths (4 files:hook-wrappers.ts, the inverted test, the changeset, the docs edit), re-derived after the final commit — unchanged. 43 path/convention-matched local families run, all green except two unrelated-to-this-diff NOT MEASURED reads, both resolved:check:skill-examples— first run hit a pre-existing prerequisite gap (@objectstack/client-reacthad nodistin this fresh worktree, unrelated to this diff). Built its dependency closure + itself, re-ran: green (260 prose examples type-check across 3 surfaces).pm/check-half-states— exit 3,PREREQUISITE NOT MET(no valid GitHub credential in this container) — NOT MEASURED, not a red, per the gate's own documented exit-code contract.Named-in-brief families, all green:
check:engine-double-contract,check:where-matcher,check:objectql-double-limit,check:query-options-erasure.check:type-check-debt(the ratchet covering**/*.test.ts, which the package's owntypecheckscript excludes) required the full workspace closure built (pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) — green,none above its recorded number, confirming the edited test file (part of objectql's tracked TEST_DEBT re-measure) typechecks clean.packages/objectql's ownpnpm typecheckandpnpm --filter @objectstack/objectql exec vitest run(the 5 hook-input trap-set suites, 41/41) both green.Serial slot
Both prerequisites (#12578 via #12602, #12601 as
86df0c99e) confirmed landed by content onorigin/mainbefore starting. Searched for another open PR touchinghook-wrappers.tswith a positive control first (a generic open-PR query returned 12 results, proving the channel was live) — zero hits for the file, so this is a clean single-writer claim.Generated by Claude Code