From e0e1497d27b45cf380aca57136f3777ab42f70b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 17:35:12 +0000 Subject: [PATCH 1/4] fix(types): ObjectKanbanSchema.groupBy is optional, as the protocol declares it (objectui#8990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@objectstack/spec` declares `groupBy: z.string().optional()` on `ObjectKanbanPropsSchema`; both published objectui faces required it. objectui was therefore narrower than the protocol on a published key — it refused an `object-kanban` document the protocol accepts, and that document could not be annotated with its own type. This is a widening. An authored `groupBy` is still declared and still enforced (a non-string lane key is refused exactly as before); only its absence is newly admitted. A CONTROL beside each flipped pin keeps "optional" from decaying into "undeclared". The requiredness was refuted by this repo's own corpus, not only by the spec. objectui#7780 recorded two producers excluded from objectui#7322's measurement and both are still live: the `{ type, dataSource }` board documented in `content/docs/utilities/data-objectstack.mdx`, and the node `ListView.tsx` generates as `groupBy: laneField` where `laneField` ends in `|| undefined`. Measured, not assumed — every `schema.groupBy` read is a guarded early-return, so a lane-less board degrades rather than breaking: no `columns` renders an empty board; bare-string `columns` draws those lanes titled by the raw strings; card moves are inert. Every lane-less board holds ZERO cards, because `bucketCardsIntoColumns` returns before distributing records when there is no lane key. Side effect: the protocol's bare-string `columns` arm, admitted by objectui#8913 and recorded there as unreachable, is now reachable — it fires only under `if (!schema.groupBy)`. Pinned with a firing control that distinguishes the raw option VALUES the string arm draws from the picklist LABELS a grouped board draws. objectui#8993 is NOT reached from the lane-less path and is not touched here: the double-bucketing lives after the bucketer's `!groupBy` early return, and the picklist-materialised lanes are built under `if (schema.groupBy && ...)`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../8990-object-kanban-groupby-optional.md | 43 +++ .../src/__tests__/laneLessBoard-8990.test.tsx | 328 ++++++++++++++++++ .../object-kanban-group-by-limit-7322.test.ts | 63 +++- .../object-kanban-record-source-7780.test.ts | 96 +++-- packages/types/src/objectql.ts | 107 +++++- packages/types/src/zod/objectql.zod.ts | 25 +- 6 files changed, 603 insertions(+), 59 deletions(-) create mode 100644 .changeset/8990-object-kanban-groupby-optional.md create mode 100644 packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx diff --git a/.changeset/8990-object-kanban-groupby-optional.md b/.changeset/8990-object-kanban-groupby-optional.md new file mode 100644 index 0000000000..ccf6704232 --- /dev/null +++ b/.changeset/8990-object-kanban-groupby-optional.md @@ -0,0 +1,43 @@ +--- +'@object-ui/types': minor +--- + +`ObjectKanbanSchema.groupBy` is now OPTIONAL on both published faces (objectui#8990). + +`@objectstack/spec` declares the key optional — `groupBy: z.string().optional()` on +`ObjectKanbanPropsSchema` — while this package required it on the TypeScript +declaration (`packages/types/src/objectql.ts`) and on the Zod mirror +(`packages/types/src/zod/objectql.zod.ts`). objectui was therefore **narrower than the +protocol** on a published key: `ObjectKanbanSchema.safeParse` and `safeValidateSchema` +refused an `object-kanban` node the protocol accepts, and such a node could not be +annotated with its own type. + +**This is a widening: nothing that validated before stops validating.** An authored +`groupBy` is still typed and still enforced — a non-string lane key is refused exactly +as it was. Only its absence is newly admitted. + +The requiredness was refuted by this repository's own corpus, not only by the protocol. +objectui#7322 justified it as "every documented and tested `object-kanban` node authors +this key", and objectui#7780 recorded two producers excluded from that count; both are +still live: + +- `content/docs/utilities/data-objectstack.mdx` documents an `object-kanban` node that + is exactly `{ type, dataSource }`, with no `groupBy`; +- `packages/plugin-list/src/ListView.tsx` **generates** the node as + `groupBy: laneField`, where `laneField` ends in an explicit `|| undefined`, so a view + that declares no lane field emits `groupBy: undefined` at runtime. + +**What a lane-less board does, measured rather than assumed.** Every `schema.groupBy` +read in `ObjectKanban.tsx` is a guarded early-return, so the board degrades instead of +breaking: with no lane key and no `columns` it renders an empty board; with bare-string +`columns` it draws those lanes, titled by the raw strings; card moves are inert +(`persistCardMove` and the move callback both open `if (!groupBy) return`). ⚠️ Every +lane-less board holds **zero cards** — `bucketCardsIntoColumns` returns before +distributing records when there is no lane key — so omitting `groupBy` is not a way to +configure a board, it is a board that groups by nothing. + +**Side effect worth knowing.** The protocol's bare-string `columns` arm, admitted by +objectui#8913 and recorded there as unreachable, is now reachable: it fires only under +`if (!schema.groupBy)`, which no schema-valid document could satisfy while the key was +required. Pinned with a firing control in +`packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx`. diff --git a/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx b/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx new file mode 100644 index 0000000000..b314a27545 --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx @@ -0,0 +1,328 @@ +/** + * 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. + */ + +/** + * objectui#8990 — what a LANE-LESS `object-kanban` board actually does, now + * that `ObjectKanbanSchema.groupBy` is OPTIONAL on both published faces. + * + * ## Why this file exists + * + * The card is a WIDENING: `@objectstack/spec` declares + * `groupBy: z.string().optional()` and this repository required it, so both + * published faces refused a document the protocol accepts. The risk of a + * widening is not a compile break — it is a RUNTIME PATH THAT NEVER RAN. This + * file measures that path instead of asserting the declaration, which + * `packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts` + * already does. + * + * ## ⭐ The arm this card unlocks, and its firing control + * + * `@objectstack/spec` declares `object-kanban.columns` as two array shapes — + * `{ id, title }` lanes, or bare value strings. The renderer honours the + * bare-string arm ONLY under `if (!schema.groupBy)` (`ObjectKanban.tsx`, the + * `effectiveColumns` memo). While `groupBy` was REQUIRED, no schema-valid + * document could reach that branch: objectui#8913 admitted the arm anyway + * (refusing it would have been a second narrowing) and recorded it as + * unreachable. Making `groupBy` optional is what makes it live. + * + * ⚠️ "The arm fires" is not observable from the lane COUNT alone — a grouped + * board draws two lanes too. It is observable from the lane TITLES, and that is + * this file's discriminator: + * + * - the string branch returns `{ id: val, title: val }` and never calls + * `localizeColumn`, so the lanes are titled by the RAW option VALUES; + * - the picklist branch a grouped board takes returns the option LABELS. + * + * The fixture object's `status` options are deliberately `todo -> 'To Do'` and + * `doing -> 'Doing'`, so the two paths are distinguishable on screen. The + * grouped board is the FIRING CONTROL: it proves the assertion can fail, and it + * proves the `!schema.groupBy` gate is still a gate rather than dead code. + * + * ## ⚠️ Reachable is NOT populated — the honest half of the result + * + * Every lane-less board holds ZERO cards, whatever its `columns`, because + * `bucketCardsIntoColumns` opens with + * `if (!data || !groupBy || !Array.isArray(data)) return columns.map(...)` — + * with no lane key the records are never distributed. So the widening admits + * documents that RENDER (lane headings, no crash), not documents that work + * better. ⛔ It is not an invitation to omit the key. + * + * ## ⛔ objectui#8993 is NOT reached from here, and that is asserted + * + * objectui#8993 (`bucketCardsIntoColumns` double-buckets a non-string lane id) + * lives AFTER that early return — the `knownIds` set and the `__uncolumned__` + * lane below it. A lane-less board returns before reaching any of it, and the + * picklist-materialised lanes whose ids come straight from `opt.value` are + * built under `if (schema.groupBy && ...)`, a branch a lane-less board cannot + * enter. ⇒ This card can only NARROW objectui#8993's reachable set on the + * documents it newly admits, never widen it. ⛔ Nothing here fixes #8993; it is + * a renderer behaviour change with its own card. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { SchemaRendererProvider, SchemaRenderer } from '@object-ui/react'; +import { ObjectKanbanSchema } from '@object-ui/types/zod'; +import { safeValidateSchema } from '@object-ui/types/zod'; +import { bucketCardsIntoColumns, KANBAN_UNCOLUMNED_ID } from '../index'; +// Registers `object-kanban`. +import '../index'; +// The board renders inside `KanbanRenderer`'s `React.lazy` boundary; importing +// the chunk at module scope bills the cold transform to the import phase +// instead of racing a `waitFor` budget (objectui#3010). +import '../KanbanImpl'; + +/** + * ⭐ The option VALUES and LABELS differ on purpose — that difference is what + * tells the bare-string arm apart from the picklist arm on screen. + */ +const OBJECT_SCHEMA = { + name: 'task', + fields: { + id: { type: 'text' }, + name: { type: 'text', label: 'Name' }, + status: { + type: 'select', + label: 'Status', + options: [ + { value: 'todo', label: 'To Do' }, + { value: 'doing', label: 'Doing' }, + ], + }, + }, +}; + +const ROWS = [ + { id: 'r1', name: 'Card one', status: 'todo' }, + { id: 'r2', name: 'Card two', status: 'doing' }, +]; + +const LANE_VALUES = ['todo', 'doing']; +const LANE_LABELS = ['To Do', 'Doing']; + +function makeDataSource() { + return { + find: vi.fn().mockResolvedValue({ data: ROWS, total: ROWS.length }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue(OBJECT_SCHEMA), + } as any; +} + +/** + * The board-level live region (objectui#8827): `role="status" aria-live="polite"`, + * titled "No cards". It is painted ONLY once the records have SETTLED, never + * while they are in flight — which is what makes it a legitimate settle signal + * for a board that is expected to show nothing. + */ +const emptyState = () => document.querySelector('[role="status"][aria-live="polite"]'); + +/** + * ⚠️ RIG DISCIPLINE. A "no cards on screen" assertion is only a reading if the + * rows had a chance to arrive first; asserted too early it passes for the wrong + * reason, against a board that is merely still loading. The first cut of this + * file settled on the dnd live region, which the board renders IMMEDIATELY, and + * the grouped CONTROL leg caught it by failing — the rows had not landed yet. + * + * So every leg here settles on a signal that PROVES the query resolved: + * - `expectCards` waits for a row to appear; + * - `expectSettledEmpty` waits for the objectui#8827 empty state, which the + * board withholds until its records settle, and additionally asserts `find` + * was actually called. + */ +async function renderBoard(schema: Record) { + const dataSource = makeDataSource(); + const result = render( + + + , + ); + return { ...result, find: dataSource.find as ReturnType }; +} + +/** Settle on a board that is expected to end up showing NOTHING. */ +async function expectSettledEmpty(find: ReturnType) { + await waitFor(() => expect(emptyState()).not.toBeNull()); + expect(find, 'RIG SELF-CHECK: the board must actually have queried').toHaveBeenCalled(); +} + +/** Settle on a board that is expected to end up showing cards. */ +async function expectCards(container: HTMLElement, name: string) { + await waitFor(() => expect(container.textContent).toContain(name)); +} + +/** Lane headings as drawn, in DOM order. */ +function laneTitles(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('[data-slot="kanban-column-title"], h3, h4')) + .map((el) => (el.textContent ?? '').trim()) + .filter(Boolean); +} + +afterEach(cleanup); + +describe('objectui#8990 — the contract admits a lane-less board on both entry paths', () => { + const laneless = { type: 'object-kanban', objectName: 'task', columns: LANE_VALUES }; + + it('a bare-string `columns` board with no `groupBy` parses green', () => { + const r = ObjectKanbanSchema.safeParse(laneless); + expect(r.success, 'this is the document the protocol accepts and this repo refused').toBe(true); + expect(safeValidateSchema(laneless).success, 'the union entry path must agree').toBe(true); + }); + + it('CONTROL — the same board WITH `groupBy` still parses green', () => { + // Guards the direction: this card widened the accept set, it did not swap + // one refusal for another. + expect(ObjectKanbanSchema.safeParse({ ...laneless, groupBy: 'status' }).success).toBe(true); + }); +}); + +describe('objectui#8990 — the bare-string `columns` arm FIRES on a lane-less board', () => { + it('draws its lanes titled by the RAW strings', async () => { + const { container, find } = await renderBoard({ + type: 'object-kanban', + objectName: 'task', + columns: LANE_VALUES, + }); + await expectSettledEmpty(find); + + const titles = laneTitles(container); + for (const value of LANE_VALUES) { + expect(titles, `the string arm returns { id: val, title: val }; lane "${value}" must be drawn`) + .toContain(value); + } + // ⭐ The discriminator: raw VALUES, never the picklist LABELS. Were the + // string branch skipped and the picklist branch taken, these would appear. + for (const label of LANE_LABELS) { + expect(titles, `"${label}" is the picklist LABEL — a lane-less board never localizes`) + .not.toContain(label); + } + }); + + it('FIRING CONTROL — the very same `columns` with `groupBy` set draws the picklist LABELS instead', async () => { + // This is the leg that proves the assertion above can fail, and that + // `if (!schema.groupBy)` is a live gate: with a lane key the string arm is + // skipped entirely and `effectiveColumns` materialises lanes from + // `field.options`, titled by their labels. + const { container } = await renderBoard({ + type: 'object-kanban', + objectName: 'task', + groupBy: 'status', + columns: LANE_VALUES, + }); + await expectCards(container, 'Card one'); + + const titles = laneTitles(container); + for (const label of LANE_LABELS) { + expect(titles, `a GROUPED board takes the picklist branch; "${label}" must be drawn`) + .toContain(label); + } + expect(titles, 'and the raw values must NOT be the headings on this leg') + .not.toContain('todo'); + }); + + it('⚠️ reachable is not populated — a lane-less board holds ZERO cards, the grouped control holds them', async () => { + const laneLess = await renderBoard({ + type: 'object-kanban', + objectName: 'task', + columns: LANE_VALUES, + }); + // Settled, not merely early: the empty state is withheld until the records + // land, so reaching it means the rows had their chance and none was placed. + await expectSettledEmpty(laneLess.find); + for (const row of ROWS) { + expect( + laneLess.container.textContent, + 'with no lane key the bucketer returns before distributing records', + ).not.toContain(row.name); + } + cleanup(); + + // CONTROL — the same rows DO reach the board once a lane key exists, so the + // absence above is the missing key and not a broken fixture. ⭐ This leg is + // what caught the first cut of this file, which asserted before the query + // resolved. + const grouped = await renderBoard({ + type: 'object-kanban', + objectName: 'task', + groupBy: 'status', + columns: LANE_VALUES, + }); + await expectCards(grouped.container, 'Card one'); + for (const row of ROWS) { + expect(grouped.container.textContent, 'CONTROL: the fixture rows do render when grouped') + .toContain(row.name); + } + }); + + it('a lane-less board with NO `columns` renders an EMPTY board rather than crashing', async () => { + // ⚠️ This leg cannot settle on the objectui#8827 empty state: `KanbanImpl` + // gates it on `boardColumns.length > 1`, so a ZERO-lane board never paints + // it. (Pre-existing and independent of this card — the predicate does not + // read `groupBy`.) It settles on the board region instead, and takes its + // credibility from the paired control below, which shares the whole rig and + // differs only by the lane key. + const laneLess = await renderBoard({ type: 'object-kanban', objectName: 'task' }); + await waitFor(() => expect(laneLess.find).toHaveBeenCalled()); + await waitFor(() => + expect(laneLess.container.querySelector('[role="region"][aria-label="Kanban board"]')).not.toBeNull(), + ); + // `effectiveColumns` falls past all three of its `schema.groupBy &&` guards + // and returns `[]`. + expect(laneTitles(laneLess.container), 'no lane key and no declared lanes: no lanes').toEqual([]); + for (const row of ROWS) { + expect(laneLess.container.textContent).not.toContain(row.name); + } + cleanup(); + + // CONTROL — same schema plus the lane key. `effectiveColumns` now takes the + // picklist branch, materialises both lanes from `field.options`, and the + // rows land. This is what proves the rig above was connected: the lanes and + // the cards are absent for want of a lane key, not for want of a working + // fixture. + const grouped = await renderBoard({ type: 'object-kanban', objectName: 'task', groupBy: 'status' }); + await expectCards(grouped.container, 'Card one'); + expect(laneTitles(grouped.container)).toEqual(expect.arrayContaining(LANE_LABELS)); + }); +}); + +describe('objectui#8990 — ⛔ the lane-less path does NOT reach objectui#8993', () => { + const lanes = [ + { id: 'todo', title: 'To Do' }, + { id: 'doing', title: 'Doing' }, + ]; + + it('with no lane key the bucketer early-returns: every lane empty, and NO `__uncolumned__` lane', () => { + const out = bucketCardsIntoColumns(lanes, ROWS, undefined, undefined, 'Uncategorized'); + expect(out.map((c: any) => c.id)).toEqual(['todo', 'doing']); + expect(out.every((c: any) => c.cards.length === 0)).toBe(true); + // The `knownIds` / `__uncolumned__` block — and objectui#8993's + // double-bucketing with it — sits BELOW the `!groupBy` return. + expect(out.map((c: any) => c.id)).not.toContain(KANBAN_UNCOLUMNED_ID); + }); + + it('CONTROL — the same call WITH a lane key does distribute, and can reach the uncolumned lane', () => { + const bucketed = bucketCardsIntoColumns(lanes, ROWS, 'status', undefined, 'Uncategorized'); + expect(bucketed.find((c: any) => c.id === 'todo').cards).toHaveLength(1); + expect(bucketed.find((c: any) => c.id === 'doing').cards).toHaveLength(1); + + // An off-lane record reaches the trailing lane — the code path the + // lane-less leg above never enters. + const withOrphan = bucketCardsIntoColumns( + lanes, + [...ROWS, { id: 'r3', name: 'Orphan', status: 'done' }], + 'status', + undefined, + 'Uncategorized', + ); + expect(withOrphan.map((c: any) => c.id)).toContain(KANBAN_UNCOLUMNED_ID); + }); +}); diff --git a/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts b/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts index 024dcf5499..f5f8308325 100644 --- a/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts +++ b/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts @@ -8,6 +8,16 @@ * objectui#7322 — `ObjectKanbanSchema.groupBy` / `.limit` declared and * `.groupField` RETIRED, on both faces. * + * ⚠️ SUPERSEDED IN ONE RESPECT (objectui#8990): this card also made `groupBy` + * REQUIRED, and that half is reversed — `@objectstack/spec` declares + * `groupBy: z.string().optional()`, so requiring it made this package refuse a + * document the protocol accepts. The two pins that asserted requiredness now + * assert optionality and say so at their site. Everything else this file pins + * — that `groupBy`/`limit` are DECLARED and ENFORCED, that `groupField` is a + * tombstone with zero read sites, and the read-site census — is unchanged, and + * the CONTROL added beside the flipped pin is what keeps "optional" from + * quietly decaying into "undeclared". + * * ## The defect * * `packages/plugin-kanban/src/ObjectKanban.tsx` — the component the @@ -130,12 +140,17 @@ type IsAny = 0 extends (1 & T) ? true : false; /** An object with no keys is assignable to `Pick` only when `K` is optional on `T`. */ type IsOptional = Record extends Pick ? true : false; -// `groupBy`: declared `string`, REQUIRED, not `any`. Were the member removed -// the indexed access would fall back to the index signature and resolve to -// `any`, and `Equal` is false. -export type _GroupByIsString = Expect>; +// `groupBy`: declared `string`, OPTIONAL since objectui#8990, not `any`. Were +// the member removed the indexed access would fall back to the index signature +// and resolve to `any`, and `Equal` is false — so the +// DECLARED-ness this card pinned is still pinned; only its requiredness moved. +export type _GroupByIsString = Expect>; export type _GroupByIsNotAny = Expect, false>>; -export type _GroupByIsRequired = Expect, false>>; +// objectui#8990 — `@objectstack/spec` declares `groupBy: z.string().optional()`; +// requiring it here made this package narrower than the protocol. The lane-key +// facts this card measured are untouched: the renderer still reads `groupBy` +// and never `groupField`, which is what the read-site census below pins. +export type _GroupByIsOptional = Expect>; // `limit`: declared `number`, optional, not `any`. export type _LimitIsNumberOrUndefined = Expect>; export type _LimitIsNotAny = Expect, false>>; @@ -158,9 +173,11 @@ const literal: TsObjectKanbanSchema = { ...NODE, limit: 250 }; // the tombstone is deleted or widened back to `string`. // @ts-expect-error — `groupField` is RETIRED on this node (objectui#7322); author `groupBy` const retiredLiteral: TsObjectKanbanSchema = { ...NODE, groupField: 'stage' }; -// …and REFUSES a lane-less node (TS2741): `groupBy` is required, as the retired -// `groupField` was. Making it optional turns this directive unused. -// @ts-expect-error — `groupBy` is required: a board is a grouping of records by one field +// …and ACCEPTS a lane-less node since objectui#8990. This was a +// `@ts-expect-error` (TS2741) while `groupBy` was required; the protocol +// declares the key optional, and the renderer guards every read of it, so the +// node below is one this package must annotate rather than refuse. Restoring +// the requirement turns this line red. const lanelessLiteral: TsObjectKanbanSchema = { type: 'object-kanban', objectName: 'opportunity' }; /* ── Off-disk derivations ─────────────────────────────────────────────────── */ @@ -273,16 +290,32 @@ describe('objectui#7322 — the zod mirror declares `groupBy` and `limit`', () = expect(safeValidateSchema(NODE).success).toBe(true); }); - it('`groupBy` is REQUIRED: a lane-less node is refused AT `groupBy` on both entry paths', () => { - // The retired contract required a lane field too; this is the - // required-ness carried across, not a new constraint. + // objectui#8990 — this `it` asserted the opposite until that card: a lane-less + // node was refused AT `groupBy` on both entry paths. `@objectstack/spec` + // declares `groupBy` OPTIONAL, so the refusal made this package narrower than + // the protocol on a published key. The requiredness is what moved; every + // other fact objectui#7322 established is asserted unchanged above and below. + it('`groupBy` is OPTIONAL (objectui#8990): a lane-less node parses green on both entry paths', () => { const laneless = { type: 'object-kanban', objectName: 'opportunity' }; const r = ObjectKanbanSchema.safeParse(laneless); - expect(r.success).toBe(false); - if (!r.success) expect(issuePaths(r.error.issues as readonly Issue[])).toContain('groupBy'); + expect(r.success, 'the protocol accepts a lane-less board; so must this face').toBe(true); + if (r.success) expect((r.data as Record).groupBy).toBeUndefined(); const u = safeValidateSchema(laneless); - expect(u.success).toBe(false); - if (!u.success) expect(issuePaths(u.error.issues as readonly Issue[])).toContain('groupBy'); + expect(u.success, 'the union entry path must agree with the direct one').toBe(true); + }); + + // CONTROL for the pin above — the key is still DECLARED and still ENFORCED + // when present. A widening that lost the declaration (letting `groupBy` fall + // back to `BaseSchema`'s index signature) would keep the lane-less pin green + // while silently un-judging every authored value; this is what separates the + // two. The wrong-typed case is covered by the table below. + it('CONTROL — optional does not mean unjudged: an authored `groupBy` is still typed', () => { + const good = ObjectKanbanSchema.safeParse({ type: 'object-kanban', objectName: 'opportunity', groupBy: 'stage' }); + expect(good.success).toBe(true); + if (good.success) expect((good.data as Record).groupBy).toBe('stage'); + const bad = ObjectKanbanSchema.safeParse({ type: 'object-kanban', objectName: 'opportunity', groupBy: 42 }); + expect(bad.success, 'a non-string lane key is still refused').toBe(false); + if (!bad.success) expect(issuePaths(bad.error.issues as readonly Issue[])).toContain('groupBy'); }); it.each([ diff --git a/packages/types/src/__tests__/object-kanban-record-source-7780.test.ts b/packages/types/src/__tests__/object-kanban-record-source-7780.test.ts index 38b70c2c92..3108b12c1b 100644 --- a/packages/types/src/__tests__/object-kanban-record-source-7780.test.ts +++ b/packages/types/src/__tests__/object-kanban-record-source-7780.test.ts @@ -56,15 +56,27 @@ * thing — the requiredness of `objectName` — and this file fails if a later * change quietly adds a rung under the same heading. * - * ## ⛔ `groupBy` is untouched, and that is pinned too + * ## `groupBy` was untouched HERE — and was moved later, by objectui#8990 * * objectui#7322 / PR #7774 made `groupBy` the REQUIRED lane key. Its * requiredness measurement deliberately EXCLUDED two readings from counting as * a lane-less mode: the `dataSource` json fragment in * `content/docs/utilities/data-objectstack.mdx`, and `ListView.tsx`'s * runtime-generated node. A presence rule over `bind` / `data` / `objectName` - * must not incidentally overturn that, so the `groupBy` half of the vector is - * asserted here alongside the record-source half. + * must not incidentally overturn that, so the `groupBy` half of the vector was + * asserted here alongside the record-source half — as a CONTROL on this card's + * blast radius, not as an endorsement of the requiredness. + * + * ⚠️ objectui#8990 then overturned it deliberately: `@objectstack/spec` declares + * `groupBy: z.string().optional()`, so requiring it made this package refuse a + * document the protocol accepts. Those two excluded readings turned out to be + * the corpus evidence — a documented board and a node this repo's own + * `ListView` emits, both lane-less, both refused. The `groupBy` half of the + * vector below is therefore INVERTED, and it now carries the sharper assertion: + * a lane-less board is accepted, while a SOURCE-less one is still refused AT + * THE REFINEMENT. Keeping the refusal PATH in the assertion is what stops the + * record-source rule this card exists for from decaying into a bare + * `success === false` that any reason could satisfy. */ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; @@ -137,13 +149,19 @@ export const OBJECT_BOARD: TsObjectKanbanSchema = { }; /** - * `groupBy` stayed REQUIRED (objectui#7322). This is the ONE deliberate error - * in this file, and it is the control that keeps the widening from having - * reached the lane key: delete `groupBy`'s requiredness and `TS2578` (unused - * `@ts-expect-error`) reddens `tsconfig.test.json`. + * `groupBy` stayed REQUIRED at objectui#7780 (objectui#7322), and this was the + * ONE deliberate `@ts-expect-error` in this file — the control proving THIS + * card's widening had not reached the lane key. + * + * objectui#8990 moved the lane key deliberately, on its own card and for its + * own reason (`@objectstack/spec` declares `groupBy` optional; requiring it + * made this package narrower than the protocol). So the directive is gone and + * the annotation stands on its own — the node below is now accepted. What this + * file still controls is the RECORD-SOURCE half: see the refinement pins below, + * where a board with no `bind`/`data`/`objectName` is still refused, now at the + * refinement rather than at `groupBy`. */ -// @ts-expect-error — groupBy is still required on ObjectKanbanSchema (the one deliberate error here) -export const LANELESS_BOARD_STILL_REFUSED: TsObjectKanbanSchema = { +export const LANELESS_BOARD_NOW_ACCEPTED: TsObjectKanbanSchema = { type: 'object-kanban', objectName: 'task', }; @@ -204,37 +222,65 @@ describe('objectui#7780 — the four documents, through the member and the publi }); }); -describe('objectui#7780 — `groupBy` requiredness is NOT overturned (objectui#7322 / PR #7774)', () => { - it.each(DOCUMENT_NAMES)('%s without `groupBy` is still refused AT `groupBy`', (name) => { +// objectui#8990 — this block asserted the opposite: that `groupBy` requiredness +// survived objectui#7780. It did not survive objectui#8990, which moved it for +// its own reason. The block is kept, inverted, because its DOCUMENTS are the +// evidence that mattered: PR #7774 excluded two readings from its requiredness +// measurement, and both are real lane-less producers this package was refusing. +// +// ⭐ The two halves must not blur. A lane key and a record source are different +// requirements: dropping `groupBy` is now fine, dropping all of +// `bind`/`data`/`objectName` is still refused — AT THE REFINEMENT, not at +// `groupBy`. Asserting the refusal PATH is what keeps this honest; a bare +// `success === false` would have gone on passing for the wrong reason. +describe('objectui#8990 — `groupBy` requiredness IS overturned; the record-source rule is not', () => { + it.each(DOCUMENT_NAMES)('%s without `groupBy` now parses green', (name) => { const { groupBy: _dropped, ...rest } = DOCUMENTS[name]; void _dropped; const laneless = { type: 'object-kanban', ...rest }; const r = ObjectKanbanSchema.safeParse(laneless); - expect(r.success).toBe(false); - if (r.success) return; - expect(r.error.issues.map((i) => i.path[0])).toContain('groupBy'); - expect(safeValidateSchema(laneless).success).toBe(false); + // `none` carries no record source, so it is still refused — at the + // refinement. Every other document has one and must now be accepted. + if (name === 'none') { + expect(r.success).toBe(false); + if (!r.success) { + expect(r.error.issues.map((i) => i.path[0])).not.toContain('groupBy'); + expect(r.error.issues.map((i) => (i as unknown as { params?: { code?: string } }).params?.code)) + .toContain('RECORD_SOURCE_REQUIRED'); + } + return; + } + expect(r.success, `${name} has a record source and no lane key: the protocol accepts it`).toBe(true); + expect(safeValidateSchema(laneless).success).toBe(true); }); - it("PR #7774's two EXCLUDED readings stay refused — a record source is not a lane key", () => { + it("PR #7774's two EXCLUDED readings — the corpus evidence that the requiredness was wrong", () => { // The `dataSource` json fragment taught in // `content/docs/utilities/data-objectstack.mdx`. `dataSource` is not a rung // of this ladder (`ElementDataSourceGate` maps its `object` ONTO // `objectName` upstream of the node), so this document has no record source - // AND no lane key, and is refused for both. + // — and it is STILL refused for that, which is the record-source rule doing + // its job. What changed is that `groupBy` is no longer among the reasons. const fragment = { type: 'object-kanban', dataSource: { object: 'task', filter: { project: 'acme' } } }; const f = ObjectKanbanSchema.safeParse(fragment); expect(f.success).toBe(false); if (!f.success) { - expect(f.error.issues.map((i) => i.path[0])).toContain('groupBy'); + expect( + f.error.issues.map((i) => i.path[0]), + 'the lane key is no longer a reason to refuse this documented fragment', + ).not.toContain('groupBy'); + expect(f.error.issues.map((i) => (i as unknown as { params?: { code?: string } }).params?.code)) + .toContain('RECORD_SOURCE_REQUIRED'); } // `ListView.tsx`'s runtime-generated node, in the shape it emits when the // view declared no lane field: `groupBy: laneField` with `laneField` - // undefined. Still refused, at `groupBy`. + // undefined (`laneField = groupByField || groupField || + // detectStatusField(objectDef) || undefined`). It carries `objectName`, so + // it now parses green — this package no longer refuses a node its own + // sibling emits and its own renderer serves. const generated = { type: 'object-kanban', objectName: 'task', groupBy: undefined, cardFields: [] }; const g = ObjectKanbanSchema.safeParse(generated); - expect(g.success).toBe(false); - if (!g.success) expect(g.error.issues.map((i) => i.path[0])).toContain('groupBy'); + expect(g.success, "ListView's generated lane-less node must be annotatable").toBe(true); }); it('the retired `groupField` is still refused BY NAME — the tombstone is untouched', () => { @@ -248,7 +294,7 @@ describe('objectui#7780 — the member is still an object, and NO rung was added const shape = () => (ObjectKanbanSchema as unknown as { shape: Record { success: boolean } }> }).shape; - it('`.shape` is exposed, with `objectName` optional and `groupBy` not', () => { + it('`.shape` is exposed, with `objectName` and `groupBy` both optional (objectui#8990)', () => { // zod 4 attaches a refinement in place; had it wrapped the object, `.shape` // would be gone — `object-kanban-group-by-limit-7322.test.ts` reads it, and // the parity census in `zod-mirror-parity.test.ts` would read the pair as @@ -256,7 +302,11 @@ describe('objectui#7780 — the member is still an object, and NO rung was added expect(Object.keys(shape())).toEqual(expect.arrayContaining(['objectName', 'groupBy', 'groupField', 'limit'])); expect(shape().objectName.safeParse(undefined).success).toBe(true); expect(shape().objectName.safeParse(5).success).toBe(false); - expect(shape().groupBy.safeParse(undefined).success).toBe(false); + // objectui#8990 — was `false`. Optional on the shape, and still TYPED when + // present, which is the control that separates optional from undeclared. + expect(shape().groupBy.safeParse(undefined).success).toBe(true); + expect(shape().groupBy.safeParse('status').success).toBe(true); + expect(shape().groupBy.safeParse(5).success).toBe(false); }); it('⛔ NO `staticData` rung, and `data` / `bind` are INHERITED, not re-declared — objectui#7651 refused both', () => { diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 49a3b557a5..a681252edf 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2923,19 +2923,80 @@ export interface ObjectKanbanSchema extends BaseSchema { * * The lane key the `object-kanban` renderer actually reads — * `packages/plugin-kanban/src/ObjectKanban.tsx` reads `schema.groupBy` at - * thirteen sites: lane materialisation (`:601`, `:625`, `:640`), card moves - * (`:747`, `:865`) and their effect deps. Undeclared here until + * thirteen sites: lane materialisation (`:970`, `:982`, `:994`, `:1009`), + * card moves (`:1125`, `:1243`) and their effect deps. Undeclared here until * objectui#7322, so an authored value reached the renderer only through * {@link BaseSchema}'s `[key: string]: any` — admitted, never examined. * - * REQUIRED, as the retired `groupField` was: a board is a grouping of records - * by one field. The renderer's `if (!schema.groupBy)` branches (`:601`, - * `:613`) are defensive early-returns, not a lane-less mode — every - * documented and tested `object-kanban` node authors this key, and the - * `dataSource` binding (`ElementDataSourceGate`) supplies `objectName`, - * never a lane key. - */ - groupBy: string; + * OPTIONAL since objectui#8990, matching `@objectstack/spec`, which declares + * `groupBy: z.string().optional()` on `ObjectKanbanPropsSchema` + * (`packages/spec/src/ui/component.zod.ts`, read at objectstack + * `eabdd66f45f402eba0f8404a8a9de4a501fc83a6`). It was REQUIRED here on both + * faces, so this package REFUSED A DOCUMENT THE PROTOCOL ACCEPTS — the one + * direction the maintainer principle in force forbids (2026-09-09, recorded + * verbatim and untranslated): + * 「我们的项目以 objectstack 协议为准,文档应该以实际实现为准。协议不正确的应该先修改协议。」 + * + * ## The requiredness was refuted by the corpus, not just by the protocol + * + * objectui#7322 justified it as "every documented and tested `object-kanban` + * node authors this key". Two lane-less producers were excluded from that + * count at objectui#7780 and neither has gone away: + * + * 1. `content/docs/utilities/data-objectstack.mdx` documents an + * `object-kanban` node that is exactly `{ type, dataSource }` — no + * `groupBy`. The published validator refused this repository's own + * documented example. + * 2. `packages/plugin-list/src/ListView.tsx` GENERATES the node with + * `groupBy: laneField`, where + * `laneField = groupByField || groupField || detectStatusField(objectDef) || undefined` + * — an explicit `|| undefined`. A view that declares no lane field and + * whose object has no detectable status field emits `groupBy: undefined` + * at runtime, which the renderer serves and both faces refused. + * + * Same shape as {@link objectName} (objectui#7780): a key the renderer guards + * at every read, declared REQUIRED, refusing boards that render today. + * + * ## What a lane-less board actually does — MEASURED, not argued + * + * The `if (!schema.groupBy)` branches are defensive early-returns and the + * board degrades rather than breaking. Rendered through `SchemaRenderer` with + * a real `dataSource` (pinned in + * `plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx`): + * + * - no `groupBy`, no `columns` -> `effectiveColumns` falls past all three + * of its `schema.groupBy &&` guards and returns `[]`: an empty board, no + * lanes, no crash. + * - no `groupBy`, bare-string `columns` -> the lanes are DRAWN, titled by + * the raw strings (see {@link columns}); this is the arm this card + * unlocks. + * - EVERY lane-less board holds ZERO cards, whatever its `columns`. + * `bucketCardsIntoColumns` opens with `if (!data || !groupBy || + * !Array.isArray(data)) return columns.map(...)`, so with no lane key the + * records are never distributed. A lane-less board is lane HEADINGS, not + * a populated board — which is the honest rendering of "no field places + * these records", and is why relaxing this key is not a licence to author + * it away. + * - card moves are inert, by the same guards: `persistCardMove` and the + * `handleCardMove` callback both open `if (!groupBy) return`, so nothing + * is written back. + * + * ⇒ The widening admits documents that RENDER; it does not admit documents + * that crash. ⛔ It is NOT an invitation to omit the key: a board that groups + * by nothing shows no cards. + * + * ## ⛔ What this does NOT reach — objectui#8993 + * + * `bucketCardsIntoColumns`'s double-bucketing of a non-string lane id lives + * AFTER that `!groupBy` early return (the `knownIds` set and the + * `__uncolumned__` lane below it). A lane-less board returns before reaching + * it, and the picklist-materialised lanes whose ids come straight from + * `opt.value` are built under `if (schema.groupBy && ...)` — a branch a + * lane-less board cannot enter. So this card can only NARROW objectui#8993's + * reachable set on the documents it newly admits, never widen it. Measured + * both ways in the pin file above. + */ + groupBy?: string; /** * RETIRED (objectui#7322) — the lane key this node's renderer never read. * `ObjectKanban.tsx` reads {@link groupBy} thirteen times and `groupField` @@ -3039,15 +3100,29 @@ export interface ObjectKanbanSchema extends BaseSchema { * from those read sites too — see {@link id} for the one this card had to * correct. * - * ## ⚠️ The bare-string arm is admitted for PROTOCOL PARITY and is inert here + * ## ⭐ The bare-string arm is REACHABLE since objectui#8990 * * The `object-kanban` renderer honours a bare-string lane list only when no * `groupBy` is authored (`ObjectKanban.tsx`, the `effectiveColumns` memo: - * the string branch returns only under `if (!schema.groupBy)`). `groupBy` is - * REQUIRED on this face, so no document that passes this schema can reach - * that branch — the arm is admitted because refusing it would make objectui - * narrower than the protocol, not because it does anything. The requiredness - * itself is objectui#8990; ⛔ this member does not wait on it. + * the string branch returns only under `if (!schema.groupBy)`). While + * {@link groupBy} was REQUIRED, no document that passed this schema could + * reach that branch, and objectui#8913 admitted the arm anyway — refusing it + * would have made objectui narrower than the protocol — recording it as + * inert. objectui#8990 made {@link groupBy} OPTIONAL, which is what made the + * arm live: a `groupBy`-less board with `columns: ['todo', 'doing']` now + * parses AND draws those two lanes, titled by the raw strings. + * + * ⚠️ RAW strings, not localized labels, and the difference is the arm's + * signature. The string branch returns `{ id: val, title: val }` and never + * calls `localizeColumn`; the picklist branch a GROUPED board takes returns + * the option LABELS. Measured on one object whose `status` options are + * `todo -> 'To Do'` / `doing -> 'Doing'`: lane-less draws `todo` / `doing`, + * the grouped control draws `To Do` / `Doing`. + * + * ⚠️ Reachable is not populated: every lane-less board holds ZERO cards, + * because `bucketCardsIntoColumns` early-returns before distributing records + * when there is no lane key. See {@link groupBy} for the full measured vector. + * Pinned in `plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx`. * * ⚠️ An undeclared lane key is ACCEPTED AND DROPPED from the parsed output, * not refused — the lane arm is a plain (non-passthrough) object, the same diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 2ae2b6947d..0ab6f55f9c 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -1216,10 +1216,16 @@ export const KanbanConditionalFormattingRuleSchema = z.union([ * The protocol names no mixed example. A declaration that admits a shape the * renderer mishandles is the defect this card removes, so it is refused here. * - * ⚠️ The STRING arm is admitted for protocol parity and is INERT on this face: - * the renderer's string branch returns only under `if (!schema.groupBy)`, and - * `groupBy` is required here. Its requiredness is objectui#8990; ⛔ this - * schema does not wait on that card. + * ⭐ The STRING arm is REACHABLE since objectui#8990. The renderer's string + * branch returns only under `if (!schema.groupBy)`, and `groupBy` was required + * on this face — so objectui#8913 admitted the arm (refusing it would have + * been a second narrowing) while recording that nothing could reach it. + * objectui#8990 made `groupBy` OPTIONAL, and a `groupBy`-less board with + * `columns: ['todo', 'doing']` now parses AND draws those two lanes, titled by + * the RAW strings rather than the picklist labels a grouped board would show. + * ⚠️ Reachable is not populated — a lane-less board holds zero cards, because + * `bucketCardsIntoColumns` returns before distributing records when there is + * no lane key. * * ⛔ NOT `KanbanColumnSchema`, whose `cards` is REQUIRED: that mirror is the * RUNTIME lane (`bucketCardsIntoColumns` fills `cards` before `KanbanImpl` @@ -1268,7 +1274,16 @@ function requireKanbanRecordSource( export const ObjectKanbanSchema = BaseSchema.extend({ type: z.literal('object-kanban'), objectName: z.string().optional().describe('ObjectQL object name — the LAST rung of the board ladder, after the pre-fetched data prop, bind and the inline row array on data; one of bind, data, objectName must be present (objectui#7780)'), - groupBy: z.string().describe('Field whose value places a record in a lane — the lane key the object-kanban renderer reads (ObjectKanban.tsx, thirteen sites); required, as the retired groupField was'), + // objectui#8990 — OPTIONAL, mirroring `@objectstack/spec` + // (`ObjectKanbanPropsSchema.groupBy` is `z.string().optional()`). Required + // here until this card, so this validator refused a document the protocol + // accepts — including this repo's own documented `{ type, dataSource }` + // board (`content/docs/utilities/data-objectstack.mdx`) and the node + // `plugin-list/src/ListView.tsx` generates when a view declares no lane + // field (`groupBy: laneField`, `laneField = … || undefined`). Requiredness + // is the ONLY thing that moved; the measured behaviour of a lane-less board + // (lanes drawn, ZERO cards, moves inert) is reasoned on the TS twin. + groupBy: z.string().optional().describe('Field whose value places a record in a lane — the lane key the object-kanban renderer reads (ObjectKanban.tsx, thirteen sites). Optional, as @objectstack/spec declares it: a board with no lane key renders its declared lanes and holds no cards, since the bucketer needs a key to distribute records'), groupField: retirementTombstone('RETIRED (objectui#7322) — `groupField` is not read by the object-kanban renderer; author `groupBy`. (The view-level `kanban.groupField` alias is unaffected.)'), // objectui#8913 — the lane vocabulary the renderer reads and neither face // named. Mirrors `../objectql.ts` member for member; the element union, the From e0a66279cbf6e651440f40864ee6315e75bc0a78 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 18:13:15 +0000 Subject: [PATCH 2/4] test(app-shell,kanban): follow the groupBy widening through the designer pin (objectui#8990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consequences of making `ObjectKanbanSchema.groupBy` optional, both measured: `block-config.test.ts` asserted that the node the pre-fix designer panel emitted is refused TWICE — missing `groupBy` and name-retired `groupField`. The first half is gone: an absent lane key is no longer a refusal. The falsification is kept on the half that was always sharper (the tombstone, refused BY NAME) and now asserts it as the SOLE issue, which is strictly tighter than the old two-key set. A CONTROL is added beside it so "optional" cannot be mistaken for "unjudged". `laneLessBoard-8990.test.tsx` records which of its own legs guard the widening. Ablation (source-only mutation reverting both faces to REQUIRED, dist marker read both ways and unchanged) turns exactly the CONTRACT legs red and leaves every RENDER leg green — because `SchemaRenderer` runs the structural `validateSchema`, never this package's zod mirror. So the renderer's bare-string branch was always live for a document that reached it without passing the published validator; what the requiredness prevented was a SCHEMA-VALID document getting there. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../previews/__tests__/block-config.test.ts | 53 ++++++++++++++----- .../src/__tests__/laneLessBoard-8990.test.tsx | 18 +++++++ 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts index b2f28d71ad..7ac5a4c18b 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts @@ -18,7 +18,9 @@ import { shapeMemberTypeName, } from '@object-ui/test-support'; // The NODE face of the block vocabulary — `object-kanban`'s own schema, where -// objectui#7322 declared `groupBy` REQUIRED and tombstoned `groupField`. The +// objectui#7322 declared `groupBy` and tombstoned `groupField` (that card also +// made `groupBy` REQUIRED; objectui#8990 made it OPTIONAL again, to match the +// protocol — it is still DECLARED and still typed). The // spec imports above cover the PAGE face; the two are different contracts and // this file now reads both. import { ObjectKanbanSchema } from '@object-ui/types/zod'; @@ -570,11 +572,15 @@ describe('page:accordion `title` / items `value` — dead designer inputs (#5212 * the same query, so that zero is a reading) and which objectui#7322 * retired BY NAME — `retirementTombstone()` on the zod face, `?: never` on * the TS one; - * - `groupBy`, which the same card declared REQUIRED, had no control at all. + * - `groupBy`, which the same card declared (REQUIRED then; OPTIONAL since + * objectui#8990, which aligned it with `@objectstack/spec`), had no control + * at all. * - * So the panel stably emitted a node that was missing a required key AND - * carrying a name-retired one, and rendered a board that grouped nothing with - * no diagnostic anywhere. `limit` is the third declared key it never offered: + * So the panel stably emitted a node that carried a name-retired key and no + * lane key at all, and rendered a board that grouped nothing with no diagnostic + * anywhere. ⚠️ Under today's contract the missing lane key is no longer itself a + * refusal — which is precisely why the CONTROL that the panel offers a `groupBy` + * box matters more than it did: the schema no longer backstops its absence. `limit` is the third declared key it never offered: * `ObjectKanban.tsx` sends it as a real `$top`, so a board over * `DEFAULT_KANBAN_LIMIT` records was silently truncated with no way to widen it. * @@ -582,7 +588,7 @@ describe('page:accordion `title` / items `value` — dead designer inputs (#5212 * read the CONTRACT rather than a spelling, so the next control added here is * measured against the schema instead of against a reviewer's memory. */ -describe('object-kanban — the required `groupBy` control, and the retired `groupField` (objectui#7772)', () => { +describe('object-kanban — the `groupBy` control, and the retired `groupField` (objectui#7772)', () => { const fieldNames = () => BLOCK_CONFIG['object-kanban'].map((f) => f.name); /** A block node as the canvas hoists it: `properties.*` at the top level. */ @@ -659,10 +665,18 @@ describe('object-kanban — the required `groupBy` control, and the retired `gro // FALSIFICATION for the probe above — the pre-fix control set, verbatim. A // green "it parses" means nothing unless the node this panel used to emit - // goes red, and it must go red TWICE: once for the key that is missing and - // once for the key that is refused by name. Either issue alone would be a - // different, smaller defect. - it('the node the pre-fix panel emitted is refused twice — missing `groupBy`, named `groupField`', () => { + // goes red. + // + // ⚠️ It used to go red TWICE — once for the MISSING `groupBy` and once for the + // name-retired `groupField`. objectui#8990 made `groupBy` OPTIONAL on both + // faces (`@objectstack/spec` declares it optional, and requiring it made this + // repository narrower than the protocol), so the first half is gone: an + // absent lane key is no longer a refusal anywhere. The falsification still + // holds on the half that was always the sharper one — the retired key refused + // BY NAME — and it is now asserted as the SOLE issue, which is a strictly + // tighter statement than the old two-key set: it fails if a future change + // either stops refusing `groupField` or starts refusing something else here. + it('the node the pre-fix panel emitted is still refused — at the name-retired `groupField`', () => { const result = ObjectKanbanSchema.safeParse( nodeFrom({ objectName: 'opportunity', groupField: 'stage', titleField: 'name' }), ); @@ -670,9 +684,10 @@ describe('object-kanban — the required `groupBy` control, and the retired `gro const byPath = Object.fromEntries( (result.error?.issues ?? []).map((i) => [i.path.join('.'), i]), ); - expect(Object.keys(byPath).sort()).toEqual(['groupBy', 'groupField']); - // The required key, absent. - expect(byPath.groupBy.code).toBe('invalid_type'); + // objectui#8990 — was `['groupBy', 'groupField']`. The absent lane key is no + // longer among the reasons, and asserting the WHOLE key set is what records + // that rather than letting it pass unnoticed. + expect(Object.keys(byPath).sort()).toEqual(['groupField']); // The retired key, refused BY NAME — the tombstone's guidance reaches the // author verbatim, which is the whole point of `retirementTombstone()` over // a silent strip. Asserted on the message because `invalid_type` alone @@ -681,6 +696,18 @@ describe('object-kanban — the required `groupBy` control, and the retired `gro expect(byPath.groupField.message).toContain('author `groupBy`'); }); + // CONTROL for the pin above — `groupBy` being optional must not be mistaken + // for `groupBy` being unjudged. The panel's own control writes a string, and + // a non-string still fails. + it('CONTROL — an optional `groupBy` is still TYPED when the panel writes one', () => { + expect( + ObjectKanbanSchema.safeParse(nodeFrom({ objectName: 'opportunity', groupBy: 'stage' })).success, + ).toBe(true); + const bad = ObjectKanbanSchema.safeParse(nodeFrom({ objectName: 'opportunity', groupBy: 42 })); + expect(bad.success).toBe(false); + expect((bad.error?.issues ?? []).map((i) => i.path.join('.'))).toContain('groupBy'); + }); + /* ── the placeholder states the real default ──────────────────────────── */ it("`limit`'s placeholder is DEFAULT_KANBAN_LIMIT, read from the renderer", () => { diff --git a/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx b/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx index b314a27545..bdb857db5f 100644 --- a/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx +++ b/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx @@ -52,6 +52,24 @@ * documents that RENDER (lane headings, no crash), not documents that work * better. ⛔ It is not an invitation to omit the key. * + * ## ⚠️ WHICH legs guard the widening — measured by ablation, and not obvious + * + * Reverting `groupBy` to REQUIRED on both faces (source-only mutation, no + * rebuild; the `dist/` marker read both ways and unchanged, so the run was + * reading source through vitest's `@object-ui/types` alias) turns exactly the + * CONTRACT legs of this file red — `safeParse` / `safeValidateSchema` — and + * leaves every RENDER leg green. + * + * ⭐ That is a fact about the system, not a weakness of the pins: `SchemaRenderer` + * runs the structural `validateSchema`, never this package's zod mirror, so the + * renderer's bare-string branch was ALWAYS live for a document that reached it + * without passing the published validator — `ListView.tsx`'s generated node, or + * any host not running `os check`. What the requiredness made impossible was a + * SCHEMA-VALID document reaching that branch. ⇒ ⛔ Do not read the render legs + * below as guarding the declaration; they pin renderer BEHAVIOUR, and the + * contract legs above are what pin the widening. Deleting the contract legs + * would leave this file fully green against a re-narrowed schema. + * * ## ⛔ objectui#8993 is NOT reached from here, and that is asserted * * objectui#8993 (`bucketCardsIntoColumns` double-buckets a non-string lane id) From fe330a7768b009bd0e568322bf7e4cea11250e22 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 18:16:53 +0000 Subject: [PATCH 3/4] docs(types): anchor the groupBy read-site census by symbol, not by line (objectui#8990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docblock this replaces cited six line numbers into `ObjectKanban.tsx`; all six had rotted (the same reads now sit ~370 lines lower), which is how this card found them while re-deriving its own anchors. Naming the reads by symbol — the `effectiveColumns` memo's four branches, `persistCardMove`, the `handleCardMove` callback — keeps the census exact and stops it rotting again, and matches what `check:new-cross-file-line-citations` says in its own words. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- packages/types/src/objectql.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index a681252edf..62d24ee1f0 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2923,8 +2923,17 @@ export interface ObjectKanbanSchema extends BaseSchema { * * The lane key the `object-kanban` renderer actually reads — * `packages/plugin-kanban/src/ObjectKanban.tsx` reads `schema.groupBy` at - * thirteen sites: lane materialisation (`:970`, `:982`, `:994`, `:1009`), - * card moves (`:1125`, `:1243`) and their effect deps. Undeclared here until + * thirteen sites, in three clusters: the `effectiveColumns` memo (its + * `localizeColumn` guard, its bare-string branch, its picklist branch and its + * from-data branch), `persistCardMove`, and the `handleCardMove` callback — + * plus their effect deps. + * + * ⚠️ Anchored by SYMBOL, deliberately. The docblock this replaces cited + * `:601` / `:613` / `:625` / `:640` / `:747` / `:865`; every one of those had + * rotted by the time this card re-derived them (the same reads now sit ~370 + * lines lower). `check:new-cross-file-line-citations` says the same thing in + * its own words — shifting an already-false address by a hunk delta just + * moves a wrong pointer somewhere else. Undeclared here until * objectui#7322, so an authored value reached the renderer only through * {@link BaseSchema}'s `[key: string]: any` — admitted, never examined. * From fa93c7fc259025528afee61e5fe9ead3926ac3dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 19:15:33 +0000 Subject: [PATCH 4/4] docs(kanban): make the published docs state the optional groupBy contract (objectui#8990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract review R1 (blocking): four hand-written doc sites still asserted the pre-change contract, and two of them named THIS card as the pending fix — so the PR closed the card while leaving the docs telling readers it was still open. AGENTS.md #2 ("Not done until docs reflect the code") is binding and no CI gate catches this class. - content/docs/api/schema-reference.md — the property table said "Required."; the columns callout said the bare-string arm is "inert on this block" because "groupBy is required here", and closed with "tracked as objectui#8990". - content/docs/plugins/plugin-kanban.mdx — same claim, same tracking sentence. - packages/plugin-kanban/README.md — "type and groupBy are required" and the inline "// required" comment, plus two further spots the review did not name that made the same claim. All now state optional AND the measured lane-less behaviour: lanes drawn from bare strings and titled by the RAW values (a grouped board shows picklist LABELS), zero cards, moves inert. Both "tracked as objectui#8990" sentences are deleted. The record-source rule is called out as separate and unaffected, since that refusal survives and is easy to conflate with the lane key. Also from the review: - Two unqualified sentences ("what made the arm live") now carry the schema-valid qualifier the groupBy docblock already had. The renderer never consulted this package's validator, so the arm was always live for a document that reached it unvalidated; what requiredness prevented was a document being valid AND getting there. - The mdx corpus evidence is DOWNGRADED where it was overstated: that fragment is still refused after this change, at RECORD_SOURCE_REQUIRED, so it shows a lane-less board is a documented authoring, not a document this change admits. The ListView evidence carries the argument alone and is strengthened to what was actually measured: objectDef loads async, so laneField is undefined on every load until it lands, and stays undefined when detectStatusField finds no stageField role and no status/stage/state/phase field, or when stageField is false (ADR-0085 suppression). - "can only NARROW #8993's reachable set" corrected to UNCHANGED / not widened. - The six rotted line addresses are no longer restated even as history. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../8990-object-kanban-groupby-optional.md | 14 ++-- content/docs/api/schema-reference.md | 6 +- content/docs/plugins/plugin-kanban.mdx | 18 +++-- packages/plugin-kanban/README.md | 25 ++++--- .../src/__tests__/laneLessBoard-8990.test.tsx | 13 ++-- packages/types/src/objectql.ts | 66 +++++++++++++------ 6 files changed, 96 insertions(+), 46 deletions(-) diff --git a/.changeset/8990-object-kanban-groupby-optional.md b/.changeset/8990-object-kanban-groupby-optional.md index ccf6704232..8468677cb3 100644 --- a/.changeset/8990-object-kanban-groupby-optional.md +++ b/.changeset/8990-object-kanban-groupby-optional.md @@ -21,11 +21,17 @@ objectui#7322 justified it as "every documented and tested `object-kanban` node this key", and objectui#7780 recorded two producers excluded from that count; both are still live: -- `content/docs/utilities/data-objectstack.mdx` documents an `object-kanban` node that - is exactly `{ type, dataSource }`, with no `groupBy`; - `packages/plugin-list/src/ListView.tsx` **generates** the node as - `groupBy: laneField`, where `laneField` ends in an explicit `|| undefined`, so a view - that declares no lane field emits `groupBy: undefined` at runtime. + `groupBy: laneField`. `objectDef` loads asynchronously, so `laneField` is `undefined` + on every load until it lands, and stays `undefined` whenever the object offers no + `stageField` hint and none of `status` / `stage` / `state` / `phase`. The renderer + serves that node; both published faces refused it. +- `content/docs/utilities/data-objectstack.mdx` documents an `object-kanban` node that + is exactly `{ type, dataSource }`, with no `groupBy`. ⚠️ This one is weaker and is + cited for what it is: that fragment is **still** refused after this change, at + `RECORD_SOURCE_REQUIRED`, because `dataSource` is not a rung of the record-source + ladder. It shows a lane-less board is a documented authoring; it is not a document + this change admits. **What a lane-less board does, measured rather than assumed.** Every `schema.groupBy` read in `ObjectKanban.tsx` is a guarded early-return, so the board degrades instead of diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index d41791c631..59d3898fa2 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -932,7 +932,7 @@ A drag-and-drop Kanban board. The `object-kanban` type key validates the shape t | Property | Type | Description | |----------|------|-------------| | `objectName` | `string` | Object to fetch records from. | -| `groupBy` | `string` | **Required.** Field whose values become the lanes (maps to column ids). | +| `groupBy` | `string` | Field whose values become the lanes (maps to column ids). **Optional** since objectui#8990, matching `@objectstack/spec`. A board that omits it draws whatever lanes `columns` declares — and holds **no cards**, because records are only distributed once a lane key exists. | | `columns` | `string[] \| KanbanLane[]` | Swimlane definitions — an array of `{ id, title }` lanes (one per `groupBy` value), **or** an array of bare value strings; never a mix. **Not** a field projection (that is `cardFields`). A lane's `cards` is optional: an object-bound board buckets records into the lane by `groupBy`, and only a static board writes a lane's cards itself. | | `titleField` | `string` | Field used as the card title. | | `cardFields` | `string[]` | Fields rendered on each card. | @@ -947,11 +947,11 @@ A drag-and-drop Kanban board. The `object-kanban` type key validates the shape t > `columns` is declared on this face since objectui#8913, as the pair of array shapes `@objectstack/spec` declares — an array of `{ id, title }` lanes, **or** an array of bare value strings. A **mixed** array is refused: the renderer decides which shape it has from the first element alone, so a mix yields a blank lane and mis-bucketed cards. A lane accepts `id`, `title`, `cards`, `limit`, `className` and `collapsed`, which are the members the board implementations read; `id` is a **string** — a non-string lane id makes the board render every card twice, once in its lane and once in "Uncategorized" (objectui#8993). When a lane carries `cards`, each card is judged — a card with no `title` is refused. An undeclared lane key is accepted and dropped, not refused, which is this tolerant face's posture; the strict authoring face refuses it by name. > -> ⚠️ The **bare-string array is accepted but inert on this block.** It is declared so this package does not refuse an authoring the protocol allows. The renderer reads a bare-string lane list only when a board has no `groupBy`, and `groupBy` is required here — so on `object-kanban` the strings are always ignored and the lanes come from the group field's picklist options or from the data. Write the `{ id, title }` array to control the lanes. The requiredness of `groupBy` is tracked as objectui#8990. +> ⚠️ The **bare-string array applies only to a board with no `groupBy`.** It is declared so this package does not refuse an authoring the protocol allows. The renderer reads a bare-string lane list only when a board has no `groupBy` — so on a board that *does* declare one the strings are ignored and the lanes come from the group field's picklist options or from the data. Since objectui#8990 made `groupBy` optional, a lane-less board is a valid authoring and this arm is live on it: the lanes are drawn, titled by the **raw strings** (a grouped board titles its lanes with the picklist *labels* instead). ⚠️ Such a board holds **no cards** — with no lane key the records are never distributed — and dragging a card writes nothing back. It is lane headings, not a populated board; to control the lanes of a working board, declare `groupBy` and write the `{ id, title }` array. > > The other keys the retired `kanban` arm alone declared — `cardTitle`, `swimlaneField`, `grouping` and `navigation` — are still undeclared on this face. The renderer reads them, so a board may carry them; they are simply not judged. The board's React host supplies `onCardMove` / `onCardClick` / `onQuickAdd` as props; none of the three is authorable in JSON. -> `data` and `bind` are [`BaseSchema`](#baseschema) members, not narrowed here, but this face requires **one of** `bind`, `data`, `objectName` — the renderer's own record-source ladder (an external `data` prop → `bind` via `useDataScope` → this schema's own `data` → a fetch keyed by `objectName`). A purely static board (lanes carrying their own cards, no record source) authors `"groupBy"` and `"data": []`. +> `data` and `bind` are [`BaseSchema`](#baseschema) members, not narrowed here, but this face requires **one of** `bind`, `data`, `objectName` — the renderer's own record-source ladder (an external `data` prop → `bind` via `useDataScope` → this schema's own `data` → a fetch keyed by `objectName`). A purely static board (lanes carrying their own cards, no record source) authors `"groupBy"` and `"data": []`. ⚠️ The record-source rule is **separate** from the lane key and is unaffected by objectui#8990: omitting `groupBy` is fine, omitting all of `bind` / `data` / `objectName` is still refused, at the refinement rather than at `groupBy`. **Related:** [ObjectViewSchema](#objectviewschema), [ObjectGridSchema](#objectgridschema) diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index 6a0a6e0529..79d72c9b81 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -94,13 +94,19 @@ and only a static board writes a lane's cards itself. Both faces of validated: a card with no `title` is refused. - **The bare-string array is accepted but has no effect on `object-kanban`.** It is + **The bare-string array applies only to a board with no `groupBy`.** It is declared so this package does not refuse an authoring `@objectstack/spec` allows. - The renderer only reads a bare-string lane list when a board has **no** `groupBy` - — and `groupBy` is required on `object-kanban`, so on this block the strings are - always ignored and the lanes come from the group field's picklist options or from - the data. Write the `{ id, title }` array to control the lanes. The requiredness of - `groupBy` is tracked as objectui#8990. + The renderer only reads a bare-string lane list when a board has **no** `groupBy`, + so on a board that declares one the strings are ignored and the lanes come from + the group field's picklist options or from the data. + + Since objectui#8990 made `groupBy` **optional** (matching `@objectstack/spec`), a + lane-less board is a valid authoring and this arm is live on it: the lanes are + drawn, titled by the **raw strings** — where a grouped board titles its lanes with + the picklist **labels**. ⚠️ A lane-less board holds **no cards**: with no lane key + the records are never distributed into lanes, and dragging a card writes nothing + back. It is lane headings, not a populated board. To control the lanes of a + working board, declare `groupBy` and write the `{ id, title }` array. ```plaintext diff --git a/packages/plugin-kanban/README.md b/packages/plugin-kanban/README.md index 66d6b4a24e..afa9b8528f 100644 --- a/packages/plugin-kanban/README.md +++ b/packages/plugin-kanban/README.md @@ -45,9 +45,10 @@ import '@object-ui/plugin-kanban'; // `object-kanban`. (The STORED `NamedListView.type` value `"kanban"` is a // different layer and is unaffected — do not rewrite saved views.) // -// `groupBy` and ONE record source (`data`, `bind` or `objectName`) are what the -// surviving face requires of every board; the shape below is the one the -// catalog fixture `plugin-kanban/basic-kanban-board.json` carries. +// ONE record source (`data`, `bind` or `objectName`) is what the surviving face +// requires of every board; `groupBy` is OPTIONAL since objectui#8990 but is what +// makes the lanes hold cards, so every working board authors it. The shape below +// is the one the catalog fixture `plugin-kanban/basic-kanban-board.json` carries. const schema = { type: 'object-kanban', groupBy: 'status', @@ -106,8 +107,9 @@ const column: KanbanColumn = { }; // ⚠️ `object-kanban`: the bare `kanban` node type key and its `KanbanSchema` -// arm RETIRED in objectui#8802. `groupBy` and one of `bind` / `data` / -// `objectName` are what the surviving face requires of every board. +// arm RETIRED in objectui#8802. One of `bind` / `data` / `objectName` is what the +// surviving face requires of every board; `groupBy` is optional since +// objectui#8990, and authored here because a board without it holds no cards. const schema: ObjectKanbanSchema = { type: 'object-kanban', groupBy: 'status', @@ -123,9 +125,14 @@ import type { ObjectKanbanSchema } from '@object-ui/types'; declare const columns: KanbanColumn[]; -// The board document. `type` and `groupBy` are required, and so is ONE record -// source — `bind`, `data` or `objectName`. `columns` and `className` are -// optional. +// The board document. `type` is required, and so is ONE record source — `bind`, +// `data` or `objectName`. `groupBy`, `columns` and `className` are optional. +// +// `groupBy` is OPTIONAL since objectui#8990, matching `@objectstack/spec`. It is +// still the key that makes the board work: with no lane key the records are never +// distributed, so a lane-less board draws whatever lanes `columns` declares and +// holds NO cards, and dragging a card writes nothing back. Omitting it is valid, +// not useful — author it on any board meant to group records. // // ⚠️ `onCardMove` is NOT a document key: it is a React prop the host supplies // (JSON has no function value), which is why it is spelled with explicit @@ -136,7 +143,7 @@ declare const columns: KanbanColumn[]; // not what catches a misspelt board key. const board: ObjectKanbanSchema = { type: 'object-kanban', - groupBy: 'status', // required — the field that makes the lanes + groupBy: 'status', // optional, but the field that makes the lanes data: [], // one record source is required columns, // Array of columns className: 'h-full', // Tailwind classes diff --git a/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx b/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx index bdb857db5f..b1394965fb 100644 --- a/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx +++ b/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx @@ -28,7 +28,11 @@ * `effectiveColumns` memo). While `groupBy` was REQUIRED, no schema-valid * document could reach that branch: objectui#8913 admitted the arm anyway * (refusing it would have been a second narrowing) and recorded it as - * unreachable. Making `groupBy` optional is what makes it live. + * unreachable. Making `groupBy` optional is what makes it reachable BY A + * SCHEMA-VALID DOCUMENT — the qualifier matters, and the section headed "WHICH + * legs guard the widening" below is where it is measured: the RENDERER never + * consulted this package's validator, so the branch was always live for a + * document that reached it unvalidated. ⛔ Not "dead code that came alive". * * ⚠️ "The arm fires" is not observable from the lane COUNT alone — a grouped * board draws two lanes too. It is observable from the lane TITLES, and that is @@ -77,9 +81,10 @@ * lane below it. A lane-less board returns before reaching any of it, and the * picklist-materialised lanes whose ids come straight from `opt.value` are * built under `if (schema.groupBy && ...)`, a branch a lane-less board cannot - * enter. ⇒ This card can only NARROW objectui#8993's reachable set on the - * documents it newly admits, never widen it. ⛔ Nothing here fixes #8993; it is - * a renderer behaviour change with its own card. + * enter. ⇒ objectui#8993's reachable set is UNCHANGED by this card: admitting + * documents cannot shrink it, and none of the documents newly admitted can reach + * the defect. ⛔ Not "narrowed" — the claim is that nothing was widened. ⛔ And + * nothing here fixes #8993; it is a renderer behaviour change with its own card. */ import React from 'react'; diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 62d24ee1f0..0fc888e327 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2928,11 +2928,12 @@ export interface ObjectKanbanSchema extends BaseSchema { * from-data branch), `persistCardMove`, and the `handleCardMove` callback — * plus their effect deps. * - * ⚠️ Anchored by SYMBOL, deliberately. The docblock this replaces cited - * `:601` / `:613` / `:625` / `:640` / `:747` / `:865`; every one of those had - * rotted by the time this card re-derived them (the same reads now sit ~370 - * lines lower). `check:new-cross-file-line-citations` says the same thing in - * its own words — shifting an already-false address by a hunk delta just + * ⚠️ Anchored by SYMBOL, deliberately. The docblock this replaces carried six + * bare line addresses into that file and EVERY ONE had rotted by the time this + * card re-derived them — the same reads now sit some 370 lines lower. The + * numbers are not restated here even as history: a literal address in prose is + * the thing that rots, and `check:new-cross-file-line-citations` says the same + * in its own words — shifting an already-false address by a hunk delta only * moves a wrong pointer somewhere else. Undeclared here until * objectui#7322, so an authored value reached the renderer only through * {@link BaseSchema}'s `[key: string]: any` — admitted, never examined. @@ -2954,17 +2955,31 @@ export interface ObjectKanbanSchema extends BaseSchema { * * 1. `content/docs/utilities/data-objectstack.mdx` documents an * `object-kanban` node that is exactly `{ type, dataSource }` — no - * `groupBy`. The published validator refused this repository's own - * documented example. + * `groupBy`. ⚠️ WEAKER THAN IT LOOKS, and the limit is worth stating: that + * fragment is STILL refused after this card, at `RECORD_SOURCE_REQUIRED` + * — `dataSource` is not a rung of this ladder — so it is evidence that a + * lane-less board is a DOCUMENTED AUTHORING, not a document this card + * admits. ⛔ Do not cite it as "objectui refuses its own documented + * example" without that qualifier; the record-source half of the refusal + * is deliberate and survives. * 2. `packages/plugin-list/src/ListView.tsx` GENERATES the node with * `groupBy: laneField`, where - * `laneField = groupByField || groupField || detectStatusField(objectDef) || undefined` - * — an explicit `|| undefined`. A view that declares no lane field and - * whose object has no detectable status field emits `groupBy: undefined` - * at runtime, which the renderer serves and both faces refused. - * - * Same shape as {@link objectName} (objectui#7780): a key the renderer guards - * at every read, declared REQUIRED, refusing boards that render today. + * `laneField = groupByField || groupField || detectStatusField(objectDef) || undefined`. + * ⭐ This is the load-bearing one, and it is stronger than the explicit + * `|| undefined` alone suggests: `objectDef` loads ASYNCHRONOUSLY, so + * `laneField` is `undefined` on EVERY load until it lands — transiently + * for every list-view kanban — and PERSISTENTLY in two cases from + * `detectStatusField` (`packages/types/src/record-semantics.ts`): the + * object declares no `stageField` role AND carries no field named + * `status` / `stage` / `state` / `phase` and none typed `status`/`stage`; + * or it sets `stageField: false`, which suppresses detection outright + * (ADR-0085 — a status-shaped field that is not a linear flow). The + * renderer serves that node and both published faces refused it. + * + * ⇒ Evidence 2 carries this on its own; evidence 1 supports the authoring + * shape, not the accept set. Same shape as {@link objectName} (objectui#7780): + * a key the renderer guards at every read, declared REQUIRED, refusing boards + * that render today. * * ## What a lane-less board actually does — MEASURED, not argued * @@ -3001,9 +3016,11 @@ export interface ObjectKanbanSchema extends BaseSchema { * `__uncolumned__` lane below it). A lane-less board returns before reaching * it, and the picklist-materialised lanes whose ids come straight from * `opt.value` are built under `if (schema.groupBy && ...)` — a branch a - * lane-less board cannot enter. So this card can only NARROW objectui#8993's - * reachable set on the documents it newly admits, never widen it. Measured - * both ways in the pin file above. + * lane-less board cannot enter. So objectui#8993's reachable set is UNCHANGED + * by this card — admitting documents cannot shrink it, and none of the + * documents newly admitted can reach the defect. ⛔ Not "narrowed": the + * containment is that nothing was widened. Measured both ways in the pin file + * above. */ groupBy?: string; /** @@ -3117,9 +3134,18 @@ export interface ObjectKanbanSchema extends BaseSchema { * {@link groupBy} was REQUIRED, no document that passed this schema could * reach that branch, and objectui#8913 admitted the arm anyway — refusing it * would have made objectui narrower than the protocol — recording it as - * inert. objectui#8990 made {@link groupBy} OPTIONAL, which is what made the - * arm live: a `groupBy`-less board with `columns: ['todo', 'doing']` now - * parses AND draws those two lanes, titled by the raw strings. + * inert. objectui#8990 made {@link groupBy} OPTIONAL, which is what makes the + * arm reachable BY A SCHEMA-VALID DOCUMENT: a `groupBy`-less board with + * `columns: ['todo', 'doing']` now parses AND draws those two lanes, titled by + * the raw strings. + * + * ⚠️ The qualifier is load-bearing, and matches {@link groupBy}'s. The RENDERER + * never consulted this package's validator — `SchemaRenderer` runs core's + * structural `validateSchema`, which carries no kanban rule — so the string + * branch was always live for a document that reached it WITHOUT passing the + * published faces (`ListView.tsx`'s generated node; any host not running + * `os check`). What the requiredness prevented was a document being valid AND + * getting there. ⛔ Do not restate this as "the arm was dead code". * * ⚠️ RAW strings, not localized labels, and the difference is the arm's * signature. The string branch returns `{ id: val, title: val }` and never