diff --git a/.changeset/8990-object-kanban-groupby-optional.md b/.changeset/8990-object-kanban-groupby-optional.md
new file mode 100644
index 0000000000..8468677cb3
--- /dev/null
+++ b/.changeset/8990-object-kanban-groupby-optional.md
@@ -0,0 +1,49 @@
+---
+'@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:
+
+- `packages/plugin-list/src/ListView.tsx` **generates** the node as
+ `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
+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/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/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/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
new file mode 100644
index 0000000000..b1394965fb
--- /dev/null
+++ b/packages/plugin-kanban/src/__tests__/laneLessBoard-8990.test.tsx
@@ -0,0 +1,351 @@
+/**
+ * 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 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
+ * 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.
+ *
+ * ## ⚠️ 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)
+ * 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. ⇒ 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';
+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..0fc888e327 100644
--- a/packages/types/src/objectql.ts
+++ b/packages/types/src/objectql.ts
@@ -2923,19 +2923,106 @@ 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, 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 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.
*
- * 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`. ⚠️ 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`.
+ * ⭐ 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
+ *
+ * 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 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;
/**
* RETIRED (objectui#7322) — the lane key this node's renderer never read.
* `ObjectKanban.tsx` reads {@link groupBy} thirteen times and `groupField`
@@ -3039,15 +3126,38 @@ 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 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
+ * 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