fix(types): judge the object-kanban handler keys the board reads (objectui#7804) - #9338
Conversation
`ObjectKanbanSchema` declares `onCardClick` and `onQuickAdd` as objectui#6124
RUNTIME SLOTS on both faces — callable on the TypeScript twin, refused BY NAME
in the zod mirror. Until now neither was declared anywhere: `BaseSchema` is
`.passthrough()`, so an authored `onCardClick: { "action": "toast" }` was not
refused — it stopped being judged, the value was KEPT, and `KanbanRenderer`
forwarded it to a call site expecting a function. Measured on this branch, that
document parsed GREEN with the object surviving into the parsed output.
Each disposition is measured at its own channel rather than shared across the
prefix: `onQuickAdd` arrives at the board implementation BY IDENTITY through
`ObjectKanban`'s schema spread, while `onCardClick` — substituted on that
spread — arrives instead as the React prop `ObjectKanbanComponentProps`
declares, which `ObjectKanban`'s own wrapper CALLS.
The third key `KanbanRenderer` forwards, `onCardMove`, is deliberately NOT
declared. Its authored value reaches neither channel, which is the `'retired'`
disposition, and `check:handler-key-reads` refuses that spelling while the
renderer still reads the key. It keeps its `KNOWN_UNDECLARED_READS` row; the two
that landed are drained, because a row outliving its read reddens the gate.
No renderer change and no runtime behaviour change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…eir call sites `packages/plugin-kanban/tsconfig.test.json` compiles this package's tests, and `allowJs` is off, so importing `scripts/check-handler-key-read-sites.mjs` and `scripts/js-comment-mask.mjs` is TS7016 — measured by the repo-wide type-check, not guessed. The convention `ObjectKanban.titleFieldPrecedence-8308.test.tsx` already uses is applied here: `@ts-expect-error` on the import with the reason, and a locally annotated binding so the call sites stay checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Ruling —
|
…arrier The accept-set move was declared, but not in the spelling this repo's release notes are scanned in: `.changeset/7664-kanban-arm-plugin-dialect.md` and `.changeset/7742-kanban-arm-batch70.md` — the two nearest precedents, both on this same arm family — lead with `**BREAKING**`, and 7742 with the exact `(scored `minor` per this repo's version-alignment convention)` parenthetical. This note buried the same claim three paragraphs in as "breaking, shipped as `minor`", so a consumer scanning the release note for the token would have missed it. Prose only: the grade, the direction and every measurement are unchanged. Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
Contract reviewHead judged: ① Derived judgments — the accept-set moves, one at a time
1 — direction, read from the helper rather than from the body's claim. 2 — the TypeScript face narrows too, and in the same direction: 3 — the third key. Verified by enumeration, not by the total: I agree with the refusal to spell it either way that would go green. 4 — ordering, and a collision this seat owns rather than the PR. The ② Semver
③ Boundary flags raised by the implementing seat
Verified on the merged tree by this seat
Independence⛔ Stated plainly rather than dressed up: for this seat a clause-② review is default-tier self-review plus the gates, not an independence-qualified ruling — the independence requirement and the downgrade fuse bind the VerdictPASS. Both carriers cleared in the same stroke as this comment; the card limb is then re-hung immediately because objectui#7804's Generated by Claude Code |
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…ed figures The sibling slice of objectui#7804 landed (the `plugin-kanban` arm, PR #9338), so both numbers this branch carried were derived against a base that has moved. Neither is re-stated by hand here: each is read back off the merged tree. `KnownDrift` header bullet — the one textual conflict. Both sides rewrote the head of the same bullet, so both one-sided resolutions were reproduced against the merged tree first and both are RED, which excludes picking a side: ours (40 entries / 63 keys) -> 2 failed: entry count 40, want 41 key total 63, want 65 theirs(41 entries / 63 keys) -> 1 failed: key total 63, want 65 The merged tree derives 41 entries / 65 keys (`ledgerEntryKeys` and `ledgerEntryMembers` over this file's own AST, via the two pins objectui#7733 and objectui#8222). The bullet now leads with those and narrates both slices in order, newest first: the `plugin-detail` keys onto an existing entry, then the `plugin-kanban` entry, then the objectui#8802 retirement below it. The restatement `N of the registered pairs carry TYPE drift TODAY` tracks the ENTRY count, not the key total. This slice adds two keys to an entry that already existed, so it does not move: main's 41 auto-merged and is correct. `KNOWN_UNDECLARED_READS` merged cleanly — the two slices drained disjoint rows. The gate of record now prints `35 exempted by ledger`, not the 37 this branch was written against. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
… detail slice drained it A semantic collision the merge could not see: PR #9338's `suite 4` CONTROL names one surviving `KNOWN_UNDECLARED_READS` row to prove the leg reads a SHRINKING map rather than an empty one, and the row it named was `detail::DetailSchema.onNavigate` — exactly the row this branch drains. Both PRs were green alone; the control only reddens once both are in one tree. Re-derived against the merged ledger rather than weakened: the witness moves to `button::ButtonSchema.onSuccess`, one of the 35 rows that survive both slices and belongs to neither. The assertion keeps both halves it had — a non-empty map AND a named row — because the length check alone passes on a map holding a single stale row, which is the reading the control exists to refuse. The comment now records that the witness is re-derived on each landing, so the next slice of objectui#7804 to drain it knows to move it rather than drop it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
Part of objectui#7804 — the
plugin-kanbanslice. ⛔ That card stays OPEN and stays the parent; PRs land per package (director seat, decision batch #69, 2026-09-07).What this does
ObjectKanbanSchemanow judges two of the three handler keysKanbanRendererreads off the authored document, each disposition measured at its own channel rather than shared across theon*prefix:onCardClickObjectKanbansubstitutes the schema key, butSchemaRendererspreads the authored key as a React prop,ObjectKanbanComponentPropsdeclares that prop, and the substituting wrapper calls itonQuickAddObjectKanban's...schemaspread untouched and arrives at the board implementation by identityonCardMove'retired'— measured, NOT landedhandleCardMove, andObjectKanbandeclares noonCardMoveprop (its rest parameter is discarded)⛔ "
ObjectKanbanoverrides it" is true ofonCardClickandonCardMoveand separates neither. What separates them is the prop channel, which onlyonCardClickhas — so that is what the pin drives, rather than inferring from the override.The base reading, pinned before the code
The pin was written first and run on the unmodified base tree (
0f3d15314). All three keys parsed GREEN, with the authored value surviving into the parsed output:That is objectui#7664's measured transition, inherited by this face: the three sat on the bare
kanbanarm as objectui#6124 runtime slots until objectui#8802 retired that arm, and the survivingobject-kanbanface kept the reads without the declarations.check:handler-key-readsis the gate of record here, and it refused the'retired'spelling. Its own printed line, verbatim from a run on this branch:The gate's own contract says why: a tombstone "exists precisely because nothing reads the key — it has no read site BY CONSTRUCTION".
KanbanRendererdoes readschema.onCardMove; what the gate cannot see is that the value at that read was substituted one hop earlier.⛔ Both spellings that would make it green are worse, so neither was taken:
'runtime-slot'keeps the TypeScript twin callable and so publishes a key the object-bound board drops — the one resolution this package's ownquickAddcarve-out records as forbidden ("it would publish a key the renderer cannot honour, which is the failure mode this whole gate exists to catch").KanbanRenderer's published props. That is the objectui#7742 remedy (objectFields, one file over, maintainer decision batch Add cloud storage functionality with Vercel KV integration in studio #70) and is a ruling, not a repair.⇒
onCardMovekeeps itsKNOWN_UNDECLARED_READSrow naming objectui#7804, and the pin records it as still accepted and KEPT so the exposure cannot drift in silence. The two rows that landed are drained, because a row outliving its read reddensstaleExemptions().Gate of record, after
39 → 37 ledger rows.
--listnow reports:Contract carrier
Neither landed disposition is an arm widening: both are objectui#6124 named refusals, so the published zod accept set narrows (two documents that parsed green are now refused, each at its own path) and nothing new becomes authorable.
@objectstack/spec'sObjectKanbanPropsSchema— 14 keys on the installed 17.4.0 pin — already refuses all three withunrecognized_keys(measured, withzzzNotAKeyas the firing control and a key-less document as the lit control), so this moves toward the protocol, not away from it. The TypeScript face gains two typed members whereBaseSchema's index signature used to absorb them.needs:contract-reviewis hung all the same: the dispatch claim declaredClause-②: yes, and this is an accept-set move on a published mirror even though it is a narrowing. ⛔ Neither limb is cleared here — that is the PM's.Blast radius
apps/·examples/·content/·packages/: noobject-kanbandocument in this repository authorsonCardClickoronQuickAdd. The twoexamples/schema-catalogkanban boards carry{ columns, data, groupBy, type }only.packages/components/src/renderers/complex/README-KANBAN.mdteaches"onCardMove": "(event) => …"on anobject-kanbannode. It is untouched here — that key's acceptance does not move in this PR — and it is named in the Acceptance notes as evidence for the escalation above.inputschange.Tests
packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx— 4 suites, 14 tests. Suite 2 drives the handler the board was handed for the dead-key claim rather than inferring it from identity, withonCardClickas the lit control on the same document and the same render (an absent read and an unreachable one look identical to a grep).kanban-handler-slots-7664.test.tsx(its verdict flips a second time, now spelled per key),handler-keys-json-refusal-6124.test.ts(ledger 62 → 64 sites, 42 → 44 runtime slots),zod-mirror-parity.test.ts(KnownDrift40 → 41 entries, 61 → 63 keys),scripts/__tests__/check-handler-key-read-sites.test.ts.pickKeyin the finding(types): 28 zod-mirror keys are declaredz.function(), which NO JSON document can satisfy — 14 in-repo corpus files author them and are silently invalid #6124 ledger is rebuilt from the member declaration instead of.pick():ObjectKanbanSchemaends with.superRefine(requireKanbanRecordSource), and zod throws on.pick()over an object carrying refinements — a throw would have read as the instrument breaking rather than as a missing fixture. Every probe writes only the key under test, so no existing reading moves.In flight
Confirmed disjoint rather than assumed, by reading each PR's file list: objectui#9334 (
plugin-detail,plugin-form,react) — no overlap; PR objectui#9310 touchespackages/plugin-kanban/src/index.tsx, but only insideObjectKanbanRenderer, below theKanbanRendererbody this PR's derivations read, and it touches nopackages/typesfile and none of the shared pins. No textual or semantic overlap; the union is a plain merge.Acceptance notes
Found while measuring, not fixed here:
onCardClickauthored on anobject-kanbandocument runs TWICE per card click — filed as objectui#9341.ObjectKanbanpasses the same function touseNavigationOverlayasonRowClick— whosehandleClickforwards to it and returns — and then calls it again itself. Measured: the lit control in the new pin fired 2, not 1, with the siblingonCardMovespy at 0 on the same render as the firing control. Reported rather than pinned, so repairing it does not redden a file that is not about it. Deduplicated against the nine openplugin: kanbancards with objectui#8285 as the known-hit control;/search/issuesis proxy-refused in this container, so the repo-scoped list plus a local grep is the channel.@object-ui/typesreturns 37 dependants, all declaringtype-check,@object-ui/siteamong them — so the wholepnpm type-checkwas run (81/81 tasks green) instead of a filtered subset.packages/components/src/renderers/complex/README-KANBAN.mdteaches"onCardMove"as anobject-kanbanJSON key whose value is a function spelled as a string. It is accepted and dropped today — the same reading that madeonCardMovethe escalated key above. Carrier: objectui#7804 itself, whoseonCardMoveledger row stays open.⛔ objectui#7804 REMAINS OPEN after this merges, and the other 36 rows are untouched. Written that way on purpose: GitHub's closing-keyword parser ignores negation, so an English "does not close" plus the number would have shut the parent card at merge (AGENTS.md, the body-rewrite section).
Authored by the
os-devseat with Claude Code — sessionhttps://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ, recorded in prose because a PATCH rewrites the footer form.Generated by Claude Code
Generated by Claude Code