diff --git a/.changeset/9342-kanban-oncardmove-prop.md b/.changeset/9342-kanban-oncardmove-prop.md
new file mode 100644
index 0000000000..34201350e5
--- /dev/null
+++ b/.changeset/9342-kanban-oncardmove-prop.md
@@ -0,0 +1,62 @@
+---
+'@object-ui/types': minor
+'@object-ui/plugin-kanban': minor
+'@object-ui/components': minor
+---
+
+**BREAKING (scored `minor` per this repo's version-alignment convention)** —
+`KanbanRenderer` takes `onCardMove` as an explicit React prop, and the
+`object-kanban` document face tombstones the key (objectui#9342, executing the
+`domain:ui` seat's option-B ruling on PR objectui#9338; the objectui#7742 remedy
+`objectFields` took one member over, maintainer decision batch #70).
+
+A `major` is unavailable by convention, not by preference: every package in
+`.changeset/config.json`'s `fixed` group ships as one family whose major tracks
+`@objectstack`, so `scripts/check-changeset-no-major.mjs` rejects a `major`
+declaration outright. Breaking semantics are stated here instead.
+
+## `@object-ui/plugin-kanban` — the move, and what it breaks
+
+`onCardMove` is now a **React prop on `KanbanRendererProps`**, a sibling of
+`schema`, and is **no longer a member of the `schema` bag**. `ObjectKanban`
+passes its own `handleCardMove` through that prop.
+
+⚠️ **This narrows a published props surface.** A host that renders
+`KanbanRenderer` directly and wrote the handler inside `schema` must move it to
+the prop: `schema={board} onCardMove={handler}`. A typed host gets a TS error; an
+untyped one gets a silent drop, which is why the change carries
+`needs:contract-review`.
+
+## `@object-ui/types` — the key is refused by name, as a TOMBSTONE
+
+`ObjectKanbanSchema.onCardMove` is `?: never` on the TypeScript face and
+`handlerKeyRefusal('onCardMove', 'retired', …)` on the `@object-ui/types/zod`
+mirror. An authored `onCardMove` was **accepted and silently dropped** before
+this: `BaseSchema` is `.passthrough()`, so an undeclared key is not refused — it
+stops being judged and the value is KEPT — and `ObjectKanban` then substituted
+its own mover over it.
+
+⛔ **RETIRED, not a RUNTIME SLOT**, and the two are not interchangeable here. A
+slot keeps the TypeScript twin callable, which would publish a key the
+object-bound board DROPS — the resolution this package's `quickAdd` carve-out
+forbids in as many words. The sibling `onCardClick` is a slot because its
+function reaches the board through a React prop `ObjectKanban` declares;
+`onCardMove` has no such prop, and objectui#7804 measured that by driving the
+handler the board was actually handed, with `onCardClick` as the lit control on
+the same document and the same render.
+
+⭐ **Why it took a second card.** The disposition did not move — objectui#7804
+already measured `'retired'`. What blocked it was `check:handler-key-reads`,
+which refuses a tombstone while a renderer still reads the key off the document
+("a tombstone exists precisely because nothing reads the key — it has no read
+site BY CONSTRUCTION"). What that gate cannot see is that the value at the read
+was substituted one hop earlier. Moving the READ is what makes both sides true
+at once, and it drains the key's `KNOWN_UNDECLARED_READS` row.
+
+## `@object-ui/components` — the doc this makes wrong
+
+`src/renderers/complex/README-KANBAN.md` taught an `object-kanban` document
+carrying `"onCardMove": "(event) => …"` — a function spelled as a string,
+accepted and silently dropped on the day it was written and refused by name from
+now on. It teaches the React prop instead, and its prop table spells the key
+`never`.
diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md
index e8a68e05d8..94a65a7e1d 100644
--- a/content/docs/api/schema-reference.md
+++ b/content/docs/api/schema-reference.md
@@ -951,7 +951,7 @@ A drag-and-drop Kanban board. The `object-kanban` type key validates the shape t
>
> The other keys the retired `kanban` arm alone declared — `cardTitle`, `swimlaneField`, `grouping` and `navigation` — are still undeclared on this face. The renderer reads them, so a board may carry them; they are simply not judged.
-> **Handler keys are not authorable in JSON, and two of the three now say so by name.** Since objectui#7804 this face declares `onCardClick` and `onQuickAdd` as objectui#6124 **runtime slots**: a React host supplies the function through the TypeScript interface or as a React prop, and this validator **refuses the key by name** with a message pointing at the node-type spelling (`{ "type": "toast", … }`, an `action:button` node). Until then an authored `onCardClick: { "action": "toast" }` parsed **green** — `BaseSchema` is `.passthrough()`, so a key no arm declares is not refused, it stops being judged and the value is kept, then reaches a call site expecting a function. ⚠️ `onCardMove` is the third key the board component reads and it is **still undeclared**: an authored one is accepted and dropped, because on an object-bound board the renderer substitutes its own mover. That gap is open on objectui#7804.
+> **Handler keys are not authorable in JSON, and all three now say so by name.** Since objectui#7804 this face declares `onCardClick` and `onQuickAdd` as objectui#6124 **runtime slots**: a React host supplies the function through the TypeScript interface or as a React prop, and this validator **refuses the key by name** with a message pointing at the node-type spelling (`{ "type": "toast", … }`, an `action:button` node). Until then an authored `onCardClick: { "action": "toast" }` parsed **green** — `BaseSchema` is `.passthrough()`, so a key no arm declares is not refused, it stops being judged and the value is kept, then reaches a call site expecting a function. ⭐ The third key, `onCardMove`, is a **tombstone** since objectui#9342, not a runtime slot: an authored one reached **nothing** even as a function, because an object-bound board substitutes its own mover — so the TypeScript twin is `?: never` rather than callable, and the mover lives on `KanbanRenderer`'s React prop of the same name, a sibling of its `schema`.
> `data` and `bind` are [`BaseSchema`](#baseschema) members, not narrowed here, but this face requires **one of** `bind`, `data`, `objectName` — the renderer's own record-source ladder (an external `data` prop → `bind` via `useDataScope` → this schema's own `data` → a fetch keyed by `objectName`). A purely static board (lanes carrying their own cards, no record source) authors `"groupBy"` and `"data": []`. ⚠️ The record-source rule is **separate** from the lane key and is unaffected by objectui#8990: omitting `groupBy` is fine, omitting all of `bind` / `data` / `objectName` is still refused, at the refinement rather than at `groupBy`.
diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx
index c37c484261..e377e0e703 100644
--- a/content/docs/plugins/plugin-kanban.mdx
+++ b/content/docs/plugins/plugin-kanban.mdx
@@ -88,10 +88,16 @@ objectui#6124 **runtime slots**, so an authored `onCardClick: { "action":
"toast" }` is now refused with a message pointing at the node-type spelling,
where before it parsed green and was handed to a call site expecting a function.
A React host still supplies either function through the TypeScript interface or
-as a React prop; only the JSON face refuses them. ⚠️ `onCardMove` itself is
-still *undeclared* on that validator — an authored one is accepted and silently
-dropped, because an object-bound board substitutes its own mover — and that gap
-is open on objectui#7804.
+as a React prop; only the JSON face refuses them.
+
+⭐ `onCardMove` is declared too, since objectui#9342 — but as a **tombstone**,
+not a runtime slot. An authored one used to be accepted and silently dropped,
+because an object-bound board substitutes its own mover and `ObjectKanban`
+declares no `onCardMove` React prop; the key reached **nothing**, so the
+TypeScript twin is `?: never` rather than callable and the validator refuses the
+key by name. The mover is a prop on **`KanbanRenderer`**, a sibling of its
+`schema` — which is where a host that mounts the board component directly
+supplies it, and the move that let the arm carry the tombstone at all.
`columns` are the board's **swimlanes**, not a field projection — the fields drawn
on a card are `cardFields`. It is **one of two array shapes**, the pair
diff --git a/packages/components/src/renderers/complex/README-KANBAN.md b/packages/components/src/renderers/complex/README-KANBAN.md
index a82788d240..5f4b653fb1 100644
--- a/packages/components/src/renderers/complex/README-KANBAN.md
+++ b/packages/components/src/renderers/complex/README-KANBAN.md
@@ -56,16 +56,49 @@ A fully functional, schema-driven Kanban board component for Object UI with drag
### With Event Handling
+⛔ **`onCardMove` is not a document key, and never was.** JSON has no function
+value, so it cannot be authored — not as a function, and not as the string
+spelling this page used to teach:
+
```json
{
"type": "object-kanban",
- "groupBy": "status",
- "data": [],
- "columns": [...],
"onCardMove": "(event) => { console.log('Card moved:', event); }"
}
```
+That document was **accepted and silently dropped** until objectui#9342: the
+`object-kanban` validator did not declare the key, `BaseSchema` is
+`.passthrough()`, and `ObjectKanban` substituted its own mover on the schema it
+hands the board — so an author who wrote it got a board that never called it and
+no error saying why. It is now **refused by name**, with the remedy in the
+message.
+
+The board's mover is a **React prop**, supplied by the host that owns the write:
+
+```tsx
+import { KanbanRenderer } from '@object-ui/plugin-kanban';
+
+const board = { type: 'object-kanban', groupBy: 'status', data: [], columns };
+
+// A sibling of `schema`, never a member of it (objectui#9342).
+const onCardMove = (
+ cardId: string,
+ fromColumnId: string,
+ toColumnId: string,
+ newIndex: number,
+) => {
+ console.log(`Card ${cardId} moved from ${fromColumnId} to ${toColumnId} at ${newIndex}`);
+};
+
+;
+```
+
+⚠️ On an **object-bound** board (``, the `object-kanban` registry
+key) the host does **not** supply it: `ObjectKanban` owns the mover, because the
+same function owns the optimistic write, the required-fields dialog and the
+rollback. The prop above is for a host that mounts `KanbanRenderer` directly.
+
## Schema Reference
### Kanban Props
@@ -75,7 +108,7 @@ A fully functional, schema-driven Kanban board component for Object UI with drag
| `type` | `"object-kanban"` | Yes | Component type identifier. ⚠️ The bare `kanban` spelling was RETIRED in objectui#8802 (ruled 2026-09-09) and is refused by name. |
| `columns` | `KanbanColumn[]` | Yes | Array of column configurations |
| `className` | `string` | No | Custom CSS classes |
-| `onCardMove` | `function` | No | Callback when a card is moved |
+| `onCardMove` | `never` | — | ⛔ RETIRED (objectui#9342) — refused by name. Not a document key: the mover is a React prop on `KanbanRenderer`, or `ObjectKanban`'s own on an object-bound board. See "With Event Handling" above. |
### KanbanColumn
diff --git a/packages/plugin-kanban/README.md b/packages/plugin-kanban/README.md
index afa9b8528f..690e04d065 100644
--- a/packages/plugin-kanban/README.md
+++ b/packages/plugin-kanban/README.md
@@ -136,9 +136,12 @@ declare const columns: KanbanColumn[];
//
// ⚠️ `onCardMove` is NOT a document key: it is a React prop the host supplies
// (JSON has no function value), which is why it is spelled with explicit
-// parameter types below rather than inferred from the annotation. The
-// annotation is the type this package's renderer consumes; an unknown key does
-// not fail it — `ObjectKanbanSchema` extends `BaseSchema`, whose index
+// parameter types below rather than inferred from the annotation. Since
+// objectui#9342 the document face says so BY NAME — `ObjectKanbanSchema` carries
+// a `?: never` tombstone and the zod mirror refuses the key with the remedy in
+// the message, where before an authored one was accepted and silently dropped.
+// The annotation is the type this package's renderer consumes; an unknown key
+// does not fail it — `ObjectKanbanSchema` extends `BaseSchema`, whose index
// signature deliberately accepts type-specific extensions, so the compiler is
// not what catches a misspelt board key.
const board: ObjectKanbanSchema = {
@@ -243,12 +246,19 @@ const schema: ObjectKanbanSchema = {
};
// The host supplies the handler as a React prop — JSON has no function value.
+// It is a SIBLING of `schema` on `KanbanRenderer`, never a member of it
+// (objectui#9342): ``.
const onCardMove = (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => {
console.log(`Card ${cardId} moved from ${fromColumnId} to ${toColumnId} at index ${newIndex}`);
// Update your backend or state here
};
```
+⚠️ An **object-bound** board does not take this prop. `ObjectKanban` owns the
+mover — the same function owns the optimistic write, the required-fields dialog
+and the rollback — so on the `object-kanban` registry key there is no host
+handler to supply, and the document key is refused by name.
+
## Links
- 📚 [Documentation](https://www.objectui.org/docs/plugins/plugin-kanban)
diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx
index c3de2dfe56..1b5cbbd939 100644
--- a/packages/plugin-kanban/src/ObjectKanban.tsx
+++ b/packages/plugin-kanban/src/ObjectKanban.tsx
@@ -1428,6 +1428,15 @@ export const ObjectKanban: React.FC = ({
// definition, never an authoring surface. On the schema bag it was
// reachable by an author through `BaseSchema`'s passthrough.
objectFields={objectDef?.fields}
+ // A PROP, not a schema key (objectui#9342, executing the ruling on PR
+ // objectui#9338) — the same remedy `objectFields` above took one card
+ // earlier. `handleCardMove` owns the optimistic write, the
+ // required-fields dialog and the objectui#4138 rollback, so it is this
+ // board's mover and never an authored one. While it rode the `schema`
+ // bag below, an authored `onCardMove` was accepted by the passthrough,
+ // substituted here, and silently dropped; the arm can only tombstone the
+ // key once no renderer reads it off the document.
+ onCardMove={handleCardMove}
schema={{
...effectiveSchema,
// objectui#8307 — the lane headers count rows that came back, so when
@@ -1455,7 +1464,6 @@ export const ObjectKanban: React.FC = ({
captureAnchor(event);
navigation.handleClick(card, event);
},
- onCardMove: handleCardMove,
}}
/>
diff --git a/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx b/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx
index ee649c30e9..2dc5b7380a 100644
--- a/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx
+++ b/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx
@@ -7,11 +7,12 @@
*/
/**
- * Two of the three handler keys `KanbanRenderer` reads off the authored
- * document are now JUDGED by the `object-kanban` arm, and each one's
- * objectui#6124 disposition is MEASURED here rather than shared across the
- * prefix (objectui#7804, the `plugin-kanban` slice of the 39-row finding;
- * director seat ruling of 2026-09-07, decision batch #69).
+ * All three handler keys the kanban board consumes are now JUDGED by the
+ * `object-kanban` arm, and each one's objectui#6124 disposition is MEASURED
+ * here rather than shared across the prefix (objectui#7804, the `plugin-kanban`
+ * slice of the 39-row finding; director seat ruling of 2026-09-07, decision
+ * batch #69 — with the third key landing on objectui#9342, which moved the READ
+ * that had blocked its tombstone).
*
* ## The exposure this closes
*
@@ -39,36 +40,44 @@
* where `handleClick` gives it full priority and calls it. The authored
* function runs. (Until objectui#9341 the wrapper ALSO called it directly,
* which is why it ran twice — see the correction at the end of this block.)
- * - `onCardMove` — the reading is `'retired'` and it does NOT land here.
- * `ObjectKanban` replaces the schema key with `handleCardMove` and declares
- * NO `onCardMove` prop (its rest parameter is discarded), so neither
- * channel delivers and an authored value reaches nothing on this arm.
+ * - `onCardMove` — the reading is `'retired'`, and since objectui#9342 it
+ * lands here as a TOMBSTONE on both faces. `ObjectKanban` replaces the
+ * schema key with `handleCardMove` and declares NO `onCardMove` prop (its
+ * rest parameter is discarded), so neither channel delivers and an authored
+ * value reaches nothing on this arm.
*
* ⛔ "`ObjectKanban` overrides it" does not separate `onCardClick` from
* `onCardMove` — it is true of both. What separates them is the PROP channel,
* which only `onCardClick` has, so that is what suite 2 drives.
*
- * ## ⚠️ Why the third key is recorded here instead of declared
+ * ## ⭐ Why the third key took a second card — and what closed it
*
* `check:handler-key-reads` — the gate of record for this class — refuses the
* `'retired'` spelling while a renderer still reads the key, and prints
* `declares it RETIRED, but a renderer still reads it`. Its own contract says
* why: a tombstone "exists precisely because nothing reads the key — it has no
- * read site BY CONSTRUCTION". `KanbanRenderer` does read `schema.onCardMove`,
- * off the document `ObjectKanban` hands it; what the gate cannot see is that
- * the value at that read was substituted one hop earlier.
+ * read site BY CONSTRUCTION". `KanbanRenderer` DID read the key off the
+ * document `ObjectKanban` hands it; what the gate cannot see is that the value
+ * at that read was substituted one hop earlier.
*
- * ⛔ The two spellings that would make it green are both worse. Declaring
- * `'runtime-slot'` keeps the TypeScript twin callable and so publishes a key
- * the object-bound board DROPS — the one resolution this package's `quickAdd`
- * carve-out records as forbidden. Deleting the read narrows `KanbanRenderer`'s
- * published props, which is the objectui#7742 remedy (`objectFields`, one file
- * over, maintainer decision batch #70) and a ruling rather than a repair.
+ * ⛔ Of the two spellings that would have made it green, one is forbidden:
+ * declaring `'runtime-slot'` keeps the TypeScript twin callable and so
+ * publishes a key the object-bound board DROPS — the resolution this package's
+ * `quickAdd` carve-out records as forbidden.
*
- * ⇒ `onCardMove` keeps its `KNOWN_UNDECLARED_READS` row naming objectui#7804,
- * which stays open and stays the parent. Suite 1 pins it as STILL ACCEPTED AND
- * KEPT, so the exposure cannot drift silently and the day it is closed this
- * file goes red pointing at the reading that closed it.
+ * ⇒ objectui#9342 took the other one, as a RULING rather than a repair (the
+ * `domain:ui` seat on PR objectui#9338, option B; the objectui#7742 remedy
+ * `objectFields` took one file over under maintainer decision batch #70):
+ * `KanbanRenderer` now takes `onCardMove` as an explicit React PROP, a sibling
+ * of `schema`, `ObjectKanban` passes `handleCardMove` through it, and the arm
+ * carries `handlerKeyRefusal(…, 'retired', …)`. That narrows a published props
+ * surface, which is why the change carries `needs:contract-review`.
+ *
+ * ⚠️ The DISPOSITION did not move. objectui#7804 measured `'retired'` and suite
+ * 2 below still drives it dead on the same instrument; what objectui#9342
+ * supplied is the precondition the gate demanded. Suite 1 used to pin the key
+ * as STILL ACCEPTED AND KEPT, with the note that the day the read moved THAT
+ * leg would go red — it did, and it now reads the named refusal instead.
*
* ## Every control here can fire
*
@@ -86,13 +95,15 @@
*
* - suite 1 (the accept set) FAILS on the two declared keys: both parse
* GREEN and the authored value survives into the parsed output, which is
- * the exposure restated as a reading. Its `onCardMove` leg PASSES before
- * and after — that key is the one this slice does not close;
+ * the exposure restated as a reading. Its `onCardMove` leg PASSED before
+ * and after — that key was the one this slice did not close, and
+ * objectui#9342 flipped that leg to the named refusal;
* - suite 3 (both faces) FAILS on the two: nothing is declared on either
* face, so there is no disposition to read;
* - suite 4 (the drained ledger) FAILS listing the two
* `object-kanban::ObjectKanbanSchema.*` rows this slice drains, while its
- * `onCardMove` row is expected to survive;
+ * `onCardMove` row is expected to survive (⭐ objectui#9342 drained that
+ * third row too, and this suite now asserts the set is EMPTY);
* - suite 2 (the channels) PASSES UNCHANGED, before and after. It measures
* the renderer, and this card changes no renderer — which is exactly why it
* is the evidence the dispositions are derived from rather than a
@@ -222,18 +233,26 @@ describe('suite 1 — the accept set: an authored handler key on `object-kanban`
expect(parsed.success).toBe(false);
});
- it('⚠️ `onCardMove` is STILL ACCEPTED AND KEPT — the row this slice did not close', () => {
- // ⛔ Not an oversight and not a passing grade: this is the objectui#7664
- // exposure, still open on this one key, pinned so it cannot drift in
- // silence. The header says why declaring it is refused by the gate of
- // record and why the two greener spellings are worse. The day the read
- // moves to an explicit React prop and the key is tombstoned, THIS leg goes
- // red — which is the point of writing it down as a reading.
+ it('⭐ `onCardMove` is REFUSED BY NAME — the row this slice could not close (objectui#9342)', () => {
+ // ⭐ THE LEG THAT FLIPPED, and it flipped by design. It used to read
+ // `{ accepted: true, kept: { action: 'toast' } }` — the objectui#7664
+ // exposure restated as a reading, pinned so it could not drift in silence —
+ // with the note that the day the read moved to an explicit React prop and
+ // the key was tombstoned, THIS leg would go red. objectui#9342 is that day:
+ // `KanbanRenderer` takes `onCardMove` as a React prop, so the arm carries
+ // `handlerKeyRefusal(…, 'retired', …)` and the authored value is refused BY
+ // NAME instead of being accepted and dropped.
+ //
+ // ⚠️ The DISPOSITION did not move — objectui#7804 measured it `'retired'`
+ // and suite 2 below still drives it dead. Only the precondition the gate of
+ // record demanded was supplied.
const parsed = ObjectKanbanZod.safeParse(board({ onCardMove: { action: 'toast' } }));
expect({
accepted: parsed.success,
+ code: parsed.success ? null : parsed.error.issues[0]?.code,
+ path: parsed.success ? null : parsed.error.issues[0]?.path.join('.'),
kept: parsed.success ? (parsed.data as Record).onCardMove : null,
- }).toEqual({ accepted: true, kept: { action: 'toast' } });
+ }).toEqual({ accepted: false, code: 'custom', path: 'onCardMove', kept: null });
});
it('CONTROLS — the scope did not move: a declared key still parses, and an undeclared one is still KEPT', () => {
@@ -349,14 +368,14 @@ describe('suite 3 — the disposition is legible on BOTH faces, and they agree (
return src.slice(start, end);
}
- it('the zod mirror refuses the two by name — and still declares nothing for the third', () => {
+ it('the zod mirror refuses all three by name — the third as a tombstone (objectui#9342)', () => {
const shape = ObjectKanbanZod.shape as Record;
expect(KEYS.map((key) => ({ key, declared: key in shape }))).toEqual([
{ key: 'onCardClick', declared: true },
- { key: 'onCardMove', declared: false },
+ { key: 'onCardMove', declared: true },
{ key: 'onQuickAdd', declared: true },
]);
- for (const key of DECLARED) {
+ for (const key of KEYS) {
expect(shape[key]?.description, `\`${key}\` carries no author-facing guidance`).toContain(
'objectui#6124',
);
@@ -364,7 +383,7 @@ describe('suite 3 — the disposition is legible on BOTH faces, and they agree (
}
});
- it('the mirror spells RUNTIME SLOT on both landed keys, and nothing at all on the third', () => {
+ it('the mirror spells RUNTIME SLOT on the two live slots and RETIRED on the third', () => {
const shape = ObjectKanbanZod.shape as Record;
const said = (key: string) =>
shape[key]?.description?.includes('RUNTIME SLOT')
@@ -378,12 +397,15 @@ describe('suite 3 — the disposition is legible on BOTH faces, and they agree (
onQuickAdd: said('onQuickAdd'),
}).toEqual({
onCardClick: 'runtime-slot',
- onCardMove: 'undeclared',
+ // ⭐ objectui#9342. `'undefined'` here until the read moved; the reading
+ // itself is objectui#7804's, and `said()` is the same reader for all
+ // three, so this is the one that changed and not the instrument.
+ onCardMove: 'retired',
onQuickAdd: 'runtime-slot',
});
});
- it('the TypeScript twin keeps both live slots callable, and declares no third member', () => {
+ it('the TypeScript twin keeps both live slots callable and tombstones the third', () => {
const body = objectKanbanInterface();
const member = (key: string) => new RegExp(`^\\s{2}${key}\\?:\\s*([^;]+);`, 'm').exec(body)?.[1]?.trim();
expect({
@@ -397,11 +419,13 @@ describe('suite 3 — the disposition is legible on BOTH faces, and they agree (
// deleted call. A signature pin read off disk, so it moves WITH the
// declaration and cannot drift from it.
onCardClick: '(card: any, event?: any) => void',
- // ⚠️ `undefined` is the reading, not a gap in the regex — the firing
- // control below reads a member this interface has always had. A
- // `?: never` tombstone here is what the measurement asks for and what
- // the gate of record refuses; see the header.
- onCardMove: undefined,
+ // ⭐ objectui#9342 — the `?: never` tombstone the measurement always
+ // asked for. It read `undefined` (no member at all) while the gate of
+ // record refused the spelling; moving `KanbanRenderer`'s read to an
+ // explicit React prop is what made it writable. ⛔ `never`, NOT a
+ // callable: a callable twin publishes a key the object-bound board
+ // DROPS, which is the `quickAdd` carve-out's forbidden resolution.
+ onCardMove: 'never',
onQuickAdd: '(columnId: string, title: string) => void',
});
@@ -413,18 +437,15 @@ describe('suite 3 — the disposition is legible on BOTH faces, and they agree (
});
describe('suite 4 — the ledger drained with the fix (objectui#7804)', () => {
- it('the two landed rows are gone, and the one this slice did not close is still named', () => {
+ it('every `object-kanban` row is drained — the last one by objectui#9342', () => {
const rows = [...ledger.keys()].filter((row) =>
row.startsWith('object-kanban::'),
);
expect(
rows,
- 'a ledger row naming a read that is now declared is a live waiver for a defect that is gone; a ' +
- 'read that is still undeclared must keep its row or the gate loses it',
- ).toEqual(['object-kanban::ObjectKanbanSchema.onCardMove']);
- expect(
- ledger.get('object-kanban::ObjectKanbanSchema.onCardMove'),
- ).toBe('objectui#7804');
+ 'a ledger row naming a read that is now declared is a live waiver for a defect that is gone — ' +
+ '`staleExemptions()` in the gate reddens on exactly that',
+ ).toEqual([]);
});
it('CONTROL — the ledger still carries the rows this slice did NOT take', () => {
diff --git a/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx b/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx
index 4d7b8d6763..fd608943ad 100644
--- a/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx
+++ b/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx
@@ -219,22 +219,50 @@ async function boardPropsViaSchemaRenderer(schema: Record) {
}
describe('which authored handler keys reach a registered kanban board (objectui#7664)', () => {
- it('KanbanRenderer forwards onCardClick, onCardMove and onQuickAdd by identity', async () => {
+ it('KanbanRenderer forwards onCardClick and onQuickAdd off the schema bag by identity', async () => {
// ⚠️ Driven DIRECTLY rather than through a registry key: `kanban-ui`
// retired (objectui#8257). The component is unchanged and still live —
// `ObjectKanban` renders it — so the forward block this leg measures is the
// same one it always measured.
+ //
+ // ⭐ objectui#9342 — `onCardMove` LEFT this leg, and its absence is the
+ // change rather than a gap. It is no longer read off the `schema` bag at
+ // all: `KanbanRendererProps` declares it as an explicit React PROP, a
+ // sibling of `schema`, so a key written INSIDE `schema` reaches nothing.
+ // The leg below measures both directions on the same render.
const spies = authored();
const props = await kanbanRendererProps(spies);
expect({
onCardClick: props.onCardClick === spies.onCardClick,
- onCardMove: props.onCardMove === spies.onCardMove,
onQuickAdd: props.onQuickAdd === spies.onQuickAdd,
- }).toEqual({ onCardClick: true, onCardMove: true, onQuickAdd: true });
+ }).toEqual({ onCardClick: true, onQuickAdd: true });
+ });
+
+ it('⭐ onCardMove travels the PROP and NOT the schema bag (objectui#9342)', async () => {
+ // Both channels on ONE render, so the contrast is about the channel and not
+ // about how each was authored. The prop is the firing control for the
+ // schema-bag reading: without it, "the schema key did nothing" would also
+ // be produced by a forward block that stopped forwarding anything.
+ const onSchemaBag = vi.fn();
+ const onProp = vi.fn();
+ const before = recorded.impl.length;
+ const { unmount } = render(
+
+
+ ,
+ );
+ const props = await lastBoardProps('impl', before, unmount);
+ expect({
+ fromProp: props.onCardMove === onProp,
+ fromSchemaBag: props.onCardMove === onSchemaBag,
+ }).toEqual({ fromProp: true, fromSchemaBag: false });
});
it.each(['object-kanban'])(
- "`'%s'` (ObjectKanban) passes onQuickAdd through and replaces BOTH onCardClick and onCardMove with its own",
+ "`'%s'` (ObjectKanban) passes onQuickAdd through and supplies its OWN onCardClick and onCardMove",
async (type) => {
const spies = authored();
const props = await boardPropsFor(type, 'impl', spies);
@@ -296,7 +324,10 @@ describe("ObjectKanban's own onCardClick wrapper CALLS the authored handler (obj
describe('the handler keys KanbanRenderer forwards, and where they are declared (objectui#7664)', () => {
const INDEX_TSX = join(dirname(fileURLToPath(import.meta.url)), '..', 'index.tsx');
- /** The `schema.on*` reads inside the `KanbanRenderer` component body, read off the source. */
+ /** The `schema.on*` reads inside the `KanbanRenderer` component body, read off the source.
+ * ⚠️ `schema.`-anchored ON PURPOSE: this measures the DOCUMENT read path, so
+ * a handler that arrives as an explicit React prop (`onCardMove` since
+ * objectui#9342) is correctly absent rather than missed. */
function forwardedByKanbanRenderer(): string[] {
const src = readFileSync(INDEX_TSX, 'utf8');
const start = src.indexOf('export const KanbanRenderer');
@@ -311,8 +342,12 @@ describe('the handler keys KanbanRenderer forwards, and where they are declared
.sort();
}
- it('the read site is measured, not listed: KanbanRenderer forwards exactly these three', () => {
- expect(forwardedByKanbanRenderer()).toEqual(['onCardClick', 'onCardMove', 'onQuickAdd']);
+ it('the read site is measured, not listed: KanbanRenderer reads exactly these two off the document', () => {
+ // ⭐ THREE until objectui#9342 moved `onCardMove` onto an explicit React
+ // prop. That shrink is the deliverable, not a drift: the gate of record
+ // refuses a `'retired'` tombstone while a renderer still reads the key off
+ // the document, so the read had to go before the arm could carry one.
+ expect(forwardedByKanbanRenderer()).toEqual(['onCardClick', 'onQuickAdd']);
});
it('⭐ two of the three are declared on the surviving `object-kanban` face — measured per key, not per prefix', () => {
@@ -327,18 +362,24 @@ describe('the handler keys KanbanRenderer forwards, and where they are declared
// 1 above is where the two channels that make them so are measured —
// identity through the schema spread for the second, and the React prop
// the wrapper calls (suite 2) for the first.
- // - `onCardMove` is still declared by NOTHING. Its authored value reaches
- // neither channel, which is the `'retired'` disposition, and
- // `check:handler-key-reads` refuses that spelling while this very
- // forward block still reads the key. It keeps its
- // `KNOWN_UNDECLARED_READS` row on objectui#7804, which stays open.
+ // - `onCardMove` was declared by NOTHING and is no longer read off the
+ // document at all. Its authored value reaches neither channel, which is
+ // the `'retired'` disposition; `check:handler-key-reads` refused that
+ // spelling while this very forward block read the key, so objectui#9342
+ // moved the read to an explicit React prop and the arm now carries the
+ // tombstone. The third assertion below reads it off the mirror.
const shape = ObjectKanbanZod.shape as Record;
const forwarded = forwardedByKanbanRenderer();
expect(forwarded.map((key) => ({ key, declared: key in shape }))).toEqual([
{ key: 'onCardClick', declared: true },
- { key: 'onCardMove', declared: false },
{ key: 'onQuickAdd', declared: true },
]);
+ // The key that LEFT the forward block is declared all the same — as a
+ // tombstone, which is the whole point of moving the read (objectui#9342).
+ expect({
+ declared: 'onCardMove' in shape,
+ retired: shape.onCardMove?.description?.includes('RETIRED'),
+ }).toEqual({ declared: true, retired: true });
// Firing control on the SAME instrument: a key this face really does
// declare reads `true`, so the three `false`s above are readings and not a
// shape lookup that answers `false` to everything (an unwrapped
diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx
index da52ecb3cc..4596106d3a 100644
--- a/packages/plugin-kanban/src/index.tsx
+++ b/packages/plugin-kanban/src/index.tsx
@@ -181,7 +181,6 @@ export interface KanbanRendererProps {
data?: Array;
groupBy?: string;
swimlaneField?: string;
- onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void;
onCardClick?: (card: any) => void;
quickAdd?: boolean;
onQuickAdd?: (columnId: string, title: string) => void;
@@ -243,13 +242,43 @@ export interface KanbanRendererProps {
* rather than fixed here.
*/
objectFields?: unknown;
+
+ /**
+ * The board's card-move callback, injected by the host that owns the write.
+ *
+ * ⛔ A React PROP, a sibling of `schema`, and deliberately NOT a member of the
+ * `schema` bag (objectui#9342, executing the ruling on PR objectui#9338; the
+ * objectui#7742 remedy `objectFields` above already took, one member over,
+ * under maintainer decision batch #70).
+ *
+ * Inside `schema` the key was reachable by an AUTHOR and reached NOTHING.
+ * `BaseSchema` is `.passthrough()`, so `onCardMove` was accepted and KEPT on
+ * an `object-kanban` document, and then dropped: `ObjectKanban` substitutes
+ * its own `handleCardMove` on the schema it hands down — that wrapper owns the
+ * optimistic write, the required-fields dialog and the rollback — and declares
+ * no `onCardMove` React prop of its own (its rest parameter is discarded), so
+ * neither channel delivered. Measured, driven rather than inferred, with
+ * `onCardClick` as the lit control on the same document and the same render
+ * (`__tests__/handlerKeyDispositionsMeasured-7804.test.tsx`).
+ *
+ * Moving the READ here is what lets the `object-kanban` arm tombstone the key
+ * with `handlerKeyRefusal(…, 'retired', …)` and still satisfy
+ * `check:handler-key-reads`, whose contract is that a tombstone "has no read
+ * site BY CONSTRUCTION". What that gate could not see is that the value at the
+ * old read was substituted one hop earlier.
+ *
+ * ⚠️ NARROWS A PUBLISHED PROPS SURFACE. A host that rendered `KanbanRenderer`
+ * directly and wrote the key inside `schema` gets a TS error if it is typed
+ * and a silent drop if it is not; it must move the function to this prop.
+ */
+ onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void;
}
/**
* KanbanRenderer - The public API for the kanban board component
* This wrapper handles lazy loading internally using React.Suspense
*/
-export const KanbanRenderer: React.FC = ({ schema, objectFields }) => {
+export const KanbanRenderer: React.FC = ({ schema, objectFields, onCardMove }) => {
const { t } = useUncolumnedT();
// ⚡️ Adapter: Map flat 'data' + 'groupBy' to nested 'cards' structure.
const processedColumns = React.useMemo(
@@ -268,7 +297,7 @@ export const KanbanRenderer: React.FC = ({ schema, objectFi
}>
= {};
for (const m of RETIRED) split[m.file] = (split[m.file] ?? 0) + 1;
expect({ total: RETIRED.length, split }).toEqual({
- total: 26,
+ total: 27,
split: {
'app.ts': 1,
'complex.ts': 2,
@@ -303,6 +311,7 @@ describe('the retired population is measured off the shipped tree (objectui#7340
'feedback.ts': 1,
'form.ts': 8,
'navigation.ts': 3,
+ 'objectql.ts': 1,
'overlay.ts': 2,
'reports.ts': 2,
},
@@ -324,6 +333,11 @@ describe('the retired population is measured off the shipped tree (objectui#7340
// to classify. They are not "live again": the document that could have
// carried them is refused at its `type`.
expect(UNAMBIGUOUSLY_RETIRED_NAMES).toEqual([
+ // objectui#9342 — no shipped interface declares an `onCardMove` callable
+ // any more. `KanbanRendererProps.onCardMove` is a React prop on
+ // `@object-ui/plugin-kanban`, not a member of a `@object-ui/types`
+ // document interface, so it is not in this census's population.
+ 'onCardMove',
'onClose',
'onCollapsedChange',
'onConfirm',
diff --git a/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts b/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts
index 027e2c0f19..b5d1e68b5b 100644
--- a/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts
+++ b/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts
@@ -300,11 +300,12 @@ const RUNTIME_SLOT: readonly Site[] = [
// and is live anyway through the other channel — `SchemaRenderer` spreads
// the authored key as a React prop, `ObjectKanbanComponentProps` declares
// it, and the substituted wrapper CALLS it.
- // ⚠️ The third key `KanbanRenderer` forwards, `onCardMove`, is NOT here. Its
+ // ⚠️ The third key, `onCardMove`, is NOT in this half and never was: its
// authored value reaches nothing on this entry, which is the `'retired'`
- // disposition, and `check:handler-key-reads` refuses that spelling while the
- // renderer still reads the key — so it keeps its `KNOWN_UNDECLARED_READS` row
- // on objectui#7804 instead of being guessed into either half of this ledger.
+ // disposition. It is filed in `RETIRED` below since objectui#9342, which
+ // moved `KanbanRenderer`'s read off the document and onto an explicit React
+ // prop — until then `check:handler-key-reads` refused the tombstone spelling
+ // while the renderer still read the key.
['objectql.zod.ts', 'ObjectKanbanSchema', 'onCardClick', ObjectKanbanZod],
['objectql.zod.ts', 'ObjectKanbanSchema', 'onQuickAdd', ObjectKanbanZod],
['overlay.zod.ts', 'DialogSchema', 'onOpenChange', DialogZod],
@@ -320,8 +321,12 @@ const RUNTIME_SLOT: readonly Site[] = [
];
/**
- * 22 keys NO renderer reads — the TypeScript interface carries the `?: never`
- * tombstone. Measured per key: the renderer takes `({ schema })` only, or
+ * The keys NO renderer reads — the TypeScript interface carries the `?: never`
+ * tombstone. ⚠️ The population is whatever this array holds and the length
+ * assertion below states; a figure repeated in this sentence would be derived
+ * once and never again (AGENTS.md #9), and it already drifted here — it read
+ * `22` while the assertion read `20`, across objectui#8802's arm retirement.
+ * Measured per key: the renderer takes `({ schema })` only, or
* strips the key through a `toFormControlDomProps` whitelist, or spreads it
* onto a DOM element / primitive that has no such prop (React warns about an
* unknown event handler and attaches nothing). `CommandSchema.onChange` is the
@@ -352,6 +357,16 @@ const RETIRED: readonly Site[] = [
['navigation.zod.ts', 'BreadcrumbItemSchema', 'onClick', BreadcrumbItemZod],
['navigation.zod.ts', 'SidebarSchema', 'onCollapsedChange', SidebarZod],
['navigation.zod.ts', 'ButtonGroupButtonSchema', 'onClick', ButtonGroupButtonZod],
+ // ⭐ objectui#9342 — the THIRD `ObjectKanbanSchema` handler key, and the one
+ // its own slice could not file. Its disposition was measured `'retired'` by
+ // objectui#7804 (an authored value reaches nothing: `ObjectKanban`
+ // substitutes its own mover and declares no `onCardMove` React prop), but
+ // `check:handler-key-reads` refuses a tombstone while a renderer still reads
+ // the key off the document, so the row sat in that gate's
+ // `KNOWN_UNDECLARED_READS` instead. `KanbanRenderer` now takes `onCardMove`
+ // as an explicit React prop — the objectui#7742 remedy `objectFields` took —
+ // and the tombstone is spelled on both faces.
+ ['objectql.zod.ts', 'ObjectKanbanSchema', 'onCardMove', ObjectKanbanZod],
['overlay.zod.ts', 'AlertDialogSchema', 'onConfirm', AlertDialogZod],
['overlay.zod.ts', 'AlertDialogSchema', 'onCancel', AlertDialogZod],
];
@@ -429,7 +444,7 @@ describe('census: no on* key in the eight mirrors is declared z.function() (obje
]);
});
- it('64 sites are ledgered, 44 runtime slots + 20 retired, with no key filed twice', () => {
+ it('65 sites are ledgered, 44 runtime slots + 21 retired, with no key filed twice', () => {
// 58 from objectui#6124; the 59th is `ObjectDataTableSchema.onRowClick`,
// minted with its arm by objectui#6576 / #6914; the 60th is
// `AlertDialogSchema.onAction`, declared by objectui#7104 for a key the
@@ -449,14 +464,19 @@ describe('census: no on* key in the eight mirrors is declared z.function() (obje
// ⭐ 62 → 64: objectui#7804 declared `ObjectKanbanSchema.onCardClick` and
// `.onQuickAdd`, two of the three reads the retirement above left on the
// SURVIVING face with nothing declaring them. ⚠️ TWO, not three — the
- // third (`onCardMove`) is measured `'retired'` and the gate of record
- // refuses that spelling while the renderer still reads the key, so it stays
+ // third (`onCardMove`) was measured `'retired'` and the gate of record
+ // refused that spelling while the renderer still read the key, so it stayed
// in `KNOWN_UNDECLARED_READS` rather than being filed here under a
// disposition nothing measured.
+ //
+ // ⭐ 64 → 65: objectui#9342 moved that read to an explicit React prop on
+ // `KanbanRendererProps`, which is what let the arm carry the tombstone the
+ // measurement always asked for. A ledger GROWTH on the retired half, and
+ // the disposition is the one objectui#7804 measured — not a new reading.
expect(RUNTIME_SLOT).toHaveLength(44);
- expect(RETIRED).toHaveLength(20);
+ expect(RETIRED).toHaveLength(21);
const ids = ALL_SITES.map(([file, schema, key]) => `${file}#${schema}.${key}`);
- expect(new Set(ids).size).toBe(64);
+ expect(new Set(ids).size).toBe(65);
});
it.each(ALL_SITES)('%s %s.%s is DECLARED on the mirror shape, with the objectui#6124 guidance as its description', (_file, _schema, key, mirror) => {
diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts
index 30ad6fd282..d2647911e1 100644
--- a/packages/types/src/__tests__/zod-mirror-parity.test.ts
+++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts
@@ -1516,11 +1516,12 @@ interface KnownDrift {
* refused BY NAME by `RetiredKanbanNodeSchema`, pinned in
* `./bare-kanban-node-key-retired-8802.test.ts`.
*
- * ⚠️ Recorded rather than repaired: `KanbanRenderer` still forwards all three,
- * and the SURVIVING `objectql.zod.ts#ObjectKanbanSchema` pair declares none of
- * them, so they are read-but-undeclared on the surviving face. Declaring them
- * there would WIDEN a published accept set, which is a ruling and not a
- * repair — reported on the retirement PR.
+ * ⚠️ Recorded rather than repaired AT THE TIME: `KanbanRenderer` forwarded
+ * all three and the SURVIVING `objectql.zod.ts#ObjectKanbanSchema` pair
+ * declared none, so they were read-but-undeclared on the surviving face.
+ * Both halves of that have since landed — objectui#7804 declared the two
+ * live slots, and objectui#9342 moved the `onCardMove` READ to an explicit
+ * React prop and tombstoned the key on both faces.
*/
/**
* RUNTIME SLOT (objectui#7344): `register('detail', DetailView)` — `DetailView`'s
@@ -1655,12 +1656,18 @@ interface KnownDrift {
* spread — arrives instead as the React PROP `ObjectKanbanComponentProps`
* declares, which `ObjectKanban`'s own wrapper CALLS.
*
- * ⚠️ The THIRD key, `onCardMove`, is deliberately NOT here and is not drift:
- * neither face declares it, so `undefined` meets `undefined`. Its authored
- * value reaches nothing on this entry — the `'retired'` disposition — and
- * `check:handler-key-reads` refuses that spelling while the renderer still
- * reads the key, so it keeps its `KNOWN_UNDECLARED_READS` row on
- * objectui#7804 rather than joining either face.
+ * ⚠️ The THIRD key, `onCardMove`, is still NOT here, and the reason CHANGED
+ * with objectui#9342 — it is not drift either way. It used to be absent
+ * because NEITHER face declared it (`undefined` meeting `undefined`); since
+ * objectui#9342 BOTH faces declare it as a tombstone — `?: never` on the
+ * TypeScript twin, `handlerKeyRefusal(…, 'retired', …)` on this mirror — and
+ * a matched pair of tombstones is the shape `complex.zod.ts#CarouselSchema`
+ * already carries for `onSlideChange`, which is likewise not in this ledger.
+ * Its `'retired'` reading is objectui#7804's measurement (an authored value
+ * reaches nothing: `ObjectKanban` substitutes its own mover and declares no
+ * `onCardMove` React prop); what was missing was the precondition, and
+ * objectui#9342 supplied it by moving `KanbanRenderer`'s read onto an
+ * explicit React prop so `check:handler-key-reads` would accept a tombstone.
*/
'objectql.zod.ts#ObjectKanbanSchema': 'onCardClick' | 'onQuickAdd';
/**
diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts
index 3ed667b7ee..cc68e92401 100644
--- a/packages/types/src/objectql.ts
+++ b/packages/types/src/objectql.ts
@@ -3367,12 +3367,12 @@ export interface ObjectKanbanSchema extends BaseSchema {
* substitutes its own wrapper on the schema it hands down, because that
* wrapper also owns the record-detail overlay. Reachability here is the PROP
* channel, and that is the whole difference between this key and the sibling
- * `onCardMove`, which this face still does NOT declare: "the wrapper
- * overrides it" is true of both and separates neither. `onCardMove` has no
- * prop channel — `ObjectKanban` declares no such prop and discards its rest
- * parameter — so an authored one reaches nothing, which is a `'retired'`
- * reading that `check:handler-key-reads` refuses while `KanbanRenderer` still
- * reads the key. It keeps its `KNOWN_UNDECLARED_READS` row on objectui#7804.
+ * `onCardMove` BELOW, which is a tombstone: "the wrapper overrides it" is true
+ * of both and separates neither. `onCardMove` has no prop channel on this
+ * element — `ObjectKanban` declares no such prop and discards its rest
+ * parameter — so an authored one reaches nothing, which is the `'retired'`
+ * reading objectui#9342 landed once `KanbanRenderer`'s read had moved off the
+ * document and onto an explicit React prop.
*
* ⚠️ TWO PARAMETERS since objectui#9341, and the second is what the surviving
* channel actually delivers: `handleClick` forwards `onRowClick(record,
@@ -3392,6 +3392,34 @@ export interface ObjectKanbanSchema extends BaseSchema {
*/
onCardClick?: (card: any, event?: any) => void;
+ /**
+ * RETIRED (objectui#6124, ADR-0049; landed by objectui#9342 on the ruling
+ * recorded on PR objectui#9338) — JSON has no function value, and on this
+ * element an authored `onCardMove` reached NOTHING even as a programmatic
+ * value. `ObjectKanban` substitutes its own `handleCardMove` on the schema it
+ * hands down — that wrapper owns the optimistic write, the required-fields
+ * dialog and the objectui#4138 rollback — and declares no `onCardMove` React
+ * prop, so neither channel delivered. Measured by driving the handler the
+ * board was actually handed, with `onCardClick` as the lit control on the
+ * same document and the same render.
+ *
+ * ⚠️ RETIRED, not a RUNTIME SLOT, and the two are not interchangeable here.
+ * A slot keeps this member callable, which would publish a key the
+ * object-bound board DROPS — the resolution this package's `quickAdd`
+ * carve-out forbids in as many words. The sibling `onCardClick` above IS a
+ * slot because its function reaches the board through a React prop this
+ * element declares; `onCardMove` has no such prop.
+ *
+ * Where the mover lives now: `KanbanRendererProps.onCardMove`, an explicit
+ * React prop of `@object-ui/plugin-kanban`'s `KanbanRenderer`, a sibling of
+ * its `schema` — the objectui#7742 remedy `objectFields` took one card
+ * earlier. Until that read moved, `check:handler-key-reads` refused this very
+ * tombstone, because a tombstone "has no read site BY CONSTRUCTION".
+ *
+ * @deprecated Not part of this contract — the value was accepted and dropped.
+ */
+ onCardMove?: never;
+
/**
* Quick Add handler.
*
diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts
index 5348cd630b..428c92716f 100644
--- a/packages/types/src/zod/objectql.zod.ts
+++ b/packages/types/src/zod/objectql.zod.ts
@@ -1509,19 +1509,24 @@ export const ObjectKanbanSchema = BaseSchema.extend({
// `__tests__/handlerKeyDispositionsMeasured-7804.test.tsx`; the twin
// docblocks in `../objectql.ts` carry the reasons member by member.
//
- // ⚠️ TWO of the three land here. `onCardMove` is the third and it is NOT
- // declared, deliberately: its authored value is measured to reach NOTHING on
- // this entry (`ObjectKanban` substitutes its own mover and declares no
- // `onCardMove` React prop), which is the `'retired'` disposition — and
- // `check:handler-key-reads` REFUSES that spelling while `KanbanRenderer`
- // still reads the key off the document it is handed, printing
- // `declares it RETIRED, but a renderer still reads it`. Closing that needs
- // the READ to move to an explicit React prop — the objectui#7742 remedy this
- // same file already applied to `objectFields` — which narrows a published
- // component's props and is a ruling, not a repair. So the key keeps its
- // `KNOWN_UNDECLARED_READS` row naming objectui#7804, which stays open and
- // stays the parent, and this arm does not pretend to judge it.
+ // ⚠️ The three do NOT share a disposition. Two are RUNTIME SLOTS, whose
+ // function value reaches the board on this face; the third, `onCardMove`, is
+ // a TOMBSTONE, and reading it as a slot because it shares the prefix is the
+ // error batch #69 forbids. Its authored value was measured to reach NOTHING
+ // here — `ObjectKanban` substitutes its own `handleCardMove` and declares no
+ // `onCardMove` React prop — which is the `'retired'` disposition.
+ //
+ // ⭐ It could not be spelled until objectui#9342 (the ruling recorded on PR
+ // objectui#9338) moved the READ. `check:handler-key-reads` REFUSES a
+ // tombstone while a renderer still reads the key off the document, printing
+ // `declares it RETIRED, but a renderer still reads it` — a tombstone "has no
+ // read site BY CONSTRUCTION". `KanbanRenderer` now takes `onCardMove` as an
+ // explicit React prop, a sibling of its `schema`, which is the objectui#7742
+ // remedy this same file already applied to `objectFields`; it narrows a
+ // published component's props, so it is a ruling and not a repair, and the
+ // change carries `needs:contract-review` on its own merits.
onCardClick: handlerKeyRefusal('onCardClick', 'runtime-slot', 'Card click handler'),
+ onCardMove: handlerKeyRefusal('onCardMove', 'retired', 'Card move handler'),
onQuickAdd: handlerKeyRefusal('onQuickAdd', 'runtime-slot', 'Quick Add handler'),
}).superRefine(requireKanbanRecordSource);
diff --git a/scripts/__tests__/check-handler-key-read-sites.test.ts b/scripts/__tests__/check-handler-key-read-sites.test.ts
index 2425dc5edb..9260d7ced7 100644
--- a/scripts/__tests__/check-handler-key-read-sites.test.ts
+++ b/scripts/__tests__/check-handler-key-read-sites.test.ts
@@ -589,14 +589,20 @@ describe('check-handler-key-read-sites — this repository', () => {
// ⭐ objectui#7804 closed TWO of them on the surviving face, each measured
// at its own channel, and the split is asserted rather than averaged: a
// reading that put all three in one bucket would be the error that ruling
- // forbids. `onCardMove` is the third — its authored value reaches nothing
+ // forbids. `onCardMove` was the third — its authored value reaches nothing
// on this entry, which is the `'retired'` disposition, and THIS GATE
- // refuses that spelling while `KanbanRenderer` still reads the key — so it
- // keeps its `KNOWN_UNDECLARED_READS` row on objectui#7804.
+ // refused that spelling while `KanbanRenderer` still read the key.
+ //
+ // ⭐ objectui#9342 resolved that standoff by moving the READ: `onCardMove`
+ // is an explicit React prop on `KanbanRendererProps` now, so it is no
+ // longer a READ SITE at all and correctly leaves this census — while the
+ // arm carries the tombstone. ⚠️ Its absence below is therefore a reading,
+ // and the two survivors are what keep it from being a census that lost the
+ // kanban walk altogether (the hop this whole leg exists for).
const judged = result.census.map((c) => `${c.type}.${c.key}`);
expect(judged).toContain('object-kanban.onCardClick');
- expect(judged).toContain('object-kanban.onCardMove');
expect(judged).toContain('object-kanban.onQuickAdd');
+ expect(judged).not.toContain('object-kanban.onCardMove');
const kanbanRow = (key: string) =>
result.census.find((c) => c.type === 'object-kanban' && c.key === key);
expect(
@@ -607,7 +613,10 @@ describe('check-handler-key-read-sites — this repository', () => {
})),
).toEqual([
{ key: 'onCardClick', declared: true, disposition: 'runtime-slot' },
- { key: 'onCardMove', declared: false, disposition: undefined },
+ // No census row: the key is declared on the arm (as a tombstone) but the
+ // renderer no longer reads it off the document, so there is nothing to
+ // judge. The arm-side reading is asserted in the resolver leg below.
+ { key: 'onCardMove', declared: undefined, disposition: undefined },
{ key: 'onQuickAdd', declared: true, disposition: 'runtime-slot' },
]);
@@ -707,17 +716,18 @@ describe('check-handler-key-read-sites — this repository', () => {
expect(objectKanban?.members.has('groupBy')).toBe(true);
// ⭐ And the resolver reads its handler dispositions PER KEY, which is what
// objectui#7804 measured this face on: two of the three keys the plugin
- // reads are objectui#6124 RUNTIME SLOTS, and `onCardMove` is declared by
- // nothing — its authored value reaches neither channel, which is the
- // `'retired'` disposition, and this gate refuses that spelling while the
- // renderer still reads the key.
+ // consumes are objectui#6124 RUNTIME SLOTS, and `onCardMove` is `'retired'`
+ // — its authored value reaches neither channel. ⭐ `undefined` here until
+ // objectui#9342: this gate refuses a tombstone while a renderer still reads
+ // the key, so the arm could not carry one until that read moved to an
+ // explicit React prop on `KanbanRendererProps`.
expect({
onCardClick: objectKanban?.members.get('onCardClick'),
onCardMove: objectKanban?.members.get('onCardMove'),
onQuickAdd: objectKanban?.members.get('onQuickAdd'),
}).toEqual({
onCardClick: 'runtime-slot',
- onCardMove: undefined,
+ onCardMove: 'retired',
onQuickAdd: 'runtime-slot',
});
diff --git a/scripts/check-handler-key-read-sites.mjs b/scripts/check-handler-key-read-sites.mjs
index 407acf0edf..fec1ae8eef 100644
--- a/scripts/check-handler-key-read-sites.mjs
+++ b/scripts/check-handler-key-read-sites.mjs
@@ -160,26 +160,21 @@ export const KNOWN_UNDECLARED_READS = new Map([
['form::FormSchema.onSuccess', 'objectui#7804'],
['object-grid::ObjectGridSchema.onNavigate', 'objectui#7804'],
['grid::GridSchema.onNavigate', 'objectui#7804'],
- // ⭐ TWO of the three `object-kanban::ObjectKanbanSchema` rows LANDED and are
- // gone (objectui#7804's `plugin-kanban` slice): `onCardClick` and
- // `onQuickAdd` are now objectui#6124 RUNTIME SLOTS on the arm, each measured
- // at its own channel — the first by the React PROP `ObjectKanban` declares
- // and its click wrapper CALLS, the second by identity through the schema
- // spread. Draining them is part of the landing, not cleanup after it: a row
- // that outlived its read reddens `staleExemptions()` below.
+ // ⭐ ALL THREE `object-kanban::ObjectKanbanSchema` rows LANDED and are gone.
+ // `onCardClick` and `onQuickAdd` went with objectui#7804's `plugin-kanban`
+ // slice — objectui#6124 RUNTIME SLOTS on the arm, each measured at its own
+ // channel, the first by the React PROP `ObjectKanban` declares and its click
+ // wrapper CALLS, the second by identity through the schema spread.
//
- // ⚠️ `onCardMove` STAYS, and the row is not a leftover. Measured on the same
- // instrument and driven rather than inferred, an authored `onCardMove` on an
- // `object-kanban` document reaches NOTHING — `ObjectKanban` substitutes its
- // own mover on the schema it hands down and declares no `onCardMove` React
- // prop — so the objectui#6124 disposition is `'retired'`, and THIS GATE
- // refuses that spelling while `KanbanRenderer` still reads the key
- // (`retired-but-read`). The exit is to move the READ to an explicit React
- // prop, the objectui#7742 remedy already applied to `objectFields` one file
- // over, which narrows a published component's props and is a ruling rather
- // than a repair. So the key stays undeclared, accepted and KEPT, and the row
- // keeps naming objectui#7804 — which stays open and stays the parent.
- ['object-kanban::ObjectKanbanSchema.onCardMove', 'objectui#7804'],
+ // `onCardMove` went with objectui#9342, and it is the one row this gate
+ // itself blocked. Its disposition was `'retired'` — an authored value reaches
+ // NOTHING, `ObjectKanban` substituting its own mover — but a tombstone "has
+ // no read site BY CONSTRUCTION", so this gate refused the spelling as
+ // `retired-but-read` while `KanbanRenderer` still read `schema.onCardMove`.
+ // That read is now an explicit React prop on `KanbanRendererProps`, the
+ // objectui#7742 remedy `objectFields` took one file over, and the arm carries
+ // the tombstone. Draining a row is part of the landing, not cleanup after it:
+ // a row that outlived its read reddens `staleExemptions()` below.
['list-view::ListViewSchema.onAddRecord', 'objectui#7804'],
['list-view::ListViewSchema.onBulkAction', 'objectui#7804'],
['list-view::ListViewSchema.onDensityChange', 'objectui#7804'],