Skip to content

Commit c6b3a01

Browse files
fix(spec): the field → name respelling reaches a form view's subform columns as its own ADR-0087 step, and two step-18 entries read true (#20901) (#20953)
Part of #20901 Clause-②: no The follow-up the contract review of PR #20927 owes (review record `5919009567`, items 5, 6 and 9, and the ① text findings), dispatched by landing record `5919493523`. #20901 stays open for the seat to close by hand. ## What changed 1. **Review item 5, the step-18 entry `inline-grid-column-currency-scale-refused`.** Its surface said a column that declares no `type` "is untouched", and its acceptance said such columns "keep their `scale`". Since `bee75cebe6` that is false for an identity-only column over a `currency` child field, which `defineStack` refuses. The surface, the reach sentence of the reason and the acceptance now say so, and each names the sibling entry `inline-grid-column-identity-only-currency-scale-refused`. The registry was regenerated with `gen:migration-registry`. 2. **Review item 6, ruled WIDEN: the `field` → `name` respelling is now an ADR-0087 chain step on the form-view carrier.** New D2 conversion `form-view-subform-columns-canonicalized` (protocol 18, `retiredFromLoadPath: true`, `retiredAfter: '17.5.0'`). It walks every FORM payload `mapViewPayloads` reaches, which is `form`, each `formViews` entry, 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). It also walks the assembled `viewItems` channel. It uses the same respelling rule as `field-column-lists-canonicalized`, now one shared function (`respellInlineGridColumns`) called by both entries. An entry already spelled `name` is left alone, and so is one carrying both keys, exactly as the existing walk behaves. The D3 entry `form-view-subform-columns-closed` said "NOT mechanically converted". It now names this conversion, and its comment no longer says a stored row is "refused": a stored row is diagnosed at rehydration. 3. **Review item 9.** A pin in `inline-grid-column-carriers.test.ts` probes `InlineGridColumnSchema` for every (type, key, value) that parses with the key alone and with the type alone but not with both. Today it finds exactly `currency` + `scale` (at `0` and `2`), and it asserts that `defineStack` refuses each such rule on an identity-only column over a field of that type. A new type-conditional rule without a `HYDRATED_INLINE_COLUMN_TYPE` row goes red, and the failure message names the table. `stack.zod.ts` itself is unchanged apart from comments. 4. **The ① text findings, in the files touched above.** `stack.zod.ts`: the objectui `hydrateColumns` / `fieldTypeToColumnType` claim is now a historical pin citation (`.objectui-sha` pin `db11afd4967c`, re-read at that sha: `hydrateColumns` leaves a typed column alone, and `fieldTypeToColumnType`'s only `currency` arm is `case 'currency'`), and `check:objectui-pin-citations` lists it. The over-broad "like the view data-source check" is cut. The instruction "adds its row here" now names the pin that holds it. ## Route change on item 6: its own entry, not a wider walk inside `field-column-lists-canonicalized` The ruling's intent is carried out exactly: a lossless respelling on the new carrier, landed as a `retiredFromLoadPath` chain step in the same release. The vehicle differs from the ruling's wording, for one ledger fact the gates cannot see. `retiredAfter` is one value per entry: "the last published `@objectstack/spec` whose authoring surface still accepted the old shape" (ADR-0087 amendment of 2026-09-30, maintainer ruling A on #20390). `field-column-lists-canonicalized` is published with `17.0.0`, and `retired-after.census.test.ts` pins that value, while the form-view carrier accepted `field` through 17.5.0. The artifact-ingestion door opens its window per entry by that value. Measured at this head, with `applyArtifactForwardConversions`, an artifact declaring `engines.protocol: ^17.5.0`, and runtime label `17.5.0`: - verdict `converted-retired-after`; - the subform column `{ field: 'quantity' }` became `{ name: 'quantity' }` through the new entry (listed in `replayedRetirements` with `retiredAfter` `17.5.0`); - the same `{ field: 'quantity' }` on a relationship field's `inlineColumns` stayed as authored, because `field-column-lists-canonicalized` (`17.0.0`) stays closed at that floor. That is correct for that carrier, and it is what a folded-in subform walk would have done to the new carrier: the refusal instead of the rewrite. A second, smaller reason: the per-release section of `spec-changes.json` reports conversion ids that are new in a release, so a widened, already-published id would be invisible there. If the seat still wants one id, the fold-in is mechanical: move the `mapViewPayloads` walk into `field-column-lists-canonicalized` and delete the sibling. The cost is the artifact-door gap above. ## Verification (at `06f079864a`, which carries `origin/main` `3fbf3ca617`) - **Pins** `packages/spec/src/inline-grid-column-carriers.test.ts`: 21 passed (13 existing + 8 new). - **Ablation 1** (`scripts/ablation-replace.mjs`, `registry.ts`: the new entry's `respellInlineGridColumns(subform.columns, …)` call replaced by `subform.columns`; anchor x1 → x0; blob `909148b74725` → `3b07d9f2d145`), run over the pins plus `conversions.test.ts`: `Tests 4 failed | 251 passed (255)`. The red ones are the fixture pair of `form-view-subform-columns-canonicalized`, the stored-row test in all three `view` spellings, the `os migrate meta` chain test, and the two-carriers-one-rule test. The controls (an entry spelled `name`, one carrying both keys, and the authoring funnel's refusal) stayed green. Restored: blob == HEAD, `git diff HEAD` empty. - **Ablation 2** (`stack.zod.ts`: the `currency` row deleted from `HYDRATED_INLINE_COLUMN_TYPE`; blob `f93870ec6a88` → `d3aca2ec1d38`): `Tests 4 failed | 17 passed (21)`. The new table pin is red with "…add the row to HYDRATED_INLINE_COLUMN_TYPE in stack.zod.ts", and so are the three existing hydrated refusals. Restored: blob == HEAD. - Both ablations ran from committed state (`ca5ad202a9`). The subject resolves to `src` through relative imports, so no dist leg was owed. - **Spec suite** `vitest run --project local`: `Test Files 584 passed (584) / Tests 17200 passed | 1 todo`. **Typecheck** `pnpm --filter @objectstack/spec typecheck`: exit 0 (`check:test-typecheck: OK`; `tsconfig.test.json --listFiles` includes the pin file). **Repo-project subset** (major-18 merge, step-18 rationale merge, retired-key migrate sentence, two view retirement pins): 82 passed. The full `test:repo` project is left to CI. - **`check:generated`**: all 15 artifacts up to date after a fresh `pnpm --filter @objectstack/spec build`. - **Derived gates** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 90 derived, 90 run, all exit 0. `--ran` reconciliation reports "0 NOT-MEASURED (a DERIVED zero — all 90 recorded an exit code)". Four first exited 3 (PREREQUISITE NOT MET: `lint check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`) and were green on a re-run after `turbo run build` over `./packages/*` and `./packages/*/*`. - **ESLint** (a narrowed run, stated as such): `eslint --no-inline-config --format json` over the 7 changed `.ts` files reports files 7, errors 0, warnings 0. The population is `eslint.config.mjs`'s `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`. That config never enables type-aware linting (its own comment: no `parserOptions.project`, no typed rules), so this diff cannot move the verdict on any untouched file. ## One test touched outside the dispatched files `packages/spec/scripts/conversions-major18-merge.test.ts` went red on this diff. It builds its synthetic retirement pair beside the entry at the middle of `MAJOR_18_CONVERSIONS`. At the base that neighbour was `hookTimeoutToTimeoutMs`, which predates the placement rule. With this entry added, the neighbour is `formViewSubformColumnsCanonicalized`, which is placed by the rule. The "defined after every other conversion" variant then reports the neighbour too, because its entry is now followed by the synthetic one. The expectation now derives that neighbour finding when, and only when, the neighbour is not exempt. At the base it reduces to the old single-element list. 12/12 pass. ## Changeset `@objectstack/spec: patch`. The act adds no exported symbol (`check:api-surface` green) and no accepted key or value on an authoring surface: authored sources are refused exactly as before. It rewrites stored and assembled data that the contract already names (`name`), so under the "WHICH LEVEL" rule it is a fix and stays `patch`. `Clause-②: no`, per the gate's definition (a new key on a published payload): the conversion adds no key, and the step-18 edits are prose. It is not a narrowing either, since the data-at-rest seams now accept strictly more. ## Acceptance notes - `retiredAfter: '17.5.0'` is the package label, as `retired-after.census.test.ts` requires for an unpublished entry. The census is refreshed after the next publish. - Step 18's `rationale` gains no fragment. PR #20927 added none for its two D3 entries either, and no gate requires one per entry. - Not changed here: the PR #20927 body sentence "No check read `subforms[].childObject`", recorded by the adoption for its next touch; `packages/spec/src/data/field.zod.ts`, which the dispatch keeps out of scope; #20928 and #20929; and objectui#11266. --- _Generated by [Claude Code](https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c1602ba commit c6b3a01

8 files changed

Lines changed: 427 additions & 58 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
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)
6+
7+
**`@objectstack/spec`**
8+
9+
- **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.
10+
- **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`.
11+
- **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.

‎packages/spec/scripts/conversions-major18-merge.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,13 @@ describe('major 18 in the conversions registry — two retirements, no merge dri
355355
expect(placementFindings(side)).toEqual([]);
356356
}
357357
expect(sortFindings(retireAtListEnd(SOURCE, A, NEXT_ORDER))).not.toEqual([]);
358+
// A's entry follows its neighbour's, so a neighbour placed by the rule is
359+
// found too: its entry is now followed by A's, and A is defined elsewhere.
360+
const neighbour = IDENTS[k - 1]!;
358361
expect(placementFindings(retireAtDefinitionsEnd(SOURCE, A, NEXT_ORDER))).toEqual([
362+
...(PLACED_BEFORE_THE_RULE.has(neighbour)
363+
? []
364+
: [`${neighbour} is defined above ${IDENTS[k]}; define it directly above ${A}'s definition, the entry that follows it`]),
359365
`${A} is defined above nothing (it is the last conversion defined); define it directly above ${IDENTS[k]}'s `
360366
+ 'definition, the entry that follows it',
361367
]);

‎packages/spec/src/conversions/registry.ts‎

Lines changed: 190 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7623,6 +7623,36 @@ const translationComponentSubmitLabelRemoved: MetadataConversion = {
76237623
},
76247624
};
76257625

7626+
/**
7627+
* The inline grid column's one mechanical respelling, shared by both of its
7628+
* carriers — a relationship field's `inlineColumns`
7629+
* ({@link fieldColumnListsCanonicalized}) and a form view's `subforms[].columns`
7630+
* ({@link formViewSubformColumnsCanonicalized}) — so the two conversions cannot
7631+
* drift on what they rewrite, as the two carriers cannot on what they accept
7632+
* (both reference `InlineGridColumnSchema`).
7633+
*
7634+
* `{ field: 'x' }` → `{ name: 'x' }`, every other key kept. An entry already
7635+
* carrying `name` is left alone — rewriting a live key on the strength of a
7636+
* stale one would guess; the parse refuses the mixed shape loudly instead.
7637+
* Returns the same array when nothing was respelled (copy-on-write).
7638+
*/
7639+
function respellInlineGridColumns(
7640+
columns: readonly unknown[],
7641+
path: string,
7642+
emit: (detail: ConversionApplication) => void,
7643+
): readonly unknown[] {
7644+
let changed = false;
7645+
const next = columns.map((entry, i) => {
7646+
if (!isDict(entry) || typeof entry.field !== 'string' || 'name' in entry) return entry;
7647+
const renamed = renameKey(entry, 'field', 'name');
7648+
if (!renamed) return entry;
7649+
emit({ from: 'field', to: 'name', path: `${path}[${i}].name` });
7650+
changed = true;
7651+
return renamed;
7652+
});
7653+
return changed ? next : columns;
7654+
}
7655+
76267656
/**
76277657
* `field.inlineColumns[]` / `field.relatedListColumns[]` — the mechanical half
76287658
* of the #9227 strict-element narrowing (protocol 18).
@@ -7667,16 +7697,8 @@ const fieldColumnListsCanonicalized: MetadataConversion = {
76677697
let next: Dict = def;
76687698
const inline = def.inlineColumns;
76697699
if (Array.isArray(inline)) {
7670-
let changed = false;
7671-
const cols = inline.map((entry, i) => {
7672-
if (!isDict(entry) || typeof entry.field !== 'string' || 'name' in entry) return entry;
7673-
const renamed = renameKey(entry, 'field', 'name');
7674-
if (!renamed) return entry;
7675-
emit({ from: 'field', to: 'name', path: `${path}.inlineColumns[${i}].name` });
7676-
changed = true;
7677-
return renamed;
7678-
});
7679-
if (changed) next = { ...next, inlineColumns: cols };
7700+
const cols = respellInlineGridColumns(inline, `${path}.inlineColumns`, emit);
7701+
if (cols !== inline) next = { ...next, inlineColumns: cols };
76807702
}
76817703
const related = def.relatedListColumns;
76827704
if (Array.isArray(related)) {
@@ -9820,6 +9842,163 @@ const connectorConnectionTimeoutMsRemoved: MetadataConversion = {
98209842
},
98219843
};
98229844

9845+
/**
9846+
* `subforms[].columns[].field` → `name` on every form view — the mechanical
9847+
* half of the #20901 closure of the form-view carrier (protocol 18).
9848+
*
9849+
* `FormViewSchema.subforms[].columns` was `z.array(z.any())` through 17.5.0 and
9850+
* now references `InlineGridColumnSchema`, whose alias table refuses `field`
9851+
* with a prescription naming `name`. That is the break a relationship field's
9852+
* `inlineColumns` took in #9227, and the respelling is the same one: it runs
9853+
* through {@link respellInlineGridColumns}, shared with
9854+
* {@link fieldColumnListsCanonicalized}. ADR-0087's pre-GA policy owes a
9855+
* lossless break a `retiredFromLoadPath` chain step in the same release.
9856+
*
9857+
* **Its own entry, not a wider walk in `field-column-lists-canonicalized`,**
9858+
* because `retiredAfter` is one fact per entry (ADR-0087, amended 2026-09-30):
9859+
* that entry's carriers stopped accepting `field` after 17.0.0, and the census
9860+
* pins that published value, while this carrier accepted it through 17.5.0. The
9861+
* artifact-ingestion door opens its window per entry by `retiredAfter`, so under
9862+
* the older stamp an artifact whose declared floor is 17.5.0 would meet the
9863+
* refusal instead of this rewrite on a runtime still labelled 17.5.0.
9864+
*
9865+
* **Reach: every FORM payload.** {@link mapViewPayloads} reaches `form`,
9866+
* `formViews.*`, a form view item's `config` and a flattened form overlay — the
9867+
* stored-row seam wraps a `view` row as `{ views: [row] }` in any of those
9868+
* spellings — and the assembled-manifest `viewItems` channel
9869+
* ({@link ASSEMBLED_VIEW_ITEMS_KEY}) carries the last two. A list payload is
9870+
* never judged. `FormViewSchema` is the only schema that declares `subforms`.
9871+
*
9872+
* `retiredFromLoadPath`: the parse refuses `field` with the prescription, so a
9873+
* live author is taught rather than rewritten. The entry exists so stored rows
9874+
* and assembled artifacts replay clean, and so `os migrate meta` lists the edit.
9875+
* Idempotent by construction: the rewrite leaves no `field` on the entry.
9876+
*/
9877+
const formViewSubformColumnsCanonicalized: MetadataConversion = {
9878+
id: 'form-view-subform-columns-canonicalized',
9879+
toMajor: 18,
9880+
retiredFromLoadPath: true,
9881+
retiredAfter: '17.5.0',
9882+
surface: 'view.form.subforms[].columns[].field / view.formViews.<key>.subforms[].columns[].field',
9883+
summary:
9884+
"form-view subform grid column entries respelled 'field' → 'name', the grid's column identity "
9885+
+ '(the carrier accepted any value until it took the inline grid column contract; a '
9886+
+ "relationship field's inlineColumns get the same respelling from field-column-lists-canonicalized)",
9887+
apply(stack, emit) {
9888+
const respellSubforms = (form: Dict, path: string): Dict => {
9889+
const subforms = form.subforms;
9890+
if (!Array.isArray(subforms)) return form;
9891+
let changed = false;
9892+
const next = subforms.map((subform, j) => {
9893+
if (!isDict(subform) || !Array.isArray(subform.columns)) return subform;
9894+
const columns = respellInlineGridColumns(subform.columns, `${path}.subforms[${j}].columns`, emit);
9895+
if (columns === subform.columns) return subform;
9896+
changed = true;
9897+
return { ...subform, columns };
9898+
});
9899+
return changed ? { ...form, subforms: next } : form;
9900+
};
9901+
const withViews = mapViewPayloads(stack, (payload, kind, path) =>
9902+
kind === 'form' ? respellSubforms(payload, path) : payload);
9903+
return mapCollection(withViews, ASSEMBLED_VIEW_ITEMS_KEY, (item, path) => {
9904+
if (item.viewKind !== 'form') return item;
9905+
if (isDict(item.config)) {
9906+
const config = respellSubforms(item.config, `${path}.config`);
9907+
return config === item.config ? item : { ...item, config };
9908+
}
9909+
// A flattened form overlay — the body IS the payload. A present but
9910+
// malformed `config` is neither shape and is left for the parse.
9911+
return item.config === undefined ? respellSubforms(item, path) : item;
9912+
});
9913+
},
9914+
fixture: {
9915+
before: {
9916+
views: [
9917+
{
9918+
object: 'crm_invoice',
9919+
// A container: the default form and a named form view.
9920+
form: {
9921+
type: 'simple',
9922+
subforms: [{
9923+
childObject: 'crm_invoice_line',
9924+
columns: [
9925+
// The `field` spelling, every other key kept.
9926+
{ field: 'product' },
9927+
{ field: 'quantity', label: 'Qty' },
9928+
// Already name-keyed — untouched.
9929+
{ name: 'unit_price' },
9930+
// Both keys — untouched: which column was meant is the
9931+
// author's call, and the parse names both keys.
9932+
{ field: 'amount', name: 'total' },
9933+
],
9934+
}],
9935+
},
9936+
formViews: {
9937+
quick: { type: 'simple', subforms: [{ childObject: 'crm_invoice_line', columns: [{ field: 'product' }] }] },
9938+
},
9939+
},
9940+
],
9941+
viewItems: [
9942+
// An assembled form view item record, and a flattened form overlay.
9943+
{
9944+
name: 'crm_invoice.entry',
9945+
object: 'crm_invoice',
9946+
viewKind: 'form',
9947+
config: { type: 'simple', subforms: [{ childObject: 'crm_invoice_line', columns: [{ field: 'product' }] }] },
9948+
},
9949+
{
9950+
name: 'crm_invoice.edit',
9951+
object: 'crm_invoice',
9952+
viewKind: 'form',
9953+
type: 'simple',
9954+
subforms: [{ childObject: 'crm_invoice_line', columns: [{ field: 'product' }] }],
9955+
},
9956+
],
9957+
},
9958+
after: {
9959+
views: [
9960+
{
9961+
object: 'crm_invoice',
9962+
form: {
9963+
type: 'simple',
9964+
subforms: [{
9965+
childObject: 'crm_invoice_line',
9966+
columns: [
9967+
{ name: 'product' },
9968+
{ name: 'quantity', label: 'Qty' },
9969+
{ name: 'unit_price' },
9970+
{ field: 'amount', name: 'total' },
9971+
],
9972+
}],
9973+
},
9974+
formViews: {
9975+
quick: { type: 'simple', subforms: [{ childObject: 'crm_invoice_line', columns: [{ name: 'product' }] }] },
9976+
},
9977+
},
9978+
],
9979+
viewItems: [
9980+
{
9981+
name: 'crm_invoice.entry',
9982+
object: 'crm_invoice',
9983+
viewKind: 'form',
9984+
config: { type: 'simple', subforms: [{ childObject: 'crm_invoice_line', columns: [{ name: 'product' }] }] },
9985+
},
9986+
{
9987+
name: 'crm_invoice.edit',
9988+
object: 'crm_invoice',
9989+
viewKind: 'form',
9990+
type: 'simple',
9991+
subforms: [{ childObject: 'crm_invoice_line', columns: [{ name: 'product' }] }],
9992+
},
9993+
],
9994+
},
9995+
// One per respelled column: two in the container's `form`, one in its named
9996+
// form view, one in the assembled record and one in the assembled overlay.
9997+
// The name-keyed and the two-key entries emit none.
9998+
expectedNotices: 5,
9999+
},
10000+
};
10001+
982310002
/**
982410003
* `hook.timeout` → `hook.timeoutMs` (protocol 18, #14478; maintainer ruling
982510004
* 2026-09-02, recorded on the card as "ruled B").
@@ -13074,6 +13253,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [
1307413253
{ conversion: flowDecisionModeInclusiveExplicit, order: 45 },
1307513254
{ conversion: formLayoutInlineGridToVertical, order: 40 },
1307613255
{ conversion: formViewOptionDefaultRemoved, order: 17 },
13256+
{ conversion: formViewSubformColumnsCanonicalized, order: 51 },
1307713257
{ conversion: hookTimeoutToTimeoutMs, order: 21 },
1307813258
{ conversion: jobTimeoutToTimeoutMs, order: 22 },
1307913259
{ conversion: listViewSortStringClauseToArray, order: 30 },

0 commit comments

Comments
 (0)