Skip to content

Commit 7056ca5

Browse files
os-billclaude
andauthored
spec(ui): widen record:related_list.columns to the saved-view ListColumn union (#19235)
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.columns` declared `z.array(z.string())`. The saved-view key the **same spec** declares — `listViews[].columns` — declared `z.union([z.array(z.string()), z.array(ListColumnSchema)])`. And objectui copies a saved view's `columns` onto the block **verbatim**: `composeElementDataSource` does `const 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 objectui `dda8f3815d`). So a view whose columns carried `label` / `width` / `hidden` / `summary` arrived 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: - `columns` becomes the same union, with `ListColumnSchema` **imported from the view face** rather than re-spelled. `component.zod.ts` already takes `NavigationConfigSchema` and `TimelineConfigSchema` from `./view.zod` for exactly this reason, and `InterfacePageConfigSchema` in `page.zod.ts` already composes this very union the same way. `packages/spec/src/ui/view.zod.ts` is **read only** — it is not edited by this PR. - the `describe()` names the override chain (child `highlightFields` → field-level `relatedListColumns` → this inline list), says a view-supplied list may arrive in the `ListColumn` spelling and why, and says the two arms are exclusive. `.changeset/18639-related-list-columns-listcolumn-union.md` — `@objectstack/spec` **minor**, carrying the `Clause-②: yes (widening)` line. Generated artefacts: `check:generated` proved exactly **2 of 16** stale and `--fix` regenerated those two and no others — `packages/spec/api-surface-declarations/ui.txt` and `content/docs/references/ui/component.mdx`, which now renders the nested `RecordRelatedListProps.columns[number]` table. Nothing was hand-edited or anchor-edited. ## The union is the SAME union, and the pins say so three ways 1. **Reference identity.** The object arm's array element is asserted `toBe(ListColumnSchema)` — for this key *and* for `ListViewSchema.shape.columns`, then the two are asserted equal to each other. One def, two carriers. 2. **Verdict parity.** Ten fixtures (string arrays, decorated arrays, an unknown member, a mixed array, a bad nested `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. 3. **The refusal text.** An unknown member is refused with `unrecognized_keys` whose 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, the `ListColumn` spelling, the `listViews[].columns` reference 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-object` blob changed) rather than against an exit code, then restores in WRAP mode and proves the restore against `HEAD`. No rebuild is needed between legs: `component.test.ts` imports `./component.zod` as **source** inside the same package, so nothing resolves through `dist/` here. **Leg A — remove the widened arm.** `z.array(ListColumnSchema)` becomes `z.array(z.string())`. On disk: anchor `x1 -> x0`, blob `4d341f733c82` -> `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 and `describe()` pins stay green, and that is the point: with the arm gone the description would still have promised the `ListColumn` spelling 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: anchor `x1 -> x0`, blob `4d341f733c82` -> `1a95d6eb5996`. Reading: **4 failed | 347 passed (351)**, and the failures say exactly why a lookalike is not good enough: - the decoration-survival pin prints `link`, `align`, `summary`, `hidden` and `sortable` **silently stripped** from the parsed value — the lookalike accepts the input and throws away the keys the ruling wants rendered; - the strictness pin shows `{ field: 'amount', bogus: 1 }` now **accepted** — `z.object` strips unknown keys where `ListColumnSchema` names them; - the identity and parity pins go red on their own terms. 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 (4d341f7)` and `git diff HEAD` empty, 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)**, and `git status --porcelain` is empty at `007310f74d`. ## 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 real `FieldSchema` parse and asserts a `ListColumn`-shaped entry is refused with the derivation prescription (`FIELD-NAME strings`). - the `field-column-lists-canonicalized` conversion (`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.ts` alone 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/objectstack` derived **107** families at `007310f74d`; all 107 were run, each exit code captured before any pipe, and reconciled with `--ran` in the tool’s `COMMAND :: exit CODE` form: **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, and `check:skill-examples` with exit **1**, which is the conflation carded as #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** are `check:dual-build-cjs-loads` and `check: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 declares `columns` as `{ 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/main` `e233db9dbb`: `RecordRelatedListProps` is at `component.zod.ts:1136` (card: near :1092) and the saved-view union is at `view.zod.ts:2230` with `ListColumnSchema` at `: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 above `relatedListColumns` says "the page-block sibling `record:related_list.columns` is the same strings-only shape". After this PR it is not. Only the `.describe()` below it renders into `content/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.ts` by 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 on `component.zod.ts` states the current truth from this side, so a reader arriving from the block never sees the stale claim. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1b82c51 commit 7056ca5

7 files changed

Lines changed: 384 additions & 10 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`record:related_list.columns` now declares the SAME union the saved-view key declares — `z.union([z.array(z.string()), z.array(ListColumnSchema)])` — so a saved view's per-column decoration reaches the related list instead of being refused at the block door (#18639, the upstream half of objectui#9593).
6+
7+
**Clause-②: yes (widening)** — one published accept set grows: the key admitted `string[]` and now also admits `ListColumn[]`. Nothing previously admitted is refused, no key is renamed or retired, and no producer is required to write the new arm. Contract-review tier.
8+
9+
Two published declarations disagreed about one key. `RecordRelatedListProps.columns` (`ui/component.zod.ts`) was `z.array(z.string())`, while `listViews[].columns` (`ui/view.zod.ts`) was already the union — and objectui composes a saved view's `columns` onto this block **verbatim** (`dataSource.view` → `composeElementDataSource` → `savedViewColumns`). A view whose columns carried `label` / `width` / `hidden` / `summary` therefore arrived at a block that declared it could not carry them.
10+
11+
- **The same union, by reference — not a lookalike.** `ListColumnSchema` is imported from the view face rather than re-spelled, so the object arm is one def with two carriers. The pin asserts reference identity on both sides and then asserts block and saved view return the same verdict for every fixture: two spellings of one key is the defect this closes, so a second spelling would not have fixed it.
12+
- **The arms are exclusive, and the description says so because the schema enforces it.** `['name', { field: 'amount' }]` matches neither arm and is refused. The decoration also survives the parse — a description promising keys a parse strips would be the same defect one layer up, so the pin asserts the parsed value, not merely `success`.
13+
- **Unchanged, by ruling and by measurement.** `field.relatedListColumns` stays child field-name STRINGS only and still refuses a column object with its derivation prescription, and the `field-column-lists-canonicalized` conversion still folds an object entry on that key to its identity string. Both are pinned next to the widening so the fences cannot erode quietly.
14+
15+
No migration: authors writing `string[]` are unaffected, and the new arm is opt-in.

‎content/docs/references/ui/component.mdx‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1366,7 +1366,7 @@ Type: `string`
13661366
| **objectName** | `string` | ✅ | Related object name (e.g., "task", "opportunity") |
13671367
| **relationshipField** | `string` | ✅ | Field on related object that points to this record (e.g., "account_id") |
13681368
| **relationshipValueField** | `string` | optional (default: `"id"`) | Parent-record field whose value relationshipField stores (default 'id'; e.g. 'name' for name-keyed junctions). |
1369-
| **columns** | `string[]` | optional | Fields to display in the related list. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns → this inline list. |
1369+
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Fields to display in the related list — either plain field-name strings, or the same per-column entries a saved list view declares (`ListColumn`: `field`, plus `label`, `width`, `align`, `hidden`, `sortable`, `summary`, …). A view-supplied list may arrive in the `ListColumn` spelling: objectui composes a saved view's `columns` onto this block verbatim, and this key declares the SAME union as `listViews[].columns`. One spelling per list — the two arms are exclusive, so an array mixing strings and column objects is refused. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns (field-name strings only) → this inline list. |
13701370
| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order for related records |
13711371
| **limit** | `integer` | optional (default: `5`) | Number of records to display initially |
13721372
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria for related records |
@@ -1378,6 +1378,25 @@ Type: `string`
13781378
| **redactFields** | `string[]` | optional | Field names this list never renders, whatever the permission answer (renderer default: render every column authored or derived). Applies to the authored `columns` AND to the columns the list derives for itself when none are authored. Presentation only, evaluated in the browser after the rows are fetched — the values are still in the page, so this is NOT a data-access control and NOT the object's `publicSharing.redactFields`, which removes them server-side. To keep a value from the caller, gate the field itself (`requiredPermissions` / `maskingRule`, ADR-0066 D3) or the permission set. |
13791379
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
13801380

1381+
### Nested Shape: `RecordRelatedListProps.columns[number]`
1382+
1383+
| Property | Type | Required | Description |
1384+
| :--- | :--- | :--- | :--- |
1385+
| **field** | `string` | ✅ | Field name (snake_case) |
1386+
| **label** | `string \| Record<string, string>` | optional | Display label override |
1387+
| **width** | `number` | optional | Column width in pixels |
1388+
| **align** | `Enum<'left' \| 'center' \| 'right'>` | optional | Text alignment |
1389+
| **hidden** | `boolean` | optional | Hide column by default |
1390+
| **sortable** | `boolean` | optional | Allow sorting by this column |
1391+
| **resizable** | `boolean` | optional | Allow resizing this column |
1392+
| **wrap** | `boolean` | optional | Allow text wrapping |
1393+
| **type** | `string` | optional | Renderer type override (e.g., "currency", "date") |
1394+
| **pinned** | `Enum<'left' \| 'right'>` | optional | Pin/freeze column to left or right side |
1395+
| **summary** | `Enum<'none' \| 'count' \| 'count_empty' \| 'count_filled' \| 'count_unique' \| …> \| { type: Enum<'none' \| 'count' \| 'count_empty' \| 'count_filled' \| 'count_unique' \| …>; field?: string }` | optional | Footer aggregation for this column — the function alone, or `{ type, field }` to aggregate another field |
1396+
| **prefix** | `{ field: string; type: Enum<'badge' \| 'text'> }` | optional | Field rendered inline before this cell value |
1397+
| **link** | `boolean` | optional | Functions as the primary navigation link (triggers View navigation) |
1398+
| **action** | `string` | optional | Registered Action ID to execute when clicked |
1399+
13811400
### Nested Shape: `RecordRelatedListProps.filter[number]`
13821401

13831402
View filter rule

‎packages/spec/api-surface-declarations/ui.txt‎

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3097,7 +3097,74 @@ declare const ComponentPropsMap: {
30973097
objectName: z.ZodString;
30983098
relationshipField: z.ZodString;
30993099
relationshipValueField: z.ZodDefault<z.ZodString>;
3100-
columns: z.ZodOptional<z.ZodArray<z.ZodString>>;
3100+
columns: z.ZodOptional<z.ZodUnion<readonly [z.ZodArray<z.ZodString>, z.ZodArray<z.ZodObject<{
3101+
field: z.ZodString;
3102+
label: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodType<Record<string, string> & {
3103+
key?: never;
3104+
defaultValue?: never;
3105+
}, Record<string, string> & {
3106+
key?: never;
3107+
defaultValue?: never;
3108+
}, z.core.$ZodTypeInternals<Record<string, string> & {
3109+
key?: never;
3110+
defaultValue?: never;
3111+
}, Record<string, string> & {
3112+
key?: never;
3113+
defaultValue?: never;
3114+
}>>]>>;
3115+
width: z.ZodOptional<z.ZodNumber>;
3116+
align: z.ZodOptional<z.ZodEnum<{
3117+
left: "left";
3118+
right: "right";
3119+
center: "center";
3120+
}>>;
3121+
hidden: z.ZodOptional<z.ZodBoolean>;
3122+
sortable: z.ZodOptional<z.ZodBoolean>;
3123+
resizable: z.ZodOptional<z.ZodBoolean>;
3124+
wrap: z.ZodOptional<z.ZodBoolean>;
3125+
type: z.ZodOptional<z.ZodString>;
3126+
pinned: z.ZodOptional<z.ZodEnum<{
3127+
left: "left";
3128+
right: "right";
3129+
}>>;
3130+
summary: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
3131+
count: "count";
3132+
none: "none";
3133+
min: "min";
3134+
max: "max";
3135+
sum: "sum";
3136+
avg: "avg";
3137+
count_empty: "count_empty";
3138+
count_filled: "count_filled";
3139+
count_unique: "count_unique";
3140+
percent_empty: "percent_empty";
3141+
percent_filled: "percent_filled";
3142+
}>, z.ZodObject<{
3143+
type: z.ZodEnum<{
3144+
count: "count";
3145+
none: "none";
3146+
min: "min";
3147+
max: "max";
3148+
sum: "sum";
3149+
avg: "avg";
3150+
count_empty: "count_empty";
3151+
count_filled: "count_filled";
3152+
count_unique: "count_unique";
3153+
percent_empty: "percent_empty";
3154+
percent_filled: "percent_filled";
3155+
}>;
3156+
field: z.ZodOptional<z.ZodString>;
3157+
}, z.core.$strict>]>>;
3158+
prefix: z.ZodOptional<z.ZodObject<{
3159+
field: z.ZodString;
3160+
type: z.ZodDefault<z.ZodEnum<{
3161+
text: "text";
3162+
badge: "badge";
3163+
}>>;
3164+
}, z.core.$strict>>;
3165+
link: z.ZodOptional<z.ZodBoolean>;
3166+
action: z.ZodOptional<z.ZodString>;
3167+
}, z.core.$strict>>]>>;
31013168
sort: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodObject<{
31023169
field: z.ZodString;
31033170
order: z.ZodEnum<{
@@ -19236,7 +19303,74 @@ declare const RecordRelatedListProps: z.ZodObject<{
1923619303
objectName: z.ZodString;
1923719304
relationshipField: z.ZodString;
1923819305
relationshipValueField: z.ZodDefault<z.ZodString>;
19239-
columns: z.ZodOptional<z.ZodArray<z.ZodString>>;
19306+
columns: z.ZodOptional<z.ZodUnion<readonly [z.ZodArray<z.ZodString>, z.ZodArray<z.ZodObject<{
19307+
field: z.ZodString;
19308+
label: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodType<Record<string, string> & {
19309+
key?: never;
19310+
defaultValue?: never;
19311+
}, Record<string, string> & {
19312+
key?: never;
19313+
defaultValue?: never;
19314+
}, z.core.$ZodTypeInternals<Record<string, string> & {
19315+
key?: never;
19316+
defaultValue?: never;
19317+
}, Record<string, string> & {
19318+
key?: never;
19319+
defaultValue?: never;
19320+
}>>]>>;
19321+
width: z.ZodOptional<z.ZodNumber>;
19322+
align: z.ZodOptional<z.ZodEnum<{
19323+
left: "left";
19324+
right: "right";
19325+
center: "center";
19326+
}>>;
19327+
hidden: z.ZodOptional<z.ZodBoolean>;
19328+
sortable: z.ZodOptional<z.ZodBoolean>;
19329+
resizable: z.ZodOptional<z.ZodBoolean>;
19330+
wrap: z.ZodOptional<z.ZodBoolean>;
19331+
type: z.ZodOptional<z.ZodString>;
19332+
pinned: z.ZodOptional<z.ZodEnum<{
19333+
left: "left";
19334+
right: "right";
19335+
}>>;
19336+
summary: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
19337+
count: "count";
19338+
none: "none";
19339+
min: "min";
19340+
max: "max";
19341+
sum: "sum";
19342+
avg: "avg";
19343+
count_empty: "count_empty";
19344+
count_filled: "count_filled";
19345+
count_unique: "count_unique";
19346+
percent_empty: "percent_empty";
19347+
percent_filled: "percent_filled";
19348+
}>, z.ZodObject<{
19349+
type: z.ZodEnum<{
19350+
count: "count";
19351+
none: "none";
19352+
min: "min";
19353+
max: "max";
19354+
sum: "sum";
19355+
avg: "avg";
19356+
count_empty: "count_empty";
19357+
count_filled: "count_filled";
19358+
count_unique: "count_unique";
19359+
percent_empty: "percent_empty";
19360+
percent_filled: "percent_filled";
19361+
}>;
19362+
field: z.ZodOptional<z.ZodString>;
19363+
}, z.core.$strict>]>>;
19364+
prefix: z.ZodOptional<z.ZodObject<{
19365+
field: z.ZodString;
19366+
type: z.ZodDefault<z.ZodEnum<{
19367+
text: "text";
19368+
badge: "badge";
19369+
}>>;
19370+
}, z.core.$strict>>;
19371+
link: z.ZodOptional<z.ZodBoolean>;
19372+
action: z.ZodOptional<z.ZodString>;
19373+
}, z.core.$strict>>]>>;
1924019374
sort: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodObject<{
1924119375
field: z.ZodString;
1924219376
order: z.ZodEnum<{

‎packages/spec/src/data/field.zod.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1454,8 +1454,19 @@ export const FieldSchema = lazySchema(() => {
14541454
* Deliberately narrower than `inlineColumns`: the related list is not an
14551455
* editable grid, and per-column display overrides are not part of its
14561456
* measured renderer contract (objectui RelatedList hydrates string entries
1457-
* fully; the page-block sibling `record:related_list.columns` is the same
1458-
* strings-only shape). Column OBJECTS are refused with a prescription.
1457+
* fully). Column OBJECTS are refused with a prescription.
1458+
*
1459+
* ⚠️ AND deliberately narrower than the PAGE-BLOCK sibling, which is no
1460+
* longer the same shape (#18639). `record:related_list.columns`
1461+
* (`ui/component.zod.ts`) now declares the SAME union as the saved-view key
1462+
* `listViews[].columns` — field-name strings OR `ListColumnSchema` entries —
1463+
* because objectui composes a saved view's `columns` onto that block
1464+
* VERBATIM, so a decorated list arrives there already in the `ListColumn`
1465+
* spelling. THIS key stays strings-only BY RULING: objectui#9593 ruling A
1466+
* widened that one and fenced this one in the same breath, and the
1467+
* `field-column-lists-canonicalized` conversion still folds an object entry
1468+
* here down to its identity string. The two keys diverge ON PURPOSE —
1469+
* ⛔ do not "align" them.
14591470
*/
14601471
relatedListColumns: z.array(z.string({
14611472
error: (issue) => issue.code === 'invalid_type'

‎packages/spec/src/data/inline-related-columns.test.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,14 @@
2525
* parse clean and render every computed cell '—'. The refusal is the
2626
* producer-side guard for that renderer fact.
2727
* 4. `relatedListColumns`: child field-name STRINGS only, matching every
28-
* in-repo usage and the strings-only page-block sibling
29-
* (`record:related_list.columns`, ui/component.zod.ts). A column OBJECT
30-
* is refused with the derivation prescription.
28+
* in-repo usage. A column OBJECT is refused with the derivation
29+
* prescription. ⚠️ Since #18639 this is NO LONGER the same shape as the
30+
* page-block sibling: `record:related_list.columns`
31+
* (ui/component.zod.ts) declares the saved-view `ListColumnSchema` union,
32+
* because a saved view's columns are composed onto that block verbatim.
33+
* THIS key stays strings-only by ruling — objectui#9593 ruling A widened
34+
* that one and fenced this one — so the divergence is DELIBERATE, and
35+
* every pin below is unchanged by it.
3136
* 5. The showcase invoice fixture form — identity-only `{ name }` entries —
3237
* parses, so the one authored in-repo usage stays green in the spelling
3338
* the renderer actually reads.

0 commit comments

Comments
 (0)