From 0fc945f29f2a673b01ec993ed3fbbe8918d3ff26 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 06:30:31 +0000 Subject: [PATCH 1/8] docs(spec): record the per-kind view `limit` as read by nobody, and state the gate guard the gate implements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #19228, prose + pins only. ⛔ No `.default()` moves and no precedence is picked — both are contract directions this card is explicitly not allowed to take. Measured first-hand at the objectui pin `87af769e9` (2026-09-21T06:30-06:40Z), over all 8,228 files tracked at that commit: - `.kanban.limit` / `.gallery.limit` / `.timeline.limit` -> 0 read points, against 8 for the identically-shaped `.kanban.groupByField` / `.gallery.coverField` / `.timeline.scale` control on the same instrument. - The row caps objectui does read are `savedViewLimit` (a view's `pagination.pageSize`, else its flat `limit`) and the element block's own flat `limit`. `ListView`'s `baseProps` carries no `limit` on any branch. - `ElementDataSourceGate`'s arm is `!fromView || !isUsableRowLimit(authored)`, reading the ELEMENT-face key, which is `.optional()` with no applied default. The arm is reachable; the view-face default never lands on it. So the published «fills it only when unset» was narrower than the guard, and the per-kind key #19226 declared reaches no consumer at all. Both are now recorded where an author and an auditor read them. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx --- packages/spec/src/ui/component.test.ts | 70 +++++++++++++++++++++++++- packages/spec/src/ui/component.zod.ts | 54 +++++++++++++++++--- packages/spec/src/ui/view.zod.ts | 19 +++++++ 3 files changed, 134 insertions(+), 9 deletions(-) diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index a4e52411d59..a641bf30353 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -24,7 +24,10 @@ import { ObjectKanbanPropsSchema, } from './component.zod'; import { PageComponentSchema, PageSchema, PageComponentType, ElementDataSourceSchema, RETIRED_PAGE_COMPONENT_TYPES } from './page.zod'; -import { GanttConfigSchema, TreeConfigSchema, ListMapConfigSchema, ListColumnSchema, ListViewSchema } from './view.zod'; +import { + GanttConfigSchema, TreeConfigSchema, ListMapConfigSchema, ListColumnSchema, ListViewSchema, + TimelineConfigSchema, DEFAULT_VIEW_ROW_LIMIT, +} from './view.zod'; import { FieldSchema } from '../data/field.zod'; import { ALL_CONVERSIONS } from '../conversions/registry'; import { strictObjectDeclarations } from '../shared/strict-object'; @@ -3799,3 +3802,68 @@ describe('the three #18305 object blocks — key sets derived from the renderers expect((ComponentPropsMap as Record)['object-chart']).toBeUndefined(); }); }); + +// #19228 — two authorable row bounds land on one `object-timeline` node, and +// the react tier's own precedence sentence was narrower than the guard it +// names. ⛔ This card picks NO precedence and changes no `.default()`; these +// pins only hold the two structural facts the repair rests on, measured +// first-hand at the objectui pin `87af769e9` on 2026-09-21T06:30-06:40Z. +describe('row caps on the object-bound blocks — what #19228 recorded', () => { + const timeline = ComponentPropsMap['object-timeline']; + const kanban = ComponentPropsMap['object-kanban']; + + it('leaves the ELEMENT-face `limit` undefaulted — the fact that keeps the gate arm alive', () => { + // `ElementDataSourceGate` lowers a bound view's cap into this key only + // when it does not already carry a USABLE one + // (`ElementDataSourceGate.tsx:316-331`, `!fromView || !isUsableRowLimit`). + // An applied default here would make every parsed node carry a usable cap + // and kill that arm outright — the failure #19228 feared, on the schema it + // would actually happen to. ⛔ Do not "fix" a red here by deleting the pin. + for (const [label, schema] of [['object-kanban', kanban], ['object-timeline', timeline]] as const) { + const parsed = schema.parse({ objectName: 'task' }) as Record; + expect(Object.prototype.hasOwnProperty.call(parsed, 'limit'), label).toBe(false); + } + + // LIT CONTROL, same instrument (a Zod applied default, observed through + // `parse`): the VIEW-face sibling DOES materialize one, so the zeros above + // are a reading rather than a parse that never ran. + const viewSide = TimelineConfigSchema.parse({ startDateField: 'start_date', titleField: 'name' }) as { limit?: number }; + expect(viewSide.limit).toBe(DEFAULT_VIEW_ROW_LIMIT); + }); + + it('materializes the NESTED `timeline.limit` on a node whose flat `limit` stays absent', () => { + // The shape the record is about: one strictObject, two authorable row + // caps. At the pin, `ListView.tsx:3084` forwards this block nested and + // does NOT hoist `limit` to a flat prop, and `ObjectTimeline.tsx:407` + // queries off the flat key alone — so the 100 below reaches no query. + const result = timeline.safeParse({ + objectName: 'task', + timeline: { startDateField: 'start_date', titleField: 'name' }, + }); + expect(result.success).toBe(true); + const data = (result.success ? result.data : undefined) as + { limit?: unknown; timeline?: { limit?: unknown } } | undefined; + expect(data?.timeline?.limit).toBe(DEFAULT_VIEW_ROW_LIMIT); + expect(Object.prototype.hasOwnProperty.call(data ?? {}, 'limit')).toBe(false); + + // CONTROL — the node is still strict, so the acceptance above is not the + // verdict of a map that has stopped refusing anything. + const control = timeline.safeParse({ objectName: 'task', zzUnlikelyBogusKey__: 1 }); + expect(control.success).toBe(false); + expect(JSON.stringify(control.error?.issues)).toContain('unrecognized_keys'); + }); + + it('states the gate guard the gate actually implements, not the narrower 「unset」 arm', () => { + // ⛔ The one prose assertion here, and it is negative on purpose: this + // card's whole repair IS the published sentence, so without a pin the + // change has no falsifier. The retired wording claimed the view's cap + // lands ONLY on an unset key; measured, it also lands on a key set to a + // cap the contract refuses, with `describeDisplacedRowLimit` reporting it. + const shape = (ObjectKanbanPropsSchema as unknown as { + def: { shape: Record }; + }).def.shape; + const description = shape.limit?.description ?? ''; + expect(description).not.toContain('only when unset'); + expect(description).toContain('usable'); + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 31fb9f59513..724c2caa00a 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3092,12 +3092,34 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ * Why the carrier is `limit` and not the bound view's `pagination.pageSize` * (the alternative the card opened): precedence is the `ElementDataSourceGate` * table, not this key's. The component-level `dataSource.limit` overrides - * this key, and a bound named view's `pagination.pageSize` is LOWERED INTO - * it through the `limit: 'limit'` mapping only when the component authored - * none (`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`, + * this key unconditionally; a bound named view's row cap is LOWERED INTO it + * through the `limit: 'limit'` mapping only when this key does not already + * carry a USABLE cap (`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`, * `readLimit`/`writeLimit` keyed by `ElementDataSourceLimitKey`; the branch * gained objectui#9899's presence-is-not-authorship test and a - * `describeDisplacedRowLimit` report on this hop). The board + * `describeDisplacedRowLimit` report on this hop). + * + * ⚠️ 「only when UNSET」 is what this docblock and the describe beside it + * used to say, and it is narrower than the guard — re-READ first-hand at the + * pin `87af769e9` on 2026-09-21T06:35Z. The branch is + * `if (!fromView || !isUsableRowLimit(authored))`, so the view's cap also + * lands when this key IS set to a value the contract refuses (`0`, negative, + * fractional, non-number), with `describeDisplacedRowLimit` telling the + * author. Unset is one arm of that guard, not the whole of it. The view half + * is likewise not `pagination.pageSize` alone: `savedViewLimit` reads + * `pagination.pageSize`, else that view's FLAT `limit` + * (`core/src/data-scope/element-data-source.ts:237-241`); the per-kind + * `kanban.limit` / `gallery.limit` / `timeline.limit` #19226 declared is read + * by neither door (0 read points at this pin — see `rowLimitKey` in + * `view.zod.ts`). ⛔ This note reports the guard; it picks no precedence. + * + * ⭐ The arm therefore stays REACHABLE: its guard reads THIS key, which is + * `.optional()` with no applied default, so an author's silence is still + * silence at parse time. #19228 read the applied default on the VIEW-face + * per-kind `limit` as killing this arm; the two are different schemas and + * the view-face default never lands on this key. ⛔ Do not add a + * `.default()` here: that — and only that — is what would make it dead. + * The board * has no `pagination` read point, so declaring that spelling here would name * a key the renderer ignores — the accepted-and-dropped defect this section * exists to remove. Same shape as the `element:record_picker` and @@ -3106,7 +3128,7 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ * a schema default would materialize `limit: 100` on every parsed board. */ limit: z.number().int().positive().optional() - .describe("Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills it only when unset"), + .describe("Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's row cap (`pagination.pageSize`, else that view's flat `limit`) fills this key unless it already carries a USABLE cap — a cap the contract refuses (zero, negative, fractional) is displaced by the view's and reported, not honoured"), data: z.array(z.unknown()).optional().describe('Static inline cards — bypasses the object query'), cardTitle: z.string().optional().describe('Field rendered as each card title'), titleField: z.string().optional().describe('Legacy fallback for `cardTitle` (the board reads `cardTitle || titleField`). Prefer `cardTitle`'), @@ -3942,8 +3964,13 @@ const OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ * the canonical nested config every field resolution prefers), `filter` * (`:210`, `:232` — verbatim to `$filter`), `sort` (`:211`, `:233` — through * the shared `convertSortToQueryParams` sink, as `object-calendar`'s does), - * `limit` (`:234`, `:254` — the fetch's top-level `$top`, renderer default - * `DEFAULT_TIMELINE_LIMIT` = 100 at `:28`), `items` (`:170`, `:247`, `:299`, + * `limit` (⭐ re-READ at the CURRENT pin `87af769e9` on 2026-09-21T06:30Z, + * #19228 — the other anchors in this list are still the `53ded82b` readings + * the header names: `:407`, the fetch's one top-level `$top`, through + * `resolveRowLimit(schema.limit, DEFAULT_TIMELINE_LIMIT)` with the default + * `100` at `:29` and the refused-cap diagnostic at `:279`. ⚠️ It is the FLAT + * key that is read — `schema.timeline.limit` has 0 read points anywhere in + * objectui at that pin; see the `timeline` door below), `items` (`:170`, `:247`, `:299`, * `:480` — the authored pass-through that short-circuits the object query), * `data` (`:171`, `:247`, `:254`, `:256` — the pre-fetched record source, * read off REACT PROPS rather than `schema`; the door's own docblock carries @@ -3969,6 +3996,17 @@ const OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ * VALUE posture: `timeline` takes {@link TimelineConfigSchema}, the block * `ListViewSchema.timeline` already declares — one vocabulary, taken by * reference, so this element face cannot fork from the view face. + * ⚠️ Taking it by reference also imported #19226's new `limit` onto THIS + * strictObject, beside the flat `limit` below — two authorable row caps on one + * node, one live and one inert, and the nested one carries an APPLIED default + * so every parsed node with a `timeline` block materializes `timeline.limit: + * 100` (#19228). Measured at the pin `87af769e9`, 2026-09-21T06:30Z: + * `ListView.tsx:3062-3117` forwards this block NESTED (`:3084`) and hoists + * only `startDateField` / `endDateField` / `titleField` / `groupByField` / + * `colorField` / `scale` to flat props — `limit` is not among them — while + * `ObjectTimeline.tsx:407` queries off the flat `schema.limit` alone. + * ⛔ Recorded, not repaired: which key should carry a timeline's row cap is + * the open half of #19228 and is not answered here. * `mapping` stays `z.unknown()`: its contract * (`TimelineMappingSchema`) still lives in objectui, which is the * `object-calendar.calendar` posture this section's header prescribes for @@ -3992,7 +4030,7 @@ export const ObjectTimelinePropsSchema = lazySchema(() => strictObject({ objectName: z.string().optional() .describe('Object this timeline binds to. Optional because the component-level `dataSource` binding can supply the object instead — this block registers through `ElementDataSourceGate`, which lowers the binding onto this key before the renderer sees the node'), timeline: TimelineConfigSchema.optional() - .describe('Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: { startDateField, endDateField, titleField, groupByField, colorField, scale }. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling'), + .describe('Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: { startDateField, endDateField, titleField, groupByField, colorField, scale, limit }. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling. ⚠️ `limit` is the one member of that block NO renderer reads: this face queries off the FLAT `limit` beside this key, and a `timeline.limit` written here is accepted, defaulted to 100 by the block, and then dropped'), /** Base query filter — the family's one `ViewFilterRule` array orthography (#15449). */ filter: z.array(ViewFilterRuleSchema, { error: ruleArrayFilterError({ diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 32e4b6b5a8d..4d945678796 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1216,6 +1216,25 @@ type RowLimitView = keyof typeof ROW_LIMIT_SUBJECT; * as complete, which is worse than the unbounded-and-silent one this key * replaces — the author needs to know the cap is visible, and the renderer * author needs to know it is owed. + * + * ⚠️ NO CONSUMER READS THIS KEY YET — recorded, not repaired (#19228). + * Measured 2026-09-21T06:40Z at the pin this repo builds against + * (`.objectui-sha` = `87af769e9`), by `git grep` over all 8,228 files tracked + * at that commit: `.kanban.limit` / `.gallery.limit` / `.timeline.limit` → + * **0** read points, against **8** for the identically-shaped control + * `.kanban.groupByField` / `.gallery.coverField` / `.timeline.scale` on the + * same instrument. The row caps objectui DOES read are two other keys: a + * saved view's `pagination.pageSize`, else that view's FLAT `limit` + * (`core/src/data-scope/element-data-source.ts:237-241`, `savedViewLimit`), + * and the element block's own flat `limit` (`plugin-timeline/src/ + * ObjectTimeline.tsx:407`, `plugin-kanban/src/ObjectKanban.tsx:676`). + * `ListView`'s `baseProps` (`plugin-list/src/ListView.tsx:2840-2865`) carries + * no `limit` on any branch, so a parsed view's per-kind ceiling reaches no + * query at all — it is declared-and-dropped, the ADR-0049 class, at birth. + * + * ⛔ Which of the three row bounds wins is NOT decided here and NOT implied by + * this declaration: #19228 opens that question and picks nothing, and neither + * does this note. What is recorded is only what each key reaches today. */ const rowLimitKey = (view: RowLimitView) => z.number().int().positive().default(DEFAULT_VIEW_ROW_LIMIT).describe( From 5344ddf3593bfd4c480cbd7754cb5bdcc53ee3d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 06:44:03 +0000 Subject: [PATCH 2/8] test(spec): pin the two structural facts #19228 recorded, and regenerate the reference page Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx --- content/docs/references/ui/component.mdx | 4 ++-- packages/spec/src/ui/component.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index d40ed33acb2..3dc021e44f2 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -617,7 +617,7 @@ Sort field and direction pair | **groupBy** | `string` | optional | Field whose values become the board columns | | **columns** | `any[]` | optional | Swimlane definitions (`{ id, title }` per `groupBy` value, or bare value strings) — NOT a field projection | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter, handed to the wire `$filter` — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | -| **limit** | `integer` | optional | Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills it only when unset | +| **limit** | `integer` | optional | Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's row cap (`pagination.pageSize`, else that view's flat `limit`) fills this key unless it already carries a USABLE cap — a cap the contract refuses (zero, negative, fractional) is displaced by the view's and reported, not honoured | | **data** | `any[]` | optional | Static inline cards — bypasses the object query | | **cardTitle** | `string` | optional | Field rendered as each card title | | **titleField** | `string` | optional | Legacy fallback for `cardTitle` (the board reads `cardTitle \|\| titleField`). Prefer `cardTitle` | @@ -803,7 +803,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **objectName** | `string` | optional | Object this timeline binds to. Optional because the component-level `dataSource` binding can supply the object instead — this block registers through `ElementDataSourceGate`, which lowers the binding onto this key before the renderer sees the node | -| **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: `{ startDateField, endDateField, titleField, groupByField, colorField, scale }`. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling | +| **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: `{ startDateField, endDateField, titleField, groupByField, colorField, scale, limit }`. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling. ⚠️ `limit` is the one member of that block NO renderer reads: this face queries off the FLAT `limit` beside this key, and a `timeline.limit` written here is accepted, defaulted to 100 by the block, and then dropped | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order for the fetched entries — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **limit** | `integer` | optional | Maximum number of records loaded onto the rail (row cap); lowered to the query's top-level `$top` (renderer default 100). A timeline renders one rail with no pagination control, so this is the author's window rather than a page size | diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index a641bf30353..1c6a58a5161 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -3864,6 +3864,6 @@ describe('row caps on the object-bound blocks — what #19228 recorded', () => { }).def.shape; const description = shape.limit?.description ?? ''; expect(description).not.toContain('only when unset'); - expect(description).toContain('usable'); + expect(description).toMatch(/usable cap/i); }); }); From 5514a956e8abfab123d1b0f3bd243f34c0f6502b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 07:36:56 +0000 Subject: [PATCH 3/8] chore(changeset): minor for the #19228 describe repair and the read-point record Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx --- ...228-view-row-limit-read-points-recorded.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .changeset/19228-view-row-limit-read-points-recorded.md diff --git a/.changeset/19228-view-row-limit-read-points-recorded.md b/.changeset/19228-view-row-limit-read-points-recorded.md new file mode 100644 index 00000000000..16a5823ae42 --- /dev/null +++ b/.changeset/19228-view-row-limit-read-points-recorded.md @@ -0,0 +1,37 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec): the react-tier `limit` describe states the guard `ElementDataSourceGate` implements, and the per-kind view `limit` is recorded as read by nobody (#19228) + +Prose and pins only. ⛔ No `.default()` moves, ⛔ no precedence is picked: which of the +per-kind view `limit`, a view's `pagination.pageSize` and a component's flat `limit` +should win is the open half of #19228 and is not answered here. + +## What the published text said, and what the consumer does + +`ObjectKanbanPropsSchema.limit` told authors that a bound view's `pagination.pageSize` +「fills it only when unset」. Measured first-hand at the objectui pin this repo builds +against (`.objectui-sha` = `87af769e9`, read 2026-09-21T06:35Z), the branch is +`if (!fromView || !isUsableRowLimit(authored))` +(`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`): the view's cap ALSO +lands when the key is set to a cap the contract refuses — zero, negative or fractional — +with `describeDisplacedRowLimit` telling the author. Unset is one arm of that guard, not +the whole of it. The view half is likewise not `pagination.pageSize` alone: `savedViewLimit` +reads `pagination.pageSize`, else that view's flat `limit` +(`core/src/data-scope/element-data-source.ts:237-241`). + +The describe now states the guard as implemented. No accept set moves; the same documents +parse to the same values before and after. + +## The per-kind view `limit` reaches no consumer + +`git grep` over all 8,228 files tracked at that pin: `.kanban.limit` / `.gallery.limit` / +`.timeline.limit` → **0** read points, against **8** for the identically-shaped control +`.kanban.groupByField` / `.gallery.coverField` / `.timeline.scale`. `ListView`'s `baseProps` +carries no `limit` on any branch, and `ObjectTimeline` queries off the FLAT `limit` alone +(`ObjectTimeline.tsx:407`). Recorded on `rowLimitKey` in `view.zod.ts` and on the +`object-timeline` door — declared, defaulted to 100 on every parse, and dropped. + +Authors of an `object-timeline` node: `timeline.limit` is accepted and has no effect today; +the flat `limit` beside it is the key the rail's query reads. From c6b3663cef016a1eb976cd91d5a2fd94cc599136 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 08:26:52 +0000 Subject: [PATCH 4/8] =?UTF-8?q?fix(spec):=20correct=20the=20#19228=20recor?= =?UTF-8?q?d=20=E2=80=94=20the=20per-kind=20view=20limit=20reaches=20the?= =?UTF-8?q?=20node=20through=20a=20SPREAD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The at-tier review of #19533 returned FAIL on five prose grounds, all descending from one hole: the zero was taken with a property-access instrument, and a spread carries a key without ever spelling it. Re-measured first-hand at the pin 87af769e9 with a SECOND instrument (spreads, lit control ...mergedTimeline = 1 line), which returns four: plugin-list/src/ListView.tsx:2979 ...restKanban plugin-view/src/ObjectView.tsx:1638 ...restKanban plugin-view/src/ObjectView.tsx:1697 ...(viewOptions.gallery || {}) plugin-view/src/ObjectView.tsx:1725 ...(viewOptions.timeline || {}) Neither restKanban destructure strips limit, so a view's per-kind limit -- the materialized 100 included -- lands on the flat key ObjectKanban:553 and ObjectTimeline:279 read. The $top it would govern is not issued on either route (both hosts pass rows as a React data prop; children short-circuit at :559 / :420), which is a statement about the query, not about the key being unread. Gallery alone is genuinely read by nobody: ObjectGallery.tsx contains no limit. Fixed: the rowLimitKey docblock and its contradicting describe, the kanban docblock, the object-timeline read anchor, the VALUE posture paragraph, the published timeline describe, one test comment, and the changeset -- which drops from minor to patch (zero accept-set movement, zero export movement). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx --- ...228-view-row-limit-read-points-recorded.md | 37 ---------- .../19228-view-row-limit-route-record.md | 41 ++++++++++ packages/spec/src/ui/component.test.ts | 9 ++- packages/spec/src/ui/component.zod.ts | 65 ++++++++++------ packages/spec/src/ui/view.zod.ts | 74 +++++++++++++------ 5 files changed, 141 insertions(+), 85 deletions(-) delete mode 100644 .changeset/19228-view-row-limit-read-points-recorded.md create mode 100644 .changeset/19228-view-row-limit-route-record.md diff --git a/.changeset/19228-view-row-limit-read-points-recorded.md b/.changeset/19228-view-row-limit-read-points-recorded.md deleted file mode 100644 index 16a5823ae42..00000000000 --- a/.changeset/19228-view-row-limit-read-points-recorded.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec): the react-tier `limit` describe states the guard `ElementDataSourceGate` implements, and the per-kind view `limit` is recorded as read by nobody (#19228) - -Prose and pins only. ⛔ No `.default()` moves, ⛔ no precedence is picked: which of the -per-kind view `limit`, a view's `pagination.pageSize` and a component's flat `limit` -should win is the open half of #19228 and is not answered here. - -## What the published text said, and what the consumer does - -`ObjectKanbanPropsSchema.limit` told authors that a bound view's `pagination.pageSize` -「fills it only when unset」. Measured first-hand at the objectui pin this repo builds -against (`.objectui-sha` = `87af769e9`, read 2026-09-21T06:35Z), the branch is -`if (!fromView || !isUsableRowLimit(authored))` -(`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`): the view's cap ALSO -lands when the key is set to a cap the contract refuses — zero, negative or fractional — -with `describeDisplacedRowLimit` telling the author. Unset is one arm of that guard, not -the whole of it. The view half is likewise not `pagination.pageSize` alone: `savedViewLimit` -reads `pagination.pageSize`, else that view's flat `limit` -(`core/src/data-scope/element-data-source.ts:237-241`). - -The describe now states the guard as implemented. No accept set moves; the same documents -parse to the same values before and after. - -## The per-kind view `limit` reaches no consumer - -`git grep` over all 8,228 files tracked at that pin: `.kanban.limit` / `.gallery.limit` / -`.timeline.limit` → **0** read points, against **8** for the identically-shaped control -`.kanban.groupByField` / `.gallery.coverField` / `.timeline.scale`. `ListView`'s `baseProps` -carries no `limit` on any branch, and `ObjectTimeline` queries off the FLAT `limit` alone -(`ObjectTimeline.tsx:407`). Recorded on `rowLimitKey` in `view.zod.ts` and on the -`object-timeline` door — declared, defaulted to 100 on every parse, and dropped. - -Authors of an `object-timeline` node: `timeline.limit` is accepted and has no effect today; -the flat `limit` beside it is the key the rail's query reads. diff --git a/.changeset/19228-view-row-limit-route-record.md b/.changeset/19228-view-row-limit-route-record.md new file mode 100644 index 00000000000..fe113d5679d --- /dev/null +++ b/.changeset/19228-view-row-limit-route-record.md @@ -0,0 +1,41 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): state the row-cap guard `ElementDataSourceGate` implements, and record where the per-kind view `limit` actually lands (#19228) + +Prose and pins only — zero accept-set movement, zero export movement. The same documents parse +to the same values before and after. ⛔ No `.default()` moves, ⛔ no precedence is picked: which +of the per-kind view `limit`, a view's `pagination.pageSize` and a component's flat `limit` +should win is the open half of #19228 and is not answered here. + +## What the published text said, and what the consumer does + +`ObjectKanbanPropsSchema.limit` told authors that a bound view's `pagination.pageSize` +「fills it only when unset」. Measured first-hand at the objectui pin this repo builds against +(`.objectui-sha` = `87af769e9`), the branch is `if (!fromView || !isUsableRowLimit(authored))` +(`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`): the view's cap ALSO lands +when the key is set to a cap the contract refuses — zero, negative or fractional — with +`describeDisplacedRowLimit` telling the author. Unset is one arm of that guard, not the whole of +it. The view half is likewise not `pagination.pageSize` alone: `savedViewLimit` reads +`pagination.pageSize`, else that view's flat `limit` +(`core/src/data-scope/element-data-source.ts:237-241`). The describe now states the guard as +implemented. + +## Where the per-kind view `limit` lands + +The adapters spread a view's per-kind block FLAT onto the node they generate — `...restKanban` +(`plugin-list/src/ListView.tsx:2979`, `plugin-view/src/ObjectView.tsx:1638`; neither destructure +strips `limit`) and `...(viewOptions.timeline || {})` / `...(viewOptions.gallery || {})` +(`ObjectView.tsx:1725` / `:1697`). So a view's `kanban.limit` or `timeline.limit` — including the +100 the applied default materializes — becomes the flat `limit` that `ObjectKanban.tsx:553` and +`ObjectTimeline.tsx:279` read. The `$top` it would govern is not issued on either adapter route +today, because both hosts hand rows down as a React `data` prop and both children short-circuit +their own fetch; ⛔ that is a statement about the query, not about the key being unread. + +**Gallery is the exception**, and only gallery: the block is spread flat by `ObjectView.tsx:1697` +and `ObjectGallery.tsx` contains no `limit` at all. + +⚠️ For authors: `timeline.limit` inside an `object-timeline` node's `timeline` block reaches the +rail on one adapter route and not on the other. Write the flat `limit` beside it when you mean +the row cap. diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 1c6a58a5161..3ad34a72c1e 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -3833,9 +3833,12 @@ describe('row caps on the object-bound blocks — what #19228 recorded', () => { it('materializes the NESTED `timeline.limit` on a node whose flat `limit` stays absent', () => { // The shape the record is about: one strictObject, two authorable row - // caps. At the pin, `ListView.tsx:3084` forwards this block nested and - // does NOT hoist `limit` to a flat prop, and `ObjectTimeline.tsx:407` - // queries off the flat key alone — so the 100 below reaches no query. + // caps, and an applied default on the nested one. ⚠️ Where that 100 then + // GOES is route-dependent and is deliberately not asserted here: at the + // pin `ObjectView.tsx:1725` spreads the block FLAT (so it becomes the flat + // `limit` the rail reads) while `ListView.tsx:3084` forwards it nested (so + // nothing reads it). This pin is about the PARSE, which is the only half a + // schema owns. const result = timeline.safeParse({ objectName: 'task', timeline: { startDateField: 'start_date', titleField: 'name' }, diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 724c2caa00a..869f1ab900f 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3109,16 +3109,30 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ * is likewise not `pagination.pageSize` alone: `savedViewLimit` reads * `pagination.pageSize`, else that view's FLAT `limit` * (`core/src/data-scope/element-data-source.ts:237-241`); the per-kind - * `kanban.limit` / `gallery.limit` / `timeline.limit` #19226 declared is read - * by neither door (0 read points at this pin — see `rowLimitKey` in - * `view.zod.ts`). ⛔ This note reports the guard; it picks no precedence. + * `kanban.limit` #19226 declared takes neither of those two doors. + * ⛔ This note reports the guard; it picks no precedence. * - * ⭐ The arm therefore stays REACHABLE: its guard reads THIS key, which is - * `.optional()` with no applied default, so an author's silence is still - * silence at parse time. #19228 read the applied default on the VIEW-face - * per-kind `limit` as killing this arm; the two are different schemas and - * the view-face default never lands on this key. ⛔ Do not add a - * `.default()` here: that — and only that — is what would make it dead. + * ⚠️ It takes a THIRD door, and missing it is what the at-tier review of + * #19533 caught. The ADAPTERS spread the view's kanban block FLAT onto the + * generated node — `plugin-list/src/ListView.tsx:2979` and + * `plugin-view/src/ObjectView.tsx:1638`, both `...restKanban`, and neither + * destructure (`ListView.tsx:2952`, `ObjectView.tsx:1579`) strips `limit`. + * So a view's `kanban.limit`, INCLUDING the 100 its applied default + * materializes, lands on THIS key, and `ObjectKanban.tsx:553` reads it + * (`describeRefusedRowLimit`, unconditional). The `$top` at `:676` is not + * issued on either route today — both hosts pass rows down as a React `data` + * prop and the board short-circuits at `:559` — so it governs no query + * there, which is ⛔ NOT the same claim as 「no consumer reads it」. A + * spread carries a key without spelling it, so a property-access sweep + * cannot see this and returns a confident zero. + * + * ⭐ The arm is nonetheless REACHABLE, for a reason the spreads do not + * touch: its guard reads THIS key on the node as AUTHORED, and this + * declaration is `.optional()` with no applied default, so an author's + * silence is still silence at parse time. #19228 read the VIEW-face applied + * default as killing this arm; the two are different schemas, and what the + * adapters lower is a rendered node, not the parse of this one. ⛔ Do not add + * a `.default()` here: that — and only that — is what would make it dead. * The board * has no `pagination` read point, so declaring that spelling here would name * a key the renderer ignores — the accepted-and-dropped defect this section @@ -3968,9 +3982,11 @@ const OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ * #19228 — the other anchors in this list are still the `53ded82b` readings * the header names: `:407`, the fetch's one top-level `$top`, through * `resolveRowLimit(schema.limit, DEFAULT_TIMELINE_LIMIT)` with the default - * `100` at `:29` and the refused-cap diagnostic at `:279`. ⚠️ It is the FLAT - * key that is read — `schema.timeline.limit` has 0 read points anywhere in - * objectui at that pin; see the `timeline` door below), `items` (`:170`, `:247`, `:299`, + * `100` at `:29` and the refused-cap diagnostic at `:279`. ⚠️ It is this FLAT + * key that is read, and the nested `timeline` block REACHES it: the + * `plugin-view` adapter spreads that block flat at `ObjectView.tsx:1725` + * (`plugin-list` forwards it nested instead). See the `timeline` door below), + * `items` (`:170`, `:247`, `:299`, * `:480` — the authored pass-through that short-circuits the object query), * `data` (`:171`, `:247`, `:254`, `:256` — the pre-fetched record source, * read off REACT PROPS rather than `schema`; the door's own docblock carries @@ -3998,15 +4014,20 @@ const OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ * reference, so this element face cannot fork from the view face. * ⚠️ Taking it by reference also imported #19226's new `limit` onto THIS * strictObject, beside the flat `limit` below — two authorable row caps on one - * node, one live and one inert, and the nested one carries an APPLIED default - * so every parsed node with a `timeline` block materializes `timeline.limit: - * 100` (#19228). Measured at the pin `87af769e9`, 2026-09-21T06:30Z: - * `ListView.tsx:3062-3117` forwards this block NESTED (`:3084`) and hoists - * only `startDateField` / `endDateField` / `titleField` / `groupByField` / - * `colorField` / `scale` to flat props — `limit` is not among them — while - * `ObjectTimeline.tsx:407` queries off the flat `schema.limit` alone. - * ⛔ Recorded, not repaired: which key should carry a timeline's row cap is - * the open half of #19228 and is not answered here. + * node, and the nested one carries an APPLIED default, so every parsed node + * with a `timeline` block materializes `timeline.limit: 100` (#19228). + * Measured at the pin `87af769e9`, 2026-09-21T08:05Z, the two adapter routes + * DISAGREE about that key and both readings are needed: + * - `plugin-view/src/ObjectView.tsx:1725` spreads the whole block FLAT + * (`...(viewOptions.timeline || {})`), so `timeline.limit` becomes the flat + * `limit` that `ObjectTimeline.tsx:279` reads and `:407` would lower. + * - `plugin-list/src/ListView.tsx:3062-3117` forwards it NESTED (`:3084`) and + * hoists only `startDateField` / `endDateField` / `titleField` / + * `groupByField` / `colorField` / `scale` — `limit` is not among them, so + * on that route it stays nested and nothing reads it. + * ⛔ So 「inert」 is route-dependent and was stated flatly here before the + * at-tier review of #19533 measured the spread. Recorded, not repaired: which + * key should carry a timeline's row cap is the open half of #19228. * `mapping` stays `z.unknown()`: its contract * (`TimelineMappingSchema`) still lives in objectui, which is the * `object-calendar.calendar` posture this section's header prescribes for @@ -4030,7 +4051,7 @@ export const ObjectTimelinePropsSchema = lazySchema(() => strictObject({ objectName: z.string().optional() .describe('Object this timeline binds to. Optional because the component-level `dataSource` binding can supply the object instead — this block registers through `ElementDataSourceGate`, which lowers the binding onto this key before the renderer sees the node'), timeline: TimelineConfigSchema.optional() - .describe('Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: { startDateField, endDateField, titleField, groupByField, colorField, scale, limit }. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling. ⚠️ `limit` is the one member of that block NO renderer reads: this face queries off the FLAT `limit` beside this key, and a `timeline.limit` written here is accepted, defaulted to 100 by the block, and then dropped'), + .describe('Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: { startDateField, endDateField, titleField, groupByField, colorField, scale, limit }. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling. ⚠️ `limit` is the one member whose effect depends on which adapter rendered the node: one spreads this block flat, so it becomes the flat `limit` beside this key, and one forwards it nested, where nothing reads it. Write the flat `limit` when you mean the row cap for the rail'), /** Base query filter — the family's one `ViewFilterRule` array orthography (#15449). */ filter: z.array(ViewFilterRuleSchema, { error: ruleArrayFilterError({ diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 4d945678796..1cedd7c2150 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1217,32 +1217,60 @@ type RowLimitView = keyof typeof ROW_LIMIT_SUBJECT; * replaces — the author needs to know the cap is visible, and the renderer * author needs to know it is owed. * - * ⚠️ NO CONSUMER READS THIS KEY YET — recorded, not repaired (#19228). - * Measured 2026-09-21T06:40Z at the pin this repo builds against - * (`.objectui-sha` = `87af769e9`), by `git grep` over all 8,228 files tracked - * at that commit: `.kanban.limit` / `.gallery.limit` / `.timeline.limit` → - * **0** read points, against **8** for the identically-shaped control - * `.kanban.groupByField` / `.gallery.coverField` / `.timeline.scale` on the - * same instrument. The row caps objectui DOES read are two other keys: a - * saved view's `pagination.pageSize`, else that view's FLAT `limit` - * (`core/src/data-scope/element-data-source.ts:237-241`, `savedViewLimit`), - * and the element block's own flat `limit` (`plugin-timeline/src/ - * ObjectTimeline.tsx:407`, `plugin-kanban/src/ObjectKanban.tsx:676`). - * `ListView`'s `baseProps` (`plugin-list/src/ListView.tsx:2840-2865`) carries - * no `limit` on any branch, so a parsed view's per-kind ceiling reaches no - * query at all — it is declared-and-dropped, the ADR-0049 class, at birth. - * - * ⛔ Which of the three row bounds wins is NOT decided here and NOT implied by - * this declaration: #19228 opens that question and picks nothing, and neither - * does this note. What is recorded is only what each key reaches today. + * ⚠️ WHAT THIS KEY REACHES TODAY — recorded, not repaired (#19228). Measured + * first-hand at the pin this repo builds against (`.objectui-sha` = + * `87af769e9`), 2026-09-21T08:05Z, with TWO instruments, because one was not + * enough and the first one's answer was wrong: + * + * 1. PROPERTY-ACCESS spellings — `.kanban.limit` / `.gallery.limit` / + * `.timeline.limit` and the receiver alternation: **0** hits. Lit control, + * identical shape, `.kanban.groupByField` / `.gallery.coverField` / + * `.timeline.scale`: **13** lines. A live instrument, and a WRONG answer. + * 2. ⭐ SPREADS — a spread carries a key without ever spelling it, so it is + * the hole instrument 1 cannot see by construction. Lit control: + * `...mergedTimeline`, 1 line. It returns FOUR, and they overturn the zero: + * `plugin-list/src/ListView.tsx:2979` `...restKanban` + * `plugin-view/src/ObjectView.tsx:1638` `...restKanban` + * `plugin-view/src/ObjectView.tsx:1697` `...(viewOptions.gallery || {})` + * `plugin-view/src/ObjectView.tsx:1725` `...(viewOptions.timeline || {})` + * Neither `restKanban` destructure strips `limit` (`ListView.tsx:2952`, + * `ObjectView.tsx:1579`), so a view's per-kind `limit` — INCLUDING the 100 + * this applied default materializes — lands on the generated node's FLAT + * `limit`, which is the key the renderers read. + * + * ⇒ **kanban and timeline: the key LANDS and IS READ.** + * `ObjectKanban.tsx:553` / `ObjectTimeline.tsx:279` run + * `describeRefusedRowLimit(schema.limit, …)` unconditionally. + * ⚠️ The `$top` it would govern (`ObjectKanban.tsx:676`, + * `ObjectTimeline.tsx:407`) is not issued on either adapter route today: + * both hosts hand rows down as a React `data` prop (`ListView.tsx:4702`, + * `ObjectView.tsx:2319`) and both children short-circuit their own fetch on + * it (`ObjectKanban.tsx:559`, `ObjectTimeline.tsx:420`). So it governs no + * query ON THOSE ROUTES — ⛔ which is not the same claim as 「reaches no + * consumer」, and the difference is the whole correction. + * ⇒ **gallery, and gallery alone: carried flat and read by NOBODY.** + * `ObjectView.tsx:1697` delivers it; `ObjectGallery.tsx` contains no `limit` + * at all (0 occurrences, case-insensitive, against a lit control + * `schema.imageField` / `schema.titleField` at `:340` / `:348`). ⛔ Do not + * generalise that asymmetry to the other two — it is gallery's alone. + * + * ⚠️ A consequence of APPLIED that the open decision needs: through those + * spreads a spec-parsed view emits a node carrying an authored-LOOKING flat + * `limit: 100` that no author wrote. ⛔ Flagged, not acted on — changing it is + * a contract direction, not a tidy-up. + * + * ⛔ Which of the row bounds wins is NOT decided here and NOT implied by this + * declaration: #19228 opens that question and picks nothing, and neither does + * this note. What is recorded is only what each key reaches today. */ const rowLimitKey = (view: RowLimitView) => z.number().int().positive().default(DEFAULT_VIEW_ROW_LIMIT).describe( - `Row ceiling — the most ${ROW_LIMIT_SUBJECT[view]}, sent as the query \`$top\`; default ` - + `${DEFAULT_VIEW_ROW_LIMIT} when the key is absent. When the ceiling APPLIES (the filtered ` - + 'set is larger than it), the renderer must show a visible truncation signal saying what is ' - + 'on screen is not the whole set — a bounded view that looks complete is worse than an ' - + 'unbounded one.', + `Row ceiling — the most ${ROW_LIMIT_SUBJECT[view]}; default ` + + `${DEFAULT_VIEW_ROW_LIMIT} when the key is absent. The renderer owes two things: bound its ` + + 'fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), ' + + 'show a visible truncation signal saying what is on screen is not the whole set — a bounded ' + + 'view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a ' + + 'renderer that reads this key yet; which do is recorded on the declaration.', ); /** From 99fd9f9ac48ead66410f1bebb7578c077f1d8e60 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 08:43:54 +0000 Subject: [PATCH 5/8] fix(spec): regenerate view.mdx for the rowLimitKey describe, and restore the changeset to minor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI reds from c6b3663c. 1. `check:generated` — the rowLimitKey `.describe()` change is published on nine `content/docs/references/ui/view.mdx` rows and that page was left stale. Regenerated with `check:generated --fix`, never by hand; the diff is exactly 9 insertions / 9 deletions. 2. `Check Changeset` LEVEL axis (#16055 / #16776) refuses a PR that declares `Clause-②: yes` while grading no moved package `minor` or above. The at-tier review ruled `patch` on the lane's auxiliary-surface criterion, and a required gate insists on `minor`. Acting on the stricter of the two and leaving the conflict to its own card, per the dispatch protocol; the declaration is NOT flipped to `no` to make `patch` legal. The changeset prose now says the level is the gate's floor and not a behaviour change. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx --- .../19228-view-row-limit-route-record.md | 9 ++++++++- content/docs/references/ui/component.mdx | 4 ++-- content/docs/references/ui/view.mdx | 18 +++++++++--------- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.changeset/19228-view-row-limit-route-record.md b/.changeset/19228-view-row-limit-route-record.md index fe113d5679d..02310667466 100644 --- a/.changeset/19228-view-row-limit-route-record.md +++ b/.changeset/19228-view-row-limit-route-record.md @@ -1,5 +1,5 @@ --- -"@objectstack/spec": patch +"@objectstack/spec": minor --- fix(spec): state the row-cap guard `ElementDataSourceGate` implements, and record where the per-kind view `limit` actually lands (#19228) @@ -9,6 +9,13 @@ to the same values before and after. ⛔ No `.default()` moves, ⛔ no precedenc of the per-kind view `limit`, a view's `pagination.pageSize` and a component's flat `limit` should win is the open half of #19228 and is not answered here. +⚠️ **Why `minor` when nothing behavioural moved.** `check-changeset-no-major`'s LEVEL axis +(#16055 / #16776) requires a PR that declares `Clause-②: yes` to grade at least one package +whose published source it moves `minor` or above, and this PR carries that declaration. The bump +is therefore the gate's floor, ⛔ not a description of a behaviour change: no key is added, +removed or renamed, no export moves, and every document that parsed before parses to the same +value after. Upgrading gains corrected published prose and nothing else. + ## What the published text said, and what the consumer does `ObjectKanbanPropsSchema.limit` told authors that a bound view's `pagination.pageSize` diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 3dc021e44f2..e2d3217e962 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -803,7 +803,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **objectName** | `string` | optional | Object this timeline binds to. Optional because the component-level `dataSource` binding can supply the object instead — this block registers through `ElementDataSourceGate`, which lowers the binding onto this key before the renderer sees the node | -| **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: `{ startDateField, endDateField, titleField, groupByField, colorField, scale, limit }`. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling. ⚠️ `limit` is the one member of that block NO renderer reads: this face queries off the FLAT `limit` beside this key, and a `timeline.limit` written here is accepted, defaulted to 100 by the block, and then dropped | +| **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: `{ startDateField, endDateField, titleField, groupByField, colorField, scale, limit }`. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling. ⚠️ `limit` is the one member whose effect depends on which adapter rendered the node: one spreads this block flat, so it becomes the flat `limit` beside this key, and one forwards it nested, where nothing reads it. Write the flat `limit` when you mean the row cap for the rail | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order for the fetched entries — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **limit** | `integer` | optional | Maximum number of records loaded onto the rail (row cap); lowered to the query's top-level `$top` (renderer default 100). A timeline renders one rail with no pagination control, so this is the author's window rather than a page size | @@ -828,7 +828,7 @@ View filter rule | **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. | | **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color | | **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ObjectTimelineProps.filter[number]` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 0b98f3d5254..ea800bd3ae5 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -545,7 +545,7 @@ Gallery/card view configuration | **cardSize** | `Enum<'small' \| 'medium' \| 'large'>` | optional (default: `"medium"`) | Card size in gallery view | | **titleField** | `string` | optional | Field to display as card title | | **visibleFields** | `string[]` | optional | Fields to display on card body | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | --- @@ -701,7 +701,7 @@ HTTP methods a view data source may request — the subset of `HttpMethod` witho | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | --- @@ -940,7 +940,7 @@ View filter rule | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ListView.calendar` @@ -995,7 +995,7 @@ View filter rule | **cardSize** | `Enum<'small' \| 'medium' \| 'large'>` | optional (default: `"medium"`) | Card size in gallery view | | **titleField** | `string` | optional | Field to display as card title | | **visibleFields** | `string[]` | optional | Fields to display on card body | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ListView.timeline` @@ -1007,7 +1007,7 @@ View filter rule | **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. | | **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color | | **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ListView.chart` @@ -1342,7 +1342,7 @@ View filter rule | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ObjectListView.calendar` @@ -1397,7 +1397,7 @@ View filter rule | **cardSize** | `Enum<'small' \| 'medium' \| 'large'>` | optional (default: `"medium"`) | Card size in gallery view | | **titleField** | `string` | optional | Field to display as card title | | **visibleFields** | `string[]` | optional | Fields to display on card body | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ObjectListView.timeline` @@ -1409,7 +1409,7 @@ View filter rule | **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. | | **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color | | **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | ### Nested Shape: `ObjectListView.chart` @@ -1663,7 +1663,7 @@ Timeline view configuration | **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. | | **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color | | **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale | -| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. | --- From ff3d631309a9bfdce0b41e328259e2106e1b1cd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 09:39:05 +0000 Subject: [PATCH 6/8] fix(spec): name the FACE before every statement, and take the changeset to patch At-tier FAIL, grounds 1-3. Grounds 1 and 2 were one error: the previous round fixed a face-swap by swapping the other way. A view-face fact had sat on the element-face key; the rewrite put an element-face key under a view-face fact. Re-measured first-hand at pin 87af769e9: - ELEMENT FACE, a node's own nested timeline.limit: read on NO route. ObjectTimeline binds timelineConfig = schema.timeline (:262) and reads exactly eight members off it -- startDateField, dateField, titleField, endDateField, groupByField, colorField, metaFields (:520, behind an `as any` cast, which is why a timelineConfig?.x sweep under-counts) and scale. No spread of a node's own timeline block exists anywhere (0 hits; lit control = the view-block spreads, which fire). The gate mapping is limit: 'limit' (plugin-timeline/src/index.tsx:333) -- flat. - VIEW FACE, a ListViewSchema document's timeline.limit: that is the route-dependent one. ObjectView.tsx:1725 flattens it onto the node's flat limit (and that node then carries no timeline block at all); ListView.tsx:3084 keeps it nested and unread. So every sentence now names its face first, in both docblocks, the published describe, the test comment and the changeset -- the distinction is stated rather than navigated, because three rounds have failed on it. Also flagged, not fixed: a third route neither the card nor the first two reviews described -- a hand-authored object-timeline node reaching ObjectTimeline through SchemaRenderer with no adapter. Its nested timeline.limit is unread there too, and that is the route an author face is written for. The second instrument is now recorded by its PREDICATE (a spread whose target is the object literal an adapter returns as the node) rather than by its count, so the reading can be re-taken without re-deriving the rule. Changeset level to patch per the coordinator's correction: the gate's two remedies are not interchangeable and this is the second one -- the level was right and the declaration was wrong. The "Why minor" paragraph is dropped. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx --- .../19228-view-row-limit-route-record.md | 45 +++++------ content/docs/references/ui/component.mdx | 2 +- packages/spec/src/ui/component.test.ts | 13 ++-- packages/spec/src/ui/component.zod.ts | 65 +++++++++++----- packages/spec/src/ui/view.zod.ts | 77 ++++++++++++------- 5 files changed, 127 insertions(+), 75 deletions(-) diff --git a/.changeset/19228-view-row-limit-route-record.md b/.changeset/19228-view-row-limit-route-record.md index 02310667466..058853271d9 100644 --- a/.changeset/19228-view-row-limit-route-record.md +++ b/.changeset/19228-view-row-limit-route-record.md @@ -1,5 +1,5 @@ --- -"@objectstack/spec": minor +"@objectstack/spec": patch --- fix(spec): state the row-cap guard `ElementDataSourceGate` implements, and record where the per-kind view `limit` actually lands (#19228) @@ -9,13 +9,6 @@ to the same values before and after. ⛔ No `.default()` moves, ⛔ no precedenc of the per-kind view `limit`, a view's `pagination.pageSize` and a component's flat `limit` should win is the open half of #19228 and is not answered here. -⚠️ **Why `minor` when nothing behavioural moved.** `check-changeset-no-major`'s LEVEL axis -(#16055 / #16776) requires a PR that declares `Clause-②: yes` to grade at least one package -whose published source it moves `minor` or above, and this PR carries that declaration. The bump -is therefore the gate's floor, ⛔ not a description of a behaviour change: no key is added, -removed or renamed, no export moves, and every document that parsed before parses to the same -value after. Upgrading gains corrected published prose and nothing else. - ## What the published text said, and what the consumer does `ObjectKanbanPropsSchema.limit` told authors that a bound view's `pagination.pageSize` @@ -29,20 +22,28 @@ it. The view half is likewise not `pagination.pageSize` alone: `savedViewLimit` (`core/src/data-scope/element-data-source.ts:237-241`). The describe now states the guard as implemented. -## Where the per-kind view `limit` lands +## Where the per-kind VIEW `limit` lands + +⚠️ Two different keys are easy to confuse here, so each statement names its face. The **view +face** is a `ListViewSchema` document's `kanban` / `gallery` / `timeline` block — that is where +this key lives. The **element face** is a page component node's own flat `limit`, declared in +`component.zod.ts`, and that is the key every renderer actually reads. An adapter turns the +first into the second. The adapters spread a view's per-kind block FLAT onto the node they generate — `...restKanban` (`plugin-list/src/ListView.tsx:2979`, `plugin-view/src/ObjectView.tsx:1638`; neither destructure -strips `limit`) and `...(viewOptions.timeline || {})` / `...(viewOptions.gallery || {})` -(`ObjectView.tsx:1725` / `:1697`). So a view's `kanban.limit` or `timeline.limit` — including the -100 the applied default materializes — becomes the flat `limit` that `ObjectKanban.tsx:553` and -`ObjectTimeline.tsx:279` read. The `$top` it would govern is not issued on either adapter route -today, because both hosts hand rows down as a React `data` prop and both children short-circuit -their own fetch; ⛔ that is a statement about the query, not about the key being unread. - -**Gallery is the exception**, and only gallery: the block is spread flat by `ObjectView.tsx:1697` -and `ObjectGallery.tsx` contains no `limit` at all. - -⚠️ For authors: `timeline.limit` inside an `object-timeline` node's `timeline` block reaches the -rail on one adapter route and not on the other. Write the flat `limit` beside it when you mean -the row cap. +strips `limit`) and `...(viewOptions.gallery || {})` / `...(viewOptions.timeline || {})` +(`ObjectView.tsx:1697` / `:1725`). So a view's `kanban.limit` — including the 100 the applied +default materializes — becomes the node's flat `limit`, which `ObjectKanban.tsx:553` reads. A +view's `timeline.limit` is route-dependent: `plugin-view` flattens it and `ObjectTimeline.tsx:279` +reads it, while `plugin-list` forwards the block nested, where nothing does. A view's +`gallery.limit` is flattened too and read by nobody — `ObjectGallery.tsx` contains no `limit` at +all. + +Where it is read, the `$top` it would govern is still not issued on either adapter route today, +because both hosts hand rows down as a React `data` prop and both children short-circuit their +own fetch; ⛔ that is a statement about the query, not about the key being unread. + +⚠️ For authors of an `object-timeline` NODE: a `limit` written inside that node's own `timeline` +block is read by no renderer on any route — the rail is capped by the flat `limit` beside it, +which is also the only one a bound `dataSource` lowers into. Write the flat one. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index e2d3217e962..095679df312 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -803,7 +803,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **objectName** | `string` | optional | Object this timeline binds to. Optional because the component-level `dataSource` binding can supply the object instead — this block registers through `ElementDataSourceGate`, which lowers the binding onto this key before the renderer sees the node | -| **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: `{ startDateField, endDateField, titleField, groupByField, colorField, scale, limit }`. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling. ⚠️ `limit` is the one member whose effect depends on which adapter rendered the node: one spreads this block flat, so it becomes the flat `limit` beside this key, and one forwards it nested, where nothing reads it. Write the flat `limit` when you mean the row cap for the rail | +| **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: `{ startDateField, endDateField, titleField, groupByField, colorField, scale, limit }`. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling. ⚠️ `limit` is the one member of this block NO renderer reads on any route: the rail is capped by the FLAT `limit` beside this key, which is also the only one a bound `dataSource` lowers into. A `limit` written inside this block is accepted, defaulted to 100, and never read | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order for the fetched entries — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **limit** | `integer` | optional | Maximum number of records loaded onto the rail (row cap); lowered to the query's top-level `$top` (renderer default 100). A timeline renders one rail with no pagination control, so this is the author's window rather than a page size | diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 3ad34a72c1e..6043169c63f 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -3833,12 +3833,13 @@ describe('row caps on the object-bound blocks — what #19228 recorded', () => { it('materializes the NESTED `timeline.limit` on a node whose flat `limit` stays absent', () => { // The shape the record is about: one strictObject, two authorable row - // caps, and an applied default on the nested one. ⚠️ Where that 100 then - // GOES is route-dependent and is deliberately not asserted here: at the - // pin `ObjectView.tsx:1725` spreads the block FLAT (so it becomes the flat - // `limit` the rail reads) while `ListView.tsx:3084` forwards it nested (so - // nothing reads it). This pin is about the PARSE, which is the only half a - // schema owns. + // caps, and an applied default on the nested one. ⚠️ Faces, because this + // card keeps confusing them: the NESTED key asserted below is the ELEMENT + // face, and at the pin no renderer reads it on any route. The + // route-dependent one is a VIEW document's `timeline.limit`, a different + // key on a different document, which `ObjectView.tsx:1725` flattens onto + // a generated node's FLAT `limit`. Neither is asserted here: this pin is + // about the PARSE, which is the only half a schema owns. const result = timeline.safeParse({ objectName: 'task', timeline: { startDateField: 'start_date', titleField: 'name' }, diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 869f1ab900f..e820271a323 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3113,8 +3113,12 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ * ⛔ This note reports the guard; it picks no precedence. * * ⚠️ It takes a THIRD door, and missing it is what the at-tier review of - * #19533 caught. The ADAPTERS spread the view's kanban block FLAT onto the - * generated node — `plugin-list/src/ListView.tsx:2979` and + * #19533 caught. Stated with its faces named, because that is where this + * card keeps going wrong: `kanban.limit` is a VIEW-FACE key (a member of a + * `ListViewSchema` document's `kanban` block) and THIS `limit` is the + * ELEMENT-FACE key on the node; the third door is the one that turns the + * first into the second. The ADAPTERS spread the view's kanban block FLAT + * onto the generated node — `plugin-list/src/ListView.tsx:2979` and * `plugin-view/src/ObjectView.tsx:1638`, both `...restKanban`, and neither * destructure (`ListView.tsx:2952`, `ObjectView.tsx:1579`) strips `limit`. * So a view's `kanban.limit`, INCLUDING the 100 its applied default @@ -3983,10 +3987,10 @@ const OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ * the header names: `:407`, the fetch's one top-level `$top`, through * `resolveRowLimit(schema.limit, DEFAULT_TIMELINE_LIMIT)` with the default * `100` at `:29` and the refused-cap diagnostic at `:279`. ⚠️ It is this FLAT - * key that is read, and the nested `timeline` block REACHES it: the - * `plugin-view` adapter spreads that block flat at `ObjectView.tsx:1725` - * (`plugin-list` forwards it nested instead). See the `timeline` door below), - * `items` (`:170`, `:247`, `:299`, + * key that is read — this node's own nested `timeline.limit` is not, on any + * route. What reaches this flat key is a VIEW document's `timeline.limit`, + * flattened onto the generated node by `ObjectView.tsx:1725`; see the + * `timeline` door below), `items` (`:170`, `:247`, `:299`, * `:480` — the authored pass-through that short-circuits the object query), * `data` (`:171`, `:247`, `:254`, `:256` — the pre-fetched record source, * read off REACT PROPS rather than `schema`; the door's own docblock carries @@ -4016,18 +4020,41 @@ const OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ * strictObject, beside the flat `limit` below — two authorable row caps on one * node, and the nested one carries an APPLIED default, so every parsed node * with a `timeline` block materializes `timeline.limit: 100` (#19228). - * Measured at the pin `87af769e9`, 2026-09-21T08:05Z, the two adapter routes - * DISAGREE about that key and both readings are needed: - * - `plugin-view/src/ObjectView.tsx:1725` spreads the whole block FLAT - * (`...(viewOptions.timeline || {})`), so `timeline.limit` becomes the flat - * `limit` that `ObjectTimeline.tsx:279` reads and `:407` would lower. - * - `plugin-list/src/ListView.tsx:3062-3117` forwards it NESTED (`:3084`) and - * hoists only `startDateField` / `endDateField` / `titleField` / - * `groupByField` / `colorField` / `scale` — `limit` is not among them, so - * on that route it stays nested and nothing reads it. - * ⛔ So 「inert」 is route-dependent and was stated flatly here before the - * at-tier review of #19533 measured the spread. Recorded, not repaired: which - * key should carry a timeline's row cap is the open half of #19228. + * + * ⛔ THE TWO ARE ON DIFFERENT FACES, and #19228 has now gone wrong on that + * boundary in both directions — once putting a view-face fact on the element + * key, once the reverse. So each statement names its face first. Measured at + * the pin `87af769e9`, 2026-09-21T09:15Z: + * - **ELEMENT FACE — THIS node's own nested `timeline.limit`: read on NO + * route.** `ObjectTimeline` binds `timelineConfig = schema.timeline` + * (`ObjectTimeline.tsx:262`) and reads exactly eight members off it — + * `startDateField`, `dateField`, `titleField`, `endDateField`, + * `groupByField`, `colorField`, `metaFields` (`:520`, through an `as any` + * cast, which is why a `timelineConfig?.x` sweep alone under-counts) and + * `scale`. `limit` is not among them, and NO spread of a node's own + * `timeline` block exists anywhere in objectui (0 hits; lit control, same + * shape, the view-block spreads `...mergedTimeline` / + * `...(viewOptions.timeline || {})`, which do fire). The + * `ElementDataSourceGate` mapping is `limit: 'limit'` + * (`plugin-timeline/src/index.tsx:333`) — FLAT, so it never touches the + * nested key either. + * - **VIEW FACE — a `ListViewSchema` document's `timeline.limit`: that is + * the route-dependent one**, and it is a different key on a different + * document. `ObjectView.tsx:1725` flattens the view block onto the node it + * returns (which then carries no `timeline` block at all), so the value + * arrives as this node's FLAT `limit` and is read; `ListView.tsx:3084` + * forwards it nested instead, where nothing reads it. Recorded on + * `rowLimitKey` in `view.zod.ts`, which is where that key lives. + * + * ⚠️ Flagged, not fixed — a THIRD route neither the card nor the first two + * reviews described: a hand-authored `object-timeline` node reaching + * `ObjectTimeline` through `SchemaRenderer` with no adapter in between. Its + * nested `timeline.limit` is unread there too (it is the same element-face + * key as the first bullet), and that route is the one an AUTHOR face is + * written for — so it is the route the open question most concerns. + * + * ⛔ Recorded, not repaired: which key should carry a timeline's row cap is + * the open half of #19228. * `mapping` stays `z.unknown()`: its contract * (`TimelineMappingSchema`) still lives in objectui, which is the * `object-calendar.calendar` posture this section's header prescribes for @@ -4051,7 +4078,7 @@ export const ObjectTimelinePropsSchema = lazySchema(() => strictObject({ objectName: z.string().optional() .describe('Object this timeline binds to. Optional because the component-level `dataSource` binding can supply the object instead — this block registers through `ElementDataSourceGate`, which lowers the binding onto this key before the renderer sees the node'), timeline: TimelineConfigSchema.optional() - .describe('Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: { startDateField, endDateField, titleField, groupByField, colorField, scale, limit }. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling. ⚠️ `limit` is the one member whose effect depends on which adapter rendered the node: one spreads this block flat, so it becomes the flat `limit` beside this key, and one forwards it nested, where nothing reads it. Write the flat `limit` when you mean the row cap for the rail'), + .describe('Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: { startDateField, endDateField, titleField, groupByField, colorField, scale, limit }. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling. ⚠️ `limit` is the one member of this block NO renderer reads on any route: the rail is capped by the FLAT `limit` beside this key, which is also the only one a bound `dataSource` lowers into. A `limit` written inside this block is accepted, defaulted to 100, and never read'), /** Base query filter — the family's one `ViewFilterRule` array orthography (#15449). */ filter: z.array(ViewFilterRuleSchema, { error: ruleArrayFilterError({ diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 1cedd7c2150..939c93d4694 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1217,9 +1217,21 @@ type RowLimitView = keyof typeof ROW_LIMIT_SUBJECT; * replaces — the author needs to know the cap is visible, and the renderer * author needs to know it is owed. * - * ⚠️ WHAT THIS KEY REACHES TODAY — recorded, not repaired (#19228). Measured - * first-hand at the pin this repo builds against (`.objectui-sha` = - * `87af769e9`), 2026-09-21T08:05Z, with TWO instruments, because one was not + * ⚠️ WHICH FACE THIS KEY IS ON, and why that has to be said first. There are + * TWO `limit`s a reader can confuse, on two different documents, and three + * rounds of #19228 went wrong on the boundary: + * · **VIEW FACE** — THIS key. A member of a `ListViewSchema` document's + * `kanban` / `gallery` / `timeline` block. An ADAPTER turns that document + * into a rendered node; no renderer reads this document directly. + * · **ELEMENT FACE** — a page component node's OWN `limit` + * (`ObjectKanbanPropsSchema`, `ObjectTimelinePropsSchema`), + * declared in `component.zod.ts`, with no applied default. That is the key + * every renderer and `ElementDataSourceGate` actually read. + * Every sentence below names its face before it says anything else. + * + * ⚠️ WHAT THIS VIEW-FACE KEY REACHES TODAY — recorded, not repaired (#19228). + * Measured first-hand at the pin this repo builds against (`.objectui-sha` = + * `87af769e9`), 2026-09-21T09:15Z, with TWO instruments, because one was not * enough and the first one's answer was wrong: * * 1. PROPERTY-ACCESS spellings — `.kanban.limit` / `.gallery.limit` / @@ -1227,37 +1239,48 @@ type RowLimitView = keyof typeof ROW_LIMIT_SUBJECT; * identical shape, `.kanban.groupByField` / `.gallery.coverField` / * `.timeline.scale`: **13** lines. A live instrument, and a WRONG answer. * 2. ⭐ SPREADS — a spread carries a key without ever spelling it, so it is - * the hole instrument 1 cannot see by construction. Lit control: - * `...mergedTimeline`, 1 line. It returns FOUR, and they overturn the zero: + * the hole instrument 1 cannot see by construction. ⛔ Re-take it by its + * PREDICATE, not by its count: **a spread whose target is the object + * literal an adapter RETURNS as the node** — flattening onto the node — + * as against a merge that builds a nested config (`...mergedTimeline` is + * the lit control for the instrument AND the example of what the predicate + * excludes). A grep broad enough to find these also returns the nested + * merges, so the rule, not the number, is what makes it reproducible. + * Under that predicate, at that pin, the VIEW-face per-kind blocks give: * `plugin-list/src/ListView.tsx:2979` `...restKanban` * `plugin-view/src/ObjectView.tsx:1638` `...restKanban` * `plugin-view/src/ObjectView.tsx:1697` `...(viewOptions.gallery || {})` * `plugin-view/src/ObjectView.tsx:1725` `...(viewOptions.timeline || {})` * Neither `restKanban` destructure strips `limit` (`ListView.tsx:2952`, - * `ObjectView.tsx:1579`), so a view's per-kind `limit` — INCLUDING the 100 - * this applied default materializes — lands on the generated node's FLAT - * `limit`, which is the key the renderers read. - * - * ⇒ **kanban and timeline: the key LANDS and IS READ.** - * `ObjectKanban.tsx:553` / `ObjectTimeline.tsx:279` run - * `describeRefusedRowLimit(schema.limit, …)` unconditionally. - * ⚠️ The `$top` it would govern (`ObjectKanban.tsx:676`, - * `ObjectTimeline.tsx:407`) is not issued on either adapter route today: - * both hosts hand rows down as a React `data` prop (`ListView.tsx:4702`, - * `ObjectView.tsx:2319`) and both children short-circuit their own fetch on - * it (`ObjectKanban.tsx:559`, `ObjectTimeline.tsx:420`). So it governs no - * query ON THOSE ROUTES — ⛔ which is not the same claim as 「reaches no - * consumer」, and the difference is the whole correction. - * ⇒ **gallery, and gallery alone: carried flat and read by NOBODY.** - * `ObjectView.tsx:1697` delivers it; `ObjectGallery.tsx` contains no `limit` - * at all (0 occurrences, case-insensitive, against a lit control - * `schema.imageField` / `schema.titleField` at `:340` / `:348`). ⛔ Do not - * generalise that asymmetry to the other two — it is gallery's alone. + * `ObjectView.tsx:1579`), so a VIEW's per-kind `limit` — INCLUDING the 100 + * this applied default materializes — becomes the generated node's + * ELEMENT-face flat `limit`, which is the key the renderers read. + * + * ⇒ **A view's `kanban.limit`: flattened on BOTH adapter routes, and read.** + * `ObjectKanban.tsx:553` runs `describeRefusedRowLimit(schema.limit, …)` + * unconditionally. + * ⇒ **A view's `timeline.limit`: ROUTE-DEPENDENT.** `plugin-view` flattens it + * (`ObjectView.tsx:1725`) and the node it returns carries no `timeline` + * block at all, so the value arrives as the node's flat `limit` and + * `ObjectTimeline.tsx:279` reads it. `plugin-list` instead forwards the + * block NESTED (`ListView.tsx:3084`), where nothing reads it. + * ⇒ **A view's `gallery.limit`: flattened by `ObjectView.tsx:1697` and read by + * NOBODY** — `ObjectGallery.tsx` contains no `limit` at all (0 occurrences, + * case-insensitive, against a lit control `schema.imageField` / + * `schema.titleField` at `:340` / `:348`). ⛔ Do not generalise that + * asymmetry to the other two; it is gallery's alone. + * + * ⚠️ Where it IS read, the `$top` it would govern (`ObjectKanban.tsx:676`, + * `ObjectTimeline.tsx:407`) is still not issued on either adapter route today: + * both hosts hand rows down as a React `data` prop (`ListView.tsx:4702`, + * `ObjectView.tsx:2319`) and both children short-circuit their own fetch on it + * (`ObjectKanban.tsx:559`, `ObjectTimeline.tsx:420`). ⛔ That is a statement + * about the QUERY, not about the key being unread. * * ⚠️ A consequence of APPLIED that the open decision needs: through those - * spreads a spec-parsed view emits a node carrying an authored-LOOKING flat - * `limit: 100` that no author wrote. ⛔ Flagged, not acted on — changing it is - * a contract direction, not a tidy-up. + * spreads a spec-parsed view emits a node carrying an authored-LOOKING + * ELEMENT-face `limit: 100` that no author wrote. ⛔ Flagged, not acted on — + * changing it is a contract direction, not a tidy-up. * * ⛔ Which of the row bounds wins is NOT decided here and NOT implied by this * declaration: #19228 opens that question and picks nothing, and neither does From 01a7ce466ef335d31f9d15daf86073e42d8e2627 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 10:28:20 +0000 Subject: [PATCH 7/8] =?UTF-8?q?fix(spec):=20the=20repaired=20describe=20na?= =?UTF-8?q?med=20a=20third=20face=20and=20an=20unreachable=20arm=20?= =?UTF-8?q?=E2=80=94=20both=20removed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 4 leads, each verified first-hand before a line moved. Both halves of lead 1 reproduce; the repair shipped in round 1 was wrong in its own way. 1. `ListViewSchema` REFUSES a flat `limit`. Measured on this tree with four lit controls: the minimal view document parses; + `pagination.pageSize: 50` parses; + per-kind `kanban.limit: 50` parses; + flat `limit: 50` is REFUSED with unrecognized_keys: ["limit"], the same verdict a bogus key gets. Only three `limit` members exist on any view document and all three are rowLimitKey(...) per-kind blocks; no retiredKey() tombstone for a flat one. So `savedViewLimit`'s fallback to `view.limit` names a THIRD face -- a saved-view RECORD from the adapter's listViews() -- and stating it on the author face with no qualifier is the same face-merge this card has failed on three times. Dropped from the describe, recorded in the docblock. 2. The displaced-and-reported arm is unreachable from inside the accept set. isUsableRowLimit is `typeof v === 'number' && Number.isInteger(v) && v > 0` -- the same set this key declares as z.number().int().positive(). Measured per value: 1/25/100/5000 accepted and usable; 0/-1/2.5/'100'/null refused by both; unset accepted and not usable. So across the whole accept set "set but not usable" is EMPTY and the guard is exactly "only when unset" -- the wording round 1 retired as too narrow. Restored, with the reason. The prose pin that guarded the retired sentence is replaced by the structural fact underneath it: the schema's accept set and isUsableRowLimit coincide, so the sentence is derivable rather than pinned, and either side widening reds it. Lead 3: the property-access control is now published as its EXPRESSION plus every hit -- 13 lines across 6 files -- with the filter that yields 8 stated beside it (3 comments, 2 inside a quoted source-text pin; one hit set, three readings). The spread predicate now also names what it EXCLUDES: app-shell timelineViewOptions:206 and galleryViewOptions:342, options-bag builders feeding ListView's nested forward. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx --- content/docs/references/ui/component.mdx | 2 +- packages/spec/src/ui/component.test.ts | 44 ++++++++++++++++------- packages/spec/src/ui/component.zod.ts | 45 ++++++++++++++++-------- packages/spec/src/ui/view.zod.ts | 29 ++++++++++++--- 4 files changed, 88 insertions(+), 32 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 095679df312..92d30aa5822 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -617,7 +617,7 @@ Sort field and direction pair | **groupBy** | `string` | optional | Field whose values become the board columns | | **columns** | `any[]` | optional | Swimlane definitions (`{ id, title }` per `groupBy` value, or bare value strings) — NOT a field projection | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter, handed to the wire `$filter` — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | -| **limit** | `integer` | optional | Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's row cap (`pagination.pageSize`, else that view's flat `limit`) fills this key unless it already carries a USABLE cap — a cap the contract refuses (zero, negative, fractional) is displaced by the view's and reported, not honoured | +| **limit** | `integer` | optional | Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills this key only when it is unset — and on this face unset is the whole rule, because every cap this key accepts is one the binding gate already treats as authored | | **data** | `any[]` | optional | Static inline cards — bypasses the object query | | **cardTitle** | `string` | optional | Field rendered as each card title | | **titleField** | `string` | optional | Legacy fallback for `cardTitle` (the board reads `cardTitle \|\| titleField`). Prefer `cardTitle` | diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 6043169c63f..86819ff02e7 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -3857,17 +3857,37 @@ describe('row caps on the object-bound blocks — what #19228 recorded', () => { expect(JSON.stringify(control.error?.issues)).toContain('unrecognized_keys'); }); - it('states the gate guard the gate actually implements, not the narrower 「unset」 arm', () => { - // ⛔ The one prose assertion here, and it is negative on purpose: this - // card's whole repair IS the published sentence, so without a pin the - // change has no falsifier. The retired wording claimed the view's cap - // lands ONLY on an unset key; measured, it also lands on a key set to a - // cap the contract refuses, with `describeDisplacedRowLimit` reporting it. - const shape = (ObjectKanbanPropsSchema as unknown as { - def: { shape: Record }; - }).def.shape; - const description = shape.limit?.description ?? ''; - expect(description).not.toContain('only when unset'); - expect(description).toMatch(/usable cap/i); + it('admits exactly the caps the binding gate calls usable — which is WHY 「unset」 is the whole rule', () => { + // ⛔ Not a prose pin. The published sentence says a bound view's + // `pagination.pageSize` fills this key only when it is UNSET, and this is + // the structural fact that makes that true rather than narrow: + // `ElementDataSourceGate`'s guard is `!isUsableRowLimit(authored)` with + // `isUsableRowLimit = typeof v === 'number' && Number.isInteger(v) && v > 0` + // — the same set this key declares. So across the whole accept set the + // guard has exactly two outcomes, and 「set but not usable」 is empty. + // If either side ever widens (a `.nullable()`, a `0` sentinel, a float), + // this reds and the sentence has to be rewritten with it. + const usableToTheGate = (v: unknown): boolean => + typeof v === 'number' && Number.isInteger(v) && v > 0; + + // ACCEPTED by the schema ⇒ usable to the gate ⇒ the view's cap does NOT land. + for (const cap of [1, 25, 100, 5000]) { + const r = kanban.safeParse({ objectName: 'x', limit: cap }); + expect(r.success, `accept ${cap}`).toBe(true); + expect(usableToTheGate((r.success ? r.data : {} as never).limit), `usable ${cap}`).toBe(true); + } + + // REFUSED by the schema ⇒ never reaches the gate from a valid document, + // which is why the displaced-and-reported arm is not in the describe. + for (const cap of [0, -1, 2.5, '100', null]) { + expect(kanban.safeParse({ objectName: 'x', limit: cap }).success, `refuse ${JSON.stringify(cap)}`).toBe(false); + expect(usableToTheGate(cap), `gate also rejects ${JSON.stringify(cap)}`).toBe(false); + } + + // UNSET — accepted, and the one state the gate treats as unauthored. + const unset = kanban.safeParse({ objectName: 'x' }); + expect(unset.success).toBe(true); + expect(Object.prototype.hasOwnProperty.call(unset.success ? unset.data : {}, 'limit')).toBe(false); + expect(usableToTheGate(undefined)).toBe(false); }); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index e820271a323..8c8ceb59956 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3092,24 +3092,39 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ * Why the carrier is `limit` and not the bound view's `pagination.pageSize` * (the alternative the card opened): precedence is the `ElementDataSourceGate` * table, not this key's. The component-level `dataSource.limit` overrides - * this key unconditionally; a bound named view's row cap is LOWERED INTO it - * through the `limit: 'limit'` mapping only when this key does not already - * carry a USABLE cap (`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`, + * this key unconditionally; a bound named view's `pagination.pageSize` is + * LOWERED INTO it through the `limit: 'limit'` mapping only when this key is + * unset (`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`, * `readLimit`/`writeLimit` keyed by `ElementDataSourceLimitKey`; the branch * gained objectui#9899's presence-is-not-authorship test and a * `describeDisplacedRowLimit` report on this hop). * - * ⚠️ 「only when UNSET」 is what this docblock and the describe beside it - * used to say, and it is narrower than the guard — re-READ first-hand at the - * pin `87af769e9` on 2026-09-21T06:35Z. The branch is - * `if (!fromView || !isUsableRowLimit(authored))`, so the view's cap also - * lands when this key IS set to a value the contract refuses (`0`, negative, - * fractional, non-number), with `describeDisplacedRowLimit` telling the - * author. Unset is one arm of that guard, not the whole of it. The view half - * is likewise not `pagination.pageSize` alone: `savedViewLimit` reads - * `pagination.pageSize`, else that view's FLAT `limit` - * (`core/src/data-scope/element-data-source.ts:237-241`); the per-kind - * `kanban.limit` #19226 declared takes neither of those two doors. + * ⚠️ 「only when UNSET」 reads narrower than the guard and is nonetheless + * EXACTLY right on this face — a correction to a correction, measured + * 2026-09-21T10:20Z. The branch is + * `if (!fromView || !isUsableRowLimit(authored))`, and + * `isUsableRowLimit` is `typeof v === 'number' && Number.isInteger(v) && v > 0` + * (`ElementDataSourceGate.tsx:192-194`) — the SAME set this key declares, + * `z.number().int().positive()`. So for every node this schema ACCEPTS, + * `authored` is either absent (not usable ⇒ the view's cap lands) or a + * positive integer (usable ⇒ it does not): unset is the only reachable arm. + * The extra arm — a cap displaced and reported because it is `0`, negative + * or fractional — is reachable ONLY for a node this contract refuses, so + * ⛔ it does not belong in an author-facing describe. Pinned structurally + * beside the parse pins in `component.test.ts` rather than as prose. + * + * ⚠️ And the view half is `pagination.pageSize` ALONE on this face. + * `savedViewLimit` does fall back to a flat `view.limit` + * (`core/src/data-scope/element-data-source.ts:237-241`), but that names a + * THIRD face — a saved-view RECORD as the adapter's `listViews()` returns it + * — not an authored view document. Measured on this tree: `ListViewSchema` + * REFUSES a flat `limit` with `unrecognized_keys: ["limit"]`, the same + * verdict a bogus key gets, while the same minimal document parses with + * `pagination.pageSize: 50` and with a per-kind `kanban.limit: 50`. There is + * no flat `limit` member on any view document and no `retiredKey()` + * tombstone for one. ⛔ So naming that arm here would put a runtime-record + * shape on the author face with no qualifier — the face-merge this card has + * now failed on three times. * ⛔ This note reports the guard; it picks no precedence. * * ⚠️ It takes a THIRD door, and missing it is what the at-tier review of @@ -3146,7 +3161,7 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ * a schema default would materialize `limit: 100` on every parsed board. */ limit: z.number().int().positive().optional() - .describe("Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's row cap (`pagination.pageSize`, else that view's flat `limit`) fills this key unless it already carries a USABLE cap — a cap the contract refuses (zero, negative, fractional) is displaced by the view's and reported, not honoured"), + .describe("Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills this key only when it is unset — and on this face unset is the whole rule, because every cap this key accepts is one the binding gate already treats as authored"), data: z.array(z.unknown()).optional().describe('Static inline cards — bypasses the object query'), cardTitle: z.string().optional().describe('Field rendered as each card title'), titleField: z.string().optional().describe('Legacy fallback for `cardTitle` (the board reads `cardTitle || titleField`). Prefer `cardTitle`'), diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 939c93d4694..b682a165da7 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1234,10 +1234,24 @@ type RowLimitView = keyof typeof ROW_LIMIT_SUBJECT; * `87af769e9`), 2026-09-21T09:15Z, with TWO instruments, because one was not * enough and the first one's answer was wrong: * - * 1. PROPERTY-ACCESS spellings — `.kanban.limit` / `.gallery.limit` / - * `.timeline.limit` and the receiver alternation: **0** hits. Lit control, - * identical shape, `.kanban.groupByField` / `.gallery.coverField` / - * `.timeline.scale`: **13** lines. A live instrument, and a WRONG answer. + * 1. PROPERTY-ACCESS spellings. ⛔ Published as its EXPRESSION, not as a + * number — this card exists because a confident count was wrong once, so + * a control nobody can re-derive is not a control. Run at the pin, from + * an objectui checkout, over every tracked file: + * probe: git grep -nIE '\.(kanban|gallery|timeline)(\?)?\.limit\b' + * control: git grep -nIE '\.(kanban|gallery|timeline)(\?)?\.(groupByField|scale|coverField)\b' + * Probe: **0** lines, 0 files. Control: **13** lines across **6** files — + * `app-shell/src/views/ObjectView.galleryBinding-7547.test.tsx:41`, + * `app-shell/src/views/ObjectView.tsx:450`, + * `plugin-list/src/ListView.tsx:2538`, `:2540`, `:2547`, `:3057`, `:3114`, + * `:3116`, + * `plugin-list/src/__tests__/ListView.kanbanOptionsBagCanonical-8193.test.tsx:42`, + * `:99`, `plugin-view/src/ObjectView.tsx:1695`, and + * `types/src/__tests__/object-kanban-group-by-limit-7322.test.ts:146`, `:148`. + * ⚠️ Filtering changes that number and the filter must be stated with it: + * 3 of the 13 are in COMMENTS and 2 more sit inside a quoted source-text + * pin, so a reader counting executable reads only gets **8**. All three + * readings are of one hit set. A live instrument — and a WRONG answer. * 2. ⭐ SPREADS — a spread carries a key without ever spelling it, so it is * the hole instrument 1 cannot see by construction. ⛔ Re-take it by its * PREDICATE, not by its count: **a spread whose target is the object @@ -1246,6 +1260,13 @@ type RowLimitView = keyof typeof ROW_LIMIT_SUBJECT; * the lit control for the instrument AND the example of what the predicate * excludes). A grep broad enough to find these also returns the nested * merges, so the rule, not the number, is what makes it reproducible. + * ⛔ And name what the predicate EXCLUDES, or the next reader re-finds + * it and wonders: `app-shell/src/views/ObjectView.tsx:206` and `:342` + * ARE spreads of a view block, inside `timelineViewOptions` (`:201`) and + * `galleryViewOptions` (`:334`). They build an OPTIONS BAG that feeds + * `ListView`'s nested forward, not the object literal an adapter returns + * as the node, so the predicate excludes them — deliberately, not by + * oversight. * Under that predicate, at that pin, the VIEW-face per-kind blocks give: * `plugin-list/src/ListView.tsx:2979` `...restKanban` * `plugin-view/src/ObjectView.tsx:1638` `...restKanban` From b922105e1629344f85c6f03519b9ff8bb64cbe86 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 11:09:10 +0000 Subject: [PATCH 8/8] =?UTF-8?q?fix(spec):=20the=20changeset=20still=20asse?= =?UTF-8?q?rted=20what=20round=204=20retracted=20=E2=80=94=20and=20three?= =?UTF-8?q?=20wordings=20overstated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 4 touched four files and not the changeset, so the tree carried both the retracted claim and the corrected one at once. The changeset is the published CHANGELOG entry for @objectstack/spec, an author-facing surface, which makes that round 2's lesson repeated inside the tree instead of in the PR body. 1. Changeset 'What the published text said' rewritten to the round-4 claim: 'only when unset' is exactly right for this face; the accept set is a SUBSET of isUsableRowLimit so 'set but not usable' is empty; the view half is pagination.pageSize ALONE, with savedViewLimit's flat view.limit named as the saved-view RECORD face it belongs to. Grade unchanged at patch -- this is text, not level. 2. 'the SAME set this key declares' overstated and is now the derivable claim, accept-set SUBSET-OF usable-set, with the separating witness: 2**53+2 is refused here (zod 4 .int() is safe-integer, too_big) and Number.isInteger calls it usable. Verified by parse, not asserted. The test title moves the same way and the witness is pinned as a case. 3. The test comment claimed 'either side widening reds this'. Half wrong, and now stated asymmetrically: the SPEC side reds (nullable, a 0 sentinel, dropping .int(), adding a .default() each fail a specific expect), but the GATE side CANNOT -- usableToTheGate is a transcription of isUsableRowLimit dated to pin 87af769e9, not an import, so a rewrite at objectui HEAD leaves this green. It refreshes only on a pin bump, by hand. 4. The instrument partition was off by one label: ListView.kanbanOptionsBag Canonical-8193.test.tsx:99 is an it() title string, not a comment. Now 2 comments, 1 test-title string, 2 quoted-pin lines -> 8 executable of 13. The spread predicate's exclusion list also gains ListView.tsx:3044-3046 (mergedGallery, nested) and app-shell ObjectView.tsx:1284 (a write back into a view document's own block, not a node build). No describe changed, so no .mdx regenerates; check:generated is green without --fix, which is the prediction this round was able to state in advance. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx --- .../19228-view-row-limit-route-record.md | 35 ++++++++++++------- packages/spec/src/ui/component.test.ts | 31 ++++++++++++---- packages/spec/src/ui/component.zod.ts | 12 ++++--- packages/spec/src/ui/view.zod.ts | 19 +++++++--- 4 files changed, 70 insertions(+), 27 deletions(-) diff --git a/.changeset/19228-view-row-limit-route-record.md b/.changeset/19228-view-row-limit-route-record.md index 058853271d9..4693c232b48 100644 --- a/.changeset/19228-view-row-limit-route-record.md +++ b/.changeset/19228-view-row-limit-route-record.md @@ -9,18 +9,29 @@ to the same values before and after. ⛔ No `.default()` moves, ⛔ no precedenc of the per-kind view `limit`, a view's `pagination.pageSize` and a component's flat `limit` should win is the open half of #19228 and is not answered here. -## What the published text said, and what the consumer does - -`ObjectKanbanPropsSchema.limit` told authors that a bound view's `pagination.pageSize` -「fills it only when unset」. Measured first-hand at the objectui pin this repo builds against -(`.objectui-sha` = `87af769e9`), the branch is `if (!fromView || !isUsableRowLimit(authored))` -(`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`): the view's cap ALSO lands -when the key is set to a cap the contract refuses — zero, negative or fractional — with -`describeDisplacedRowLimit` telling the author. Unset is one arm of that guard, not the whole of -it. The view half is likewise not `pagination.pageSize` alone: `savedViewLimit` reads -`pagination.pageSize`, else that view's flat `limit` -(`core/src/data-scope/element-data-source.ts:237-241`). The describe now states the guard as -implemented. +## What the published text said, and what an author can actually reach + +`ObjectKanbanPropsSchema.limit` tells authors that a bound view's `pagination.pageSize` fills it +「only when unset」. Measured first-hand at the objectui pin this repo builds against +(`.objectui-sha` = `87af769e9`), that sentence is exactly right for this face, and the describe +now says WHY rather than leaving it to look narrower than the mechanism. + +The gate's branch is `if (!fromView || !isUsableRowLimit(authored))` +(`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`), and `isUsableRowLimit` is +`typeof v === 'number' && Number.isInteger(v) && v > 0` (`:192-194`). Every cap this key ACCEPTS +is one that predicate already calls usable — the accept set is a subset of the usable set — so +across the whole accept set the guard has exactly two outcomes and 「set but not usable」 is +empty. The extra arm, a cap displaced and reported because it is zero, negative or fractional, +is reachable only for a node this contract refuses, so it is recorded in the docblock rather +than in an author-facing sentence. + +The view half is `pagination.pageSize` ALONE on this face. `savedViewLimit` does fall back to a +flat `view.limit` (`core/src/data-scope/element-data-source.ts:237-241`), but that names a +saved-view RECORD as the adapter's `listViews()` returns it — a third face, not an authored view +document. Measured on this tree: `ListViewSchema` REFUSES a flat `limit` with +`unrecognized_keys: ["limit"]`, the verdict a bogus key gets, while the same minimal document +parses with `pagination.pageSize: 50` and with a per-kind `kanban.limit: 50`. No view document +declares a flat `limit` and none carries a tombstone for one. ## Where the per-kind VIEW `limit` lands diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 86819ff02e7..5279794ce70 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -3857,16 +3857,26 @@ describe('row caps on the object-bound blocks — what #19228 recorded', () => { expect(JSON.stringify(control.error?.issues)).toContain('unrecognized_keys'); }); - it('admits exactly the caps the binding gate calls usable — which is WHY 「unset」 is the whole rule', () => { + it('admits only caps the binding gate calls usable — the SUBSET that makes 「unset」 the whole rule', () => { // ⛔ Not a prose pin. The published sentence says a bound view's // `pagination.pageSize` fills this key only when it is UNSET, and this is // the structural fact that makes that true rather than narrow: // `ElementDataSourceGate`'s guard is `!isUsableRowLimit(authored)` with - // `isUsableRowLimit = typeof v === 'number' && Number.isInteger(v) && v > 0` - // — the same set this key declares. So across the whole accept set the - // guard has exactly two outcomes, and 「set but not usable」 is empty. - // If either side ever widens (a `.nullable()`, a `0` sentinel, a float), - // this reds and the sentence has to be rewritten with it. + // `isUsableRowLimit = typeof v === 'number' && Number.isInteger(v) && v > 0`. + // This key's accept set is a SUBSET of that predicate — ⛔ NOT the same + // set; `2 ** 53 + 2` separates them, and the case below pins it. Subset is + // the direction the sentence needs: it makes 「set but not usable」 empty + // across the whole accept set, so the guard has exactly two outcomes. + // + // ⚠️ What this pin can and cannot catch, because the two sides are not + // symmetric here: + // · SPEC side — reds. A `.nullable()`, a `0` sentinel, dropping `.int()` + // or adding a `.default()` each fail a specific expect below. + // · GATE side — ⛔ CANNOT red. `usableToTheGate` is a TRANSCRIPTION of + // `isUsableRowLimit` as it read at objectui pin `87af769e9`, not an + // import — nothing here resolves into objectui. A rewrite of that + // predicate at objectui HEAD leaves this test green. It is re-read on + // a PIN BUMP, by hand, and that is the only thing that refreshes it. const usableToTheGate = (v: unknown): boolean => typeof v === 'number' && Number.isInteger(v) && v > 0; @@ -3884,6 +3894,15 @@ describe('row caps on the object-bound blocks — what #19228 recorded', () => { expect(usableToTheGate(cap), `gate also rejects ${JSON.stringify(cap)}`).toBe(false); } + // ⛔ The sets are NOT equal, and this is the witness. `2 ** 53 + 2` is + // refused here (zod 4's `.int()` enforces SAFE integers, `too_big`) while + // `Number.isInteger` calls it usable. Subset, not coincidence — if this + // case ever flips, the docblock sentence built on the subset direction + // has to be re-derived rather than reworded. + const beyondSafe = 2 ** 53 + 2; + expect(kanban.safeParse({ objectName: 'x', limit: beyondSafe }).success).toBe(false); + expect(usableToTheGate(beyondSafe)).toBe(true); + // UNSET — accepted, and the one state the gate treats as unauthored. const unset = kanban.safeParse({ objectName: 'x' }); expect(unset.success).toBe(true); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 8c8ceb59956..ac648bd4b6b 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3104,10 +3104,14 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ * 2026-09-21T10:20Z. The branch is * `if (!fromView || !isUsableRowLimit(authored))`, and * `isUsableRowLimit` is `typeof v === 'number' && Number.isInteger(v) && v > 0` - * (`ElementDataSourceGate.tsx:192-194`) — the SAME set this key declares, - * `z.number().int().positive()`. So for every node this schema ACCEPTS, - * `authored` is either absent (not usable ⇒ the view's cap lands) or a - * positive integer (usable ⇒ it does not): unset is the only reachable arm. + * (`ElementDataSourceGate.tsx:192-194`), and this key's accept set + * (`z.number().int().positive()`) is a SUBSET of it — ⛔ not the same set, + * and the difference is reachable: `2^53 + 2` is refused here (zod 4's + * `.int()` is safe-integer, `too_big`) and `Number.isInteger` calls it + * usable. Subset is the direction that matters, and it is the whole + * argument: for every node this schema ACCEPTS, `authored` is either absent + * (not usable ⇒ the view's cap lands) or a cap the gate already treats as + * authored (⇒ it does not). So unset is the only reachable arm. * The extra arm — a cap displaced and reported because it is `0`, negative * or fractional — is reachable ONLY for a node this contract refuses, so * ⛔ it does not belong in an author-facing describe. Pinned structurally diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index b682a165da7..860d3ac1e6d 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1248,10 +1248,14 @@ type RowLimitView = keyof typeof ROW_LIMIT_SUBJECT; * `plugin-list/src/__tests__/ListView.kanbanOptionsBagCanonical-8193.test.tsx:42`, * `:99`, `plugin-view/src/ObjectView.tsx:1695`, and * `types/src/__tests__/object-kanban-group-by-limit-7322.test.ts:146`, `:148`. - * ⚠️ Filtering changes that number and the filter must be stated with it: - * 3 of the 13 are in COMMENTS and 2 more sit inside a quoted source-text - * pin, so a reader counting executable reads only gets **8**. All three - * readings are of one hit set. A live instrument — and a WRONG answer. + * ⚠️ Filtering changes that number and the filter must be stated with it. + * Of the 13: **2 are COMMENTS** (`ObjectView.galleryBinding-7547.test.tsx:41`, + * `ListView.kanbanOptionsBagCanonical-8193.test.tsx:42`), **1 is an + * `it()` TITLE string** (same file, `:99` — ⛔ not a comment), and **2 are + * lines inside a QUOTED source-text pin** + * (`object-kanban-group-by-limit-7322.test.ts:146`, `:148`). So a reader + * counting executable reads only gets **8**. All three readings are of one + * hit set. A live instrument — and a WRONG answer. * 2. ⭐ SPREADS — a spread carries a key without ever spelling it, so it is * the hole instrument 1 cannot see by construction. ⛔ Re-take it by its * PREDICATE, not by its count: **a spread whose target is the object @@ -1266,7 +1270,12 @@ type RowLimitView = keyof typeof ROW_LIMIT_SUBJECT; * `galleryViewOptions` (`:334`). They build an OPTIONS BAG that feeds * `ListView`'s nested forward, not the object literal an adapter returns * as the node, so the predicate excludes them — deliberately, not by - * oversight. + * oversight. Two more the predicate excludes for their own reasons: + * `plugin-list/src/ListView.tsx:3044-3046` (`mergedGallery`) builds a + * NESTED gallery prop, the `...mergedTimeline` family; and + * `app-shell/src/views/ObjectView.tsx:1284` + * (`spec.kanban = { ...(spec.kanban || {}), columns }`) writes back into a + * VIEW document's own block — a metadata write, not a node build. * Under that predicate, at that pin, the VIEW-face per-kind blocks give: * `plugin-list/src/ListView.tsx:2979` `...restKanban` * `plugin-view/src/ObjectView.tsx:1638` `...restKanban`