From 28094b1ddc4cd3abc59978b83bdd75d74814293a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 02:56:42 +0000 Subject: [PATCH 1/4] feat(types): add ObjectGallerySchema and ObjectDataTableSchema to the ObjectQL union on both faces (objectui#7363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #7355 (objectui#6576) minted both schemas and deliberately left `ObjectQLComponentSchema` alone, so `AnyComponentSchema` had no arm for an `object-gallery` or `object-data-table` node: `validateSchema` / `safeValidateSchema` / `objectui validate` refused such a document as matching no arm, exactly as before the schemas existed, and a wrong-typed declared key on it could never be diagnosed by name. Both unions gain the two members, in the same order. The pin (`objectql-union-arms-7363.test.ts`) is two-legged per node type: a minimal document validates, and a wrong-typed DECLARED key is refused BY NAME — the by-name half is the load-bearing one, since the documents were refused before too, for the wrong reason. Measured on the ten-arm tree first: 9 of its assertions red (`data` and `className` probes dropped because a sibling arm already named them there). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- .changeset/7363-objectql-union-arms.md | 28 ++++ .../objectql-union-arms-7363.test.ts | 121 ++++++++++++++++++ packages/types/src/objectql.ts | 8 ++ packages/types/src/zod/objectql.zod.ts | 14 ++ 4 files changed, 171 insertions(+) create mode 100644 .changeset/7363-objectql-union-arms.md create mode 100644 packages/types/src/__tests__/objectql-union-arms-7363.test.ts diff --git a/.changeset/7363-objectql-union-arms.md b/.changeset/7363-objectql-union-arms.md new file mode 100644 index 0000000000..b660f6a8f2 --- /dev/null +++ b/.changeset/7363-objectql-union-arms.md @@ -0,0 +1,28 @@ +--- +'@object-ui/types': minor +--- + +`ObjectGallerySchema` and `ObjectDataTableSchema` are members of +`ObjectQLComponentSchema` on both faces — the TS union in `objectql.ts` and the +zod union in `zod/objectql.zod.ts` — so `AnyComponentSchema`, and with it +`validateSchema` / `safeValidateSchema` / `objectui validate`, has an arm for +`object-gallery` and `object-data-table` nodes (objectui#7363). + +PR #7355 (objectui#6576) minted both schemas beside the other `Object*Schema` +members and deliberately left the unions alone. Until now a document carrying +either node was refused as matching NO arm — exactly as before the schemas +existed — and a wrong-typed declared key on it could never be diagnosed by name. + +Accept-set change on the published validator, both directions, stated plainly: + +- WIDENS: a well-formed `object-gallery` / `object-data-table` node now + validates instead of being refused for having no arm. +- NARROWS in effect: a malformed one (`searchable: 'yes'`, `imageField: 42`, + `onRowClick` authored as JSON) is now refused BY NAME by the arm, where it was + previously refused only as "no arm matches". +- TS face: `Extract< ObjectQLComponentSchema, { type: 'object-gallery' } >` + resolves to `ObjectGallerySchema` instead of `never`; likewise for + `object-data-table`. `SchemaByType` has no in-repo consumer, and the wider + `AnySchema` union already carried `BaseSchema`, so nothing narrows there. + +No renderer behaviour changes; both nodes rendered before and render the same. diff --git a/packages/types/src/__tests__/objectql-union-arms-7363.test.ts b/packages/types/src/__tests__/objectql-union-arms-7363.test.ts new file mode 100644 index 0000000000..731e7d9951 --- /dev/null +++ b/packages/types/src/__tests__/objectql-union-arms-7363.test.ts @@ -0,0 +1,121 @@ +/** + * 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. + */ + +/** + * `object-gallery` / `object-data-table` nodes reach a validation arm + * (objectui#7363). + * + * PR #7355 (objectui#6576) minted `ObjectGallerySchema` and + * `ObjectDataTableSchema` on both faces and deliberately left + * `ObjectQLComponentSchema` alone, so `AnyComponentSchema` — and with it + * `validateSchema` / `safeValidateSchema` / `objectui validate` — had no arm for + * either node type. A document carrying one was refused as matching NO arm, + * exactly as before the schemas existed, and a wrong-typed key on such a node + * could never be diagnosed by name. + * + * Two legs per node type, because a pass alone proves nothing — the shape + * `safe-validate-corpus-6318.test.ts` uses for `code-editor` / `bar-chart`: + * + * 1. a minimal document validates through the published entry point; + * 2. the arm is a real declaration: a wrong-typed DECLARED key on the node is + * refused, and the refusal names that key. `BaseSchema` is `.passthrough()`, + * so an UNKNOWN key would prove nothing; every probe below is a key the + * mirror declares. Before the arms existed the same documents were refused + * too — for the wrong reason, with no arm naming the key — which is why the + * BY-NAME half is the load-bearing one, not `.success === false`. + * + * The TS face is pinned beside it: `ObjectQLComponentSchema` narrows to each + * declaration by its discriminant, instead of to `never`. + */ +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { safeValidateSchema, ObjectQLComponentSchema as ObjectQLComponentZod } from '../zod/index.zod.js'; +import type { ObjectQLComponentSchema, ObjectGallerySchema, ObjectDataTableSchema } from '../objectql.js'; + +/* ── Type-level pins: the TS union narrows by discriminant ─────────────────── */ + +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Expect< T extends true > = T; + +/** Was `never` while the union had ten arms. */ +export type assertionGalleryIsAnArm = + Expect< Equal< Extract< ObjectQLComponentSchema, { type: 'object-gallery' } >, ObjectGallerySchema > >; +export type assertionDataTableIsAnArm = + Expect< Equal< Extract< ObjectQLComponentSchema, { type: 'object-data-table' } >, ObjectDataTableSchema > >; + +/* ── Runtime helpers ───────────────────────────────────────────────────────── */ + +/** + * Every issue path in the tree, INCLUDING the per-arm `errors` a Zod 4 + * `invalid_union` hangs off its top-level issue (objectui#7004 measured that + * shape: the root reports one `invalid_union` at `[]` and the arms' real + * diagnoses live in `errors[i]`). A node with no arm produces paths of `type` + * only — every arm fails on its literal — so a DECLARED key's path appearing + * here is proof that an arm accepted the discriminant and read the key. + */ +function issuePaths(result: ReturnType): string[] { + if (result.success) return []; + const out: string[] = []; + const walk = (issues: readonly z.core.$ZodIssue[]) => { + for (const issue of issues) { + out.push(issue.path.map(String).join('.')); + const nested = (issue as { errors?: readonly (readonly z.core.$ZodIssue[])[] }).errors; + if (nested) for (const arm of nested) walk(arm); + } + }; + walk(result.error.issues); + return out; +} + +describe('objectui#7363 — the two objectui#6576 schemas are arms of the ObjectQL union', () => { + it('the zod union carries both literals', () => { + const literals = ObjectQLComponentZod.options.map( + (arm) => (arm.shape.type as z.ZodLiteral).value, + ); + expect(literals).toContain('object-gallery'); + expect(literals).toContain('object-data-table'); + // The ten arms PR #7355 left in place are all still there — this is an + // addition, not a reshuffle. + expect(literals).toHaveLength(12); + }); + + it.each([ + ['object-gallery', { type: 'object-gallery' }], + ['object-gallery with its keys', { type: 'object-gallery', objectName: 'contact', imageField: 'photo', titleField: 'name' }], + ['object-data-table', { type: 'object-data-table' }], + ['object-data-table with its keys', { type: 'object-data-table', objectName: 'contact', searchable: true, pagination: false, dataProvider: { provider: 'objectql', object: 'contact' } }], + ])('a minimal %s document validates through safeValidateSchema', (_label, doc) => { + const r = safeValidateSchema(doc); + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues, null, 2)).toBe(true); + }); + + it.each([ + ['object-gallery', 'imageField', { type: 'object-gallery', imageField: 42 }], + ['object-data-table', 'searchable', { type: 'object-data-table', searchable: 'yes' }], + ['object-data-table', 'dataProvider.provider', { type: 'object-data-table', dataProvider: { provider: 42 } }], + ])('a wrong-typed declared key on %s is refused BY NAME at %s', (_type, path, doc) => { + // Probe keys are ones NO OTHER arm declares: measured on the ten-arm tree, + // `data` and `className` were already named by a sibling arm's issues, so + // they could not tell "this arm read it" from "some arm read it". + const r = safeValidateSchema(doc); + expect(r.success).toBe(false); + // The load-bearing half: an arm accepted the discriminant and diagnosed + // the key. With no arm, every path here is `type`. + expect(issuePaths(r)).toContain(path); + }); + + it('the runtime-slot refusal on object-data-table now reaches the author (objectui#6124 shape)', () => { + // `onRowClick` is refused BY NAME by the mirror (`handlerKeyRefusal`); until + // the arm existed that refusal was unreachable — the node never got past + // `type`. + const r = safeValidateSchema({ type: 'object-data-table', onRowClick: 'handler' }); + expect(r.success).toBe(false); + expect(issuePaths(r)).toContain('onRowClick'); + }); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 651c8a9016..2714449218 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2865,6 +2865,12 @@ export interface ObjectDataTableSchema extends BaseSchema { /** * Union type of all ObjectQL component schemas + * + * `ObjectGallerySchema` and `ObjectDataTableSchema` joined in objectui#7363. + * PR #7355 (objectui#6576) minted both beside the other members and left this + * union alone, so `Extract< ObjectQLComponentSchema, { type: 'object-gallery' } >` + * was `never` and — through the zod twin in `zod/objectql.zod.ts`, which carries + * the same twelve members — `AnyComponentSchema` had no arm for either node. */ export type ObjectQLComponentSchema = | ObjectGridSchema @@ -2876,4 +2882,6 @@ export type ObjectQLComponentSchema = | ObjectCalendarSchema | ObjectKanbanSchema | ObjectChartSchema + | ObjectGallerySchema + | ObjectDataTableSchema | ListViewSchema; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 507b8d66e3..082e831c41 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -963,6 +963,18 @@ export const ObjectDataTableSchema = BaseSchema.extend({ /** * ObjectQL Component Schema Union + * + * Same twelve members as the TS union in `../objectql.ts`, in the same order. + * `ObjectGallerySchema` and `ObjectDataTableSchema` joined in objectui#7363: + * PR #7355 (objectui#6576) minted both mirrors and deliberately did not extend + * this union, so `AnyComponentSchema` — and `validateSchema` / + * `safeValidateSchema` / `objectui validate` with it — had NO arm for an + * `object-gallery` or `object-data-table` node. Such a document was refused as + * matching no arm, exactly as before the mirrors existed, and a wrong-typed + * declared key on it (`searchable: 'yes'`) could never be diagnosed by name. + * Both nodes render (`plugin-list` registers `object-gallery`, `plugin-dashboard` + * registers `object-data-table`); this is the validating face catching up with + * the rendering one. The behaviour pin is `__tests__/objectql-union-arms-7363.test.ts`. */ export const ObjectQLComponentSchema = z.union([ ObjectGridSchema, @@ -974,5 +986,7 @@ export const ObjectQLComponentSchema = z.union([ ObjectCalendarSchema, ObjectKanbanSchema, ObjectChartSchema, + ObjectGallerySchema, + ObjectDataTableSchema, ListViewSchema, ]); From f7a428fbd79642ea12e3a026711a933054c7a242 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 06:57:45 +0000 Subject: [PATCH 2/4] =?UTF-8?q?wip(types):=20DrillDownConfig=20mirror=20in?= =?UTF-8?q?=20progress=20=E2=80=94=20INCOMPLETE=20AND=20UNREVIEWED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rescue commit made by the PM seat after the dispatched dev was terminated mid-task by an account session limit (HTTP 429, resets 2026-09-05T06:40Z). Committed so the work survives container reclaim; it is NOT a claim that the change is correct or finished. Observed state at rescue time, and nothing beyond it: M packages/types/src/zod/data-display.zod.ts M packages/types/src/zod/index.zod.ts M packages/types/src/zod/objectql.zod.ts ?? packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts The dev's last recorded status was that the instrument was written and the gap-proof run on BASE (both files, expected red) had not yet been executed. No gate was run against this content. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- .../drill-down-config-mirror-7352.test.ts | 193 ++++++++++++++++++ packages/types/src/zod/data-display.zod.ts | 66 ++++++ packages/types/src/zod/index.zod.ts | 1 + packages/types/src/zod/objectql.zod.ts | 19 +- 4 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts diff --git a/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts b/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts new file mode 100644 index 0000000000..008dac87f9 --- /dev/null +++ b/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts @@ -0,0 +1,193 @@ +/** + * 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. + */ + +/** + * `drillDown` is validated by name wherever a mirror declares it + * (objectui#7352). + * + * `DrillDownConfig` (`../data-display.ts`) is the drill configuration five + * widgets share, and two mirrored declarations carry `drillDown?: DrillDownConfig` + * — `ChartSchema` and `ObjectDataTableSchema` (objectui#6576). Neither mirror + * knew the key: there was no `DrillDownConfigSchema`, so under `BaseSchema`'s + * `.passthrough()` a `drillDown: { enabled: 'yes' }` parsed GREEN and rode + * through to a widget that reads `enabled` as truthy. Both pairs were ledgered + * in `zod-mirror-parity.test.ts` (`UnmirroredDeclared`) as the measured debt. + * + * This file is the behaviour pin for the repair: the mirror exists, is + * exported from `@object-ui/types/zod`, is wired into both declarations, and + * REFUSES the malformed value by name where nothing refused it before. The + * ledger side (both `UnmirroredDeclared` rows gone, the new pair registered) + * is pinned by the parity file's own ratchet. + * + * ⚠️ `PivotTableSchema.drillDown` is NOT covered here: that declaration has no + * zod mirror at all, so it sits in no ledger. The mirror minted here is the home + * that key will use whenever the pivot pair is mirrored (a separate card). + * + * ⚠️ Not the spec's `ChartDrillDownSchema`, deliberately: `@objectstack/spec/ui` + * models the CHART-ONLY subset (`enabled` / `filter` / `title` / `target` / + * `columns` / `maxRows`) as a strict object that refuses `mode` and `report` by + * name, and both of those are real keys on the table / pivot / metric widgets + * that share `DrillDownConfig`. Referencing it would make the published + * validator refuse what the published TypeScript declares — the class this + * card closes, in the other direction. + */ +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { + safeValidateSchema, + DrillDownConfigSchema, + ChartSchema, + ObjectDataTableSchema, +} from '../zod/index.zod.js'; +import type { DrillDownConfig } from '../data-display.js'; + +/* ── Runtime helpers ───────────────────────────────────────────────────────── */ + +/** Every issue path in the tree, per-arm `errors` included (see the objectui#7363 pin). */ +function issuePaths(result: { success: true } | { success: false; error: { issues: readonly z.core.$ZodIssue[] } }): string[] { + if (result.success) return []; + const out: string[] = []; + const walk = (issues: readonly z.core.$ZodIssue[]) => { + for (const issue of issues) { + out.push(issue.path.map(String).join('.')); + const nested = (issue as { errors?: readonly (readonly z.core.$ZodIssue[])[] }).errors; + if (nested) for (const arm of nested) walk(arm); + } + }; + walk(result.error.issues); + return out; +} + +/** A chart document that validates on its own, so a red run is about `drillDown`. */ +const chart = (drillDown: unknown) => ({ + type: 'chart', + chartType: 'bar', + series: [{ name: 'revenue' }], + drillDown, +}); +const table = (drillDown: unknown) => ({ type: 'object-data-table', objectName: 'contact', drillDown }); + +/* ── The values hosts actually synthesise ──────────────────────────────────── */ + +/** + * Read from the renderers, not invented: `DashboardRenderer.tsx` writes + * `{ enabled: true }` for drillable charts and `{ enabled: true, mode: 'record' }` + * for object-backed tables; `ObjectMetricWidget.tsx` writes + * `{ enabled: true, mode: 'record', target: 'dialog' }`; the drill tests author + * `{ enabled: false }` and `{ enabled: true, mode: 'filter' }`. `report` takes the + * two structural forms `DrillDownConfig` declares. + */ +const ACCEPTED: Array<[string, DrillDownConfig]> = [ + ['the empty block', {}], + ['enabled', { enabled: true }], + ['disabled', { enabled: false }], + ['record mode', { enabled: true, mode: 'record' }], + ['filter mode', { enabled: true, mode: 'filter' }], + ['record mode in a dialog', { enabled: true, mode: 'record', target: 'dialog' }], + ['navigate target', { target: 'navigate' }], + ['the full drill-through shape', { + enabled: true, + title: '${event.rowLabel}', + filter: { status: '${event.rowKey}', owner: 42 }, + columns: ['name', 'amount'], + maxRows: 50, + }], + ['an inline report', { report: { name: 'pipeline', objectName: 'opportunity', type: 'summary', columns: [] } }], + ['an inline report carrying extra report keys', { + report: { name: 'pipeline', objectName: 'opportunity', columns: [{ field: 'amount' }], groupBy: ['stage'] }, + }], + ['a named report reference', { report: { name: 'pipeline' } }], +]; + +/** Each is a DECLARED key with a value outside its declared type. */ +const REFUSED: Array<[string, unknown, string]> = [ + ['enabled as a string', { enabled: 'yes' }, 'enabled'], + ['an unknown mode', { mode: 'jump' }, 'mode'], + ['an unknown target', { target: 'popup' }, 'target'], + ['title as a number', { title: 42 }, 'title'], + ['filter as a string', { filter: 'status eq open' }, 'filter'], + ['columns as a string', { columns: 'name' }, 'columns'], + ['maxRows as a string', { maxRows: '50' }, 'maxRows'], + ['report as a bare string', { report: 'pipeline' }, 'report'], + ['an inline report without objectName', { report: { name: 'pipeline', columns: [] } }, 'report'], + ['a report reference with a non-string name', { report: { name: 42 } }, 'report'], +]; + +describe('objectui#7352 — DrillDownConfigSchema is the zod mirror of DrillDownConfig', () => { + it('is exported from the /zod barrel and declares every key the TS interface declares', () => { + expect(DrillDownConfigSchema).toBeDefined(); + expect(Object.keys(DrillDownConfigSchema.shape).sort()).toEqual( + ['columns', 'enabled', 'filter', 'maxRows', 'mode', 'report', 'target', 'title'], + ); + }); + + it.each(ACCEPTED)('accepts %s', (_label, value) => { + const r = DrillDownConfigSchema.safeParse(value); + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues, null, 2)).toBe(true); + }); + + it('keeps an inline report\'s extra keys — the declaration has an index signature', () => { + const r = DrillDownConfigSchema.safeParse({ + report: { name: 'pipeline', objectName: 'opportunity', columns: [], groupBy: ['stage'] }, + }); + expect(r.success).toBe(true); + expect(r.success && (r.data.report as Record).groupBy).toEqual(['stage']); + }); + + it.each(REFUSED)('refuses %s, naming the key', (_label, value, key) => { + const r = DrillDownConfigSchema.safeParse(value); + expect(r.success).toBe(false); + expect(issuePaths(r).some((p) => p === key || p.startsWith(`${key}.`))).toBe(true); + }); +}); + +describe('objectui#7352 — both declaring mirrors read the key', () => { + it('ChartSchema and ObjectDataTableSchema declare drillDown through the shared mirror', () => { + expect(ChartSchema.shape.drillDown).toBeDefined(); + expect(ObjectDataTableSchema.shape.drillDown).toBeDefined(); + }); + + it.each(ACCEPTED)('a chart carrying %s validates', (_label, value) => { + const r = safeValidateSchema(chart(value)); + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues, null, 2)).toBe(true); + }); + + it.each(ACCEPTED)('an object-data-table carrying %s validates', (_label, value) => { + const r = safeValidateSchema(table(value)); + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues, null, 2)).toBe(true); + }); + + it('the card\'s own repro is refused where it used to parse green: chart', () => { + // Under `.passthrough()` this parsed green and the widget read `'yes'` as + // truthy. `.success === false` is the whole leg here — it was TRUE before. + const r = safeValidateSchema(chart({ enabled: 'yes' })); + expect(r.success).toBe(false); + expect(issuePaths(r)).toContain('drillDown.enabled'); + }); + + it('the card\'s own repro is refused BY NAME on object-data-table', () => { + // This node was refused before too — for having NO arm at all (objectui#7363). + // The by-name path is what did not exist. + const r = safeValidateSchema(table({ enabled: 'yes' })); + expect(r.success).toBe(false); + expect(issuePaths(r)).toContain('drillDown.enabled'); + }); + + it.each(REFUSED)('a chart carrying %s is refused under drillDown', (_label, value, key) => { + const r = safeValidateSchema(chart(value)); + expect(r.success).toBe(false); + expect(issuePaths(r).some((p) => p === `drillDown.${key}` || p.startsWith(`drillDown.${key}.`))).toBe(true); + }); + + it('the mirror does not reach beyond the two declaring pairs: a plain data-table has no drillDown arm', () => { + // `DataTableSchema` does not declare `drillDown` on either face; a value there + // still rides through on `.passthrough()`, unchanged by this card. + const r = safeValidateSchema({ type: 'data-table', columns: [], data: [], drillDown: { enabled: 'yes' } }); + expect(r.success).toBe(true); + }); +}); diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 1178da417a..10ba8741b8 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -453,6 +453,71 @@ function foldChartXAxisAlias>(input: T): T { return (rest.xAxisKey === undefined ? { ...rest, xAxisKey: alias } : rest) as T; } +/** + * Drill-down configuration — the zod mirror of `DrillDownConfig` + * (`../data-display.ts`), key for key (objectui#7352). + * + * Shared by the declarations that carry `drillDown`: `ChartSchema` below and + * `ObjectDataTableSchema` (`objectql.zod.ts`) reference it. `PivotTableSchema` + * declares the key too but has no mirror of its own, so it sits in no ledger; + * this is the home that key uses whenever the pivot pair is mirrored. Until + * this mirror existed neither declaring mirror had heard of the key, so under + * `BaseSchema`'s `.passthrough()` a `drillDown: { enabled: 'yes' }` parsed green + * and reached a widget that reads `enabled` as truthy — `declared !== enforced`, + * ledgered in `zod-mirror-parity.test.ts` (`UnmirroredDeclared`) by + * objectui#6058 for `ChartSchema` and by objectui#6576 for `ObjectDataTableSchema`. + * + * ⚠️ NOT `@objectstack/spec/ui`'s `ChartDrillDownSchema`, deliberately. That + * object is the CHART-ONLY subset (`enabled` / `filter` / `title` / `target` / + * `columns` / `maxRows`), strict, and refuses `mode` and `report` BY NAME with + * guidance — both are live keys on this wider type: `mode` picks drill-through + * vs drill-to-record on tables / pivots / metrics (`DashboardRenderer` writes + * `{ enabled: true, mode: 'record' }` for every object-backed table widget), and + * `report` drills a metric into an analytical report. Referencing the spec's + * object would make the published validator refuse what the published + * TypeScript declares. No spec export is named `DrillDownConfig` or + * `DrillDownConfigSchema`, so `check:spec-symbols` has nothing to match; the + * pair is registered against the LOCAL declaration, the `ObjectMapConfigSchema` + * precedent. Whether `ChartSchema.drillDown` should one day narrow to the + * spec's chart subset is a separate ruling — the declaration says + * `DrillDownConfig`, and this mirror says the same. + * + * `report` keeps the declaration's structural union: an inline report shape + * (`name` + `objectName` + `columns`, `type` optional, every other report key + * riding through on the index signature — `.catchall(z.unknown())` is what + * `[k: string]: unknown` spells) OR a named reference `{ name }`. Arm order + * matters to `z.union`: the inline arm is tried first, so a value satisfying it + * keeps its extra keys; only a value that fails it falls through to the + * reference arm. + */ +export const DrillDownConfigSchema = z.object({ + enabled: z.boolean().optional().describe('Master switch — true, or any other key present, turns the drill on'), + mode: z.enum(['filter', 'record']).optional().describe( + "'filter' (the aggregate default) drills through to a filtered list; 'record' (the table / list default) opens the clicked record itself", + ), + target: z.enum(['drawer', 'dialog', 'navigate']).optional().describe( + "Where the drill lands: 'drawer' (default), 'dialog', or 'navigate' to the object's full list page (falls back to 'drawer' without host drill navigation)", + ), + filter: z.record(z.string(), z.unknown()).optional().describe('Filter applied to the drilled list; values support ${event.*} interpolation'), + title: z.string().optional().describe('Drawer / dialog title; supports ${event.*} interpolation'), + report: z + .union([ + z + .object({ + name: z.string(), + objectName: z.string(), + type: z.enum(['tabular', 'summary', 'matrix', 'joined']).optional(), + columns: z.array(z.unknown()), + }) + .catchall(z.unknown()), + z.object({ name: z.string() }), + ]) + .optional() + .describe('Drill into an analytical report instead of the record list: an inline SpecReport shape, or a named report reference'), + columns: z.array(z.string()).optional().describe('Column whitelist for the inline drill list'), + maxRows: z.number().optional().describe('Hard cap on rows fetched'), +}); + /** * Chart Schema - Chart/graph component * @@ -506,6 +571,7 @@ export const ChartSchema = BaseSchema.extend({ showGrid: z.boolean().optional().describe('Show grid lines'), animate: z.boolean().optional().describe('Enable animations'), config: z.record(z.string(), z.any()).optional().describe('Additional chart configuration'), + drillDown: DrillDownConfigSchema.optional().describe('Drill-down: clicking a chart segment opens a filtered list view (drawer / dialog)'), }).overwrite(foldChartXAxisAlias); /** diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index cba8d1aa1a..94054257ca 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -143,6 +143,7 @@ export { TreeViewSchema, ChartTypeSchema, ChartDataSeriesSchema, + DrillDownConfigSchema, ChartSchema, TimelineEventSchema, TimelineSchema, diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 082e831c41..70fec93c61 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -37,6 +37,7 @@ import { } from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; import { handlerKeyRefusal } from './tombstone.zod.js'; +import { DrillDownConfigSchema } from './data-display.zod.js'; /** * HTTP Method Schema — `@objectstack/spec/ui` schema re-exported by reference @@ -935,18 +936,21 @@ export const ObjectGallerySchema = BaseSchema.extend({ /** * ObjectDataTable Schema (objectui#6576 / objectui#6914) * - * Mirrors the `ObjectDataTableSchema` interface in `objectql.ts`. Two keys - * follow the parity ledger's discipline rather than the literal shape: + * Mirrors the `ObjectDataTableSchema` interface in `objectql.ts`. One key + * follows the parity ledger's discipline rather than the literal shape, and + * one used to: * * - `onRowClick` is a RUNTIME SLOT — a host-supplied function the widget * forwards into `data-table` — so the mirror refuses it BY NAME * (`handlerKeyRefusal`, the objectui#6124 shape) while the TS twin stays * callable; `KnownDrift` in `zod-mirror-parity.test.ts` records the * divergence. - * - `drillDown` is declared on the TS face and deliberately NOT mirrored: - * `DrillDownConfig` has no zod mirror in this package (`ChartSchema.drillDown` - * is in the same state), and minting one is a new export outside the - * objectui#6576 ruling. `UnmirroredDeclared` records it. + * - `drillDown` is mirrored through `DrillDownConfigSchema` + * (`data-display.zod.ts`, objectui#7352). objectui#6576 declared the key + * and left it unmirrored — minting the mirror was a new export outside + * that ruling — so `UnmirroredDeclared` carried it, as it had carried + * `ChartSchema.drillDown` since objectui#6058; both entries left the + * ledger with that mirror. */ export const ObjectDataTableSchema = BaseSchema.extend({ type: z.literal('object-data-table'), @@ -958,6 +962,9 @@ export const ObjectDataTableSchema = BaseSchema.extend({ columns: z.array(z.any()).optional().describe('Column definitions (names or column objects)'), searchable: z.boolean().optional().describe('Forwarded to the rendered data-table'), pagination: z.boolean().optional().describe('Forwarded to the rendered data-table'), + drillDown: DrillDownConfigSchema.optional().describe( + 'Drill-to-record: clicking a row opens that record in a detail drawer (DashboardRenderer defaults object-backed table widgets to { enabled: true, mode: record })', + ), onRowClick: handlerKeyRefusal('onRowClick', 'runtime-slot', 'Row click handler (overrides drill-to-record)'), }); From 11834ce8726f60da29f05385e1633ce3d24a43e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 07:08:44 +0000 Subject: [PATCH 3/4] feat(types): mirror DrillDownConfig in zod and wire it into both declaring mirrors (objectui#7352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the rescued WIP commit f7a428fbd, which carried the mirror, the barrel export and the behaviour pin, and had never been run through a gate. What that content was missing, measured rather than assumed: - `zod-mirror-parity.test.ts` did not know the new export. Its population census was RED ("New zod export(s) in packages/types/src/zod/ that this guard does not cover: data-display.zod.ts#DrillDownConfigSchema"), and `tsc -p tsconfig.test.json` was RED on the staleness ratchet at line 1478 — both `drillDown` rows in `UnmirroredDeclared` had become stale the moment the mirror wired in. The pair is now registered in MIRRORS / Declared against the LOCAL `DrillDownConfig` (the `ObjectMapConfigSchema` precedent), both rows are retired with the movement recorded, and every count the change moves is updated: 160 → 161 pairs, UnmirroredDeclared 15 / 96 → 13 / 94 (LOCAL 12 / 83 → 11 / 82), the "no entry in either" population 144 → 147. The stale "143 of the 160" beside `assertionRatchetAcceptsCleanPair` — already wrong before this change — reads 147 / 161 with the rest. - One assertion in the pin was wrong about the DECLARATION, not about the mirror: it expected `report: { name, columns }` (an inline report missing `objectName`) to be refused. `DrillDownConfig`'s second arm is `{ name: string }`, which is not exact, so TypeScript accepts that value — refusing it would make the mirror NARROWER than the declaration, the drift class this ledger exists to stop. It moved to the accepted table, annotated `DrillDownConfig` so `tsc -p tsconfig.test.json` is the standing witness. - `widget-schema-anchors-6576.test.ts` listed `drillDown` as a ledgered unmirrored read. The mirror declares it now, so the exception is retired; a stale one widens `declared` for a key nothing checks any more. Changeset added: the new `/zod` export and the accept-set narrowing are published. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- .changeset/7352-drill-down-config-mirror.md | 34 +++++ .../drill-down-config-mirror-7352.test.ts | 10 +- .../widget-schema-anchors-6576.test.ts | 11 +- .../src/__tests__/zod-mirror-parity.test.ts | 118 ++++++++++-------- 4 files changed, 119 insertions(+), 54 deletions(-) create mode 100644 .changeset/7352-drill-down-config-mirror.md diff --git a/.changeset/7352-drill-down-config-mirror.md b/.changeset/7352-drill-down-config-mirror.md new file mode 100644 index 0000000000..6056dcd59d --- /dev/null +++ b/.changeset/7352-drill-down-config-mirror.md @@ -0,0 +1,34 @@ +--- +'@object-ui/types': minor +--- + +`DrillDownConfigSchema` is the zod mirror of `DrillDownConfig`, and both +declarations that carry `drillDown` reference it — `ChartSchema` +(`zod/data-display.zod.ts`) and `ObjectDataTableSchema` (`zod/objectql.zod.ts`) +— so the published validator under `@object-ui/types/zod` reads the key for the +first time (objectui#7352). + +`DrillDownConfig` has been declared on the TypeScript face since objectui#6058 +seeded the parity ledger, and objectui#6576 declared it on a second type. No zod +mirror existed, so under `BaseSchema`'s `.passthrough()` a +`drillDown: { enabled: 'yes' }` parsed green and rode through to a widget that +reads `enabled` as truthy — `declared !== enforced` on a published surface. + +Accept-set change on the published validator, stated plainly: + +- NARROWS: a `drillDown` whose declared key holds a value outside its declared + type (`enabled: 'yes'`, `mode: 'jump'`, `maxRows: '50'`, `report: 'pipeline'`) + is now refused BY NAME on a `chart` or `object-data-table` node, where it + previously rode through untouched. +- Unchanged: every value the TypeScript declares still validates, including the + `{ enabled: true }` / `{ enabled: true, mode: 'record' }` blocks the dashboard + renderer synthesises, `report`'s two structural forms, and an inline report's + extra keys (the declaration's index signature is `.catchall(z.unknown())`). +- Unchanged: `PivotTableSchema.drillDown` has no zod mirror at all, and + `DataTableSchema` declares the key on neither face — both are untouched here. +- New export on `@object-ui/types/zod`: `DrillDownConfigSchema`. + +`DrillDownConfigSchema` is deliberately NOT `@objectstack/spec/ui`'s +`ChartDrillDownSchema`: that object models the chart-only subset strictly and +refuses `mode` and `report` by name, both of which are live keys on the table / +pivot / metric widgets that share `DrillDownConfig`. diff --git a/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts b/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts index 008dac87f9..d1d9acdb5b 100644 --- a/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts +++ b/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts @@ -102,6 +102,15 @@ const ACCEPTED: Array<[string, DrillDownConfig]> = [ report: { name: 'pipeline', objectName: 'opportunity', columns: [{ field: 'amount' }], groupBy: ['stage'] }, }], ['a named report reference', { report: { name: 'pipeline' } }], + // ⚠️ MEASURED, not assumed, and it moved here from the refusal table below. The + // declaration's second arm is `{ name: string }` — not an exact type — so a value + // with a string `name` satisfies it whatever else it carries, and TypeScript's + // excess-property check passes because `columns` is a member of the OTHER arm. + // This entry is annotated `DrillDownConfig`, so `tsc -p tsconfig.test.json` is the + // witness: if the declaration ever refused it, this line stops compiling. A mirror + // that refused it would be NARROWER than the declaration — the drift class this + // ledger exists to stop, in the direction that hurts authors. + ['an inline report missing objectName, which the reference arm accepts', { report: { name: 'pipeline', columns: [] } }], ]; /** Each is a DECLARED key with a value outside its declared type. */ @@ -114,7 +123,6 @@ const REFUSED: Array<[string, unknown, string]> = [ ['columns as a string', { columns: 'name' }, 'columns'], ['maxRows as a string', { maxRows: '50' }, 'maxRows'], ['report as a bare string', { report: 'pipeline' }, 'report'], - ['an inline report without objectName', { report: { name: 'pipeline', columns: [] } }, 'report'], ['a report reference with a non-string name', { report: { name: 42 } }, 'report'], ]; diff --git a/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts b/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts index 14279f5e7a..4da024fc5d 100644 --- a/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts +++ b/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts @@ -171,11 +171,14 @@ const WIDGETS = [ /** The dashboard plugin registers its widgets from the barrel, not the widget file. */ registrationFile: 'packages/plugin-dashboard/src/index.tsx', mirror: ObjectDataTableMirror, - // `drillDown` is declared on the TS face and ledgered as unmirrored in + // `drillDown` was listed here while it was ledgered as unmirrored in // `zod-mirror-parity.test.ts` (`UnmirroredDeclared`, LOCAL — the same - // reading as `ChartSchema.drillDown`): `DrillDownConfig` has no zod mirror - // in this package, and minting one is a new export outside #6576's ruling. - unmirroredReads: ['drillDown'] as readonly string[], + // reading as `ChartSchema.drillDown`). objectui#7352 minted + // `DrillDownConfigSchema` and wired it into this mirror, so the key is now + // DECLARED by the mirror and needs no exception: the census below reads it + // out of `mirror.shape`. An exception left standing here would be a hole — + // it widens `declared` for a key nothing checks any more. + unmirroredReads: [] as readonly string[], /** The two objectui#6914 casts the declaration makes unnecessary. */ retiredCasts: ['schema.drillDown as DrillDownConfig', '(schema as any).onRowClick'] as readonly string[], }, diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index bc125618fe..358307b947 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -28,7 +28,7 @@ * * It reads `.shape` and not `keyof z.input` because that spelling is * vacuous — `.passthrough()` collapses the inferred key union to bare `string`. - * `assertionNoVacuousEntry` below pins that for all 160 entries at once. + * `assertionNoVacuousEntry` below pins that for all 161 entries at once. * * ## What is registered * @@ -53,9 +53,12 @@ * framing, and objectui#6058's own dispatch, both inherited "163"). On a file whose * entire subject is measurement that is worth stating explicitly: * - * - **160 pairs** — `Object.keys(MIRRORS).length`, which `assertionRegistryHalvesAgree` - * already pins equal to `keyof Declared`. Nothing asserts it against a written - * number, so this line is prose and can rot; the pin that cannot is the one + * - **161 pairs** — `Object.keys(MIRRORS).length`, which `assertionRegistryHalvesAgree` + * already pins equal to `keyof Declared`. It was 160 until objectui#7352 registered + * `data-display.zod.ts#DrillDownConfigSchema` — a nested config mirror paired with + * the local `DrillDownConfig`, the `ObjectMapConfigSchema` precedent — + * and it carries no ledger entry. Nothing asserts the number 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`, **56 keys** across them — 39 / 55 until * objectui#7455 SEEDED `app.zod.ts#AppComponentSchema` with its one @@ -71,7 +74,11 @@ * and 37 / 53 until objectui#7344 swept the string / `z.any()` handler mirrors: * `DetailSchema` and `DetailViewSchema` entered (one `onBack` each) and * `CalendarViewSchema` grew by `onEventClick`. - * - **15 entries** in `UnmirroredDeclared`, **96 keys** across them. It read 17 / 98 + * - **13 entries** in `UnmirroredDeclared`, **94 keys** across them — 15 / 96 until + * objectui#7352 MIRRORED both `drillDown` rows at once (`ChartSchema` and + * `ObjectDataTableSchema`, each the entry's whole content, so both entries went): + * the ledger's second and third shrink by REPAIR, on the route objectui#6639 opened. + * It read 17 / 98 * between objectui#6576, which SEEDED the new `ObjectDataTableSchema` pair with its * one measured key `drillDown` (a pair born ledgered, not growth on an existing * one), and objectui#7129, which RETIRED `DetailViewSectionSchema.hideEmpty` — @@ -93,10 +100,12 @@ * "no entry in either" population dropped by one to 141 — went to 142 when * objectui#6576 added two pairs, one of them ledgered, went to 143 when * objectui#7129 retired `DetailViewSectionSchema`'s only ledgered key, and - * stands at **144** since objectui#7623 retired `DashboardComponentSchema`'s + * went to 144 when objectui#7623 retired `DashboardComponentSchema`'s * only UNMIRRORED one (that pair keeps its `KnownDrift` entry — this - * population counts entries in the two unmirrored ledgers). - * - 160 − 40 = **120**, the "pairs with no entry" `LedgerMismatch` speaks of. + * population counts entries in the two unmirrored ledgers), and stands at **147** + * since objectui#7352: two pairs left the unmirrored ledgers by repair and the new + * `DrillDownConfigSchema` pair arrived clean. + * - 161 − 40 = **121**, the "pairs with no entry" `LedgerMismatch` speaks of. * * ## Two ratchets, because the forward comparison has two halves * @@ -117,7 +126,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 40 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 40 of the 161 pairs carry TYPE drift TODAY (measured, not assumed). Each is * pinned to its EXACT drifted key set, so the entry fails when new drift appears on * that mirror AND when the recorded drift is fixed — a stale entry cannot rot * quietly. Correcting them is not one change: the pairs below split into DISJOINT @@ -153,7 +162,7 @@ import { AppActionSchema, AppComponentSchema, NavigationAreaSchema } from '../zo import { BaseSchema, ComponentConfigSchema, ComponentInputSchema, ComponentMetaSchema, KeyedI18nLabelSchema } from '../zod/base.zod.js'; import { CalendarEventSchema, CalendarViewSchema, CarouselItemSchema, CarouselSchema, ChatbotSchema, ChatMessageSchema, ChatMessageSourceSchema, ChatToolInvocationSchema, DashboardComponentSchema, DashboardConfigSchema, DashboardWidgetConfigSchema, DashboardWidgetLayoutSchema, DashboardWidgetSchema, FilterBuilderSchema, FilterFieldSchema, DeclarativeKanbanCardSchema, DeclarativeKanbanColumnSchema, DeclarativeKanbanSchema } from '../zod/complex.zod.js'; import { ActionCallbackSchema, CRUDDialogSchema, DetailSchema } from '../zod/crud.zod.js'; -import { AlertSchema, AvatarSchema, BadgeSchema, BarChartSchema, ChartDataSeriesSchema, ChartSchema, DataTableSchema, HtmlSchema, KbdSchema, ListItemSchema, ListSchema, MarkdownSchema, StaticTableColumnSchema, StatisticSchema, TableColumnSchema, TableSchema, TimelineEventSchema, TimelineSchema, TreeViewSchema } from '../zod/data-display.zod.js'; +import { AlertSchema, AvatarSchema, BadgeSchema, BarChartSchema, ChartDataSeriesSchema, ChartSchema, DataTableSchema, DrillDownConfigSchema, HtmlSchema, KbdSchema, ListItemSchema, ListSchema, MarkdownSchema, StaticTableColumnSchema, StatisticSchema, TableColumnSchema, TableSchema, TimelineEventSchema, TimelineSchema, TreeViewSchema } from '../zod/data-display.zod.js'; import { AccordionItemSchema, AccordionSchema, CollapsibleSchema, ToggleGroupItemSchema, ToggleGroupSchema } from '../zod/disclosure.zod.js'; import { EmptySchema, LoadingSchema, ProgressSchema, SkeletonSchema, SonnerSchema, SpinnerSchema, ToasterSchema, ToastSchema } from '../zod/feedback.zod.js'; import { ButtonSchema, CalendarSchema, CheckboxSchema, CodeEditorSchema, ComboboxOptionSchema, ComboboxSchema, CommandGroupSchema, CommandItemSchema, CommandSchema, DatePickerSchema, FieldConditionSchema, FieldConstraintsSchema, FileUploadSchema, FormFieldSchema, FormSchema, InputOTPSchema, InputSchema, LabelSchema, RadioGroupSchema, RadioOptionSchema, SelectOptionSchema, SelectSchema, SliderSchema, SwitchSchema, TextareaSchema, ToggleSchema } from '../zod/form.zod.js'; @@ -169,7 +178,7 @@ import type { BaseSchema as Ts_BaseSchema, ComponentConfig as Ts_ComponentConfig import type { CalendarEvent as Ts_CalendarEvent, CalendarViewSchema as Ts_CalendarViewSchema, CarouselItem as Ts_CarouselItem, CarouselSchema as Ts_CarouselSchema, ChatbotSchema as Ts_ChatbotSchema, ChatMessage as Ts_ChatMessage, ChatMessageSource as Ts_ChatMessageSource, ChatToolInvocation as Ts_ChatToolInvocation, DashboardComponentSchema as Ts_DashboardComponentSchema, DashboardWidgetLayout as Ts_DashboardWidgetLayout, DashboardWidgetSchema as Ts_DashboardWidgetSchema, FilterBuilderSchema as Ts_FilterBuilderSchema, FilterField as Ts_FilterField, DeclarativeKanbanCard as Ts_KanbanCard, DeclarativeKanbanColumn as Ts_KanbanColumn, DeclarativeKanbanSchema as Ts_KanbanSchema } from '../complex'; import type { DashboardConfig as Ts_DashboardConfig, DashboardWidgetConfig as Ts_DashboardWidgetConfig } from '../designer'; import type { ActionCallback as Ts_ActionCallback, CRUDDialogSchema as Ts_CRUDDialogSchema, DetailSchema as Ts_DetailSchema } from '../crud'; -import type { AlertSchema as Ts_AlertSchema, AvatarSchema as Ts_AvatarSchema, BadgeSchema as Ts_BadgeSchema, BarChartSchema as Ts_BarChartSchema, ChartDataSeries as Ts_ChartDataSeries, ChartSchema as Ts_ChartSchema, DataTableSchema as Ts_DataTableSchema, HtmlSchema as Ts_HtmlSchema, KbdSchema as Ts_KbdSchema, ListItem as Ts_ListItem, ListSchema as Ts_ListSchema, MarkdownSchema as Ts_MarkdownSchema, StaticTableColumn as Ts_StaticTableColumn, StatisticSchema as Ts_StatisticSchema, TableColumn as Ts_TableColumn, TableSchema as Ts_TableSchema, TimelineEvent as Ts_TimelineEvent, TimelineSchema as Ts_TimelineSchema, TreeViewSchema as Ts_TreeViewSchema, BreadcrumbItem as Ts_BreadcrumbItem, BreadcrumbSchema as Ts_BreadcrumbSchema } from '../data-display'; +import type { AlertSchema as Ts_AlertSchema, AvatarSchema as Ts_AvatarSchema, BadgeSchema as Ts_BadgeSchema, BarChartSchema as Ts_BarChartSchema, ChartDataSeries as Ts_ChartDataSeries, ChartSchema as Ts_ChartSchema, DataTableSchema as Ts_DataTableSchema, DrillDownConfig as Ts_DrillDownConfig, HtmlSchema as Ts_HtmlSchema, KbdSchema as Ts_KbdSchema, ListItem as Ts_ListItem, ListSchema as Ts_ListSchema, MarkdownSchema as Ts_MarkdownSchema, StaticTableColumn as Ts_StaticTableColumn, StatisticSchema as Ts_StatisticSchema, TableColumn as Ts_TableColumn, TableSchema as Ts_TableSchema, TimelineEvent as Ts_TimelineEvent, TimelineSchema as Ts_TimelineSchema, TreeViewSchema as Ts_TreeViewSchema, BreadcrumbItem as Ts_BreadcrumbItem, BreadcrumbSchema as Ts_BreadcrumbSchema } from '../data-display'; import type { AccordionItem as Ts_AccordionItem, AccordionSchema as Ts_AccordionSchema, CollapsibleSchema as Ts_CollapsibleSchema, ToggleGroupItem as Ts_ToggleGroupItem, ToggleGroupSchema as Ts_ToggleGroupSchema } from '../disclosure'; import type { EmptySchema as Ts_EmptySchema, LoadingSchema as Ts_LoadingSchema, ProgressSchema as Ts_ProgressSchema, SkeletonSchema as Ts_SkeletonSchema, SonnerSchema as Ts_SonnerSchema, SpinnerSchema as Ts_SpinnerSchema, ToasterSchema as Ts_ToasterSchema, ToastSchema as Ts_ToastSchema } from '../feedback'; import type { ButtonSchema as Ts_ButtonSchema, CalendarSchema as Ts_CalendarSchema, CheckboxSchema as Ts_CheckboxSchema, CodeEditorSchema as Ts_CodeEditorSchema, ComboboxOption as Ts_ComboboxOption, ComboboxSchema as Ts_ComboboxSchema, CommandGroup as Ts_CommandGroup, CommandItem as Ts_CommandItem, CommandSchema as Ts_CommandSchema, DatePickerSchema as Ts_DatePickerSchema, FieldCondition as Ts_FieldCondition, FieldValidationRules as Ts_FieldValidationRules, FileUploadSchema as Ts_FileUploadSchema, FormField as Ts_FormField, FormSchema as Ts_FormSchema, InputOTPSchema as Ts_InputOTPSchema, InputSchema as Ts_InputSchema, LabelSchema as Ts_LabelSchema, RadioGroupSchema as Ts_RadioGroupSchema, RadioOption as Ts_RadioOption, SelectOption as Ts_SelectOption, SelectSchema as Ts_SelectSchema, SliderSchema as Ts_SliderSchema, SwitchSchema as Ts_SwitchSchema, TextareaSchema as Ts_TextareaSchema, ToggleSchema as Ts_ToggleSchema } from '../form'; @@ -282,7 +291,7 @@ export type ReconcileAgainstLedger< K, Measured, Recorded > = export type assertionRatchetAcceptsAgreement = Expect< Equal< ReconcileAgainstLedger< 'p', 'a', 'a' >, never > >; -/** …and so does a clean pair with no entry, which is the case for 143 of the 160. */ +/** …and so does a clean pair with no entry, which is the case for 147 of the 161. */ export type assertionRatchetAcceptsCleanPair = Expect< Equal< ReconcileAgainstLedger< 'p', never, never >, never > >; @@ -399,6 +408,7 @@ const MIRRORS = { 'data-display.zod.ts#ChartDataSeriesSchema': ChartDataSeriesSchema, 'data-display.zod.ts#ChartSchema': ChartSchema, 'data-display.zod.ts#DataTableSchema': DataTableSchema, + 'data-display.zod.ts#DrillDownConfigSchema': DrillDownConfigSchema, 'data-display.zod.ts#HtmlSchema': HtmlSchema, 'data-display.zod.ts#KbdSchema': KbdSchema, 'data-display.zod.ts#BarChartSchema': BarChartSchema, @@ -557,6 +567,7 @@ interface Declared { 'data-display.zod.ts#ChartDataSeriesSchema': Ts_ChartDataSeries; 'data-display.zod.ts#ChartSchema': Ts_ChartSchema; 'data-display.zod.ts#DataTableSchema': Ts_DataTableSchema; + 'data-display.zod.ts#DrillDownConfigSchema': Ts_DrillDownConfig; 'data-display.zod.ts#HtmlSchema': Ts_HtmlSchema; 'data-display.zod.ts#KbdSchema': Ts_KbdSchema; 'data-display.zod.ts#BarChartSchema': Ts_BarChartSchema; @@ -910,9 +921,9 @@ interface KnownDrift { * invites an author to write and the published validator has never heard of. * * ## ⚠️ READ THIS BEFORE QUOTING THE NUMBER — 121 became 98 by RECLASSIFICATION, - * ## then 97 by the first REPAIR, then 96 by RETIREMENT + * ## then 97 by the first REPAIR, then 96 by RETIREMENT, then 94 by REPAIR again * - * objectui#6058 seeded this ledger at **121 keys**. It records **96**, and the + * objectui#6058 seeded this ledger at **121 keys**. It records **94**, and the * movements are different facts. objectui#6152 measured the 23 callback-shaped * (`on*`) keys and ruled that mirroring is the wrong remedy for every one of them; * they moved, intact and still pinned, to `RuntimeOnlyDeclared` below — ⛔ nothing @@ -922,12 +933,17 @@ interface KnownDrift { * first shrink by repair. Then objectui#7129 and objectui#7623 each RETIRED a key by * deleting the DECLARATION (`DetailViewSectionSchema.hideEmpty`; * `DashboardComponentSchema.title`) — shrinks by removal, which is neither a repair - * of the mirror nor a waiver: the key stops being offered to authors at all. + * of the mirror nor a waiver: the key stops being offered to authors at all. Then + * objectui#7352 MIRRORED the two `drillDown` keys (`ChartSchema`, + * `ObjectDataTableSchema`) by minting the `DrillDownConfigSchema` both entries named + * as their remedy — the second and third repairs, and the first to close two entries + * in one change. * - * So: **96 is the mirroring debt; 96 − 1 seeded + 1 mirrored + 2 retired + 23 + * So: **94 is the mirroring debt; 94 − 1 seeded + 3 mirrored + 2 retired + 23 * reclassified is what "121" used to mean** (the seed is objectui#6576's - * `ObjectDataTableSchema.drillDown`, which was never part of the 121). A card that - * cites 121 as the size of the mirroring problem, or 98/97/96 as a shrink from it, + * `ObjectDataTableSchema.drillDown`, which was never part of the 121 — and which + * objectui#7352 has since repaired). A card that + * cites 121 as the size of the mirroring problem, or 98/97/96/94 as a shrink from it, * is wrong in both directions. objectui#6141 is the * standing example of what a silently moved count costs — it is why this paragraph * is in the ledger rather than in a commit message. @@ -942,7 +958,7 @@ interface KnownDrift { * is being let through, because there was no such thing. Seeding takes the count of * VISIBLE, RATCHETED facts from 0 to 121 and installs a floor: the problem cannot * grow while they are worked off, and a new declared-but-unmirrored key on any of - * the 160 pairs reddens immediately (`assertionRatchetRejectsFreshDrift`) — + * the 161 pairs reddens immediately (`assertionRatchetRejectsFreshDrift`) — * including a callback-shaped one, which reddens until it is filed in * `RuntimeOnlyDeclared` (`assertionSplitLedgerRejectsFreshCallback`). * @@ -979,9 +995,10 @@ interface KnownDrift { * spec schema does not model, which is objectui#2231's unification question and * NOT a local mirror edit. They are marked, not exempted: exempting them in the * instrument would re-blind exactly the pairs objectui#5927 leaned on hardest. - * - **LOCAL (12 entries, 83 keys)** — plain omissions from a hand-written mirror. + * - **LOCAL (11 entries, 82 keys)** — plain omissions from a hand-written mirror. * It was 13 / 84 until objectui#7129 RETIRED `DetailViewSectionSchema.hideEmpty`, - * a shrink by removing the DECLARATION rather than by mirroring it. + * a shrink by removing the DECLARATION rather than by mirroring it, and 12 / 83 + * until objectui#7352 MIRRORED `ChartSchema.drillDown` — its whole entry. * * ⚠️ Both counts moved with the reclassification: the spec-derived side lost * `ObjectViewSchema.onNavigate` (14 → 13) and the local side lost the other 22 @@ -990,10 +1007,11 @@ interface KnownDrift { * reclassification — every affected pair kept keys here — so the split read 16 * entries / 97 keys after it. Three later changes moved the entry count itself: * objectui#6576 SEEDED `ObjectDataTableSchema` (a 17th entry, in neither half - * above), objectui#7129 RETIRED an entry from the LOCAL half, and objectui#7623 - * RETIRED one from the SPEC-DERIVED half (13 → 12 keys there). The ledger now - * totals **15 entries / 96 keys** — 2 / 12 spec-derived, 12 / 83 local, plus the - * seeded pair. + * above), objectui#7129 RETIRED an entry from the LOCAL half, objectui#7623 + * RETIRED one from the SPEC-DERIVED half (13 → 12 keys there), and objectui#7352 + * MIRRORED two — the LOCAL `ChartSchema` entry and the seeded `ObjectDataTableSchema` + * one. The ledger now totals **13 entries / 94 keys** — 2 / 12 spec-derived, + * 11 / 82 local; the seeded pair is no longer among them. * * ## How this was measured, and the trap that makes the number hard to get * @@ -1039,8 +1057,14 @@ interface UnmirroredDeclared { * routes the same way. */ 'complex.zod.ts#DashboardWidgetSchema': 'pagination' | 'searchable'; - /** LOCAL. Declared in `../data-display.ts`, absent from the mirror. */ - 'data-display.zod.ts#ChartSchema': 'drillDown'; + // `data-display.zod.ts#ChartSchema` recorded `drillDown` here (LOCAL) from + // objectui#6058's seeding until objectui#7352 MIRRORED it: `DrillDownConfigSchema` + // (`data-display.zod.ts`, a registered pair of its own above) now restates + // `DrillDownConfig` key for key, and `ChartSchema.drillDown` references it. A shrink + // by REPAIR — the objectui#6639 route, the ledger's second and third use of it (this + // entry and `ObjectDataTableSchema`'s below went in the same change) — not a + // reclassification and not a retirement: the key is still declared, still authorable, + // and is now enforced. The pair holds no entry in either unmirrored ledger. /** * LOCAL, and still the largest single entry at 17. It was 29: the twelve `on*` keys * are in `RuntimeOnlyDeclared` below (objectui#6152). `rowActions` is in @@ -1070,18 +1094,13 @@ interface UnmirroredDeclared { 'form.zod.ts#LabelSchema': 'content'; /** LOCAL. */ 'navigation.zod.ts#PaginationSchema': 'currentPage'; - /** - * LOCAL — a SEED, not growth. This pair was minted by objectui#6576 (folding - * #6914, which found the widget reading `drillDown` behind a cast and declaring - * it nowhere); the declaration now carries it, and the mirror does not, for the - * same reason `ChartSchema.drillDown` above is here: `DrillDownConfig` has no - * zod mirror in this package, and minting one is a new export outside that - * ruling. The shrink-only rule governs keys that APPEAR on registered pairs; - * a pair born with its measured debt written down is the seeding discipline - * objectui#6058 established, applied once. Remedy when ruled: a paired - * `DrillDownConfigSchema` mirror, which shrinks THIS entry and `ChartSchema`'s. - */ - 'objectql.zod.ts#ObjectDataTableSchema': 'drillDown'; + // `objectql.zod.ts#ObjectDataTableSchema` recorded `drillDown` here (LOCAL) as a + // SEED — the pair was minted by objectui#6576 with its measured debt written down, + // and the entry named its own remedy: "a paired `DrillDownConfigSchema` mirror, + // which shrinks THIS entry and `ChartSchema`'s". objectui#7352 was that ruling and + // did exactly that, so both rows are gone together. The pair keeps its `KnownDrift` + // entry above (`onRowClick`, a runtime slot the mirror refuses by name) and records + // nothing here. /** * LOCAL, and still the second-largest at 21. It was 26: the five `on*` keys are in * `RuntimeOnlyDeclared` below (objectui#6152). ⚠️ `submitHandler` is NOT among them @@ -1373,7 +1392,7 @@ export type assertionLedgerHalvesAreDisjoint = Expect< Equal< DoubleFiledKey, ne /** * Every pair's TYPE drift equals what `KnownDrift` records for it — `never` for the - * 120 pairs with no entry (160 − 40). + * 121 pairs with no entry (161 − 40). * * Routed through `ReconcileAgainstLedger` rather than spelling the conditional * inline. That is a semantics-preserving refactor and nothing else — the type is @@ -1421,15 +1440,16 @@ export const assertionDriftMatchesLedger: never = 0 as unknown as LedgerMismatch /** * The SECOND half of the forward comparison: every pair's declared-but-unmirrored - * key set equals what the two ledgers TOGETHER record for it — `never` for the 144 - * pairs with no entry in either (160 − 16). Six of `RuntimeOnlyDeclared`'s seven - * pairs are a measured subset of `UnmirroredDeclared`'s 15, so objectui#6152's + * key set equals what the two ledgers TOGETHER record for it — `never` for the 147 + * pairs with no entry in either (161 − 14). Six of `RuntimeOnlyDeclared`'s seven + * pairs are a measured subset of `UnmirroredDeclared`'s 13, so objectui#6152's * reclassification left the clean population unchanged; objectui#6150 then added - * `TreeViewSchema`, whose only entry is runtime-only, which is why the union is 16 - * pairs and not 15. (objectui#6576 took the union to 18; objectui#7129 brought it - * back to 17 by retiring `DetailViewSectionSchema`'s only ledgered key, and - * objectui#7623 to 16 by retiring `DashboardComponentSchema`'s — each leaving its - * pair with no entry in either half. ⚠️ That pair still carries a `KnownDrift` + * `TreeViewSchema`, whose only entry is runtime-only, which is why the union is one + * pair larger than `UnmirroredDeclared` itself. (objectui#6576 took the union to 18; + * objectui#7129 brought it back to 17 by retiring `DetailViewSectionSchema`'s only + * ledgered key, objectui#7623 to 16 by retiring `DashboardComponentSchema`'s, and + * objectui#7352 to 14 by MIRRORING both `drillDown` entries — each leaving its + * pair with no entry in either half. ⚠️ Two of those pairs still carry a `KnownDrift` * entry: "no entry in either" is about the two UNMIRRORED ledgers.) * * ⚠️ **The discriminating signal is the PER-PAIR set, not this file's exit code.** @@ -1480,7 +1500,7 @@ export const assertionUnmirroredMatchesLedger: never = 0 as unknown as { }[MirrorKey]; /** - * Non-vacuity for all 160 entries at once. + * Non-vacuity for all 161 entries at once. * * `NarrowerThanDeclared` is `never` — green — for an entry whose mirror exposes no * `.shape`, and also for one whose key union has degenerated to bare `string` (the From dfec57b5e2701543b9d28a1ddbdf88b237de1973 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 07:53:10 +0000 Subject: [PATCH 4/4] =?UTF-8?q?docs(types):=20re-derive=20the=20parity=20l?= =?UTF-8?q?edger's=20absolute=20counts=20=E2=80=94=20155=20pairs,=20not=20?= =?UTF-8?q?161=20(objectui#7352)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment-only. Contract review on PR #7701 caught that the pair count this card moved was stale before it: I added 1 to a number that had been wrong twice, and rewrote one wrong derived figure into a different wrong one. Re-derived by two independent methods that agree, rather than by stepping a delta: a TypeScript AST walk counting `PropertyAssignment` nodes in the `MIRRORS` initializer and `PropertySignature` members in each ledger interface, and a line-oriented parse of the same blocks. MIRRORS pairs 155 (prose said 161) KnownDrift 40 / 56 (unchanged, and correct) UnmirroredDeclared 13 / 94 (unchanged, and correct) RuntimeOnlyDeclared 7 / 24 (unchanged, and correct) union of the two unmirrored ledgers 14 (unchanged, and correct) clean population 141 (prose said 147) pairs with no KnownDrift entry 115 (prose said 121) The same walk run over this file at three revisions confirms how it drifted, so the correction is recorded rather than just applied: `4ca30d044` wrote "160" when `MIRRORS` held 163; `d88e20f55` (objectui#7432) took the registry to 154 without touching the sentence; this branch then added its 1 to the stale baseline. Two consequences beyond the numbers: - The running chain of per-card deltas under the population bullet (141 -> 142 -> 143 -> 144 -> 147) was arithmetic on the wrong base and is not re-derivable from this file. It is replaced by the measurement plus a note saying so, with an explicit instruction to subtract the union from the registry count rather than restart the chain. - `RuntimeOnlyDeclared`'s docblock restated the same debt as "97"; it was stale before this branch too (the ledger held 96) and now reads 94, with the equation balanced the way the other ledger states it. Also in this commit, from the same review, both optional and both measured: - The accepted-table comment in `drill-down-config-mirror-7352.test.ts` said a string `name` satisfies the reference arm "whatever else it carries". That overstates it: the acceptance is shape-specific, and — measured — `{ name, columns: [] }` PARSES TO `{ name }`, because the reference arm is a plain object that strips what it does not declare, while the inline arm keeps extras through `.catchall`. The comment now says both halves. - The changeset gains that parsed-output difference, since consumers read `report` after parsing. Ratchet unaffected: no MIRRORS entry, ledger row or type moved in this commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- .changeset/7352-drill-down-config-mirror.md | 7 ++ .../drill-down-config-mirror-7352.test.ts | 20 ++++-- .../src/__tests__/zod-mirror-parity.test.ts | 69 +++++++++++-------- 3 files changed, 63 insertions(+), 33 deletions(-) diff --git a/.changeset/7352-drill-down-config-mirror.md b/.changeset/7352-drill-down-config-mirror.md index 6056dcd59d..b3d40dd2e0 100644 --- a/.changeset/7352-drill-down-config-mirror.md +++ b/.changeset/7352-drill-down-config-mirror.md @@ -24,6 +24,13 @@ Accept-set change on the published validator, stated plainly: `{ enabled: true }` / `{ enabled: true, mode: 'record' }` blocks the dashboard renderer synthesises, `report`'s two structural forms, and an inline report's extra keys (the declaration's index signature is `.catchall(z.unknown())`). +- Output shape, worth knowing before you read a parsed `drillDown.report`: the + member is a union, and its two arms differ in what they KEEP. The inline arm + carries the declaration's index signature as `.catchall(z.unknown())`, so extra + report keys survive; the named-reference arm is a plain object, so a value that + reaches it keeps only `name` (`{ name: 'x', columns: [] }` is accepted, and + parses to `{ name: 'x' }`). Both were accepted and unvalidated before, and + neither is refused now. - Unchanged: `PivotTableSchema.drillDown` has no zod mirror at all, and `DataTableSchema` declares the key on neither face — both are untouched here. - New export on `@object-ui/types/zod`: `DrillDownConfigSchema`. diff --git a/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts b/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts index d1d9acdb5b..0bfcd8debd 100644 --- a/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts +++ b/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts @@ -103,13 +103,23 @@ const ACCEPTED: Array<[string, DrillDownConfig]> = [ }], ['a named report reference', { report: { name: 'pipeline' } }], // ⚠️ MEASURED, not assumed, and it moved here from the refusal table below. The - // declaration's second arm is `{ name: string }` — not an exact type — so a value - // with a string `name` satisfies it whatever else it carries, and TypeScript's - // excess-property check passes because `columns` is a member of the OTHER arm. + // declaration's second arm is `{ name: string }`, and THIS value reaches it: the + // inline arm wants `objectName`, so the value falls through to the reference arm, + // which a string `name` satisfies. TypeScript agrees for the same structural + // reason, and its excess-property check passes on this literal because `columns` + // is a member of the OTHER arm — ⚠️ that last part is what makes the acceptance + // shape-specific rather than universal: it is not "any extra key rides along". // This entry is annotated `DrillDownConfig`, so `tsc -p tsconfig.test.json` is the - // witness: if the declaration ever refused it, this line stops compiling. A mirror - // that refused it would be NARROWER than the declaration — the drift class this + // witness — if the declaration ever refused it, this line stops compiling. A mirror + // that refused it would be NARROWER than the declaration, the drift class this // ledger exists to stop, in the direction that hurts authors. + // + // ⚠️ ACCEPTED is not "unchanged": the reference arm is a plain `z.object`, so it + // STRIPS what it does not declare, and the parsed output here is `{ name }` — the + // `columns` is gone (measured: `{ name, columns: [] }` parses to `{ name }`, while + // the full inline shape below keeps every extra key through `.catchall`). The test + // right below this table pins that surviving half. Consumers read `report` after + // parsing, so the two arms differ in output, not only in acceptance. ['an inline report missing objectName, which the reference arm accepts', { report: { name: 'pipeline', columns: [] } }], ]; diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 358307b947..835d15d6f8 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -28,7 +28,7 @@ * * It reads `.shape` and not `keyof z.input` because that spelling is * vacuous — `.passthrough()` collapses the inferred key union to bare `string`. - * `assertionNoVacuousEntry` below pins that for all 161 entries at once. + * `assertionNoVacuousEntry` below pins that for all 155 entries at once. * * ## What is registered * @@ -53,11 +53,20 @@ * framing, and objectui#6058's own dispatch, both inherited "163"). On a file whose * entire subject is measurement that is worth stating explicitly: * - * - **161 pairs** — `Object.keys(MIRRORS).length`, which `assertionRegistryHalvesAgree` - * already pins equal to `keyof Declared`. It was 160 until objectui#7352 registered + * - **155 pairs** — `Object.keys(MIRRORS).length`, which `assertionRegistryHalvesAgree` + * already pins equal to `keyof Declared`. 154 until objectui#7352 registered * `data-display.zod.ts#DrillDownConfigSchema` — a nested config mirror paired with - * the local `DrillDownConfig`, the `ObjectMapConfigSchema` precedent — - * and it carries no ledger entry. Nothing asserts the number against a written + * the local `DrillDownConfig`, the `ObjectMapConfigSchema` precedent — which + * carries no ledger entry. ⚠️ **This line rotted twice and was re-derived at + * objectui#7352 contract review.** Its history, measured by running the same walk over + * this file at each revision rather than by reading the prose: `4ca30d044` wrote + * "160" when `MIRRORS` held **163**; `d88e20f55` (objectui#7432) took the registry to + * **154** without touching the sentence; objectui#7352 then added its 1 to the stale + * baseline and wrote 161. Two independent derivations agree on 155 today — a + * TypeScript AST walk counting `PropertyAssignment` nodes in the `MIRRORS` + * initializer, and a line-oriented parse of the same block — and they agree on + * the three historical figures above. ⛔ Do not add 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`, **56 keys** across them — 39 / 55 until @@ -94,18 +103,18 @@ * meaning — the comparable figure is 95 + 1 mirrored + 2 retired + 23 * reclassified. The full statement is on that ledger. * - **7 entries** in `RuntimeOnlyDeclared`, **24 keys** across them. Six of the - * seven are a subset of the 15 pairs above; `TreeViewSchema` is NOT — it is + * seven are a subset of the 13 pairs above; `TreeViewSchema` is NOT — it is * the first pair whose ONLY ledger entry is a runtime-only one - * (objectui#6150 declared `onNodeClick` on an otherwise clean pair), so the - * "no entry in either" population dropped by one to 141 — went to 142 when - * objectui#6576 added two pairs, one of them ledgered, went to 143 when - * objectui#7129 retired `DetailViewSectionSchema`'s only ledgered key, and - * went to 144 when objectui#7623 retired `DashboardComponentSchema`'s - * only UNMIRRORED one (that pair keeps its `KnownDrift` entry — this - * population counts entries in the two unmirrored ledgers), and stands at **147** - * since objectui#7352: two pairs left the unmirrored ledgers by repair and the new - * `DrillDownConfigSchema` pair arrived clean. - * - 161 − 40 = **121**, the "pairs with no entry" `LedgerMismatch` speaks of. + * (objectui#6150 declared `onNodeClick` on an otherwise clean pair), which is why + * the union of the two unmirrored ledgers is **14** pairs and not 13. + * - **141 pairs with no entry in either** unmirrored ledger — 155 − 14, measured, + * not stepped. ⚠️ This line used to carry a running chain of deltas (141 → 142 → + * 143 → 144 → 147, one per card). Every one of those was computed against the + * stale pair count above, so they were arithmetic on a wrong base and are NOT + * re-derivable from this file; objectui#7352 contract review replaced the chain with + * the measurement. ⛔ Do not restart the chain — subtract the union from the + * registry count, both read from the file. + * - 155 − 40 = **115**, the "pairs with no entry" `LedgerMismatch` speaks of. * * ## Two ratchets, because the forward comparison has two halves * @@ -126,7 +135,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 40 of the 161 pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 40 of the 155 pairs carry TYPE drift TODAY (measured, not assumed). Each is * pinned to its EXACT drifted key set, so the entry fails when new drift appears on * that mirror AND when the recorded drift is fixed — a stale entry cannot rot * quietly. Correcting them is not one change: the pairs below split into DISJOINT @@ -291,7 +300,7 @@ export type ReconcileAgainstLedger< K, Measured, Recorded > = export type assertionRatchetAcceptsAgreement = Expect< Equal< ReconcileAgainstLedger< 'p', 'a', 'a' >, never > >; -/** …and so does a clean pair with no entry, which is the case for 147 of the 161. */ +/** …and so does a clean pair with no entry, which is the case for 141 of the 155. */ export type assertionRatchetAcceptsCleanPair = Expect< Equal< ReconcileAgainstLedger< 'p', never, never >, never > >; @@ -958,7 +967,7 @@ interface KnownDrift { * is being let through, because there was no such thing. Seeding takes the count of * VISIBLE, RATCHETED facts from 0 to 121 and installs a floor: the problem cannot * grow while they are worked off, and a new declared-but-unmirrored key on any of - * the 161 pairs reddens immediately (`assertionRatchetRejectsFreshDrift`) — + * the 155 pairs reddens immediately (`assertionRatchetRejectsFreshDrift`) — * including a callback-shaped one, which reddens until it is filed in * `RuntimeOnlyDeclared` (`assertionSplitLedgerRejectsFreshCallback`). * @@ -1156,15 +1165,19 @@ interface UnmirroredDeclared { * ## ⚠️ THIS IS WHERE 23 KEYS WENT — a RECLASSIFICATION, not a fix * * `UnmirroredDeclared` above was seeded at **121 keys** by objectui#6058. It records - * **97** today: these 23 moved here whole, and `ObjectGridSchema.title` was later - * MIRRORED by objectui#6639 — the move recorded HERE repaired nothing. ⛔ Nothing was + * **94** today: these 23 moved here whole, and three keys were later MIRRORED + * (`ObjectGridSchema.title` by objectui#6639; `ChartSchema.drillDown` and + * `ObjectDataTableSchema.drillDown` by objectui#7352) while two were RETIRED by + * deleting the declaration (objectui#7129, objectui#7623) — the move recorded HERE + * repaired nothing. ⛔ Nothing was * mirrored by it, no declaration was removed, no defect was repaired and nothing was * waived: the same 23 facts are still measured, still declared-but-unmirrored, still * reconciled against the same measurement — under a different remedy. * - * **97 is the mirroring debt; 97 + 1 mirrored + 23 reclassified is what "121" used - * to mean.** Cite it that way. objectui#6141 is the standing example of what a - * silently moved count costs. + * **94 is the mirroring debt; 94 − 1 seeded + 3 mirrored + 2 retired + 23 + * reclassified is what "121" used to mean.** Cite it that way. objectui#6141 is the + * standing example of what a silently moved count costs — and the pair count in the + * file header is the second, re-derived at objectui#7352 contract review. * * ## Why mirroring is the wrong remedy for these (the objectui#6152 ruling) * @@ -1392,7 +1405,7 @@ export type assertionLedgerHalvesAreDisjoint = Expect< Equal< DoubleFiledKey, ne /** * Every pair's TYPE drift equals what `KnownDrift` records for it — `never` for the - * 121 pairs with no entry (161 − 40). + * 115 pairs with no entry (155 − 40). * * Routed through `ReconcileAgainstLedger` rather than spelling the conditional * inline. That is a semantics-preserving refactor and nothing else — the type is @@ -1440,8 +1453,8 @@ export const assertionDriftMatchesLedger: never = 0 as unknown as LedgerMismatch /** * The SECOND half of the forward comparison: every pair's declared-but-unmirrored - * key set equals what the two ledgers TOGETHER record for it — `never` for the 147 - * pairs with no entry in either (161 − 14). Six of `RuntimeOnlyDeclared`'s seven + * key set equals what the two ledgers TOGETHER record for it — `never` for the 141 + * pairs with no entry in either (155 − 14). Six of `RuntimeOnlyDeclared`'s seven * pairs are a measured subset of `UnmirroredDeclared`'s 13, so objectui#6152's * reclassification left the clean population unchanged; objectui#6150 then added * `TreeViewSchema`, whose only entry is runtime-only, which is why the union is one @@ -1500,7 +1513,7 @@ export const assertionUnmirroredMatchesLedger: never = 0 as unknown as { }[MirrorKey]; /** - * Non-vacuity for all 161 entries at once. + * Non-vacuity for all 155 entries at once. * * `NarrowerThanDeclared` is `never` — green — for an entry whose mirror exposes no * `.shape`, and also for one whose key union has degenerated to bare `string` (the