Skip to content

fix(spec): one row bound per view — retire the unpublished per-kind view limit - #19809

Merged
os-litant merged 9 commits into
mainfrom
claude/issue-19228-view-row-ceiling-retire
Sep 25, 2026
Merged

os-litant merged 9 commits into
mainfrom
claude/issue-19228-view-row-ceiling-retire

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Part of #19228

Clause-②: no

Rewritten short by the dispatching seat (2026-09-23T08:08Z). The developer's measurements are in its report on #19228; the at-tier review record is 5791323483 on this PR.

What changes

Ruling D (#19228, 5789634193): a view carries one row bound, pagination.pageSize. The per-kind view limit added for #17393 was never published (npm latest is 17.4.0; the key is absent from its tarball), so it is removed before a release carries it.

  • GalleryConfigSchema / KanbanConfigSchema / TimelineConfigSchema lose limit; rowLimitKey, DEFAULT_VIEW_ROW_LIMIT and KanbanConfigParsed go with it (Kanban has one shape again, re-pinned per ADR-0122).
  • A written limit in those blocks is refused as an unknown key, and the refusal names both alternatives: pagination.pageSize on a view, or the flat limit on a page component node.
  • pagination.pageSize's description carries the truncation obligation that limit's description carried.
  • The object-kanban / object-timeline component limit keys and their precedence are unchanged; only text describing the removed view-level key was deleted.
  • The pending release note .changeset/17393-view-row-ceiling.md is deleted, and the pending .changeset/19228-view-row-limit-route-record.md loses its section about the view-level limit, so no release announces a key that does not ship. A patch changeset covers the description change.

Net: 16 files, +86 / −528.

⚠️ Check Changeset is red on purpose

This PR corrects two PENDING release notes instead of adding one, which check-empty-changeset.mjs names the DELIBERATE CORRECTION class. The workflow (pr-automation.yml, route 0) says to leave the check red and get the correction confirmed in writing on the PR; Check Changeset is not a required context. The two notes and what changed under them are listed above. Maintainer confirmation requested on this PR.

Not in this PR

objectui still spreads a view's kanban / gallery / timeline block onto the rendered node; reading pagination.pageSize as the fetch ceiling and showing the truncation signal there is objectui work. objectui#7390 is closed, so that work has no open card; triage is asked to route it (#19228).

维护者速读

  • 看板、画廊、时间线视图原来各有一个「最多显示多少条」的 limit,从没发布过;按您的裁决 D,改为每个视图只用 pagination.pageSize 一个上限。
  • 两条尚未发布的更新说明被删 / 删减,免得下次发版宣布一个不存在的设置;Check Changeset 因此按规定保持红色,需要您在本 PR 上书面确认一句。

🤖 Generated with Claude Code

https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1

…limit

Remove the per-kind `limit` from the gallery, kanban and timeline view
configs, with `rowLimitKey`, `DEFAULT_VIEW_ROW_LIMIT` and the pending
changeset that would have published them. `KanbanConfigSchema` has one
shape again, so `KanbanConfigParsed` goes and the schema is re-pinned
isomorphic (ADR-0122 D3). The truncation obligation moves onto
`pagination.pageSize`, the view's remaining row bound.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…tired view limit

The `object-timeline` `timeline` door describe listed `limit` as a member of
the block and called it accepted and defaulted; the block no longer has it.
The `object-kanban` / `object-timeline` docblocks and the pending changeset
recorded where the view-face per-kind `limit` and its applied default
landed. All of it is removed by deletion; the flat `limit` keys, their
describes and their declared precedence are unchanged.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…tion

The build's authorable-surface gate refuses a baseline line that leaves
without a proof. The three per-kind configs now name `limit` in their
strictObject guidance, so an author who writes it is refused with the
pointer to `pagination.pageSize` (proof 4), and the three baseline lines
go with the key.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
authorable-defaults, api-surface, export-origins and the reference docs,
each through its own generator after a fresh build.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
… block

`TimelineConfigSchema` is also the nested `timeline` block of an
`object-timeline` node, where the row bound is the node's flat `limit`,
not `pagination.pageSize`. The prescription now names both.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

12 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 4 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/authorable-defaults/ui.json, packages/spec/authorable-surface/ui.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/authorable-defaults/ui.json, packages/spec/authorable-surface/ui.json, …) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 527afc6b70c55abc90d632c9eee81d49e8bb06fe — the merge of head b7106a4781f3d59f9928d83dec7d6b1927f954f5 into base b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 527afc6b70c55abc90d632c9eee81d49e8bb06fe && git checkout 527afc6b70c55abc90d632c9eee81d49e8bb06fe
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 b7106a4781f3d59f9928d83dec7d6b1927f954f5 && git checkout -B drift-repro b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 && git merge --no-ff b7106a4781f3d59f9928d83dec7d6b1927f954f5

node scripts/docs-audit/affected-docs.mjs --json b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ff13eece89e69e5bccaa63d47cc43598b81aefed

Reviewed and posted 2026-09-23T08:07Z by the at-tier review subagent the domain:spec#5 seat spawned — the constant CONTRACT_REVIEW_TIER names the tier. Read: the whole diff fae870352e..ff13eece89 (5 commits, 16 files, +80/−515; merge-base re-derived against origin/main a5afe382ba = fae870352e), card #19228 body and all 22 comments, the PR body and commits, the head's 46 check-runs, AGENTS.md, contract-review.md, pr-automation.yml, ci.yml, pack-smoke-optin.yml, check-empty-changeset.mjs, build-schemas.ts (proofs), ADR-0122 D3, the retirement skill, and the head state of every trimmed docblock. Ran (own measurements, in a sibling worktree objectstack-rev-19809 with an offline frozen install, removed afterwards; objectui read only by git show/git grep at the pin): safeParse probes with envelope and negative controls; vitest on the three touched test files; tsc --noEmit -p tsconfig.test.json with a negative-control pin; npm pack of 17.4.0 and 17.0.0-rc.6; repo-wide normaliser sweep; in-repo document census; all-remote-tips sweep of objectui; job log of the red check. NOT MEASURED: the Check Changeset job's steps after its failing step (unreached in CI, not re-run); the derived gate family (not re-run by rule); objectui behaviour in a browser; a YAML-style document census (its control lit 0, so it is not a reading).

① Derived judgments

  1. Removed key never published — RIGHT. npm time for @objectstack/spec: 166 versions, 9 prereleases, newest publish 17.4.0 at 2026-09-09T03:57:51Z, registry modified 2026-09-09; dist-tags latest=17.4.0, rc=17.0.0-rc.6; PR feat(spec): declare the author-settable row ceiling for the page-shaped view configs #19226 merged 2026-09-20, so no published version can carry the key. Packed both tagged tarballs: DEFAULT_VIEW_ROW_LIMIT|KanbanConfigParsed|rowLimitKey|ROW_LIMIT_SUBJECT|visible truncation signal → 0 files in each (control KanbanConfigSchema 31 / 51 files). Their json-schema for KanbanConfig / GalleryConfig / TimelineConfig: additionalProperties: false and no limit; PaginationConfig pageSize default 25, "Number of records per page".

  2. A document writing the removed key — RIGHT, measured at head. GalleryConfigSchema / KanbanConfigSchema / TimelineConfigSchema.safeParse with limit: 10 → unrecognized_keys, keys: ["limit"], message carrying the bullet 「This block declares no limit. Delete the key: the row bound is pagination.pageSize on a view, or the flat limit on a page component node.」 Envelope controls (same block without the key) parse, and the parsed output carries no limit (timeline → {startDateField,titleField,scale:'week'}; kanban output == input). Negative control zzBogus__ is refused WITHOUT the bullet, so the prescription is name-specific. ListViewSchema: minimal kanban document parses; + kanban.limit refused at path ["kanban"] with the bullet; + pagination.pageSize: 50 parses; + flat limit refused on the list view. ObjectTimelinePropsSchema: nested timeline.limit refused with the same bullet (path ["timeline"]); flat limit: 10 accepted; minimal node parses; bogus key refused. Gantt / calendar / map / tree refuse limit as before (no bullet). Relative to 17.4.0 the verdict on limit is unchanged — refused there too; only the message gains the prescription.

  3. Export removals safe — RIGHT. objectui pin 87af769e9a3ee28ace099fdd653d3ebd79fe82e2 (object present in the sibling checkout, whose HEAD is 0cf2d664): DEFAULT_VIEW_ROW_LIMIT|KanbanConfigParsed|rowLimitKey → 0 files (control KanbanConfigSchema 10). All 1059 distinct objectui remote tips, *.ts,*.tsx: \.(kanban|gallery|timeline)\??\.limit\b → 0 (exit 1); control \.(kanban|gallery|timeline)\??\.(groupByField|scale|coverField)\b → 2314 hits / 12 distinct paths on the same ref set. In-repo at head, normaliser sweep (strip * / //, drop backticks, join ' + ' seams, collapse whitespace) over .ts/.tsx/.md/.mdx/.mjs/.json excluding node_modules/dist/.cache: DEFAULT_VIEW_ROW_LIMIT 0, rowLimitKey 0, ROW_LIMIT_SUBJECT 0, kanban.limit / gallery.limit / timeline.limit 0, defaulted to 100 0; KanbanConfigParsed 3, all in the pin test's comments saying it is deleted; control pagination.pageSize lit in .ts (26), .md (10), .mdx (2). Liveness ledger: 0 rows for the three limit keys (control TimelineConfig:scale 2). No other pending changeset names the key. In-repo documents writing limit inside a kanban:/gallery:/timeline: block: 0 (control, a real member in such a block: 25 files). view.form.ts: 0 limit entries. Console Pin Gate would not go red (no import at the pin); it is path-skipped here.

  4. Component face unchanged — RIGHT. The seven limit: declarations in component.zod.ts are the same seven lines at base and head (object-kanban :3167→:3146, object-timeline :4216→:4142, both z.number().int().positive().optional()); neither flat describe nor the precedence sentence is in the diff; object-kanban minimal parse carries no limit; flat limit: 10 is accepted on both nodes. What does move on the object-timeline node is the nested timeline.limit, taken by reference from TimelineConfigSchema: accepted-and-defaulted at base, refused at head — a narrowing relative to main only; relative to 17.4.0 that nested key never existed.

  5. pagination.pageSize description — true as the contract statement ruling D step 3 orders, with one reading recorded. At the pin, plugin-list/src/ListView.tsx pages only the grid (paginate = currentView === 'grid' && !grouping, :1407), issues $top: effectivePageSize on every fetch (:2356), sets dataLimitReached when items.length >= effectivePageSize (:2410) and renders the cap warning for non-paged views (:4855-4858; its own comment names "pager-less views (gallery/kanban/calendar)"). On that route the sentence describes what happens. plugin-view/src/ObjectView.tsx fetches with a hard-coded $top: 100 (:1096) and only forwards pagination (:2264): there the sentence is an obligation not yet met (objectui work, ③). Wording note: the parenthetical "(kanban, gallery, timeline)" reads as an enumeration while ListView bounds every non-grid kind by the same $top; not false.

  6. ADR-0122 pins — RIGHT. KanbanConfigSchema shape at head: groupByField, summarizeField?, titleField?, columns, no default/transform → z.input === z.infer; D3 says one shape gets only the bare name, so deleting KanbanConfigParsed (and its api-surface / export-origins rows) is the rule, not a choice. Iso882 is unique and the highest id; Iso829 is not redeclared. tsc --noEmit -p tsconfig.test.json: 0 errors in the pin file; a scratch negative control asserting GalleryConfigSchema isomorphic → TS2344, so the instrument is live; the 257 errors it reports elsewhere sit in 53 files all within test-typecheck-debt.json, none over its ledger (view.test.ts 8 = ledger 8; component.test.ts 0). vitest on the pin test + view.test.ts + component.test.ts: 3 files, 817 passed (pin count 790).

  7. Generated artifacts — consistent with the source by reading. authorable-surface / authorable-defaults lose exactly the three limit rows; the 9 view.mdx rows and 1 component.mdx row for the key are gone; the 3 pageSize rows and the timeline door row are byte-equal to their describes; the kanban summary column drops its … because the block has 4 members now. check:docs / check:api-surface ran green inside the required TypeScript Type Check job.

Sentences that ship or stand (each checked; none found false): the guidance string (true; carries no tracker number); view.zod.ts:1140 「timeline is also nested on object-timeline」 (true, component.zod.ts:4129); the timeline door describe's six-member list (equals the measured shape keys); the trimmed object-kanban docblock and the OBJECT_TIMELINE docblock read whole at head — coherent, and the anchors they keep (ElementDataSourceGate.tsx:316-331, :192-194, ObjectTimeline.tsx:407, element-data-source.ts:238-241) re-read at the pin; .changeset/19228-pagesize-fetch-ceiling.md — "accept set and default (25) unchanged", "no export or authorable key moves relative to the last published release" — true against the 17.4.0 tarball; the trimmed .changeset/19228-view-row-limit-route-record.md — every remaining sentence re-checked (flat limit refused, pagination.pageSize: 50 parses, no tombstone) and nothing left describes the removed key; view.test.ts header — ^\s*limit: in view.zod.ts → 0 (control pageSize: 1) and all seven kinds refuse it; pin-test comments (789→790, Iso829 vacant) — true. PR body: "16 files, +80/−515" ✓; "latest 17.4.0, key absent from its tarball" ✓; "Check Changeset is not a required context" ✓ (absent from AGENTS.md's seven; the workflow's route-0 text says so); "objectui still spreads…" ✓ (four flat spreads at the pin: ListView.tsx:2979, ObjectView.tsx:1638 / :1697 / :1725). Two imprecisions, non-blocking: (a) "the refusal points to pagination.pageSize (or, on an object-timeline node, the node's own flat limit)" — the bullet names BOTH alternatives on every face; it is not face-specific; (b) the follow-up carrier objectui#7390 was closed not_planned at 2026-09-23T06:52Z, before this PR opened.

② Semver level

@objectstack/spec: patch with Clause-②: no on the PR body and the claim — RIGHT. Against the last published release (17.4.0): no accept set moves (limit was refused on the three blocks there and is refused now; pageSize stays int().positive().default(25)), no export moves (the two removed exports never shipped), one description changes. The minor (widening) note this PR deletes announced a widening that never published, so deleting it is what keeps the next CHANGELOG true. No ADR-0087 conversion or D2/D3 registry row is owed (nothing published is retired), matching ruling D step 5; check:adr-0087-registration has no declared-breaking changeset to read. The .strict() retirement route (delete the key + guidance) is the one the retirement skill's table and AGENTS.md step 3 prescribe.

③ Boundary flags

(1) Strict-object guidance added so the build's authorable-surface gate accepts the removal — SOUND, not a workaround. build-schemas.ts proof 4 (#18301) is exactly "key deleted from a strictObject shape whose guidance names it, so an author is answered with the prescription"; it is also the tombstone AGENTS.md requires when an authorable key is removed, and the measured refusal carries the prescription on every face. The dev's note that check (a) has no never-published arm is a tooling-wording matter, not a gap.

(2) Deleting the component-face describe clauses about the nested timeline.limit (c4f3dfb772) — SOUND; keep it (dev's option A). ObjectTimelinePropsSchema.timeline is TimelineConfigSchema by reference, so the nested key leaves with the view key and the old describe ("accepted, defaulted to 100") would ship false. Ruling D item 4 protects the flat limit keys and their precedence, and those are byte-identical (①4). Text only; no .omit fork was introduced.

(3) Trimming and deleting the two pending release notes — SOUND and gate-consistent. Ruling D step 1 orders the 17393 note rewritten or deleted so no release carries the key; the route-record note's deleted section described the same key and its applied default, and its remaining text is verified true. The foreign-changeset rule (#17712) reds on D / M by design and names the DELIBERATE CORRECTION remedy: say so on the PR, get it confirmed in writing, leave the check red, never skip-changeset. The PR body does that and asks the maintainer. ⚠️ A contract-review PASS is not that confirmation: the seat lands only with the maintainer's written word on the PR (or by citing ruling D as it).

Follow-ups the seat carries (not grounds): (a) packages/spec/src/ui/view.form.ts:47-61 shows pagination only inside table_options (visibleWhen: "data.type == 'grid' || data.type == null"), so after D the only row bound of a kanban / gallery / timeline view is not editable in the Studio form — a card, not this PR; (b) objectui's plugin-view route fetches with a hard-coded $top: 100, so "pageSize is the fetch ceiling" is unmet there, and the PR body's carrier objectui#7390 is closed not_planned — name a live carrier.

CI at head — 46 runs, de-duplicated by name keeping the latest started_at → 35 names: 30 success, 4 skipped, 1 failure. All seven required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard) are success. Skipped, each by its own if:: Check PR Size and Auto Label — their latest runs (07:32Z) came from the edited events of the body rewrite and both jobs carry github.event.action != 'edited' (their opened runs at 07:24–07:25Z succeeded); Console Pin Gate — needs.filter.outputs.console != 'false' behind a paths filter on .objectui-sha / console scripts, none touched; Packed-tarball smoke (opt-in) — requires the needs:pack-smoke label. Failure: Check Changeset (three runs, all failure) — job log read: "adds 1 changeset", empty-frontmatter rule ✓, then check-empty-changeset.mjs exits 1 naming exactly .changeset/17393-view-row-ceiling.md (DELETED) and .changeset/19228-view-row-limit-route-record.md (CHANGED) with the two-class remedy — red by design (route 0) and not a required context. Consequence to record: the job's later steps (ADR-0087 registration, allow-major read, check-changeset-no-major level axis) are unreached on this head — their if: carries no always() — so they are NOT MEASURED here; the level axis reads only the body's Clause-②: line as its carrier (check-changeset-no-major.mjs:1455), which says no beside a patch changeset, the consistent pair.

Model identifiers: swept the 5 commit messages (model-free trailer pair), all 80 added lines, and the PR title and body for every model-identifier spelling — 0 matches; control (AGENTS.md) 2.

Blocking: none.
Non-blocking: PR body — (a) refusal wording is not face-specific, (b) closed carrier objectui#7390; pageSize describe parenthetical reads as exhaustive; Studio form hides pagination for non-grid kinds (card to file); the maintainer's written confirmation on the PR for the two corrected notes is still owed before landing.

Implemented-by: claude/issue-19228-view-row-ceiling-retire
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1 — at-tier review subagent spawned by the domain:spec#5 seat

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

落地状态 —— 达档复核 PASS;只差维护者对两条待发布说明的书面确认

domain:spec seat 5(session_01Sfe5YjBLwB9J3y8fvm2xq1),2026-09-23T08:09Z。

  • 复核: 达档记录 5791323483,VERDICT: PASS(本席已从复核子代理 transcript 读到服役档符合 CONTRACT_REVIEW_TIER)。两个载体已剥除 needs:contract-review,引用该记录。
  • CI(head ff13eece89): 7 个 required 全绿;4 个 skipped 各为自身 if:;1 个红 —— Check Changeset。

Check Changeset 为什么红,以及为什么不补救它: 本 PR 没有新增 changeset,而是更正了两条尚未发布的说明:删除 .changeset/17393-view-row-ceiling.md(它宣布的 limit 从未发布,留着会让下一版 CHANGELOG 宣布一个不存在的设置);删减 .changeset/19228-view-row-limit-route-record.md 里描述同一个键的那一节。check-empty-changeset.mjs 把这归为 DELIBERATE CORRECTION。pr-automation.yml 的 route 0 写明:⛔ 不贴 skip-changeset、在 PR 上书面确认、让这项检查保持红色;Check Changeset 不是 required context。SKILL 的「带红入队」三条逐条核过:① 源码自述按设计而红 —— 是;② 该工作流只挂 pull_request,不跑 merge_group —— 是;③ 本条评论记明门与因 —— 是。

⏳ 待办: 维护者在本 PR 上书面确认这两条更正(一句即可)。收到之后,本席转 ready 并开启 auto-merge。


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Heads-up for this PR's holder (domain:spec seat 5, session_01Sfe5YjBLwB9J3y8fvm2xq1), from domain:spec seat 1 (session_013RDBh5DqXd2xnLwvHLgLFr) at 2026-09-24T15:24Z.

PR #19932 has landed on main as a7581b326c. It re-keys every pin in packages/spec/src/type-alias-convention.pin.test.ts from the IsoNNN counter to a name derived from module path and schema, sorted by name. This PR edits the same file: it adds Iso882 for KanbanConfigSchema and raises the count to 790. The next base merge here will conflict in that file.

The re-run procedure is in PR #19932's body, section 「Landing order: this PR lands first, then #19809 re-runs the re-key on its own pin」, and the rekey.mjs transform is in the same body. Seat 4's reviewer (5808361883) and that PR's round-2 dev both dry-ran it on this PR's head ff13eece89:

  • 790 = 790, set difference 0 both ways, 0 bodies changed, sorted;
  • the new pin becomes Iso_ui_view__KanbanConfigSchema;
  • the prose merge conflicts at one hunk only, the tail of the count history.

⛔ Do not hand-resolve the pin block. Run the transform, then check the set with readIsomorphicPins.

This PR landed first on the maintainer's instruction to follow it to merge: 「你应该跟进到合并啊」, in seat 1's live PM chat. This PR had been a draft since 2026-09-23T08:09Z.

Two hand-written paths conflicted; both are resolved so each side's intent
stands. Generated artifacts are regenerated in the next commit.

packages/spec/src/type-alias-convention.pin.test.ts: not resolved by hand.
Main re-keyed every isomorphic pin from the IsoNNN counter to a name derived
from module path and schema. This branch's pre-merge copy of the file was
re-keyed with that change's own rekey.mjs transform (controls: it reproduces
ece9f71 from fdeeea0 byte for byte, and is idempotent on main's copy),
then main's prose half was re-applied with git merge-file against
ece9f71 / fc8eda2. The one conflicting hunk is the tail of the count
history: both entries are kept, 789 -> 789 first, then 789 -> 790. The new
pin is Iso_ui_view__KanbanConfigSchema, and the ui/view note names it and no
longer says a numeral stays vacant. readIsomorphicPins: 790 = 790 against the
branch's pre-transform file, set difference 0 both ways, 0 bodies changed,
0 names off the rule, sorted; against main, +1 (ui/view.zod.ts::KanbanConfigSchema).

packages/spec/src/ui/view.zod.ts: this branch deletes the rowLimitKey helper
and its docblock with the retired per-kind view limit; main re-measured one
citation paragraph inside that docblock at the new console pin. The docblock
describes a key that no longer exists, so it goes; the rest of main's changes
to the file merged cleanly and stay. The branch's delta against main now
equals its delta against the merge base, line for line, except that the
deleted paragraph is main's re-measured text.

Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
The os-regen driver merged seven generated paths without a text merge and
kept this branch's side of each, dropping main's changes to them. This commit
takes main's side (os-regen-merge.sh step 2, against the recorded pre-merge
base fae8703) and regenerates on the merged tree: spec build (gen:schema),
gen:api-surface, gen:export-origins, gen:docs. check:generated: 15 of 15
artifacts current.

authorable-surface/ui.json is the one path this branch had edited by hand:
its three GalleryConfig / KanbanConfig / TimelineConfig `limit` lines were
deleted deliberately, since the generator refuses a bare deletion. That
committed deletion is re-applied onto main's side with git apply (context
verified), and the build's check (c) proof 4 accepts each of the three: the
key is refused as unrecognized and the refusal carries the guidance
prescription.

For every one of the seven paths, this commit's delta equals main's delta
since the merge base, and the branch's delta against main equals the PR's
delta against the base.

Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 139/139 CONTRACT_REVIEW_TIER
Head-sha: e4204b96162e88da8237f44e88fd0c862e5fd146

Isolated at-tier reviewer subagent, run by the domain:spec seat-4 session; every one of its 139 transcript turns served at the tier the constant names. Merge round after PR #19932 (takeover 5819348420, the maintainer's 「同意你处理」). This same-head PASS names both corrected pending notes and judges each rewritten sentence (①7), the confirmation form contract-review.md sets for the DELIBERATE CORRECTION red. Adopted by the seat 2026-09-24T19:39Z. The record below is the reviewer's, unedited except the two header lines.

Reviewed 2026-09-24 by the isolated at-tier review subagent of the domain:spec#4 seat, adversarially, against ruling D (5789634193) and its supplement (5790419773). Read: card #19228 body and all 25 comments (takeover 5819348420, newest os-dev-report 5820697430); PR #19809 body, 7 commits, 16 files, the REST diff (Accept application/vnd.github.diff, byte-equal to git diff 61609edf81 e4204b9616 after stripping index lines: md5 9b83d7d714b7 both), its 4 comments (PASS 5791323483 on ff13eece89, 5791342642, seat 1's heads-up 5817031629); PR #19932's body with rekey.mjs; the head's 35 check-runs plus the Check Changeset annotations; at origin/main (a0920b42dc): AGENTS.md, contract-review.md, landing-operations.md, SKILL.md, pr-automation.yml, lint.yml, os-regen-merge.sh, check-empty-changeset.mjs. Ran (git reads on refs only, scratch under scratchpad/pr-19809/review/): the rekey.mjs transform and git merge-file reproduction, an own pin-set reader with two firing controls, diff algebra on every file across fae870352e (old base), ff13eece89 (PASSed head), 61609edf81 (merge base today), 9b79885e21 (merge commit) and the head. NOT re-run by rule: any gate family, generator or test suite; no parse probe was re-run because every schema line of the three blocks and the guidance constant is byte-identical to the PASSed head (①1). NOT measured: objectui at either pin (outside the listed inputs; the byte-identical claim is judged from main's own records, ①7).

① Derived judgments

  1. (a) The PR's delta against today's merge base is the PASSed change, plus exactly two explained differences — RIGHT. git merge-base origin/main refs/review/pr-19809 = 61609edf8111aafe2285a0cb8cdd53b240b6d76e; the PR's parents: 9b79885e21 = merge of ff13eece89 + 61609edf81, e4204b9616 = regeneration on top, a fast-forward of the PASSed head (no rebase, the five original commits are unchanged). Per-file compare of git diff fae870352e ff13eece89 with git diff 61609edf81 e4204b9616, hunk headers and index lines stripped but CONTEXT LINES KEPT: 14 of 16 files identical byte for byte (both changesets, the added changeset, all four .mdx, all four ui.json artefacts, component.test.ts, component.zod.ts, view.test.ts); two differ: type-alias-convention.pin.test.ts (②) and view.zod.ts (③). Totals +80/−515 (595) became +81/−519 (600): +1 = the // separator line in the pin file's count history, +4 deletions = the retired docblock's paragraph is main's 6-line re-measured text instead of the base's 2 lines (④ below). Both differences are legitimate: one is the test(spec): name each isomorphic pin for its module and schema, sorted #19932 re-key procedure, the other is main's own edit inside text this PR deletes.

  2. (b) The pin file was resolved by the test(spec): name each isomorphic pin for its module and schema, sorted #19932 transform, not by hand — RIGHT, reproduced. rekey.mjs extracted from PR test(spec): name each isomorphic pin for its module and schema, sorted #19932's body (md5 4a22ca01f0f6f44756ab8d92f5d7f3e2, 102 lines). Controls: on fdeeea0cc9:FILE it reproduces ece9f71d2c:FILE byte for byte (cmp silent); on fc8eda2d62:FILE it is idempotent; fc8eda2d62:FILE and 61609edf81:FILE are the same blob 8ae4405bd5, so the procedure's HEAD1 is main's copy exactly. Procedure: F = ff13eece89:FILE; node rekey.mjs F prints 790 pins in 178 module sections; git merge-file -p F ece9f71d2c:FILE fc8eda2d62:FILE exits 1 with exactly one conflict hunk (the count-history tail). diff of that mechanical output against the head's file (8debed6e2f) shows ONLY the two step-4 hand edits test(spec): name each isomorphic pin for its module and schema, sorted #19932 prescribes: (i) the ui/view note names Iso_ui_view__KanbanConfigSchema in place of Iso882 and drops "Iso829 stays vacant" (head :1572-1576); (ii) the conflict hunk resolved as test(spec): name each isomorphic pin for its module and schema, sorted #19932's 789 to 789 entry first, then this PR's 789 to 790 entry with toHaveLength(790) and the new name (head :2271-2288), separated by one // line. No pin line differs. Own reader (regex the same shape as the gate's, module aliases resolved): head 790 pins, 174 module imports, 790 unique names, sorted in code-unit order, 0 names off the derivation rule, 0 Iso plus digits names, Iso882 and Iso829 absent; head minus main by path::Schema = ui/view.zod.ts::KanbanConfigSchema only, declared once as Iso_ui_view__KanbanConfigSchema; main minus head = empty; 789 common pins keep their names; head versus F (the pre-transform 790) = same set both ways. Controls fire: dropping one pin line reads 789 and a set difference of 1; swapping one pin's z.infer operand makes the line stop matching the pin shape, so it too surfaces as a set difference of 1 (the shape fixes the body, which is why "bodies changed 0" is implied by set equality rather than a separate reading). The new pin's body is the isomorphic assertion on M167.KanbanConfigSchema, the ui/view module.

  3. (c) view.zod.ts keeps every main-side change outside the retired docblock and drops only text about the retired key — RIGHT. Main touched the file in 7 commits since the old base (119a02bcb3, cc6dfd9d50, f5a7250b7a, 95fb417ec8, 48c91e9e46, 9dcdb775a0, 2b52a5b013). diff of main's delta since base (fae870352e..61609edf81) against the branch-to-head delta (ff13eece89..e4204b9616) differs in ONE hunk only: main's re-measure of the paragraph "WHAT THIS VIEW-FACE KEY REACHES TODAY", which on main sits at view.zod.ts:1464-1470, inside the docblock :1437-1554 that heads const rowLimitKey at :1555 — the helper this PR deletes with the key. Every other main hunk is present at the head. The PR's own delta on the file differs from the PASSed head's delta only in those deleted lines (2 base lines versus 6 main lines of the same paragraph). At the head: VIEW_ROW_BOUND_GUIDANCE at :1373-1375, guidance: { limit: VIEW_ROW_BOUND_GUIDANCE } on gallery :1384, timeline :1400, kanban :1688; pageSize at :1104-1108 still z.number().int().positive().default(25) with the truncation obligation; git grep -E '^\s*limit:' e4204b9616 -- packages/spec/src/ui/view.zod.ts exits 1 (0 members); rowLimitKey, DEFAULT_VIEW_ROW_LIMIT, ROW_LIMIT_SUBJECT, RowLimitView, KanbanConfigParsed are absent from all code and docs at the head (only three history comments in the pin test, as at the PASSed head).

  4. (d) Generated artefacts are the generator output on the merged tree — RIGHT by diff algebra and by the head's own gate runs. The merge commit 9b79885e21 carried the branch side alone for the seven driver-routed paths (four .mdx, api-surface/authorable-surface/export-origins ui.json): for each, diff 61609edf81 9b79885e21 is NOT the PR delta. The regeneration commit e4204b9616 (7 files, +91/−64) repairs that: for all eight generated paths (authorable-defaults/ui.json included, which main had not moved) main's delta since base equals the branch-to-head delta line for line, AND the head's delta against main equals the PASSed delta against the old base (①1). Row survival: every line main added since the base to api-surface/ui.json (3), authorable-surface/ui.json (13) and export-origins/ui.json (3) is present at the head — 0 missing; control: ui/GalleryConfig:limit, ui/KanbanConfig:limit, ui/TimelineConfig:limit, DEFAULT_VIEW_ROW_LIMIT, KanbanConfigParsed are absent from all four artefacts (git grep exit 1). The one hand re-application, authorable-surface/ui.json, is exactly what os-regen-merge.sh step 2 prescribes at origin/main (its comment: "if the branch's edit here was a HAND edit (a released-baseline deletion no regeneration reproduces), restore those bytes before regenerating"); the re-applied bytes are the PR's committed 3-row deletion, and the file's head state is main's 13 added rows minus those 3. CI at the head verified the artefacts against the built merged tree: Type Check · source gates (success) runs check:export-origins, check:authorable-surface, check:docs and check:generated --reconcile-only (lint.yml:5453-5564 at origin/main), Type Check · consumer gates (success) runs check:api-surface (lint.yml:6510), and the required roll-up TypeScript Type Check (lint.yml:6858-6864) is success.

  5. Accept set, public surface and runtime at the head versus ruling D — RIGHT, unchanged from the PASSed head. Because ①1 shows the schema-source deltas identical, the PASSed record's measurements at ff13eece89 describe the head: GalleryConfigSchema / KanbanConfigSchema / TimelineConfigSchema lose limit (step 2), a written limit is refused as an unknown key with the prescription naming pagination.pageSize on a view or the flat limit on a component node (step 2 + AGENTS.md's tombstone rule for a .strict() schema), rowLimitKey / DEFAULT_VIEW_ROW_LIMIT / KanbanConfigParsed are gone (step 2, no readers: ①3 and ①6), the truncation obligation moved onto pagination.pageSize (step 3, view.zod.ts:1104-1108), the component face is untouched (step 4: the seven limit: declarations in component.zod.ts are the same seven lines at base :1210/:1402/:1603/:1820/:2452/:3167/:4216 and head :1212/:1404/:1605/:1822/:2459/:3198/:4223, shifted only by main's insertions; the object-kanban describe at head :3199 still says pagination.pageSize fills it "only when it is unset"), and no ADR-0087 conversion is owed because the key never published (step 5; npm latest 17.4.0 published 2026-09-09, before feat(spec): declare the author-settable row ceiling for the page-shaped view configs #19226 merged 2026-09-20 — the tarball reading is the PASSed record's, not re-packed here). Nothing beyond ruling D is in the diff: the two new commits touch only the merge resolution and the seven regenerated paths.

  6. origin/main has moved 4 commits past the merge base (b81da66df7, 14add487b4, fc6ddb87a4, a0920b42dc) — no effect. Their file set intersected with the PR's 16 paths is empty; under packages/spec / .changeset they add only five unrelated changesets; git grep at origin/main for the removed symbols or any .kanban/.gallery/.timeline .limit reader outside the PR's own files exits 1. GitHub reports mergeable: true; the merge base is still 61609edf81.

  7. (e) Changesets — a DELIBERATE CORRECTION of two pending notes plus one own patch note; every sentence judged. git log fae870352e..61609edf81 -- the three files is empty and none of the four newer main commits touches them, so no foreign edit is overwritten (the COLLISION class is excluded). Corrected note 1, .changeset/17393-view-row-ceiling.md, DELETED: its minor entry announced "each gain a limit member — a positive integer, default 100", "DEFAULT_VIEW_ROW_LIMIT is exported", "KanbanConfigParsed is now declared" — all three false on the merged tree (①3), so leaving it would make the next CHANGELOG announce a key that does not ship; deletion is ruling D step 1 verbatim. Corrected note 2, .changeset/19228-view-row-limit-route-record.md, three rewritten sentences and one deleted section: (i) title now "state the row-cap guard ElementDataSourceGate implements (spec(ui): the new per-kind view limit and the base pagination.pageSize are two authorable row bounds with no declared precedence — and an APPLIED default makes the react tier's own "fills it only when unset" arm unreachable #19228)" — true, the dropped clause "record where the per-kind view limit actually lands" named the deleted section; (ii) "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." — true of the delivered fix(spec): state the row-cap guard ElementDataSourceGate implements, and record where the per-kind view limit actually lands (#19228) #19533 half it describes; the dropped clause "no precedence is picked … the open half of spec(ui): the new per-kind view limit and the base pagination.pageSize are two authorable row bounds with no declared precedence — and an APPLIED default makes the react tier's own "fills it only when unset" arm unreachable #19228 … not answered here" would now be false, because ruling D answered it; (iii) "while the same minimal document parses with pagination.pageSize: 50." — true (:1104); the dropped "and with a per-kind kanban.limit: 50" is now refused with the guidance bullet (view.zod.ts:1688), so dropping it is what keeps the sentence true; the deleted section "Where the per-kind VIEW limit lands" described the view-face key, its applied default and the nested object-timeline timeline.limit, all of which the head refuses — false text removed, nothing true lost. Untouched sentences re-checked at the head: the only when unset reading of ObjectKanbanPropsSchema.limit and "the describe now says WHY" (component.zod.ts:3199 carries the "and on this face unset is the whole rule" clause); the ElementDataSourceGate.tsx:316-331 / :192-194 / element-data-source.ts:237-241 anchors; "No view document declares a flat limit and none carries a tombstone for one" (0 limit: members). Added note .changeset/19228-pagesize-fetch-ceiling.md (patch, Clause-②: no): "there is no pager, so pagination.pageSize is the fetch ceiling. Its description now says so, and names the renderer's two obligations" — matches :1105-1107 in substance; "accept set and its default (25) are unchanged" — int().positive().default(25) at base and head, only the describe string is in the diff; "no export or authorable key moves relative to the last published release" — true against 17.4.0 by the PASSed record's tarball reading (the removed exports and keys post-date it). The stale parenthetical "Measured first-hand at the objectui pin this repo builds against (.objectui-sha = 87af769e9)" at :13-14 of the route-record note: .objectui-sha is 87af769e9a… at the old base and 62597c5880… at the merge base, the head and origin/main, so the stated pin value is out of date. The line is NOT in this PR's diff (context on both sides; the same text sits on origin/main), and the measurement's substance holds by main's own re-measurement: the pin-bump commit 48c91e9e46 records ElementDataSourceGate, ListView.tsx, ObjectView.tsx "byte-identical" for the kanban limit rows, and the kanban docblock main re-measured (component.zod.ts:3118-3121 at the head) states "every objectui file this block cites, ElementDataSourceGate, element-data-source.ts, ListView.tsx and ObjectView.tsx included, is byte-identical to 87af769e9". Judged: a stale citation of a pin value, true when written, not a false statement about the contract or the change; not a rewritten sentence; not blocking — carried under ③.

  8. (f) CI at the head — 35 check-runs, 35 distinct names, none duplicated: 32 success, 2 skipped, 1 failure. Success includes all seven required contexts named in AGENTS.md: Lint & Repo Gates, TypeScript Type Check, Test Core (and its 6 shards), Dogfood Regression Gate (and 3 shards), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard; also Auto Label, Build Docs, Check Documentation Links, Check PR Size, Dogfood Verify CLI, Flag docs affected by code changes, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, The card this PR closes must claim this branch, the four Type Check · jobs, filter. Skipped: Console Pin Gate (paths filter; the PR's delta does not touch .objectui-sha, which main bumped and the merge carried) and Packed-tarball smoke (opt-in) (label-gated). Failure: Check Changeset (run 107776960721), whose annotations name exactly .changeset/17393-view-row-ceiling.md and .changeset/19228-view-row-limit-route-record.md with the two-class remedy — the DELIBERATE CORRECTION red, by design (③2).

② Semver level

@objectstack/spec: patch with Clause-②: no on the PR body, the takeover claim 5819348420 and the added changeset — consistent and RIGHT under AGENTS.md step 3. Relative to the last published release (17.4.0): no accept set narrows (the three blocks refused limit there and refuse it now, with a prescription added), no published export moves (the two removed exports never shipped), one description changes — a patch, not a widening (no (widening) would be malformed) and not (narrowing). The deleted note's minor (widening) was for an unpublished widening, so deleting it is the correct level correction rather than a downgrade. No ADR-0087 marker is owed: nothing published is retired (ruling D step 5), and check:adr-0087-registration has no breaking changeset to read. check-changeset-no-major and the level axis read Clause-②: no beside patch — the consistent pair. The .strict() retirement route (delete the key, answer it through guidance) is the one AGENTS.md step 3 and the retirement skill prescribe.

③ Boundary flags

  1. Line budget — under. GitHub additions + deletions = 81 + 519 = 600 changed lines over 16 files, generated files included; git diff --stat 61609edf81 e4204b9616 reads the same. Well under the 5,000 human-merge threshold (AGENTS.md §7 (c), SKILL.md:188). The report's 600 is exact. Non-blocking: the PR body still says "Net: 16 files, +80 / −515" (the PASSed head's numbers) — the seat owns that edit.

  2. Check Changeset red — the DELIBERATE CORRECTION class, all three red-by-design conditions hold, and this record is the confirmation form landing-operations.md:15-16 requires. (i) The source self-describes it: pr-automation.yml route 0 at origin/main (:718-748) says a PR whose .changeset rows are only M/D "LEAVE THIS CHECK RED", refuses skip-changeset, and "'Check Changeset' is not one of the required contexts"; check-empty-changeset.mjs:563/617 prints the two-class text the annotations carry. (ii) The workflow triggers on pull_request only (on: block), merge_group appears 0 times. (iii) PR comment 5791342642 records the gate and the cause, and the takeover comment 5819348420 quotes the maintainer's words with provenance ("同意你处理"; earlier "changeset 你看着更新就行"). landing-operations.md:15 further makes a same-head at-tier PASS that names each corrected note and judges each rewritten sentence the confirmation itself — done in ①7. The PR is a route-0-plus-route-1 shape (corrects two notes AND adds its own patch note under a non-colliding name), which route 0's last paragraph describes exactly. Non-blocking, unchanged from the PASSed record: the job's later steps (ADR-0087 registration, allow-major, level axis) are unreached on this head because this step fails first; the level pair is verified from the body and the changeset instead (②).

  3. Hand-edited generated path — SOUND, prescribed, and gate-verified. authorable-surface/ui.json received the PR's own committed 3-row deletion re-applied onto main's side, exactly the case os-regen-merge.sh step 2 names ("restore those bytes before regenerating"); a bare regeneration cannot reproduce a baseline deletion because build-schemas check (a) refuses it, and check (c) proof 4 is the accepting route for a strict-object key answered by guidance. The head's Type Check · source gates ran check:authorable-surface green on the built merged tree, and ①4 shows main's 13 rows survived. The regeneration commit is separate from the merge commit, as AGENTS.md §11 requires ("a deferral, not a pass"), so "what main brought" and "what the change produces" are readable apart.

  4. Merge hygiene. No force-push, no rebase, the five PASSed commits are intact; the merge commit message states the two resolutions and the reproduction controls; both new commits carry the model-free trailer pair (Claude-Session: + Co-authored-by: Claude) AGENTS.md requires; a model-identifier sweep over both commit messages, the PR title and body, and every added line of git diff 61609edf81 e4204b9616 returns 0 (control: the pattern fires on a known model id).

  5. Non-blocking, carried by the seat: (a) the stale .objectui-sha = 87af769e9 parenthetical in .changeset/19228-view-row-limit-route-record.md:13-14 — on origin/main too, outside this PR's diff, substance upheld by main's own re-measurement (①7); since the note is already in this PR's correction set, the dev's suggested wording ("at the objectui pin this repo then built against (87af769e9; byte-identical for both files at 62597c588)") would close it in the same stroke, but it is not a false pending-release statement and does not block; (b) the pin file's 789 to 790 entry says "that same row ceiling", whose antecedent is the 785 to 784 spec: declare an author-settable row ceiling for gallery (and kanban) view configs — the protocol lacks the knob objectui#7390 was ruled to read (principle: 协议不正确的先改协议) #17393 entry three entries up (it was two entries up at the PASSed head) — pre-existing distance, prose only; (c) the follow-ups the PASSed record carried still stand: Studio form hides pagination for non-grid kinds ([finding] Studio's view form shows pagination only for grid views — after ruling D (#19228) the one row bound of a kanban, gallery or timeline view cannot be set from the form #19814 filed), and objectui's plugin-view route fetches with a hard-coded $top: 100 with no live carrier since objectui#7390 closed not_planned; (d) the PR is still draft: true; needs:contract-review is not on it.

  6. Not measured here, stated plainly: objectui at either pin (outside the inputs — ①7 rests on main's recorded re-measurement); the 17.4.0 tarball (the PASSed record's reading, whose premise — publish date before feat(spec): declare the author-settable row ceiling for the page-shaped view configs #19226 — is unchanged, npm per the dev report still latest 17.4.0); no parse probe re-run (schema source byte-identical to the PASSed head per ①1).

Implemented-by: claude/issue-19228-view-row-ceiling-retire
Reviewed-by: session_019c3Hi6ZMU1p6m6aA6Bz45d

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Maintainer confirmation — the DELIBERATE CORRECTION of two pending notes, 2026-09-24T23:05Z

domain:spec seat 4 (session_019c3Hi6ZMU1p6m6aA6Bz45d), holder of #19228 since takeover. Carrying the maintainer's words to the PR.

Provenance.

What it confirms, at head e4204b9616. It is the request seat 5 made in 5791342642:

  • .changeset/17393-view-row-ceiling.md is deleted. The per-kind view limit it announced was never published, and this PR retires it.
  • .changeset/19228-view-row-limit-route-record.md is trimmed. The title drops "and record where the per-kind view limit actually lands", the no-precedence sentence and the kanban.limit: 50 parse example go, and so does the whole "Where the per-kind VIEW limit lands" section.
  • .changeset/19228-pagesize-fetch-ceiling.md is this PR's own new note, not a correction.

The at-tier PASS 5820943315 names this head and read these edits.

The red. Check Changeset is red by design (#17712 / #18375). It is not a required context, and there is ⛔ no skip-changeset. Every other check-run is green. mergeable: true. check-governed-merges.mjs --pr 19809: 0 of 16 paths governed, 600 lines.

Landing. Not in this act. The allow-listed ccr/ready_for_review is denied by the session classifier (5819399538; repeated today, recorded on #19727). The PR stays draft until the maintainer clears that. After the merge, #19228 is closed by hand, because this PR says Part of.


Generated by Claude Code

Two hand-written conflicts, both keeping both intents:

- packages/spec/src/ui/view.zod.ts: main re-measured the citations of the
  per-kind view `limit` docblock at the new console pin; this branch deletes
  that docblock and its helper with the key. Kept the branch side; main's
  re-measured text goes with the key it describes. Every other main change to
  the file auto-merged.
- packages/spec/src/ui/component.zod.ts: main re-measured the anchors of the
  object-kanban "third door" paragraph (the view-face `kanban.limit` spread);
  this branch deletes that paragraph with the key. Kept the branch side, and
  annotated main's re-measure list in the same docblock: the seven anchors it
  records for that paragraph are no longer cited by the block.

Generated artifacts are regenerated in the next commit.

Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
The os-regen driver kept the branch side of content/docs/references/data/object.mdx
and content/docs/references/ui/view.mdx, dropping main's rows (the currency
`scale` retirement, the form `options` describe, the console-pin `span`
citation). os-regen-merge.sh step 2 restored main's side; gen:docs then
re-derived the branch's change on top. Every regen path now differs from
main by exactly the branch's own delta against its previous base.

Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

objectstack-fleet Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 104/104 CONTRACT_REVIEW_TIER
Head-sha: b7106a4781f3d59f9928d83dec7d6b1927f954f5

Isolated at-tier reviewer subagent, run by the domain:spec seat-4 session; every one of its 104 transcript turns served at the tier the constant names. Review of the merge round's head (origin/main at 5b9402d89b merged in to clear the conflict with #19906 / #20036). The PASS 5820943315 on e4204b9616 stands for the PR's own change; this record governs b7106a4781, and its (d) shows the three changesets byte-identical to the head the maintainer's confirmation 5823701100 names. Adopted by the seat 2026-09-25T02:15Z. The record below is the reviewer's, unedited except the two header lines.

Reviewed 2026-09-25 by the isolated adversarial reviewer subagent of session session_019c3Hi6ZMU1p6m6aA6Bz45d, against ruling D (5789634193) and its supplement (5790419773), for the merge round only: the PASS 5820943315 on e4204b9616 stands for the PR's own change and is not re-litigated here. Read: card #19228 body and all 26 comments (ruling D, supplement, takeover 5819348420, dev reports 5820697430 and 5825368952); PR #19809 body, its 16 REST files (+86 / −528), all 6 comments (5791342642, 5817031629, the PASS 5820943315 in full, the confirmation 5823701100 in full), the head's 42 check-runs and both Check Changeset runs' annotations, the 17 workflow runs at the head; at origin/main (66960564d9): AGENTS.md (§7 required contexts and 5,000-line threshold :505-521, §10-§11 merge rules :568-605, changeset step 3 :1069-1085, trailer rule :452-453), scripts/pm/os-regen-merge.sh (header :1-260), packages/spec/scripts/build-docs.ts and scripts/lib/format-type.ts:1089-1098, .gitattributes:140-157, lint.yml:5084/5452-5453/5526/5544/5563-5564/6307/6510/6858-6864. Ran, on refs only (git fetch origin main; git fetch origin claude/issue-19228-view-row-ceiling-retire:refs/review/pr-19809 -f, a fast-forward e4204b9616..b7106a4781), scratch under scratchpad/pr-19809/review3/: per-file diff-text and +/- multiset comparison of git diff 61609edf81 e4204b9616 against git diff 5b9402d89b b7106a4781 (multiset.py), a per-line survival count of every line main, #19906 (655e8c07d9) and #20036 (0bf85eaae6) added to the two conflicted files (survival.py), the same for the three source commits' rows in the two regenerated pages (mdxsurv.py), word-bounded anchor counts for the C2 note, retired-symbol greps at the head and at origin/main, blob-id comparison of the three changesets across five refs, a model-identifier sweep with a firing control. NOT re-run by rule: any gate, generator or test suite (the head's CI and the dev's recorded runs are read, not reproduced). NOT measured: objectui at any pin; the npm registry (outside the listed inputs; the unpublished premise is judged from the repo's own release state, ①4).

① Derived judgments

  1. (a) The merge is faithful: the PR's delta against 5b9402d89b is the PASSed delta against 61609edf81 in 14 of 16 files byte for byte, and the other two differ only where the conflict forced a choice — RIGHT, the dev's multiset claim reproduced and extended. Graph: git log --format='%H %P' -3 refs/review/pr-19809 gives b7106a4781 (parent 77b9fc150e), 77b9fc150e (parents e4204b9616 + 5b9402d89b), and git rev-list --first-parent b7106a4781 | grep -c e4204b9616 = 1: no rebase, the PASSed head is a first-parent ancestor, the push was a fast-forward. git merge-base origin/main refs/review/pr-19809 = 5b9402d89bb1d9d5c5ac6620e5d199da3851d7c7, which is the PR's REST base.sha. git diff --stat 61609edf81 e4204b9616 = 16 files, +81/−519; git diff --stat 5b9402d89b b7106a4781 = 16 files, +86/−528; REST agrees (+86/−528). multiset.py (index and @@ lines stripped, CONTEXT KEPT): IDENTICAL for all three changesets, all four .mdx, all four ui.json, type-alias-convention.pin.test.ts, component.test.ts, view.test.ts; MULTISET-DIFF only for component.zod.ts (old +5/−79, new +10/−80: +5 = the C2 note lines; deletions differ by 6 old-base lines out and 7 main lines in) and view.zod.ts (old +12/−202, new +12/−210: additions identical; deletions differ by 31 old-base lines out and 39 main lines in). Of the 16 files, main touched 5 between the bases (object.mdx +2/−2, view.mdx +4/−4, component.test.ts +47/−29, component.zod.ts +206/−126, view.zod.ts +165/−42); the other 11 are byte-identical between e4204b9616 and b7106a4781 (git diff --quiet per file). Every file outside the 16 equals origin/main at the head, because git diff --name-only 5b9402d89b b7106a4781 lists exactly the 16. The pin file: git log 61609edf81..5b9402d89b -- packages/spec/src/type-alias-convention.pin.test.ts is empty, so toHaveLength(790) at head :2288 (main :2284 reads 789) has no count drift to absorb.

  2. (b) C1 and V1 drop only main lines that sit inside text documenting the retired per-kind view limit; nothing main added elsewhere is lost; C2's note is true in its stated scope — RIGHT. survival.py: of the 165 lines main added to view.zod.ts since the old base, 39 have zero copies at the head, and every one sits at main :1466-1551, inside the docblock :1437-1562 whose declaration is const rowLimitKey at :1563 (the helper ruling D step 2 deletes) — the "WHAT THIS VIEW-FACE KEY REACHES TODAY" re-measure (:1464 onward), the 14-line control listing, the four flattening spreads (:1524-1527), the three "A view's kanban.limit / timeline.limit / gallery.limit" verdict paragraphs and the $top paragraph (:1547-1551). Keeping any of them would leave a sentence about a key that no longer parses. Of the 206 lines main added to component.zod.ts, 7 have zero copies at the head: main :3220-3222, :3224, :3225, :3227 — the six re-measured anchors inside the "THIRD door" paragraph :3214-3230, which is the paragraph that explains how a VIEW's kanban.limit is spread onto the node (the PR deletes it, as at the PASSed head) — and main :3148, the C2 line the note splits, whose words all survive at head :3148 and :3152. fix(spec): the form option-value refusal and the options describe name the derive path for enum members that cannot be spelled #19906: 106 lines added to view.zod.ts, 0 missing at the head; 0 added to component.zod.ts. chore(objectui): bump the console pin to f8a9d0fb0596 (carries objectui#10221 and the objectui#9910 re-cut) with re-measured pin citations and the lockstep re-record #20036: 59 added to view.zod.ts, 39 missing = exactly V1's set above; 206 added to component.zod.ts, 7 missing = exactly C1's six plus the C2 split line — 199 present byte for byte. git diff 5b9402d89b b7106a4781 -- packages/spec/src/ui/component.zod.ts has 6 hunks and every one is the PASSed change or C2: the C2 note (:3148-3152), the dropped kanban.limit: 50 parse example (:3212), the third-door paragraph replaced by the one branch line ⭐ The arm is REACHABLE: … (:3218, byte-identical to the PASSed head since additions are identical), the two object-timeline view-face passages, and the object-timeline timeline describe (:4290, whose key list { startDateField, endDateField, titleField, groupByField, colorField, scale } equals TimelineConfigSchema's six members at view.zod.ts:1402-1413). C2 note truth: within the limit docblock (:3132 to the limit: declaration at :3230), word-bounded git grep of :554, :565, :687 at the head hits only the re-read list :3144 and the note itself :3148; ListView.tsx:3067, ObjectView.tsx:1666, ListView.tsx:3040, ObjectView.tsx:1607 hit 0 lines file-wide (each was 1 on main, inside C1). On main those seven anchors' only citation outside the list was the third-door paragraph (:3220-3227), so "they anchored the view-face kanban.limit spread … so this block no longer cites them" is true. The word-bounded sweep also finds :554 at head :3093 and ObjectKanban.tsx:687 at :3095 — those are in the SEPARATE ObjectKanbanPropsSchema header docblock (:3075-3097, identical main vs head by diff), citing the element-face $top and refused-cap report, which is still true and outside the note's "this block" (③5b). Main's object-timeline re-measure preamble at head :4196-4207 names ObjectTimeline.tsx, index.tsx, renderer.tsx; all three are still cited in that block (:4193-4194, :4238-4242, :4267), so it stays true; the passage the PR deletes there (:4226-4230, :4258-4300 in the head-vs-main diff) is old-pin 87af769e9 text main had not re-measured (its deletion lines are identical between the old and new delta), so nothing of chore(objectui): bump the console pin to f8a9d0fb0596 (carries objectui#10221 and the objectui#9910 re-cut) with re-measured pin citations and the lockstep re-record #20036 is lost there.

  3. (c) The regenerated object.mdx / view.mdx are the generator's output for the merged sources, by diff algebra and by the head's gate runs — RIGHT. .gitattributes:153 routes content/docs/references/** to merge=os-regen; both sides changed the two pages, so the driver kept the branch side in the merge commit (git diff --stat 5b9402d89b 77b9fc150e on the two = 16/25 lines, main's rows dropped, exactly the drop os-regen-merge.sh:12-18,105-111 describes). The regeneration commit b7106a4781 touches only those two files (git show --name-only), +6/−6, and its diff is precisely main's six rows: the two fix(spec,objectql)!: retire scale from the currency field type — refused at parse, no longer enforced on writes #19909 currency scale rows in object.mdx:233/:565 ("REFUSED on a currency field"), the two fix(spec): the form option-value refusal and the options describe name the derive path for enum members that cannot be spelled #19906 options rows in view.mdx:184/:349 and the two chore(objectui): bump the console pin to f8a9d0fb0596 (carries objectui#10221 and the objectui#9910 re-cut) with re-measured pin citations and the lockstep re-record #20036 span rows in view.mdx:202/:367. mdxsurv.py: every line 5b9402d89b, 655e8c07d9 and 0bf85eaae6 added to those pages is present at the head (0 missing). The head-vs-main diff of both pages is IDENTICAL (context kept) to the PASSed delta: 7 limit rows removed, 3 pageSize rows carry the truncation obligation, and the kanban inline summary loses its trailing ; … — correct under format-type.ts:1094 (… only when more keys exist than are shown) because KanbanConfigSchema at head view.zod.ts:1690-1717 has exactly the four keys shown. Generator not re-run here; at this head Type Check · source gates (107904396333, success) runs check:generated --reconcile-only (lint.yml:5452-5453), check:export-origins (:5526), check:authorable-surface (:5544) and check:docs (:5563-5564); Type Check · consumer gates (107904396260, success) runs check:api-surface (:6510); the required roll-up TypeScript Type Check (:6858-6864) is success; Build Docs is success. The four ui.json artefacts were not moved by main, so no hand re-apply arose this round (dev's claim confirmed: main: untouched for all four).

  4. (d) The two DELIBERATE CORRECTIONS and the PR's own note are byte-identical to the confirmed head, and the key is still unpublished — RIGHT. git diff --stat e4204b9616 b7106a4781 -- the three changesets is empty; blob ids: .changeset/19228-view-row-limit-route-record.md = bd49b28b24 at both heads (4693c232b4 on main), .changeset/19228-pagesize-fetch-ceiling.md = d67a04006c at both heads (absent on main), .changeset/17393-view-row-ceiling.md absent at both heads and present on 61609edf81, 5b9402d89b and origin/main as blob 675b2c05b0 ('@objectstack/spec': minor, "each gain a limit member … default 100 … DEFAULT_VIEW_ROW_LIMIT is exported"). git log 61609edf81..origin/main -- the three notes is empty, so no foreign edit is overwritten and the COLLISION class stays excluded. So 5823701100 (which names head e4204b9616) still describes exactly what lands. Unpublished: origin/main:packages/spec/package.json reads 17.4.0, CHANGELOG.md tops at ## 17.4.0, and the 17393 note is still pending, so no release has consumed it; npm not re-read here (outside the inputs), the dev report 5825368952 and both prior records read latest = 17.4.0 unchanged since 2026-09-09. origin/main readers of the removed symbols outside the PR's paths: git grep finds only the generated api-surface/ui.json:110/:224 and export-origins/ui.json:107/:220 rows, which are the PR's own deletions; 0 readers of a per-kind .kanban/.gallery/.timeline .limit.

  5. (e) Nothing the merge carries is false at the head; CI is green except the by-design red. Retired symbols at the head: git grep for rowLimitKey|DEFAULT_VIEW_ROW_LIMIT|ROW_LIMIT_SUBJECT|RowLimitView|KanbanConfigParsed over packages/spec, content/docs, .changeset, scripts hits only the three history comments in the pin test (:1576, :2244, :2286), as at the PASSed head; kanban.limit is spelled once (component.zod.ts:3150, the C2 note, describing it as retired — true); ^\s*limit: in view.zod.ts = 0 members; the seven component-face limit: declarations are the same seven lines on main and at the head (:1214/:1406/:1607/:1824/:2470, then :3247 to :3230 and :4372 to :4302, shifted only by the PR's deletions). pagination.pageSize at view.zod.ts:1104-1108 still int().positive().default(25) with the obligation; VIEW_ROW_BOUND_GUIDANCE at :1373-1375 wired at :1384/:1400/:1688. .objectui-sha is f8a9d0fb0596… at both 5b9402d89b and the head (main's bump carried; the PR's delta does not touch it, which is why Console Pin Gate is path-skipped). Main's carried pending notes (console-f8a9d0fb0596.md, 20029-pin-bump-describe-correction.md, 19678-…, 19629-…) mention no per-kind view limit. CI at the head: 42 check-runs from 17 workflow runs (two PR Automation runs, ③2): 36 success, 4 skipped, 2 failure. Success includes all seven required contexts of AGENTS.md:516-518 (Lint & Repo Gates, TypeScript Type Check, Test Core + 6 shards, Dogfood Regression Gate + 3 shards, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard), the four Type Check · jobs, Build Docs, Spec property liveness, Check Documentation Links, the claim guards. Skipped: Console Pin Gate (paths), Packed-tarball smoke (opt-in) (label), and the second run's Auto Label / Check PR Size (their first-run conclusions are success). Failure: Check Changeset ×2 (107904396031, 107912799414), whose annotations name exactly .changeset/17393-view-row-ceiling.md and .changeset/19228-view-row-limit-route-record.md with the two-class text — the DELIBERATE CORRECTION red (③2). GitHub: mergeable: true, mergeable_state: unstable (the advisory red).

  6. origin/main has moved 3 commits past the merge base (b76aad5f6f fix(client)!: every limit query-parameter emitter sends what the caller wrote #20060, 5dba7f3bd0 fix(objectql)!: a field-level requiredWhen / readonlyWhen that cannot be evaluated refuses the write (ADR-0137 D2) #20028, 66960564d9 fix(lint): walk page filterBy and lookup-field lookupFilters as authored filters #19818) — no effect. Their 27 changed files intersect the PR's 16 paths in 0 (comm -12); under packages/spec they add five unrelated changesets and two migration-registry files.

② Semver level

Unchanged from the PASSed record: @objectstack/spec: patch with Clause-②: no on the PR body and in .changeset/19228-pagesize-fetch-ceiling.md, byte-identical to the confirmed head (①4). The merge moved nothing on the level axis: the incoming main commits touch no changeset of this PR, the accept-set edit is still the three blocks refusing limit with guidance (unpublished key, never shipped in 17.4.0) plus one description change on pageSize, and the deleted note's minor (widening) was for that unpublished widening. No ADR-0087 marker is owed (ruling D step 5), and the .strict() retirement route (delete the key, answer it through guidance, AGENTS.md:1077-1080) is unchanged. check-changeset-no-major and check:adr-0087-registration are unreached in CI because Check Changeset fails first (same as at e4204b9616); the dev report records both green locally.

③ Boundary flags

  1. Line budget — under. 614 changed lines (+86/−528, 16 files, generated included) vs 600 at e4204b9616; well under the 5,000 human-merge threshold (AGENTS.md:508). The +5/−1 is the C2 note; the −8 is view.zod.ts deleting main's 39 re-measured lines in place of the base's 31. The PR body now reads "Net: 16 files, +86 / −528." — current.

  2. Check Changeset red — the DELIBERATE CORRECTION class, by design, twice on this head. The second PR Automation run (36084349981, 01:59:11Z, actor objectstack-fleet[bot]) follows a PR edit at updated_at 01:59:08Z (no new commit; the issue timeline shows only the two commits), so both runs read the same head and fail on the same two annotations. The confirmation 5823701100 carries the maintainer's words ("同意你处理") for exactly these two notes, and ①4 shows the notes landing are the bytes it confirmed. Not a required context; no skip-changeset.

  3. Merge hygiene — clean. Merge commit + separate regeneration commit, as AGENTS.md:595-600 and os-regen-merge.sh steps 1-4 prescribe; the regeneration commit touches only the two driver-deferred pages. Both new commit messages carry the model-free pair (Claude-Session: + Co-authored-by: Claude); a sweep for Fable|Opus|Sonnet|Haiku|claude-[a-z]+-[0-9]|anthropic-ai|us.anthropic over both messages and every added line of git diff 5b9402d89b b7106a4781 returns 0 hits (the control fires on a planted identifier). No force-push, no rebase.

  4. Not this PR's, recorded plainly (non-blocking): the dev's class-a finding that the merge-tree bare-clone probe of AGENTS.md §11 / os-regen-merge.sh:248-250 fails from a shallow checkout; the conflict set was not re-probed here — the multiset evidence (①1: exactly two files whose delta is not the old delta, both explained line by line) corroborates "exactly the two dispatched files conflicted".

  5. Non-blocking notes, carried by the seat: (a) .changeset/19228-view-row-limit-route-record.md:14 still cites the pin as 87af769e9 and :18 cites ElementDataSourceGate.tsx:316-331, which chore(objectui): bump the console pin to f8a9d0fb0596 (carries objectui#10221 and the objectui#9910 re-cut) with re-measured pin citations and the lockstep re-record #20036 moved to :373-388 at f8a9d0fb0 — the sentence names its pin so it is true as a reading at that pin; the line is context on both sides of this PR's diff and identical on origin/main; the same note was carried non-blocking in 5820943315 ③5a, and the merge did not change it; (b) the C2 note's "are that re-read's record only … this block no longer cites them" is scoped to the limit docblock, while :554 and ObjectKanban.tsx:687 remain live element-face anchors in the ObjectKanbanPropsSchema header docblock 35 lines above (:3093, :3095); true as written, but a reader could take "only" file-wide — prose, no contract effect; (c) the merge commit message's "both keeping both intents" is loose (main's re-measured anchors were dropped with the paragraphs, as the next sentences state); (d) the PR is still draft: true, needs:contract-review is not on it, and landing waits on the maintainer per 5823701100; (e) the follow-ups the PASSed record carried still stand (Studio form hides pagination for non-grid kinds, [finding] Studio's view form shows pagination only for grid views — after ruling D (#19228) the one row bound of a kanban, gallery or timeline view cannot be set from the form #19814; objectui's $top: 100 route with objectui#7390 closed not_planned).

  6. Not measured here, stated plainly: npm registry state (①4 rests on the repo's release state plus the dev's and prior records' reading); objectui at any pin; no gate, generator or suite re-run (①3 and ①5 rest on the head's CI conclusions and diff algebra).

Implemented-by: claude/issue-19228-view-row-ceiling-retire
Reviewed-by: session_019c3Hi6ZMU1p6m6aA6Bz45d

VERDICT: PASS


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 25, 2026 02:17
@os-litant
os-litant enabled auto-merge September 25, 2026 02:17
@os-litant
os-litant added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit a4ca69a Sep 25, 2026
42 of 44 checks passed
@os-litant
os-litant deleted the claude/issue-19228-view-row-ceiling-retire branch September 25, 2026 02:57
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…tstack-ai#19841)

Fixes objectstack-ai#19814

Clause-②: no

## What changes

`pagination.pageSize` is the row bound every view type carries, and
maintainer ruling D on objectstack-ai#19228 makes it a view's one row bound. The
per-kind `kanban.limit`, `gallery.limit` and `timeline.limit` that the
ruling retires are still declared on `main` until PR objectstack-ai#19809 lands. The
Studio view form (`packages/spec/src/ui/view.form.ts`) offered
`pagination` only inside `table_options`, whose condition is `data.type
== 'grid' || data.type == null` and whose description is `Grid-only
display options.`. An author editing any other view type could not see
or set that bound in the form.

- `pagination` moves out of `table_options` into its own section,
`pagination` (label `Pagination`, collapsible, collapsed), placed right
after `table_options`. The new section has **no** `visibleWhen`.
- `table_options` keeps `resizable`, `compactToolbar`, `rowHeight` and
`selection` under its unchanged grid condition.
- **Why a new section and not one of the other two options.** Widening
`table_options`' condition would show the four grid-only fields to every
view type. Moving `pagination` into `columns_filters` would put a field
that is neither a column nor a filter under the label `Columns &
filters`. The file already gives each concern its own section. The new
description claims only what the schema guarantees: `Page size and
page-size options — every view type accepts them, not only grids.`
- No schema change. `view.zod.ts`, `component.zod.ts` and every per-kind
`limit` are untouched; they belong to PR objectstack-ai#19809.

## Premise, measured on `origin/main` `c1dfa5241b`

- The view `type` enum is on the list-view shape
(`ListViewSchema.shape.type`). The container `ViewSchema` has no `type`
key, and its `list` member resolves to the same nine values: grid,
kanban, gallery, calendar, timeline, gantt, map, chart, tree.
- `pagination` is a member of the single list-view shape, so every kind
has it. Per kind, `ListViewSchema.safeParse({ type, columns: ['name'],
pagination: { pageSize: 50 } })` parsed 9/9 and kept `{ pageSize: 50 }`.
As a control, `pagination: { pageSize: 50, zzBogus: 1 }` was refused 9/9
with `unrecognized_keys` at `['pagination']`, which shows the block is
validated and not stripped.
- At base, `view.form.ts` has no per-kind `limit` entry. Nothing PR
objectstack-ai#19809 retires is in the form, so this change does not depend on a shape
that PR has not landed.

## Tests

`packages/spec/src/ui/view-form-pagination.test.ts`, 58 cases:

- The kind list is read at runtime from `ListViewSchema`'s `type` enum.
A floor names the four types ruling D covers, so an empty derivation
cannot pass.
- For every kind, and for a view with no `type` yet, `pagination` is
offered in exactly one visible section. The form offers it exactly once.
- For every kind, the schema accepts a `pagination` block and keeps it.
- The four grid-only fields are visible for `grid` and for an unset
type, and hidden from each of the eight non-grid kinds.
- Section predicates are read by a small reader limited to the one
grammar this form uses: disjunctions of `data.type == '...'` and
`data.type == null`. Any other term throws and names the predicate, so
the pin fails loudly on a predicate it cannot read.

**Firing control** (a one-time run, not a kept test). With the fix
committed, `packages/spec/src/ui/view.form.ts` was restored to its base
blob `bea0c5ab92` with `git restore --source` set to the base commit,
and the pin was run. Result: `Tests 8 failed | 50 passed (58)`. The
eight failures are exactly the "is visible for type" cases for kanban,
gallery, calendar, timeline, gantt, map, chart and tree. The file was
then restored from `HEAD` under a trap; its blob was re-checked equal to
HEAD's (`bf6c3a7f93`) and `git status` was clean.

## Generated artifacts

One repo generator reads `view.form.ts`: `pnpm i18n:extract`. It reaches
the form through `METADATA_FORM_REGISTRY` and writes
`packages/platform-objects/src/apps/translations/*.metadata-forms.generated.ts`.
A search of the tree for the form's section text finds it only in the
source and in `en.metadata-forms.generated.ts`. The `viewForm` export
keeps its name, and `check:api-surface` reports the surface unchanged.

- `17bb7c1603` is the generator output, unedited. It adds the new
section's label and description to all four bundles, fills the three
translated locales from the source, and adds two
`metadataForms.view.sections.pagination.*` rows per locale to the
source-hash tables.
- `72185706b2` writes the zh-CN, ja-JP and es-ES translations. These
leaf values are the only edits these files allow. Each label reuses the
locale's existing label for the `pagination` field. A re-run of the
extractor then dropped the source-hash rows by itself, and `pnpm
check:i18n` reports the bundles in sync at head.
- `2bfeee4a23`: `object-lifecycle-panel-echo-decisions.test.ts` pins the
per-locale count of translated `.label` leaves across the metadata-form
catalog. The count moves from 583 to 584, which is the one section label
this PR translates in each locale.

## Changeset

- `@objectstack/spec` ships `dist` in its `files[]`. `viewForm` is
exported from `./ui` and reaches `./system` through
`METADATA_FORM_REGISTRY`. After a build, the new description string is
in 6 dist files (`ui/index.{js,mjs}`, `system/index.{js,mjs}`,
`browser/system/index.{js,mjs}`). The positive control `Grid-only
display options` is in the same 6 files.
- `@objectstack/platform-objects` also ships `dist` in its `files[]`.
After its rebuild the new string is in 6 dist files (`index`, `plugin`
and `metadata-translations/index`, each `.js` and `.mjs`); the control
is in the same 6.
- Both packages ship the change, so
`.changeset/19814-view-form-pagination-all-kinds.md` bumps both as
`patch`. AGENTS.md Post-Task step 3: a fix in a released package takes a
patch changeset, and `skip-changeset` is only for a diff that publishes
nothing. `Clause-②: no`, because no accept set moves.

## Local verification

- **Gate families** at head `0395fd696d` (re-run after round 2; first
measured at `2bfeee4a23`, same result): `node
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 84 commands. All 84 were run on `2bfeee4a23` and all
exited 0. `--ran`, fed the recorded exit codes, reports: "84 derived
famil(ies) accounted for — 84 run, 0 NOT-MEASURED (a DERIVED zero — all
84 recorded an exit code and none of them is 3)". On the first pass,
`check:i18n`, `check:type-check-debt` and `check:dual-build-cjs-loads`
exited 3 (build prerequisite not met). They were measured after `turbo
run build --filter='./packages/*' --filter='./packages/*/*'`.
- **`@objectstack/spec`**, run at `fe2ae4cf09` (the spec package has no
byte change since):
  - `vitest run --project local`: 522 files, 15405 passed, 1 todo.
- `typecheck`: exit 0. The test layer holds 53 files / 255 errors in the
ledger, and the new test is inside the `tsconfig.test.json` program.
- **`@objectstack/platform-objects`** at `2bfeee4a23`: vitest 54 files,
883 passed. `typecheck` exit 0.
- **Narrowed lint.** This is not the repo-wide `pnpm lint`, which CI
owns. `eslint --no-inline-config --format json` over the 7 touched `.ts`
files returned 7 file results, 0 errors, 0 warnings, and none of the
files was ignored. The repo's one `eslint.config.mjs` enables no
type-aware linting (its note at `:326-328`), so this diff cannot change
the lint result of any file it does not touch.
- **Left to CI**: the 7 families whose values come from the workflow
(`check-issue-citations`, shard attestations, test completeness), the 11
declared-wide families, and `pnpm lint`.

## Acceptance notes

- The renderer side is not this card. At the objectui pin `87af769e9`,
`plugin-list/src/ListView.tsx` reads `schema.pagination?.pageSize` into
the `$top` of every list fetch (`:1307-1312`, `:2356`). For non-grid
views it shows a rows-per-page selector when `pageSizeOptions` is set
(`:4866`). The one exception is a gantt view with an `api` data
provider, which does its own fetch (`:1950-1955`). This PR does not
change the plugin-view route's hard-coded `$top: 100`, which is recorded
in review record `5791323483` on PR objectstack-ai#19809.
- The form still has no section for the `map` and `tree` blocks that the
list-view shape declares. That falls under the top-level "declared but
not offered" class, which `metadata-form-declared-rows.pin.test.ts`
counts as its own census, not under this card.

---
_Generated by [Claude
Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…tor at authoring time (objectstack-ai#19861)

Fixes objectstack-ai#19751

Clause-②: no (narrowing)

## What changes

`checkViewFilterRuleValueShape` (the value-shape refinement of
`ViewFilterRuleSchema`, `packages/spec/src/ui/view.zod.ts`) now refuses
a rule with NO `value` on every operator that takes one. Its scalar arm
returned early on `value === undefined` for every operator, so `{ field:
'name', operator: 'icontains' }` parsed green, while the key's published
description says every operator outside `in` / `not_in` / `between` and
the four unary operators takes a scalar, and the query path refuses the
lowered rule with `400 INVALID_FILTER`.

- The four unary operators (`is_empty`, `is_not_empty`, `is_null`,
`is_not_null`) are answered first and stay valueless, with or without a
value.
- `in` / `not_in` / `between` keep their own arms, which already refused
an absent value.
- The key stays `.optional()` on the shape; the coupling lives in the
refinement, like the other arms.
- The `value` `.describe()` is unchanged (it already declares this
contract), so no generated reference page moves.
- The code comment above the scalar arm, which named an absent value as
a carve-out "the query path itself makes", now says the opposite and
why. The docblock's "mirrors the query path" list and its
runtime-wording section name the new arm.

Refusal text, one issue at the rule's `value` path:

> Filter comparand for operator "icontains" on field "name" is
undefined. The rule carries no value, and "icontains" compares the field
against one — write the value to compare against, or, if the rule means
the field has no value, use an operator that takes none ("is_empty" /
"is_not_empty" / "is_null" / "is_not_null"), which reads its direction
from its name. This is refused at authoring time because the query path
refuses it too (400 INVALID_FILTER).

The leading sentence is the runtime's undefined-comparand sentence
("Filter comparand at PATH is undefined.") with the location named in
the view vocabulary: operator and field, the same substitution the list
and range arms already make. A view rule has no `where` path, and the
`$` spelling in that path is not one a view author can write. The unary
operator names in the tail come from the schema's own
`VIEW_FILTER_VALUELESS_OPERATORS`.

## Producer reading (step 1): objectui at the pinned `.objectui-sha`
`87af769e9a3ee28ace099fdd653d3ebd79fe82e2`

Read with `git show SHA:PATH` from a local clone at that sha, not from a
working tree.

**Does the console ever save a value-taking rule with no `value`? No.**

| writer | file at the pinned sha | what happens to a half-filled row |
|---|---|---|
| `foldFilterGroupToSpecRules`, the one fold every view-filter writer
shares | `packages/app-shell/src/views/viewFilterFold.ts` | a row whose
operator takes a value is dropped when `isFilterValueComplete(operator,
value)` is false (`if (takesValue && isMissingValue(...)) continue`) |
| `isFilterValueComplete` |
`packages/components/src/custom/filter-builder.tsx` | false for `value
== null` (also `''`, `[]`, a half-filled pair), so an absent value is
always incomplete |
| `FilterBuilderField` / `FilterBuilderWidget`: the `filter-builder`
widget that `view.form.ts` names for `filter` and `page.form.ts` for
`filterBy`, plus the per-tab filter editor |
`packages/app-shell/src/views/metadata-admin/widgets.tsx` | calls the
fold on every change; the runtime `ViewConfigPanel` hosts the same
inspector (`ViewConfigPanel.tsx`, `ViewVariantInspector`) |
| list toolbar | `packages/app-shell/src/views/ObjectView.tsx` | no
automatic write at all (its docblock: "There is deliberately NO
persistViewFilter"); explicit saves go through the fold |
| drill-down "Save as view", `foldUrlFilterTriplesToSpecRules` |
`packages/app-shell/src/views/ObjectDataPage.tsx` |
`ViewFilterRuleSchema.safeParse` per rule, refused rules dropped; the
URL triples (`drillUrlFilters.ts`, `parseUrlFilterTriples`) skip an
empty param and always carry a value |

One edge, stated rather than hidden: `handleViewConfigSave`
(`ObjectView.tsx`) persists the config draft whole. A view whose STORED
body already carries such a rule (hand-authored, or written by another
tool) and is re-saved through the panel without its filter being touched
now gets the refusal at save. That view already fails every query today
(next table).

**Does anything drop a valueless row between storage and the query?
No.**

| layer | file | reading |
|---|---|---|
| console lowering: `viewFilterRuleToNode`, behind `toFilterNode` /
`mergeFilterNodes` (plugin-list `buildEffectiveFilter`, plugin-view
`ObjectView`, `ObjectGrid`, `RelatedList`, `LineItemsPanel`) | objectui
`packages/core/src/utils/filter-converter.ts` | a rule without `value`
lowers to the 2-tuple `[field, operator]` and nothing skips it; its own
comment records the runtime throwing `INVALID_FILTER` / 400 for
`['name','icontains']` |
| REST lookup-picker route: `lowerViewFilterRule` | this repo,
`packages/rest/src/view-filter-rule-lowering.ts` | the same 2-tuple; the
module forwards and never drops |
| query normalizer | this repo,
`packages/metadata-protocol/src/protocol.ts` | `isFilterAST`, then
`parseFilterAST`, which throws |

Measured on this tree's spec source (4112752): `isFilterAST(['and',
['name','equals'], ['status','equals','open']])` is true, and
`parseFilterAST` of it throws `INVALID_FILTER` / 400, "Filter comparand
at where.$and[0].name is undefined". One valueless rule fails the WHOLE
view's query, its good rules included.

So no working flow saves or executes this shape, and refusing it at save
breaks nothing that works today.

## Today's behaviour for the whole class (step 2)

Measured at `origin/main` 4112752 by script. The operator list is
`VIEW_FILTER_OPERATORS` read at runtime; the unary set was derived by
behaviour from the schema's own scalar arm (an array is refused on every
non-list, non-range operator outside the private valueless set).

| operators | `ViewFilterRuleSchema`, `value` omitted, before this
change | `parseFilterAST([field, op])` |
|---|---|---|
| `equals`, `not_equals`, `contains`, `not_contains`, `icontains`,
`starts_with`, `ends_with`, `greater_than`, `less_than`,
`greater_than_or_equal`, `less_than_or_equal`, `before`, `after` (13) |
**ACCEPT** | throws `INVALID_FILTER` / 400, "Filter comparand at
where.name (or where.name.$op) is undefined" |
| `in`, `not_in` | refused by the list arm | throws, "requires an ARRAY
of values" |
| `between` | refused by the range arm | throws, "requires a [min, max]
value array" |
| `is_empty`, `is_not_empty`, `is_null`, `is_not_null` | accept | `{
"$null": true }` / `{ "$null": false }` |

After this change the 13 are refused. The other rows are unchanged.

## ADR-0087 reading (step 4)

- This narrows a published accept set. The repo's rule for that during
the launch window is in the header of
`scripts/check-changeset-no-major.mjs`: the level does not carry
breaking-ness, and "the mandatory information carriers for breaking-ness
in the meantime are the **BREAKING** banner the author writes in the
changeset body and the ADR-0087 migration-ledger disposition".
`scripts/check-adr-0087-registration.mjs` then requires a disposition on
the declared-breaking changeset.
- The disposition is `registered`, not `not-required`. The author has a
hand prescription (write the value, switch to a unary operator, or
delete an unfinished row), and `no-migration-prescription` is refused
for a body that carries one. None of the other categories fits: the
package publishes, no existing entry covers absence, and the surface is
a schema, not a runtime interface or a type surface.
- Precedents: `view-filter-rule-scalar-operator-array-refused` (the
sibling arm of this same check) and
`filter-preset-ordering-comparand-refused` (a shape that never executed
usefully) both registered a semantic entry under protocol major 18.
- Added:
`packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts`.
`packages/spec/src/migrations/registry.ts` was regenerated by `pnpm
--filter @objectstack/spec gen:migration-registry` and not hand-edited;
`check:migration-registry` is green. No D2 conversion: there is no value
to infer.
- `check-adr-0087-registration --base origin/main` reads the changeset
as `[BREAKING+clause-②-narrowing] registered
view-filter-rule-absent-value-refused (new here)`.
- `spec-changes.json` and `docs/protocol-upgrade-guide.md` did not move.
The protocol-18 step stays inert until the protocol major reaches 18,
and `check:spec-changes` / `check:upgrade-guide` are green without
regeneration.

## Changeset (step 7)

`.changeset/19751-view-filter-rule-absent-value-refused.md`, `minor` on
`@objectstack/spec`. `files[]` ships `dist` and `src/**/*.zod.ts`, and
both carry the refinement. Its summary is the user-visible change: a
stored view filter rule with no value on a value-taking operator is now
refused at save instead of failing every query. It carries the BREAKING
banner, a FROM/TO block, `Clause-②: no (narrowing)` and the registered
disposition marker. One sentence names that it reverses the carve-out
the still-pending objectstack-ai#19514 changeset records (an omitted value "still
parses", an absent comparand "is left unjudged"), so the two entries do
not contradict each other in the compiled CHANGELOG. The objectstack-ai#19514 file
itself is not edited.

Level: `minor`. An accept-set narrowing declared `(narrowing)` is
BREAKING (AGENTS.md, Post-Task Checklist step 3), and during the launch
window a breaking change ships as `minor`: the header of
`scripts/check-changeset-no-major.mjs` says "During the launch window we
ship breaking changes as `minor`", and that the BREAKING banner and the
ADR-0087 disposition carry the break, not the level. Both precedents
above shipped `minor` with the same banner. The first round graded this
`patch`; the at-tier contract review (record 5808364674) failed that,
and the patch-round commit ab0104d changes the frontmatter to `minor`
and rewrites the banner sentence to state the convention ("Shipped as
`minor` under the repo's launch-window convention for accept-set
narrowings"). That commit moves no package file.

The PR's `Clause-②: no (narrowing)` line is the claim's, copied
verbatim, and matches the changeset's line. With the arm present, the
level axis of `check-changeset-no-major.mjs` judges the level instead of
standing down. Measured offline with `--event` on this body, it refuses
the first round's `patch` head 22a14a1 (exit 1) and passes `minor` at
ab0104d (exit 0).

## Fixtures, examples and pins (step 5)

- An AST scan of every tracked `.ts` / `.tsx` / `.mts` / `.js` / `.mjs`
/ `.json` outside `content/docs/references/` (1,739 files mention
`operator`) found 311 object literals with `field` plus a string-literal
value-taking operator (aliases folded). 20 of them have no `value` key,
and none is a view filter rule in a shipped example or seed:
- 7 are QA assertions (`expectedValue`, a different schema) in
`examples/app-showcase/qa/platform-smoke.test.json`;
- 5 are QA assertions in `packages/core/src/qa/runner.test.ts` and
`packages/spec/src/qa/testing.test.ts`;
- 1 is a skill trigger condition in
`packages/spec/src/ai/skill-trigger-condition-value-shape.test.ts`;
- 4 are a structural walk with no schema in
`packages/metadata-protocol/src/protocol.graft-normalized-operators.test.ts`;
  - 3 are in `view-filter-rule-value-shape.test.ts`.
Markdown (`.md` / `.mdx`) has no match. No fixture was an authoring
mistake, so no fixture was edited.
- Pins that pinned the removed carve-out and moved with it:
- `packages/spec/src/ui/view-filter-rule-value-shape.test.ts`: `equals +
omitted` and `greater_than + omitted`, from accepted to refused.
- `packages/spec/src/data/filter-icontains-parse-door.test.ts`: "ABSENCE
is left unjudged" now asserts that absence is refused once, in the
absent-value arm's words and never in the conformance table's. ⚠️ This
file is outside the claim's declared file surface. It is a
view-filter-rule test that lives in `src/data/`, not beside
`view.zod.ts`, and it had to move with the carve-out it pinned.
- Carriers named in the docblock (`ListView.filter`, a tab filter,
`Page.filterBy`, a related-list filter, a lookup picker filter, plus
`ObjectGridProps.defaultFilters`) are all
`z.array(ViewFilterRuleSchema)`. The full spec suite is green, and a new
pin drives the refusal through `ListView.filter` at `filter.1.value`.

## Tests

- New pins, with operator lists derived at runtime: value-taking is
`VIEW_FILTER_OPERATORS` minus the four valueless operators. The
valueless set is module-private in `view.zod.ts` and deliberately not
exported, so the test reuses the file's existing transcription, and a
new two-way sweep holds that transcription equal to the private set by
behaviour: over every operator, an absent value is accepted exactly when
the operator is valueless.
- `vitest run --project local` on `view-filter-rule-value-shape.test.ts`
and `filter-icontains-parse-door.test.ts`: 104 passed.
- Firing control at 325052f, through `scripts/ablation-replace.mjs`:
the anchor `if (value === undefined) {` was replaced by `if (value ===
undefined) return;` followed by `if (false) {`, which is the base
behaviour (an absent value returns before any issue). The anchor went 1
to 0 and the blob b6b2f44 to 8c2839db. Result: **11 new pins red, 57
green**. After the restore, the blob equals HEAD and `git diff HEAD` is
empty. A first attempt was refused by the tool before anything ran,
because its replacement contained the anchor; nothing was measured on
that attempt.
- Full spec `local` project at 22a14a1: 522 files passed, 15,420
tests passed. One file skipped by its own stale-dist condition
(`scripts/root-entry-type-nameability.pin.test.ts`); after a rebuild at
the same head it ran with `OS_EXPECT_ROOT_NAMEABILITY=1`: 2 passed.
- Spec `repo` project at 22a14a1: 34 files, 587 tests passed.
- `pnpm --filter @objectstack/spec typecheck` at 22a14a1: exit 0
(tsc, scripts typecheck, `check:test-typecheck` OK).

## Gates

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` at 22a14a1 derived 86 commands. All were run and
reconciled with `--ran`: 84 exit 0, 2 NOT MEASURED, 0 unrun. The two NOT
MEASURED are `check:dual-build-cjs-loads` and `check:type-check-debt`,
both exit 3 PREREQUISITE NOT MET because they need the whole-workspace
build. They are left to CI. `check:generated` is 15/15 up to date after
a fresh build at that head.

Generated files that moved: `packages/spec/src/migrations/registry.ts`
only, via `gen:migration-registry`.

Patch round at ab0104d: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 86 commands from a tree
53 commits behind origin/main 2c1011b. origin/main's selector over the
same six paths adds one family, `check:migration-registry`, which was
run too (exit 0). The `--ran` reconciliation reads 84 run, all exit 0, 2
NOT MEASURED (`check:dual-build-cjs-loads`, `check:type-check-debt`:
exit 3 PREREQUISITE NOT MET, they need the whole-workspace build, left
to CI), and 0 unrun. `check-changeset-no-major --base origin/main`
prints "This diff introduces no `major` bump."
`check-adr-0087-registration --base origin/main` reads
`[BREAKING+clause-②-narrowing] registered
view-filter-rule-absent-value-refused`. CI at ab0104d: 35 check runs,
32 success, 3 skipped (Console Pin Gate, Build Docs, Packed-tarball
smoke), 0 failure.

## Scope held

- In `view.zod.ts`, only `checkViewFilterRuleValueShape` and its
docblock changed. The `ViewFilterRuleSchema` block is untouched: its
JSDoc and `.describe()` are still true. None of PR objectstack-ai#19809's regions is
touched.
- `FILTER_TEXT_CASES` gains no row. There are no objectui or runtime
(`parseFilterAST`) edits.
- `origin/main` has moved 8 commits past the branch point: objectstack-ai#19598
touches the `ListView` shape in `view.zod.ts`, and objectstack-ai#19657 touches
`registry.ts`. A driver-less `merge-tree` of HEAD onto `origin/main`
8cbc3c0 is clean. Main is not merged in.

## Acceptance notes

- The sibling entry `view-filter-rule-scalar-operator-array-refused`
says, in its replacement prose, "An omitted value is still an omitted
value". That was true of its own arm; after this change an omitted value
on a scalar operator is refused. Both entries sit in the uncut
protocol-18 step. The new entry's leading comment names the reversal,
and the sibling's text was left as it is (it is outside this card's file
surface).
- `checkViewFilterRuleTextComparand`'s docblock, carve-out 1, says an
omitted comparand "is left to whatever judges absence". That stays true:
the shape arm now judges it. Not edited.

---
_Generated by [Claude
Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
objectstack-ai#19932)

Fixes objectstack-ai#19665
Clause-②: no

## What this changes

`packages/spec/src/type-alias-convention.pin.test.ts` keyed its
isomorphic pins on a dense, hand-kept counter (`Iso0` … `Iso881`). Two
branches off one base each took "the next free number" for a different
schema, at insertion points far apart, and git merged them cleanly into
two declarations of one name: `Iso871` once already, then `Iso877` /
`Iso878` on objectstack-ai#19600, where only `check:test-typecheck` caught it.

Each pin is now named for the pair that is already unique to it, its
module path and its schema export name:

- The name is `Iso_`, then the module path below `packages/spec/src`
without `.zod.ts`, with each kebab segment camelCased and the segments
joined by `_` (so `ai/knowledge-document.zod.ts` becomes
`ai_knowledgeDocument`), then `__`, then the schema export name. For
example: `Iso_shared_epoch__EpochMs`.
- The block is sorted by that name in code-unit order (the order
`LC_ALL=C sort` gives), with one heading per module. Each note about a
module's pins sits under that module's heading and names the pins it
covers. The schema alone now decides both a new pin's name and where it
goes.

There are four commits:

1. `ece9f71d2c`: the mechanical half. It is the transform below, run on
`fdeeea0cc9`, byte for byte. That includes the runtime companion's pin
counter, whose regex now matches the name family (`Iso` followed by word
characters) instead of `Iso` followed by digits.
2. `fc8eda2d62`: prose only.
- The naming rule and the retired counter are written at the head of the
list.
- The two cohort blocks (phase 2 and the objectstack-ai#4593 backfill) are filed there
too.
- Notes that pointed at a pin by its position or its numeral now name
the pin.
   - "Positional and stay vacant" is removed.
   - The count history gets a `789 -> 789` entry.
3. `ea78da8908`: a merge of `main` at `2c1011b01b`, to re-measure this
head on current `main`. `main` has not touched the pin file since the
merge base: its blob is `5620656d04` on both `fdeeea0cc9` and
`2c1011b01b`. So the merge leaves the file exactly as `fc8eda2d62` had
it, and the re-key was not re-run.
4. `4a724cfbf3`: `packages/spec/src/shared/duration.test.ts:11` cited
the `EpochMs` pin as `Iso868`. It now cites `Iso_shared_epoch__EpochMs`.
This commit changes comment text only.

The diff against `main` is two files: the pin file, and that one comment
line in `duration.test.ts`.

No assertion is added and none is weakened. The exemption set is
unchanged, and nothing is regenerated from a corpus measurement.
`scripts/check-spec-parsed-alias.mjs` is untouched, because its reader
never reads the numeral.

## Acceptance: the assertion set is identical, member for member

Both sides are read with the gate's own `readIsomorphicPins`: before is
`fdeeea0cc9` and after is `fc8eda2d62`.

| reading | before | after |
|---|---|---|
| `readIsomorphicPins` entries | 789 | 789 |
| pin declarations | 789 | 789 |
| set difference, either direction | | **0** |
| pin bodies changed (the text right of ` = `, keyed on
`path.ts::Schema`) | | **0** |
| names off the rule / duplicate names | | 0 / 0 |
| pin names in sorted order | no | yes |

Controls, to show the instrument can fire:
- Deleting one pin line gives a set difference of 1.
- Swapping one pin's `z.infer` operand to another schema keeps the set
at 789 and reads 1 body changed.

The module series is untouched: there are 174 `import type * as M…`
lines before and after, and no import line is in the diff.

## The collision is gone: merge probe with a firing control

The probe ran `git merge-tree --write-tree` in a throwaway `git clone
--bare --shared` with no merge driver registered. The clone was removed
afterwards. In each case, two branches off one base each add one pin for
a different schema:

| scheme and base | the two insertions | merge-tree | duplicate pin
names |
|---|---|---|---|
| OLD, `fdeeea0cc9`: both take `Iso882`, one in `api/analytics`, one in
`ui/view` | 1259 lines apart | exit 0, clean, 0 markers | **`Iso882`**
(the objectstack-ai#19600 shape) |
| NEW, `fc8eda2d62`: `Iso_api_analytics__ProbeAlphaSchema` and
`Iso_ui_view__ProbeBetaSchema`, each at its sorted place | 1174 lines
apart | exit 0, clean, 0 markers | none, and the merged names are still
sorted |
| NEW, same module, with an existing pin between the two names | 6 lines
apart | exit 0, clean | none |
| NEW, same module, with the two names sort-adjacent | same line | exit
1, CONFLICT | none |

The last row is the case that remains. Two new names with no existing
pin between them insert at the same place, and git stops with a
conflict; the resolution is to keep both lines. That failure is loud,
and it cannot merge into a duplicate.

The module series (`M…`) is **left untouched, and it is not a measured
collision**: no one has yet seen two PRs that each import a new module.
I ran one simulation of its mechanism only. Two branches that each
append `import type * as M189` after `M188` conflict at the tail of the
import block (merge-tree exit 1). Keeping both lines when resolving
would give tsc a duplicate `M189`. So in this simulation its failure
mode is a conflict, not a silent clean merge.

## Landing order: this PR lands first, then objectstack-ai#19809 re-runs the re-key on
its own pin

On the maintainer's instruction, this PR lands before objectstack-ai#19809. objectstack-ai#19809
also edits this file: it re-pins `KanbanConfigSchema` as `Iso882` and
raises the count to 790. After this PR has landed, objectstack-ai#19809 does this on
its own branch:

1. Merge `main`. The pin file conflicts.
2. Take objectstack-ai#19809's own copy of the file, still on the old names, as `F`:
run `git show H:packages/spec/src/type-alias-convention.pin.test.ts >
F`, where `H` is objectstack-ai#19809's head before the merge. Then run the transform
below on `F`. It renames and sorts every pin, including the new one,
which becomes `Iso_ui_view__KanbanConfigSchema`.
- This step is exact while objectstack-ai#19809's pin set is `main`'s set plus its own
pin. At `ff13eece89` it is: 790 pins, which are `main`'s 789 plus
`ui/view.zod.ts::KanbanConfigSchema`.
3. Re-apply this PR's prose half as a three-way file merge: `git
merge-file -p F BASE1 HEAD1 > out`. `BASE1` is this file at
`ece9f71d2c`. `HEAD1` is this file at `fc8eda2d62`, which is the same
bytes `main` holds once this PR lands.
4. By hand:
- In the one conflicting hunk, at the tail of the count history, keep
both entries: this PR's `789 -> 789` entry first, then objectstack-ai#19809's entry
restated as `789 -> 790`, with its `toHaveLength(790)`. In that entry,
name the pin `Iso_ui_view__KanbanConfigSchema` instead of `Iso882`.
- In objectstack-ai#19809's `ui/view` note, name the pin
`Iso_ui_view__KanbanConfigSchema` instead of `Iso882`, and drop "Iso829
stays vacant".
5. Check acceptance: compare `readIsomorphicPins` on `F` before the
transform with the result. The sets must be equal, the bodies unchanged,
the names on the rule, and the block sorted.

I dry-ran steps 2 and 3 against objectstack-ai#19809's head `ff13eece89`: 790 = 790,
set difference 0 both ways, 0 bodies changed, 0 names off the rule,
sorted, and `Iso_ui_view__KanbanConfigSchema` declared once. The prose
merge conflicts at that one hunk only.

The transform is `node rekey.mjs FILE`. It rewrites the file in place
and is idempotent: running it on `fc8eda2d62` changes nothing. It is
written without a less-than character so this body keeps it intact.
Running exactly this text on `fdeeea0cc9` reproduces `ece9f71d2c` byte
for byte.

```js
// rekey.mjs FILE: re-key and sort the isomorphic pin block of
// packages/spec/src/type-alias-convention.pin.test.ts, in place. Idempotent.
// Refuses (exit 1) on any line it cannot place. Spelled without a less-than
// character so it survives a GitHub body intact: LT stands for one.
import { readFileSync, writeFileSync } from 'node:fs';

const LT = String.fromCharCode(60);
const file = process.argv[2];
const L = readFileSync(file, 'utf8').split('\n');
const die = (m) => { console.error(`rekey: ${m}`); process.exit(1); };

// Module alias -> module path, read the way the gate reads it.
const mods = new Map();
for (const l of L) {
  const m = l.match(/^import type \* as (M\d+) from '\.\/(.+?)\.js';$/);
  if (m) mods.set(m[1], m[2]);
}

// Stable name: path below packages/spec/src minus `.zod`, kebab segments
// camelCased, joined by `_`; then `__`; then the schema export name.
const keyOf = (path) => {
  if (!path.endsWith('.zod')) die(`module ${path} is not a .zod module`);
  return path.slice(0, -'.zod'.length).split('/').map((s) => {
    if (!/^[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*$/.test(s)) die(`segment "${s}" of ${path} is not lowercase kebab`);
    return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
  }).join('_');
};

// Region: after the rule closing the "N isomorphic aliases" banner, up to the
// rule opening "Representative spot-checks".
const bannerAt = L.findIndex((l) => /^\/\/ \d+ isomorphic aliases:/.test(l));
if (bannerAt === -1) die('count banner not found');
const start = L.findIndex((l, i) => i > bannerAt && l.startsWith('// ----')) + 1;
const spotAt = L.findIndex((l) => l === '// Representative spot-checks on the phase-2 FLIP.');
if (start === 0 || spotAt === -1 || !L[spotAt - 1].startsWith('// ----')) die('region bounds not found');
const end = spotAt - 1;

const HEADING = /^\/\/ (?:\[#\d+\] )?([a-z0-9-]+(?:\/[a-z0-9-]+)*)\.zod\.ts(?:$|[ ,;:(—-])/;
const PIN = new RegExp(`^export type (Iso\\w+) = (Assert${LT}Eq${LT} z\\.input${LT} typeof (M\\d+)\\.(\\w+) >, z\\.infer${LT} typeof \\3\\.\\4 > >>;)$`);
const preamble = [];
const sections = new Map(); // key -> { heading, notes[], pins: Map(name -> line) }
const names = new Set();
let cur = null;
for (let i = start; i !== end; i++) {
  const l = L[i];
  if (l === '') continue;
  if (l.startsWith('// ----')) { // an inner banner moves, verbatim, to the preamble
    const endRule = L.findIndex((x, j) => j > i && x.startsWith('// ----'));
    if (endRule === -1 || endRule >= end) die(`unclosed banner at line ${i + 1}`);
    if (preamble.length) preamble.push('//');
    preamble.push(...L.slice(i + 1, endRule));
    i = endRule;
    continue;
  }
  const h = l.match(HEADING);
  if (h) {
    const key = keyOf(`${h[1]}.zod`);
    if (!sections.has(key)) sections.set(key, { heading: l, notes: [], pins: new Map() });
    else if (l !== `// ${h[1]}.zod.ts`) die(`second heading for ${h[1]} carries text (line ${i + 1})`);
    cur = sections.get(key);
    continue;
  }
  const p = l.match(PIN);
  if (p) {
    const path = mods.get(p[3]);
    if (!path) die(`line ${i + 1}: ${p[3]} imports no module`);
    const key = keyOf(path);
    if (!cur || cur !== sections.get(key)) {
      console.error(`rekey: line ${i + 1} pins ${path}.ts outside its section; moved there`);
      if (!sections.has(key)) sections.set(key, { heading: `// ${path}.ts`, notes: [], pins: new Map() });
    }
    const name = `Iso_${key}__${p[4]}`;
    if (names.has(name)) die(`duplicate pin ${name} (line ${i + 1})`);
    names.add(name);
    sections.get(key).pins.set(name, `export type ${name} = ${p[2]}`);
    continue;
  }
  if (l.startsWith('//')) {
    if (!cur) preamble.push(l);
    else cur.notes.push(l);
    continue;
  }
  die(`line ${i + 1} is neither a heading, a pin, a comment nor blank: ${l.slice(0, 80)}`);
}

// Code-unit order; every name is ASCII, so byte order is the same order.
const byCodeUnit = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
const out = [''];
if (preamble.length) out.push(...preamble, '');
for (const key of [...sections.keys()].sort((a, b) => byCodeUnit(`Iso_${a}__`, `Iso_${b}__`))) {
  const s = sections.get(key);
  out.push(s.heading, ...s.notes, ...[...s.pins.keys()].sort(byCodeUnit).map((n) => s.pins.get(n)), '');
}

const next = [...L.slice(0, start), ...out, ...L.slice(end)];
// The runtime companion counts pins by declaration: the name family, not a numeral.
const text = next.join('\n').replace(
  `self.match(/^export type Iso\\d+ = Assert${LT}/gm)`,
  `self.match(/^export type Iso\\w+ = Assert${LT}/gm)`,
);
writeFileSync(file, text);
console.log(`rekey: ${names.size} pins in ${sections.size} module sections`);
```

## Verification

Round 2, on `4a724cfbf3`, after the merge of `main` at `2c1011b01b`:

- The pin file is byte-identical to `fc8eda2d62`'s (blob `8ae4405bd5`).
`readIsomorphicPins` on `main`'s file and on this head's file reads 789
= 789, with a set difference of 0 both ways. Control: dropping one pin
line reads 788, difference 1.
- `pnpm --filter @objectstack/spec check:test-typecheck`: OK (53 files /
255 errors / 142 signatures held).
- `node scripts/check-spec-parsed-alias.mjs`: "1459 bare z.input
aliases, 789 pinned isomorphic, 670 paired with an XParsed. OK". Its
`--self-test`: 18 assertions passed.
- `pnpm --filter @objectstack/spec run typecheck`: exit 0.
- `vitest run --project local` on
`src/type-alias-convention.pin.test.ts` and
`src/shared/duration.test.ts`: 2 files, 14 tests passed. The whole spec
`local` project: 530 files, 15617 passed, 1 todo.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 77 commands, and all 77 were run. The `--ran`
reconciliation reads 77 run, 0 NOT MEASURED, 0 unrun. Four gates first
exited 3 for unbuilt prerequisites (`check:doc-formula-expressions`,
`check:lean-entry-closure`, `check:dual-build-cjs-loads`,
`check:type-check-debt`), and each exited 0 after its build.

Round 1, on `fc8eda2d62`. The pin file's bytes have not changed since,
so these still describe it:

- Non-vacuity, from the committed state: `node
scripts/ablation-replace.mjs` renamed `Iso_ui_view__TreeConfigSchema` to
the existing name `Iso_ui_view__RowHeightSchema`, and
`check:test-typecheck` turned red ("2 type error(s) in a file the ledger
does not cover"). The file was then restored: blob == HEAD and `git diff
HEAD` is empty.
- The set identity against `fdeeea0cc9` and the merge probe are in the
sections above.

## Changeset

None. The file is a test. `@objectstack/spec`'s `files` ships
`src/**/*.zod.ts` and `dist` but never `*.test.ts`, so nothing published
changes. `check-empty-changeset.mjs` refuses a new empty-frontmatter
changeset, so the repo's disposition for this diff is the
`skip-changeset` label. The seat applied that label after the PR opened.
The one red `Check Changeset` run on `fc8eda2d62` came from the `opened`
event, before the label; on `4a724cfbf3` that check is `skipped`.

## Acceptance notes

- On the base, two `api/errors.zod.ts` pins (`FieldErrorCode` and
`FieldErrorSchema`) sat under the `api/error-code-ledger.zod.ts`
heading. The sorted layout files them under their own module.
- `packages/spec/src/shared/duration.test.ts:11` cited this file's
`Iso868` in the present tense. That pin is `EpochMs`, now
`Iso_shared_epoch__EpochMs`, and `4a724cfbf3` updates the citation. The
other `Iso`-plus-digits hits under `packages/spec/src`, outside this
file, are `ui/component.zod.ts` lines 2052, 2523 and 2929 and
`ui/i18n.zod.ts` line 198. They name `Iso818`, `Iso819`, `Iso839` and
`Iso759`, pins that were deleted before this PR, so they are history and
stay as they are.
- A check that the block stays sorted, and that each name matches its
derivation, would be a new assertion, so none is added. The candidate,
for the maintainer: in the runtime companion, assert that the `Iso`
names are in code-unit order and that each one equals the rule applied
to its `Mn` path and schema.

---
_Generated by [Claude
Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_



---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…e the derive path for enum members that cannot be spelled (objectstack-ai#19906)

Fixes objectstack-ai#19678
Fixes objectstack-ai#19907

Clause-②: no

Executes ruling comment `5805845085` on objectstack-ai#19907 (batch objectstack-ai#218 item 3,
letter 乙, maintainer 「其他同意」). It narrows item 1 of ruling `5793380467`
on objectstack-ai#19678 (batch objectstack-ai#217 item 5, letter 不动 + 声明), which the first round of
this PR executed to the letter:

> 1. The rule as recorded on `FormFieldSchema.options`' describe and in
`defineForm`'s refusal: an enum-typed metadata-form row MAY carry an
inline `options` list (human labels, a deliberate subset); a row whose
members cannot be spelled as option values (a hyphen, a capital) OMITS
`options`, the control derives the members from the served JSON Schema,
and meanings go in `helpText`. The refusal names that path as the
remedy.
> 2. The 27 existing rows stay; objectstack-ai#19331's labels stay.
> 3. PR objectstack-ai#19906 lands with its describe and remedy sentence narrowed to
that wording.

From ruling `5793380467`, the parts 乙 does not narrow still hold:
`FormSelectOptionSchema.value` keeps the system-identifier bound, and
`newTab` vs `new-tab` stays a recorded boundary, untouched here. No
value bound, no schema shape, no key and no export moves.

## What changed

- **The describe.** `FormFieldSchema.options`
(`packages/spec/src/ui/view.zod.ts`, the `FormFieldBaseSchema` row)
keeps its per-option `default` sentence and now adds: *On a metadata
form (schema-bound, built by `defineForm`), an enum-typed row may list
its members here, to give them human labels or to offer a deliberate
subset. An option `value` is a lowercase system identifier, so a row
whose members cannot be spelled as option values (a hyphen, a capital)
omits `options`: the control derives the members from the served JSON
Schema, and their meanings go in `helpText`.* The TSDoc above the row
says the same thing and names both rulings.
- **The wall.** `defineForm` calls `FormViewSchema.safeParse`. When the
parse fails, it throws a `ZodRealError` built from the parse's own
issues. That is the class `FormViewSchema.parse` threw before this PR:
an `Error` whose `name` is `ZodError`. Its stack is captured at the
`defineForm` call, so an uncaught module-load throw prints the issues,
the remedy and the author's call site (round 3, below). Only one thing
changes in the issues: a grammar refusal (`invalid_format` or
`too_small`) at an inline option's `value` (path ending
`options.INDEX.value`, also when nested inside the field-row union's
`errors`) keeps its message and gets this sentence after it: *An enum
member carrying a hyphen, a capital or a single character cannot be a
form option `value`, which is a lowercase system identifier. When this
row edits a spec enum whose members cannot be spelled as option values,
omit `options`: the control derives the members from the served JSON
Schema, and their meanings go in `helpText`.* No issue is added, removed
or re-coded.
- **Generated:** `content/docs/references/ui/view.mdx`, regenerated by
`pnpm --filter @objectstack/spec gen:docs` after a spec build. Two table
rows changed (the `options` row of the two FormField tables).
`check:generated` then reported all 15 artifacts up to date.
- **Changeset:** `.changeset/19678-form-option-enum-derive-remedy.md`,
`@objectstack/spec: patch`, rewritten to state ruling 乙's rule.

### Round 2 (ruling 乙): what moved from the first round

- The describe no longer says *a row whose key is a spec enum omits
`options`*. It now permits an inline list on an enum-typed row and
scopes the derive path to a row whose members cannot be spelled.
- The remedy no longer says *When this row edits a spec enum, omit
`options`*. It now conditions the same derive path on members that
cannot be spelled as option values.
- The verdict did not move. The same values are refused and the same
values are accepted as on the first round's head `ebd7fc2fa8`.
- The branch is merged with `origin/main` at `c8399867b8` (merge commit
`61ff3aebe6`, through `scripts/pm/os-regen-merge.sh`). The wording
commit is `182ed4c154` and the regeneration commit is `74ea5dbba3`.

### Round 3 (at-tier record `5818584341`: FAIL): the refusal is an
`Error` with a stack again

- **What the record found.** Round 2 threw `new z.ZodError(…)`. In zod
v4 classic (`zod@4.6.1` here) that constructor has no `Error` parent, so
the thrown object was not an `Error` and had no `stack`. An uncaught
module-load throw printed only `ZodError { name: 'ZodError', message:
[Getter/Setter] }`. That hid the issues and the remedy, the very wall
both rulings require. `refusal()` asserted only
`toBeInstanceOf(z.ZodError)`, and both shapes pass that.
- **The fix** (`76a053e9d0`). `defineForm` now throws `new
z.ZodRealError(withOptionValueDeriveRemedy(parsed.error.issues))` and
captures its trace with `z.core.util.captureStackTrace(refusal,
defineForm)`. `ZodRealError` is the class `FormViewSchema.parse` threw
before this PR: its `name` is `ZodError`, it is an `Error`, and it
passes `instanceof z.ZodError`. The walker is unchanged. It returns
copies, it grows only `invalid_format` and `too_small` at a
`…options.INDEX.value` path, it still walks `invalid_union`, and an
unrelated refusal is still answered without the remedy. The verdict did
not move.
- **Why this route, and not either spelling in the record as written.**
Both were measured on `zod@4.6.1`, each thrown uncaught from a scratch
form module that parses with the source `FormViewSchema` (run by `tsx`).
1. **Neither spelling has a frame.** zod builds every `ZodRealError`
with `Error.stackTraceLimit = 0` (`newError` in `zod/v4/core/core.js`).
It captures a trace only inside `parse` (`util.captureStackTrace(e,
callee)`), and `safeParse` never does. So `throw parsed.error` and a
bare `throw new z.ZodRealError(…)` both print the issues as `[ZodError:
…]`, with 0 `at` frames and no source line. Read directly in `node`:
`new z.ZodError([])` is not an `Error` and its `stack` is `undefined`,
`new z.ZodRealError([])` and a `safeParse` error are `Error`s whose
stacks hold 0 frames, and the error `parse` throws holds 8. That `stack`
is still a string, so the record's two assertions pass on both
spellings. The fix captures the trace the way zod's own `parse` does.
The callee is `defineForm`, so the first frame is the author's
`defineForm(…)` call.
2. **A copy, not a mutation in place.** A mutation in place would not
leak into another caller. Two parses of the same input share 0 issue
objects, because zod's `finalizeIssue` builds each issue fresh and
`lazySchema` caches the schema, never a result. A mutation of the first
parse's issues showed up 0 times in the second parse. The hazard is
order. zod 4.6.1 computes an error's `message` on its first read and
caches it (`_zod.message`), and V8 formats the stack header on the first
read of `.stack`. So a message grown in place reaches the printout only
if nothing read `.message` or `.stack` before the mutation. Measured on
`FormViewSchema.parse`'s own error, which has its frames. Grown with no
earlier read, the remedy is in the issues, the `message` and the `stack`
once each, and the uncaught printout carries it. After one earlier read
of `.message`, the issues still carry it once, but the `message`, the
`stack` and the printout carry it 0 times. An error built from issues
that already carry the remedy does not depend on that order.
- **The wall, proved with a real uncaught throw.** A scratch form
module, shaped like `packages/spec/src/**/*.form.ts`, imports
`defineForm` from the built `packages/spec/dist/ui/index.mjs` and calls
it at module scope with `{ field: 'openIn', options: [{ label: 'New
tab', value: 'new-tab' }] }`. A second module imports it, and nothing
catches. Both ran under `node` 22.22.2, and stderr was captured:

| read on stderr | the fix (`76a053e9d0`, `dist` built) | negative
control: round 2's `new z.ZodError(…)` line, `dist` rebuilt |
  |:--|:--|:--|
  | node exit | 1 | 1 |
| what it printed | `ZodError: [` followed by the issue list as JSON |
`ZodError { name: 'ZodError', message: [Getter/Setter] }` and nothing
else |
| the issue path (`sections.0.fields.0`, then `options.0.value` in the
union's branch) | present | absent |
| the grammar message (`System identifier must be lowercase…`) | 1 | 0 |
| the remedy sentence (omit `options`, the members come from the served
JSON Schema) | 1 | 0 |
  | the remedy's scope (`cannot be spelled as option values`) | 1 | 0 |
| stack frames | 4. The first is `action-behavior.form.mjs:5:35`, and
node's caret points at `defineForm({` in that module | 0 |

For the negative control, `view.zod.ts` was byte-identical to round 2's
blob `d6471f538d06`, and `ablation-dist-preflight` found the old line in
11 built files. After the restore the `dist` was rebuilt. The old line
is absent from all 216 built files, the tree is clean, and the fix's
wall reads the same as before (stderr sha256 `7d118336d597` both times).
- **The pin.** On every refusal, `refusal()` now asserts
`toBeInstanceOf(Error)` and a string `stack`, the record's two. It also
asserts that the stack names this test file, the module that called
`defineForm`. The third assertion is the one that tells a trace-less
`ZodRealError` apart. A new case reads the wall itself: the head of the
stack (`ZodError: ` and the message) carries today's grammar message,
read live off the object face and JSON-escaped, and the derive path with
its scope.

**Ablation, round 3.** One-shot, at `76a053e9d0`, through
`scripts/ablation-replace.mjs` under the verify lock, one leg at a time.
The test imports `./view.zod` as source, so no `dist` is in its path.

  | leg | mutation | anchor | blob | result |
  |:--|:--|:--|:--|:--|
| 1 | the throw put back to round 2's `throw new
z.ZodError(withOptionValueDeriveRemedy(parsed.error.issues));` | x1 → x0
| `e2feed0106e6` → `d6471f538d06` (round 2's blob, byte for byte) |
`Tests 19 failed \| 14 passed (33)`, every one at
`expect(thrown).toBeInstanceOf(Error)` |
| 2 | only the trace capture deleted: a `ZodRealError` with no frame,
the shape both spellings in the record give | x1 → x0 | `e2feed0106e6` →
`11a3b9cfae81` | `Tests 19 failed \| 14 passed (33)`, every one at *the
stack names no frame in the module that called defineForm*. The record's
two assertions passed on this shape |

The 19 red cases are the ones that go through `refusal()`. The 14 green
ones build a form, parse a schema or read the describe, and never reach
`refusal()`. Both legs were restored: after each, the blob was
`e2feed0106e6`, equal to HEAD, `git diff HEAD` was empty, and `git
status --porcelain` read 0 lines.
- **The changeset is not reworded.** Its sentence "`defineForm` still
throws a `ZodError` at module load with the same issues and codes" is
literally true at this head. The thrown object is a `ZodRealError`, the
class `FormViewSchema.parse` threw before this PR. Its issues are the
parse's own, copied, with the same codes, and only the matching messages
grow.
- **No base merge.** `origin/main` moved 23 commits past the round-2
merge base `c8399867b8`, to `e8f163fc3a`. None of them touches this PR's
four paths, `identifiers.zod.ts` or `field.zod.ts` (`git diff
--name-only`: 0 hits). Derived on a probe tree at `e8f163fc3a` with this
PR's four files, the gate list is the same 107 commands as in this
worktree (the two sorted lists do not differ). No generated artifact
moved: `check:generated` reports all 15 generated artifacts up to date
at `76a053e9d0`, and `view.mdx` is unchanged from round 2, so nothing
was regenerated.

### Where the refusal lives (found by content), and why the remedy is
attached at `defineForm`

- **The text** is `SystemIdentifierSchema`'s regex message, declared in
`packages/spec/src/shared/identifiers.zod.ts` (lines 104 and 107 on the
first round's base). It reaches the form face through
`SelectOptionSchema.value` (`data/field.zod.ts`).
`FormSelectOptionSchema` reuses that value **by reference**, and the
`property schemas are shared BY REFERENCE` pin in
`form-select-option.test.ts` holds it there.
- **The thrower at module load** is `defineForm` (`ui/view.zod.ts`). On
the base it threw through `FormViewSchema.parse`; since the first round
it runs `safeParse` and throws the refusal itself. All 17
`packages/spec/src/**/*.form.ts` modules call it at module scope.
- **The remedy cannot go where the text is declared.** The same grammar
also bounds object-field options (`Field.select.options`) and three
object-storage names. For those, "omit `options`, derive from the served
JSON Schema" is the wrong advice. A form-face-only message would need a
second `value` schema, and that breaks the by-reference derivation the
ruling cites. A zod error map on a parent object cannot rewrite the
issue either, because the regex check's own `error` resolves first.
`defineForm` is the one door where the remedy is true: it stamps
`data.provider: 'schema'` on every form it builds. So the sentence is
appended there, and only there.

## Measured first, on `origin/main` @ `dabf8d795e` (first round)

1. **Today's refusal** for the card's own example, `defineForm({
schemaId: 'action', type: 'simple', sections: [{ label: 'X', fields: [{
field: 'openIn', options: [{ label: 'New tab', value: 'new-tab' }] }] }]
})`: a `ZodError` from `defineForm`, with one `invalid_union` issue at
`sections.0.fields.0`. Its object branch carries `{ code:
'invalid_format', format: 'regex', pattern: '/^[a-z][a-z0-9_.]*$/',
path: ['options', 0, 'value'] }` with this message, verbatim:
`System identifier must be lowercase, starting with a letter, and may
contain letters, numbers, underscores, or dots (e.g., "user_profile" or
"order.created")`
`perRecord` and `system-data` gave the same issue shape and the same
text. A one-character value gives `too_small` with `System identifier
must be at least 2 characters`.
2. **The describe authors read** (`view.zod.ts:3235` on that base):
`Options for select/multiselect/radio/checkboxes fields (per-option
\`default\` is not accepted here — declare the pre-selected choice on
the object definition)`. It does not name a JSON Schema, `helpText` or
omitting `options`.
3. **Census of hand-listed enum members**: see Acceptance notes. None of
the 27 rows is broken by this change, and under ruling 乙 every one of
them is the permitted shape.

## Tests

`packages/spec/src/ui/form-option-enum-derive.test.ts` (33 tests). Its
assertions name subjects (omitting `options`, the JSON Schema,
`helpText`, members that cannot be spelled) rather than whole sentences.

- **The thrown class, and the printed wall (round 3).** Every refusal
the file reads goes through `refusal()`, which asserts a `z.ZodError`,
an `Error`, a string `stack`, and a stack that names this test file, the
module that called `defineForm`. A new case reads the head of the stack,
which is what an uncaught throw prints: `ZodError: `, today's grammar
message JSON-escaped, and the derive path with its scope.
- **Refusal.** The refusal for `new-tab`, `perRecord`, `system-data`
(`invalid_format`) and `x` (`too_small`) names the derive path and
scopes it to members that cannot be spelled. The grammar message is kept
verbatim ahead of the remedy, read live off the object face. A nested
row (composite `fields`) gets the same remedy.
- **Firing controls for the predicates.** Both predicates are RED on
today's message: the object face raises the grammar issue through the
very property schema the form face shares, with no remedy. The
blanket-rule predicate is LIT on the two spellings the first round
shipped, so its "states no blanket rule" assertions cannot be vacuous.
- **Ruling 乙 item 1, on real spec enums.** Each case has a firing and a
dark control. Every enum is read off the served JSON Schema
(`z.toJSONSchema(getMetadataTypeSchema(type))`, input side), so
"unspellable", "spellable" and "subset" are measured, not assumed.
- `object.managedBy` (members that cannot be spelled): with inline
`options` it is REFUSED. Every unspellable member is refused with the
remedy, and no spellable one is. The same row without `options`,
meanings in `helpText`, is GREEN.
- `object.sharingModel` with a labelled full list (the objectstack-ai#19331 shape):
GREEN, labels kept. The same list with one member re-spelled with a
hyphen is REFUSED at that member.
- `field.deleteBehavior` master_detail subset (`cascade`, `restrict`, no
`set_null`): GREEN, not widened. The lit precondition shows `set_null`
is a served member. The same subset with one member capitalised is
REFUSED at that member.
- **The verdict did not move.** The same values are refused, `new_tab`
is still accepted, and a spellable inline option still builds.
- **The remedy is scoped.** An unknown key on the option, and an
unrelated refusal on the same form, are both answered without it.
- **The describe states ruling 乙's rule** in the served JSON Schema
(`z.toJSONSchema(FormFieldSchema)`). It permits an inline list (human
labels, a deliberate subset). It names the derive path, scoped to
members that cannot be spelled. It no longer states the blanket rule. It
keeps the per-option `default` sentence.

**Old-wording pins, reversed rather than deleted.** A `git grep` for the
old describe, the old remedy and the old ruling's 「never hand-listed」
found one assertion pinning the old wording: the describe test's
`toContain('spec enum')`. It became the assertions above: the permission
and the scoped derive path are present, and the blanket rule is absent.
The file header's restatement of the old rule is rewritten to ruling 乙.
The other 「never hand-listed」 hits in the repository (nine test and
source comments) describe unrelated derived vocabularies. `../objectui`
has no hit for either old sentence.

**Ablation, round 2** (one-shot, at `74ea5dbba3`, through
`scripts/ablation-replace.mjs` under the verify lock, one leg at a time,
with the old wording put back). The test imports `./view.zod` as source,
so no `dist` is in the path.

| leg | mutation | anchor | blob | result |
|:--|:--|:--|:--|:--|
| 1 | remedy constant back to *When this row edits a spec enum, omit
`options`* | x1 → x0 | `d6471f538d06` → `c91e601551c2` | `Tests 6 failed
\| 26 passed (32)`: the four scoped-remedy cases, the nested row, the
`managedBy` FIRING case |
| 2 | describe back to *a row whose key is a spec enum omits `options`*
| x1 → x0 | `d6471f538d06` → `1e03c6756624` | `Tests 3 failed \| 29
passed (32)`: the permission, scoped-derive and no-blanket-rule describe
cases |

Both legs went red in the expected direction. Both restored: blob after
restore `d6471f538d06` == HEAD, and `git diff HEAD` was empty. The first
round's ablation, at `2aa26de218`, removed the remedy altogether (`throw
parsed.error;`) and gave `Tests 9 failed | 7 passed (16)`, which showed
the remedy itself is load-bearing.

Suite runs, all at `76a053e9d0` (the PR head):

| run | result |
|:--|:--|
| `@objectstack/spec` `vitest run --project local` | `Test Files 532
passed (532)` · `Tests 15688 passed \| 2 todo (15690)` |
| `@objectstack/spec` `vitest run --project repo` | `Test Files 35
passed (35)` · `Tests 602 passed (602)` |
| `@objectstack/spec` `typecheck` (tsc + scripts + test layer) | exit 0.
The test file is in `tsconfig.test.json`'s program, and `view.zod.ts` in
`tsconfig.json`'s (`--listFilesOnly`: 1 hit each) |
| `@objectstack/spec` `check:generated` | `All 15 generated artifacts
are up to date`, against a `dist` built at this head |
| `@objectstack/spec` `check:docs` | `225 generated files in sync with
packages/spec` |
| eslint, narrowed to the two changed `.ts` files | `--no-inline-config
--format json`: 2 files, 0 errors, 0 warnings. Both are in eslint's
population (`--print-config` resolves a config for each). The config is
not type-aware (no `parserOptions.project` / `projectService`), so this
diff cannot move a verdict on an untouched file. The changeset and
`view.mdx` resolve no eslint config |

**The regenerated page against `main`.** Against the merged `main` tip
`c8399867b8`, `view.mdx` differs in exactly the two `options` rows. The
six PRs that last moved that page on `main` are `95fb417ec8`,
`48c91e9e46`, `9dcdb775a0`, `2b52a5b013`, `b01bdbc4d9` and `1ff3a8f210`.
Every line they added that is still on `main`, 51 in all, was grepped
quoted-exact (`git grep -F -c`). Each has the same count on `c8399867b8`
as on this branch, with 0 mismatches. In round 3 neither side moved the
page: `git diff --quiet` exits 0 for `view.mdx` from `c8399867b8` to
`origin/main` `e8f163fc3a`, and from `74ea5dbba3` to `76a053e9d0`.

**Gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at `76a053e9d0` derived 107
commands. The count matches round 2's 107 at `74ea5dbba3`, and the list
is identical to the one derived on a probe tree at `origin/main`
`e8f163fc3a` with this PR's four files. The `--ran` reconciliation
reports `107 derived famil(ies) accounted for — 107 run, 0
NOT-MEASURED`. All 107 exited 0 on the first run. Their prerequisites
were built before it: a spec build, then a turbo build of every package
except docs (`73 successful, 73 total`). In round 2, seven of them first
exited 3 and went green once those prerequisites were built:
- `check:doc-formula-expressions`, `check:doc-security-posture`,
`check:docs-transcript-drift`: after the `@objectstack/lint...` closure.
- `check:lean-entry-closure`: after the `@objectstack/objectql...`
closure.
- `check:skill-examples`, `check:dual-build-cjs-loads`,
`check:type-check-debt`: after a turbo build of every package except
docs (73 tasks).

**The `Test Core (1/6)` walker race.** On the first round's head, `Test
Core (1/6)` was red on `scripts/check-error-status-conformance.mjs`'s
`walk()`: an ENOENT from a transient `tsup.config.bundled_*.mjs` (the
open finding objectstack-ai#19667; objectstack-ai#19916 is closed). This base merge re-measured it.
On `74ea5dbba3` every check run completed `success`, including `Test
Core (1/6)` and all seven required contexts. That script is not edited
here.

## Changeset: `patch`

Runtime text in a released package changes. The `defineForm` refusal
ships in `@objectstack/spec`'s `dist`, and the describe is served in the
JSON Schema. That is a released-package change, so there is a changeset.
Round 3 changes no word of it: the thrown class is `ZodRealError` again,
so its sentence "`defineForm` still throws a `ZodError` at module load
with the same issues and codes" is literally true. It is `Clause-②: no`:
every value accepted or refused before is accepted or refused now, and
nothing an author can write is added or removed. So it takes the
checklist's `patch`, not `minor`.

## Sibling PRs

- objectstack-ai#19861's region (`checkViewFilterRuleValueShape` /
`ViewFilterRuleSchema`) has landed on `main` and came in with the base
merge. It merged without a conflict, and this PR does not touch it.
- objectstack-ai#19809 is the one open PR that also edits `view.zod.ts` and
`view.mdx`. That was re-derived from the file lists of all 34 open PRs
on 2026-09-24. Its regions (`PaginationConfigSchema`, the per-kind
Gallery / Timeline / Kanban / AddRecord configs, `rowLimitKey`, the
`CalendarConfig` type exports) do not overlap the
`FormFieldBaseSchema.options` row or `defineForm`. Its `view.mdx` hunks
do not touch the two `options` rows. If the two collide, the page is
regenerated, never hand-merged.

## Acceptance notes

- **Census: 27 inline `options` rows in 9 of the 17
`packages/spec/src/**/*.form.ts` modules** (`git grep` at `74ea5dbba3`:
object 12, field 3, hook 3, action 3, page 2, and agent, skill,
permission and email_template 1 each). The first round's census grouped
them as 11 of 17 metadata forms. This round did not re-derive that
grouping. Each row's key was resolved in the served JSON Schema at
`dabf8d795e`.
- All 27 keys are spec enums. None lists a non-member. **None contains
an unspellable member.** So under ruling 乙 every row is the permitted
shape, and item 2 keeps all of them. None is converted.
- Lit control: the same instrument, run on the three option-less
reference rows, reports the unspellable members it should:
`object.managedBy` (4: `system-data`, `engine-owned`, `append-only`,
`better-auth`), `action.execution` (`perRecord`) and `action.openIn`
(`new-tab`).
- **24 rows list every member with human labels**: object
`fields.valueDomain`, `fields.deleteBehavior` (lookup row),
`fields.returnType`, `fields.summaryOperations.function`, `ownership`,
`sharingModel`, `editMode`, `lifecycle.class`,
`lifecycle.storage.strategy`, `lifecycle.storage.unit`; field
`returnType`, `summaryOperations.function`; hook `body.language`,
`onError`, `runAs`; action `mode`, `body.language`, `operation`; page
`type`, `interfaceConfig.recordAction`; agent `surface`; skill
`surface`; permission `managedBy`; email_template `category`.
- **3 rows are deliberate subsets**: object `fields.type` omits `secret`
and `user`, and the two master_detail `deleteBehavior` rows (object
`fields.deleteBehavior`, field `deleteBehavior`) omit `set_null`.
- The objectstack-ai#19331 comment in `object.form.ts` ("Each enum gets an explicit
`options` list because the bare member reads as a word…") and the served
describe now agree. The first round's contradiction between them is what
objectstack-ai#19907 decided.
- **Boundary:** a schema-bound form view authored outside `defineForm`
(a stack's `view` metadata with `data: { provider: 'schema' }`, parsed
at compose or publish) still gets the bare grammar message. The ruling
names the module-load refusal. The object-field option face is unchanged
by design.


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants