You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[finding] The flat-input Proxy's getOwnPropertyDescriptor synthesises a descriptor instead of mirroring data's — now reachable, since #12277 routed defineProperty into data #12397
Found while enumerating the trap set for #12277 (PR #12396). Reported there under "Reported, not fixed" and filed here so it is not lost with that PR. Unassigned, observation class — no measured consumer, and the fix has real design questions attached.
What it does
installFlatInput (packages/objectql/src/hook-wrappers.ts) answers getOwnPropertyDescriptor for any key present on data with a synthesised descriptor:
It never reads data's real descriptor. For every key created by ordinary assignment the synthesis happens to be the truth, which is why this has cost nothing so far.
Why it is worth writing down now
#12277 routed deleteProperty and defineProperty into data, so a hook can now put a key on the record payload with non-default attributes for the first time:
Object.defineProperty(ctx.input,'k',{value: 1,enumerable: false,configurable: true});Object.getOwnPropertyDescriptor(ctx.input,'k');// reports enumerable: true — it is not
Before that change the same call defined on the wrapper and never reached data at all, so the inaccuracy was unreachable in practice. The fix did not create the synthesis; it gave it a way to be wrong.
Why it was not just repaired in the same PR
configurable: true is not free to change. The proxy target is the { data, options, id? } wrapper, which does not carry the key, and a proxy may not report a non-existent target property as non-configurable — so a naive mirror throws TypeError on any key data holds as non-configurable. A correct trap has to force configurable: true while mirroring enumerable / writable (and decide what to do with accessor descriptors on a record payload, which the engine then persists by evaluating). That is a contract question about what a record payload may hold, not a mechanical repair, and it did not belong inside a card about delete.
Adjacent residual from the same fix, recorded here rather than as a second card
applyMutationsToInput (packages/runtime/src/sandbox/body-runner.ts) decides deletability by probing each entry-snapshot value with JSON.stringify. safeJsonStringify marshals a bigint into the VM as a string, but the probe throws on it and drops the key — so delete ctx.input.<bigint_field> in a sandboxed body is still lost. Deliberately conservative (losing a delete beats destroying a field on absent evidence) and documented at the call site.
Dedup
Scanned the 100 most recently created open issues plus a local grep for getOwnPropertyDescriptor across packages/** — no existing card. ⚠️ The contract's prescribed REST dedup channel returned 403 for this session (GitHub access is not enabled for this session), which is #12293's shape, so this scan went through the MCP list endpoint instead — declared rather than reported as a clean search.
Comments re-read in the same action as the claim: two, both from the triage seat (the grading-and-block, and the unlock scan). No prior claim by any other session.
Premise re-verified on currentorigin/main, not inherited from the unlock scan. Triage checked it at 4045b95; main has moved a long way since, and a re-check costs one command. Still true — packages/objectql/src/hook-wrappers.ts:609–613:
Synthesised, not mirrored. Nothing since has absorbed the card.
Serial check: no open PR touches packages/objectql/src/hook-wrappers.ts — checked against the full open-PR list by head branch, not by glob. The hot-file collision triage worried about when it blocked this card is gone with #12396.
The standing charter goes to the dev unchanged, including the part that matters most: mirror data's real enumerable/writable while forcingconfigurable: true (the proxy invariant the card itself derives — a naive mirror throws TypeError because the target wrapper does not carry the key). And ⛔ the accessor-descriptor-on-a-record-payload question is a contract question: if the implementation cannot avoid deciding what a record payload may hold, it stops and forks to needs-user-decision rather than choosing quietly. That is not a formality here — the engine persists a record payload by evaluating it, so an accessor that lands in data is a behaviour question, not a descriptor question.
The applyMutationsToInput bigint residual recorded in the card body rides along only if that file is already open and the fix stays conservative; otherwise it stays recorded.
{
"issue": 12397,
"status": "done",
"branch": "claude/issue-12397-flat-input-descriptor-mirror",
"pr": "https://github.com/objectstack-ai/objectstack/pull/12581",
"premise_still_valid": true,
"clause2": "no-accept-set-narrowing",
"clause2_evidence": "git diff --stat origin/main...HEAD = 3 files, +337/-2: .changeset/flat-input-descriptor-mirror.md (52), packages/objectql/src/hook-input-descriptor-mirror.test.ts (244), packages/objectql/src/hook-wrappers.ts (43, +41/-2). One source file, one trap body. No packages/spec/**, no *.zod.ts, no exported type/signature/.d.ts-shaping export — nothing a caller passes and nothing a consumer type-checks or validates against changed, so no accept set is narrowed. It IS observable runtime behaviour on a shipped surface, and one reported answer gets strictly stricter (own-ness for inherited keys: fabricated descriptor -> undefined), so it ships with a patch changeset plus a downstream sweep: grep of packages/*/src (non-test) shows zero consumers reading a descriptor off the flat input, and @objectstack/runtime (the one downstream package pinning this proxy) is 41/41 green in body-runner.test.ts + perrow-dispatch-signal.integration.test.ts + script-runner.test.ts, run after `pnpm --filter '@objectstack/runtime^...' build` because those tests reach objectql via package exports (dist), not a src alias. Prefix filter form = DOWNSTREAM. Declared narrowing: the full downstream closure was not run locally; CI runs the farm.",
"forkReported": false,
"summary": "installFlatInput's getOwnPropertyDescriptor trap now mirrors data's own descriptor instead of answering with a fixed synthesised literal, forcing configurable: true because the proxy target is the { data, options, id? } wrapper, which does not carry the record key — a verbatim mirror is a proxy-invariant violation and throws TypeError on any key data holds non-configurable, taking Object.keys and spread with it. enumerable/writable are mirrored. Two consequences fell out of 'mirror, do not synthesise' and are pinned: a descriptor read no longer runs author code (the synthesis evaluated data[prop] to fill value, invoking a getter), and an inherited key (prop in data is true up the whole prototype chain) no longer gets a fabricated own descriptor. The accessor contract question was NOT decided and did not have to be: routing and persistence are untouched, and the accessor pin asserts only the two facts true under either answer (no throw; the getter is not run). Enumeration is untouched — ownKeys still lists data's own enumerable keys and the mirror reports those as enumerable.",
"tests": "All at final head 68122f71b2, each exit code captured before any pipe. REPRODUCTION (pre-fix, on unmodified origin/main at 5fbd58e0d): the new pin file ran 5 failed / 6, received { configurable: true, enumerable: true, value: 1, writable: true } where data holds { value: 1, writable: false, enumerable: false, configurable: true }. POST-FIX: pnpm --filter @objectstack/objectql test -> 238 files, 4197 passed (4197), RC 0; pnpm --filter @objectstack/objectql typecheck -> RC 0; targeted run of the new pin file + the sibling #12277 trap-set file -> 12 passed (12). CONSUMER SWEEP: @objectstack/runtime sandbox pins -> 41 passed (41), RC 0. GATES: gate union derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no hand-built path list) and all 26 matched families re-run at the final head -> 26/26 RC=0, each with its own printed verdict line (e.g. check-cross-package-test-inputs: 'OK: 20 package(s) read outside themselves, all declared'). ABLATION: see ablations[].",
"gates": {
"green": [
"check:changeset-gate-self-tests", "check:cross-package-test-inputs", "check:durability-log-level",
"check:objectql-double-limit", "check:objectui-changeset", "check:page-declaration-shape",
"check:published-files", "check:slot-lookup", "check:test-source-alias", "check:type-source-resolution",
"check:nul-bytes", "check:query-options-erasure", "check:type-check-coverage",
"check:engine-double-contract", "check:where-matcher", "scripts/check-adr-0087-registration.mjs",
"scripts/check-changeset-no-major.mjs", "scripts/check-ci-filter-parity.mjs",
"scripts/check-comment-mask-adoption.mjs", "scripts/check-cross-package-test-inputs.mjs",
"scripts/check-empty-changeset.mjs", "scripts/check-engine-split-ratio.mjs",
"scripts/check-plugin-teardown-shape.mjs", "scripts/docs-audit/check-affected-docs.mjs",
"scripts/docs-audit/check-drift-comment.mjs", "scripts/pm/release-rehearsal-clone.mjs --self-test"
],
"red": [],
"notMeasured": [
"check:type-check-debt — the FAMILY did not run: `check-type-check-coverage.mjs --re-measure` refuses without a built workspace closure, and that throw is NOT MEASURED, never 'not applicable'. Substituted a declared, checkable narrowing instead of leaving it dark: the program it would run for the ONE ledgered package in this diff was reproduced directly (tsc --noEmit over packages/objectql with tests included — they are excluded from the package tsconfig, which is why the package carries a TEST_DEBT entry and why `pnpm typecheck` cannot see the new file). 355 errors, 0 of them in the new file and 0 in the sibling trap-set file; 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 single TS6059 rootDir diagnostic that measureTestDebt drops via dropRootDirDiagnostics. So the recorded number cannot drift upward from this change, and no other ledger entry is in the diff. That measurement also caught a real defect in my own test file: it first used Object.hasOwn (ES2022) against this workspace's lib: [ES2020], which would have added 2x TS2550 and reddened the ratchet invisibly to `pnpm typecheck`; it now uses Object.prototype.hasOwnProperty.call, with the reason on the line."
]
},
"ablations": [
{
"leg": "revert the mirror to the synthesis literal that stood on main",
"predicted_before_running": "RED — exactly 5 of 6 in hook-input-descriptor-mirror.test.ts (REPRODUCTION, `writable: false`, INVARIANT non-configurable, INHERITED, accessor-getter-not-run), POSITIVE CONTROL stays green, and the sibling #12277 hook-input-mutation-traps.test.ts stays 6/6 green — i.e. 5 failed of 12 across the two files.",
"observed": "5 failed | 7 passed (12), and the five failures are exactly the five named. Sibling file 6/6 green — the existing #12277 pins cannot catch this defect, because the synthesis is the truth for ordinary assigned keys.",
"mutation_proved_on_disk": "anchored grep -cF taken BEFORE any result was read: injected anchor `const own = Object.getOwnPropertyDescriptor(data, prop);` 1 -> 0; synthesis anchor `return { configurable: true, enumerable: true, writable: true, value: (data as any)[prop] };` 0 -> 1.",
"rebuild": "No rebuild leg owed on either side, and this is a claim about the resolution path, not about the suite's name: the pin file imports './hook-wrappers.js' RELATIVELY inside its own package, so vitest transforms src directly and no dist sits between the mutation and the assertion. Evidenced independently — the pre-fix and post-fix runs differed with no build between them.",
"restore": "`trap <restore> EXIT INT TERM`, restoring ONLY the one mutated path (never `git checkout HEAD -- .`). Verified: anchors back to injected=1/synthesis=0 and `git diff --exit-code` RC=0 (clean tree)."
}
],
"openQuestions": [
{
"question": "May a record payload carry an ACCESSOR (get/set), and what should the engine do when it persists one by evaluating the payload? UNCHANGED by this PR and deliberately not decided — reported so the fork stays visible, not as a blocker.",
"options": [
"A — leave it open: defineProperty keeps routing whatever it is given into data (#12277) and the descriptor trap keeps reporting it faithfully. This is the shipped state.",
"B — refuse accessors at the routing point (defineProperty trap), so a payload can only ever hold data properties.",
"C — flatten an accessor to its evaluated value at definition time."
],
"recommendation": "A for this card: the implementation did not need a position, and the mirror decides nothing — it reports whatever is there under every answer. The accessor pin asserts only the two facts true under all three options (the trap does not throw; a descriptor read does not invoke the getter). Note that the alternative shape for the trap — flattening the accessor to { value: data[prop] } — WOULD have been a position, and it is exactly the side-effecting synthesis this PR removes. If the project wants B or C, it belongs at the routing/persistence seam, not in a read-only descriptor trap."
}
],
"out_of_scope_findings": [
"filed as #12578: the flat-input Proxy's `ownKeys` lists only data's ENUMERABLE keys (Object.keys(data)), so an own non-enumerable key is absent from Object.getOwnPropertyNames(input)/Reflect.ownKeys(input) while hasOwnProperty and — after this PR — the descriptor trap both report it as own; symbol keys are never enumerated either. Not repaired here: the bounded-in-place test fails on the 'mechanical, shape pinned by existing evidence' condition — Reflect.ownKeys(data) would newly expose symbol keys and cuts across the sandbox's documented 'materialises only what ownKeys enumerates' contract (body-runner.ts), whose own test double already models the trap as Reflect.ownKeys. Unassigned, `finding` label, no pm:queue. Dedup: REST channel 403 (#12293 shape) so the 100-most-recent open-issue scan went through the MCP list endpoint — declared, per the precedent triage accepted on this card's own filing — plus a local grep for ownKeys/getOwnPropertyNames across packages/**.",
"NOT filed, recorded on #12397 as the charter directs: the applyMutationsToInput bigint residual in packages/runtime/src/sandbox/body-runner.ts (the JSON.stringify deletability probe throws on a bigint and drops the key, so a delete of a bigint-valued key in a sandboxed body is still lost). It rides along only if that file is already open for another reason — it is not; this fix is entirely in objectql — so it stays recorded rather than becoming a second card."
]
}
(Re-post of the comment directly above. The HTML-comment marker it opened with did not survive the body sanitizer — read back and confirmed stripped, along with an angle-bracketed token inside the JSON — so this copy leads with the literal text instead and drops the brackets. Same report, no content change.)
{
"issue": 12397,
"status": "done",
"branch": "claude/issue-12397-flat-input-descriptor-mirror",
"pr": "https://github.com/objectstack-ai/objectstack/pull/12581",
"premise_still_valid": true,
"clause2": "no-accept-set-narrowing",
"clause2_evidence": "git diff --stat origin/main...HEAD = 3 files, +337/-2: .changeset/flat-input-descriptor-mirror.md (52), packages/objectql/src/hook-input-descriptor-mirror.test.ts (244), packages/objectql/src/hook-wrappers.ts (43, +41/-2). One source file, one trap body. No packages/spec/**, no *.zod.ts, no exported type/signature/.d.ts-shaping export — nothing a caller passes and nothing a consumer type-checks or validates against changed, so no accept set is narrowed. It IS observable runtime behaviour on a shipped surface, and one reported answer gets strictly stricter (own-ness for inherited keys: fabricated descriptor -> undefined), so it ships with a patch changeset plus a downstream sweep: grep of packages/*/src (non-test) shows zero consumers reading a descriptor off the flat input, and @objectstack/runtime (the one downstream package pinning this proxy) is 41/41 green in body-runner.test.ts + perrow-dispatch-signal.integration.test.ts + script-runner.test.ts, run after `pnpm --filter '@objectstack/runtime^...' build` because those tests reach objectql via package exports (dist), not a src alias. Prefix filter form = DOWNSTREAM. Declared narrowing: the full downstream closure was not run locally; CI runs the farm.",
"forkReported": false,
"summary": "installFlatInput's getOwnPropertyDescriptor trap now mirrors data's own descriptor instead of answering with a fixed synthesised literal, forcing configurable: true because the proxy target is the { data, options, id? } wrapper, which does not carry the record key — a verbatim mirror is a proxy-invariant violation and throws TypeError on any key data holds non-configurable, taking Object.keys and spread with it. enumerable/writable are mirrored. Two consequences fell out of 'mirror, do not synthesise' and are pinned: a descriptor read no longer runs author code (the synthesis evaluated data[prop] to fill value, invoking a getter), and an inherited key (prop in data is true up the whole prototype chain) no longer gets a fabricated own descriptor. The accessor contract question was NOT decided and did not have to be: routing and persistence are untouched, and the accessor pin asserts only the two facts true under either answer (no throw; the getter is not run). Enumeration is untouched — ownKeys still lists data's own enumerable keys and the mirror reports those as enumerable.",
"tests": "All at final head 68122f71b2, each exit code captured before any pipe. REPRODUCTION (pre-fix, on unmodified origin/main at 5fbd58e0d): the new pin file ran 5 failed / 6, received { configurable: true, enumerable: true, value: 1, writable: true } where data holds { value: 1, writable: false, enumerable: false, configurable: true }. POST-FIX: pnpm --filter @objectstack/objectql test -> 238 files, 4197 passed (4197), RC 0; pnpm --filter @objectstack/objectql typecheck -> RC 0; targeted run of the new pin file + the sibling #12277 trap-set file -> 12 passed (12). CONSUMER SWEEP: @objectstack/runtime sandbox pins -> 41 passed (41), RC 0. GATES: gate union derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no hand-built path list) and all 26 matched families re-run at the final head -> 26/26 RC=0, each with its own printed verdict line (e.g. check-cross-package-test-inputs: 'OK: 20 package(s) read outside themselves, all declared'). ABLATION: see ablations[].",
"gates": {
"green": [
"check:changeset-gate-self-tests", "check:cross-package-test-inputs", "check:durability-log-level",
"check:objectql-double-limit", "check:objectui-changeset", "check:page-declaration-shape",
"check:published-files", "check:slot-lookup", "check:test-source-alias", "check:type-source-resolution",
"check:nul-bytes", "check:query-options-erasure", "check:type-check-coverage",
"check:engine-double-contract", "check:where-matcher", "scripts/check-adr-0087-registration.mjs",
"scripts/check-changeset-no-major.mjs", "scripts/check-ci-filter-parity.mjs",
"scripts/check-comment-mask-adoption.mjs", "scripts/check-cross-package-test-inputs.mjs",
"scripts/check-empty-changeset.mjs", "scripts/check-engine-split-ratio.mjs",
"scripts/check-plugin-teardown-shape.mjs", "scripts/docs-audit/check-affected-docs.mjs",
"scripts/docs-audit/check-drift-comment.mjs", "scripts/pm/release-rehearsal-clone.mjs --self-test"
],
"red": [],
"notMeasured": [
"check:type-check-debt — the FAMILY did not run: `check-type-check-coverage.mjs --re-measure` refuses without a built workspace closure, and that throw is NOT MEASURED, never 'not applicable'. Substituted a declared, checkable narrowing instead of leaving it dark: the program it would run for the ONE ledgered package in this diff was reproduced directly (tsc --noEmit over packages/objectql with tests included — they are excluded from the package tsconfig, which is why the package carries a TEST_DEBT entry and why `pnpm typecheck` cannot see the new file). 355 errors, 0 of them in the new file and 0 in the sibling trap-set file; 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 single TS6059 rootDir diagnostic that measureTestDebt drops via dropRootDirDiagnostics. So the recorded number cannot drift upward from this change, and no other ledger entry is in the diff. That measurement also caught a real defect in my own test file: it first used Object.hasOwn (ES2022) against this workspace's lib: [ES2020], which would have added 2x TS2550 and reddened the ratchet invisibly to `pnpm typecheck`; it now uses Object.prototype.hasOwnProperty.call, with the reason on the line."
]
},
"ablations": [
{
"leg": "revert the mirror to the synthesis literal that stood on main",
"predicted_before_running": "RED — exactly 5 of 6 in hook-input-descriptor-mirror.test.ts (REPRODUCTION, `writable: false`, INVARIANT non-configurable, INHERITED, accessor-getter-not-run), POSITIVE CONTROL stays green, and the sibling #12277 hook-input-mutation-traps.test.ts stays 6/6 green — i.e. 5 failed of 12 across the two files.",
"observed": "5 failed | 7 passed (12), and the five failures are exactly the five named. Sibling file 6/6 green — the existing #12277 pins cannot catch this defect, because the synthesis is the truth for ordinary assigned keys.",
"mutation_proved_on_disk": "anchored grep -cF taken BEFORE any result was read: injected anchor `const own = Object.getOwnPropertyDescriptor(data, prop);` 1 -> 0; synthesis anchor `return { configurable: true, enumerable: true, writable: true, value: (data as any)[prop] };` 0 -> 1.",
"rebuild": "No rebuild leg owed on either side, and this is a claim about the resolution path, not about the suite's name: the pin file imports './hook-wrappers.js' RELATIVELY inside its own package, so vitest transforms src directly and no dist sits between the mutation and the assertion. Evidenced independently — the pre-fix and post-fix runs differed with no build between them.",
"restore": "`trap restore EXIT INT TERM`, restoring ONLY the one mutated path (never `git checkout HEAD -- .`). Verified: anchors back to injected=1/synthesis=0 and `git diff --exit-code` RC=0 (clean tree)."
}
],
"openQuestions": [
{
"question": "May a record payload carry an ACCESSOR (get/set), and what should the engine do when it persists one by evaluating the payload? UNCHANGED by this PR and deliberately not decided — reported so the fork stays visible, not as a blocker.",
"options": [
"A — leave it open: defineProperty keeps routing whatever it is given into data (#12277) and the descriptor trap keeps reporting it faithfully. This is the shipped state.",
"B — refuse accessors at the routing point (defineProperty trap), so a payload can only ever hold data properties.",
"C — flatten an accessor to its evaluated value at definition time."
],
"recommendation": "A for this card: the implementation did not need a position, and the mirror decides nothing — it reports whatever is there under every answer. The accessor pin asserts only the two facts true under all three options (the trap does not throw; a descriptor read does not invoke the getter). Note that the alternative shape for the trap — flattening the accessor to { value: data[prop] } — WOULD have been a position, and it is exactly the side-effecting synthesis this PR removes. If the project wants B or C, it belongs at the routing/persistence seam, not in a read-only descriptor trap."
}
],
"out_of_scope_findings": [
"filed as #12578: the flat-input Proxy's `ownKeys` lists only data's ENUMERABLE keys (Object.keys(data)), so an own non-enumerable key is absent from Object.getOwnPropertyNames(input)/Reflect.ownKeys(input) while hasOwnProperty and — after this PR — the descriptor trap both report it as own; symbol keys are never enumerated either. Not repaired here: the bounded-in-place test fails on the 'mechanical, shape pinned by existing evidence' condition — Reflect.ownKeys(data) would newly expose symbol keys and cuts across the sandbox's documented 'materialises only what ownKeys enumerates' contract (body-runner.ts), whose own test double already models the trap as Reflect.ownKeys. Unassigned, `finding` label, no pm:queue. Dedup: REST channel 403 (#12293 shape) so the 100-most-recent open-issue scan went through the MCP list endpoint — declared, per the precedent triage accepted on this card's own filing — plus a local grep for ownKeys/getOwnPropertyNames across packages/**.",
"NOT filed, recorded on #12397 as the charter directs: the applyMutationsToInput bigint residual in packages/runtime/src/sandbox/body-runner.ts (the JSON.stringify deletability probe throws on a bigint and drops the key, so a delete of a bigint-valued key in a sandboxed body is still lost). It rides along only if that file is already open for another reason — it is not; this fix is entirely in objectql — so it stays recorded rather than becoming a second card."
]
}
Activity
os-warren commentedon Aug 26, 2026
Claimed and dispatched
session_01W6HFzyH98W1YaQXhJUJt6o(seat post [PM seat] domain:server · seat 1 — 🟢 sunhaolin #6367)claude/issue-12397-flat-input-descriptor-mirrorPremise re-verified on current
origin/main, not inherited from the unlock scan. Triage checked it at4045b95; main has moved a long way since, and a re-check costs one command. Still true —packages/objectql/src/hook-wrappers.ts:609–613:Synthesised, not mirrored. Nothing since has absorbed the card.
Serial check: no open PR touches
packages/objectql/src/hook-wrappers.ts— checked against the full open-PR list by head branch, not by glob. The hot-file collision triage worried about when it blocked this card is gone with #12396.The standing charter goes to the dev unchanged, including the part that matters most: mirror
data's realenumerable/writablewhile forcingconfigurable: true(the proxy invariant the card itself derives — a naive mirror throwsTypeErrorbecause the target wrapper does not carry the key). And ⛔ the accessor-descriptor-on-a-record-payload question is a contract question: if the implementation cannot avoid deciding what a record payload may hold, it stops and forks toneeds-user-decisionrather than choosing quietly. That is not a formality here — the engine persists a record payload by evaluating it, so an accessor that lands indatais a behaviour question, not a descriptor question.The
applyMutationsToInputbigint residual recorded in the card body rides along only if that file is already open and the fix stays conservative; otherwise it stays recorded.Generated by Claude Code
fix(objectql): mirror data's own descriptor in the flat-input Proxy (#…
os-warren commentedon Aug 26, 2026
{ "issue": 12397, "status": "done", "branch": "claude/issue-12397-flat-input-descriptor-mirror", "pr": "https://github.com/objectstack-ai/objectstack/pull/12581", "premise_still_valid": true, "clause2": "no-accept-set-narrowing", "clause2_evidence": "git diff --stat origin/main...HEAD = 3 files, +337/-2: .changeset/flat-input-descriptor-mirror.md (52), packages/objectql/src/hook-input-descriptor-mirror.test.ts (244), packages/objectql/src/hook-wrappers.ts (43, +41/-2). One source file, one trap body. No packages/spec/**, no *.zod.ts, no exported type/signature/.d.ts-shaping export — nothing a caller passes and nothing a consumer type-checks or validates against changed, so no accept set is narrowed. It IS observable runtime behaviour on a shipped surface, and one reported answer gets strictly stricter (own-ness for inherited keys: fabricated descriptor -> undefined), so it ships with a patch changeset plus a downstream sweep: grep of packages/*/src (non-test) shows zero consumers reading a descriptor off the flat input, and @objectstack/runtime (the one downstream package pinning this proxy) is 41/41 green in body-runner.test.ts + perrow-dispatch-signal.integration.test.ts + script-runner.test.ts, run after `pnpm --filter '@objectstack/runtime^...' build` because those tests reach objectql via package exports (dist), not a src alias. Prefix filter form = DOWNSTREAM. Declared narrowing: the full downstream closure was not run locally; CI runs the farm.", "forkReported": false, "summary": "installFlatInput's getOwnPropertyDescriptor trap now mirrors data's own descriptor instead of answering with a fixed synthesised literal, forcing configurable: true because the proxy target is the { data, options, id? } wrapper, which does not carry the record key — a verbatim mirror is a proxy-invariant violation and throws TypeError on any key data holds non-configurable, taking Object.keys and spread with it. enumerable/writable are mirrored. Two consequences fell out of 'mirror, do not synthesise' and are pinned: a descriptor read no longer runs author code (the synthesis evaluated data[prop] to fill value, invoking a getter), and an inherited key (prop in data is true up the whole prototype chain) no longer gets a fabricated own descriptor. The accessor contract question was NOT decided and did not have to be: routing and persistence are untouched, and the accessor pin asserts only the two facts true under either answer (no throw; the getter is not run). Enumeration is untouched — ownKeys still lists data's own enumerable keys and the mirror reports those as enumerable.", "tests": "All at final head 68122f71b2, each exit code captured before any pipe. REPRODUCTION (pre-fix, on unmodified origin/main at 5fbd58e0d): the new pin file ran 5 failed / 6, received { configurable: true, enumerable: true, value: 1, writable: true } where data holds { value: 1, writable: false, enumerable: false, configurable: true }. POST-FIX: pnpm --filter @objectstack/objectql test -> 238 files, 4197 passed (4197), RC 0; pnpm --filter @objectstack/objectql typecheck -> RC 0; targeted run of the new pin file + the sibling #12277 trap-set file -> 12 passed (12). CONSUMER SWEEP: @objectstack/runtime sandbox pins -> 41 passed (41), RC 0. GATES: gate union derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no hand-built path list) and all 26 matched families re-run at the final head -> 26/26 RC=0, each with its own printed verdict line (e.g. check-cross-package-test-inputs: 'OK: 20 package(s) read outside themselves, all declared'). ABLATION: see ablations[].", "gates": { "green": [ "check:changeset-gate-self-tests", "check:cross-package-test-inputs", "check:durability-log-level", "check:objectql-double-limit", "check:objectui-changeset", "check:page-declaration-shape", "check:published-files", "check:slot-lookup", "check:test-source-alias", "check:type-source-resolution", "check:nul-bytes", "check:query-options-erasure", "check:type-check-coverage", "check:engine-double-contract", "check:where-matcher", "scripts/check-adr-0087-registration.mjs", "scripts/check-changeset-no-major.mjs", "scripts/check-ci-filter-parity.mjs", "scripts/check-comment-mask-adoption.mjs", "scripts/check-cross-package-test-inputs.mjs", "scripts/check-empty-changeset.mjs", "scripts/check-engine-split-ratio.mjs", "scripts/check-plugin-teardown-shape.mjs", "scripts/docs-audit/check-affected-docs.mjs", "scripts/docs-audit/check-drift-comment.mjs", "scripts/pm/release-rehearsal-clone.mjs --self-test" ], "red": [], "notMeasured": [ "check:type-check-debt — the FAMILY did not run: `check-type-check-coverage.mjs --re-measure` refuses without a built workspace closure, and that throw is NOT MEASURED, never 'not applicable'. Substituted a declared, checkable narrowing instead of leaving it dark: the program it would run for the ONE ledgered package in this diff was reproduced directly (tsc --noEmit over packages/objectql with tests included — they are excluded from the package tsconfig, which is why the package carries a TEST_DEBT entry and why `pnpm typecheck` cannot see the new file). 355 errors, 0 of them in the new file and 0 in the sibling trap-set file; 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 single TS6059 rootDir diagnostic that measureTestDebt drops via dropRootDirDiagnostics. So the recorded number cannot drift upward from this change, and no other ledger entry is in the diff. That measurement also caught a real defect in my own test file: it first used Object.hasOwn (ES2022) against this workspace's lib: [ES2020], which would have added 2x TS2550 and reddened the ratchet invisibly to `pnpm typecheck`; it now uses Object.prototype.hasOwnProperty.call, with the reason on the line." ] }, "ablations": [ { "leg": "revert the mirror to the synthesis literal that stood on main", "predicted_before_running": "RED — exactly 5 of 6 in hook-input-descriptor-mirror.test.ts (REPRODUCTION, `writable: false`, INVARIANT non-configurable, INHERITED, accessor-getter-not-run), POSITIVE CONTROL stays green, and the sibling #12277 hook-input-mutation-traps.test.ts stays 6/6 green — i.e. 5 failed of 12 across the two files.", "observed": "5 failed | 7 passed (12), and the five failures are exactly the five named. Sibling file 6/6 green — the existing #12277 pins cannot catch this defect, because the synthesis is the truth for ordinary assigned keys.", "mutation_proved_on_disk": "anchored grep -cF taken BEFORE any result was read: injected anchor `const own = Object.getOwnPropertyDescriptor(data, prop);` 1 -> 0; synthesis anchor `return { configurable: true, enumerable: true, writable: true, value: (data as any)[prop] };` 0 -> 1.", "rebuild": "No rebuild leg owed on either side, and this is a claim about the resolution path, not about the suite's name: the pin file imports './hook-wrappers.js' RELATIVELY inside its own package, so vitest transforms src directly and no dist sits between the mutation and the assertion. Evidenced independently — the pre-fix and post-fix runs differed with no build between them.", "restore": "`trap <restore> EXIT INT TERM`, restoring ONLY the one mutated path (never `git checkout HEAD -- .`). Verified: anchors back to injected=1/synthesis=0 and `git diff --exit-code` RC=0 (clean tree)." } ], "openQuestions": [ { "question": "May a record payload carry an ACCESSOR (get/set), and what should the engine do when it persists one by evaluating the payload? UNCHANGED by this PR and deliberately not decided — reported so the fork stays visible, not as a blocker.", "options": [ "A — leave it open: defineProperty keeps routing whatever it is given into data (#12277) and the descriptor trap keeps reporting it faithfully. This is the shipped state.", "B — refuse accessors at the routing point (defineProperty trap), so a payload can only ever hold data properties.", "C — flatten an accessor to its evaluated value at definition time." ], "recommendation": "A for this card: the implementation did not need a position, and the mirror decides nothing — it reports whatever is there under every answer. The accessor pin asserts only the two facts true under all three options (the trap does not throw; a descriptor read does not invoke the getter). Note that the alternative shape for the trap — flattening the accessor to { value: data[prop] } — WOULD have been a position, and it is exactly the side-effecting synthesis this PR removes. If the project wants B or C, it belongs at the routing/persistence seam, not in a read-only descriptor trap." } ], "out_of_scope_findings": [ "filed as #12578: the flat-input Proxy's `ownKeys` lists only data's ENUMERABLE keys (Object.keys(data)), so an own non-enumerable key is absent from Object.getOwnPropertyNames(input)/Reflect.ownKeys(input) while hasOwnProperty and — after this PR — the descriptor trap both report it as own; symbol keys are never enumerated either. Not repaired here: the bounded-in-place test fails on the 'mechanical, shape pinned by existing evidence' condition — Reflect.ownKeys(data) would newly expose symbol keys and cuts across the sandbox's documented 'materialises only what ownKeys enumerates' contract (body-runner.ts), whose own test double already models the trap as Reflect.ownKeys. Unassigned, `finding` label, no pm:queue. Dedup: REST channel 403 (#12293 shape) so the 100-most-recent open-issue scan went through the MCP list endpoint — declared, per the precedent triage accepted on this card's own filing — plus a local grep for ownKeys/getOwnPropertyNames across packages/**.", "NOT filed, recorded on #12397 as the charter directs: the applyMutationsToInput bigint residual in packages/runtime/src/sandbox/body-runner.ts (the JSON.stringify deletability probe throws on a bigint and drops the key, so a delete of a bigint-valued key in a sandboxed body is still lost). It rides along only if that file is already open for another reason — it is not; this fix is entirely in objectql — so it stays recorded rather than becoming a second card." ] }Generated by Claude Code
os-warren commentedon Aug 26, 2026
os-dev-report
(Re-post of the comment directly above. The HTML-comment marker it opened with did not survive the body sanitizer — read back and confirmed stripped, along with an angle-bracketed token inside the JSON — so this copy leads with the literal text instead and drops the brackets. Same report, no content change.)
{ "issue": 12397, "status": "done", "branch": "claude/issue-12397-flat-input-descriptor-mirror", "pr": "https://github.com/objectstack-ai/objectstack/pull/12581", "premise_still_valid": true, "clause2": "no-accept-set-narrowing", "clause2_evidence": "git diff --stat origin/main...HEAD = 3 files, +337/-2: .changeset/flat-input-descriptor-mirror.md (52), packages/objectql/src/hook-input-descriptor-mirror.test.ts (244), packages/objectql/src/hook-wrappers.ts (43, +41/-2). One source file, one trap body. No packages/spec/**, no *.zod.ts, no exported type/signature/.d.ts-shaping export — nothing a caller passes and nothing a consumer type-checks or validates against changed, so no accept set is narrowed. It IS observable runtime behaviour on a shipped surface, and one reported answer gets strictly stricter (own-ness for inherited keys: fabricated descriptor -> undefined), so it ships with a patch changeset plus a downstream sweep: grep of packages/*/src (non-test) shows zero consumers reading a descriptor off the flat input, and @objectstack/runtime (the one downstream package pinning this proxy) is 41/41 green in body-runner.test.ts + perrow-dispatch-signal.integration.test.ts + script-runner.test.ts, run after `pnpm --filter '@objectstack/runtime^...' build` because those tests reach objectql via package exports (dist), not a src alias. Prefix filter form = DOWNSTREAM. Declared narrowing: the full downstream closure was not run locally; CI runs the farm.", "forkReported": false, "summary": "installFlatInput's getOwnPropertyDescriptor trap now mirrors data's own descriptor instead of answering with a fixed synthesised literal, forcing configurable: true because the proxy target is the { data, options, id? } wrapper, which does not carry the record key — a verbatim mirror is a proxy-invariant violation and throws TypeError on any key data holds non-configurable, taking Object.keys and spread with it. enumerable/writable are mirrored. Two consequences fell out of 'mirror, do not synthesise' and are pinned: a descriptor read no longer runs author code (the synthesis evaluated data[prop] to fill value, invoking a getter), and an inherited key (prop in data is true up the whole prototype chain) no longer gets a fabricated own descriptor. The accessor contract question was NOT decided and did not have to be: routing and persistence are untouched, and the accessor pin asserts only the two facts true under either answer (no throw; the getter is not run). Enumeration is untouched — ownKeys still lists data's own enumerable keys and the mirror reports those as enumerable.", "tests": "All at final head 68122f71b2, each exit code captured before any pipe. REPRODUCTION (pre-fix, on unmodified origin/main at 5fbd58e0d): the new pin file ran 5 failed / 6, received { configurable: true, enumerable: true, value: 1, writable: true } where data holds { value: 1, writable: false, enumerable: false, configurable: true }. POST-FIX: pnpm --filter @objectstack/objectql test -> 238 files, 4197 passed (4197), RC 0; pnpm --filter @objectstack/objectql typecheck -> RC 0; targeted run of the new pin file + the sibling #12277 trap-set file -> 12 passed (12). CONSUMER SWEEP: @objectstack/runtime sandbox pins -> 41 passed (41), RC 0. GATES: gate union derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no hand-built path list) and all 26 matched families re-run at the final head -> 26/26 RC=0, each with its own printed verdict line (e.g. check-cross-package-test-inputs: 'OK: 20 package(s) read outside themselves, all declared'). ABLATION: see ablations[].", "gates": { "green": [ "check:changeset-gate-self-tests", "check:cross-package-test-inputs", "check:durability-log-level", "check:objectql-double-limit", "check:objectui-changeset", "check:page-declaration-shape", "check:published-files", "check:slot-lookup", "check:test-source-alias", "check:type-source-resolution", "check:nul-bytes", "check:query-options-erasure", "check:type-check-coverage", "check:engine-double-contract", "check:where-matcher", "scripts/check-adr-0087-registration.mjs", "scripts/check-changeset-no-major.mjs", "scripts/check-ci-filter-parity.mjs", "scripts/check-comment-mask-adoption.mjs", "scripts/check-cross-package-test-inputs.mjs", "scripts/check-empty-changeset.mjs", "scripts/check-engine-split-ratio.mjs", "scripts/check-plugin-teardown-shape.mjs", "scripts/docs-audit/check-affected-docs.mjs", "scripts/docs-audit/check-drift-comment.mjs", "scripts/pm/release-rehearsal-clone.mjs --self-test" ], "red": [], "notMeasured": [ "check:type-check-debt — the FAMILY did not run: `check-type-check-coverage.mjs --re-measure` refuses without a built workspace closure, and that throw is NOT MEASURED, never 'not applicable'. Substituted a declared, checkable narrowing instead of leaving it dark: the program it would run for the ONE ledgered package in this diff was reproduced directly (tsc --noEmit over packages/objectql with tests included — they are excluded from the package tsconfig, which is why the package carries a TEST_DEBT entry and why `pnpm typecheck` cannot see the new file). 355 errors, 0 of them in the new file and 0 in the sibling trap-set file; 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 single TS6059 rootDir diagnostic that measureTestDebt drops via dropRootDirDiagnostics. So the recorded number cannot drift upward from this change, and no other ledger entry is in the diff. That measurement also caught a real defect in my own test file: it first used Object.hasOwn (ES2022) against this workspace's lib: [ES2020], which would have added 2x TS2550 and reddened the ratchet invisibly to `pnpm typecheck`; it now uses Object.prototype.hasOwnProperty.call, with the reason on the line." ] }, "ablations": [ { "leg": "revert the mirror to the synthesis literal that stood on main", "predicted_before_running": "RED — exactly 5 of 6 in hook-input-descriptor-mirror.test.ts (REPRODUCTION, `writable: false`, INVARIANT non-configurable, INHERITED, accessor-getter-not-run), POSITIVE CONTROL stays green, and the sibling #12277 hook-input-mutation-traps.test.ts stays 6/6 green — i.e. 5 failed of 12 across the two files.", "observed": "5 failed | 7 passed (12), and the five failures are exactly the five named. Sibling file 6/6 green — the existing #12277 pins cannot catch this defect, because the synthesis is the truth for ordinary assigned keys.", "mutation_proved_on_disk": "anchored grep -cF taken BEFORE any result was read: injected anchor `const own = Object.getOwnPropertyDescriptor(data, prop);` 1 -> 0; synthesis anchor `return { configurable: true, enumerable: true, writable: true, value: (data as any)[prop] };` 0 -> 1.", "rebuild": "No rebuild leg owed on either side, and this is a claim about the resolution path, not about the suite's name: the pin file imports './hook-wrappers.js' RELATIVELY inside its own package, so vitest transforms src directly and no dist sits between the mutation and the assertion. Evidenced independently — the pre-fix and post-fix runs differed with no build between them.", "restore": "`trap restore EXIT INT TERM`, restoring ONLY the one mutated path (never `git checkout HEAD -- .`). Verified: anchors back to injected=1/synthesis=0 and `git diff --exit-code` RC=0 (clean tree)." } ], "openQuestions": [ { "question": "May a record payload carry an ACCESSOR (get/set), and what should the engine do when it persists one by evaluating the payload? UNCHANGED by this PR and deliberately not decided — reported so the fork stays visible, not as a blocker.", "options": [ "A — leave it open: defineProperty keeps routing whatever it is given into data (#12277) and the descriptor trap keeps reporting it faithfully. This is the shipped state.", "B — refuse accessors at the routing point (defineProperty trap), so a payload can only ever hold data properties.", "C — flatten an accessor to its evaluated value at definition time." ], "recommendation": "A for this card: the implementation did not need a position, and the mirror decides nothing — it reports whatever is there under every answer. The accessor pin asserts only the two facts true under all three options (the trap does not throw; a descriptor read does not invoke the getter). Note that the alternative shape for the trap — flattening the accessor to { value: data[prop] } — WOULD have been a position, and it is exactly the side-effecting synthesis this PR removes. If the project wants B or C, it belongs at the routing/persistence seam, not in a read-only descriptor trap." } ], "out_of_scope_findings": [ "filed as #12578: the flat-input Proxy's `ownKeys` lists only data's ENUMERABLE keys (Object.keys(data)), so an own non-enumerable key is absent from Object.getOwnPropertyNames(input)/Reflect.ownKeys(input) while hasOwnProperty and — after this PR — the descriptor trap both report it as own; symbol keys are never enumerated either. Not repaired here: the bounded-in-place test fails on the 'mechanical, shape pinned by existing evidence' condition — Reflect.ownKeys(data) would newly expose symbol keys and cuts across the sandbox's documented 'materialises only what ownKeys enumerates' contract (body-runner.ts), whose own test double already models the trap as Reflect.ownKeys. Unassigned, `finding` label, no pm:queue. Dedup: REST channel 403 (#12293 shape) so the 100-most-recent open-issue scan went through the MCP list endpoint — declared, per the precedent triage accepted on this card's own filing — plus a local grep for ownKeys/getOwnPropertyNames across packages/**.", "NOT filed, recorded on #12397 as the charter directs: the applyMutationsToInput bigint residual in packages/runtime/src/sandbox/body-runner.ts (the JSON.stringify deletability probe throws on a bigint and drops the key, so a delete of a bigint-valued key in a sandboxed body is still lost). It rides along only if that file is already open for another reason — it is not; this fix is entirely in objectql — so it stays recorded rather than becoming a second card." ] }Generated by Claude Code
docs+changeset: document the four reserved names on ctx.input, add ch…
fix(objectql): refuse a symbol key at the flat-input set/defineProper…
fix(objectql): mirror data's own descriptor in the flat-input Proxy (o…
fix(objectql): flat-input envelope wins consistently across get/getOw…
fix(objectql): refuse a symbol key at the flat-input set/defineProper…