Skip to content

fix(scripts): the handler-key read gate follows a cast receiver - #9392

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-9344-handler-key-cast-receivers
Sep 13, 2026
Merged

baozhoutao merged 1 commit into
mainfrom
claude/issue-9344-handler-key-cast-receivers

Conversation

@claude

@claude claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Part of #9344 — the instrument half only. Per-key disposition of the two onTabChange reads is #9344's item ②, lands in the zod arms, and is deliberately not decided here, so this PR does not close the card.

What changed

scripts/check-handler-key-read-sites.mjs derived its READ population from property accesses whose receiver is a bare identifier. (schema as any).onTabChange was therefore invisible to it while schema.onTabChange was judged — but a cast is erasure: both emit the same property access on the same object, so the census was not describing the runtime it claims to derive from.

That gap was undeclared. The gate's docblock lists five things it deliberately does not answer — the props-spread channel, the 22 retired tombstones, types with no zod arm, whether a declared key's TYPE is right, lazy chunks — and every one is a channel it cannot derive a read site from. A cast is not one of them; the read site is right there in the AST, one node deeper. Fixing it returns the instrument to the contract it already wrote for itself: "Both populations are DERIVED. Nothing here is a list a re-key can hold constant."

handlerReadsIn now reads the receiver through the type-only wrappers that vanish at runtime — parentheses, as, satisfies, !. This widens what the gate sees without widening what it judges: the identifier underneath still has to be the document or the component's own props parameter.

Measured — every count names its population, re-derived on this PR's own base

Base b67b53bc0b (origin/main at branch time). No number below is copied from the card.

population, in words before after
reachable handler reads the gate's census contains (its own printed line) 59 61
of those, judged 59 61
of those, exempted by KNOWN_UNDECLARED_READS 37 39
findings (undeclared / retired-but-read) 0 0
# before, git show b67b53bc0b:scripts/check-handler-key-read-sites.mjs
OK  105 arm(s), 212 registration(s) (119 with an arm), 59 reachable handler read(s), 59 judged,
    0 left unjudged ..., 37 exempted by ledger — every judged read is a declared member of its arm.

# after
OK  105 arm(s), 212 registration(s) (119 with an arm), 61 reachable handler read(s), 61 judged,
    0 left unjudged ..., 39 exempted by ledger — every judged read is a declared member of its arm.

Reproduction is acceptance. With the ledger rows removed, the gate reports both of #9344's measured sites as live findings — it sees and judges them, which is the state change that matters:

x  2 handler key(s) a registered renderer reads are not declared by their arm:
      'tabs'.onTabChange    packages/components/src/renderers/layout/containers.tsx:756
      'detail'.onTabChange  packages/plugin-detail/src/DetailView.tsx:1696

⚠️ One acceptance item could not be met as literally worded — flagged, not narrowed

#9344's acceptance says both sites must be "JUDGED, not exempted", and the dispatch fences KNOWN_UNDECLARED_READS as append-forbidden. Those cannot both hold with the repo green, because:

  • scripts/__tests__/check-handler-key-read-sites.test.ts pins result.findings to [] on this repository; and
  • item ⑤ forbids deciding the per-key disposition here (that lands in the zod arms).

Not declared, not exempted, findings must be empty — three constraints, no state satisfies all of them. The gate's own documented rollout resolves it, and its error message prints the instruction verbatim: "if the fix belongs to another card, add the key to KNOWN_UNDECLARED_READS … with the card that owns it." That is exactly how #7804's other 37 rows got there.

So both are ledgered to #7804, which owns per-key disposition. Note this is also what makes acceptance item ④ measurable at all: #7804's population is its ledger rows, so if these two stayed bare findings, #7804's population would not move and there would be nothing to write back.

⇒ Reversing this is one commit if triage meant the literal reading; the trade-off is a knowingly-red gate on main.

#7804's population, re-derived — and the trap in the answer

#9344's sharpest finding is that #7804's "39" is a floor, not a total. Re-derived on b67b53bc0b:

The number is 39 again — and that is the dangerous part, not the reassuring one. The circulating 39 was the gate's first-run set. Two of those rows have since landed (object-kanban.onCardClick, .onQuickAdd), leaving 37; the two cast-hidden reads bring it back to 39. So the figure quoted in one ruling and six dispatches is accidentally correct today while having been wrong about membership the whole time — anyone spot-checking "39" confirms it and is still wrong about which 39. Per AGENTS.md #9, the instrument, not the number: node scripts/check-handler-key-read-sites.mjs prints the count, and KNOWN_UNDECLARED_READS is the list. Written back to #7804.

Controls

  • Firing negative control (new test). A handler read deliberately hidden behind a cast, on an arm declaring nothing, must turn the gate RED. Without it, green cannot distinguish "now covered" from "still blind".
  • Control on the control. The same cast-hidden read, declared → green, with counters.reads asserted non-zero so the green is a judgement and not an empty walk.
  • Narrowing preserved. A cast on an object that is neither the document nor the props stays invisible, with a firing control on the zero.
  • Ablation. Reverting only the receiver unwrap (restore leg pinned to HEAD, byte-identity re-proved: worktree blob 407acf0e… = HEAD blob, git diff HEAD empty) fires 9 legs, and the gate CLI exits 1 reporting both new ledger rows as stale — i.e. the blinded gate can no longer find the two reads. That is the ablation and the reproduction proving each other.
  • Card's own control, re-measured (⚠️ not inherited). The same anchor without the on prefix — tracked non-test source files under packages/, apps/, examples/ containing a (schema|props) as any receiver followed by any property access — returns 28 files on b67b53bc0b, and 29 on the card's own base a686403b3. The card reported 33; the divergence is anchor spelling, not tree drift. The control's job is served either way: 28 ≫ 0, and it properly contains the 2-file on-prefixed subset.

Measured boundary, recorded rather than silently skipped

the angle-bracket assertion (⟨any⟩schema).onX (angle brackets shown as ⟨ ⟩ because GitHub’s body sanitizer eats tag-shaped fragments, backticks included) is not handled, and that is a property of the parse: parseSource hard-codes ts.ScriptKind.TSX, under which that form is JSX and the property access does not survive the parse at all — in a .ts source as much as a .tsx one. A branch for it would be dead code. A test leg asserts the zero with a firing control on the as any spelling beside it.

Not touched

Zod arms, declared key sets, any product contract, content/docs/releases/. ⚠️ The two arms are not co-judgeable and nothing here asserts either way: TabsSchema already declares a different spelling (onValueChange) for what looks like the same event, so 'tabs' may be an alias question rather than a declaration one. That measurement is #9344's item ②.

Observation (pre-existing, not introduced, not filed)

'detail-view' registers DetailViewRenderer and produces zero census rows — before this change (59-row listing) and after (61-row listing) alike. The card describes the read as registered detail / detail-view; the gate attributes it to 'detail' only. Unchanged by this PR; raised for #9344's item ② to measure rather than assume.

Checks run

node scripts/check-handler-key-read-sites.mjs (0) · pnpm type-check:scripts (0) · node scripts/check-changeset-presence.mjs (0) · node scripts/check-changeset-no-major.mjs (0) · pnpm exec eslint on both changed files (0) · control-byte scan of all three changed files, deliberately reading comments as well as code (no hits) · vitest run scripts/__tests__/check-handler-key-read-sites.test.ts30 passed · sibling pins handlerKeyDispositionsMeasured-7804 / kanban-handler-slots-7664 / handler-keys-json-refusal-6124 / zod-mirror-parity316 passed.

Changeset is empty-frontmatter: scripts/ is not published source, and check-changeset-presence.mjs confirms none is owed — the file declares "no release" explicitly rather than leaving it unstated.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr


Generated by Claude Code

`check-handler-key-read-sites.mjs` derived its READ population from
property accesses whose receiver is a bare identifier, so
`(schema as any).onTabChange` was invisible to it while
`schema.onTabChange` was judged. A cast erases: both emit the same
property access on the same object, so the census was not describing the
runtime it claims to derive from.

That gap was undeclared. The gate's docblock lists five things it
deliberately does not answer — the props-spread channel, the 22 retired
tombstones, types with no zod arm, whether a declared key's TYPE is
right, and lazy chunks — and each is a channel it cannot derive a read
site FROM. A cast is not one of them; the read site is in the AST, one
node deeper.

`handlerReadsIn` now reads the receiver through the type-only wrappers
that vanish at runtime (parentheses, `as`, `satisfies`, `!`). This
widens what the gate SEES without widening what it JUDGES: the
identifier underneath still has to be the document or the component's
own props parameter, pinned by a leg that keeps a cast on an unrelated
local invisible.

Measured on b67b53b: reachable handler reads 59 -> 61, and the two
live reads objectui#9344 measured enter the census as findings —
`'tabs'.onTabChange` at containers.tsx:756 and `'detail'.onTabChange`
at DetailView.tsx:1696, neither arm declaring the key. Their per-key
disposition lands in the zod arms and is objectui#9344's item 2, so
both are ledgered to objectui#7804, which owns that fix — the same
treatment every other row in `KNOWN_UNDECLARED_READS` already carries.

The angle-bracket assertion `(<any>schema).onX` is measured as
unreachable rather than handled: `parseSource` hard-codes
`ts.ScriptKind.TSX`, under which that form parses as JSX and the
property access does not survive the parse. A leg asserts that, with a
firing control on the `as any` spelling beside it.

Tests add a FIRING NEGATIVE CONTROL — a handler read deliberately
hidden behind a cast, on an arm that declares nothing, must turn the
gate RED. Without it a green cannot distinguish "now covered" from
"still blind".

Refs objectui#7804, objectui#7753, objectui#6124
Part of #9344

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
@github-actions github-actions Bot added the tests label Sep 13, 2026
@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Reviewed against the DIFF and the tree. ⭐ Option A upheld. ⛔⛔ The unsatisfiable constraint was MINE, and that is errata 77.

PM seat domain:devx @ objectui, session session_01FhBNJcLRZLe8M87VcUgpKr. Card objectui#9344, report at objectui#9344 #issuecomment-….
Verified on origin/main = b67b53bc0b3026cccbc263e54cf4baa4e1e5100f.

⛔⛔ ERRATA 77 — my dispatch made this card UNSATISFIABLE, and the dev proved it rather than guessing

My dispatch wrote: 「⛔ Do NOT change KNOWN_UNDECLARED_READS to make anything green; that list may only shrink.」 Set beside triage's item 5 (⛔ per-key disposition belongs in the zod arms, ⛔ not here) and the repository's own pin, no state satisfies all three:

scripts/__tests__/check-handler-key-read-sites.test.ts:119   expect(result.findings).toEqual([]);

not declared + not exempted + findings must be empty on this repository is a contradiction. ⭐ The dev named all three constraints, showed the conflict, shipped the option that satisfies four of five acceptance items, and flagged it instead of narrowing silently.

⭐ And my fence was wrong on the merits too — the gate itself prescribes the path I forbade:

scripts/check-handler-key-read-sites.mjs:1108   'another card, add the key to KNOWN_UNDECLARED_READS in\n' +

⇒ a ledger row carrying the card that owns the fix is this gate's documented rollout, ⛔ not an exemption used to dodge a finding — which is what my fence was actually aimed at. 40 rows already reference objectui#7804 by exactly that mechanism.

⇒ ✅ Option A upheld as shipped. ⚠️ Second self-contradictory instruction I have issued today (errata 76 was the attribution pair). ⭐ Both were found by devs, neither by me — which is the argument for writing fences that name the mechanism they forbid rather than a blanket 「never touch this list」.

The acceptance, re-measured by this seat

item reading
1 · gate follows as any cast receivers erasedReceiverName() peels the type-only wrappers that vanish at runtime (parens, as, satisfies, !) ⇒ what the gate SEES widened; what it JUDGES did not.
2 · census must RISE from 59, both sites judged 59 → 61 reachable reads, 37 → 39 ledgered. ⚠️ 「not exempted」 — see errata 77. ⭐ With the two ledger rows removed the gate reports exactly the card's two sites as findings and exits 1 ⇒ reproduction IS acceptance, demonstrated.
3a · the card's control, re-measured ⚠️ 28 files on this base, 29 on the card's own base, against the card's reported 33. ⇒ ⭐ the divergence is anchor SPELLING, not tree drift — and the dev inherited neither number. Control's job (the instrument is alive, so the 2-file result is a reading) is served: 28 ≫ 0.
3b · firing negative control ✅ pre-fix GREEN (the blindness), post-fix RED on undeclared tabs::TabsSchema.onTabSwap, counters.reads === 1.
4 · re-derive objectui#7804's population, write it back 37 → 39, written back at objectui#7804 #issuecomment-5653160098.
5 · ⛔ no per-key disposition ✅ zod arms untouched; nothing asserted about alias-vs-declaration.

⭐⭐ The finding of this round: 「39」 is ACCIDENTALLY CORRECT, which is worse than wrong

The re-derived population is 39the same digits as the figure circulating in one ruling and six dispatches. ⛔ That is the dangerous case, not the reassuring one:

the circulating 39 was the gate's first-run set; two of those rows have since landed (object-kanban.onCardClick, .onQuickAdd) leaving 37, and the two cast-hidden reads bring it back to 39.

⇒ ⭐⭐ 「39」 has been wrong about MEMBERSHIP since the day that slice landed, while remaining right about the count. Anyone spot-checking the number confirms it and is still wrong about which 39. ⚠️ A count that survives its own invalidation is invisible to exactly the check a careful reader would run. ⇒ the writeback points at the instrument and tells readers to stop quoting any hard-coded number — including the 39 in the writeback itself.

⭐ And the dev ⛔ did not edit objectui#7804's title (it still reads 「39 handler keys …」) — another seat's card, left to its owner with the reason stated. Correct.

⭐ Three instrument facts earned here, each of which could have produced a clean false green

  1. ⭐⭐ The fixture key had to be onTabSwap, not the live onTabChange. KNOWN_UNDECLARED_READS is consulted for fixture trees too, so a synthetic fixture naming a real ledgered type::Schema.key is silently exempted and reports no finding. Spelled onTabChange, the negative control went green for that reason alone. Caught, diagnosed, recorded in the test where the next fixture author will read it.
  2. The angle-bracket cast form is UNREACHABLE by constructionparseSource hard-codes ts.ScriptKind.TSX, under which it parses as JSX and the property access does not survive the parse at all (measured in .ts and .tsx alike). ⇒ the dev removed the branch they had drafted for it rather than ship dead code, and pinned the boundary with a firing control on the as any spelling in the same test.
  3. A bare git checkout -- PATH would have taken the file out of the INDEX — which was the mutation itself. The ablation used git checkout HEAD -- <abs path> and proved the restore by observed state (blob id, sha256, git diff HEAD empty), ⛔ never by a clean exit code.

⚠️ One reported non-finding, correctly not filed

detail-view (registered to DetailViewRenderer, arm views.zod.ts#DetailViewSchema, which also does not declare onTabChange) produces zero census rows — before the change and after. The card describes the read as registered detail / detail-view; the gate attributes it to detail only. ⭐ The dev suspected the constructsNewDocument narrowing, ⛔ did not measure it, and therefore did not file a card asserting it. ⇒ correct: a card asserting an unmeasured defect is worse than no card. Recorded here so it is not lost.

Status

This seat does NOT reverse option A. If triage meant item 2's literal reading, reversal is one commit — and its cost is a knowingly-red gate on main blocking every subsequent PR, with objectui#7804's population stuck at 37 and nothing to write back.
Next: CI green → flip draft→ready (⚠️ adds Governed Surface Queue Guard) → re-wait → enqueue → confirm added_to_merge_queue → ⭐ re-count on the ACTUAL merge parent (errata 62b has fired eight times today).


Generated by Claude Code

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Probe ARMED — 9 legs, ⛔ posted BEFORE the flip and BEFORE any enqueue. ⛔ And one of my own legs was VACUOUS again.

PM seat domain:devx @ objectui, session session_01FhBNJcLRZLe8M87VcUgpKr. Card objectui#9344.
Counted by this seat on base = b67b53bc0b3026cccbc263e54cf4baa4e1e5100f, head = e7afd521300e8c9f6e2b84ad025aed4a481dbe47. ⚠️ Errata 62b has fired EIGHT times today — ⭐ every pre-value is re-confirmed on the ACTUAL <merge>^ after the merge.

⛔ SIXTH time today: a leg of mine matched NOTHING and read as agreement

My first pass at leg 3 counted 'objectui#7804' through a shell layer that ate the single quotes. It returned 0 on both sides — while leg 4, in the same run, was printing ['tabs::TabsSchema.onTabChange', 'objectui#7804'], on screen. ⇒ ⭐ the two legs contradicted each other, and that is the only reason it was caught.

Re-run with an anchored pattern, the population named in words — rows of KNOWN_UNDECLARED_READS naming that card:

pre=37  post=39
> ['tabs::TabsSchema.onTabChange', 'objectui#7804'],
> ['detail::DetailSchema.onTabChange', 'objectui#7804'],

⚠️ ⭐ That is six legs of mine defective today against zero confirmed dev miscounts. ⇒ the standing rule is now mechanical, not advisory: any leg that agrees on both sides must either print its matched lines or carry a non-empty guard, before it is allowed into a report.

The probe

# leg kind pre post
1 erasedReceiverName must-ARRIVE 0 3
2 ts.isIdentifier(current.expression) — the identifier-only guard, code-only reader must-LEAVE 1 0
3 KNOWN_UNDECLARED_READS rows naming objectui#7804 must-GROW-BY-TWO 37 39
4 the two named sites present as ledger rows must-ARRIVE 0 2, printed above
5 zod arms crud.zod.ts a5e45b4c7b · layout.zod.ts ccae643633 · views.zod.ts db4f255aec must-STAY-UNCHANGED all three identical
6 ⭐ the two LIVE source sites DetailView.tsx 001ff5e4e8 · containers.tsx 2c8c7aef4d must-STAY-UNCHANGED both identical
7 control check-handler-key-read-sites, known direction control, HITS 3 3
8 control — total ledger rows of ANY card control, HITS 37 39
9 nonsense erasedRecieverName / objectui#78040 control 0 0 / 0

Diff is 3 files, +240/−2: the gate, its test, and a changeset.

⭐⭐ Legs 5 + 6 are the ones that make this card's claim true rather than plausible

The whole thesis is 「what the gate SEES widened; what it JUDGES did not」. Two byte-identical readings prove it without reading a word of the diff:

  • the three zod arms are untouched ⇒ triage's item 5 held — ⛔ no per-key disposition rode along, and nothing here asserts whether tabs is an alias question or a declaration one;
  • the two live source files are untouched ⇒ the two onTabChange reads were made visible, ⛔ not changed, so the census rise is the instrument's, not the tree's.

⚠️ Errata 77 stands — the constraint that made this card unsatisfiable was MINE

My dispatch banned touching KNOWN_UNDECLARED_READS while triage's item 5 banned declaring, and the repo's own pin asserts findings).toEqual([]) at check-handler-key-read-sites.test.ts:119. No state satisfies all three. ⭐ And the gate's own error text at :1108 prescribes the path I forbade — 「add the key to KNOWN_UNDECLARED_READS」 with the owning card, which is how all 37 prior rows arrived. ⇒ ✅ option A upheld; ⛔ the fence was mine and it was wrong. Full ruling at #issuecomment-5653186603.

⭐ The finding to carry off this card: 「39」 is ACCIDENTALLY CORRECT

Re-derived population: 39 — the same digits circulating in one ruling and six dispatches. ⛔ The reassuring case is the dangerous one: the original 39 was the gate's first-run set, two rows have since landed (→ 37), and these two cast-hidden reads bring it back to 39. ⇒ ⭐⭐ it has been wrong about MEMBERSHIP since that slice landed while staying right about the count — so anyone spot-checking the number confirms it and is still wrong about which 39. A count that survives its own invalidation is invisible to precisely the check a careful reader would run. Writeback at objectui#7804 #issuecomment-5653160098, pointing at the instrument and telling readers to stop quoting any hard-coded number, including its own.

Status

CI on e7afd52130: 29 success · 3 skipped · 3 shards in flight · ⛔ 0 red. Still draft.
Next: green → flip (⚠️ adds Governed Surface Queue Guard, 9/9 this shift) → re-wait → enqueue → confirm added_to_merge_queue → re-count all nine on the real parent.


Generated by Claude Code

@baozhoutao
baozhoutao marked this pull request as ready for review September 13, 2026 12:24
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit ac05d4f Sep 13, 2026
37 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-9344-handler-key-cast-receivers branch September 13, 2026 12:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants