From 23f839fed868f7b46d2346d641366f48b8ece747 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 02:38:02 +0000 Subject: [PATCH 01/10] fix(types): declare onNavigate and onAddComment on the detail arm `BaseSchemaCore` ends `.passthrough()`, so a key the arm for `type: 'detail'` does not declare is not refused - it stops being judged and the value is KEPT, then reaches the renderer. `DetailView` is registered RAW for `'detail'`, so both keys arrived by identity and RAN: `onNavigate` is called in the component's own body (`handleBack` / `handleEdit` / the post-delete redirect), `onAddComment` is forwarded as a prop into the comment composer that awaits it. Measured on the unmodified arm before the code: an authored `{ "action": "toast" }` parsed GREEN on both keys with the object surviving into the parsed output, and the Back click then reported `TypeError: schema.onNavigate is not a function`. `onBack`, already a named refusal on the same arm, was refused on the same document and is the lit control. Both are therefore RUNTIME SLOTS, measured per key rather than per prefix: a named refusal on the JSON face, a callable twin on the TypeScript face. The two `KNOWN_UNDECLARED_READS` rows they held are drained (39 -> 37). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../7804-detail-handler-slots-declared.md | 43 +++ .../detail-handler-slots-7804.test.tsx | 249 ++++++++++++++++++ ...ndler-keys-string-any-mirrors-7344.test.ts | 76 +++++- .../src/__tests__/zod-mirror-parity.test.ts | 25 +- packages/types/src/crud.ts | 25 ++ packages/types/src/zod/crud.zod.ts | 45 ++++ scripts/check-handler-key-read-sites.mjs | 2 - 7 files changed, 456 insertions(+), 9 deletions(-) create mode 100644 .changeset/7804-detail-handler-slots-declared.md create mode 100644 packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx diff --git a/.changeset/7804-detail-handler-slots-declared.md b/.changeset/7804-detail-handler-slots-declared.md new file mode 100644 index 0000000000..0f236ebb49 --- /dev/null +++ b/.changeset/7804-detail-handler-slots-declared.md @@ -0,0 +1,43 @@ +--- +'@object-ui/types': minor +--- + +Declare the two handler keys the `'detail'` renderer reads (objectui#7804, the +`plugin-detail` slice; director seat, decision batch #69, 2026-09-07). + +`DetailSchema` — the zod arm `type: 'detail'` selects — now declares +`onNavigate` and `onAddComment` as objectui#6124 RUNTIME SLOTS: a named refusal +on the JSON face, a callable twin on the TypeScript face. + +**Breaking, and measured.** `BaseSchemaCore` ends `.passthrough()`, so a key an +arm does not declare is not refused — it stops being judged and the value is +KEPT. Both keys were declared on NEITHER face while `DetailView` read and RAN +them. Measured on the unmodified arm: + +- `{ "type": "detail", "onNavigate": { "action": "toast" } }` parsed GREEN, with + `{"action":"toast"}` surviving into the parsed output; clicking Back then + reported `TypeError: schema.onNavigate is not a function`. +- the same document spelling `onAddComment` parsed GREEN the same way, and the + value was forwarded into the comment composer that awaits it. +- `onBack` — already a named refusal on the same arm — was refused on the same + document, which is the control proving the probe could see a refusal. + +After this change both keys are refused BY NAME with the objectui#6124 guidance +(issue `code: 'custom'` at the key's own path) and the message points at the +node-type spelling. A version shipped as `minor` because this repo's 41-package +`fixed` group makes `major` unavailable (`scripts/check-changeset-no-major.mjs`); +the accept-set move is the breaking part. + +**Migration.** Nothing in the corpus has to change: no authored `'detail'` +document in this repository, its examples or its docs writes either key — they +were only ever reachable as host-supplied functions. A React host keeps +supplying them exactly as before, through the TypeScript interface, which now +declares the signature the call site builds (`onNavigate(url, { replace })`, +`onAddComment(text)`) instead of leaving it to `BaseSchema`'s `any`-valued index +signature. A document that *did* author either key was never running anything: +it was being handed an object where a function was expected. + +Per key, not per prefix: the two reach the renderer on different channels — +`onNavigate` is called in `DetailView`'s own body, `onAddComment` is forwarded +as a prop into the comment composer — and both were driven through the real +`SchemaRenderer` before the disposition was assigned. diff --git a/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx b/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx new file mode 100644 index 0000000000..09c5718b40 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx @@ -0,0 +1,249 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Which authored `on*` keys reach the registered `'detail'` renderer — measured + * per key, driven through the real `SchemaRenderer` — and the guard that a bare + * deletion of either declaration goes red (objectui#7804, the `plugin-detail` + * slice of the 39-row `KNOWN_UNDECLARED_READS` ledger). + * + * ## The exposure + * + * `BaseSchema` is `.passthrough()`. A key an arm does not declare is NOT + * refused — it stops being judged and the value is KEPT, then reaches the + * renderer that reads it. `ComponentRegistry.register('detail', DetailView)` + * registers `DetailView` RAW (no wrapper, `../index.tsx`), so nothing is + * interposed: the authored value arrives at `schema.onNavigate` / + * `schema.onAddComment` BY IDENTITY and is CALLED. An authored + * `onNavigate: { action: 'toast' }` therefore parses GREEN and is handed to a + * call site expecting a function — objectui#7664's measured mechanism, and the + * shape `AlertDialogSchema.onAction` had until objectui#7104 declared it. + * + * ## Per-key disposition, MEASURED not assumed (the batch #69 ruling) + * + * Both keys measure `'runtime-slot'`, and they do NOT share one channel: + * + * - `onNavigate` — read in `DetailView`'s OWN body (`handleBack`, + * `handleEdit`, the post-delete redirect) and CALLED there. Suite 1 drives + * the back button and the authored function runs with the arguments the + * call site builds. + * - `onAddComment` — never called by `DetailView`; it is FORWARDED as a React + * prop into ``, whose `handleSubmit` awaits it. Suite 1 + * drives the composer, so the leg measures the forward AND the call at the + * other end. It is also gated by `schema.comments`, itself an undeclared + * key kept alive by the same passthrough. + * + * ⇒ two keys, two channels, one disposition — which is a reading, not an + * assumption: the sibling slice on `object-kanban` got three DIFFERENT + * dispositions out of three keys sharing one prefix. + * + * ## Every control can fire + * + * `onNeverReadByAnyKnownReader` is authored on the SAME document, in the SAME + * render, and must stay at zero calls. Without it a harness that fired every + * authored function would read exactly like a live channel. + * + * ## Predictions, written before the code (red-first, base `a686403b3`) + * + * - suite 1 (reachability) PASSES on the base tree — the channels are what + * this change declares, not what it builds; + * - suite 2 (the hazard) PASSES on the base tree for the same reason; + * - suite 3 (the zod face) FAILS on both keys: `DetailSchema.shape.onNavigate` + * and `.onAddComment` are `undefined`, and an authored action object parses + * GREEN and survives into the parsed output. `onBack` — already a #6124 + * runtime-slot refusal on this very arm — is the lit control and is GREEN + * before and after; + * - suite 4 (the derivation) FAILS on both keys for the same reason. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { render, fireEvent, screen, waitFor, cleanup } from '@testing-library/react'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DetailSchema as DetailZod } from '@object-ui/types/zod'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Module-scope side-effect imports: the registry must hold `'detail'` and the +// widgets it renders into before the first render (AGENTS.md, test discipline). +import '@object-ui/components'; +import '@object-ui/fields'; +import '../index'; + +afterEach(() => cleanup()); + +/** The corpus spelling an author learns — a declarative action object where a + * function is expected. objectui#7664 measured this exact value surviving. */ +const AUTHORED_ACTION_OBJECT = { action: 'toast', title: 'Saved', variant: 'success' }; + +/** Render an authored `'detail'` document the way production does. */ +function renderDetail(schema: Record) { + return render( + + + , + ); +} + +/* -- Suite 1: reachability, per key, driven ------------------------------- */ + +describe('an authored handler key reaches the registered `detail` renderer (objectui#7804)', () => { + it('onNavigate: the authored function is CALLED by DetailView, with the arguments the call site builds', () => { + const onNavigate = vi.fn(); + const onNeverReadByAnyKnownReader = vi.fn(); + renderDetail({ onNavigate, onNeverReadByAnyKnownReader }); + + // No `data` and no fetch inputs -> the not-found panel, whose "Go back" + // button is wired to the same `handleBack` as the header's. + fireEvent.click(screen.getByRole('button', { name: 'Go back' })); + + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onNavigate).toHaveBeenCalledWith('/', { replace: true }); + // The control: same document, same render, a key no reader names. + expect(onNeverReadByAnyKnownReader).toHaveBeenCalledTimes(0); + }); + + it('onAddComment: forwarded into RecordComments as a prop and awaited there', async () => { + const onAddComment = vi.fn(); + const onNeverReadByAnyKnownReader = vi.fn(); + renderDetail({ + data: { id: '1', name: 'Acme' }, + // `comments` is itself undeclared on the arm and kept by the same + // passthrough — the gate this key sits behind. + comments: [{ id: 'c1', author: 'Ada', content: 'first', createdAt: '2026-01-01T00:00:00Z' }], + onAddComment, + onNeverReadByAnyKnownReader, + }); + + const box = await screen.findByPlaceholderText(/Add a comment/i); + fireEvent.change(box, { target: { value: 'measured' } }); + const send = box.parentElement!.querySelector('button')!; + fireEvent.click(send); + + await waitFor(() => expect(onAddComment).toHaveBeenCalledTimes(1)); + expect(onAddComment).toHaveBeenCalledWith('measured'); + expect(onNeverReadByAnyKnownReader).toHaveBeenCalledTimes(0); + }); +}); + +/* -- Suite 2: the hazard the disposition names ---------------------------- */ + +describe('the value the passthrough keeps is handed to a call site expecting a function', () => { + /** + * Every string React or the runtime reported while `body()` ran. + * + * ⚠️ Shaped by a MISSED prediction, recorded rather than smoothed: this leg + * was first written as `expect(() => fireEvent.click(...)).toThrow()`, on the + * assumption that a handler error propagates out of the dispatch. It does + * not — React 19 REPORTS it (the `TypeError: schema.onNavigate is not a + * function` this leg now reads) and the click returns normally. An + * `expect(...).toThrow()` here would have been a green assertion about a + * hazard that never fired. + */ + function reportedWhile(body: () => void): string[] { + const seen: string[] = []; + const onError = (e: Event) => seen.push(String((e as ErrorEvent).error ?? e)); + const spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + seen.push(args.map((a) => String(a)).join(' ')); + }); + window.addEventListener('error', onError); + try { + body(); + } catch (err) { + seen.push(String(err)); + } finally { + window.removeEventListener('error', onError); + spy.mockRestore(); + } + return seen; + } + + it('an authored onNavigate action OBJECT reaches the call site and is invoked as a function', () => { + renderDetail({ onNavigate: AUTHORED_ACTION_OBJECT }); + const reported = reportedWhile(() => { + fireEvent.click(screen.getByRole('button', { name: 'Go back' })); + }); + expect( + reported.some((m) => /onNavigate is not a function/.test(m)), + `the kept object was never invoked; reported: ${JSON.stringify(reported)}`, + ).toBe(true); + }); + + it('CONTROL: the same click on a document authoring NO onNavigate reports nothing', () => { + renderDetail({ title: 'Acme' }); + const reported = reportedWhile(() => { + fireEvent.click(screen.getByRole('button', { name: 'Go back' })); + }); + expect(reported.filter((m) => /is not a function/.test(m))).toEqual([]); + }); +}); + +/* -- Suite 3: the zod face ------------------------------------------------ */ + +describe('the `detail` arm declares every handler key its renderer reads (objectui#7804)', () => { + const DECLARED: ReadonlyArray = [['onNavigate'], ['onAddComment']]; + + it.each(DECLARED)('DetailSchema.%s is a DECLARED member carrying the objectui#6124 runtime-slot guidance', (key) => { + // Deliberately `.shape`, not `safeParse`: under `.passthrough()` a DELETED + // key still parses green, so a parse-only pin stays green through the very + // deletion it exists to catch. + const member = DetailZod.shape[key as keyof typeof DetailZod.shape] as { description?: string } | undefined; + expect(member).toBeDefined(); + expect(member!.description).toContain('objectui#6124'); + expect(member!.description).toContain('RUNTIME SLOT'); + expect(member!.description).not.toContain('RETIRED'); + }); + + it.each([...DECLARED, ['onBack']])('an authored action object on DetailSchema.%s is refused BY NAME', (key) => { + const result = DetailZod.safeParse({ type: 'detail', [key]: AUTHORED_ACTION_OBJECT }); + expect(result.success).toBe(false); + if (result.success) return; + const issue = result.error.issues.find((i) => String(i.path[0]) === key); + expect(issue, `no issue addressed to \`${key}\``).toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toContain(`\`${key}\``); + }); + + it('the arm still parses GREEN without the keys — the refusal is about the key, not the document', () => { + expect(DetailZod.safeParse({ type: 'detail', title: 'Acme' }).success).toBe(true); + }); +}); + +/* -- Suite 4: derived from the read site, so a deletion cannot hide -------- */ + +describe('the declaration is derived from the read site, not from a list (objectui#7804)', () => { + const SOURCE = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '..', 'DetailView.tsx'), + 'utf8', + ); + + /** `schema.onX` — the reads the repo gate `check:handler-key-reads` judges. */ + const plainReads = [...new Set([...SOURCE.matchAll(/\bschema\.(on[A-Z][A-Za-z]*)/g)].map((m) => m[1]))].sort(); + /** `(schema as any).onX` — the same read, behind a cast. */ + const castReads = [...new Set([...SOURCE.matchAll(/\(schema as any\)\.(on[A-Z][A-Za-z]*)/g)].map((m) => m[1]))].sort(); + + it('every plain `schema.on*` read in DetailView is a declared member of the `detail` arm', () => { + expect(plainReads, 'the census instrument found no read at all').not.toEqual([]); + const undeclared = plainReads.filter((k) => !(k in DetailZod.shape)); + expect(undeclared).toEqual([]); + }); + + it('the two keys this card owns are exactly the plain reads DetailView names', () => { + expect(plainReads).toEqual(['onAddComment', 'onNavigate']); + }); + + it('records the ONE read a cast hides from the repo gate (filed separately, NOT repaired here)', () => { + // `DetailView.tsx` reads `(schema as any).onTabChange` in its `autoTabs` + // branch and forwards it into the `` it renders. The + // repo gate reads the AST and never sees a read behind that cast, so the + // key is absent from `KNOWN_UNDECLARED_READS` and from this card's 39 rows. + // Recorded here as a reading, deliberately NOT declared: that is a + // different card's scope. + expect(castReads).toEqual(['onTabChange']); + }); +}); diff --git a/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts b/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts index cbeb46e314..813d273bd4 100644 --- a/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts +++ b/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts @@ -47,6 +47,27 @@ * now declares what the renderer invokes. * - `crud.zod.ts#DetailSchema.onBack` — `ComponentRegistry.register('detail', * DetailView)` (`plugin-detail/src/index.tsx`), the same `handleBack`. + * - `crud.zod.ts#DetailSchema.onNavigate` and `.onAddComment` — objectui#7804, + * added on the batch #69 ruling. ⚠️ They do NOT belong to this file's + * origin story: neither was ever `z.string()` or `z.any()`, so #7339's + * anchor could not have missed them — they were declared on NEITHER face, + * which is why `.passthrough()` KEPT them and the repo gate + * `check:handler-key-reads` carried them as `KNOWN_UNDECLARED_READS` rows. + * They are ledgered here because this file owns the `crud.zod.ts#DetailSchema` + * pair, and its per-key assertions are exactly what they need. + * Measured per key, and the two channels are NOT the same one: + * - `onNavigate` — `DetailView` reads it in its OWN body and CALLS it + * (`handleBack`, `handleEdit`, the post-delete redirect). The `'detail'` + * registration is the RAW component, so the authored value arrives by + * identity with nothing interposed. + * - `onAddComment` — `DetailView` never calls it; it FORWARDS it as a + * React prop into ``, whose submit handler awaits it, + * behind a `schema.comments` gate that the same passthrough keeps alive. + * Both driven through the real `SchemaRenderer` in + * `plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx`, where + * the base reading was: an authored `{ action: 'toast' }` parsed GREEN on + * both keys and survived into the parsed output, while `onBack` — the lit + * control on the same arm — was refused. * - `crud.zod.ts#ActionSchema.onClick` — `ActionRunner.ts` `await * action.onClick()` (two sites); `action-menu.tsx`, `containers.tsx`, * `record-quick-actions.tsx` all `typeof action.onClick === 'function'`. @@ -174,11 +195,17 @@ const objectOf = (mirror: z.ZodType, key: string): z.ZodObject => return obj; }; -/** The four keys whose function value REACHES a renderer (channels above). */ +/** The six keys whose function value REACHES a renderer (channels above). */ const RUNTIME_SLOT: readonly Site[] = [ ['views.zod.ts', 'DetailViewSchema', 'onBack', DetailViewZod], ['crud.zod.ts', 'ActionSchema', 'onClick', ActionZod], ['crud.zod.ts', 'DetailSchema', 'onBack', DetailZod], + // objectui#7804 — the two keys `DetailView` reads off a `'detail'` document + // that its arm never declared. They arrive by a DIFFERENT route from the four + // above (never `z.string()` / `z.any()`, simply absent), and on two different + // channels from each other; see the docblock's objectui#7804 section. + ['crud.zod.ts', 'DetailSchema', 'onNavigate', DetailZod], + ['crud.zod.ts', 'DetailSchema', 'onAddComment', DetailZod], ['complex.zod.ts', 'CalendarViewSchema', 'onEventClick', CalendarViewZod], ]; @@ -284,11 +311,15 @@ describe('census: the only on*: z.(function|string|any) lines left in packages/t expect(MIRROR_FILES.length).toBeGreaterThanOrEqual(12); }); - it('8 sites are ledgered, 4 runtime slots + 4 retired, with no key filed twice', () => { - expect(RUNTIME_SLOT).toHaveLength(4); + it('10 sites are ledgered, 6 runtime slots + 4 retired, with no key filed twice', () => { + // 8 at objectui#7344; 10 since objectui#7804 declared the two keys + // `DetailView` reads off a `'detail'` document undeclared. ⛔ A ledger + // GROWS here by a declaration landing, never by a key being reclassified + // in place — the shrink direction is the failure this family guards. + expect(RUNTIME_SLOT).toHaveLength(6); expect(RETIRED).toHaveLength(4); const ids = ALL_SITES.map(([file, schema, key]) => `${file}#${schema}.${key}`); - expect(new Set(ids).size).toBe(8); + expect(new Set(ids).size).toBe(10); }); 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) => { @@ -752,6 +783,15 @@ export type assertionRetiredKeysAreTombstoned = [ export type assertionRuntimeSlotsKeepTheirFunctionType = [ Expect>, Expect>, + // objectui#7804, listed here for uniformity but ⛔ NOT the assertion that + // holds them: these two were UNDECLARED, not `string` / `any`, so their base + // state was `BaseSchema`'s index signature — and `KeepsFunction` is + // `true` (`[any] extends [never]` is false). On this pair the helper CANNOT + // FAIL, which is exactly the reason `RetiredIsNever` above is spelled with + // `Equal`. `assertionDetailSlotsAreDECLARED` below is the one with a control + // that fires. + Expect>, + Expect>, Expect>, Expect>, ]; @@ -764,7 +804,33 @@ export type assertionStringTwinsStopDeclaringString = [ Expect>, ]; -// The three helpers must be able to FAIL — synthetic controls, both directions. +/** + * The member is DECLARED on the interface, not inherited from `BaseSchema`'s + * `[key: string]: any` index signature. + * + * ⚠️ This exists because objectui#7804's two keys enter this ledger from a base + * state the other four never had: ABSENT. `Extract`-based helpers read an + * absent member as `any` and answer `true` for it, so a one-way check would + * have passed on the unmodified tree and asserted nothing. `Equal` separates + * `any` from a real declaration, which is the same reason `RetiredIsNever` is + * spelled with it. + */ +type DeclaresExactly = Equal; + +/** objectui#7804 — the two `'detail'` slots declare the signature their call + * site builds, and the control proves the check can fail on an ABSENT member. */ +export type assertionDetailSlotsAreDECLARED = [ + Expect void>>, + Expect void | Promise>>, +]; + +// The four helpers must be able to FAIL — synthetic controls, both directions. +export type assertionDeclaresExactlyCanFail = [ + // an ABSENT member, as `BaseSchema`'s index signature types it + Expect void>, false>>, + // a DECLARED member with the wrong signature + Expect void) | undefined, () => void>, false>>, +]; export type assertionRetiredIsNeverCanFail = Expect void) | undefined>, false>>; export type assertionKeepsFunctionCanFail = Expect, false>>; export type assertionStringIsGoneCanFail = Expect, false>>; diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index a10f6f80e1..e22dd3bcd9 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -129,7 +129,17 @@ * a delta to this number; count the registry. Nothing asserts it against a written * one, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **40 entries** in `KnownDrift`, **61 keys** across them — 41 / 64 until + * - **40 entries** in `KnownDrift`, **63 keys** across them — 40 / 61 until + * objectui#7804 (batch #69) DECLARED `onNavigate` and `onAddComment` on + * `crud.zod.ts#DetailSchema`, an existing entry (so the entry count did not + * move). ⭐ The first keys this ledger has gained from a pair that declared + * them on NEITHER face: the TypeScript side typed them `any` through + * `BaseSchema`'s index signature and the mirror kept them through + * `.passthrough()`, while the registered renderer read and RAN them — so the + * pair was not "in parity", it was mutually silent, and declaring the + * callable twin against the mirror's named refusal is what makes the drift + * visible. Growth by REPAIR, in a ledger whose entries usually shrink by it. + * It was 41 / 64 until * objectui#8802 RETIRED the bare `kanban` node type key (maintainer ruling * 2026-09-09) and the `complex.zod.ts#KanbanSchema` pair with it, taking that * entry's three RUNTIME SLOT keys (`onCardMove` / `onCardClick` / @@ -1515,8 +1525,19 @@ interface KnownDrift { * RUNTIME SLOT (objectui#7344): `register('detail', DetailView)` — `DetailView`'s * `handleBack` calls `onBack()` when set. The mirror was `z.any()` (wider than * the declared callable, objectui#7069's direction); it now refuses by name. + * + * `onNavigate` and `onAddComment` joined with objectui#7804 (batch #69), and + * they drift for the SAME reason `onBack` does — a callable twin against a + * named refusal — but they reach that state from the opposite side. `onBack` + * was declared on both faces and the mirror was too WIDE. These two were + * declared on NEITHER: the TypeScript face typed them `any` through + * `BaseSchema`'s index signature and the mirror kept them through + * `.passthrough()`, while `DetailView` read and ran them. ⇒ this entry + * GREW by a repair, which is the direction this ledger's drift entries + * normally shrink in; the growth is the declaration arriving, not a + * regression. */ - 'crud.zod.ts#DetailSchema': 'onBack'; + 'crud.zod.ts#DetailSchema': 'onBack' | 'onNavigate' | 'onAddComment'; /** * `rowActions` was the FIFTH key here until objectui#6940 settled the ruling * this entry was explicitly waiting on. It read: DISJOINT — TS declares diff --git a/packages/types/src/crud.ts b/packages/types/src/crud.ts index e8a9665fa1..31aefc3eeb 100644 --- a/packages/types/src/crud.ts +++ b/packages/types/src/crud.ts @@ -319,6 +319,31 @@ export interface DetailSchema extends BaseSchema { * which accepted `z.any()`, now refuses the key by name. */ onBack?: () => void; + /** + * SPA navigation callback — RUNTIME SLOT (objectui#7804, the objectui#6124 + * shape): a host-supplied function, NOT authorable metadata. `'detail'` is + * registered to `DetailView` RAW, so an authored value reaches + * `schema.onNavigate` by identity and `handleBack` / `handleEdit` / the + * post-delete redirect CALL it. Until this card the key was declared on + * NEITHER face: `BaseSchema`'s index signature typed it `any` here and the + * zod mirror's `.passthrough()` kept it there, so an authored + * `{ "action": "toast" }` parsed green and threw `schema.onNavigate is not a + * function` at click. The zod twin now refuses the key by name; supply it + * from a React host. + * + * Signature taken from the call site (`(url, { replace })`), which is the + * same one `views.ts#DetailViewSchema.onNavigate` declares — one component, + * two registrations, one contract. + */ + onNavigate?: (url: string, options?: { replace?: boolean; newTab?: boolean }) => void; + /** + * New comment callback — RUNTIME SLOT (objectui#7804), reaching the renderer + * on a DIFFERENT channel from {@link DetailSchema.onNavigate}: `DetailView` + * does not call it, it forwards it as a prop into ``, whose + * submit handler awaits it. Measured per key rather than per prefix, as the + * batch #69 ruling requires. + */ + onAddComment?: (text: string) => void | Promise; /** * Force the loading skeleton. * diff --git a/packages/types/src/zod/crud.zod.ts b/packages/types/src/zod/crud.zod.ts index a5e45b4c7b..557c86b6ba 100644 --- a/packages/types/src/zod/crud.zod.ts +++ b/packages/types/src/zod/crud.zod.ts @@ -175,6 +175,51 @@ export const DetailSchema = BaseSchema.extend({ // RUNTIME SLOT (objectui#7344): `register('detail', DetailView)` — the same // `handleBack` that calls `onBack()` for `detail-view`. Was `z.any()`. onBack: handlerKeyRefusal('onBack', 'runtime-slot', 'Custom back action'), + /** + * RUNTIME SLOT (objectui#7804, the objectui#6124 shape, batch #69 ruling) — + * DECLARED here for the first time; it was never on this arm at all, so + * `BaseSchemaCore`'s `.passthrough()` KEPT an authored value instead of + * refusing it and handed it to a call site expecting a function. + * + * ## The channel, measured — not the same one `onAddComment` uses + * + * `ComponentRegistry.register('detail', DetailView)` registers `DetailView` + * RAW (no wrapper, unlike `'detail-view'`), so nothing is interposed: an + * authored value arrives at `schema.onNavigate` BY IDENTITY, and `DetailView` + * CALLS it in its own body — `handleBack`, `handleEdit`, and the post-delete + * redirect. Driven through the real `SchemaRenderer` in + * `plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx`: the + * authored function runs, with the arguments the call site builds. + * + * The base reading that made this a defect, measured on the unmodified arm: + * `{ type: 'detail', onNavigate: { action: 'toast' } }` parsed GREEN with + * `{"action":"toast"}` surviving into the parsed output, and clicking Back + * then reported `TypeError: schema.onNavigate is not a function`. The sibling + * `onBack` above — already a named refusal — was the lit control and was + * refused on the same document. + * + * The signature the TypeScript twin declares is the one the call site builds: + * `(url, { replace })`. Same spelling as `views.ts#DetailViewSchema.onNavigate`, + * because it is the same component reading it under the other registration. + */ + onNavigate: handlerKeyRefusal('onNavigate', 'runtime-slot', 'SPA navigation callback'), + /** + * RUNTIME SLOT (objectui#7804), and a DIFFERENT channel from `onNavigate` + * above — which is why the ruling demands a measurement per key rather than + * per prefix. + * + * `DetailView` never calls this one. It FORWARDS it as a React prop into the + * `` it renders, whose submit handler awaits it; the forward + * is itself gated by `schema.comments`, an undeclared key the same + * passthrough keeps alive. Driven end to end in the same probe: the composer + * is typed into, the send button clicked, and the authored function runs with + * the text. + * + * ⚠️ `comments` is deliberately NOT declared here. It is not a handler key, + * declaring it is an accept-set decision of its own, and objectui#7804's rows + * are the handler keys — noted on that card instead of ridden in on this one. + */ + onAddComment: handlerKeyRefusal('onAddComment', 'runtime-slot', 'New comment callback'), loading: z.boolean().optional().describe('Whether to show loading state'), }); diff --git a/scripts/check-handler-key-read-sites.mjs b/scripts/check-handler-key-read-sites.mjs index 8bd5e448c8..157a8f5a6e 100644 --- a/scripts/check-handler-key-read-sites.mjs +++ b/scripts/check-handler-key-read-sites.mjs @@ -147,8 +147,6 @@ export const KNOWN_UNDECLARED_READS = new Map([ ['data-table::DataTableSchema.onRowClick', 'objectui#7804'], ['data-table::DataTableSchema.onRowSave', 'objectui#7804'], ['tree-view::TreeViewSchema.onNodeClick', 'objectui#7804'], - ['detail::DetailSchema.onAddComment', 'objectui#7804'], - ['detail::DetailSchema.onNavigate', 'objectui#7804'], ['object-form::ObjectFormSchema.onCancel', 'objectui#7804'], ['object-form::ObjectFormSchema.onError', 'objectui#7804'], ['object-form::ObjectFormSchema.onOpenChange', 'objectui#7804'], From 6546a156420b990c25b13289ed52aa48b0916a28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 03:05:31 +0000 Subject: [PATCH 02/10] test(plugin-detail): type the detail handler-slot probe's each-tables uniformly `it.each([...DECLARED, ['onBack']])` widened the row type to `string[] | readonly [key: string]`, which vitest cannot narrow into a callback signature (TS2345 under `tsc -p tsconfig.test.json`). Both tables are now `as const` tuples, so the key parameter types as the literal union and the `.shape` index needs no cast. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../src/__tests__/detail-handler-slots-7804.test.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx b/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx index 09c5718b40..cbceec30ba 100644 --- a/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx +++ b/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx @@ -186,20 +186,25 @@ describe('the value the passthrough keeps is handed to a call site expecting a f /* -- Suite 3: the zod face ------------------------------------------------ */ describe('the `detail` arm declares every handler key its renderer reads (objectui#7804)', () => { - const DECLARED: ReadonlyArray = [['onNavigate'], ['onAddComment']]; + const DECLARED = [['onNavigate'], ['onAddComment']] as const; + /** The same two plus `onBack` — a named refusal on this arm since + * objectui#7344, so it is the LIT CONTROL: it was refused on the base tree + * too, and a probe that could not see a refusal would have failed on it + * first rather than reporting a clean pair of new ones. */ + const WITH_CONTROL = [...DECLARED, ['onBack']] as const; it.each(DECLARED)('DetailSchema.%s is a DECLARED member carrying the objectui#6124 runtime-slot guidance', (key) => { // Deliberately `.shape`, not `safeParse`: under `.passthrough()` a DELETED // key still parses green, so a parse-only pin stays green through the very // deletion it exists to catch. - const member = DetailZod.shape[key as keyof typeof DetailZod.shape] as { description?: string } | undefined; + const member = DetailZod.shape[key] as { description?: string } | undefined; expect(member).toBeDefined(); expect(member!.description).toContain('objectui#6124'); expect(member!.description).toContain('RUNTIME SLOT'); expect(member!.description).not.toContain('RETIRED'); }); - it.each([...DECLARED, ['onBack']])('an authored action object on DetailSchema.%s is refused BY NAME', (key) => { + it.each(WITH_CONTROL)('an authored action object on DetailSchema.%s is refused BY NAME', (key) => { const result = DetailZod.safeParse({ type: 'detail', [key]: AUTHORED_ACTION_OBJECT }); expect(result.success).toBe(false); if (result.success) return; From 980126de0f3d16571d947d5719608f3a9605a9e0 Mon Sep 17 00:00:00 2001 From: os-tesla Date: Sun, 13 Sep 2026 05:19:56 +0000 Subject: [PATCH 03/10] test(plugin-kanban): re-derive the ledger control's witness after the detail slice drained it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../handlerKeyDispositionsMeasured-7804.test.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx b/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx index 3ccca1db02..cf0ec2b072 100644 --- a/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx +++ b/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx @@ -401,8 +401,18 @@ describe('suite 4 — the ledger drained with the fix (objectui#7804)', () => { it('CONTROL — the ledger still carries the rows this slice did NOT take', () => { // objectui#7804 stays the parent and lands per package. A drained ledger // would mean this leg is reading an empty map rather than a shrinking one. + // + // ⚠️ The WITNESS is re-derived, not decorative. This leg named + // `detail::DetailSchema.onNavigate` until the `plugin-detail` slice of the + // same card declared it and drained the row — a row this slice did not + // take, taken by a sibling slice that landed after it. That is the shape + // to expect here: the witness is only ever a row no LANDED slice has + // closed yet, so when its own slice lands, re-derive it against + // `KNOWN_UNDECLARED_READS` rather than dropping the name and leaving the + // length check alone — the length alone passes on a map holding one stale + // row, which is the reading this control exists to refuse. const remaining = [...ledger.keys()]; expect(remaining.length).toBeGreaterThan(0); - expect(remaining).toContain('detail::DetailSchema.onNavigate'); + expect(remaining).toContain('button::ButtonSchema.onSuccess'); }); }); From ff831dd5650eb675e06c5094d082fc18f67cc93d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 14:21:31 +0000 Subject: [PATCH 04/10] docs(changeset): point the fixed-group sentence at config.json, not at a count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sentence explaining why this ships as `minor` stated a package count for the `fixed` group. The count did not match what `.changeset/config.json` enumerates, and a changeset body publishes VERBATIM into the CHANGELOG, so the wrong figure was headed for a release note. Repaired per AGENTS.md #9 rather than by correcting the digits: the sentence now points at the instrument that re-derives the group — the `fixed` array `.changeset/config.json` enumerates — and carries no figure at all. A count written down here is derived once and never again; the next member added to or dropped from the group falsifies it silently, and a reader who spot-checks a still-correct figure confirms it and is wrong the moment the group moves. The sentence keeps its job: the reader still learns this package ships inside a fixed group, that any `major` would carry every member with it, and that `scripts/check-changeset-no-major.mjs` is what makes `major` unavailable. Why no gate caught it: `scripts/check-changeset-claims.mjs` is report-only and covers WENT FALSE only. This was BORN FALSE — a cardinal in prose, which that gate's own header records as the shape it refuses to judge. Frontmatter and every other line of the changeset are untouched. Refs: objectui#7804 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .changeset/7804-detail-handler-slots-declared.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.changeset/7804-detail-handler-slots-declared.md b/.changeset/7804-detail-handler-slots-declared.md index 0f236ebb49..0ec5346411 100644 --- a/.changeset/7804-detail-handler-slots-declared.md +++ b/.changeset/7804-detail-handler-slots-declared.md @@ -24,9 +24,11 @@ them. Measured on the unmodified arm: After this change both keys are refused BY NAME with the objectui#6124 guidance (issue `code: 'custom'` at the key's own path) and the message points at the -node-type spelling. A version shipped as `minor` because this repo's 41-package -`fixed` group makes `major` unavailable (`scripts/check-changeset-no-major.mjs`); -the accept-set move is the breaking part. +node-type spelling. A version shipped as `minor` because this package ships +inside the `fixed` group `.changeset/config.json` enumerates, where any +`major` would carry every member with it, so `major` is unavailable +(`scripts/check-changeset-no-major.mjs`); the accept-set move is the breaking +part. **Migration.** Nothing in the corpus has to change: no authored `'detail'` document in this repository, its examples or its docs writes either key — they From 083a7f1d68d4e8c72c76cd22eb5de660c11647fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 23:21:59 +0000 Subject: [PATCH 05/10] test(plugin-detail): retire the defunct cast-blind narration in the 7804 slots suite Suite 4's last leg in `detail-handler-slots-7804.test.tsx` shipped a green assertion narrating a fact this tree contradicts. Its name said a cast HIDES the read from the repo gate, and its comment said the key is therefore absent from `KNOWN_UNDECLARED_READS`. Neither holds at this head: the gate unwraps `as` / `!` / `satisfies` / parens before it names the receiver, so `(schema as any).onTabChange` in `DetailView.tsx` IS judged and IS carried as a ledger row attributed to objectui#7804. The assertion itself is a SOURCE census of the cast spelling and stays correct, which is precisely why nothing went red -- the AGENTS.md #9 shape. Repaired, narration only: - the leg's name and comment now describe the tree they ship in, and say out loud what the leg does measure (the source spelling, not the gate's reach) and where the key's disposition is decided (objectui#9344 item 2, in the zod arms); - the file docblock's hard-coded ledger size is gone in favour of the instrument -- the count is whatever `check-handler-key-read-sites.mjs` prints, and is no longer written down. The figure that stood there went stale inside the life of this branch. The assertion, the declarations, the zod arms and the changeset are untouched. Measured at this working tree: - `node scripts/check-handler-key-read-sites.mjs` exit 0, and its `--list` output is byte-identical before and after this commit; - the ledger holds 37 rows by three independent instruments (the gate's own printed line, `KNOWN_UNDECLARED_READS.size` read off the module, and the `[UNDECLARED]` occurrences in `--list`), with `detail::DetailSchema.onTabChange` last in declaration order; - the four touched test files: 4 files / 130 tests passed, exit 0; - eslint on the edited file: exit 0, 0 errors, 0 warnings; - `check:control-bytes`, `check:new-line-citations`, `check:comment-mask-corpus` and `check:test-path-roots` all exit 0. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .../detail-handler-slots-7804.test.tsx | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx b/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx index cbceec30ba..de9cf85539 100644 --- a/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx +++ b/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx @@ -10,7 +10,10 @@ * Which authored `on*` keys reach the registered `'detail'` renderer — measured * per key, driven through the real `SchemaRenderer` — and the guard that a bare * deletion of either declaration goes red (objectui#7804, the `plugin-detail` - * slice of the 39-row `KNOWN_UNDECLARED_READS` ledger). + * slice of the `KNOWN_UNDECLARED_READS` ledger — ⛔ that ledger's size is NOT + * written down here: it is whatever `node scripts/check-handler-key-read-sites.mjs` + * prints, and the hard-coded figure this line used to carry went stale inside + * the life of this branch. AGENTS.md #9). * * ## The exposure * @@ -242,13 +245,28 @@ describe('the declaration is derived from the read site, not from a list (object expect(plainReads).toEqual(['onAddComment', 'onNavigate']); }); - it('records the ONE read a cast hides from the repo gate (filed separately, NOT repaired here)', () => { + it('records the ONE cast-spelled read DetailView still has — ledgered here, dispositioned elsewhere', () => { // `DetailView.tsx` reads `(schema as any).onTabChange` in its `autoTabs` - // branch and forwards it into the `` it renders. The - // repo gate reads the AST and never sees a read behind that cast, so the - // key is absent from `KNOWN_UNDECLARED_READS` and from this card's 39 rows. - // Recorded here as a reading, deliberately NOT declared: that is a - // different card's scope. + // branch and forwards it into the `` it renders. + // + // ⚠️ This leg USED TO say that the repo gate could not see a read behind + // that cast, and that the key was therefore absent from the ledger. Both + // halves stopped being true inside the life of this branch: the gate now + // unwraps `as` / `!` / `satisfies` / parens before it names the receiver, + // so this read IS judged and the key IS carried as a + // `KNOWN_UNDECLARED_READS` row attributed to objectui#7804. Kept as a + // record rather than smoothed away, because the assertion below never + // moved — it was a GREEN assertion narrating a fact that had died, which is + // the shape AGENTS.md #9 exists to name. ⛔ Neither the row's position nor + // the ledger's size is written down here; the gate's own + // `check-handler-key-read-sites.mjs --list` enumerates both. + // + // So what this leg measures is the SOURCE spelling, not the gate's reach: + // that `(schema as any)` is still how `DetailView` reads the key, and that + // there is exactly ONE such read. Deciding the key's objectui#6124 + // disposition is objectui#9344's item ② — it lands in the zod arms, not + // here, and `DetailSchema` today declares neither `onTabChange` nor the + // `onValueChange` spelling `TabsSchema` carries for the same event. expect(castReads).toEqual(['onTabChange']); }); }); From bcbd2793cbe249d637dd8ca21d767fb412ca0240 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 23:36:39 +0000 Subject: [PATCH 06/10] test(plugin-kanban): defer the 7804 slice docblock's row count to the instrument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sibling of the narration repair one commit back, and the same defect class: the docblock hard-coded a row count that nothing re-derives, and the figure had already been overtaken by the population it described. Seat ruling on the record: the count is dropped in favour of the instrument, ⛔ not restated as the current one. Restating it would have been a NEW false claim rather than a repair -- the rows live on this tree are not the same SET the slice's finding named. Measured across four refs: at a686403b39 the ledger held 39 rows with no cast-spelled rows and no cast-piercing in the gate; at this head it holds 37, two of them `onTabChange` rows that arrived with objectui#9392 and two `detail` rows drained by this branch. Same cardinality family, different set. This file already reads the ledger live -- it imports `KNOWN_UNDECLARED_READS` from the gate of record -- so the docblock now points at that import and at the gate's printed line and `--list`, per AGENTS.md #9. ⛔ Narration only, in one docblock sentence. Proved rather than asserted: the file's non-comment lines are byte-identical before and after (209 lines, sha256 d7e9bae66... both sides), so no assertion, import or executable line moved. The changeset, the declarations, the zod arms, the labels and the draft state are untouched. Measured at this working tree: - `node scripts/check-handler-key-read-sites.mjs` exit 0, and its `--list` output is byte-identical to the previous head's; - the four touched test files: 4 files / 130 tests passed, exit 0, per-file split unchanged (71 / 32 / 14 / 13); - eslint on the edited file exit 0; `check:comment-mask-corpus`, `check:control-bytes` and `check:new-line-citations` all exit 0. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .../__tests__/handlerKeyDispositionsMeasured-7804.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx b/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx index a65aa9af07..b7db3f77ef 100644 --- a/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx +++ b/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx @@ -10,7 +10,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; + * prefix (objectui#7804, the `plugin-kanban` slice of the + * `KNOWN_UNDECLARED_READS` finding — ⛔ its row count is NOT written down here. + * This file reads that ledger live, through the import below, and + * `node scripts/check-handler-key-read-sites.mjs` prints the count while its + * `--list` enumerates the rows. AGENTS.md #9: the figure this line used to + * hard-code has since been overtaken by the population it described; * director seat ruling of 2026-09-07, decision batch #69). * * ## The exposure this closes From 8a5fdc8f6a6075e094348f406281b3281a72d5b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 00:27:47 +0000 Subject: [PATCH 07/10] docs(api): say by name that the detail arm refuses its handler keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/api/schema-reference.md` is the author-facing reference for the `type: "detail"` face, and this branch moves `onNavigate` and `onAddComment` on that face from ACCEPTED AND KEPT to REFUSED BY NAME. The reference said nothing about either key, so it was left teaching the old acceptance, and nothing mechanical catches that: `check:doc-types` judges registered component TYPES, not accept sets, and is exit 0 either way. Same shape and role as the note objectui#9338 — the preceding slice of this same card, under the same batch #69 ruling — put under `### ObjectKanbanSchema` in this same file, but measured against THIS arm rather than transposed: - three keys are refused here, not two: `onBack` since objectui#7344, plus `onNavigate` and `onAddComment` from this change; - the refusal message is quoted from what `handlerKeyRefusal` actually emits, read off the built mirror (`packages/types/dist/zod/index.zod.js`); - the two keys reach the renderer on DIFFERENT channels, so the "used to do" clause names both: `schema.onNavigate` is called in `DetailView`'s own body (`handleBack`, `handleEdit`, the post-delete redirect), while `onAddComment` is forwarded as a prop into the comment composer, which renders BECAUSE the key is truthy and then awaits it on send; - the still-undeclared third key differs from the kanban board's. `onCardMove` is accepted and DROPPED; `detail`'s `onTabChange` is accepted, KEPT, read through a cast and handed to the tab strip's `onValueChange` — so it still reaches a call site expecting a function. The note says so rather than forcing the parallel. Docs only: no schema, no renderer, no test and no changeset change. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- content/docs/api/schema-reference.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index e8a68e05d8..0267ef06da 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -712,6 +712,8 @@ A single-record detail view with grouped fields, actions, and tabs. | `showBack` | `boolean` | Show a back navigation button. | | `loading` | `boolean` | Show loading state. | +> **Handler keys are not authorable in JSON, and this face refuses three of them by name.** `onBack` has been a named refusal since objectui#7344; since objectui#7804 this face also declares `onNavigate` and `onAddComment` as objectui#6124 **runtime slots**: a React host supplies the function through the TypeScript interface or as a React prop, and the validator **refuses the key by name** — the message leads with the slot's label (`SPA navigation callback`, `New comment callback`), states that the key "is a RUNTIME SLOT for a host-supplied function, not authorable metadata (objectui#6124): JSON has no function value, and no handler key consumes a declarative action object", and closes by pointing at the node-type spelling (`{ "type": "toast", … }`, an `action:button` node). Until then an authored `onNavigate: { "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 — and because every read site only tests the key for truthiness, the kept object then reached a call site expecting a function: `handleBack`, `handleEdit` and the post-delete redirect call `schema.onNavigate(url, { replace })` in `DetailView`'s own body, while `onAddComment` is forwarded as a prop into the comment composer, which renders *because* the key is truthy and then awaits it on send. ⚠️ `onTabChange` is a fourth handler key this view reads and it is **still undeclared** — and unlike the `object-kanban` board's third key, an authored one is **not** dropped: it is kept, read through a cast and handed to the tab strip's `onValueChange`, so it still reaches a call site expecting a function at the first tab switch. That gap is open on objectui#7804. + **Related:** [DetailViewSchema](#detailviewschema), [ObjectGridSchema](#objectgridschema) --- From e3c3589d1f52af418860423b22a5059e9372dbf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 00:59:53 +0000 Subject: [PATCH 08/10] docs(api): re-point the DetailSchema note at the tombstone the merge created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `### DetailSchema` note contrasted `onTabChange` with "the `object-kanban` board's third key" to make one point: an authored `onTabChange` is KEPT and reaches a call site, where the board's loose key was accepted and dropped. Merging origin/main moved the comparand. objectui#9342 gave `onCardMove` a tombstone on both faces, so the board's third key is no longer accepted-and- dropped — it is refused by name. Re-measured on the BUILT mirror (`packages/types/dist/zod/index.zod.js`) after a scoped build: ObjectKanbanSchema + onCardMove -> REFUSED "Card move handler — `onCardMove` is RETIRED (objectui#6124, ADR-0049)" DetailSchema + onTabChange -> ACCEPTED, key kept in the parsed output DetailSchema + onNavigate -> REFUSED ("SPA navigation callback …") DetailSchema + onAddComment -> REFUSED ("New comment callback …") DetailSchema + onBack -> REFUSED ("Custom back action …") Controls for that reading, so the refusals are not a blanket rejection and the acceptances are not a dead probe: the bare `{ type: 'detail' }` document parses green, and an invented `zzNotAHandler` key is ACCEPTED and kept on both arms. The contrast the sentence was making still holds and is now stated against what the arm actually does. Every other clause in the note was re-checked and none of them moved: the three refusal messages, their lead labels, the objectui#6124 sentence and the node-type closing are verbatim from the probe above, and the read sites it names are still `DetailView.tsx` 699-704, 715-720, 784-785 for `onNavigate`, 1753 for the `onAddComment` prop, and the cast read at 1696 that reaches `` at 1765. main did not touch `DetailView.tsx`. No count is written down here: `onTabChange` remains a live `KNOWN_UNDECLARED_READS` row, and `check-handler-key-read-sites.mjs --list` stays the instrument that enumerates it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- content/docs/api/schema-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 1dbd04b762..de0e6c95bf 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -712,7 +712,7 @@ A single-record detail view with grouped fields, actions, and tabs. | `showBack` | `boolean` | Show a back navigation button. | | `loading` | `boolean` | Show loading state. | -> **Handler keys are not authorable in JSON, and this face refuses three of them by name.** `onBack` has been a named refusal since objectui#7344; since objectui#7804 this face also declares `onNavigate` and `onAddComment` as objectui#6124 **runtime slots**: a React host supplies the function through the TypeScript interface or as a React prop, and the validator **refuses the key by name** — the message leads with the slot's label (`SPA navigation callback`, `New comment callback`), states that the key "is a RUNTIME SLOT for a host-supplied function, not authorable metadata (objectui#6124): JSON has no function value, and no handler key consumes a declarative action object", and closes by pointing at the node-type spelling (`{ "type": "toast", … }`, an `action:button` node). Until then an authored `onNavigate: { "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 — and because every read site only tests the key for truthiness, the kept object then reached a call site expecting a function: `handleBack`, `handleEdit` and the post-delete redirect call `schema.onNavigate(url, { replace })` in `DetailView`'s own body, while `onAddComment` is forwarded as a prop into the comment composer, which renders *because* the key is truthy and then awaits it on send. ⚠️ `onTabChange` is a fourth handler key this view reads and it is **still undeclared** — and unlike the `object-kanban` board's third key, an authored one is **not** dropped: it is kept, read through a cast and handed to the tab strip's `onValueChange`, so it still reaches a call site expecting a function at the first tab switch. That gap is open on objectui#7804. +> **Handler keys are not authorable in JSON, and this face refuses three of them by name.** `onBack` has been a named refusal since objectui#7344; since objectui#7804 this face also declares `onNavigate` and `onAddComment` as objectui#6124 **runtime slots**: a React host supplies the function through the TypeScript interface or as a React prop, and the validator **refuses the key by name** — the message leads with the slot's label (`SPA navigation callback`, `New comment callback`), states that the key "is a RUNTIME SLOT for a host-supplied function, not authorable metadata (objectui#6124): JSON has no function value, and no handler key consumes a declarative action object", and closes by pointing at the node-type spelling (`{ "type": "toast", … }`, an `action:button` node). Until then an authored `onNavigate: { "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 — and because every read site only tests the key for truthiness, the kept object then reached a call site expecting a function: `handleBack`, `handleEdit` and the post-delete redirect call `schema.onNavigate(url, { replace })` in `DetailView`'s own body, while `onAddComment` is forwarded as a prop into the comment composer, which renders *because* the key is truthy and then awaits it on send. ⚠️ `onTabChange` is a fourth handler key this view reads and it is **still undeclared** — and where the `object-kanban` board's third key, `onCardMove`, has been a **tombstone** refused by name since objectui#9342, an authored `onTabChange` is not refused at all: it is **kept**, read through a cast and handed to the tab strip's `onValueChange`, so it still reaches a call site expecting a function at the first tab switch. That gap is open on objectui#7804. **Related:** [DetailViewSchema](#detailviewschema), [ObjectGridSchema](#objectgridschema) From cb66ea5a3717eec36ec5744ca8bc0c610b5456d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 02:10:53 +0000 Subject: [PATCH 09/10] docs(plugin-detail): re-point the onTabChange disposition at its live carrier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `detail-handler-slots-7804.test.tsx` routed the key's objectui#6124 disposition to "objectui#9344's item ②". That card is no longer open — the API reads `state_reason: completed`, dated 2026-09-13T12:55:04Z — and its item 2 was never carried anywhere, so the comment sent the reader to a card that cannot answer it. The same file already said, nine lines above, that the row is "attributed to objectui#7804"; the two halves of one comment block disagreed, and the published note in `content/docs/api/schema-reference.md` plus the ledger rows in `check-handler-key-read-sites.mjs` both name objectui#7804. The sentence now says what is true: the disposition is OPEN, is decided neither here nor anywhere else yet, and sits on objectui#7804 — the card the gate script itself names for this row. ⛔ No new card is invented, and ⛔ the gap is not described as decided. Comment-only. The assertion `expect(castReads).toEqual(['onTabChange'])` is untouched, as is every other line of the suite. Measured at this head, from the repository root: - gate of record: 60 reachable / 60 judged / 36 exempted, exit 0 - `--list`: 61 lines, sha256 prefix 0b586b1c9e568ffc - the four touched test files: 4 files / 130 tests passed, exit 0 - `check:control-bytes`: 7625 tracked text files, exit 0 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .../src/__tests__/detail-handler-slots-7804.test.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx b/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx index de9cf85539..6e30581913 100644 --- a/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx +++ b/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx @@ -263,10 +263,13 @@ describe('the declaration is derived from the read site, not from a list (object // // So what this leg measures is the SOURCE spelling, not the gate's reach: // that `(schema as any)` is still how `DetailView` reads the key, and that - // there is exactly ONE such read. Deciding the key's objectui#6124 - // disposition is objectui#9344's item ② — it lands in the zod arms, not - // here, and `DetailSchema` today declares neither `onTabChange` nor the - // `onValueChange` spelling `TabsSchema` carries for the same event. + // there is exactly ONE such read. The key's objectui#6124 disposition is + // OPEN — ⛔ not decided here, and ⛔ not decided anywhere else yet. It + // lands in the zod arms, and the ledger row above carries it on + // objectui#7804, which is the card `check-handler-key-read-sites.mjs` + // itself names for this row. `DetailSchema` today declares neither + // `onTabChange` nor the `onValueChange` spelling `TabsSchema` carries for + // the same event. expect(castReads).toEqual(['onTabChange']); }); }); From 8da6a093c7d904bd96222487fa4dcfb454647ccd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 03:10:57 +0000 Subject: [PATCH 10/10] docs(plugin-detail): count channels and dispositions separately in the kanban cross-reference The docblock claimed the sibling kanban slice got "three DIFFERENT dispositions out of three keys". It cannot have: HandlerKeyDisposition has exactly two members, so three keys can never carry three distinct dispositions. What that slice measured is three different CHANNELS carrying two dispositions - two runtime slots and one tombstone - which is what its own objectql.zod.ts docblock already says in as many words. The corrected reading is the stronger argument for per-key measurement, not a climb-down: it is exactly the case where counting keys and counting dispositions come apart, so neither can be read off the other. Comment-only. The gate of record and its --list digest are unmoved. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .../__tests__/detail-handler-slots-7804.test.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx b/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx index 6e30581913..97eea6c345 100644 --- a/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx +++ b/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx @@ -42,8 +42,20 @@ * key kept alive by the same passthrough. * * ⇒ two keys, two channels, one disposition — which is a reading, not an - * assumption: the sibling slice on `object-kanban` got three DIFFERENT - * dispositions out of three keys sharing one prefix. + * assumption: the sibling slice on `object-kanban` took three keys sharing one + * prefix and measured them into three DIFFERENT channels carrying only TWO + * dispositions — two of them are RUNTIME SLOTS, and the third, `onCardMove`, + * is a TOMBSTONE. That is what the kanban slice's own `objectql.zod.ts` + * docblock says in as many words, and this sentence agrees with it. + * + * ⭐ That slice is precisely where the two counts COME APART, which is a + * SHARPER case for measuring per key than three-out-of-three would have been. + * Neither count can be read off the other: `HandlerKeyDisposition` offers + * exactly TWO values, so three keys can never carry three distinct + * dispositions — while the routes by which a key reaches a renderer are + * bounded by nothing, and there were three. ⛔ A per-prefix reading collapses + * both counts to one and cannot say which of the two it lost; only the per-key + * channel reading recovers them. * * ## Every control can fire *