spec(ui): widen record:related_list.columns to the saved-view ListColumn union - #19235
Conversation
…stColumn` union `RecordRelatedListProps.columns` declared `z.array(z.string())` while the saved-view key the same spec declares — `listViews[].columns` — declared `z.union([z.array(z.string()), z.array(ListColumnSchema)])`, and objectui composes a saved view's columns onto this block verbatim (`dataSource.view` → `composeElementDataSource` → `savedViewColumns`). Two published declarations disagreed about one key. The block key now takes the SAME union by reference — `ListColumnSchema` is imported from the view face rather than re-spelled — so a decorated saved view arrives here in the spelling it was authored in, and the two declarations cannot drift apart. The `describe()` names the override chain and the `ListColumn` spelling, and says the arms are exclusive because the schema enforces that. Scope fences from the ruling are unchanged and pinned: `field.relatedListColumns` stays child field-name strings only, and the `field-column-lists-canonicalized` conversion still folds its object entries to the identity string. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
…d-list columns `check:generated` proved exactly two artifacts stale and `--fix` regenerated those two, never the whole set: `api-surface-declarations/ui.txt` (the declaration text of `RecordRelatedListProps` and `ComponentPropsMap`) and `content/docs/references/ui/component.mdx`, which now renders the nested `RecordRelatedListProps.columns[number]` shape the union admits. Changeset: `@objectstack/spec` minor, `Clause-②: yes (widening)`. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f75e45b239fac4f6a05fa0fb578866409d6f9aaa && git checkout f75e45b239fac4f6a05fa0fb578866409d6f9aaa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1b82c519df9db93891514b814b4d8e16e42d5d5c 91b9137c498b0202023dbbc58f86122750e2067a && git checkout -B drift-repro 1b82c519df9db93891514b814b4d8e16e42d5d5c && git merge --no-ff 91b9137c498b0202023dbbc58f86122750e2067a
node scripts/docs-audit/affected-docs.mjs --json 1b82c519df9db93891514b814b4d8e16e42d5d5c
|
…in both comments Widening `record:related_list.columns` to the saved-view `ListColumn` union falsified one clause in two in-repo comments, both of which claimed the page-block sibling was "the same strings-only shape" as `field.relatedListColumns`. Both now state what is true after the change AND that the divergence is deliberate: the block key takes the saved-view union because objectui composes a saved view's columns onto it verbatim, while `field.relatedListColumns` stays child field-name strings only by ruling and its `field-column-lists-canonicalized` conversion still folds object entries to the identity string. Comment text only — no schema, no refusal behaviour, no conversion and no assertion is touched, and the diff carries no non-comment line. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsTrees. Every reading names its tree: HEAD-WT = a fresh worktree at
Also run at head (HEAD-WT): ② Semver level
③ Boundary flagsAnswered against the two report comments on #18639 (5747170075, 5747387415) and the seat augmentation (5747181231):
Follow-ups, none blocking:
Implemented-by: VERDICT: PASS Generated by Claude Code |
…lated-list-columns-union
`main` moved under this branch: #19226 landed the author-settable row ceiling and touched the same two generated artifacts this branch owns. Both are routed to the `os-regen` merge driver, which merges them with exit 0 while silently keeping one side, so `scripts/pm/os-regen-merge.sh` was run: it merged `origin/main`, took main's side of the two artifacts in the worktree, and committed the merge first. This is its step 4 — regenerate on the committed merge, never a text merge and never a hand edit. Regenerated with the repo's own tooling on a REAL build (no `OS_SKIP_DTS`): `build` -> `gen:schema` -> `gen:api-surface-declarations` -> `gen:docs`. `check:api-surface-declarations` reads "declaration text unchanged (17 entry points, 5364 declarations)" and `check:generated` is green on all 16 artefacts. Both sides asserted present afterwards, against the STAGED index blobs as well as the worktree, with a dark control reading 0: #19226's `ui/GalleryConfig:limit` / `ui/KanbanConfig:limit` / `ui/TimelineConfig:limit`, `DEFAULT_VIEW_ROW_LIMIT` and `KanbanConfigParsed`; and this branch's own `RecordRelatedListProps.columns` union with its nested `columns[number]` docs table. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsSecond at-tier record on a NEW head. The first, 5747522705 (head
Also run at head (HEAD-WT): committed ② Semver level
③ Boundary flagsThe live question. Did the sync change anything a contract review must judge — no: implementation bytes identical (①1), contribution identical at path AND line grain (①3), the union / fences / exclusivity re-measured unchanged (①6, ①7). Did it drop anything — no: both sides present at the head by path (①2), and the head's artefacts are what the generators produce on a real build (①4). Did it smuggle anything — no (①3). The drop the driver makes is REAL — reproduced by me at the index-blob grain — and is confined to the intermediate commit Is this head WORSE than the head that passed, in any respect? One, and it is a property of the relay rather than of the change: the branch now carries an intermediate commit ( What still stands from 5747522705. Its ① items 1–10, re-measured here where executable and transferred by identity where the bytes are unchanged; its ② verdict, re-run; its ③ readings: the two NOT MEASURED families ( Claim thread, read in full (9 comments). Live claim 5747539585; file-surface augmentation 5747181231 authorises the two comment-only edits; 5747533468 is a prose retraction that is INVALID under the current rule (a retraction is the Not verified by me, stated: the two NOT MEASURED families (CI's, above); the implementer's full-package run (14642 tests) — I ran the five pin-bearing files (927) and the first record plus CI cover the rest; the "MM" staging sequence in the implementer's own worktree (that worktree no longer exists on disk; I reproduced the merge and the drop in a worktree of my own instead, which is the stronger reading). Implemented-by: VERDICT: PASS Generated by Claude Code |
Provenance —— 同步后的达档复核已采纳,三条落地前检通过
Record of record: 本 PR 评论 5747832209, 档位证据(读自子代理自己的转录,常量现场 import): 授权链( 三条落地前检:
⭐ 本轮第一次有人复现了 os-regen 的静默丢弃,而不是从暂存区推断复核自己在旧 head
⇒ 两侧在 head 上的存活,按路径点名:#19226 的 入队前的 os-regen 漂移,在 arm 这一刻重测串行接力:本席此刻队列里没有其它 PR;#19223 复核三轮全 PASS 但被同一个 ③ 里唯一一条「比通过的那个更差」中间提交
落地路径:ready → auto-merge → 合并队列。⛔ 队列外不合并。 Generated by Claude Code |
…ged tree Baseline drift, not a code change — the fourth sync lap on this branch. Main's #19226 and #19235 moved `packages/spec/api-surface-declarations/{data,root, system,ui}.txt`, and that directory is a `merge=os-regen` path, so the merge produced four files current for neither side. Regenerated from a real build of the merged tree (34/34 declaration files emitted; ⛔ no `OS_SKIP_DTS`), via `scripts/pm/os-regen-merge.sh`, with `MERGE_HEAD` confirmed absent first — the build opens with `gen:schema`, and running that in MERGE state is the anchor-rollback trap.⚠️ The `MM` grade was live here and was read on purpose. After regenerating, the index held main's side (803/535) while the worktree held the regeneration (323/4); a bare `git commit` would have landed the index. `git add -A` first, then `git diff --cached` re-read as the 323/4 it should be, and every one of the four index blobs hash-matches its worktree file. BOTH SIDES asserted by quoted-exact name over the WHOLE TREE with paths printed, then again against the index blobs, with a dark control reading 0 files: this branch's facade signature and prescription constant; #19226's `DEFAULT_VIEW_ROW_LIMIT`, `KanbanConfigParsed` and the three `ui/{Gallery,Kanban,Timeline}Config:limit` keys; #19235's `RecordRelatedListProps.columns[number]` and `z.array(ListColumnSchema)`; and #19219's `ObjectTimelinePropsSchema` carried forward. Note the three `limit` keys live ONLY in `authorable-surface/ui.json` and `authorable-defaults/ui.json` and the related-list row ONLY in `content/docs/references/ui/component.mdx` — a grep scoped to the declaration files reads 0 for them out of range, not loss. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18639
Clause-②: yes (widening)
Ruling A on objectstack-ai/objectui#9593 — decision batch #144 item 3, maintainer verbatim 「9593 A,其他同意」 (comment 5713646766). This is the upstream spec half. The objectui half is objectstack-ai/objectui#9593; it unblocks on the released package and the pin bump, not on this merge, and nothing here touches that repository.
The disagreement this closes
RecordRelatedListProps.columnsdeclaredz.array(z.string()). The saved-view key the same spec declares —listViews[].columns— declaredz.union([z.array(z.string()), z.array(ListColumnSchema)]). And objectui copies a saved view'scolumnsonto the block verbatim:composeElementDataSourcedoesconst columns = savedViewColumns(view); if (columns !== undefined) composed.columns = columns;with no normalisation in between (packages/core/src/data-scope/element-data-source.ts, read at objectuidda8f3815d). So a view whose columns carriedlabel/width/hidden/summaryarrived at a block that declared it could not carry them. Two published declarations, one key.What changed
packages/spec/src/ui/component.zod.ts— one key:columnsbecomes the same union, withListColumnSchemaimported from the view face rather than re-spelled.component.zod.tsalready takesNavigationConfigSchemaandTimelineConfigSchemafrom./view.zodfor exactly this reason, andInterfacePageConfigSchemainpage.zod.tsalready composes this very union the same way.packages/spec/src/ui/view.zod.tsis read only — it is not edited by this PR.describe()names the override chain (childhighlightFields→ field-levelrelatedListColumns→ this inline list), says a view-supplied list may arrive in theListColumnspelling and why, and says the two arms are exclusive..changeset/18639-related-list-columns-listcolumn-union.md—@objectstack/specminor, carrying theClause-②: yes (widening)line.Generated artefacts:
check:generatedproved exactly 2 of 16 stale and--fixregenerated those two and no others —packages/spec/api-surface-declarations/ui.txtandcontent/docs/references/ui/component.mdx, which now renders the nestedRecordRelatedListProps.columns[number]table. Nothing was hand-edited or anchor-edited.The union is the SAME union, and the pins say so three ways
toBe(ListColumnSchema)— for this key and forListViewSchema.shape.columns, then the two are asserted equal to each other. One def, two carriers.prefix.type, an empty object, two non-arrays) are asserted to get the same accept/refuse answer from this block key and from the saved-view key.unrecognized_keyswhose message names this list column —ListColumnSchema's own surface word. A re-spelled lookalike would also refuse, and would not say that.Acceptance is pinned on the parsed value, not on
success: the decoration keys the ruling wants on the screen are asserted still present after the parse. A schema that accepted and stripped would be the same defect one layer up.Prose and schema agree, and the agreement is pinned
The
describe()claims exclusivity, so exclusivity is measured:['name', { field: 'amount' }]matches neither arm and is refused (invalid_union). A separate pin reads the description back and asserts it still carries the override chain, theListColumnspelling, thelistViews[].columnsreference and the exclusivity clause — so the two cannot drift apart silently.Ablation — two legs, each proven on disk
Both legs go through
scripts/ablation-replace.mjs, which refuses an anchor that does not hit exactly once and verifies the write against the disk (anchor count down, replacement count up,git hash-objectblob changed) rather than against an exit code, then restores in WRAP mode and proves the restore againstHEAD. No rebuild is needed between legs:component.test.tsimports./component.zodas source inside the same package, so nothing resolves throughdist/here.Leg A — remove the widened arm.
z.array(ListColumnSchema)becomesz.array(z.string()). On disk: anchorx1 -> x0, blob4d341f733c82->7cadd11f55f7. Reading: 4 failed | 347 passed (351) — the acceptance pin, the identity pin, the verdict-parity pin and the strictness pin all go red together. The mixed-array, non-array anddescribe()pins stay green, and that is the point: with the arm gone the description would still have promised theListColumnspelling while the schema refused it, which is the prose-without-schema shape this PR is careful about.Leg B — a LOOKALIKE object arm instead of the saved-view def.
z.array(z.object({ field, label?, width? })). On disk: anchorx1 -> x0, blob4d341f733c82->1a95d6eb5996. Reading: 4 failed | 347 passed (351), and the failures say exactly why a lookalike is not good enough:link,align,summary,hiddenandsortablesilently stripped from the parsed value — the lookalike accepts the input and throws away the keys the ruling wants rendered;{ field: 'amount', bogus: 1 }now accepted —z.objectstrips unknown keys whereListColumnSchemanames them;So the "SAME union, not a lookalike" requirement is not decoration on the commit message; it is the thing three of these pins measure.
Restore — proven, both legs.
blob after restore == blob at HEAD (4d341f733c82)andgit diff HEADempty, asserted by the tool on the absolute path (an empty hash would be read as failure, not as a match). Final green leg on the restored tree: 351 passed (351), andgit status --porcelainis empty at007310f74d.Scope fences from the ruling — held by measurement, not by intent
field.relatedListColumns(data/field.zod.ts) is unchanged and still strings-only: the pin drives a realFieldSchemaparse and asserts aListColumn-shaped entry is refused with the derivation prescription (FIELD-NAME strings).field-column-lists-canonicalizedconversion (conversions/registry.ts) is unchanged: the pin asserts its declared surface string and that its fixture still folds{ field: 'status', label: 'Status' }down to'status'— the decoration is still dropped on that key, which is exactly what widening the block sibling does not do.Verification
pnpm --filter @objectstack/spec test— 500 test files / 14636 tests passed;src/ui/component.test.tsalone is 351.pnpm --filter @objectstack/spec typecheck— green, all three legs (tsc --noEmit,check:scripts-typecheck,check:test-typecheck: 54 files / 259 errors / 144 pinned signatures, ledger unchanged).pnpm check:adr-anchors— exit 0:check-adr-anchors: OK (53 anchored file(s), every governing ADR still referenced; 133 decision number(s) ...; 36678 citation(s) across 4723 file(s) resolve ...). This PR writes no ADR id anywhere — source, test, changeset or this body.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 107 families at007310f74d; all 107 were run, each exit code captured before any pipe, and reconciled with--ranin the tool’sCOMMAND :: exit CODEform: 107 accounted, 105 run, 2 NOT MEASURED, 0 unrun. Seven were non-zero on the first sweep and every one of them refused its own prerequisite rather than reporting a finding — six with exit 3, andcheck:skill-exampleswith exit 1, which is the conflation carded as finding(tooling): six packages/spec gates answer an unmet prerequisite with exit 1 — the code a real finding uses — while this repo declares EXIT_PREREQUISITE_NOT_MET=3 and argues why; successor to the #13983/#14008 migration #19227. Five went green once their closure was built (pnpm --filter '@objectstack/lint...' --filter '@objectstack/client-react...' --filter '@objectstack/objectql...' build):check:doc-formula-expressions,check:doc-security-posture,check:docs-transcript-drift,check:lean-entry-closure,check:skill-examples. The two that stay NOT MEASURED arecheck:dual-build-cjs-loadsandcheck:type-check-debt; both need every workspace package built, both say so in their own output, and both are CI's to answer.pnpm --filter @objectstack/spec check:generated— green after the regeneration (16 artefacts).pnpm --filter @objectstack/spec check:api-surface-declarations—declaration text unchanged ✓ (17 entry points, 5362 declarations).MANIFEST=... check:react-declaration-parity --strict— exit 0,no new DECLARATION divergence vs accepted baseline. The registry still declarescolumnsas{ type: 'array', of: 'string' }; this gate compares prop NAMES, not types, so the widening does not move it. The type half is the objectui card's.pnpm check:nul-bytes—OK (scanned 9012 text file(s) ... no raw ASCII control bytes), plus a direct control-byte scan over the three hand-written files.Two line numbers on the card are stale
The card says to anchor on the symbol, and the tree disagrees with its line numbers. Re-derived on
origin/maine233db9dbb:RecordRelatedListPropsis atcomponent.zod.ts:1136(card: near :1092) and the saved-view union is atview.zod.ts:2230withListColumnSchemaat:821(card: :2021). The card's substantive reading holds.Acceptance notes
Noted, not filed — a stale cross-reference this PR creates, in two in-repo source comments, neither of which reaches published docs:
packages/spec/src/data/field.zod.ts— the TSDoc aboverelatedListColumnssays "the page-block siblingrecord:related_list.columnsis the same strings-only shape". After this PR it is not. Only the.describe()below it renders intocontent/docs/references/data/field.mdx, and that sentence stays true, so nothing published is wrong.packages/spec/src/data/inline-related-columns.test.ts— its file header repeats the same clause in item 4.Not corrected here: the ruling fences
field.zod.tsby name, and the dispatch repeats that fence, so the correction is left for the seat rather than crossed quietly. Both files were measured free across all 20 open PRs (340 changed paths, read 2026-09-20), so it is a one-edit follow-up whenever the seat wants it. The new TSDoc oncomponent.zod.tsstates the current truth from this side, so a reader arriving from the block never sees the stale claim.Generated by Claude Code