Skip to content

Commit 0fc945f

Browse files
committed
docs(spec): record the per-kind view limit as read by nobody, and state the gate guard the gate implements
#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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
1 parent ef256e6 commit 0fc945f

3 files changed

Lines changed: 134 additions & 9 deletions

File tree

‎packages/spec/src/ui/component.test.ts‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ import {
2424
ObjectKanbanPropsSchema,
2525
} from './component.zod';
2626
import { PageComponentSchema, PageSchema, PageComponentType, ElementDataSourceSchema, RETIRED_PAGE_COMPONENT_TYPES } from './page.zod';
27-
import { GanttConfigSchema, TreeConfigSchema, ListMapConfigSchema, ListColumnSchema, ListViewSchema } from './view.zod';
27+
import {
28+
GanttConfigSchema, TreeConfigSchema, ListMapConfigSchema, ListColumnSchema, ListViewSchema,
29+
TimelineConfigSchema, DEFAULT_VIEW_ROW_LIMIT,
30+
} from './view.zod';
2831
import { FieldSchema } from '../data/field.zod';
2932
import { ALL_CONVERSIONS } from '../conversions/registry';
3033
import { strictObjectDeclarations } from '../shared/strict-object';
@@ -3799,3 +3802,68 @@ describe('the three #18305 object blocks — key sets derived from the renderers
37993802
expect((ComponentPropsMap as Record<string, unknown>)['object-chart']).toBeUndefined();
38003803
});
38013804
});
3805+
3806+
// #19228 — two authorable row bounds land on one `object-timeline` node, and
3807+
// the react tier's own precedence sentence was narrower than the guard it
3808+
// names. ⛔ This card picks NO precedence and changes no `.default()`; these
3809+
// pins only hold the two structural facts the repair rests on, measured
3810+
// first-hand at the objectui pin `87af769e9` on 2026-09-21T06:30-06:40Z.
3811+
describe('row caps on the object-bound blocks — what #19228 recorded', () => {
3812+
const timeline = ComponentPropsMap['object-timeline'];
3813+
const kanban = ComponentPropsMap['object-kanban'];
3814+
3815+
it('leaves the ELEMENT-face `limit` undefaulted — the fact that keeps the gate arm alive', () => {
3816+
// `ElementDataSourceGate` lowers a bound view's cap into this key only
3817+
// when it does not already carry a USABLE one
3818+
// (`ElementDataSourceGate.tsx:316-331`, `!fromView || !isUsableRowLimit`).
3819+
// An applied default here would make every parsed node carry a usable cap
3820+
// and kill that arm outright — the failure #19228 feared, on the schema it
3821+
// would actually happen to. ⛔ Do not "fix" a red here by deleting the pin.
3822+
for (const [label, schema] of [['object-kanban', kanban], ['object-timeline', timeline]] as const) {
3823+
const parsed = schema.parse({ objectName: 'task' }) as Record<string, unknown>;
3824+
expect(Object.prototype.hasOwnProperty.call(parsed, 'limit'), label).toBe(false);
3825+
}
3826+
3827+
// LIT CONTROL, same instrument (a Zod applied default, observed through
3828+
// `parse`): the VIEW-face sibling DOES materialize one, so the zeros above
3829+
// are a reading rather than a parse that never ran.
3830+
const viewSide = TimelineConfigSchema.parse({ startDateField: 'start_date', titleField: 'name' }) as { limit?: number };
3831+
expect(viewSide.limit).toBe(DEFAULT_VIEW_ROW_LIMIT);
3832+
});
3833+
3834+
it('materializes the NESTED `timeline.limit` on a node whose flat `limit` stays absent', () => {
3835+
// The shape the record is about: one strictObject, two authorable row
3836+
// caps. At the pin, `ListView.tsx:3084` forwards this block nested and
3837+
// does NOT hoist `limit` to a flat prop, and `ObjectTimeline.tsx:407`
3838+
// queries off the flat key alone — so the 100 below reaches no query.
3839+
const result = timeline.safeParse({
3840+
objectName: 'task',
3841+
timeline: { startDateField: 'start_date', titleField: 'name' },
3842+
});
3843+
expect(result.success).toBe(true);
3844+
const data = (result.success ? result.data : undefined) as
3845+
{ limit?: unknown; timeline?: { limit?: unknown } } | undefined;
3846+
expect(data?.timeline?.limit).toBe(DEFAULT_VIEW_ROW_LIMIT);
3847+
expect(Object.prototype.hasOwnProperty.call(data ?? {}, 'limit')).toBe(false);
3848+
3849+
// CONTROL — the node is still strict, so the acceptance above is not the
3850+
// verdict of a map that has stopped refusing anything.
3851+
const control = timeline.safeParse({ objectName: 'task', zzUnlikelyBogusKey__: 1 });
3852+
expect(control.success).toBe(false);
3853+
expect(JSON.stringify(control.error?.issues)).toContain('unrecognized_keys');
3854+
});
3855+
3856+
it('states the gate guard the gate actually implements, not the narrower 「unset」 arm', () => {
3857+
// ⛔ The one prose assertion here, and it is negative on purpose: this
3858+
// card's whole repair IS the published sentence, so without a pin the
3859+
// change has no falsifier. The retired wording claimed the view's cap
3860+
// lands ONLY on an unset key; measured, it also lands on a key set to a
3861+
// cap the contract refuses, with `describeDisplacedRowLimit` reporting it.
3862+
const shape = (ObjectKanbanPropsSchema as unknown as {
3863+
def: { shape: Record<string, { description?: string }> };
3864+
}).def.shape;
3865+
const description = shape.limit?.description ?? '';
3866+
expect(description).not.toContain('only when unset');
3867+
expect(description).toContain('usable');
3868+
});
3869+
});

‎packages/spec/src/ui/component.zod.ts‎

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3092,12 +3092,34 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({
30923092
* Why the carrier is `limit` and not the bound view's `pagination.pageSize`
30933093
* (the alternative the card opened): precedence is the `ElementDataSourceGate`
30943094
* table, not this key's. The component-level `dataSource.limit` overrides
3095-
* this key, and a bound named view's `pagination.pageSize` is LOWERED INTO
3096-
* it through the `limit: 'limit'` mapping only when the component authored
3097-
* none (`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`,
3095+
* this key unconditionally; a bound named view's row cap is LOWERED INTO it
3096+
* through the `limit: 'limit'` mapping only when this key does not already
3097+
* carry a USABLE cap (`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`,
30983098
* `readLimit`/`writeLimit` keyed by `ElementDataSourceLimitKey`; the branch
30993099
* gained objectui#9899's presence-is-not-authorship test and a
3100-
* `describeDisplacedRowLimit` report on this hop). The board
3100+
* `describeDisplacedRowLimit` report on this hop).
3101+
*
3102+
* ⚠️ 「only when UNSET」 is what this docblock and the describe beside it
3103+
* used to say, and it is narrower than the guard — re-READ first-hand at the
3104+
* pin `87af769e9` on 2026-09-21T06:35Z. The branch is
3105+
* `if (!fromView || !isUsableRowLimit(authored))`, so the view's cap also
3106+
* lands when this key IS set to a value the contract refuses (`0`, negative,
3107+
* fractional, non-number), with `describeDisplacedRowLimit` telling the
3108+
* author. Unset is one arm of that guard, not the whole of it. The view half
3109+
* is likewise not `pagination.pageSize` alone: `savedViewLimit` reads
3110+
* `pagination.pageSize`, else that view's FLAT `limit`
3111+
* (`core/src/data-scope/element-data-source.ts:237-241`); the per-kind
3112+
* `kanban.limit` / `gallery.limit` / `timeline.limit` #19226 declared is read
3113+
* by neither door (0 read points at this pin — see `rowLimitKey` in
3114+
* `view.zod.ts`). ⛔ This note reports the guard; it picks no precedence.
3115+
*
3116+
* ⭐ The arm therefore stays REACHABLE: its guard reads THIS key, which is
3117+
* `.optional()` with no applied default, so an author's silence is still
3118+
* silence at parse time. #19228 read the applied default on the VIEW-face
3119+
* per-kind `limit` as killing this arm; the two are different schemas and
3120+
* the view-face default never lands on this key. ⛔ Do not add a
3121+
* `.default()` here: that — and only that — is what would make it dead.
3122+
* The board
31013123
* has no `pagination` read point, so declaring that spelling here would name
31023124
* a key the renderer ignores — the accepted-and-dropped defect this section
31033125
* exists to remove. Same shape as the `element:record_picker` and
@@ -3106,7 +3128,7 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({
31063128
* a schema default would materialize `limit: 100` on every parsed board.
31073129
*/
31083130
limit: z.number().int().positive().optional()
3109-
.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"),
3131+
.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"),
31103132
data: z.array(z.unknown()).optional().describe('Static inline cards — bypasses the object query'),
31113133
cardTitle: z.string().optional().describe('Field rendered as each card title'),
31123134
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[] = [
39423964
* the canonical nested config every field resolution prefers), `filter`
39433965
* (`:210`, `:232` — verbatim to `$filter`), `sort` (`:211`, `:233` — through
39443966
* the shared `convertSortToQueryParams` sink, as `object-calendar`'s does),
3945-
* `limit` (`:234`, `:254` — the fetch's top-level `$top`, renderer default
3946-
* `DEFAULT_TIMELINE_LIMIT` = 100 at `:28`), `items` (`:170`, `:247`, `:299`,
3967+
* `limit` (⭐ re-READ at the CURRENT pin `87af769e9` on 2026-09-21T06:30Z,
3968+
* #19228 — the other anchors in this list are still the `53ded82b` readings
3969+
* the header names: `:407`, the fetch's one top-level `$top`, through
3970+
* `resolveRowLimit(schema.limit, DEFAULT_TIMELINE_LIMIT)` with the default
3971+
* `100` at `:29` and the refused-cap diagnostic at `:279`. ⚠️ It is the FLAT
3972+
* key that is read — `schema.timeline.limit` has 0 read points anywhere in
3973+
* objectui at that pin; see the `timeline` door below), `items` (`:170`, `:247`, `:299`,
39473974
* `:480` — the authored pass-through that short-circuits the object query),
39483975
* `data` (`:171`, `:247`, `:254`, `:256` — the pre-fetched record source,
39493976
* 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[] = [
39693996
* VALUE posture: `timeline` takes {@link TimelineConfigSchema}, the block
39703997
* `ListViewSchema.timeline` already declares — one vocabulary, taken by
39713998
* reference, so this element face cannot fork from the view face.
3999+
* ⚠️ Taking it by reference also imported #19226's new `limit` onto THIS
4000+
* strictObject, beside the flat `limit` below — two authorable row caps on one
4001+
* node, one live and one inert, and the nested one carries an APPLIED default
4002+
* so every parsed node with a `timeline` block materializes `timeline.limit:
4003+
* 100` (#19228). Measured at the pin `87af769e9`, 2026-09-21T06:30Z:
4004+
* `ListView.tsx:3062-3117` forwards this block NESTED (`:3084`) and hoists
4005+
* only `startDateField` / `endDateField` / `titleField` / `groupByField` /
4006+
* `colorField` / `scale` to flat props — `limit` is not among them — while
4007+
* `ObjectTimeline.tsx:407` queries off the flat `schema.limit` alone.
4008+
* ⛔ Recorded, not repaired: which key should carry a timeline's row cap is
4009+
* the open half of #19228 and is not answered here.
39724010
* `mapping` stays `z.unknown()`: its contract
39734011
* (`TimelineMappingSchema`) still lives in objectui, which is the
39744012
* `object-calendar.calendar` posture this section's header prescribes for
@@ -3992,7 +4030,7 @@ export const ObjectTimelinePropsSchema = lazySchema(() => strictObject({
39924030
objectName: z.string().optional()
39934031
.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'),
39944032
timeline: TimelineConfigSchema.optional()
3995-
.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'),
4033+
.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'),
39964034
/** Base query filter — the family's one `ViewFilterRule` array orthography (#15449). */
39974035
filter: z.array(ViewFilterRuleSchema, {
39984036
error: ruleArrayFilterError({

‎packages/spec/src/ui/view.zod.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,6 +1216,25 @@ type RowLimitView = keyof typeof ROW_LIMIT_SUBJECT;
12161216
* as complete, which is worse than the unbounded-and-silent one this key
12171217
* replaces — the author needs to know the cap is visible, and the renderer
12181218
* author needs to know it is owed.
1219+
*
1220+
* ⚠️ NO CONSUMER READS THIS KEY YET — recorded, not repaired (#19228).
1221+
* Measured 2026-09-21T06:40Z at the pin this repo builds against
1222+
* (`.objectui-sha` = `87af769e9`), by `git grep` over all 8,228 files tracked
1223+
* at that commit: `.kanban.limit` / `.gallery.limit` / `.timeline.limit` →
1224+
* **0** read points, against **8** for the identically-shaped control
1225+
* `.kanban.groupByField` / `.gallery.coverField` / `.timeline.scale` on the
1226+
* same instrument. The row caps objectui DOES read are two other keys: a
1227+
* saved view's `pagination.pageSize`, else that view's FLAT `limit`
1228+
* (`core/src/data-scope/element-data-source.ts:237-241`, `savedViewLimit`),
1229+
* and the element block's own flat `limit` (`plugin-timeline/src/
1230+
* ObjectTimeline.tsx:407`, `plugin-kanban/src/ObjectKanban.tsx:676`).
1231+
* `ListView`'s `baseProps` (`plugin-list/src/ListView.tsx:2840-2865`) carries
1232+
* no `limit` on any branch, so a parsed view's per-kind ceiling reaches no
1233+
* query at all — it is declared-and-dropped, the ADR-0049 class, at birth.
1234+
*
1235+
* ⛔ Which of the three row bounds wins is NOT decided here and NOT implied by
1236+
* this declaration: #19228 opens that question and picks nothing, and neither
1237+
* does this note. What is recorded is only what each key reaches today.
12191238
*/
12201239
const rowLimitKey = (view: RowLimitView) =>
12211240
z.number().int().positive().default(DEFAULT_VIEW_ROW_LIMIT).describe(

0 commit comments

Comments
 (0)