fix(spec): state the row-cap guard ElementDataSourceGate implements, and record where the per-kind view limit actually lands (#19228) - #19533
Conversation
…tate 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
…ate the reference page Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
…oint record Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
…ew-limit-unreachable-arm
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 304229705818df682866ab8d570fca5a1b152cda && git checkout 304229705818df682866ab8d570fca5a1b152cda
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 37ed9ae04b7b656114009378fe62cd186ab3b168 b922105e1629344f85c6f03519b9ff8bb64cbe86 && git checkout -B drift-repro 37ed9ae04b7b656114009378fe62cd186ab3b168 && git merge --no-ff b922105e1629344f85c6f03519b9ff8bb64cbe86
node scripts/docs-audit/affected-docs.mjs --json 37ed9ae04b7b656114009378fe62cd186ab3b168 |
…s the node through a SPREAD 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
…ore the changeset to minor Two CI reds from c6b3663. 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
limit as read by nobody (#19228)limit actually lands (#19228)
…et 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
…e arm — both removed 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
…three wordings overstated 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Part of #19228
Clause-②: no
#19228 says an APPLIED
.default(100)makes the react tier's 「fills it only when unset」 armstructurally unreachable. Measured end to end at the objectui pin this repo builds against
(
.objectui-sha=87af769e9, 2026-09-21T06:30–06:40Z), it does not:readLimit(base, 'limit')wherebaseis theobject-kanban/object-timelinenode. That key is.optional()with no applied default,so an author's silence is still silence at parse time.
#19226added sits on the view-face per-kind blocks(
GalleryConfigSchema/KanbanConfigSchema/TimelineConfigSchema). Different schemas.composed.limit = config.limit ?? savedViewLimit(view),and
savedViewLimitreadsview.pagination.pageSize, else a flatlimiton the runtime saved-viewRECORD —
ElementSavedViewis an untyped string-keyed record from the adapter's ownlistViews()(
core/src/data-scope/element-data-source.ts:96, fallback at:237-241), ⛔ NOT a documentListViewSchemaaccepts: a flatlimiton a view document is refusedunrecognized_keys. A thirdface, named here so it is not read as the author face — and never a per-kind block.
ListView'sbaseProps(plugin-list/src/ListView.tsx:2840-2865) carries nolimiton any branch.⛔ So no
.default()moves in this PR, and no precedence is picked — both fences the dispatch sethold. What DID measure as a real 说明书脱节 is a different, smaller thing, and that is what is repaired.
What is repaired
1. The describe's 「only when unset」 is right for its face — and now names the face.
⚠️ RETRACTED — rounds 1 and 3 both got this backwards, and round 4 measured it. On THIS face the
The branch is
if (!fromView || !isUsableRowLimit(authored))(
react/src/element-data-source/ElementDataSourceGate.tsx:316-331).displacement arm is unreachable: the key is
z.number().int().positive().optional()and the gate'sisUsableRowLimitadmits integers above zero, so every value the contract accepts is usable and the「set but not usable」 cell is EMPTY (measured per value: 1 / 25 / 100 / 5000 accepted and usable;
0 / -1 / 2.5 /
'100'/ null refused by both). ⇒ 「fills this key only when it is unset」 — which round 1retired as too narrow — was never too narrow; it was right for its face, and is restored. The view half
is
pagination.pageSizeALONE on this face, per the bullet above. The describe states the rule that isreachable from the accept set; the unreachable arm is recorded in the docblock, ⛔ not in author prose.
2. The per-kind view⚠️ An earlier revision of this section said 「read by nobody」; that was measured with an instrument blind to spreads and is retracted — see the CORRECTION sections below. Kanban and timeline DO read it; gallery alone does not.⚠️ That zero is WRONG as a claim about readers: a spread carries the key without spelling it. SPREADS, by the predicate 「a spread whose target is the object literal an adapter returns as the node」, return four, and they overturn it. Recorded in two places, on two different faces: on
limitis recorded as WHERE IT LANDS.Two instruments, because the first one's answer was wrong. PROPERTY-ACCESS over all 8,228 files tracked at the pin — probe
\.(kanban|gallery|timeline)(\?)?\.limit\b→ 0 lines; control\.(kanban|gallery|timeline)(\?)?\.(groupByField|scale|coverField)\b→ 13 lines across 6 files.rowLimitKeyinview.zod.ts(the VIEW face — flattened onto the node and read, on both routes for kanban, on the plugin-view route for timeline, and read by nobody for gallery), and on theobject-timelinedoor incomponent.zod.ts(the ELEMENT face — a node's own nestedtimeline.limit, which is the one read on no route at all). ⛔ Recorded, not repaired: what should read it is the card's open half.3.
ObjectTimelinePropsSchemacarries two authorable row caps on one strictObject.Taking
TimelineConfigSchemaby reference imported #19226'slimitbeside the flatlimit. Thepublished member list for that door omitted the new key; it now names it and says it is inert here.
The stale
limitread anchor (:234,:254, at the old pin53ded82b) is corrected to:407atthe current pin; ⛔ the other anchors in that list are NOT swept — they remain the
53ded82breadings the docblock header names, and the note says so.
The three relayed consumer readings, reproduced first-hand
Comment 5747444798 marked these 「复核的读数,⛔ 本席未复现」. All three reproduce by shape; two
of the three file:line pairs are wrong at this pin, which is why shape was the instrument.
87af769e9ListView.tsx:2493forwards the timeline block nested:3084insidecase 'timeline':(:3062-3117).:2493is auseMemodependency array here (:2496). OnlystartDateField/endDateField/titleField/groupByField/colorField/scaleare hoisted flat;limitis notObjectTimeline.tsx:234reads only the flatschema.limit$topis:407,resolveRowLimit(schema.limit, DEFAULT_TIMELINE_LIMIT); also:279/:281/:442. No nested read anywheretimeline.limitThe zero's instrument and its reach radius.
⚠️ A first attempt at this probe returned
git grepover the 8,228 files tracked at commit87af769e9— a literal text search of property-access spellings. Lit control on the identicalreceiver alternation (
schema.timeline/timelineConfig/resolvedTimeline/mergedTimeline/tCfg):.startDateField→ 7 hits,.limit→ 0.0 for the control too — a broken bracket expression — and is recorded here because a dead instrument
and a clean result are indistinguishable without one.
One known target deliberately outside the radius: a computed read (
⚠️ CORRECTION (seat, after the at-tier review at head
cfg[key]withkeyfrom avariable) or a wholesale spread of the config into a query builder is invisible to a text search.
85ad2898). That hole was named andthen closed by ASSERTION rather than by an instrument, and the assertion was false. A spread carries
a key without ever spelling it, so this radius owed a SECOND instrument with its own lit control; it
was not built. Built afterwards, it returns four flat spreads, not one:
ListView.tsx:2979andObjectView.tsx:1638(...restKanban),ObjectView.tsx:1697(...(viewOptions.gallery || {}))and
ObjectView.tsx:1725(...(viewOptions.timeline || {})). NeitherrestKanbandestructurestrips
limit. ⭐ Naming a limitation is not discharging it. Also outside the radius: untrackedbuild output, and objectui HEAD rather than the pin.
Verification
pnpm --filter @objectstack/spec build && typecheck && test— 509 files / 14,890 tests pass(post-merge run on this head).
@objectstack/lint— the only package outsidepackages/specnaming any ofthe touched symbols (in a comment, not a runtime read; lit control:
ListViewSchema/PageComponentSchemafire across 8+ files, so the instrument discriminates). Its first run was aFailed to resolve entrycascade from unbuilt workspace deps; afterpnpm --filter '@objectstack/lint^...' buildit reads 106 files / 4,034 tests pass, 5 skipped.pnpm --filter @objectstack/spec check:generated— all 15 generated artifacts up to date(
content/docs/references/ui/component.mdxregenerated via--fix; three table rows changed incomponent.mdxand nine inview.mdx,nothing else).
pnpm lint(repo-wideeslint . --no-inline-config) — exit 0, the whole union, no narrowing.check:react-declaration-parityrun exactly aslint.ymlruns it(
MANIFEST="$PWD/sdui.manifest.json" … --baseline react-declaration-parity.baseline.json --strict)— exit 0, no new declaration divergence.
pre-existing divergences; that invocation is not the gate.
scripts/pm/dispatch-gates.mjs --commands, reconciled with--ran):108 derived, 100 measured green, 8 NOT MEASURED — every one of the 8 a
PREREQUISITE NOT METexit 3 (unbuilt sibling packages;
check-plugin-teardown-shape --self-testneeds an unshallowclone). ⛔ Reported as what they are, never as a pass. None of the 8 reads
packages/spec/src/ui/.origin/mainmerged throughscripts/pm/os-regen-merge.sh; no regeneration debt, and main'snewest entry (
filter-between-field-reference-endpoint-refused) survives at the same 2-file counton both sides.
⛔ Refused / not done
limit, a view'spagination.pageSizeanda component's flat
limitwins is untouched, in prose and in code..default(100)not moved, demoted or removed on any of the three view configs.object-timelinedocblock were NOT re-swept at the current pin.Acceptance notes
packages/spec/src/ui/component.zod.ts. Two files fall outside it:packages/spec/src/ui/view.zod.ts(the applied default the card is about lives in
rowLimitKeythere, not incomponent.zod.ts—the claim and the dispatch were both written against a stale location) and
content/docs/references/ui/component.mdx(the mandatory regeneration from a.describe()change;check:docsreds without it — the claim anticipated regeneration but namedpackages/spec/json-schema/, which this diff leaves untouched). Both are inside the seat's measuredserial-constraint clearance: zero open PRs hold any path under
packages/spec/src/ui/.Part of, notFixes. This PR delivers the 说明书 half triage queued; the card's own two contract questions stay open, so line 1 is deliberatelyPart of #19228and merging this does not close the card.object-timelinedocblock header claims every read anchor was taken at pin53ded82bwhile.objectui-shais87af769e9; only thelimitanchor is re-read here. Carrier: afuture
object-timelinecard, or the seat that next re-pins that block.surface no renderer reads」 — the measurement above says the ceiling it DID add is exactly that.
The pin is still correct about the four; the irony is a reading, not a defect.
limitis NOT inertSeat, after the at-tier review at head
85ad2898, re-measured first-hand at the pin. This PR'searlier claim of zero read points was taken with an instrument blind to spreads. Four flat spreads
carry the key onto the generated node (
ListView.tsx:2979,ObjectView.tsx:1638/:1697/:1725), andObjectKanban.tsx/ObjectTimeline.tsxread the resulting flatlimit.⛔ ADR-0049 enforce-or-remove is therefore NOT a live option for kanban or timeline — retiring
that key would break two adapters that actively forward it. It IS the honest reading for gallery
alone, whose renderer contains no
limitat all (0 occurrences, against a lit control that fireson the same file).
$topthose keys would govern is not issued oneither route today, because both hosts hand the child its rows as a
dataprop and the childshort-circuits its own fetch. So 「governs no query on these two routes」 is true; 「read by nobody」
is not.
⭐ For the decision box: through those same spreads an APPLIED default puts an authored-LOOKING flat
limit: 100on every node built from a spec-parsed view — a value no author wrote.Cross-repo, ⛔ not acted on
objectui#7390's acceptance face is where this gets consumed.⚠️ Corrected: kanban and timeline have
a read point today; gallery is the one that owes one. Stated here for the seat to file in
objectui's queue with a
Blocked-by:line; ⛔ nothing was written there.Generated by Claude Code