From b115c9c1c0db92280a962761a13ad2b0b2270a6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 16:33:51 +0000 Subject: [PATCH 1/9] wip(spec): tombstone the object-master-detail-form detail entry sortField The console reads no authored value: the line grid stamps the field it derives from the child object. retiredKey() tombstone, D2 strip conversion, nested RETIRED_KEYS_BY_MAJOR row and D3 entry; the derived sort-field names move to one relative-import-only declaration the prescription prints. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../lint/src/validate-component-props.test.ts | 25 + packages/spec/src/conversions/registry.ts | 203 ++++++++ packages/spec/src/data/inline-grid-columns.ts | 10 +- .../spec/src/data/inline-grid-sort-fields.ts | 40 ++ .../src/inline-grid-column-carriers.test.ts | 11 +- ...asterDetailFormProps__details.sortField.ts | 23 + ...r-detail-form-detail-sort-field-retired.ts | 37 ++ packages/spec/src/migrations/registry.ts | 70 +++ packages/spec/src/ui/component.zod.ts | 76 ++- ...etail-detail-sort-field-retirement.test.ts | 474 ++++++++++++++++++ 10 files changed, 948 insertions(+), 21 deletions(-) create mode 100644 packages/spec/src/data/inline-grid-sort-fields.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectMasterDetailFormProps__details.sortField.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.object-master-detail-form-detail-sort-field-retired.ts create mode 100644 packages/spec/src/ui/master-detail-detail-sort-field-retirement.test.ts diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index a4f7cf10fd0..f58b5557831 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -91,6 +91,31 @@ describe('validateComponentProps — undeclared keys', () => { } }); + // #21589 — an `object-master-detail-form` detail entry's `sortField` is a + // retiredKey tombstone, one array level down. The same door, the same + // warning: the finding names the entry's key, and the page is never refused. + it('reports a retired detail-entry `sortField` as a warning carrying the prescription, at the entry\'s key', () => { + const findings = validateComponentProps( + stackWith([ + { + type: 'object-master-detail-form', + properties: { + objectName: 'invoice', + details: [ + { title: 'Payments', childObject: 'invoice_payment' }, + { title: 'Lines', childObject: 'invoice_line', sortField: 'line_no' }, + ], + }, + }, + ]), + ); + expect(findings).toHaveLength(1); + const [f] = findings; + expect(f.severity).toBe('warning'); + expect(f.path).toBe('pages[0].regions[0].components[0].properties.details.1.sortField'); + expect(f.message).toContain('`object-master-detail-form` property `details[].sortField` was removed in @objectstack/spec 17'); + }); + it('walks components nested inside `properties` (tabs items → children)', () => { const findings = validateComponentProps( stackWith([ diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 5fc79cb7a9a..79d8fc11dae 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -12332,6 +12332,208 @@ const dashboardWidgetChartConfigStructureRemoved: MetadataConversion = { }, }; +/** + * `object-master-detail-form`'s detail entry `sortField` leaves the contract + * (protocol 18, #21589 — ADR-0049 enforce-or-remove; the spec half of + * objectui#11070 round 9, the direction recorded on #21220's landing and + * mirrored on objectui#11396 ③: tombstone plus ADR-0087). + * + * **A pure lossless delete.** The console stopped reading the authored + * override at objectui `0a3e5409f`, and the `.objectui-sha` pin + * (`89cad75d5570`) is past it: `MasterDetailDetailConfig` has no `sortField` + * member (`plugin-form/src/MasterDetailForm.tsx:83`), and the field the line + * grid stamps with each line's position is the one `deriveDetail` derives from + * the child object (`deriveMasterDetail.ts:540`), handed to the grid as + * `sort_field` (`:874`). An authored value changed nothing on that console, so + * deleting it preserves observed behaviour exactly; the line order is kept by + * the child object's own field, which the entry's tombstone names. The + * tombstone refuses the key for a live author (advisory, via the props lint: + * `PageComponentSchema.properties` is an open bag). + * + * ⚠️ Scoped by component `type` and by POSITION — `properties.details[]` of an + * `object-master-detail-form` — never by key name: `sortField` is an ordinary + * name for an open-namespace component's own prop, and the fixture's + * non-carrier control is such a component authoring the same shape. A + * `details` entry that is not an object rides through untouched (the props + * gate reports it; it is not this entry's to fix). + * + * Zero authored occurrences in this repo's corpora at the retirement — no + * detail entry under `examples/`, `apps/`, `packages/`, `skills/` or + * `content/docs/` writes the key (control: the sibling detail-entry key + * `addLabel`, authored on the showcase project workspace's entry, same + * instrument, origin/main 9a4182a752) — so this entry exists for stored + * `sys_metadata` rows and for authors outside the repo, which the census could + * not measure. + */ +const objectMasterDetailFormDetailSortFieldRemoved: MetadataConversion = { + id: 'object-master-detail-form-detail-sort-field-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.6.0', + surface: 'page.component.object-master-detail-form.details[].sortField', + summary: + "object-master-detail-form detail entry prop 'sortField' removed (the console reads no authored " + + 'value: the line grid stamps the field it derives from the child object, so the key was ' + + "accepted and dropped; delete the key — the child object's own position field keeps the line order)", + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + if (component.type !== 'object-master-detail-form') return component; + const properties = component.properties; + if (!isDict(properties) || !Array.isArray(properties.details)) return component; + const details = properties.details as unknown[]; + let changed = false; + const nextDetails = details.map((entry, i) => { + if (!isDict(entry) || !('sortField' in entry)) return entry; + changed = true; + return stripKeys(entry, ['sortField'], emit, `${path}.properties.details[${i}]`); + }); + if (!changed) return component; + return { ...component, properties: { ...properties, details: nextDetails } }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'invoice_entry', + regions: [ + { + name: 'main', + components: [ + // The carrier: a detail entry authoring the retired override, + // beside an entry WITHOUT it, which rides through untouched. + { + type: 'object-master-detail-form', + id: 'm1', + properties: { + objectName: 'crm_invoice', + details: [ + { childObject: 'crm_invoice_line', title: 'Lines', sortField: 'line_no' }, + { childObject: 'crm_invoice_payment', title: 'Payments' }, + ], + }, + }, + // ⚠️ The same shape on a component that is NOT an + // `object-master-detail-form` — its own prop, not this entry's + // key. Untouched: the strip is scoped by component type. + { + type: 'acme:line_editor', + id: 'x1', + properties: { details: [{ childObject: 'crm_invoice_line', sortField: 'position' }] }, + }, + // A block whose entries carry no `sortField`, and one with a + // non-object entry: both ride through untouched, by reference. + { + type: 'object-master-detail-form', + id: 'm2', + properties: { objectName: 'crm_order', details: [{ childObject: 'crm_order_line' }, 'crm_order_note'] }, + }, + // The nested position (#6775's lesson): a block inside a + // card's `children` is still a component. + { + type: 'page:card', + id: 'c1', + properties: { + children: [ + { + type: 'object-master-detail-form', + id: 'm3', + properties: { + objectName: 'crm_quote', + details: [{ childObject: 'crm_quote_line', sortField: 'position' }], + }, + }, + ], + }, + }, + ], + }, + ], + }, + // The named-slot shape (#6776): the block authored into a slotted page. + { + name: 'invoice_entry_detail', + kind: 'slotted', + regions: [], + slots: { + details: { + type: 'object-master-detail-form', + id: 'm4', + properties: { + objectName: 'crm_invoice', + details: [{ childObject: 'crm_invoice_line', sortField: 'sequence' }], + }, + }, + }, + }, + ], + }, + after: { + pages: [ + { + name: 'invoice_entry', + regions: [ + { + name: 'main', + components: [ + { + type: 'object-master-detail-form', + id: 'm1', + properties: { + objectName: 'crm_invoice', + details: [ + { childObject: 'crm_invoice_line', title: 'Lines' }, + { childObject: 'crm_invoice_payment', title: 'Payments' }, + ], + }, + }, + { + type: 'acme:line_editor', + id: 'x1', + properties: { details: [{ childObject: 'crm_invoice_line', sortField: 'position' }] }, + }, + { + type: 'object-master-detail-form', + id: 'm2', + properties: { objectName: 'crm_order', details: [{ childObject: 'crm_order_line' }, 'crm_order_note'] }, + }, + { + type: 'page:card', + id: 'c1', + properties: { + children: [ + { + type: 'object-master-detail-form', + id: 'm3', + properties: { objectName: 'crm_quote', details: [{ childObject: 'crm_quote_line' }] }, + }, + ], + }, + }, + ], + }, + ], + }, + { + name: 'invoice_entry_detail', + kind: 'slotted', + regions: [], + slots: { + details: { + type: 'object-master-detail-form', + id: 'm4', + properties: { objectName: 'crm_invoice', details: [{ childObject: 'crm_invoice_line' }] }, + }, + }, + }, + ], + }, + // Three notices: the region-level entry, the nested one and the slotted + // one. The open-namespace sibling and the entries without the key emit none. + expectedNotices: 3, + }, +}; + /** * `object.tenancy.organizationField` leaves the authorable surface (protocol * 18, #19054 — ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18, @@ -14456,6 +14658,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: objectGridDefaultSortRemoved, order: 14 }, { conversion: objectGridResizableColumnsRemoved, order: 57 }, { conversion: objectKanbanQuickAddRemoved, order: 15 }, + { conversion: objectMasterDetailFormDetailSortFieldRemoved, order: 59 }, { conversion: objectTenancyOrganizationFieldRemoved, order: 35 }, { conversion: pageAssignedProfilesRemoved, order: 31 }, { conversion: pageComponentFilterRecordToRuleArray, order: 36 }, diff --git a/packages/spec/src/data/inline-grid-columns.ts b/packages/spec/src/data/inline-grid-columns.ts index 4c0884e40d5..3eedbed06ce 100644 --- a/packages/spec/src/data/inline-grid-columns.ts +++ b/packages/spec/src/data/inline-grid-columns.ts @@ -86,6 +86,11 @@ * no expand control. */ +// The sort-position names live in their own module, reached by relative +// import only: the retired `object-master-detail-form` detail entry +// `sortField`'s prescription prints the same list (`ui/component.zod.ts`). +import { INLINE_GRID_SORT_FIELDS } from './inline-grid-sort-fields'; + /** Default-visible column budget of a derived inline grid; the rest are `defaultHidden`. */ export const DEFAULT_MAX_INLINE_GRID_COLUMNS = 6; @@ -107,11 +112,6 @@ const INLINE_GRID_SYSTEM_FIELDS: ReadonlySet = new Set([ 'organization_id', 'tenant_id', 'space', 'owner', ]); -/** Field names that hold a line's sort position: the grid stamps them on drag-reorder. */ -const INLINE_GRID_SORT_FIELDS: ReadonlySet = new Set([ - 'position', 'sort_order', 'sequence', 'line_no', 'line_number', 'sort', -]); - /** Field types a line-item cell cannot edit. File-family types are absent: they render an upload cell. */ const INLINE_GRID_NON_EDITABLE_TYPES: ReadonlySet = new Set([ 'formula', 'summary', 'rollup', 'autonumber', 'auto_number', diff --git a/packages/spec/src/data/inline-grid-sort-fields.ts b/packages/spec/src/data/inline-grid-sort-fields.ts new file mode 100644 index 00000000000..730899d294c --- /dev/null +++ b/packages/spec/src/data/inline-grid-sort-fields.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The child field names that hold a line's sort position — ONE declaration, + * read by every place the spec states which field an inline grid stamps. + * + * The renderer derives the field it stamps with each line's position on + * drag-reorder from the child object: the first of these names the child + * declares (objectui `packages/plugin-form/src/deriveMasterDetail.ts`, + * `SORT_FIELD_NAMES`, the same six names in the same order at the + * `.objectui-sha` pin `89cad75d5570`). Two readers here: + * + * - `inline-grid-columns.ts`, whose derived grid columns and per-row form + * fields skip these names, as the renderer's do; + * - `ui/component.zod.ts`, whose prescriptions for the retired + * `object-master-detail-form` detail entry `sortField` name them: the + * authored override is gone, so naming the child's field IS the remedy. + * + * Why a module of its own: both files must print the SAME list, never two + * copies of it, and exported from `inline-grid-columns.ts` it would ride the + * `data` barrel into the published API — a contract for what is the + * renderer's derivation rule, mirrored here so the spec's text agrees with it. + * This module is reached by relative import only, like + * `ui/action-target-aliases.ts`. + */ + +/** Field names that hold a line's sort position: the grid stamps them on drag-reorder. */ +export const INLINE_GRID_SORT_FIELDS: ReadonlySet = new Set([ + 'position', 'sort_order', 'sequence', 'line_no', 'line_number', 'sort', +]); + +/** + * {@link INLINE_GRID_SORT_FIELDS} as a prescription prints it, in order: + * `` `position` / `sort_order` / … / `sort` ``. A module-level constant of a + * module that imports nothing, so it is initialised before any importer's body + * runs — `OS_EAGER_SCHEMAS=1` included. + */ +export const INLINE_GRID_SORT_FIELD_LIST: string = [...INLINE_GRID_SORT_FIELDS] + .map((name) => `\`${name}\``) + .join(' / '); diff --git a/packages/spec/src/inline-grid-column-carriers.test.ts b/packages/spec/src/inline-grid-column-carriers.test.ts index 4dcfe89ddb9..4c506cad903 100644 --- a/packages/spec/src/inline-grid-column-carriers.test.ts +++ b/packages/spec/src/inline-grid-column-carriers.test.ts @@ -425,7 +425,13 @@ describe('#20901 — the `field` → `name` respelling is a chain step on the fo */ const MASTER_DETAIL_PROPS = ComponentPropsMap['object-master-detail-form']; -/** Every key objectui's `MasterDetailForm` reads off a detail entry (the `.objectui-sha` pin `31971ff1e28f`). */ +/** + * Every key objectui's `MasterDetailForm` reads off a detail entry (the + * `.objectui-sha` pin `31971ff1e28f`, re-read at `89cad75d5570`). `sortField` + * is not one of them: the renderer derives the line-position field from the + * child object, and the key is a tombstone (#21589, + * `ui/master-detail-detail-sort-field-retirement.test.ts`). + */ const FULL_DETAIL_ENTRY = { childObject: 'crm_invoice_line', relationshipField: 'invoice', @@ -433,7 +439,6 @@ const FULL_DETAIL_ENTRY = { formFields: ['quantity', 'amount'], inlineMode: 'grid', amountField: 'amount', - sortField: 'position', totalField: 'total', title: 'Lines', minRows: 1, @@ -543,7 +548,7 @@ describe('#20928 — the master-detail block\'s detail entry is strict, and its expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); expect((result.data as { details: unknown[] }).details).toEqual([entry]); } - expect(Object.keys(FULL_DETAIL_ENTRY)).toHaveLength(12); + expect(Object.keys(FULL_DETAIL_ENTRY)).toHaveLength(11); }); }); diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectMasterDetailFormProps__details.sortField.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectMasterDetailFormProps__details.sortField.ts new file mode 100644 index 00000000000..e90a3e693c3 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectMasterDetailFormProps__details.sortField.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #21589 — ADR-0049 enforce-or-remove through the ADR-0087 D2 route, the spec +// half of objectui#11070 round 9 (the direction recorded on #21220's landing +// and mirrored on objectui#11396 ③: tombstone plus ADR-0087). The console +// retired the authored override at objectui `0a3e5409f`, and the +// `.objectui-sha` pin `89cad75d5570` is past it: `MasterDetailDetailConfig` +// has no `sortField` member (`plugin-form/src/MasterDetailForm.tsx:83`), and +// the field the line grid stamps with each line's position is the one +// `deriveDetail` derives from the child object (`deriveMasterDetail.ts:540`), +// handed to the grid as `sort_field` (`:874`). Tombstoned with `retiredKey()` +// in the strict detail entry; a NESTED row (an array member, spelled without +// its `[]`), so it has no `authorable-surface/` line and checks (b2)/(b3) +// resolve it against the emitted schema. Stored and built pages are stripped +// by the D2 conversion `object-master-detail-form-detail-sort-field-removed`, +// a pure lossless delete scoped by component `type` and position; its D3 +// record is `object-master-detail-form-detail-sort-field-retired`. +// +// Registered under 18, not 17: the removal ships on the 17.x line +// (launch-window convention: accept-set narrowings ride minor releases) and +// the prescription lives at the major boundary where `migrate meta` users +// look — the `ui/PageHeaderProps:breadcrumb` precedent. +export const entry = 'ui/ObjectMasterDetailFormProps:details.sortField'; diff --git a/packages/spec/src/migrations/entries/semantic/18.object-master-detail-form-detail-sort-field-retired.ts b/packages/spec/src/migrations/entries/semantic/18.object-master-detail-form-detail-sort-field-retired.ts new file mode 100644 index 00000000000..18c04d94f37 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.object-master-detail-form-detail-sort-field-retired.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21589 (ADR-0049 enforce-or-remove) — the D3 entry of the +// `object-master-detail-form-detail-sort-field-removed` family (one D3 entry +// per retirement family, even when D2 is lossless). Registered key: +// `ui/ObjectMasterDetailFormProps:details.sortField`. The strip changes +// nothing a user sees, because the console already ignored the authored +// value; what it leaves is the one judgment a delete cannot make — whether the +// child object carries the field the line order is kept in. +export const entry: SemanticMigration = { + id: 'object-master-detail-form-detail-sort-field-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: 'page.component.object-master-detail-form.details[].sortField — a detail entry\'s ' + + 'authored line-position field', + replacement: 'Nothing on the entry: delete the key. The line grid stamps each line\'s position into ' + + 'the child object\'s own field, derived from the child object: its first field named ' + + '`position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`. To keep the line ' + + 'order a drag-reorder sets, give the child object one of those fields (under the name the ' + + 'deleted key named, when it is one of them).', + reason: 'The D2 conversion `object-master-detail-form-detail-sort-field-removed` deletes ' + + '`sortField` from every `object-master-detail-form` detail entry, and the delete is lossless: ' + + 'the console stopped reading the authored override, and the line grid stamps the field it ' + + 'derives from the child object whatever the entry says. What the conversion cannot decide is ' + + 'where the line order lives. An entry whose key named a field the derivation does not pick — ' + + 'a name outside that list, or a second sort-named field after the first — saves its line ' + + 'order into the derived field instead, or nowhere when the child object has none. An entry ' + + 'that names `relationshipField` and at least one column and gives every column a `type` is ' + + 'kept exactly as authored: no child schema is loaded for it, so no line position is stamped ' + + 'and a drag-reorder is not saved, before and after the upgrade alike.', + acceptanceCriteria: 'No `object-master-detail-form` detail entry carries `sortField`; the props ' + + 'lint reports one with the prescription. For each entry that had set it, the child object ' + + 'declares the field the line order is kept in under one of the derived names, and after a ' + + 'drag-reorder and save the lines reload in the order they were dragged into.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 2aa79d5154c..ab00c7c5bc1 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5839,6 +5839,22 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'is a pure lossless DELETE (the key never had an effect to preserve) scoped by component ' + '`type`. Delete the key; `object-kanban` offers no quick-add control.', }, + { + id: 'object-master-detail-form-detail-sort-field-retired', + order: 68, + text: + 'It also retires an `object-master-detail-form` detail entry\'s `sortField` (#21589, ADR-0049 ' + + 'enforce-or-remove; the spec half of objectui#11070 round 9). The console stopped reading the ' + + 'authored override: the field its line grid stamps with each line\'s position on ' + + 'drag-reorder is derived from the child object — its first field named `position`, ' + + '`sort_order`, `sequence`, `line_no`, `line_number` or `sort` — and the pinned console had ' + + 'crossed that change while the spec still declared the key, so an authored value published ' + + 'green and was dropped. A retiredKey tombstone on the strict detail entry with one D2 ' + + 'conversion that is a pure lossless DELETE scoped by component `type` and by position ' + + '(`properties.details[]`); its D3 entry `object-master-detail-form-detail-sort-field-retired` ' + + 'carries the one judgment left, whether the child object declares the field the line order ' + + 'is kept in.', + }, { id: 'object-tenancy-organization-field-retired', order: 34, @@ -15179,6 +15195,39 @@ const step18: MigrationStep = { + 'the flag, the author has accepted creating records through the object\'s create action: ' + '`object-kanban` offers no quick-add control.', }, + // #21589 (ADR-0049 enforce-or-remove) — the D3 entry of the + // `object-master-detail-form-detail-sort-field-removed` family (one D3 entry + // per retirement family, even when D2 is lossless). Registered key: + // `ui/ObjectMasterDetailFormProps:details.sortField`. The strip changes + // nothing a user sees, because the console already ignored the authored + // value; what it leaves is the one judgment a delete cannot make — whether the + // child object carries the field the line order is kept in. + { + id: 'object-master-detail-form-detail-sort-field-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: 'page.component.object-master-detail-form.details[].sortField — a detail entry\'s ' + + 'authored line-position field', + replacement: 'Nothing on the entry: delete the key. The line grid stamps each line\'s position into ' + + 'the child object\'s own field, derived from the child object: its first field named ' + + '`position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`. To keep the line ' + + 'order a drag-reorder sets, give the child object one of those fields (under the name the ' + + 'deleted key named, when it is one of them).', + reason: 'The D2 conversion `object-master-detail-form-detail-sort-field-removed` deletes ' + + '`sortField` from every `object-master-detail-form` detail entry, and the delete is lossless: ' + + 'the console stopped reading the authored override, and the line grid stamps the field it ' + + 'derives from the child object whatever the entry says. What the conversion cannot decide is ' + + 'where the line order lives. An entry whose key named a field the derivation does not pick — ' + + 'a name outside that list, or a second sort-named field after the first — saves its line ' + + 'order into the derived field instead, or nowhere when the child object has none. An entry ' + + 'that names `relationshipField` and at least one column and gives every column a `type` is ' + + 'kept exactly as authored: no child schema is loaded for it, so no line position is stamped ' + + 'and a drag-reorder is not saved, before and after the upgrade alike.', + acceptanceCriteria: 'No `object-master-detail-form` detail entry carries `sortField`; the props ' + + 'lint reports one with the prescription. For each entry that had set it, the child object ' + + 'declares the field the line order is kept in under one of the derived names, and after a ' + + 'drag-reorder and save the lines reload in the order they were dragged into.', + }, // #19054 (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18) — the D3 // entry of the `object-tenancy-organization-field-removed` family (ruling B on // #17152: one D3 entry per retirement family, even when D2 is lossless). This @@ -24776,6 +24825,27 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // this shape; `ViewTabSchema` stays, reused by the page-only `userFilters.tabs` // preset bar. D2: `view-list-tabs-removed`. 'ui/ObjectListView:tabs', + // #21589 — ADR-0049 enforce-or-remove through the ADR-0087 D2 route, the spec + // half of objectui#11070 round 9 (the direction recorded on #21220's landing + // and mirrored on objectui#11396 ③: tombstone plus ADR-0087). The console + // retired the authored override at objectui `0a3e5409f`, and the + // `.objectui-sha` pin `89cad75d5570` is past it: `MasterDetailDetailConfig` + // has no `sortField` member (`plugin-form/src/MasterDetailForm.tsx:83`), and + // the field the line grid stamps with each line's position is the one + // `deriveDetail` derives from the child object (`deriveMasterDetail.ts:540`), + // handed to the grid as `sort_field` (`:874`). Tombstoned with `retiredKey()` + // in the strict detail entry; a NESTED row (an array member, spelled without + // its `[]`), so it has no `authorable-surface/` line and checks (b2)/(b3) + // resolve it against the emitted schema. Stored and built pages are stripped + // by the D2 conversion `object-master-detail-form-detail-sort-field-removed`, + // a pure lossless delete scoped by component `type` and position; its D3 + // record is `object-master-detail-form-detail-sort-field-retired`. + // + // Registered under 18, not 17: the removal ships on the 17.x line + // (launch-window convention: accept-set narrowings ride minor releases) and + // the prescription lives at the major boundary where `migrate meta` users + // look — the `ui/PageHeaderProps:breadcrumb` precedent. + 'ui/ObjectMasterDetailFormProps:details.sortField', // ADR-0090 D2 (no Profile concept) + ADR-0049 enforce-or-remove; maintainer // ruling 2026-09-12, decision batch #121 item 2, verbatim 「同意」. // `Page.assignedProfiles` was an authorable key named for the concept ADR-0090 D2 diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 2594178cfc5..9920f040b91 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -78,6 +78,10 @@ import { SectionGroupKeySchema, sectionGroupReferenceRefinement } from '../share // `subforms[].columns` take, referenced rather than copied: all three carriers // feed one objectui grid. import { InlineGridColumnSchema } from '../data/field.zod'; +// [#21589] The child field names the renderer derives a detail's line-position +// field from — the one list the retired detail-entry `sortField`'s +// prescriptions print (reached by relative import only, never the barrel). +import { INLINE_GRID_SORT_FIELD_LIST } from '../data/inline-grid-sort-fields'; // --------------------------------------------------------------------------- // CLOSED AGAINST UNKNOWN KEYS as of #4001 batch A -- all 31 object sites. @@ -2196,10 +2200,15 @@ export const RecordLineItemsProps = lazySchema(() => strictObject({ // author moving a child collection over from `object-master-detail-form` // carries along. Measured at the pin: the panel hands the grid no // `add_label` / `sort_field` and no `onRowExpand` (`:694-710`, `:806-817`). + // `sortField` has since left the detail entry too (#21589, a tombstone + // there): the line-position field is derived from the child object and + // never authored, so its answer names no block that takes it. addLabel: '`record:line_items` does not read `addLabel`: its grid draws the built-in, localized ' + 'Add button. `addLabel` belongs to an `object-master-detail-form` detail entry.', sortField: '`record:line_items` does not read `sortField`: its grid stamps no line position, so a ' - + 'drag-reorder is not saved. `sortField` belongs to an `object-master-detail-form` detail entry.', + + 'drag-reorder is not saved. No block takes an authored `sortField`: an ' + + '`object-master-detail-form` detail entry derives the line-position field from the child ' + + `object's ${INLINE_GRID_SORT_FIELD_LIST} field.`, formFields: '`record:line_items` draws an editable grid only, with no per-row expand form, so it ' + 'does not read `formFields`. It belongs to an `object-master-detail-form` detail entry.', inlineMode: '`record:line_items` draws an editable grid only, so it does not read `inlineMode`. It ' @@ -5516,10 +5525,12 @@ const MASTER_DETAIL_DETAIL_HISTORY = /** * One `object-master-detail-form` detail collection (#20928) — STRICT, and - * exactly the twelve keys objectui's `MasterDetailForm` reads off an entry + * exactly the eleven keys objectui's `MasterDetailForm` reads off an entry * (`packages/plugin-form/src/MasterDetailForm.tsx`, its - * `MasterDetailDetailConfig` and every `d.` read in the file, read at the - * `.objectui-sha` pin `31971ff1e28f`). A key nobody reads is not declared. + * `MasterDetailDetailConfig` and every `d.` read in the file; read at the + * `.objectui-sha` pin `31971ff1e28f`, re-read at `89cad75d5570` for #21589), + * plus the `sortField` tombstone ({@link MASTER_DETAIL_DETAIL_SORT_FIELD_RETIRED}). + * A key nobody reads is not declared. * * `columns` IS {@link InlineGridColumnSchema}, the same object a relationship * field's `inlineColumns` and a form view's `subforms[].columns` take: the @@ -5532,14 +5543,16 @@ const MASTER_DETAIL_DETAIL_HISTORY = * * The keys the entry shares with a form view's `subforms[]` entry take that * entry's types and alias table, so one concept is spelled one way on both - * child-collection surfaces. The three it adds are the renderer's own: - * `formFields` (the per-row expand form), `inlineMode` (the two form factors - * a relationship field's `inlineEdit` names; absence takes the relationship's - * own resolution only on an entry the renderer derives, and its describe - * states both paths) and `sortField` (the line-position field the grid stamps on - * drag-reorder). `title` and `addLabel` are plain strings because the - * renderer draws them as a React child and a button label without resolving a - * locale map. + * child-collection surfaces. The two it adds are the renderer's own: + * `formFields` (the per-row expand form) and `inlineMode` (the two form + * factors a relationship field's `inlineEdit` names; absence takes the + * relationship's own resolution only on an entry the renderer derives, and its + * describe states both paths). The line-position field the grid stamps on + * drag-reorder is NOT a key: the renderer derives it from the child object + * ({@link INLINE_GRID_SORT_FIELD_LIST}), and `sortField`, the authored override + * it used to take, is a tombstone. `title` and `addLabel` are plain strings + * because the renderer draws them as a React child and a button label without + * resolving a locale map. * * A factory called inside {@link ObjectMasterDetailFormPropsSchema}'s own lazy * body, the way a form view's `subforms[]` entry is built inline in its @@ -5547,6 +5560,41 @@ const MASTER_DETAIL_DETAIL_HISTORY = * nothing exports, which the schema-graph walks (`alias-integrity.test.ts`) * never descend into, so its alias table would go unjudged. */ +/** + * REMOVED (#21589 — ADR-0049 enforce-or-remove through the ADR-0087 D2 route; + * the spec half of objectui#11070 round 9, the direction recorded on #21220's + * landing and mirrored on objectui#11396 ③). + * + * An authored override the console no longer reads. At the `.objectui-sha` pin + * `89cad75d5570`, `MasterDetailDetailConfig` has no `sortField` member + * (`plugin-form/src/MasterDetailForm.tsx:83`): the field the line grid stamps + * with each line's position is DERIVED from the child object (`deriveDetail`, + * `deriveMasterDetail.ts:540`), carried on the resolved entry and handed to + * the grid as `sort_field` (`:874`). The pin crossed that change without the + * spec half, so an authored `sortField` published green, and a drag-reorder + * stamped the derived field, or none. + * + * The live mechanism is the child object's own field: its first field named + * one of {@link INLINE_GRID_SORT_FIELD_LIST}, on an entry the renderer + * resolves. An entry that names `relationshipField` and at least one column + * and gives every column a `type` is kept exactly as authored: the renderer + * loads no child schema for it, so it stamps no line position (`:977-979`). + * + * Stored and built pages that carry the key are stripped by the D2 conversion + * `object-master-detail-form-detail-sort-field-removed`, with a notice; its D3 + * record is `object-master-detail-form-detail-sort-field-retired`. + */ +const MASTER_DETAIL_DETAIL_SORT_FIELD_RETIRED = + '`object-master-detail-form` property `details[].sortField` was removed in @objectstack/spec 17 ' + + '(ADR-0087 D2) — the console reads no authored value: the field the line grid stamps with each ' + + 'line\'s position on drag-reorder is derived from the child object, so an authored `sortField` was ' + + 'accepted and dropped. Delete the key. For a drag-reorder to be saved, give the child object a ' + + `field named ${INLINE_GRID_SORT_FIELD_LIST}: the renderer stamps the child's first field with one ` + + 'of those names — except on an entry that names `relationshipField` and at least one column and ' + + 'gives every column a `type`, which the renderer keeps exactly as authored and stamps no line ' + + 'position on. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + function masterDetailDetailEntry() { return strictObject({ surface: 'this `object-master-detail-form` detail entry', @@ -5563,7 +5611,9 @@ function masterDetailDetailEntry() { formFields: z.array(z.string()).optional().describe("Child field names for the per-row expand form. When omitted they are derived from the child object's fields — except on an entry that names both `relationshipField` and at least one column, which is kept as authored: nothing is derived, and the per-row form is offered only when `inlineMode` is 'form', where it draws the child object's full field list"), inlineMode: z.enum(['grid', 'form']).optional().describe("Inline-edit form factor: 'grid' = editable cells; 'form' = read-only list + per-row full form. When omitted it is resolved from the relationship field's `inlineEdit`, else from the child object's shape — except on an entry that names both `relationshipField` and at least one column, which is kept as authored: nothing is resolved, the collection renders as a grid, and the per-row form is offered only when `formFields` lists more fields than `columns`"), amountField: z.string().optional().describe("Numeric child column summed for the running total and the `totalField` rollup. When omitted it is picked from the grid's number and currency columns: a computed one, else one named `amount`, `total`, `subtotal`, `line_total`, `line_amount` or `net_amount`, else the last currency column, else the last numeric one — except on an entry that names `relationshipField` and at least one column and gives every column a `type`. The renderer keeps that entry exactly as authored, so nothing is picked. With no `amountField` authored or picked, the sums read a child column named `amount`, and the grid shows a running total only when `totalField` is set"), - sortField: z.string().optional().describe("Child field holding the line sort position, stamped on drag-reorder. When omitted it is the child object's first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`, if it has one — except on an entry that names `relationshipField` and at least one column and gives every column a `type`. The renderer keeps that entry exactly as authored: nothing is derived, the grid stamps no line position, and a drag-reorder is not saved"), + // A tombstone: the line-position field is derived from the child object, + // never authored — see MASTER_DETAIL_DETAIL_SORT_FIELD_RETIRED above. + sortField: retiredKey(MASTER_DETAIL_DETAIL_SORT_FIELD_RETIRED), totalField: z.string().optional().describe('Parent field to receive the rolled-up sum'), title: z.string().optional().describe('Section title'), minRows: z.number().optional().describe('Minimum number of rows'), diff --git a/packages/spec/src/ui/master-detail-detail-sort-field-retirement.test.ts b/packages/spec/src/ui/master-detail-detail-sort-field-retirement.test.ts new file mode 100644 index 00000000000..2d8e0b881b0 --- /dev/null +++ b/packages/spec/src/ui/master-detail-detail-sort-field-retirement.test.ts @@ -0,0 +1,474 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An `object-master-detail-form` detail entry's `sortField` RETIRED (#21589) — + * ADR-0049 enforce-or-remove through the ADR-0087 D2 route, the spec half of + * objectui#11070 round 9 (the direction recorded on #21220's landing, mirrored + * on objectui#11396 ③). + * + * Measured before removal, at the `.objectui-sha` pin `89cad75d5570`: + * `MasterDetailDetailConfig` has no `sortField` member + * (`plugin-form/src/MasterDetailForm.tsx:83`); the field the line grid stamps + * with each line's position is DERIVED from the child object + * (`deriveMasterDetail.ts:540`) and handed to the grid as `sort_field` + * (`:874`). The spec still declared the key, so an authored value published + * green and was dropped. The writer census at the retirement is zero. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone on the strict detail entry — the refusal + * carries the prescription, which names the derived field set from its + * one declaration (`data/inline-grid-sort-fields.ts`), and the key's + * input type is `never`. `PageComponentSchema.properties` is an open bag, + * so the props schema is reached by the advisory props lint and never by + * the page parse: an existing page is never hard-refused. + * 2. D2 conversion `object-master-detail-form-detail-sort-field-removed` + * (step 18), a strip scoped by component type and by position, retired + * from the load path: a stored or built page replays clean, with a notice. + * 3. `RETIRED_KEYS_BY_MAJOR[18]` carries the NESTED key + * `ui/ObjectMasterDetailFormProps:details.sortField`, and the family's D3 + * entry is `object-master-detail-form-detail-sort-field-retired`. + * 4. `record:line_items`' answer to the same spelling names no block that + * takes it any more. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues carry + * `code` and `path` but no ADR-0112 `status` — no HTTP door parses this row + * (the page write door parses `properties` as an open bag). So each refusal is + * pinned by the issue `code`, the `path` naming the key, and the prescription. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import type { ConversionNotice } from '../conversions/types'; +import { INLINE_GRID_SORT_FIELDS } from '../data/inline-grid-sort-fields'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { ComponentPropsMap, ObjectMasterDetailFormPropsSchema, RecordLineItemsProps } from './component.zod'; +import { PageSchema } from './page.zod'; + +const CONVERSION_ID = 'object-master-detail-form-detail-sort-field-removed'; +const D3_ID = 'object-master-detail-form-detail-sort-field-retired'; +const REGISTERED_KEY = 'ui/ObjectMasterDetailFormProps:details.sortField'; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself. +const PRESCRIPTION = + /`object-master-detail-form` property `details\[\]\.sortField` was removed in @objectstack\/spec 17 \(ADR-0087 D2\) — the console reads no authored value.*Delete the key\..*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand\./s; + +/** A detail entry's live keys — the showcase project workspace's entry shape. */ +const ENTRY = { title: 'Lines', childObject: 'crm_invoice_line', addLabel: 'Add line' } as const; + +const props = (details: unknown[]) => ({ objectName: 'crm_invoice', details }); + +/** A stored `page` row whose block's detail entry carries `sortField`. */ +const storedPage = (sortField: string) => ({ + name: 'invoice_entry', + label: 'Invoice Entry', + type: 'app', + regions: [ + { + name: 'main', + components: [{ type: 'object-master-detail-form', properties: props([{ ...ENTRY, sortField }]) }], + }, + ], +}); + +type Notice = Pick; +const brief = (n: ConversionNotice): Notice => ({ + conversionId: n.conversionId, + path: n.path, + from: n.from, + to: n.to, +}); + +describe('object-master-detail-form detail-entry sortField retirement — the tombstone', () => { + it('refuses the key on a detail entry, at the key, with the prescription', () => { + for (const value of ['line_no', 'position']) { + const r = ObjectMasterDetailFormPropsSchema.safeParse(props([{ ...ENTRY, sortField: value }])); + expect(r.success, `sortField: ${value}`).toBe(false); + if (r.success) continue; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(['details', 0, 'sortField']); + expect(issue.message).toMatch(PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue.message.startsWith('`object-master-detail-form` property `details[].sortField` was removed')).toBe(true); + } + }); + + it('the prescription names every derived sort-field name, from the one declaration', () => { + const r = ObjectMasterDetailFormPropsSchema.safeParse(props([{ ...ENTRY, sortField: 'line_no' }])); + const message = r.success ? '' : r.error.issues[0]!.message; + expect(INLINE_GRID_SORT_FIELDS.size).toBe(6); + for (const name of INLINE_GRID_SORT_FIELDS) expect(message, name).toContain(`\`${name}\``); + }); + + it('the row the props lint dispatches on is the same schema, so it refuses it too', () => { + // `validateComponentProps` (packages/lint) reads `ComponentPropsMap[type]`; + // a rebinding to some other shape would pass the pins above and still + // accept the key where an author meets it. + expect(ComponentPropsMap['object-master-detail-form']).toBe(ObjectMasterDetailFormPropsSchema); + expect(() => ComponentPropsMap['object-master-detail-form'].parse(props([{ ...ENTRY, sortField: 'sort' }]))) + .toThrow(PRESCRIPTION); + }); + + it('refuses the key by the TOMBSTONE, not by the strict unknown-key arm — the two are different answers', () => { + const retired = ObjectMasterDetailFormPropsSchema.safeParse(props([{ ...ENTRY, sortField: 'line_no' }])); + expect(retired.success).toBe(false); + expect((retired.error?.issues ?? []).map((i) => i.code)).not.toContain('unrecognized_keys'); + // CONTROL: an undeclared sibling on the same entry comes back as + // `unrecognized_keys`. Without this pair, a shape that had simply DROPPED + // the key would pass the pin above on the strict arm's generic message. + const undeclared = ObjectMasterDetailFormPropsSchema.safeParse(props([{ ...ENTRY, zzzNotAKey: 'line_no' }])); + expect(undeclared.success).toBe(false); + const issue = undeclared.error?.issues.find((i) => i.code === 'unrecognized_keys') as + | { keys?: string[]; path?: unknown[] } + | undefined; + expect(issue?.keys).toEqual(['zzzNotAKey']); + expect(issue?.path).toEqual(['details', 0]); + }); + + it('CONTROL: an entry without the key parses, keeps its live keys, and grows no `sortField`', () => { + const r = ObjectMasterDetailFormPropsSchema.safeParse(props([ENTRY])); + expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); + if (!r.success) return; + const [entry] = (r.data as { details: Record[] }).details; + expect(entry).toEqual(ENTRY); + expect(entry).not.toHaveProperty('sortField'); + }); + + it('the walked shape keeps `sortField` as a key of the detail entry', () => { + type Unwrapped = { unwrap(): { element: { shape: Record } } }; + const details = (ObjectMasterDetailFormPropsSchema.shape as unknown as Record).details; + const entryKeys = Object.keys(details.unwrap().element.shape); + expect(entryKeys).toContain('sortField'); + expect(entryKeys, 'CONTROL: its live neighbour').toContain('amountField'); + // Eleven keys the renderer reads, plus the tombstone. + expect(entryKeys).toHaveLength(12); + }); + + it('fails tsc at the authoring site: the input type of `sortField` is `never`', () => { + const authored: z.input = { + objectName: 'crm_invoice', + details: [ + { + ...ENTRY, + // @ts-expect-error — `sortField` is a retiredKey() tombstone: its input type is `never`. + sortField: 'line_no', + }, + ], + }; + // The parse channel agrees with the type channel on the same literal. + expect(() => ObjectMasterDetailFormPropsSchema.parse(authored)).toThrow(PRESCRIPTION); + }); + + it('never hard-refuses an existing page: the page parse still accepts a block carrying the key', () => { + // `PageComponentSchema.properties` is an open bag — the page door does not + // dispatch on the component type; the tombstone speaks through the props lint. + const r = PageSchema.safeParse(storedPage('line_no')); + expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); + }); + + it('`record:line_items` answers the same spelling naming no block that takes it', () => { + const r = RecordLineItemsProps.safeParse({ + childObject: 'crm_invoice_line', + relationshipField: 'invoice', + columns: [{ name: 'quantity' }], + sortField: 'line_no', + }); + expect(r.success).toBe(false); + const issue = r.error?.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue?.path).toEqual([]); + expect(issue?.message).toContain('No block takes an authored `sortField`'); + // CONTROL: the sibling entry key still names the block that takes it. + const addLabel = RecordLineItemsProps.safeParse({ + childObject: 'crm_invoice_line', + relationshipField: 'invoice', + columns: [{ name: 'quantity' }], + addLabel: 'Add line', + }); + expect(addLabel.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain('`addLabel` belongs to an `object-master-detail-form` detail entry'); + }); +}); + +describe('object-master-detail-form detail-entry sortField retirement — the D2 conversion', () => { + it('a STORED page whose detail entry carries the key loads with it stripped and the notice recorded', () => { + const notices: ConversionNotice[] = []; + const row = storedPage('line_no'); + const rehydrated = applyConversionsToStoredItem('page', row, { + onNotice: (n) => notices.push(n), + }) as ReturnType; + + expect(notices.map(brief)).toEqual([ + { + conversionId: CONVERSION_ID, + path: 'pages[0].regions[0].components[0].properties.details[0].sortField', + from: 'sortField', + to: '(removed)', + }, + ]); + const properties = rehydrated.regions[0]!.components[0]!.properties; + // Every live key on the same entry and block survives byte-for-byte. + expect(properties).toEqual(props([ENTRY])); + // The rehydrated block is exactly what the props row accepts now, and the + // page still parses. + expect(ObjectMasterDetailFormPropsSchema.safeParse(properties).success).toBe(true); + expect(PageSchema.safeParse(rehydrated).success).toBe(true); + }); + + it('a BUILT artifact replays the same strip — every position the block can sit in, every entry', () => { + const artifact = { + pages: [ + { + name: 'invoice_entry', + regions: [ + { + name: 'main', + components: [ + { + type: 'object-master-detail-form', + properties: props([{ ...ENTRY, sortField: 'line_no' }, { childObject: 'crm_payment' }, { childObject: 'crm_step', sortField: 'position' }]), + }, + { + type: 'page:card', + properties: { + children: [{ type: 'object-master-detail-form', properties: props([{ ...ENTRY, sortField: 'sequence' }]) }], + }, + }, + ], + }, + ], + }, + { + name: 'invoice_entry_detail', + kind: 'slotted', + regions: [], + slots: { details: { type: 'object-master-detail-form', properties: props([{ ...ENTRY, sortField: 'sort' }]) } }, + }, + ], + }; + const { stack, notices } = collectConversionNotices(artifact, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID).map((n) => n.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.details[0].sortField', + 'pages[0].regions[0].components[0].properties.details[2].sortField', + 'pages[0].regions[0].components[1].properties.children[0].properties.details[0].sortField', + 'pages[1].slots.details.properties.details[0].sortField', + ]); + expect(JSON.stringify(stack)).not.toContain('"sortField"'); + }); + + it('CONTROL: a block whose entries carry no key is unchanged — no notice, and the row comes back by reference', () => { + const row = { + name: 'invoice_entry', + regions: [{ name: 'main', components: [{ type: 'object-master-detail-form', properties: props([ENTRY]) }] }], + }; + const notices: ConversionNotice[] = []; + const rehydrated = applyConversionsToStoredItem('page', row, { onNotice: (n) => notices.push(n) }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(rehydrated).toBe(row); + }); + + it('is scoped by component TYPE and POSITION: the same key elsewhere is not this entry\'s', () => { + const artifact = { + pages: [ + { + name: 'p', + regions: [ + { + name: 'main', + components: [ + // Another component's own `details[].sortField`. + { type: 'acme:line_editor', properties: { details: [{ sortField: 'position' }] } }, + // The block's TOP-level props: not a detail entry, so not this + // key (the props gate reports it as an unknown key). + { type: 'object-master-detail-form', properties: { objectName: 'crm_invoice', sortField: 'position' } }, + ], + }, + ], + }, + ], + }; + const { stack, notices } = collectConversionNotices(artifact, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toBe(artifact); + }); + + it('is idempotent by construction: a second replay converts nothing', () => { + const { stack } = collectConversionNotices({ pages: [storedPage('line_no')] }, { includeRetired: true }); + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — the authoring funnel does not rewrite a live source', () => { + const input = { pages: [storedPage('line_no')] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toEqual(input); + }); +}); + +describe('object-master-detail-form detail-entry sortField retirement — the ADR-0087 ledger row', () => { + it('declares ONE nested key under major 18', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain(REGISTERED_KEY); + const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); + expect(all.filter((k) => k.startsWith('ui/ObjectMasterDetailFormProps:'))).toEqual([REGISTERED_KEY]); + }); + + it('wires the D2 conversion into the step-18 chain as a retired, stamped, lossless strip', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion, 'the D2 conversion must be registered').toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.retiredAfter).toMatch(/^\d+\.\d+\.\d+$/); + expect(conversion!.surface).toBe('page.component.object-master-detail-form.details[].sortField'); + }); + + it('carries ONE D3 entry for the family, naming its D2 conversion', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries, 'the family needs its own D3 entry').toHaveLength(1); + const [entry] = entries; + expect(entry!.reason).toContain(`\`${CONVERSION_ID}\``); + expect(entry!.replacement).toContain('delete the key'); + // The D3 text is concatenated into the generated registry as a literal, so + // it cannot import the one declaration; this keeps the two lists equal. + for (const name of INLINE_GRID_SORT_FIELDS) expect(entry!.replacement, name).toContain(`\`${name}\``); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +// ─── Tree-scoped absence, inside the radius the package already declares ─── +// +// `tsc` sweeps only TYPED authoring sites, and a page component's `properties` +// is an open bag, so `tsc` does not reach a block authored through +// `definePage`/`defineStack` at all. This walk covers every text file under the +// five repo roots `scripts/cross-package-test-inputs.mjs` declares for +// `@objectstack/spec#test` (mirrored in `turbo.json`), plus the example apps' +// own `src/` trees. +// +// The matcher judges the AUTHORING SHAPE, never a mention: `sortField` in key +// position with a snake_case field-name value, quoted or bare (TS / JS / JSON, +// and YAML). A guidance or prescription string, a schema declaration and the +// grid's own `sort_field` never take that shape. Prose mentions are spelled in +// inline code in this repo, and inline code is stripped before judging. The +// bound, stated: a value that is not a field-name literal, a shorthand +// property, and `docs/**`, `.claude/**`, `.github/**` and the repo-root files +// are outside what this walk sees. A future schema that declares a `sortField` +// of its own would trip this walk: narrow the matcher to detail entries then, +// never exclude the new file. +describe('tree-scoped absence: nothing inside the declared radius still authors a detail-entry sortField', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const AUTHORING = /(^|[^\w.$])["']?sortField["']?[ \t]*:[ \t]*(["']?)[a-z_][a-z0-9_]*\2(?=[ \t]*([,}\]#]|$))/m; + + /** Inline code spans are prose; newline-bounded, so a fenced example is still judged. */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string): RegExpExecArray | null => AUTHORING.exec(stripInlineCode(text)); + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired key. + */ + const EXCLUDED = new Set([ + // This pin authors the key to assert its refusal and its conversion. + THIS_FILE, + // The props-lint pin authors the key to assert the advisory warning an + // author meets at `os validate` / `os build` / `os lint`. + 'packages/lint/src/validate-component-props.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement entry on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its source is `component.zod.ts`. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a prose mention, a guidance string and a declaration (anti-vacuity)', () => { + // Offenders — the retired shape, in each syntax the walk reads. + expect(judge("details: [{ childObject: 'crm_invoice_line', sortField: 'line_no' }],")).not.toBeNull(); + expect(judge(" {\n sortField: 'position',\n },")).not.toBeNull(); + expect(judge('{ "details": [{ "childObject": "crm_invoice_line", "sortField": "sequence" }] }')).not.toBeNull(); + expect(judge(' details:\n - childObject: crm_invoice_line\n sortField: sort\n')).not.toBeNull(); + expect(judge("Prose.\n\n```ts\ndefinePage({ regions: [{ components: [{ properties: { details: [{ sortField: 'line_no' }] } }] }] });\n```\n")).not.toBeNull(); + // Neighbours that must NOT match. + expect(judge("an authored `sortField: 'line_no'` was accepted and dropped")).toBeNull(); + expect(judge("sortField: '`record:line_items` does not read `sortField`: its grid stamps no line position'")).toBeNull(); + expect(judge('sortField: retiredKey(MASTER_DETAIL_DETAIL_SORT_FIELD_RETIRED),')).toBeNull(); + expect(judge("sortField: z.string().optional().describe('Child field'),")).toBeNull(); + expect(judge('sortField: derived.sortField,')).toBeNull(); + expect(judge("sort_field: entry.sortField,\n sortFields: ['name'],")).toBeNull(); + expect(judge('"ui/ObjectMasterDetailFormProps:details.sortField",')).toBeNull(); + }); + + it('no detail-entry sortField authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const m = judge(text); + if (m) offenders.push(`${rel} authors \`${m[0].trim().replace(/\s+/g, ' ')}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree and the example apps' sources. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(offenders, 'a detail-entry sortField authoring means the retirement is being undone').toEqual([]); + }); +}); From fcacd4a4733f43393f44dc3076439b7d44dc9d7e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 16:47:53 +0000 Subject: [PATCH 2/9] wip(spec): regenerate the component reference page, add the changeset and the lint pin Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../21589-detail-entry-sort-field-retired.md | 36 +++++++++++++++++++ content/docs/references/ui/component.mdx | 2 +- .../lint/src/validate-component-props.test.ts | 1 + 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 .changeset/21589-detail-entry-sort-field-retired.md diff --git a/.changeset/21589-detail-entry-sort-field-retired.md b/.changeset/21589-detail-entry-sort-field-retired.md new file mode 100644 index 00000000000..aa70334828f --- /dev/null +++ b/.changeset/21589-detail-entry-sort-field-retired.md @@ -0,0 +1,36 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: an `object-master-detail-form` detail entry's `sortField` is retired — the console derives the line-position field from the child object and reads no authored value (#21589) + +Clause-②: no (narrowing) + + + +**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + +`ComponentPropsMap['object-master-detail-form'].details[].sortField` named the child field the line grid stamps with each line's position on drag-reorder. The console stopped reading it: the field it stamps is derived from the child object, and the pinned console crossed that change while the spec still declared the key. So an authored `sortField` went through `os validate` clean and was dropped, and a drag-reorder stamped the derived field, or none (ADR-0049 enforce-or-remove). + +### FROM → TO + +| before | what to write instead | +| --- | --- | +| `details: [{ childObject: 'crm_invoice_line', sortField: 'line_no' }]` | delete `sortField`. The grid stamps the child object's first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`. | +| `sortField` naming a field outside that list | give the child object one of those fields; the line order is kept there. | +| an entry that names `relationshipField` and at least one column and gives every column a `type` | unchanged: the renderer keeps that entry exactly as authored, loads no child schema for it, and stamps no line position, before and after the upgrade alike. | + +**The one-line fix: delete `sortField` from every `object-master-detail-form` detail entry.** `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + +**What an author now sees.** Writing the key fails `tsc` (its input type is the retired-key mark), and `os validate`, `os build` and `os lint` report it as a `component-props-invalid` warning carrying the prescription at `properties.details.N.sortField`. A page that carries it still saves and loads: a page component's `properties` is not parsed on the metadata save or load path. + +### The retirement kit + +- **Tombstone.** `sortField` is a `retiredKey()` on the strict detail entry. Its prescription prints the derived field names from their one declaration, a module reached by relative import only (`data/inline-grid-sort-fields.ts`), which the derived inline-grid columns read too. +- **D2 conversion `object-master-detail-form-detail-sort-field-removed`** (step 18, retired from the load path): a lossless delete of `sortField` from every `properties.details[]` entry of an `object-master-detail-form`, scoped by component type and by position. Stored `sys_metadata` pages and built artifacts replay it, one notice per entry. +- **D3 entry `object-master-detail-form-detail-sort-field-retired`** carries the judgment the delete cannot make: whether the child object declares the field the line order is kept in. +- **`RETIRED_KEYS_BY_MAJOR[18]`** registers the nested key `ui/ObjectMasterDetailFormProps:details.sortField`. +- **`record:line_items`' answer to `sortField`** no longer sends the author to the detail entry: no block takes an authored `sortField` any more. +- **No deprecation window**: the writer census is zero. + +**Measured producers: none.** On origin/main 9a4182a752, no `object-master-detail-form` detail entry in `examples/`, `apps/`, `packages/`, `skills/` or `content/docs/` writes `sortField`, against the sibling detail-entry key `addLabel` on the showcase project workspace's entry as the control, through the same instrument. At the objectui pin `89cad75d5570` the only detail entries that write it are probes asserting that nothing reads it. Deployed metadata NOT MEASURED. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 7531d2b515c..9e88041892c 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1084,7 +1084,7 @@ Sort field and direction pair | **formFields** | `string[]` | optional | Child field names for the per-row expand form. When omitted they are derived from the child object's fields — except on an entry that names both `relationshipField` and at least one column, which is kept as authored: nothing is derived, and the per-row form is offered only when `inlineMode` is 'form', where it draws the child object's full field list | | **inlineMode** | `Enum<'grid' \| 'form'>` | optional | Inline-edit form factor: 'grid' = editable cells; 'form' = read-only list + per-row full form. When omitted it is resolved from the relationship field's `inlineEdit`, else from the child object's shape — except on an entry that names both `relationshipField` and at least one column, which is kept as authored: nothing is resolved, the collection renders as a grid, and the per-row form is offered only when `formFields` lists more fields than `columns` | | **amountField** | `string` | optional | Numeric child column summed for the running total and the `totalField` rollup. When omitted it is picked from the grid's number and currency columns: a computed one, else one named `amount`, `total`, `subtotal`, `line_total`, `line_amount` or `net_amount`, else the last currency column, else the last numeric one — except on an entry that names `relationshipField` and at least one column and gives every column a `type`. The renderer keeps that entry exactly as authored, so nothing is picked. With no `amountField` authored or picked, the sums read a child column named `amount`, and the grid shows a running total only when `totalField` is set | -| **sortField** | `string` | optional | Child field holding the line sort position, stamped on drag-reorder. When omitted it is the child object's first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`, if it has one — except on an entry that names `relationshipField` and at least one column and gives every column a `type`. The renderer keeps that entry exactly as authored: nothing is derived, the grid stamps no line position, and a drag-reorder is not saved | +| **sortField** | `never` | optional | [REMOVED] `object-master-detail-form` property `details[].sortField` was removed in @objectstack/spec 17 (ADR-0087 D2) — the console reads no authored value: the field the line grid stamps with each line's position on drag-reorder is derived from the child object, so an authored `sortField` was accepted and dropped. Delete the key. For a drag-reorder to be saved, give the child object a field named `position` / `sort_order` / `sequence` / `line_no` / `line_number` / `sort`: the renderer stamps the child's first field with one of those names — except on an entry that names `relationshipField` and at least one column and gives every column a `type`, which the renderer keeps exactly as authored and stamps no line position on. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **totalField** | `string` | optional | Parent field to receive the rolled-up sum | | **title** | `string` | optional | Section title | | **minRows** | `number` | optional | Minimum number of rows | diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index f58b5557831..11e5c85aef6 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -112,6 +112,7 @@ describe('validateComponentProps — undeclared keys', () => { expect(findings).toHaveLength(1); const [f] = findings; expect(f.severity).toBe('warning'); + expect(f.rule).toBe(COMPONENT_PROPS_INVALID); expect(f.path).toBe('pages[0].regions[0].components[0].properties.details.1.sortField'); expect(f.message).toContain('`object-master-detail-form` property `details[].sortField` was removed in @objectstack/spec 17'); }); From 117ddf1b88241116e1a346fbe8cb3604c906018f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 17:07:05 +0000 Subject: [PATCH 3/9] wip(spec): the sortField retirement pin walks the repo, so it runs in the repo project Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- packages/spec/vitest.repo-tests.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index bc93eb46351..7b2967303e0 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -46,6 +46,7 @@ "src/system/email-template-floor-locale-parity.pin.test.ts", "src/ui/action-requires-confirmation-docblock.pin.test.ts", "src/ui/form-field-public-picker-retirement.test.ts", + "src/ui/master-detail-detail-sort-field-retirement.test.ts", "src/ui/object-grid-resizable-columns-retirement.test.ts", "src/ui/page-header-breadcrumb-retirement.test.ts", "src/ui/view-item-owner-hidden-retirement.test.ts", From e9c3116f598228f8dc188464a5aa699e65a7c24f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:46:20 +0000 Subject: [PATCH 4/9] chore(spec): regenerate the component reference page on the merged tree The os-regen driver kept the branch's copy of component.mdx in the merge, dropping the element:text variant row main landed; regenerated from the merged source, the page carries both rows. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 9e88041892c..c00668bd00d 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -436,7 +436,7 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **content** | `string \| Record` | ✅ | Text or Markdown content — a plain string, or an inline locale map | -| **variant** | `Enum<'h1' \| 'h2' \| 'h3' \| 'h4' \| 'h5' \| 'h6' \| 'body' \| 'caption' \| 'overline' \| 'heading' \| 'subheading'>` | optional (default: `"body"`) | Text style variant | +| **variant** | `Enum<'h1' \| 'h2' \| 'h3' \| 'h4' \| 'h5' \| 'h6' \| 'body' \| 'caption' \| 'overline'>` | optional (default: `"body"`) | Text style variant | | **align** | `Enum<'left' \| 'center' \| 'right'>` | optional (default: `"left"`) | Text alignment | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | From 810d9624d1371e3dc0eb8d3d83d64bcd4174a2c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:46:45 +0000 Subject: [PATCH 5/9] fix(spec): take the next free step-18 orders after the element:text variant landing The element:text variant retirement landed at conversion order 59 and rationale order 68; this retirement moves to 60 and 69. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- packages/spec/src/conversions/registry.ts | 2 +- packages/spec/src/migrations/registry.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 1e6f525b4d0..9de2376ad3b 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -14800,7 +14800,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: objectGridDefaultSortRemoved, order: 14 }, { conversion: objectGridResizableColumnsRemoved, order: 57 }, { conversion: objectKanbanQuickAddRemoved, order: 15 }, - { conversion: objectMasterDetailFormDetailSortFieldRemoved, order: 59 }, + { conversion: objectMasterDetailFormDetailSortFieldRemoved, order: 60 }, { conversion: objectTenancyOrganizationFieldRemoved, order: 35 }, { conversion: pageAssignedProfilesRemoved, order: 31 }, { conversion: pageComponentFilterRecordToRuleArray, order: 36 }, diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 7ed82af22cc..aabe9fa379a 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5854,7 +5854,7 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ }, { id: 'object-master-detail-form-detail-sort-field-retired', - order: 68, + order: 69, text: 'It also retires an `object-master-detail-form` detail entry\'s `sortField` (#21589, ADR-0049 ' + 'enforce-or-remove; the spec half of objectui#11070 round 9). The console stopped reading the ' From 69f42d393d2affe661e571fda4563c5d77a141da Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:54:05 +0000 Subject: [PATCH 6/9] fix(spec): two step-18 D3 entries stop naming sortField as a detail-entry key The detail-entry closure's replacement listed `sortField?` among the keys to write, and the record:line_items closure sent `sortField` to the detail entry; both are false once the key is a tombstone there. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../18.ui-object-master-detail-form-details-closed.ts | 2 +- .../semantic/18.ui-record-line-items-props-closed.ts | 7 ++++--- packages/spec/src/migrations/registry.ts | 9 +++++---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-master-detail-form-details-closed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-master-detail-form-details-closed.ts index 66626b9d9fe..4a600a8a441 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-object-master-detail-form-details-closed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-master-detail-form-details-closed.ts @@ -21,7 +21,7 @@ export const entry: SemanticMigration = { + 'grid columns), including `scale` on a column that declares no `type` and whose `name` is a ' + '`currency` field of the entry\'s `childObject`', replacement: 'each entry is `{ childObject, relationshipField?, columns?, formFields?, ' - + 'inlineMode?, amountField?, sortField?, totalField?, title?, minRows?, maxRows?, addLabel? }` ' + + 'inlineMode?, amountField?, totalField?, title?, minRows?, maxRows?, addLabel? }` ' + '— the keys the renderer reads — with `inlineMode` one of `grid` / `form`. Each column is the ' + 'strict, name-keyed inline grid column a relationship field\'s `inlineColumns` takes — ' + '`{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest from the child ' diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts index ffa6a4c36de..bd14429b760 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts @@ -26,9 +26,10 @@ export const entry: SemanticMigration = { + '`{ name, label?, type?, options?, … }`. Write `name` where a column said `field` (or ' + '`fieldName`, `key`); declare `label`, `type` and `options` on the column, because this block ' + 'draws a column exactly as declared and hydrates nothing from the child object\'s field; ' - + 'delete `scale` from a column declaring `type: \'currency\'`; delete `addLabel`, `sortField`, ' - + '`formFields` and `inlineMode`, which belong to an `object-master-detail-form` detail entry and ' - + 'are not read here, and any other key the shape does not declare.', + + 'delete `scale` from a column declaring `type: \'currency\'`; delete `addLabel`, `formFields` ' + + 'and `inlineMode`, which belong to an `object-master-detail-form` detail entry and are not read ' + + 'here, `sortField`, which no block takes (the detail entry derives the line-position field from ' + + 'the child object), and any other key the shape does not declare.', reason: 'The block draws one inline grid of the record\'s child rows, through the same objectui ' + 'grid as the other three carriers of the inline grid column, but it had no `ComponentPropsMap` ' + 'row: it was the one entry on the string-arm registration ledger, so the component-props gate ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index aabe9fa379a..d13e923f01e 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -19956,7 +19956,7 @@ const step18: MigrationStep = { + 'grid columns), including `scale` on a column that declares no `type` and whose `name` is a ' + '`currency` field of the entry\'s `childObject`', replacement: 'each entry is `{ childObject, relationshipField?, columns?, formFields?, ' - + 'inlineMode?, amountField?, sortField?, totalField?, title?, minRows?, maxRows?, addLabel? }` ' + + 'inlineMode?, amountField?, totalField?, title?, minRows?, maxRows?, addLabel? }` ' + '— the keys the renderer reads — with `inlineMode` one of `grid` / `form`. Each column is the ' + 'strict, name-keyed inline grid column a relationship field\'s `inlineColumns` takes — ' + '`{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest from the child ' @@ -20096,9 +20096,10 @@ const step18: MigrationStep = { + '`{ name, label?, type?, options?, … }`. Write `name` where a column said `field` (or ' + '`fieldName`, `key`); declare `label`, `type` and `options` on the column, because this block ' + 'draws a column exactly as declared and hydrates nothing from the child object\'s field; ' - + 'delete `scale` from a column declaring `type: \'currency\'`; delete `addLabel`, `sortField`, ' - + '`formFields` and `inlineMode`, which belong to an `object-master-detail-form` detail entry and ' - + 'are not read here, and any other key the shape does not declare.', + + 'delete `scale` from a column declaring `type: \'currency\'`; delete `addLabel`, `formFields` ' + + 'and `inlineMode`, which belong to an `object-master-detail-form` detail entry and are not read ' + + 'here, `sortField`, which no block takes (the detail entry derives the line-position field from ' + + 'the child object), and any other key the shape does not declare.', reason: 'The block draws one inline grid of the record\'s child rows, through the same objectui ' + 'grid as the other three carriers of the inline grid column, but it had no `ComponentPropsMap` ' + 'row: it was the one entry on the string-arm registration ledger, so the component-props gate ' From 5547a5a7df42501b7844d4365315665357192d69 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 21:31:52 +0000 Subject: [PATCH 7/9] merge origin/main: regenerate component.mdx on the merged tree (os-regen step 3 hand-off) The os-regen driver kept this branch's side of content/docs/references/ui/component.mdx in the merge commit and dropped main's object-metric rows from #21622. Step 2 restored main's side; this commit regenerates the page from the merged source with gen:schema and gen:docs. Result: main's side plus this branch's one `sortField` row, and the delta against the branch side is exactly #21622's own delta on the page. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index c00668bd00d..5a4a05ac1e7 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1106,7 +1106,7 @@ Sort field and direction pair | **title** | `string \| Record` | optional | Drill-down panel title; defaults to the metric label | | **icon** | `string` | optional | Lucide icon name drawn in the metric tile header, inside the `colorVariant`-tinted square. Read on this component — `ObjectMetricWidget` forwards it to `MetricWidget`, which resolves it with `getLazyIcon` (the `LazyIcon` module: kebab-case or PascalCase, degrading to the `Database` glyph on an unknown name). | | **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Icon container color variant | -| **aggregate** | `any` | optional | Aggregation config (`{ field, function, groupBy? }`) run against the object | +| **aggregate** | `{ field?: string; function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; groupBy?: string \| object }` | optional | Aggregation run against the object: `{ field?, function, groupBy? }` — `function` one of count / sum / avg / min / max / count_distinct (`field` needed for all but count), `groupBy` a field name or a `{ field, dateGranularity }` node, absent for one number over every row | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter the aggregation is scoped by — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **format** | `string` | optional | Number format pattern (e.g. '0,0', '$0,0', '0%') | | **currency** | `string` | optional | ISO currency code (e.g. 'USD') — enables currency formatting | @@ -1115,10 +1115,18 @@ Sort field and direction pair | **invert** | `boolean` | optional | Display `1 - value` for opposite-signal gauges (compliance/uptime) | | **variant** | `Enum<'card' \| 'bare'>` | optional | Layout variant | | **fallbackValue** | `string \| number` | optional | Static value shown when no data source is available | -| **trend** | `any` | optional | Static trend info (`{ value, label, direction }`) | +| **trend** | `{ value: number; label?: string \| Record; direction?: Enum<'up' \| 'down' \| 'neutral'> }` | optional | Static trend badge `{ value, label?, direction? }` — `value` painted as a percentage, `direction` up / down / neutral. A `compareTo`-derived trend replaces it | | **drillDown** | `any` | optional | Click-through drill config — opens the underlying records | | **compareTo** | `any` | optional | Period-over-period comparison (`{ kind: 'previousPeriod' \| 'previousYear' }`) | +### Nested Shape: `ObjectMetricProps.aggregate` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | optional | Field to aggregate — required for every function but `count`, which counts rows | +| **function** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | ✅ | Aggregation function — `count`, `sum`, `avg`, `min`, `max` or `count_distinct` | +| **groupBy** | `string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string }` | optional | Field the rows are grouped by, or a `{ field, dateGranularity }` date-bucket node. Omit it for the one number over every row (`_all`) | + ### Nested Shape: `ObjectMetricProps.filter[number]` View filter rule @@ -1129,6 +1137,14 @@ View filter rule | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | +### Nested Shape: `ObjectMetricProps.trend` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **value** | `number` | ✅ | The change shown on the badge, painted as a percentage (`12` reads `12%`) | +| **label** | `string \| Record` | optional | Badge caption — a string or an inline locale map. The tile-level `description` outranks it in the one caption slot they share | +| **direction** | `Enum<'up' \| 'down' \| 'neutral'>` | optional | Arrow beside the value; omit it for no arrow | + --- From a1b0552acdde5dec4e84d2d1a5ba820534da8448 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 21:32:30 +0000 Subject: [PATCH 8/9] fix(spec): the sortField retirement's step-18 rationale fragment takes order 70 #21622 (#21464 stage 4) landed on main as 3f1bc816a2 with its step-18 rationale fragment `ui-object-metric-aggregate-trend-typed` at order 69, the order this branch's fragment also held. Re-read on the merged tree's main side (5b5e83f446) the highest STEP18_RATIONALE order is 69, so this fragment takes the next free one, 70, and now renders after #21622's. The D2 conversion keeps order 60: the highest MAJOR_18_CONVERSIONS order on main is still 59 (`elementTextVariantHeadingLevels`). Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- packages/spec/src/migrations/registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 46a9a34c5e8..e7bb5699dd7 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5854,7 +5854,7 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ }, { id: 'object-master-detail-form-detail-sort-field-retired', - order: 69, + order: 70, text: 'It also retires an `object-master-detail-form` detail entry\'s `sortField` (#21589, ADR-0049 ' + 'enforce-or-remove; the spec half of objectui#11070 round 9). The console stopped reading the ' From f765e8caaf8854f8677350dc153dbbbb3a856945 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 23:35:15 +0000 Subject: [PATCH 9/9] merge origin/main: regenerate component.mdx on the merged tree (os-regen step 3 hand-off) The os-regen driver kept this branch's side of content/docs/references/ui/component.mdx in merge commit 5f5b8092ea and dropped main's object-grid keyboardNavigation row (1cbe165bfc). Step 2 restored main's side; gen:schema and gen:docs on the merged tree (HEAD the merge commit, no MERGE_HEAD) produce this page. Its delta against origin/main 15fe567c9c is exactly this branch's one sortField row. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 5a4a05ac1e7..734d6365349 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -735,7 +735,7 @@ Sort field and direction pair | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Row-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`) | | **editable** | `boolean` | optional | Enable inline cell editing | | **singleClickEdit** | `boolean` | optional | Enter cell edit on single click (default true when editable) | -| **keyboardNavigation** | `boolean` | optional | [EXPERIMENTAL — not enforced] Arrow-key cell navigation on the WAI-ARIA grid pattern. Defaults to on when `editable` is set; a read-only grid keeps its Tab behaviour unless this is `true`. No renderer reads it yet: it is declared ahead of the grid's keyboard-navigation build, so authoring it changes nothing today | +| **keyboardNavigation** | `boolean` | optional | Arrow-key cell navigation on the WAI-ARIA grid pattern: the grid's data cells become one Tab stop that the arrow keys, Home / End and Ctrl+Home / Ctrl+End move between. Defaults to on when the grid renders editable — `editable` set and the viewer allowed to edit; a grid that renders read-only keeps every cell its own Tab stop unless this is `true`, and `false` turns it off on an editable grid | | **resizable** | `boolean` | optional | Allow column resize (the renderer default is on) | | **resizableColumns** | `never` | optional | [REMOVED] `object-grid` property `resizableColumns` was removed in @objectstack/spec 17.7.0 (ADR-0049) — it was the legacy second spelling of `resizable`, read only when `resizable` was absent, so one switch had two spellings and a grid authoring both silently ignored this one. Use `resizable`. Rename the key; the value (a boolean) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **reorderableColumns** | `boolean` | optional | Allow column drag-reorder |