feat(spec): declare the author-settable row ceiling for the page-shaped view configs - #19226
Conversation
…d view configs `GalleryConfigSchema`, `KanbanConfigSchema` and `TimelineConfigSchema` each gain a `limit` member: an int-positive row ceiling with the default applied (100), whose describe states that default and the visible truncation signal the renderer owes when the ceiling applies. `DEFAULT_VIEW_ROW_LIMIT` is exported so a consumer reads the number instead of re-declaring it. The name and the placement are the protocol absorbing keys the consumers already read: objectui caps kanban (`$top: schema.limit ?? DEFAULT_KANBAN_LIMIT`) and timeline off keys declared in `@object-ui/types` and on a component props interface, never in the protocol. The non-grid four keep their platform ceiling and gain nothing here. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
The applied default on `KanbanConfigSchema.limit` gives that schema a second shape, which is the event `type-alias-convention.pin.test.ts` exists to catch: its `Iso829` pin leaves, `KanbanConfigParsed` is declared beside the bare alias as ADR-0122 prescribes, and the pin count plus both prose statements of it move 785 -> 784. Also carries the regenerated authorable-surface and authorable-defaults rows the three new keys add. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
…the changeset api-surface, api-surface-declarations, export-origins and the docs references tree, regenerated with the repo's own tooling after a real (non-OS_SKIP_DTS) build. The four non-ui declaration files move only where the view schema is embedded in them. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
…ringified issue Measured during the ablation that falsifies it: with a ceiling planted on the gantt config there is no `unrecognized_keys` issue to stringify, so the case reddened with an argument-type complaint instead of a sentence about gantt. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. 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 e05752f131ee0ca7f365603faf3a3b8a635c7ccd && git checkout e05752f131ee0ca7f365603faf3a3b8a635c7ccd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e233db9dbb9c43816b1f2c44a3381c96402334a3 e1ae025756fe1b1d13c2947d9845414ef7ffafc8 && git checkout -B drift-repro e233db9dbb9c43816b1f2c44a3381c96402334a3 && git merge --no-ff e1ae025756fe1b1d13c2947d9845414ef7ffafc8
node scripts/docs-audit/affected-docs.mjs --json e233db9dbb9c43816b1f2c44a3381c96402334a3 |
Contract reviewServed-tier: Reviewed at head ① Derived judgments
② Semver level
③ Boundary flagsAnswering report comment 5746692131 (its deviations and honest failures), plus the flags this review raises.
Implemented-by: VERDICT: PASS Generated by Claude Code |
Provenance — at-tier contract review adopted, carriers cleared, landing three checks passed
Record of record: 本 PR 评论 5746847791 —— 档位证据,读自子代理自己的转录(逐请求 harness ⛔ 三条落地前检:
|
队列处置审计 —— 被踢出的原因是生成物冲突,⛔ 不是红,⛔ 不是 flaky
读数⇒ 两分零一秒远不够跑完 merge group 的构建( 冲突源,测到而非推断
⭐ 这正是本 PR provenance 里预告过的那个条件在兑现:那条评论写了 12 条 os-regen 路径中 11 条被其它开放 PR 占着,#19219 就在名单上。⇒ 入队前的测量没有错(彼时主干在合并基之后动过 0 条 os-regen 路径,亮对照 6、暗对照 0);⛔ 错的是把那个 0 当成了会保持。生成物占用是一个会在你排队时改变的读数。 处置⛔ 不重投(「一次处置」的额度未动用:重投只用于已知 flaky、或死在测试体之前、或同 commit 早前绿过 —— 三条都不成立)。按车道纪律,碰生成物的 PR 走固定四步序: ⛔ 永不 rebase、⛔ 永不 force-push、⛔ 永不手工编辑生成物。该脚本头部自陈两个顺序陷阱:os-regen 路径的合并会 exit 0、零冲突标记地静默丢一侧;在仍处 merge 状态时跑 这张 PR 落地前还欠一次复核 —— 因为 head 会动达档复核记录 5746847791(PASS)钉在 head Generated by Claude Code |
Sync with origin/main through scripts/pm/os-regen-merge.sh, whose step 2 restored main's side of the four os-regen artifacts both branches moved (api-surface/ui.json, api-surface-declarations/ui.txt, authorable-surface/ui.json, export-origins/ui.json) — the driver had merged them exit 0 while silently keeping one side. Regenerated from the merged tree after a real build, so both sides' entries are present: #19219's element-level `navigation` rows and its ObjectTimelineProps block, and this branch's three `limit` rows plus DEFAULT_VIEW_ROW_LIMIT and KanbanConfigParsed. gen:docs adds the timeline row to the component reference page. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Second at-tier record on a NEW head. The first record (comment 5746847791, head ① Derived judgments
② Semver level
③ Boundary flagsWhat stands from record 5746847791, cited by id. All nine of its ① judgments stand — the files they judge are byte-identical at this head and their pins were re-run green here; its ② stands with the gates re-run above on the larger diff; its FOLLOW-UP 1 (precedence between the per-kind What this head changes for the follow-ups — one amendment, the seat's act, not the implementer's. #19228 gains a third instance at this head: Is this head worse than the head that passed? In one respect, yes — ①7: the merged tree declares a two-spelling row ceiling on a single strict node that neither Other flags, answering report comment 5747309707.
Implemented-by: VERDICT: PASS Generated by Claude Code |
Provenance(第二次)—— 同步后的达档复核已采纳,三条落地前检通过
Record of record: 本 PR 评论 5747419039, 档位证据(读自子代理自己的转录,常量现场 import): 三条落地前检:
⭐ 这一轮真正被验的是「静默丢弃」有没有被修回来这张 PR 上一次被踢出队列是生成物冲突(审计见 5746759712 所在那条)。同步用的是四步序,而 os-regen 驱动确实丢了一侧:复核逐行核过,合并提交
入队前的 os-regen 漂移,在 arm 这一刻重测亮对照在响 ⇒ 那个 0 是读数。os-regen 路由本身也重测过:13 / 17(4 个 mdx + 5 个 declarations + 4 个 ui.json),⛔ 不按类别名估。 串行接力:本席此刻队列里没有其它 PR(#19223 在同步中,#19235 在复核中),放行这一张即满足「一次只放行一个」。 ⭐ 复核指出的「这个 head 比通过的那个更差」的一处 —— ③,非 FAIL合并后的树在同一个 strict 节点 判为 #19228 的第三个实例而非 FAIL(无解析差异、无行为依赖、两半都已立卡)。本席据此去补 #19228,并把「timeline 兑现哪个键」写进 objectui#7390 的验收面。 落地路径:ready → auto-merge → 合并队列。⛔ 队列外不合并。 Generated by Claude Code |
`main` moved under this branch: #19226 landed the author-settable row ceiling and touched the same two generated artifacts this branch owns. Both are routed to the `os-regen` merge driver, which merges them with exit 0 while silently keeping one side, so `scripts/pm/os-regen-merge.sh` was run: it merged `origin/main`, took main's side of the two artifacts in the worktree, and committed the merge first. This is its step 4 — regenerate on the committed merge, never a text merge and never a hand edit. Regenerated with the repo's own tooling on a REAL build (no `OS_SKIP_DTS`): `build` -> `gen:schema` -> `gen:api-surface-declarations` -> `gen:docs`. `check:api-surface-declarations` reads "declaration text unchanged (17 entry points, 5364 declarations)" and `check:generated` is green on all 16 artefacts. Both sides asserted present afterwards, against the STAGED index blobs as well as the worktree, with a dark control reading 0: #19226's `ui/GalleryConfig:limit` / `ui/KanbanConfig:limit` / `ui/TimelineConfig:limit`, `DEFAULT_VIEW_ROW_LIMIT` and `KanbanConfigParsed`; and this branch's own `RecordRelatedListProps.columns` union with its nested `columns[number]` docs table. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
…ged tree Baseline drift, not a code change — the fourth sync lap on this branch. Main's #19226 and #19235 moved `packages/spec/api-surface-declarations/{data,root, system,ui}.txt`, and that directory is a `merge=os-regen` path, so the merge produced four files current for neither side. Regenerated from a real build of the merged tree (34/34 declaration files emitted; ⛔ no `OS_SKIP_DTS`), via `scripts/pm/os-regen-merge.sh`, with `MERGE_HEAD` confirmed absent first — the build opens with `gen:schema`, and running that in MERGE state is the anchor-rollback trap.⚠️ The `MM` grade was live here and was read on purpose. After regenerating, the index held main's side (803/535) while the worktree held the regeneration (323/4); a bare `git commit` would have landed the index. `git add -A` first, then `git diff --cached` re-read as the 323/4 it should be, and every one of the four index blobs hash-matches its worktree file. BOTH SIDES asserted by quoted-exact name over the WHOLE TREE with paths printed, then again against the index blobs, with a dark control reading 0 files: this branch's facade signature and prescription constant; #19226's `DEFAULT_VIEW_ROW_LIMIT`, `KanbanConfigParsed` and the three `ui/{Gallery,Kanban,Timeline}Config:limit` keys; #19235's `RecordRelatedListProps.columns[number]` and `z.array(ListColumnSchema)`; and #19219's `ObjectTimelinePropsSchema` carried forward. Note the three `limit` keys live ONLY in `authorable-surface/ui.json` and `authorable-defaults/ui.json` and the related-list row ONLY in `content/docs/references/ui/component.mdx` — a grep scoped to the declaration files reads 0 for them out of range, not loss. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
…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
…hipped prescriptions were denying a door that exists (objectstack-ai#19234) Fixes objectstack-ai#17487 Clause-②: no ## The defect, and its direction Three shipped, customer-facing prescriptions in `@objectstack/spec` stated in the present tense that the runtime confirmation door had not shipped. It has: `actionConfirmationRefusal` is called pre-dispatch by `invokeBusinessAction` in `@objectstack/runtime`, and the MCP `run_action` tool grew the `confirm` member in the same change (the card behind it, objectstack-ai#15942, is done — `state_reason=completed`; its changeset `action-confirmation-gate-enforced` is still pending, so the door is on `main` and not yet released). So the published text denied a door that exists, and it failed in the dangerous direction: an author who reads it concludes the safety flag stops nothing, and either arranges a human in the loop some other way or stops setting the flag — losing the gate at the moment it starts working. That is the ADR-0049 false-compliance class with the sign flipped. ## Re-derivation — all three sites read on today's `origin/main` Triage's unblock comment verified site 1 only and said the other two were unmeasured. All three were re-read at merge base `805811e0d`. | # | Path | Current text | Verdict | |---|---|---|---| | 1 | `packages/spec/src/ai/tool.zod.ts` — `TOOL_RETIRED_KEY_GUIDANCE.requiresConfirmation` | "the declaration is the contract, not yet the behaviour — the runtime door that performs the refusal ships separately, and until it does, setting the flag does NOT stop an unconfirmed call. Do not try to verify the gate by invoking the operation without the member: until that door lands, such a call simply RUNS." | **FALSE today** | | 2 | `packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts` — `replacement` | "The refusal is DECLARED, not yet performed — the runtime door lands in objectstack-ai#15942, so until then the flag stops nothing on its own and the human in the loop is still yours to arrange" | **FALSE today** | | 3 | the same file — `acceptanceCriteria` | "Do NOT try to 'prove the gate' by invoking the operation without the confirmation member: the runtime door that refuses lands in objectstack-ai#15942, so before that ships the call is not refused, it RUNS the destructive operation." | **FALSE today** | **Correction to the card's count of the carriers.** The card names the `spec-changes` entry, the upgrade guide and the `os migrate meta` projection as if they were separate sites. They are not: all three are projections of the **one** ADR-0087 D3 entry file above. The measurement is therefore **three false prescriptions living in two source files**, plus three generated artefacts that carry them (`src/migrations/registry.ts`, `spec-changes.json`, `docs/protocol-upgrade-guide.md`), all regenerated here by `check:generated --fix`. Sweep radius for "is that all of them": eleven denial phrasings grepped repo-wide (`not yet the behaviour`, `ships separately`, `not yet performed`, `stops nothing`, `simply RUNS`, `until it does`, `until that door`, `door lands`, `yours to arrange`, `nothing server-side`, `no pause`), with `requiresConfirmation` lighting 10 files under `packages/spec/src` as the positive control. Two adjacent texts were read and left alone as **NOT A DEFECT**: `packages/spec/src/contracts/ai-service.ts` already states the gate in normative present tense, and `content/docs/ai/tools.mdx` says the retired **tool**-level key "returns only together with its enforcement", which is still true — the tool key has not returned. Two further readings are recorded under *Acceptance notes*. ## What the prose says now, and what holds it there Each prescription now states the refusal in the present tense **with the door's bounds**, because an unbounded "the platform refuses unconfirmed calls" is this same defect in the other direction. Read off the door's own docblock and its shipped changeset, never inferred: - the refusal is `ACTION_CONFIRMATION_REQUIRED`, 428, naming the action and the member `confirm: true`; - a GATE, not a queue — nothing is parked, and a refused call did not run: the gate sits before `loadActionSubjectRecord`, so no record is read and none written; - the enforced set is the doors that enforce the author's `ai.exposed` opt-in — today the action door reached from MCP `run_action`. REST `/actions` is **not** `ai.exposed`-gated and sits outside the gate, so an API-key agent on that route still needs its own human; - only the author's declared `ai.requiresConfirmation: true` refuses, and only the boolean `true` confirms; the wider `list_actions` heuristic advises and never refuses; - `confirm: true` is an unverifiable caller claim: the gate makes forgetting loud, it does not prove a human. `packages/spec/src/ai/tool-confirmation-prescription-tense.pin.test.ts` is the tie that was missing the first time — the prose was never bound to the function it describes, which is how it rotted. It reads the three shipped strings **and** the runtime door, and fails in both directions. **No pin was moved.** `ui/action-requires-confirmation-docblock.pin.test.ts` was read: it anchors on the `ai.requiresConfirmation` JSDoc in `ui/action.zod.ts` and on `actionLooksDestructive`, neither of which this diff touches, so it covers none of the three sites and stays as it is. ## Clause-②: no — the accept set did not move `check:authorable-surface` and `check:api-surface` are green with **zero** diff under `packages/spec/authorable-surface/` and `packages/spec/api-surface/`. The pin's last case feeds the same authored metadata in before and after: `tool.requiresConfirmation` still refused, a minimal tool still accepted, `action.ai.requiresConfirmation` still accepted for both `true` and `false`. What moved is string content inside `dist` and `spec-changes.json`, which is why a `patch` changeset is owed and present. ## Tests, and the reverse verification `pnpm --filter @objectstack/spec test` — 499 files / 14614 tests passed. `test:repo` — 34 files / 580 tests passed. `typecheck` — clean. New pin: 8/8. Three ablation legs, each mutated on disk through `scripts/ablation-replace.mjs` (anchor hit declared, blob hash proven to move), direction predicted before the run, restored and proven by blob hash against `HEAD` with `git diff HEAD` empty: | leg | mutation | predicted | observed | |---|---|---|---| | 1 | re-insert `The refusal is DECLARED, not yet performed` into the D3 entry's `replacement` | RED on "no shipped prescription denies the refusal" | RED, naming the replacement carrier | | 2 | rename the gate call inside `invokeBusinessAction` | RED on "the AI-facing door still calls the gate pre-dispatch" | RED | | 3 | make the REST `/actions` door name the gate | RED on the over-claim guard | RED | Leg 3's **first attempt was a no-op** and is reported as such: the replacement text still contained the anchor, so `ablation-replace` refused (anchor drop 0, not the declared 1) and nothing ran. It was re-anchored and re-run; the reading above is the re-run. ## Gates All 85 commands derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` for this diff were run locally and exit 0, exit codes captured before any pipe. Eight first returned a stale-`dist` or `PREREQUISITE NOT MET` result (exit 1 / exit 3 — not measured, not findings); they were re-run green after `pnpm --filter @objectstack/spec build` and a full `turbo run build` closure. `pnpm lint` (`eslint . --no-inline-config`, whole repo, no narrowing) exits 0 at `HEAD`. CI still owns its own farm: the five path-scheduled CI jobs, the 11 wide-population families and the artifact rosters are outside that 85 and are NOT MEASURED here. ## Acceptance notes Two readings taken while re-deriving, both **out of scope for this card** and neither edited here: 1. `packages/spec/docs/MCP_GUIDE.md` (around the "Side Effects" section) tells an author to gate side effects with "`ai.requiresConfirmation` on the underlying **action** (+ the HITL approval queue)" and then warns, in the adjacent block, that "nothing server-side pauses on it". The warning is correctly scoped to the MCP capability descriptor in that page's examples and is true of it; but the approval-queue requirement now overstates what the action-level flag needs, and the two paragraphs read together in the card's own dangerous direction. Not in the declared file surface. Reported for filing with dedupe words: `MCP_GUIDE`, `requiresConfirmation`, `HITL approval queue`, `nothing server-side pauses`, `confirmation gate`. 2. `content/docs/ai/actions-as-tools.mdx` — the "Human-in-the-loop approval" section still says that on the open MCP path "the approval step lives at the protocol boundary" (client-side prompting), and the numbered open-MCP action-gate list enumerates five gates without the confirmation gate that now sits between the param contract and the subject-record load. An omission against a contract that `@objectstack/spec/contracts` declares. Reported for filing with dedupe words: `actions-as-tools`, `human-in-the-loop`, `protocol boundary`, `run_action`, `confirmation gate`. Noted, not filed: `packages/spec/src/api/error-code-ledger.zod.ts` says of the `ACTION_CONFIRMATION_REQUIRED` row that "the door will assert this exact string by value" — a forward tense about something that is now true. It misleads nobody about the gate and it is provenance prose about the row's split registration, not a prescription. Successor: the next change that touches that ledger row. ## Occupancy Re-scanned at 2026-09-20T01:52Z over all 21 open PRs, with PR objectstack-ai#17076 (639 files) fully paged so no path is under-read. `packages/spec/src/ai/tool.zod.ts`, the D3 entry, `spec-changes.json`, `docs/protocol-upgrade-guide.md`, `vitest.repo-tests.json` and `src/ai/tool.test.ts` all read FREE. Firing controls in the same scan: `packages/spec/src/ui/component.zod.ts` HELD by objectstack-ai#19219, `packages/spec/src/ui/view.test.ts` HELD by objectstack-ai#19226; dark control (a nonexistent path) reads FREE. One reading to flag: `packages/spec/src/migrations/registry.ts` reads HELD by objectstack-ai#19223, objectstack-ai#19090 and objectstack-ai#18319 — it is a generated, `merge=os-regen` artefact and none of those three touches the D3 entry this diff edits, so the contention is the one the regen driver exists for rather than two hands on the same prose. --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…and record where the per-kind view `limit` actually lands (objectstack-ai#19228) (objectstack-ai#19533) Part of objectstack-ai#19228 Clause-②: no ##⚠️ Read this first — the card's causal claim did not reproduce objectstack-ai#19228 says an APPLIED `.default(100)` makes the react tier's 「fills it only when unset」 arm **structurally 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**: - The arm's guard reads the **element-face** key — `readLimit(base, 'limit')` where `base` is the `object-kanban` / `object-timeline` node. That key is `.optional()` with **no** applied default, so an author's silence is still silence at parse time. - The applied default `objectstack-ai#19226` added sits on the **view-face** per-kind blocks (`GalleryConfigSchema` / `KanbanConfigSchema` / `TimelineConfigSchema`). Different schemas. - Nothing carries the view-face default across: `composed.limit = config.limit ?? savedViewLimit(view)`, and `savedViewLimit` reads `view.pagination.pageSize`, else a **flat** `limit` on the runtime saved-view RECORD — `ElementSavedView` is an untyped string-keyed record from the adapter's own `listViews()` (`core/src/data-scope/element-data-source.ts:96`, fallback at `:237-241`), ⛔ NOT a document `ListViewSchema` accepts: a flat `limit` on a view document is refused `unrecognized_keys`. A third face, named here so it is not read as the author face — and never a per-kind block. `ListView`'s `baseProps` (`plugin-list/src/ListView.tsx:2840-2865`) carries no `limit` on any branch. ⛔ So no `.default()` moves in this PR, and **no precedence is picked** — both fences the dispatch set hold. 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.** The branch is `if (!fromView || !isUsableRowLimit(authored))` (`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`).⚠️ **RETRACTED — rounds 1 and 3 both got this backwards, and round 4 measured it.** On THIS face the displacement arm is unreachable: the key is `z.number().int().positive().optional()` and the gate's `isUsableRowLimit` admits 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 1 retired as too narrow — was never too narrow; it was right for its face, and is restored. The view half is `pagination.pageSize` ALONE on this face, per the bullet above. The describe states the rule that is reachable from the accept set; the unreachable arm is recorded in the docblock, ⛔ not in author prose. **2. The per-kind view `limit` is recorded as WHERE IT LANDS.**⚠️ 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. 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.⚠️ 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 `rowLimitKey` in `view.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 the `object-timeline` door in `component.zod.ts` (the ELEMENT face — a node's own nested `timeline.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. `ObjectTimelinePropsSchema` carries two authorable row caps on one strictObject.** Taking `TimelineConfigSchema` by reference imported objectstack-ai#19226's `limit` beside the flat `limit`. The published member list for that door omitted the new key; it now names it and says it is inert here. The stale `limit` read anchor (`:234`, `:254`, at the old pin `53ded82b`) is corrected to `:407` at the current pin; ⛔ the other anchors in that list are NOT swept — they remain the `53ded82b` readings 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. | relayed | reproduced at pin `87af769e9` | |:--|:--| | `ListView.tsx:2493` forwards the timeline block nested | ✓ shape — the nested forward is `:3084` inside `case 'timeline':` (`:3062-3117`). `:2493` is a `useMemo` dependency array here (`:2496`). Only `startDateField` / `endDateField` / `titleField` / `groupByField` / `colorField` / `scale` are hoisted flat; `limit` is not | | `ObjectTimeline.tsx:234` reads only the flat `schema.limit` | ✓ shape — the one `$top` is `:407`, `resolveRowLimit(schema.limit, DEFAULT_TIMELINE_LIMIT)`; also `:279` / `:281` / `:442`. No nested read anywhere | | nothing reads `timeline.limit` |⚠️ RETRACTED — ✓ **0** on that instrument, but see the CORRECTION sections: four flat spreads land the key where kanban/timeline read it | **The zero's instrument and its reach radius.** `git grep` over the 8,228 files tracked at commit `87af769e9` — a literal text search of property-access spellings. Lit control on the identical receiver alternation (`schema.timeline` / `timelineConfig` / `resolvedTimeline` / `mergedTimeline` / `tCfg`): `.startDateField` → **7** hits, `.limit` → **0**.⚠️ A first attempt at this probe returned 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 (`cfg[key]` with `key` from a variable) or a wholesale spread of the config into a query builder is invisible to a text search.⚠️ **CORRECTION (seat, after the at-tier review at head `85ad2898`).** That hole was named and then 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:2979` and `ObjectView.tsx:1638` (`...restKanban`), `ObjectView.tsx:1697` (`...(viewOptions.gallery || {})`) and `ObjectView.tsx:1725` (`...(viewOptions.timeline || {})`). Neither `restKanban` destructure strips `limit`. ⭐ Naming a limitation is not discharging it. Also outside the radius: untracked build 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). - Consumer package: **`@objectstack/lint`** — the only package outside `packages/spec` naming any of the touched symbols (in a comment, not a runtime read; lit control: `ListViewSchema` / `PageComponentSchema` fire across 8+ files, so the instrument discriminates). Its first run was a `Failed to resolve entry` cascade from unbuilt workspace deps; after `pnpm --filter '@objectstack/lint^...' build` it 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.mdx` regenerated via `--fix`; three table rows changed in `component.mdx` and nine in `view.mdx`, nothing else). - `pnpm lint` (repo-wide `eslint . --no-inline-config`) — **exit 0**, the whole union, no narrowing. - `check:react-declaration-parity` run exactly as `lint.yml` runs it (`MANIFEST="$PWD/sdui.manifest.json" … --baseline react-declaration-parity.baseline.json --strict`) — **exit 0**, no new declaration divergence.⚠️ Run WITHOUT the baseline it exits 1 on 126 pre-existing divergences; that invocation is not the gate. - Derived gate families (`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 MET` exit **3** (unbuilt sibling packages; `check-plugin-teardown-shape --self-test` needs an unshallow clone). ⛔ Reported as what they are, never as a pass. None of the 8 reads `packages/spec/src/ui/`. - `origin/main` merged through `scripts/pm/os-regen-merge.sh`; no regeneration debt, and main's newest entry (`filter-between-field-reference-endpoint-refused`) survives at the same 2-file count on both sides. ## ⛔ Refused / not done - ⛔ **No precedence picked.** Which of the per-kind view `limit`, a view's `pagination.pageSize` and a component's flat `limit` wins is untouched, in prose and in code. - ⛔ **`.default(100)` not moved, demoted or removed** on any of the three view configs. - ⛔ **Nothing written in objectui** — read-only at the pin, as dispatched. - ⛔ The 14 other read anchors in the `object-timeline` docblock were NOT re-swept at the current pin. ## Acceptance notes -⚠️ **File-surface deviation, declared.** The claim's declared surface named `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 `rowLimitKey` **there**, not in `component.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:docs` reds without it — the claim anticipated regeneration but named `packages/spec/json-schema/`, which this diff leaves untouched). Both are inside the seat's measured serial-constraint clearance: zero open PRs hold any path under `packages/spec/src/ui/`. -⚠️ **`Part of`, not `Fixes`.** This PR delivers the 说明书 half triage queued; the card's own two contract questions stay open, so line 1 is deliberately `Part of objectstack-ai#19228` and merging this does not close the card. - noted, not filed: the `object-timeline` docblock header claims every read anchor was taken at pin `53ded82b` while `.objectui-sha` is `87af769e9`; only the `limit` anchor is re-read here. Carrier: a future `object-timeline` card, or the seat that next re-pins that block. - noted, not filed: objectstack-ai#17393's own pin text argues an authorable ceiling on the non-grid four 「would be 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. ##⚠️ CORRECTION — the per-kind view `limit` is NOT inert Seat, after the at-tier review at head `85ad2898`, re-measured first-hand at the pin. This PR's earlier 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`), and `ObjectKanban.tsx` / `ObjectTimeline.tsx` read the resulting flat `limit`. ⛔ **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 `limit` at all (0 occurrences, against a lit control that fires on the same file).⚠️ One distinction this correction itself owes: the `$top` those keys would govern is not issued on either route today, because both hosts hand the child its rows as a `data` prop and the child short-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: 100` on 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](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17393
Clause-②: yes (widening)
GalleryConfigSchema,KanbanConfigSchemaandTimelineConfigSchemaeach gain alimitmember —
z.number().int().positive().default(100)— andDEFAULT_VIEW_ROW_LIMITis exportedbeside them. The key's own text states both halves of the contract: the default it applies, and
that the renderer must show a visible truncation signal when the ceiling applies.
The one design call the card delegates: which shape, and why
Chosen: a shared
limiton the three page-shaped config blocks. Rejected: a member on thebase view config (
ListViewShapeSchema). Three properties of this tree decide it, not taste:pagination.pageSize(PaginationConfigSchema, default 25). A second base-level row keywould leave one view with two base-level row bounds and no declared precedence between them,
and the
virtualScrolltombstone at the bottom of that same shape already prescribespaginationfor exactly that question ("large datasets page viapagination").type— the non-grid four (gantt / calendar / map/ tree) included. Their ceiling is a platform constant the renderer owns (objectui#7210) and
this card scopes them out by name, so a base member would publish an authorable ceiling on
four view kinds no renderer reads: declared-but-unenforced on the day it lands.
dda8f3815d:ListView's kanban branch destructures the merged block and spreads the restflat onto the generated
object-kanbannode (packages/plugin-list/src/ListView.tsx, the...restKanbanin that branch's return), so a protocolkanban.limitlands exactly whereObjectKanban.tsx:573already readsschema.limit. A base-level key is forwarded into noper-kind node at all.
The NAME follows the same evidence:
limitis the name the consumers already read, so thisdeclaration absorbs the two consumer-local keys instead of buying a second spelling.
The default, and the truncation signal
The default is applied, not merely described. A
.describe()naming a default the schemadoes not apply is a second contract nothing enforces, so the two are pinned to each other: the
test parses each minimal block, reads the number out of the member's own describe text, and
asserts they are the same value. Change one without the other and the case reddens.
The truncation signal cannot be enforced from a schema — it is the renderer's half. What the
protocol can do is say it is owed, which is what the describe text does, and a pin asserts the
sentence is there. That sentence is the one the objectui#7390 dispatch turns on: adding
$topwithout a signal trades "unbounded and silent" for "bounded and silent", which is worse,
because the user then believes they are seeing everything.
The consumer-local keys, measured here rather than repeated from the card
Read-only measurement of objectui at its current head
dda8f3815d(this card touches that repoin no way):
ObjectKanbanSchema.limit, declared in@object-ui/typesalone(
packages/types/src/zod/objectql.zod.ts:1762,z.number().int().positive().optional(),describe "default 100 (DEFAULT_KANBAN_LIMIT)"); read at
packages/plugin-kanban/src/ObjectKanban.tsx:573as$top: schema.limit ?? DEFAULT_KANBAN_LIMIT,with that constant
= 100at:84.limit?: numberonObjectTimeline's own props interface(
packages/plugin-timeline/src/ObjectTimeline.tsx:129) and on no published schema at all;read at
:328as$top: schema.limit ?? DEFAULT_TIMELINE_LIMIT, that constant= 100at:29.$topand zerolimitinpackages/plugin-list/src/ObjectGallery.tsx:the unbounded fetch objectui#7390 is ruled to close by reading an author-settable ceiling.
So the name this PR chose is the one the consumer already reads, at the same type (int,
positive), and the number it declares — 100 — is the value both existing renderer constants
carry, which is what the ruling asked the implementing seat to align
DEFAULT_GALLERY_LIMITwith. The card's claim was re-derived, not inherited, and it holds.
Ablation — three runs, each restored byte-clean
Every leg ran through
scripts/ablation-replace.mjs, which proves the write on disk (anchorcount, replacement count, blob hash) and proves the restore against
HEADrather than againstan exit code.
packages/spec/src/ui/view.test.tsimports./view.zodrelatively, so the pinsresolve through source and no
distleg is involved..int().positive()from the memberrefuses a value that could not bound a fetch — and refuses it BY NAMEfailed withgallery limit=0: expected true to be false; 1 failed, 5 passedlimit: rowLimitKey('gallery')expected undefined to be 100and the parser reportingUnrecognized key(s) on this gallery configurationGanttConfigSchemagantt accepts an authorable row ceiling it should not declare: expected [] to include 'limit'Leg C exists because legs A and B never moved the scope pin, and a pin never observed to fail
is not a pin. Two things went wrong on the way there and are reported rather than buried:
ablation-replacerefused (
the anchor count moved 1 -> 1, a drop of 0, not the declared 1), restored, and rannothing. It is re-run with the anchor consumed.
unrecognized_keysissue to stringify, so the assertion complained about argument typesinstead of about gantt. The pin now asserts on the refused KEY LIST, which is what makes its
red a sentence about the view type (commit
5dd3911).What the change dragged with it, named rather than buried
KanbanConfigSchemahad never carried a default, so it was on the ADR-0122 isomorphic-pin list(
Iso829). An applied default gives it a second shape, which is precisely the event that listexists to catch, so the prescribed follow-through landed with it:
KanbanConfigParseddeclaredbeside the bare alias, the pin line removed with its own receipt, and the pinned count plus both
prose statements of it moved 785 to 784.
check:spec-parsed-aliasis green on the result.That is one file outside the dispatch's declared landing surface —
packages/spec/src/type-alias-convention.pin.test.ts— and it is the only one.GalleryConfigand
TimelineConfigneeded nothing: both already carried defaults and therefore both halves ofthe alias pair.
The generated artifacts moved by a real (never
OS_SKIP_DTS=1) build and the repo's ownregenerators:
authorable-surface/ui.jsonandauthorable-defaults/ui.json(three rows each),api-surface/ui.json,export-origins/ui.json, the fiveapi-surface-declarations/*.txttheview schema is embedded in, and
content/docs/references/**. None was hand-edited.Readings
All at head
5dd3911, foreground, exit codes captured before any pipe.pnpm check:adr-anchors— exit 0: "OK (53 anchored file(s), every governing ADR stillreferenced; 133 decision number(s) ...; 36673 citation(s) across 4724 file(s) resolve)". Every
ADR id in this diff (ADR-0122, ADR-0049, ADR-0079, ADR-0087) resolves to a real record.
pnpm --filter @objectstack/spec check:generated— exit 0: "All 16 generated artifacts are upto date."
pnpm --filter @objectstack/spec test— exit 0: "Test Files 499 passed (499) / Tests 14606passed (14606)", the six new pins among them.
pnpm --filter @objectstack/spec typecheck— exit 0 (tsc --noEmit, scripts project, andcheck:test-typecheck: "54 file(s) / 259 error(s) / 144 pinned signature(s)", the ledgerunmoved).
pnpm lint— exit 0 over the whole repo (eslint . --no-inline-config), so no narrowingclaim is needed.
node scripts/pm/dispatch-gates.mjs --ran— "106 derived famil(ies) accounted for — 103 run,3 NOT-MEASURED", 0 unrun.
NOT MEASURED, with the exit code and the reason. Four, not the tool's three — the fourth is
declared here because its exit code cannot say so itself:
pnpm check:dual-build-cjs-loadsdistin this worktreepnpm check:lean-entry-closurepackages/objectql/dist/core.mjsabsentpnpm check:type-check-debt--re-measurerefuses: 30 workspace dependencies of the ledgered packages have no built type entry pointpnpm --filter @objectstack/spec check:skill-examples--rancounted it among the 103 runAll four want a repo-wide build this worktree does not carry; CI builds everything and runs them
there. The three families whose prerequisite WAS bounded were built and re-run rather than
declared:
@objectstack/lint's closure (4 packages) turnedcheck:doc-formula-expressions,check:doc-security-postureandcheck:docs-transcript-driftfrom exit 3 into exit 0.Acceptance notes
refuse a stale
distwith exit 1, the code a finding uses(
check:api-surface,check:api-surface-declarations,check:exported-any,check:dual-source-exports,check:entry-nameability,check:skill-examples), while thisrepo declares a distinct code for exactly that class and explains why in
scripts/import-prerequisite.mjs: "Exit 1 from an unmet prerequisite and exit 1 from a realfinding are the same reading — which is why the guarded refusal below does NOT keep that
number". The consequence is measurable and was measured here:
dispatch-gates --ranclassifies by exit code, so it reported "103 run, 3 NOT-MEASURED" overa record in which four families measured nothing. Successor: whoever owns
scripts/import-prerequisite.mjs's vocabulary. Dedupe words: prerequisite, exit code, staledist, api-surface, dispatch-gates.
:945/:1241); the treereads
:1135and:1437on0046a41b43, as the claim comment already corrected. Itssubstantive claim — both are
strictObjects declaring no row ceiling — holds, and the thirdsite (
TimelineConfigSchema) is declared in spec, so the card's "timeline if spec declares atimeline config" condition is met and timeline is in.
This PR is a draft on purpose: it owes the spec lane's at-tier contract review, which is the
seat's to run. No labels were written from here, and the body was written once, at creation.
Generated by Claude Code