Skip to content

[finding] The flat-input Proxy's ownKeys lists only data's ENUMERABLE keys — an own non-enumerable key is invisible to getOwnPropertyNames while hasOwnProperty and the descriptor trap both report it #12578

Description

@os-warren

Found while implementing #12397 (descriptor mirror). Unassigned, observation class — no measured consumer, and the repair is a behaviour decision rather than a mechanical one, so it is recorded rather than ridden along.

What it does

installFlatInput (packages/objectql/src/hook-wrappers.ts) answers ownKeys from Object.keys(data):

ownKeys(target) {
  const dataKeys = target.data && typeof target.data === 'object'
    ? Object.keys(target.data)
    : [];
  return Array.from(new Set(dataKeys));
},

Object.keys is own enumerable string keys only. So a key data holds as own-but-non-enumerable — or under a symbol — is absent from Object.getOwnPropertyNames(ctx.input) and Reflect.ownKeys(ctx.input) entirely, even though it is genuinely an own property of the payload the engine persists.

Why it is worth writing down now

#12277 routed defineProperty into data, so a hook can put such a key there for the first time; #12397 then made the descriptor trap tell the truth about it. After that fix the trap set answers one payload three ways:

Object.defineProperty(ctx.input, 'k', { value: 1, enumerable: false, configurable: true });

Object.getOwnPropertyDescriptor(ctx.input, 'k');            // { value: 1, enumerable: false, … }  ← own, per #12397
Object.prototype.hasOwnProperty.call(ctx.input, 'k');       // true                                ← own
Object.getOwnPropertyNames(ctx.input);                      // [ 'subject' ]                       ← not own?

Object.keys / spread / Object.entries are all correct here (they filter by enumerable and the key is not enumerable), which is why this is narrow: the inconsistency is confined to the own-key enumeration surfaces. It is legal for a proxy — the target is extensible and carries no non-configurable own key, so no [[OwnPropertyKeys]] invariant is violated — just untrue.

Why it was not repaired inside #12397

That card's charter was the descriptor trap, and the ownKeys filtering is deliberate and documented at the call site: it exists to keep the wrapper keys (id/options/ast/data) out of Object.keys/for-in. Its use of Object.keys(data) rather than Reflect.ownKeys(data) looks incidental to that intent, but replacing it is a decision with reach, not a mechanical fix:

  • Reflect.ownKeys(data) would newly expose symbol keys through the proxy, which nothing has considered.
  • packages/runtime/src/sandbox/body-runner.ts documents its snapshot as "materialises only what installFlatInput's ownKeys enumerates" (unwrapProxyToPlain, via Object.entries). Object.entries filters by enumerable, so the marshalled set would not in fact change — but that is a claim about today's spelling of the consumer, and the sandbox test double at body-runner.test.ts:141 models the trap as Reflect.ownKeys(t), i.e. the two already disagree about what the contract is.
  • Which surface is authoritative for "what keys does the payload have" — ownKeys, or the descriptor trap — is exactly the question, and it is answerable only against what a payload is allowed to hold (open in [finding] The flat-input Proxy's getOwnPropertyDescriptor synthesises a descriptor instead of mirroring data's — now reachable, since #12277 routed defineProperty into data #12397's terms).

Repro

// beforeInsert hook, caller payload { subject: 'help' }
Object.defineProperty(ctx.input, 'k', { value: 1, enumerable: false, configurable: true });
Object.getOwnPropertyNames(ctx.input);  // ['subject'] — 'k' is on `data`, own, and absent here

Dedup

Scanned the 100 most recently created open issues plus a local grep for ownKeys / getOwnPropertyNames across packages/** — no existing card. ⚠️ The contract's prescribed REST dedup channel returned 403 (GitHub access is not enabled for this session) for this session too, which is #12293's shape, so the issue scan went through the MCP list endpoint — declared rather than reported as a clean search.

Activity

  1. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    Collaborator

    Triage: leveled finding → pm:queue / domain:engine / Task (label swapped per first-touch grading). Rationale: a real own-key enumeration inconsistency, newly reachable since #12277 routed defineProperty into data and #12397 made the descriptor trap honest, but with no measured consumer breaking today; the repair is an engine-lane behavior pick (mirror the target's full own-key set minus wrapper keys, versus documenting ownKeys as the enumerable face), and the sandbox test double at body-runner.test.ts:141 already models the trap differently from the implementation, so the card should also settle which spelling is the contract. Serial constraint: same file (packages/objectql/src/hook-wrappers.ts) as in-flight #12397 — dispatch only after #12397's PR lands, and re-verify the trap set on the merged ref. Size/model suggestion: S–M; behavior decision inside one trap set, suggest opus.


    Generated by Claude Code

  2. self-assigned this
    on Aug 26, 2026
  3. os-warren commented on Aug 26, 2026

    @os-warren
    CollaboratorAuthor

    Serial hold discharged — claimed and dispatched

    • Session: session_01W6HFzyH98W1YaQXhJUJt6o (seat post [PM seat] domain:engine — ⏳ vacant #6367)
    • Branch: claude/issue-12578-ownkeys-own-key-set
    • Comments re-read in the same action as the claim: one, triage's grading and serial constraint. No prior claim by any other session.

    Triage's condition — "dispatch only after #12397's PR lands" — is met, checked two ways because a branch existing is not the same as a hold standing:

    1. By content on origin/main: Object.getOwnPropertyDescriptor(data, prop) resolves in hook-wrappers.ts, with installFlatInput firing twice in the same scan as the positive control.
    2. By merge state: PR fix(objectql): mirror data's own descriptor in the flat-input Proxy instead of synthesising one #12581 is merged: true, closed 12:41:57Z.

    A branch scan still flags claude/issue-12397-flat-input-descriptor-mirror as touching this file — but that is a stale post-squash branch, not a live claim. Worth writing down: after a squash merge the three-dot diff from the old merge base still shows the branch's changes, so a branch-only serial check reports a hold that no longer exists. The open-PR state is the authority.

    Triage's second instruction is carried into the brief as the card's real work: settle which spelling is the contract. The implementation uses Object.keys(data); the sandbox test double at body-runner.test.ts:141 already models the trap as Reflect.ownKeys — so the tree currently holds two different answers and neither is declared. Whichever way the fix goes, the disagreement is what gets closed.

    The fork triage names is an engine-lane behaviour pick, explicitly assigned to this lane rather than escalated: mirror the target's full own-key set minus wrapper keys, versus documenting ownKeys as the enumerable face. ⛔ But the boundary from #12397 still holds and is repeated in the brief: if the repair turns into a question about what a record payload may hold — symbol keys becoming reachable is the obvious way in — that is a payload-contract question and a maintainer floor. Report the fork rather than deciding it.

    Serial check: packages/objectql/src/hook-wrappers.ts is free. The in-flight #12166 is in the objectql delete path, a different file; #11728 is in packages/cli.


    Generated by Claude Code

  4. os-warren commented on Aug 26, 2026

    @os-warren
    CollaboratorAuthor
    {
      "issue": 12578,
      "branch": "claude/issue-12578-ownkeys-own-key-set",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12602",
      "status": "done",
      "premise_still_valid": true,
      "premise_evidence": "Re-verified by symbol on the merged ref f93df4dbe3, not inherited: installFlatInput resolves at hook-wrappers.ts:504, its ownKeys trap read Object.keys(target.data), and the #12397 descriptor mirror (Object.getOwnPropertyDescriptor(data, prop) + forced configurable) is present. The three-instrument disagreement then reproduced through the real proxy before any repair.",
      "summary": "installFlatInput's ownKeys trap answered from Object.keys(data) — own ENUMERABLE string keys — so an own non-enumerable key on the record payload was absent from Object.getOwnPropertyNames/Reflect.ownKeys while hasOwnProperty and the #12397 descriptor mirror both reported it, and while the engine persisted the row holding it. The trap now reports Object.getOwnPropertyNames(data). The enumerable face (Object.keys, spread, Object.entries, for..in, JSON.stringify) is byte-identical, because each applies the enumerable filter itself through the descriptor trap — which is what leaves the sandbox snapshot contract (unwrapProxyToPlain, an Object.entries over this proxy) untouched. The card's real work is done: the contract spelling is settled and declared in three places that previously held two undeclared answers.",
      "forkChosen": "Option 1 — mirror the target's own-key set minus wrapper keys — spelled Object.getOwnPropertyNames(data), deliberately NOT Reflect.ownKeys(data).",
      "forkReasoning": "Option 2 (declare ownKeys the enumerable face and make the other instruments agree with it) requires the descriptor trap and hasOwnProperty to report a genuinely-own key as NOT own. That reverts #12397's mirror for exactly the case it was built for, and replaces the current inconsistency with a worse one: the key would be invisible to all three instruments and still persist — the #12277 silent-success shape that body-runner.ts names as having no instrument to catch it. Option 1 instead makes the three instruments agree with each other AND with the persisted row. [[OwnPropertyKeys]] is also the wrong layer for an enumerability filter: every consumer that wants one applies it itself, one layer up, so filtering here made no answer cleaner and only starved the two surfaces whose job is to report the whole set. The spelling is getOwnPropertyNames rather than Reflect.ownKeys because the symbol delta is a payload-contract question and a maintainer floor (see openQuestions) — the status-quo-preserving half of option 1 is taken, and the deferral is declared in the code comment and pinned in the test so the omission cannot later read as a decision.",
      "contractSpellingSettled": "YES — the tree's two undeclared answers are now one declared answer in three places. DECLARED: packages/spec/src/data/hook.zod.ts (the hook-context contract an app author reads) now states that ownKeys reports the payload's own key set rather than its enumerable subset, that the three own-ness instruments agreeing IS the contract, and that symbols are the one remaining split, left open deliberately. IMPLEMENTED: hook-wrappers.ts. MODELLED: the sandbox test double at packages/runtime/src/sandbox/body-runner.test.ts:141 changed from Reflect.ownKeys(t) to Object.getOwnPropertyNames(t) — declared here, in the PR body, and on the line in the test file itself. Two deliberate exceptions are pinned AS exceptions so neither reads as residue of the defect: wrapper keys (hidden from enumeration by design; hasOwnProperty and the descriptor trap still report them) and the open symbol half.",
      "clause2": "YES — observable behaviour change on a shipped surface, and it WIDENS what enumeration exposes. Not asserted bare: previously-invisible keys become visible to Object.getOwnPropertyNames and Reflect.ownKeys ONLY; it is bounded to keys the payload genuinely owns and the engine already persists; no accept set is narrowed, no rejection path added or removed, no public type or spec shape changed (the spec edit is prose declaring the contract).",
      "clause2_evidence": "git diff --stat behind the claim, 6 files / +368 / -11: .changeset/flat-input-ownkeys-own-key-set.md +48 | packages/objectql/src/hook-input-ownkeys-agreement.test.ts +225 | packages/objectql/src/hook-wrappers.ts +64/-8 (executable change is the trap body alone; the remainder is the comment stating the contract and its two exceptions) | packages/runtime/src/sandbox/body-runner.test.ts +11/-1 | packages/runtime/src/sandbox/body-runner.ts +19/-3 | packages/spec/src/data/hook.zod.ts +12. The widening is measured, not estimated: the same probe run before and after the repair differs by exactly two lines — a non-enumerable key's enumeration surface flips false->true, and Object.getOwnPropertyNames(input) goes ['subject'] -> ['subject','k'], matching Object.getOwnPropertyNames(persisted row). Object.keys, spread, Object.entries and JSON.stringify are byte-identical across the same pair, as are the symbol and wrapper-key readings.",
      "gates": {
        "green": [
          "@objectstack/objectql full suite — 'Test Files 239 passed (239)' · 'Tests 4203 passed (4203)'",
          "@objectstack/runtime src/sandbox/ (after rebuild, reach proved) — 'Test Files 15 passed (15)' · 'Tests 168 passed (168)' — includes perrow-dispatch-signal.integration.test.ts, the REAL ObjectQL + SqlDriver + QuickJS composition",
          "typecheck objectql / runtime / spec — all 'Done'; spec also 'check:test-typecheck: OK'",
          "pnpm lint (whole repo, eslint . --no-inline-config) — clean in 63s; run in full, so no narrowing argument is owed",
          "check:nul-bytes — 'OK (scanned 6951 text file(s) … no raw ASCII control bytes)'",
          "spec check:authorable-surface — '✅ Successfully generated 1596 schemas.'; git status clean afterwards, so no generated-artifact drift",
          "spec check:liveness / check:empty-state / check:strictness-ledger / check:variant-docs — all ✓",
          "check:engine-double-contract — 'OK — 689 pinned, 134 in the DEBT ledger, 3 exempt'",
          "check:objectql-double-limit — 'ObjectQL double `limit` conformance holds: 280 double(s) graded'",
          "check:cross-package-test-inputs — 'OK: 20 package(s) read outside themselves, all declared'",
          "check:test-source-alias — 'OK — 72 packages with tests scanned'",
          "check:type-check-debt (--re-measure) — 'OK — 31 ledger entr(ies) re-measured in 236.1s, 1687 raw tsc error(s) total, none above its recorded number'",
          "check: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-tests, lint check:doc-formula-expressions — exit 0 each",
          "check-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-comment — exit 0 each",
          "check-dev-prereqs — green ('✓ 67 package build artifacts present') after the full package build"
        ],
        "red": [],
        "notMeasured": [
          "CI's own conclusion — reported at draft-PR time per the standing contract; no CI wait was performed",
          "The 145 of 176 check families the path derivation did not place (34 undetermined + 111 silent). CI runs the farm on every PR regardless; dispatch-gates names their silence as a weak claim, not a clearance",
          "Full `pnpm test` for packages other than @objectstack/objectql; @objectstack/runtime was run at src/sandbox/ scope, not whole-package",
          "Sibling repos objectui / cloud — untouched by this change",
          "check-dev-prereqs' FIRST run exited 1 and measured NOTHING about this diff: its own verdict was 'The workspace is not built — 1 unmet precondition, not a list of problems' (38 of 67 packages had no dist in the fresh worktree). Recorded because an exit 1 that measured nothing must not be reported as a red, and the same exit after building is what makes it a measurement"
        ],
        "unionDerivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run with NO paths so the script took its own change set from the merge base (6 paths, three-dot, vs f93df4dbe3) — 31 path-matched families plus the convention-triggered set for adding test files. All 31 matched families were run locally. Every exit was captured BEFORE any pipe (cmd > log 2>&1; ec=$?), and every verdict quoted above is the gate's own printed line, never a bare $?. Verify-lock verdict lines were read for the wrapped runs rather than the wrapper's exit."
        },
      "ablations": [
        {
          "name": "revert the repaired trap line",
          "mutation": "packages/objectql/src/hook-wrappers.ts — Object.getOwnPropertyNames(target.data) -> Object.keys(target.data) (the pre-fix spelling; comments untouched)",
          "predictedInWritingFirst": "RED, exactly 2 failed / 32 passed of 34 across the four hook-input suites; both failures in hook-input-ownkeys-agreement.test.ts (the REPRODUCTION case, and ONLY the final ownNames assertion of the enumerable-face case); the not-own control, the ordinary-key control, the wrapper-key exception and the open symbol half all stay green, as do hook-input-descriptor-mirror, hook-input-mutation-traps and hook-input-shape-contract",
          "observed": "MATCHED exactly. 'Test Files 1 failed | 3 passed (4)' · 'Tests 2 failed | 32 passed (34)'. Failure 1: expected { enumeration: false } to deeply equal { enumeration: true }. Failure 2: expected [ 'subject' ] to deeply equal [ 'subject', 'hidden' ] at line 152 — i.e. lines 148-151 (Object.keys, spread, entries, JSON) PASSED under the mutation, which is the leg proving the enumerable face untouched by either spelling. Verify-lock verdict: 'VERDICT command-exit 1' — a genuine red read from the command, not the wrapper.",
          "mutationProvedOnDisk": "Anchored grep -cF counts taken BEFORE any result was read, on the full expression (which occurs only in code, never in the prose that mentions the bare method name): injected 'Object.keys(target.data)' = 1, removed 'Object.getOwnPropertyNames(target.data)' = 0, printed as MUTATION-CONFIRMED-ON-DISK. The script exits 70 with 'results void' if either count is wrong, so a zero-hit edit voids the run instead of passing silently.",
          "rebuildDecision": "NO REBUILD OWED, justified by import form rather than assertion: the pin imports './hook-wrappers.js' — a relative specifier inside the same package, which vitest resolves to the TypeScript source; no package exports and no dist on the path. Corroborated empirically, since the before/after measurements differed with no build between them. @objectstack/runtime's sandbox tests DO reach objectql through exports -> dist, and no ablation leg touches them; that suite was run only after pnpm --filter '@objectstack/runtime^...' build, with reach proved in the artifact — packages/objectql/dist/index.js:3721 carries Object.getOwnPropertyNames(target.data) and the pre-fix spelling is absent (grep -cF = 0).",
          "restore": "Under trap '<restore>' EXIT INT TERM, so a foreground-cap SIGTERM mid-mutation could cost a reading but never leave a mutated tree. Restore verified byte-for-byte with cmp against the pre-ablation copy (IDENTICAL) and anchors back to 1/0. DECLARED DEVIATION: the restore was checked by byte comparison rather than by an empty git diff, because the ablation ran before the repair was committed — cmp is the stronger of the two checks, but it is not the one the standard clause names."
        }
      ],
      "openQuestions": [
        {
          "question": "Should the flat-input proxy's ownKeys publish SYMBOL keys the record payload holds — i.e. Reflect.ownKeys(data) rather than Object.getOwnPropertyNames(data)? Reported, NOT decided: this is the payload-contract floor #12397 drew and the brief re-drew.",
          "measurement": "The card assumed symbol keys would 'newly become reachable'. Measured, they are already reachable and already persist: input[sym] = 'symvalue' routes through the set trap into data; Object.getOwnPropertyDescriptor(input, sym) reports own + enumerable; hasOwnProperty(input, sym) is true; Object.getOwnPropertySymbols(input) is [] — the SAME three-instrument disagreement as the string half — and Object.getOwnPropertySymbols(raw.data) is [Symbol(...)] on the row the engine persists. So the question is not whether a hook can put a symbol on the payload (it can, today), but what the enumeration face should publish about a payload that holds one.",
          "options": [
            "A — leave as shipped here: getOwnPropertyNames. Symbols stay unenumerated; the string half of the disagreement is closed, the symbol half stays open and is now PINNED in its open state so answering it changes a recorded fact. Costs nothing today, but the tree keeps one instance of the very inconsistency this card exists to remove.",
            "B — switch to Reflect.ownKeys(data). Closes the symbol half too and makes the agreement total for all non-wrapper keys. Cost is real and specific: enumerable symbol keys would newly be copied by spread and Object.assign of the proxy, and reported by Object.getOwnPropertySymbols. The sandbox snapshot is unaffected either way (Object.entries drops symbols by definition), so this is not blocked by the body-runner contract.",
            "C — answer the prior question first: decide whether a record payload may carry a symbol key AT ALL, and if not, refuse it at the set/defineProperty traps rather than merely hiding it from enumeration. This is the only option that removes the hiding rather than choosing how much of it to keep, and it is contract-first: today the payload accepts and persists a key that no enumeration surface will ever show."
          ],
          "recommendation": "C, then B falls out of it. Hiding a key the engine nonetheless persists is the failure shape this whole family (#12277, #12397, this card) exists to abolish — an author gets no instrument that reports what the row will actually hold. If a symbol key is not a legal payload field, the honest fix is a loud refusal at the write, not silent invisibility at the read; if it IS legal, then B and enumeration should show it. A is what shipped here because it is the only one of the three that does not decide the payload contract, and deciding that is above this lane. One word of code either way (getOwnPropertyNames -> Reflect.ownKeys) once the maintainer answers.",
          "note": "The PR uses `Fixes #12578` as instructed by dispatch, so merging closes this card. If the maintainer wants the symbol question tracked, it needs its own card before or at merge — this report and the PR body are otherwise its only home."
        }
      ],
      "out_of_scope_findings": [
        "filed as #12601 (unassigned, `finding` + `domain:engine`): a record payload field literally named `id`/`options`/`ast`/`data` reads back the WRAPPER's value through the get trap while the descriptor trap reports the PAYLOAD's — measured `input.id` -> 'WRAPPER-ID' vs `Object.getOwnPropertyDescriptor(input,'id').value` -> 'PAYLOAD-ID', with the key listed by ownKeys, so `{...input}.id` and the sandbox's Object.entries snapshot both carry the envelope id under a payload field's name. Distinct mechanism from this card (the two traps order wrapper and payload differently; this card was the enumerable filter), and it fails the bounded in-place exemption on clause 2 — which side should win is a contract question colliding with the documented `input.id` spelling and D4's HookTargetRebindError ruling, not a mechanical fix. Untouched by this PR in either direction; measured identical before and after. Dedup: local grep across packages/** and docs/** found no in-tree record; the prescribed REST channel returned 403 ('GitHub access is not enabled for this session' — #12293's shape, confirmed GitHub-side and not the proxy), so the issue scan went through the MCP list endpoint over the 60 most recently created open issues — declared rather than reported as a clean REST search."
      ],
      "tests": "All readings taken at 2bdb36a433 (this branch's head; nothing committed after the gate union ran). Suites: @objectstack/objectql full — 'Test Files 239 passed (239)' / 'Tests 4203 passed (4203)'; @objectstack/runtime src/sandbox/ after rebuild — 'Test Files 15 passed (15)' / 'Tests 168 passed (168)'; new pin + the three sibling trap-set suites — 'Test Files 4 passed (4)' / 'Tests 34 passed (34)'. Typecheck objectql/runtime/spec all 'Done'. Repo-wide pnpm lint clean. Reproduction was measured BEFORE the repair and re-measured after, and the pair differs by exactly two lines (see clause2_evidence). Ablation predicted in writing first, proved on disk by anchored grep -cF before any result was read, restored under trap and verified byte-identical by cmp — details in the ablations entry. Every heavy run went through scripts/pm/os-verify-lock.sh and its VERDICT line was read rather than a bare $?."
    }

    Generated by Claude Code

  5. os-warren commented on Aug 26, 2026

    @os-warren
    CollaboratorAuthor

    os-dev-report

    ⚠️ This supersedes the previous comment — same report, marker repaired. That one was posted with the prescribed HTML-comment marker and GitHub's body sanitizer ate it on the way into storage, so it is invisible to a marker scan. Read back and confirmed, then reposted with the marker as literal text per the contract's fallback. The same sanitizer also ate an angle-bracketed fragment inside the JSON there, which made the ablation's trap clause read as an empty trap; it is respelled below. There is no comment-edit route from this seat (the REST PATCH channel returns 403, #12293's shape), so a repost is the only repair available. This is the authoritative copy; the earlier one is unmarked and damaged.

    {
      "issue": 12578,
      "branch": "claude/issue-12578-ownkeys-own-key-set",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12602",
      "status": "done",
      "premise_still_valid": true,
      "premise_evidence": "Re-verified by symbol on the merged ref f93df4dbe3, not inherited: installFlatInput resolves at hook-wrappers.ts:504, its ownKeys trap read Object.keys(target.data), and the #12397 descriptor mirror (Object.getOwnPropertyDescriptor(data, prop) + forced configurable) is present. The three-instrument disagreement then reproduced through the real proxy before any repair.",
      "summary": "installFlatInput's ownKeys trap answered from Object.keys(data) — own ENUMERABLE string keys — so an own non-enumerable key on the record payload was absent from Object.getOwnPropertyNames/Reflect.ownKeys while hasOwnProperty and the #12397 descriptor mirror both reported it, and while the engine persisted the row holding it. The trap now reports Object.getOwnPropertyNames(data). The enumerable face (Object.keys, spread, Object.entries, for..in, JSON.stringify) is byte-identical, because each applies the enumerable filter itself through the descriptor trap — which is what leaves the sandbox snapshot contract (unwrapProxyToPlain, an Object.entries over this proxy) untouched. The card's real work is done: the contract spelling is settled and declared in three places that previously held two undeclared answers.",
      "forkChosen": "Option 1 — mirror the target's own-key set minus wrapper keys — spelled Object.getOwnPropertyNames(data), deliberately NOT Reflect.ownKeys(data).",
      "forkReasoning": "Option 2 (declare ownKeys the enumerable face and make the other instruments agree with it) requires the descriptor trap and hasOwnProperty to report a genuinely-own key as NOT own. That reverts #12397's mirror for exactly the case it was built for, and replaces the current inconsistency with a worse one: the key would be invisible to all three instruments and still persist — the #12277 silent-success shape that body-runner.ts names as having no instrument to catch it. Option 1 instead makes the three instruments agree with each other AND with the persisted row. OwnPropertyKeys is also the wrong layer for an enumerability filter: every consumer that wants one applies it itself, one layer up, so filtering here made no answer cleaner and only starved the two surfaces whose job is to report the whole set. The spelling is getOwnPropertyNames rather than Reflect.ownKeys because the symbol delta is a payload-contract question and a maintainer floor (see openQuestions) — the status-quo-preserving half of option 1 is taken, and the deferral is declared in the code comment and pinned in the test so the omission cannot later read as a decision.",
      "contractSpellingSettled": "YES — the tree's two undeclared answers are now one declared answer in three places. DECLARED: packages/spec/src/data/hook.zod.ts (the hook-context contract an app author reads) now states that ownKeys reports the payload's own key set rather than its enumerable subset, that the three own-ness instruments agreeing IS the contract, and that symbols are the one remaining split, left open deliberately. IMPLEMENTED: hook-wrappers.ts. MODELLED: the sandbox test double at packages/runtime/src/sandbox/body-runner.test.ts:141 changed from Reflect.ownKeys(t) to Object.getOwnPropertyNames(t) — declared here, in the PR body, and on the line in the test file itself. Two deliberate exceptions are pinned AS exceptions so neither reads as residue of the defect: wrapper keys (hidden from enumeration by design; hasOwnProperty and the descriptor trap still report them) and the open symbol half.",
      "clause2": "YES — observable behaviour change on a shipped surface, and it WIDENS what enumeration exposes. Not asserted bare: previously-invisible keys become visible to Object.getOwnPropertyNames and Reflect.ownKeys ONLY; it is bounded to keys the payload genuinely owns and the engine already persists; no accept set is narrowed, no rejection path added or removed, no public type or spec shape changed (the spec edit is prose declaring the contract).",
      "clause2_evidence": "git diff --stat behind the claim, 6 files / +368 / -11: .changeset/flat-input-ownkeys-own-key-set.md +48 | packages/objectql/src/hook-input-ownkeys-agreement.test.ts +225 | packages/objectql/src/hook-wrappers.ts +64/-8 (executable change is the trap body alone; the remainder is the comment stating the contract and its two exceptions) | packages/runtime/src/sandbox/body-runner.test.ts +11/-1 | packages/runtime/src/sandbox/body-runner.ts +19/-3 | packages/spec/src/data/hook.zod.ts +12. The widening is measured, not estimated: the same probe run before and after the repair differs by exactly two lines — a non-enumerable key's enumeration surface flips false to true, and Object.getOwnPropertyNames(input) goes ['subject'] to ['subject','k'], matching Object.getOwnPropertyNames(persisted row). Object.keys, spread, Object.entries and JSON.stringify are byte-identical across the same pair, as are the symbol and wrapper-key readings.",
      "gates": {
        "green": [
          "@objectstack/objectql full suite — 'Test Files 239 passed (239)' · 'Tests 4203 passed (4203)'",
          "@objectstack/runtime src/sandbox/ (after rebuild, reach proved) — 'Test Files 15 passed (15)' · 'Tests 168 passed (168)' — includes perrow-dispatch-signal.integration.test.ts, the REAL ObjectQL + SqlDriver + QuickJS composition",
          "typecheck objectql / runtime / spec — all 'Done'; spec also 'check:test-typecheck: OK'",
          "pnpm lint (whole repo, eslint . --no-inline-config) — clean in 63s; run in full, so no narrowing argument is owed",
          "check:nul-bytes — 'OK (scanned 6951 text file(s) … no raw ASCII control bytes)'",
          "spec check:authorable-surface — 'Successfully generated 1596 schemas.'; git status clean afterwards, so no generated-artifact drift",
          "spec check:liveness / check:empty-state / check:strictness-ledger / check:variant-docs — all green",
          "check:engine-double-contract — 'OK — 689 pinned, 134 in the DEBT ledger, 3 exempt'",
          "check:objectql-double-limit — 'ObjectQL double limit conformance holds: 280 double(s) graded'",
          "check:cross-package-test-inputs — 'OK: 20 package(s) read outside themselves, all declared'",
          "check:test-source-alias — 'OK — 72 packages with tests scanned'",
          "check:type-check-debt (--re-measure) — 'OK — 31 ledger entr(ies) re-measured in 236.1s, 1687 raw tsc error(s) total, none above its recorded number'",
          "check: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-tests, lint check:doc-formula-expressions — exit 0 each",
          "check-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-comment — exit 0 each",
          "check-dev-prereqs — green ('67 package build artifacts present') after the full package build"
        ],
        "red": [],
        "notMeasured": [
          "CI's own conclusion — reported at draft-PR time per the standing contract; no CI wait was performed",
          "The 145 of 176 check families the path derivation did not place (34 undetermined + 111 silent). CI runs the farm on every PR regardless; dispatch-gates names their silence as a weak claim, not a clearance",
          "Full 'pnpm test' for packages other than @objectstack/objectql; @objectstack/runtime was run at src/sandbox/ scope, not whole-package",
          "Sibling repos objectui / cloud — untouched by this change",
          "check-dev-prereqs' FIRST run exited 1 and measured NOTHING about this diff: its own verdict was 'The workspace is not built — 1 unmet precondition, not a list of problems' (38 of 67 packages had no dist in the fresh worktree). Recorded because an exit 1 that measured nothing must not be reported as a red, and the same exit after building is what makes it a measurement"
        ],
        "unionDerivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run with NO paths so the script took its own change set from the merge base (6 paths, three-dot, vs f93df4dbe3) — 31 path-matched families plus the convention-triggered set for adding test files. All 31 matched families were run locally. Every exit was captured BEFORE any pipe (cmd redirected to a log, then ec=$?), and every verdict quoted above is the gate's own printed line, never a bare $?. Verify-lock VERDICT lines were read for the wrapped runs rather than the wrapper's exit."
      },
      "ablations": [
        {
          "name": "revert the repaired trap line",
          "mutation": "packages/objectql/src/hook-wrappers.ts — Object.getOwnPropertyNames(target.data) back to Object.keys(target.data) (the pre-fix spelling; comments untouched)",
          "predictedInWritingFirst": "RED, exactly 2 failed / 32 passed of 34 across the four hook-input suites; both failures in hook-input-ownkeys-agreement.test.ts (the REPRODUCTION case, and ONLY the final ownNames assertion of the enumerable-face case); the not-own control, the ordinary-key control, the wrapper-key exception and the open symbol half all stay green, as do hook-input-descriptor-mirror, hook-input-mutation-traps and hook-input-shape-contract",
          "observed": "MATCHED exactly. 'Test Files 1 failed | 3 passed (4)' · 'Tests 2 failed | 32 passed (34)'. Failure 1: expected { enumeration: false } to deeply equal { enumeration: true }. Failure 2: expected [ 'subject' ] to deeply equal [ 'subject', 'hidden' ] at line 152 — i.e. lines 148-151 (Object.keys, spread, entries, JSON) PASSED under the mutation, which is the leg proving the enumerable face untouched by either spelling. Verify-lock verdict: 'VERDICT command-exit 1' — a genuine red read from the command, not the wrapper.",
          "mutationProvedOnDisk": "Anchored grep -cF counts taken BEFORE any result was read, on the full expression (which occurs only in code, never in the prose that mentions the bare method name): injected 'Object.keys(target.data)' = 1, removed 'Object.getOwnPropertyNames(target.data)' = 0, printed as MUTATION-CONFIRMED-ON-DISK. The script exits 70 with 'results void' if either count is wrong, so a zero-hit edit voids the run instead of passing silently.",
          "rebuildDecision": "NO REBUILD OWED, justified by import form rather than assertion: the pin imports './hook-wrappers.js' — a relative specifier inside the same package, which vitest resolves to the TypeScript source; no package exports and no dist on the path. Corroborated empirically, since the before/after measurements differed with no build between them. @objectstack/runtime's sandbox tests DO reach objectql through exports to dist, and no ablation leg touches them; that suite was run only after pnpm --filter '@objectstack/runtime^...' build, with reach proved in the artifact — packages/objectql/dist/index.js:3721 carries Object.getOwnPropertyNames(target.data) and the pre-fix spelling is absent (grep -cF = 0).",
          "restore": "The mutate-and-run script installed a restoring trap on EXIT, INT and TERM (copying the pristine file back), so a foreground-cap SIGTERM mid-mutation could cost a reading but never leave a mutated tree. Restore verified byte-for-byte with cmp against the pre-ablation copy (IDENTICAL) and anchors back to 1/0. DECLARED DEVIATION: the restore was checked by byte comparison rather than by an empty git diff, because the ablation ran before the repair was committed — cmp is the stronger of the two checks, but it is not the one the standard clause names."
        }
      ],
      "openQuestions": [
        {
          "question": "Should the flat-input proxy's ownKeys publish SYMBOL keys the record payload holds — i.e. Reflect.ownKeys(data) rather than Object.getOwnPropertyNames(data)? Reported, NOT decided: this is the payload-contract floor #12397 drew and the brief re-drew.",
          "measurement": "The card assumed symbol keys would 'newly become reachable'. Measured, they are already reachable and already persist: input[sym] = 'symvalue' routes through the set trap into data; Object.getOwnPropertyDescriptor(input, sym) reports own + enumerable; hasOwnProperty(input, sym) is true; Object.getOwnPropertySymbols(input) is [] — the SAME three-instrument disagreement as the string half — and Object.getOwnPropertySymbols(raw.data) is [Symbol(...)] on the row the engine persists. So the question is not whether a hook can put a symbol on the payload (it can, today), but what the enumeration face should publish about a payload that holds one.",
          "options": [
            "A — leave as shipped here: getOwnPropertyNames. Symbols stay unenumerated; the string half of the disagreement is closed, the symbol half stays open and is now PINNED in its open state so answering it changes a recorded fact. Costs nothing today, but the tree keeps one instance of the very inconsistency this card exists to remove.",
            "B — switch to Reflect.ownKeys(data). Closes the symbol half too and makes the agreement total for all non-wrapper keys. Cost is real and specific: enumerable symbol keys would newly be copied by spread and Object.assign of the proxy, and reported by Object.getOwnPropertySymbols. The sandbox snapshot is unaffected either way (Object.entries drops symbols by definition), so this is not blocked by the body-runner contract.",
            "C — answer the prior question first: decide whether a record payload may carry a symbol key AT ALL, and if not, refuse it at the set/defineProperty traps rather than merely hiding it from enumeration. This is the only option that removes the hiding rather than choosing how much of it to keep, and it is contract-first: today the payload accepts and persists a key that no enumeration surface will ever show."
          ],
          "recommendation": "C, then B falls out of it. Hiding a key the engine nonetheless persists is the failure shape this whole family (#12277, #12397, this card) exists to abolish — an author gets no instrument that reports what the row will actually hold. If a symbol key is not a legal payload field, the honest fix is a loud refusal at the write, not silent invisibility at the read; if it IS legal, then B and enumeration should show it. A is what shipped here because it is the only one of the three that does not decide the payload contract, and deciding that is above this lane. One word of code either way (getOwnPropertyNames to Reflect.ownKeys) once the maintainer answers.",
          "note": "The PR uses the closing keyword on this card as dispatch instructed, so merging closes it. If the maintainer wants the symbol question tracked, it needs its own card before or at merge — this report and the PR body are otherwise its only home."
        }
      ],
      "out_of_scope_findings": [
        "filed as #12601 (unassigned, 'finding' + 'domain:engine'): a record payload field literally named id/options/ast/data reads back the WRAPPER's value through the get trap while the descriptor trap reports the PAYLOAD's — measured input.id gives 'WRAPPER-ID' vs Object.getOwnPropertyDescriptor(input,'id').value gives 'PAYLOAD-ID', with the key listed by ownKeys, so a spread of input and the sandbox's Object.entries snapshot both carry the envelope id under a payload field's name. Distinct mechanism from this card (the two traps order wrapper and payload differently; this card was the enumerable filter), and it fails the bounded in-place exemption on clause 2 — which side should win is a contract question colliding with the documented input.id spelling and D4's HookTargetRebindError ruling, not a mechanical fix. Untouched by this PR in either direction; measured identical before and after. Dedup: local grep across packages and docs found no in-tree record; the prescribed REST channel returned 403 ('GitHub access is not enabled for this session' — #12293's shape, confirmed GitHub-side and not the proxy), so the issue scan went through the MCP list endpoint over the 60 most recently created open issues — declared rather than reported as a clean REST search."
      ],
      "tests": "All readings taken at 2bdb36a433 (this branch's head; nothing committed after the gate union ran). Suites: @objectstack/objectql full — 'Test Files 239 passed (239)' / 'Tests 4203 passed (4203)'; @objectstack/runtime src/sandbox/ after rebuild — 'Test Files 15 passed (15)' / 'Tests 168 passed (168)'; new pin + the three sibling trap-set suites — 'Test Files 4 passed (4)' / 'Tests 34 passed (34)'. Typecheck objectql/runtime/spec all 'Done'. Repo-wide pnpm lint clean. Reproduction was measured BEFORE the repair and re-measured after, and the pair differs by exactly two lines (see clause2_evidence). Ablation predicted in writing first, proved on disk by anchored grep -cF before any result was read, restored under a trap on EXIT/INT/TERM and verified byte-identical by cmp — details in the ablations entry. Every heavy run went through scripts/pm/os-verify-lock.sh and its VERDICT line was read rather than a bare $?."
    }

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions