From 2b636019f0d332d1d081fd776082ada81e0d8f93 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:03:01 +0000 Subject: [PATCH 1/5] wip(spec): the field -> name respelling reaches the form-view subform carrier; step-18 entry texts name the identity-only sibling Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/src/conversions/registry.ts | 200 +++++++++++++++++- .../18.form-view-subform-columns-closed.ts | 21 +- ...line-grid-column-currency-scale-refused.ts | 26 ++- packages/spec/src/stack.zod.ts | 18 +- 4 files changed, 227 insertions(+), 38 deletions(-) diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 8fad6b00af1..909148b7472 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7623,6 +7623,36 @@ const translationComponentSubmitLabelRemoved: MetadataConversion = { }, }; +/** + * The inline grid column's one mechanical respelling, shared by both of its + * carriers — a relationship field's `inlineColumns` + * ({@link fieldColumnListsCanonicalized}) and a form view's `subforms[].columns` + * ({@link formViewSubformColumnsCanonicalized}) — so the two conversions cannot + * drift on what they rewrite, as the two carriers cannot on what they accept + * (both reference `InlineGridColumnSchema`). + * + * `{ field: 'x' }` → `{ name: 'x' }`, every other key kept. An entry already + * carrying `name` is left alone — rewriting a live key on the strength of a + * stale one would guess; the parse refuses the mixed shape loudly instead. + * Returns the same array when nothing was respelled (copy-on-write). + */ +function respellInlineGridColumns( + columns: readonly unknown[], + path: string, + emit: (detail: ConversionApplication) => void, +): readonly unknown[] { + let changed = false; + const next = columns.map((entry, i) => { + if (!isDict(entry) || typeof entry.field !== 'string' || 'name' in entry) return entry; + const renamed = renameKey(entry, 'field', 'name'); + if (!renamed) return entry; + emit({ from: 'field', to: 'name', path: `${path}[${i}].name` }); + changed = true; + return renamed; + }); + return changed ? next : columns; +} + /** * `field.inlineColumns[]` / `field.relatedListColumns[]` — the mechanical half * of the #9227 strict-element narrowing (protocol 18). @@ -7667,16 +7697,8 @@ const fieldColumnListsCanonicalized: MetadataConversion = { let next: Dict = def; const inline = def.inlineColumns; if (Array.isArray(inline)) { - let changed = false; - const cols = inline.map((entry, i) => { - if (!isDict(entry) || typeof entry.field !== 'string' || 'name' in entry) return entry; - const renamed = renameKey(entry, 'field', 'name'); - if (!renamed) return entry; - emit({ from: 'field', to: 'name', path: `${path}.inlineColumns[${i}].name` }); - changed = true; - return renamed; - }); - if (changed) next = { ...next, inlineColumns: cols }; + const cols = respellInlineGridColumns(inline, `${path}.inlineColumns`, emit); + if (cols !== inline) next = { ...next, inlineColumns: cols }; } const related = def.relatedListColumns; if (Array.isArray(related)) { @@ -9820,6 +9842,163 @@ const connectorConnectionTimeoutMsRemoved: MetadataConversion = { }, }; +/** + * `subforms[].columns[].field` → `name` on every form view — the mechanical + * half of the #20901 closure of the form-view carrier (protocol 18). + * + * `FormViewSchema.subforms[].columns` was `z.array(z.any())` through 17.5.0 and + * now references `InlineGridColumnSchema`, whose alias table refuses `field` + * with a prescription naming `name`. That is the break a relationship field's + * `inlineColumns` took in #9227, and the respelling is the same one: it runs + * through {@link respellInlineGridColumns}, shared with + * {@link fieldColumnListsCanonicalized}. ADR-0087's pre-GA policy owes a + * lossless break a `retiredFromLoadPath` chain step in the same release. + * + * **Its own entry, not a wider walk in `field-column-lists-canonicalized`,** + * because `retiredAfter` is one fact per entry (ADR-0087, amended 2026-09-30): + * that entry's carriers stopped accepting `field` after 17.0.0, and the census + * pins that published value, while this carrier accepted it through 17.5.0. The + * artifact-ingestion door opens its window per entry by `retiredAfter`, so under + * the older stamp an artifact whose declared floor is 17.5.0 would meet the + * refusal instead of this rewrite on a runtime still labelled 17.5.0. + * + * **Reach: every FORM payload.** {@link mapViewPayloads} reaches `form`, + * `formViews.*`, a form view item's `config` and a flattened form overlay — the + * stored-row seam wraps a `view` row as `{ views: [row] }` in any of those + * spellings — and the assembled-manifest `viewItems` channel + * ({@link ASSEMBLED_VIEW_ITEMS_KEY}) carries the last two. A list payload is + * never judged. `FormViewSchema` is the only schema that declares `subforms`. + * + * `retiredFromLoadPath`: the parse refuses `field` with the prescription, so a + * live author is taught rather than rewritten. The entry exists so stored rows + * and assembled artifacts replay clean, and so `os migrate meta` lists the edit. + * Idempotent by construction: the rewrite leaves no `field` on the entry. + */ +const formViewSubformColumnsCanonicalized: MetadataConversion = { + id: 'form-view-subform-columns-canonicalized', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.5.0', + surface: 'view.form.subforms[].columns[].field / view.formViews..subforms[].columns[].field', + summary: + "form-view subform grid column entries respelled 'field' → 'name', the grid's column identity " + + '(the carrier accepted any value until it took the inline grid column contract; a ' + + "relationship field's inlineColumns get the same respelling from field-column-lists-canonicalized)", + apply(stack, emit) { + const respellSubforms = (form: Dict, path: string): Dict => { + const subforms = form.subforms; + if (!Array.isArray(subforms)) return form; + let changed = false; + const next = subforms.map((subform, j) => { + if (!isDict(subform) || !Array.isArray(subform.columns)) return subform; + const columns = respellInlineGridColumns(subform.columns, `${path}.subforms[${j}].columns`, emit); + if (columns === subform.columns) return subform; + changed = true; + return { ...subform, columns }; + }); + return changed ? { ...form, subforms: next } : form; + }; + const withViews = mapViewPayloads(stack, (payload, kind, path) => + kind === 'form' ? respellSubforms(payload, path) : payload); + return mapCollection(withViews, ASSEMBLED_VIEW_ITEMS_KEY, (item, path) => { + if (item.viewKind !== 'form') return item; + if (isDict(item.config)) { + const config = respellSubforms(item.config, `${path}.config`); + return config === item.config ? item : { ...item, config }; + } + // A flattened form overlay — the body IS the payload. A present but + // malformed `config` is neither shape and is left for the parse. + return item.config === undefined ? respellSubforms(item, path) : item; + }); + }, + fixture: { + before: { + views: [ + { + object: 'crm_invoice', + // A container: the default form and a named form view. + form: { + type: 'simple', + subforms: [{ + childObject: 'crm_invoice_line', + columns: [ + // The `field` spelling, every other key kept. + { field: 'product' }, + { field: 'quantity', label: 'Qty' }, + // Already name-keyed — untouched. + { name: 'unit_price' }, + // Both keys — untouched: which column was meant is the + // author's call, and the parse names both keys. + { field: 'amount', name: 'total' }, + ], + }], + }, + formViews: { + quick: { type: 'simple', subforms: [{ childObject: 'crm_invoice_line', columns: [{ field: 'product' }] }] }, + }, + }, + ], + viewItems: [ + // An assembled form view item record, and a flattened form overlay. + { + name: 'crm_invoice.entry', + object: 'crm_invoice', + viewKind: 'form', + config: { type: 'simple', subforms: [{ childObject: 'crm_invoice_line', columns: [{ field: 'product' }] }] }, + }, + { + name: 'crm_invoice.edit', + object: 'crm_invoice', + viewKind: 'form', + type: 'simple', + subforms: [{ childObject: 'crm_invoice_line', columns: [{ field: 'product' }] }], + }, + ], + }, + after: { + views: [ + { + object: 'crm_invoice', + form: { + type: 'simple', + subforms: [{ + childObject: 'crm_invoice_line', + columns: [ + { name: 'product' }, + { name: 'quantity', label: 'Qty' }, + { name: 'unit_price' }, + { field: 'amount', name: 'total' }, + ], + }], + }, + formViews: { + quick: { type: 'simple', subforms: [{ childObject: 'crm_invoice_line', columns: [{ name: 'product' }] }] }, + }, + }, + ], + viewItems: [ + { + name: 'crm_invoice.entry', + object: 'crm_invoice', + viewKind: 'form', + config: { type: 'simple', subforms: [{ childObject: 'crm_invoice_line', columns: [{ name: 'product' }] }] }, + }, + { + name: 'crm_invoice.edit', + object: 'crm_invoice', + viewKind: 'form', + type: 'simple', + subforms: [{ childObject: 'crm_invoice_line', columns: [{ name: 'product' }] }], + }, + ], + }, + // One per respelled column: two in the container's `form`, one in its named + // form view, one in the assembled record and one in the assembled overlay. + // The name-keyed and the two-key entries emit none. + expectedNotices: 5, + }, +}; + /** * `hook.timeout` → `hook.timeoutMs` (protocol 18, #14478; maintainer ruling * 2026-09-02, recorded on the card as "ruled B"). @@ -13074,6 +13253,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: flowDecisionModeInclusiveExplicit, order: 45 }, { conversion: formLayoutInlineGridToVertical, order: 40 }, { conversion: formViewOptionDefaultRemoved, order: 17 }, + { conversion: formViewSubformColumnsCanonicalized, order: 51 }, { conversion: hookTimeoutToTimeoutMs, order: 21 }, { conversion: jobTimeoutToTimeoutMs, order: 22 }, { conversion: listViewSortStringClauseToArray, order: 30 }, diff --git a/packages/spec/src/migrations/entries/semantic/18.form-view-subform-columns-closed.ts b/packages/spec/src/migrations/entries/semantic/18.form-view-subform-columns-closed.ts index f296a99f318..8240de77c27 100644 --- a/packages/spec/src/migrations/entries/semantic/18.form-view-subform-columns-closed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.form-view-subform-columns-closed.ts @@ -6,11 +6,12 @@ import type { SemanticMigration } from '../../types.js'; // was `z.array(z.any())` while the other carrier, a relationship field's // `inlineColumns`, has been the strict `InlineGridColumnSchema` since #9227; the // carrier now REFERENCES that schema, so both carriers are judged by one -// contract. D3 only, deliberately: the one mechanical respelling the family -// had (`field` → `name`, conversion `field-column-lists-canonicalized`) is -// already retired from the load path, and every other refused shape is a -// judgment only the author can make. A stored view whose column fails is -// refused with the column schema's own prescription, never stripped. +// contract. The one mechanical respelling, `field` → `name`, is the D2 +// conversion `form-view-subform-columns-canonicalized` (the respelling +// `field-column-lists-canonicalized` makes on `inlineColumns`); every other +// refused shape is a judgment only the author can make. A view saved with a +// failing column is refused with the column schema's own prescription, and a +// stored row carrying one is diagnosed at rehydration; neither is stripped. export const entry: SemanticMigration = { id: 'form-view-subform-columns-closed', surface: 'view.form.subforms[].columns[] and view.formViews..subforms[].columns[] — the ' @@ -27,10 +28,12 @@ export const entry: SemanticMigration = { + '(option B, `scale` retired from the currency type) and the remedy ruled on 2026-09-24 ' + '(option 乙 — a currency\'s ISO 4217 minor unit decides its display) — published green here. ' + 'The carrier now references the column schema, so every rule it holds applies here too, with ' - + 'its own prescription. NOT mechanically converted: the `field` → `name` respelling this family ' - + 'had is already retired from the load path, and which column an unknown key or a mixed ' - + '`field`/`name` entry meant is the author\'s call — a conversion that dropped the key would ' - + 'accept on every load what the parse now refuses. Population measured at the change, on ' + + 'its own prescription. Only the `field` spelling is converted mechanically — by the conversion ' + + '`form-view-subform-columns-canonicalized`, which rewrites stored rows and assembled artifacts ' + + 'and lists the edit under `os migrate meta`, while an author writing `field` meets the refusal. ' + + 'Which column an unknown key or a mixed `field`/`name` entry meant is the author\'s call — a ' + + 'conversion that dropped the key would accept on every load what the parse now refuses. ' + + 'Population measured at the change, on ' + 'origin/main cb4c31dd52: zero authored `subforms` in the repository (the showcase derives its ' + 'master-detail grids from the data model instead), against one authored `inlineColumns` block ' + 'as the control. Deployed metadata NOT MEASURED.', diff --git a/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-currency-scale-refused.ts b/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-currency-scale-refused.ts index 00fc695370b..39fb94c9ed3 100644 --- a/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-currency-scale-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-currency-scale-refused.ts @@ -6,7 +6,9 @@ export const entry: SemanticMigration = { id: 'inline-grid-column-currency-scale-refused', surface: 'object.fields..inlineColumns[].scale on an inline grid column that declares ' + '`type: \'currency\'` — any declared value, `scale: 0` included, computed or not. `scale` on a ' - + '`number` column, and on a column that declares no `type`, is untouched', + + '`number` column is untouched. A column that declares no `type` is judged as the type it ' + + 'renders as: over a `currency` field of the child object it is entry ' + + '`inline-grid-column-identity-only-currency-scale-refused`', replacement: 'no `scale` on a currency inline grid column. DELETE the key — that is the whole ' + 'migration: a currency amount\'s decimal places are its currency\'s, not a column setting. The ' + 'currency\'s ISO 4217 minor unit decides how the cell displays the amount and the width a ' @@ -25,17 +27,21 @@ export const entry: SemanticMigration = { + 'every load, which is the grace window the ruling refused; the refusal names the key and its ' + 'one-line fix instead. The same change rewords the column\'s `prefix` description: it replaces ' + 'the resolved currency\'s symbol and has no default (the grid no longer falls back to a fixed ' - + 'yen sign). Reach: only a DECLARED column `type` is judged — a column that declares none takes ' - + 'its type from the child field when the console hydrates it, which the column schema cannot ' - + 'see. Population measured at the change, on origin/main 1c8b320a89: one authored ' - + '`inlineColumns` block in the tree (the showcase invoice, seven identity-only columns, none ' - + 'declaring `type` or `scale`), no platform object, skill, documentation example or JSON fixture ' - + 'declaring an inline grid column at all, and one test fixture carrying `scale: 2` on a currency ' - + 'column, re-judged in the same change. Deployed metadata NOT MEASURED.', + + 'yen sign). Reach: the column schema judges only a DECLARED column `type` — a column that ' + + 'declares none takes its type from the child field when the console hydrates it, which the ' + + 'schema cannot see; `defineStack` judges that column instead (entry ' + + '`inline-grid-column-identity-only-currency-scale-refused`). Population measured at the ' + + 'change, on origin/main 1c8b320a89: one authored `inlineColumns` block in the tree (the ' + + 'showcase invoice, seven identity-only columns, none declaring `type` or `scale`), no platform ' + + 'object, skill, documentation example or JSON fixture declaring an inline grid column at all, ' + + 'and one test fixture carrying `scale: 2` on a currency column, re-judged in the same change. ' + + 'Deployed metadata NOT MEASURED.', acceptanceCriteria: 'Every field in the stack parses: an `ObjectSchema` parse and `objectstack validate` report no ' + 'issue on an `inlineColumns[].scale` path of a column declaring `type: \'currency\'`. A ' + 'currency column that carried `scale` no longer declares it, and a diff of the column shows ' - + 'that one line deleted and no key added. `number` columns, and columns declaring no `type`, ' - + 'keep their `scale`; a column\'s `prefix` is still accepted on a currency column.', + + 'that one line deleted and no key added. `number` columns keep their `scale`, and so does a ' + + 'column declaring no `type` unless it names a `currency` field of the child object (entry ' + + '`inline-grid-column-identity-only-currency-scale-refused`); a column\'s `prefix` is still ' + + 'accepted on a currency column.', }; diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 9102d7a0316..f93870ec6a8 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -2672,12 +2672,13 @@ function collectPermissionGrantObjectErrors( * {@link InlineGridColumnSchema}, and for no other. * * Read from the renderer, not decided here: objectui's `hydrateColumns` - * (`packages/plugin-form/src/deriveMasterDetail.ts`, at the `.objectui-sha` - * pin) leaves a column that declares a `type` alone and otherwise sets - * `type: fieldTypeToColumnType(childField.type)`, whose only `currency` arm is - * the `currency` field type. Today the column schema's one type-conditional - * rule is the `currency` refusal of `scale`, so `currency` is the one row; a - * new type-conditional rule on the column adds its row here. + * (`packages/plugin-form/src/deriveMasterDetail.ts`, read at the + * `.objectui-sha` pin `db11afd4967c`) leaves a column that declares a `type` + * alone and otherwise sets `type: fieldTypeToColumnType(childField.type)`, + * whose only `currency` arm is the `currency` field type. Today the column + * schema's one type-conditional rule is the `currency` refusal of `scale`, so + * `currency` is the one row; a new type-conditional rule on the column adds its + * row here, and `inline-grid-column-carriers.test.ts` reds until it does. */ const HYDRATED_INLINE_COLUMN_TYPE: Readonly> = { currency: 'currency', @@ -2711,9 +2712,8 @@ const hasOwnKey = (record: object, key: string): boolean => Object.prototype.has * `formViews` entry) — a column names a field of the subform's * `childObject`. * - * Resolution is against the stack's own objects, like the view data-source - * check in {@link validateCrossReferences}: a `childObject` this stack does not - * declare, or a column naming no field of it, is not judged here — an + * Resolution is against the stack's own `objects`: a `childObject` this stack + * does not declare, or a column naming no field of it, is not judged here — an * unresolved column is not a wrong one, and the console's render-time report * stays the backstop for it. */ From 9b6f85acf8093bf4aa7b6e8488777afd61bdf017 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:04:08 +0000 Subject: [PATCH 2/5] wip(spec): pin the hydrated-type table against the column schema's type-conditional rules, and the form-view respelling's reach Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/inline-grid-column-carriers.test.ts | 156 +++++++++++++++++- 1 file changed, 155 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/inline-grid-column-carriers.test.ts b/packages/spec/src/inline-grid-column-carriers.test.ts index 12e0e96dd94..2a6f7bdd9cf 100644 --- a/packages/spec/src/inline-grid-column-carriers.test.ts +++ b/packages/spec/src/inline-grid-column-carriers.test.ts @@ -28,6 +28,11 @@ * 3. THE CONTROLS — valid columns on both carriers, a column that declares a * `type`, a child field of another type, and a child object this stack * does not declare are all accepted. + * 4. THE TABLE — every type-conditional rule of the column schema, found by + * probing it, reaches `defineStack` through the hydrated-type table. + * 5. THE CHAIN STEP — the `field` → `name` respelling reaches the form-view + * carrier (ADR-0087's lossless-break step): stored rows in every `view` + * spelling and `os migrate meta` get it; the authoring funnel does not. * * Key-vs-value note: the column rules judge the KEY on one type whatever its * value, so each refusal is a full parse failure at the column's own path, and @@ -35,8 +40,11 @@ */ import { describe, expect, it } from 'vitest'; +import { ALL_CONVERSIONS } from './conversions/registry'; +import { applyConversionsToStoredItem } from './conversions/stored'; import { InlineGridColumnSchema } from './data/field.zod'; -import { FormViewSchema } from './ui/view.zod'; +import { applyMetaMigrations } from './migrations/chain'; +import { FormViewSchema, ViewMetadataSchema } from './ui/view.zod'; import { defineStack } from './stack.zod'; type Issue = { code: string; path: PropertyKey[]; message: string }; @@ -240,3 +248,149 @@ describe('#20901 — defineStack judges an identity-only column by the type it r expect(() => build(stackWithInlineColumns([remedied]))).not.toThrow(); }); }); + +// --------------------------------------------------------------------------- +// The hydrated-type table in `stack.zod.ts` covers every type-conditional rule. +// --------------------------------------------------------------------------- + +/** Values tried for each column key; a key is probed with every one its own schema accepts. */ +const PROBE_VALUES: readonly unknown[] = [0, 2, true, 'x', ['x'], [{ label: 'A', value: 'a' }]]; + +type TypeConditionalRule = { type: string; key: string; value: unknown }; + +/** + * Every rule of `InlineGridColumnSchema` that depends on the column's `type`, + * found by probing the schema rather than listed: a (type, key, value) is one + * when the column parses with the key alone and with the type alone, and not + * with both. A key no probe value satisfies is reported, not skipped — its rules + * would be invisible here. + */ +function typeConditionalRules(): { rules: TypeConditionalRule[]; unprobed: string[] } { + const shape = InlineGridColumnSchema.shape as unknown as Record; + const types = (shape.type as { unwrap(): { options: string[] } }).unwrap().options; + const keys = Object.keys(shape).filter((key) => key !== 'name' && key !== 'type'); + const accepts = (column: Record) => InlineGridColumnSchema.safeParse(column).success; + const rules: TypeConditionalRule[] = []; + const unprobed: string[] = []; + for (const type of types) { + if (!accepts({ name: 'probe', type })) rules.push({ type, key: '(none)', value: undefined }); + } + for (const key of keys) { + const values = PROBE_VALUES.filter((value) => accepts({ name: 'probe', [key]: value })); + if (values.length === 0) unprobed.push(key); + for (const value of values) { + for (const type of types) { + if (!accepts({ name: 'probe', type, [key]: value })) rules.push({ type, key, value }); + } + } + } + return { rules, unprobed }; +} + +/** + * A stack whose child object carries a field `probe` of `fieldType`, and an + * identity-only `inlineColumns` entry over it. The field type is the column + * type's own name: at the `.objectui-sha` pin `db11afd4967c`, + * `fieldTypeToColumnType` maps each of the column schema's nine types' namesake + * field type to that same column type. + */ +const stackWithProbeField = (fieldType: string, column: Record) => { + const child = childObject([column]); + return { manifest, objects: [PARENT, { ...child, fields: { ...child.fields, probe: { type: fieldType } } }] }; +}; + +describe('#20901 — every type-conditional column rule has its row in the hydrated-type table', () => { + it('the probe reaches every column key, and finds the rule the tree holds today', () => { + const { rules, unprobed } = typeConditionalRules(); + expect(unprobed, 'column keys no probe value satisfies — add a value to PROBE_VALUES').toEqual([]); + // Anti-vacuity: a probe that found nothing would pass the check below vacuously. + expect(rules.some((r) => r.type === 'currency' && r.key === 'scale')).toBe(true); + }); + + it('defineStack refuses each one on an identity-only column over a field of that type', () => { + for (const { type, key, value } of typeConditionalRules().rules) { + const column = key === '(none)' ? { name: 'probe' } : { name: 'probe', [key]: value }; + const subject = `the column schema refuses ${key === '(none)' ? 'a bare' : `\`${key}: ${JSON.stringify(value)}\` on a`} ` + + `\`${type}\` column`; + let refusal: Refusal | undefined; + try { + build(stackWithProbeField(type, column)); + } catch (error) { + refusal = error as Refusal; + } + expect( + refusal, + `${subject}, and defineStack accepted an identity-only column over a \`${type}\` child field ` + + 'carrying it — add the row to HYDRATED_INLINE_COLUMN_TYPE in stack.zod.ts', + ).toBeDefined(); + expect(refusal!.code, subject).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refusal!.status, subject).toBe(422); + } + }); +}); + +// --------------------------------------------------------------------------- +// The `field` → `name` respelling reaches the form-view carrier (ADR-0087 D2). +// --------------------------------------------------------------------------- + +describe('#20901 — the `field` → `name` respelling is a chain step on the form-view carrier', () => { + const ID = 'form-view-subform-columns-canonicalized'; + const conversion = () => ALL_CONVERSIONS.find((c) => c.id === ID); + const form = (columns: unknown[]) => ({ ...FORM_BASE, subforms: [{ childObject: 'crm_invoice_line', columns }] }); + const LEGACY = [{ field: 'quantity', label: 'Qty' }, { name: 'amount' }]; + const RESPELLED = [{ name: 'quantity', label: 'Qty' }, { name: 'amount' }]; + + /** A stored `view` row in each spelling `ViewMetadataSchema` accepts. */ + const storedRows = (columns: unknown[]) => ({ + container: { name: 'crm_invoice', object: 'crm_invoice', formViews: { entry: form(columns) } }, + record: { name: 'crm_invoice.entry', object: 'crm_invoice', viewKind: 'form', config: form(columns) }, + overlay: { name: 'crm_invoice.edit', object: 'crm_invoice', viewKind: 'form', ...form(columns) }, + }); + const columnsOf = (row: Record): unknown[] => + (row.formViews?.entry ?? row.config ?? row).subforms[0].columns; + + it('is a retired protocol-18 entry stamped with the last release whose form-view carrier accepted `field`', () => { + expect(conversion()?.toMajor).toBe(18); + expect(conversion()?.retiredFromLoadPath).toBe(true); + expect(conversion()?.retiredAfter).toBe('17.5.0'); + }); + + it('a stored row in each `view` spelling is respelled, and only then parses', () => { + for (const [spelling, row] of Object.entries(storedRows(LEGACY))) { + expect(ViewMetadataSchema.safeParse(row).success, `${spelling}, as stored`).toBe(false); + const converted = applyConversionsToStoredItem('view', structuredClone(row)) as Record; + expect(columnsOf(converted), spelling).toEqual(RESPELLED); + expect(ViewMetadataSchema.safeParse(converted).success, `${spelling}, converted`).toBe(true); + } + }); + + it('CONTROLS — an entry already spelled `name`, and one carrying both keys, are left as they are', () => { + const columns = [{ name: 'quantity' }, { field: 'amount', name: 'total' }]; + for (const [spelling, row] of Object.entries(storedRows(columns))) { + expect(applyConversionsToStoredItem('view', row), spelling).toBe(row); + } + }); + + it('`os migrate meta` replays it: the step-18 chain respells a source stack, one edit per column', () => { + const result = applyMetaMigrations(structuredClone(stackWithSubformColumns(LEGACY, 'formViews')), 17, 18); + const views = result.stack.views as Array>; + expect(views[0].formViews.entry.subforms[0].columns).toEqual(RESPELLED); + expect(result.applied.filter((a) => a.conversionId === ID).map((a) => a.path)).toEqual([ + 'views[0].formViews.entry.subforms[0].columns[0].name', + ]); + }); + + it('the authoring funnel does not replay it: defineStack refuses the `field` spelling at the schema parse', () => { + const refusal = refusalOf(stackWithSubformColumns(LEGACY)); + expect(refusal.code).toBe('STACK_SCHEMA_INVALID'); + expect(refusal.status).toBe(422); + }); + + it('both carriers get one respelling: a field\'s `inlineColumns` and a subform\'s `columns` convert alike', () => { + const columns = [...LEGACY, { field: 'amount', name: 'total' }, 'not-a-column']; + const object = applyConversionsToStoredItem('object', childObject(columns)) as Record; + const view = applyConversionsToStoredItem('view', storedRows(columns).container) as Record; + expect(columnsOf(view)).toEqual(object.fields.invoice.inlineColumns); + expect(columnsOf(view)).toEqual([...RESPELLED, { field: 'amount', name: 'total' }, 'not-a-column']); + }); +}); From a4e7143473fb6dbfdb00c6f0b43a3d7e513c6e89 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:04:19 +0000 Subject: [PATCH 3/5] wip(spec): regenerate the migration registry from its entries Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/src/migrations/registry.ts | 47 ++++++++++++++---------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 3f4d9bba6f0..cdb5280de3c 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -11871,11 +11871,12 @@ const step18: MigrationStep = { // was `z.array(z.any())` while the other carrier, a relationship field's // `inlineColumns`, has been the strict `InlineGridColumnSchema` since #9227; the // carrier now REFERENCES that schema, so both carriers are judged by one - // contract. D3 only, deliberately: the one mechanical respelling the family - // had (`field` → `name`, conversion `field-column-lists-canonicalized`) is - // already retired from the load path, and every other refused shape is a - // judgment only the author can make. A stored view whose column fails is - // refused with the column schema's own prescription, never stripped. + // contract. The one mechanical respelling, `field` → `name`, is the D2 + // conversion `form-view-subform-columns-canonicalized` (the respelling + // `field-column-lists-canonicalized` makes on `inlineColumns`); every other + // refused shape is a judgment only the author can make. A view saved with a + // failing column is refused with the column schema's own prescription, and a + // stored row carrying one is diagnosed at rehydration; neither is stripped. { id: 'form-view-subform-columns-closed', surface: 'view.form.subforms[].columns[] and view.formViews..subforms[].columns[] — the ' @@ -11892,10 +11893,12 @@ const step18: MigrationStep = { + '(option B, `scale` retired from the currency type) and the remedy ruled on 2026-09-24 ' + '(option 乙 — a currency\'s ISO 4217 minor unit decides its display) — published green here. ' + 'The carrier now references the column schema, so every rule it holds applies here too, with ' - + 'its own prescription. NOT mechanically converted: the `field` → `name` respelling this family ' - + 'had is already retired from the load path, and which column an unknown key or a mixed ' - + '`field`/`name` entry meant is the author\'s call — a conversion that dropped the key would ' - + 'accept on every load what the parse now refuses. Population measured at the change, on ' + + 'its own prescription. Only the `field` spelling is converted mechanically — by the conversion ' + + '`form-view-subform-columns-canonicalized`, which rewrites stored rows and assembled artifacts ' + + 'and lists the edit under `os migrate meta`, while an author writing `field` meets the refusal. ' + + 'Which column an unknown key or a mixed `field`/`name` entry meant is the author\'s call — a ' + + 'conversion that dropped the key would accept on every load what the parse now refuses. ' + + 'Population measured at the change, on ' + 'origin/main cb4c31dd52: zero authored `subforms` in the repository (the showcase derives its ' + 'master-detail grids from the data model instead), against one authored `inlineColumns` block ' + 'as the control. Deployed metadata NOT MEASURED.', @@ -12308,7 +12311,9 @@ const step18: MigrationStep = { id: 'inline-grid-column-currency-scale-refused', surface: 'object.fields..inlineColumns[].scale on an inline grid column that declares ' + '`type: \'currency\'` — any declared value, `scale: 0` included, computed or not. `scale` on a ' - + '`number` column, and on a column that declares no `type`, is untouched', + + '`number` column is untouched. A column that declares no `type` is judged as the type it ' + + 'renders as: over a `currency` field of the child object it is entry ' + + '`inline-grid-column-identity-only-currency-scale-refused`', replacement: 'no `scale` on a currency inline grid column. DELETE the key — that is the whole ' + 'migration: a currency amount\'s decimal places are its currency\'s, not a column setting. The ' + 'currency\'s ISO 4217 minor unit decides how the cell displays the amount and the width a ' @@ -12327,19 +12332,23 @@ const step18: MigrationStep = { + 'every load, which is the grace window the ruling refused; the refusal names the key and its ' + 'one-line fix instead. The same change rewords the column\'s `prefix` description: it replaces ' + 'the resolved currency\'s symbol and has no default (the grid no longer falls back to a fixed ' - + 'yen sign). Reach: only a DECLARED column `type` is judged — a column that declares none takes ' - + 'its type from the child field when the console hydrates it, which the column schema cannot ' - + 'see. Population measured at the change, on origin/main 1c8b320a89: one authored ' - + '`inlineColumns` block in the tree (the showcase invoice, seven identity-only columns, none ' - + 'declaring `type` or `scale`), no platform object, skill, documentation example or JSON fixture ' - + 'declaring an inline grid column at all, and one test fixture carrying `scale: 2` on a currency ' - + 'column, re-judged in the same change. Deployed metadata NOT MEASURED.', + + 'yen sign). Reach: the column schema judges only a DECLARED column `type` — a column that ' + + 'declares none takes its type from the child field when the console hydrates it, which the ' + + 'schema cannot see; `defineStack` judges that column instead (entry ' + + '`inline-grid-column-identity-only-currency-scale-refused`). Population measured at the ' + + 'change, on origin/main 1c8b320a89: one authored `inlineColumns` block in the tree (the ' + + 'showcase invoice, seven identity-only columns, none declaring `type` or `scale`), no platform ' + + 'object, skill, documentation example or JSON fixture declaring an inline grid column at all, ' + + 'and one test fixture carrying `scale: 2` on a currency column, re-judged in the same change. ' + + 'Deployed metadata NOT MEASURED.', acceptanceCriteria: 'Every field in the stack parses: an `ObjectSchema` parse and `objectstack validate` report no ' + 'issue on an `inlineColumns[].scale` path of a column declaring `type: \'currency\'`. A ' + 'currency column that carried `scale` no longer declares it, and a diff of the column shows ' - + 'that one line deleted and no key added. `number` columns, and columns declaring no `type`, ' - + 'keep their `scale`; a column\'s `prefix` is still accepted on a currency column.', + + 'that one line deleted and no key added. `number` columns keep their `scale`, and so does a ' + + 'column declaring no `type` unless it names a `currency` field of the child object (entry ' + + '`inline-grid-column-identity-only-currency-scale-refused`); a column\'s `prefix` is still ' + + 'accepted on a currency column.', }, // #20901 — the reach of `inline-grid-column-currency-scale-refused`, extended to // the column that declares no `type`. The column schema judges only a DECLARED From ca5ad202a90a32ccc689783800cac8b78cdb116f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:09:20 +0000 Subject: [PATCH 4/5] wip(spec): the major-18 merge pin derives the neighbour's finding when the neighbour is placed by the rule Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/scripts/conversions-major18-merge.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/spec/scripts/conversions-major18-merge.test.ts b/packages/spec/scripts/conversions-major18-merge.test.ts index 58196562b68..d5a3bb6b698 100644 --- a/packages/spec/scripts/conversions-major18-merge.test.ts +++ b/packages/spec/scripts/conversions-major18-merge.test.ts @@ -355,7 +355,13 @@ describe('major 18 in the conversions registry — two retirements, no merge dri expect(placementFindings(side)).toEqual([]); } expect(sortFindings(retireAtListEnd(SOURCE, A, NEXT_ORDER))).not.toEqual([]); + // A's entry follows its neighbour's, so a neighbour placed by the rule is + // found too: its entry is now followed by A's, and A is defined elsewhere. + const neighbour = IDENTS[k - 1]!; expect(placementFindings(retireAtDefinitionsEnd(SOURCE, A, NEXT_ORDER))).toEqual([ + ...(PLACED_BEFORE_THE_RULE.has(neighbour) + ? [] + : [`${neighbour} is defined above ${IDENTS[k]}; define it directly above ${A}'s definition, the entry that follows it`]), `${A} is defined above nothing (it is the last conversion defined); define it directly above ${IDENTS[k]}'s ` + 'definition, the entry that follows it', ]); From 678450958838567d5e243d0722785543770c1eec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:26:13 +0000 Subject: [PATCH 5/5] wip(spec): changeset for the form-view subform column respelling Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../20901-form-view-subform-columns-canonicalized.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/20901-form-view-subform-columns-canonicalized.md diff --git a/.changeset/20901-form-view-subform-columns-canonicalized.md b/.changeset/20901-form-view-subform-columns-canonicalized.md new file mode 100644 index 00000000000..5bcdb0a440f --- /dev/null +++ b/.changeset/20901-form-view-subform-columns-canonicalized.md @@ -0,0 +1,11 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): a stored form view whose subform grid columns use the `field` spelling is respelled to `name` on the way in, as a relationship field's `inlineColumns` already are (#20901) + +**`@objectstack/spec`** + +- **New ADR-0087 conversion `form-view-subform-columns-canonicalized` (protocol 18, retired from the authoring path).** A form view's `subforms[].columns` accepted any value through 17.5.0 and now takes the inline grid column contract, which refuses `{ field: 'x' }` with the prescription naming `name`. The conversion rewrites that entry as `{ name: 'x' }`, every other key kept, wherever a form view travels as data at rest: a stored `view` row (its `form`, each `formViews` entry, a form view item's `config`, a flattened form overlay), an assembled manifest's `viewItems`, and `os migrate meta --from 17`, which lists the edit. A built artifact whose declared protocol floor is 17.5.0 or lower is converted too, not refused. An entry that already carries `name` is left alone, including one that carries both `field` and `name`: the parse names both keys, and the author picks one. It is the same respelling `field-column-lists-canonicalized` applies to a relationship field's `inlineColumns`, and both entries run one shared rule. +- **Authored sources are unchanged:** `defineStack` and `objectstack validate` do not replay a retired conversion, so a source that writes `field` on a subform column is still refused with the prescription. Write `name`. +- **Two step-18 migration entries now read true.** `inline-grid-column-currency-scale-refused` no longer says a column declaring no `type` keeps its `scale`: over a `currency` field of the child object, `defineStack` refuses it, under `inline-grid-column-identity-only-currency-scale-refused`, which the entry now names. `form-view-subform-columns-closed` names this conversion as the one mechanical edit on its carrier.