diff --git a/.changeset/15110-retired-element-node-refusal.md b/.changeset/15110-retired-element-node-refusal.md index 0148f5ef59..76387aa72b 100644 --- a/.changeset/15110-retired-element-node-refusal.md +++ b/.changeset/15110-retired-element-node-refusal.md @@ -73,4 +73,4 @@ Also corrected: the vocabulary docblock described the `ComponentPropsMap` row set as a superset of the enum by "exactly" the string-arm registrations plus the two tombstoned elements — one member short since `user:profile` joined it. - + diff --git a/.changeset/17594-step18-element-node-todo.md b/.changeset/17594-step18-element-node-todo.md new file mode 100644 index 0000000000..bf81d56375 --- /dev/null +++ b/.changeset/17594-step18-element-node-todo.md @@ -0,0 +1,40 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): the 17 → 18 chain now NAMES the bare `element:filter` / `element:form` node it leaves behind, instead of ending schema-invalid in silence (#17594) + +`element:filter` and `element:form` were retired whole at element grain, and the +two ADR-0087 D2 conversions that carry the retirement — `element-filter-removed` +and `element-form-removed` — strip every authorable key and **deliberately leave +the bare component node**: deleting an authored page node changes a page's +layout, which a mechanical conversion must not decide. That residue was inert +until both names joined `RETIRED_PAGE_COMPONENT_TYPES` and the parse began +refusing them by name — at which point deleting the node stopped being optional +and became a required step of the upgrade. + +The chain never said so. Measured on a stack carrying both nodes, before this +change: + +``` +os migrate meta --from 17 --to 18 + + --json schemaValid: false + human path "Migrated stack does not yet pass schema validation — + resolve the manual changes above" + the 115 step-18 todos 0 name `element:filter`, `element:form`, + `ElementFilter` or `ElementForm` +``` + +ADR-0087 D3 requires a structured TODO "rather than silence" for a migration +step that cannot be expressed declaratively, and this is one: only the author +knows what their region should hold once the node is gone. The new +`element-filter-and-form-node-refused` semantic entry supplies it — surface, the +two replacements (`userFilters` for the filter, the object-bound `object-form` +block for the form) and an `os validate`-clean acceptance criterion — so +`os migrate meta` and the generated upgrade guide both name the thing to delete. + +⛔ Nothing about either conversion's behaviour changes: they still strip the keys +and still leave the node, and no node is deleted for the author. + + diff --git a/packages/spec/src/migrations/entries/semantic/18.element-filter-and-form-node-refused.ts b/packages/spec/src/migrations/entries/semantic/18.element-filter-and-form-node-refused.ts new file mode 100644 index 0000000000..42addb1c0b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.element-filter-and-form-node-refused.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #17594 — the D3 half of the node-level refusal #15110 landed. The two D2 +// conversions `element-filter-removed` / `element-form-removed` strip all +// twelve authorable keys and DELIBERATELY leave the bare component node: +// deleting an authored page node is a layout decision a mechanical conversion +// must not make. That residue was inert until `element:filter` / +// `element:form` joined `RETIRED_PAGE_COMPONENT_TYPES` and the parse began +// refusing them BY NAME — at which point the deletion stopped being optional +// and became a required step of the 17 → 18 chain. +// +// Without this entry the chain ends `schemaValid: false` and `os migrate meta` +// closes with "resolve the manual changes above" over a manual-change list +// that names neither node: measured on a source carrying both, 0 of the 115 +// step-18 todos matched `element:filter`, `element:form`, `ElementFilter` or +// `ElementForm`, while sibling entries naming `element:number` and +// `element:record_picker` matched 3 each. ADR-0087 D3 calls for a structured +// TODO "rather than silence" for exactly this: a step that cannot be expressed +// declaratively, because only the author knows what the region should hold +// once the node is gone. +// +// The prescriptions are not new prose — they are the two node-level refusal +// messages in `RETIRED_PAGE_COMPONENT_TYPES` (ui/page.zod.ts), so the door that +// refuses and the chain that prescribes carry one instruction. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'element-filter-and-form-node-refused', + surface: + 'page.component.element:filter / page.component.element:form — the bare component ' + + 'node itself, left standing by the `element-filter-removed` and ' + + '`element-form-removed` conversions after they strip its properties', + replacement: + 'Delete the component node. `element:filter` → a list surface owns its own ' + + "filtering: use a view's `userFilters` quick-filter bar or the list toolbar's " + + 'filter builder. `element:form` → the object-bound `object-form` block, which is ' + + 'rendered, designer-publishable and carries the same intent (`objectName`, ' + + '`fields`, `mode`, `submitText`). Nothing is placed where the node was unless the ' + + 'page needs it — which region keeps its layout is the judgment this step delegates', + reason: + 'Both elements were retired whole at element grain (ADR-0049 enforce-or-remove): no ' + + 'renderer for either ever shipped in objectui, framework or cloud, so every ' + + 'authorable key was a capability claim nothing kept. The conversions are mechanical ' + + 'where they can be — they strip all twelve keys losslessly — and stop at the node, ' + + 'because removing an authored page node changes the LAYOUT of a page the author ' + + 'composed, and a conversion cannot know whether the region should close up, hold a ' + + 'replacement, or keep its slot. That residue is no longer inert: both names are ' + + 'members of `RETIRED_PAGE_COMPONENT_TYPES`, so `PageComponentSchema.type` refuses ' + + 'them by name, and a stack that replays the chain and stops there is schema-INVALID. ' + + 'Mechanical where it can be, delegated where it cannot — this entry is the ' + + 'delegation, in writing', + acceptanceCriteria: + 'No `element:filter` and no `element:form` component remains in any page — regions, ' + + 'named slots and nested containers alike (the conversions walk all three, so every ' + + 'place they stripped properties is a place a bare node can be sitting). `os validate` ' + + 'is clean: the refusal is reported at the node\'s `type` path with ' + + '`params.retiredComponentType` naming the element, so a remaining node is named ' + + 'individually rather than as one page-level failure. Replaying the same 17 → 18 chain ' + + 'over the edited source then reports the migrated stack schema-valid — ' + + '`schemaValid: true` in `--json`, and the run closes with the schema-valid line ' + + 'rather than the manual-changes warning', +}; diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts index 98e35daf70..dc94106eb2 100644 --- a/packages/spec/src/migrations/migrations.test.ts +++ b/packages/spec/src/migrations/migrations.test.ts @@ -211,6 +211,99 @@ describe('migration chain (ADR-0087 D3)', () => { }); }); + // The D3 half of a node-level refusal, and the one class of entry whose + // ABSENCE is invisible to every other gate in this family: `check:spec-changes` + // and `check:upgrade-guide` pin the registry to its PROJECTIONS, so an entry + // that was never written leaves them perfectly consistent. What made the gap + // reachable is that the two D2 conversions below are deliberately partial — + // they strip the props and leave the node, because deleting an authored page + // node is a layout decision a mechanical conversion must not make — while + // `RETIRED_PAGE_COMPONENT_TYPES` now refuses that same node BY NAME. Between + // the two, a 17 → 18 replay ended `schemaValid: false` and `os migrate meta` + // closed with "resolve the manual changes above" over a list that named + // neither element. This block pins the instruction back into the list. + describe('protocol-18 #17594 entry — the chain NAMES the bare node it leaves standing', () => { + /** A page authored against 17, carrying both retired elements. */ + const authored = () => ({ + pages: [ + { + name: 'order_board', + regions: [ + { + name: 'main', + components: [ + { type: 'element:filter', properties: { object: 'order', fields: ['status'] } }, + { type: 'element:form', properties: { object: 'order', fields: ['status'] } }, + ], + }, + ], + }, + ], + }); + + const entry = () => + MIGRATIONS_BY_MAJOR[18]!.semantic.find((s) => s.id === 'element-filter-and-form-node-refused'); + + it('finds the entry (anti-vacuity: every assertion below reads through this `find`)', () => { + expect(entry()).toBeDefined(); + expect(entry()!.surface).toMatch(/element:filter/); + expect(entry()!.surface).toMatch(/element:form/); + }); + + it('the replay really does leave the bare nodes — the residue this TODO is about', () => { + const result = applyMetaMigrations(authored(), 17, 18); + const ids = new Set(result.applied.map((a) => a.conversionId)); + expect(ids.has('element-filter-removed')).toBe(true); + expect(ids.has('element-form-removed')).toBe(true); + + // Both nodes survive the chain, stripped bare. If a conversion ever starts + // deleting them this line fails, and this entry's premise is what should be + // revisited — not this expectation. + const components = (result.stack.pages as any[])[0].regions[0].components; + expect(components.map((c: any) => c.type)).toEqual(['element:filter', 'element:form']); + expect(components[0].properties).toEqual({}); + expect(components[1].properties).toEqual({}); + }); + + it('a 17 → 18 run emits exactly one todo naming BOTH node types (ADR-0087 D3)', () => { + const result = applyMetaMigrations(authored(), 17, 18); + const naming = result.todos.filter( + (t) => /element:filter/.test(t.surface) && /element:form/.test(t.surface), + ); + expect(naming).toHaveLength(1); + expect(naming[0]!.id).toBe('element-filter-and-form-node-refused'); + expect(naming[0]!.toMajor).toBe(18); + }); + + it('prescribes DELETING the node, and names each element\'s replacement', () => { + // The two replacements are the ones `RETIRED_PAGE_COMPONENT_TYPES` already + // sends an author to at the parse; pinned here so the two doors cannot + // drift into prescribing different things. + const r = entry()!.replacement; + expect(r).toMatch(/Delete the component node/); + expect(r).toMatch(/userFilters/); + expect(r).toMatch(/object-form/); + }); + + it('⛔ does not prescribe an automatic delete — the conversions must not make it', () => { + const text = `${entry()!.replacement} ${entry()!.reason}`; + expect(text).toMatch(/layout/i); + expect(text).not.toMatch(/the conversion (deletes|removes) the node/i); + }); + + it('the acceptance criterion is checkable, and names `os validate` (the card\'s bar)', () => { + const a = entry()!.acceptanceCriteria; + expect(a).toMatch(/os validate/); + // Named at the node's own path, so a remaining node is reported + // individually rather than as one page-level failure. + expect(a).toMatch(/retiredComponentType/); + // Regions, slots and nested containers — the three places the conversions + // walk, and therefore the three places a bare node can be left. + expect(a).toMatch(/slots/); + expect(a).toMatch(/nested containers/); + }); + }); + describe('composition (cross-major is the designed-for case)', () => { it('composes only the steps in (from, to]', () => { const chain = composeMigrationChain(10, 11); diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9b9914a56a..9957951748 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -7208,6 +7208,42 @@ const step18: MigrationStep = { + 'two records) become off-spec fixtures and `ElementDataSourceConfig.filter`\'s ' + '"three shapes" note narrows — objectui cards filed by the seat, not blocked on here.', }, + { + id: 'element-filter-and-form-node-refused', + surface: + 'page.component.element:filter / page.component.element:form — the bare component ' + + 'node itself, left standing by the `element-filter-removed` and ' + + '`element-form-removed` conversions after they strip its properties', + replacement: + 'Delete the component node. `element:filter` → a list surface owns its own ' + + "filtering: use a view's `userFilters` quick-filter bar or the list toolbar's " + + 'filter builder. `element:form` → the object-bound `object-form` block, which is ' + + 'rendered, designer-publishable and carries the same intent (`objectName`, ' + + '`fields`, `mode`, `submitText`). Nothing is placed where the node was unless the ' + + 'page needs it — which region keeps its layout is the judgment this step delegates', + reason: + 'Both elements were retired whole at element grain (ADR-0049 enforce-or-remove): no ' + + 'renderer for either ever shipped in objectui, framework or cloud, so every ' + + 'authorable key was a capability claim nothing kept. The conversions are mechanical ' + + 'where they can be — they strip all twelve keys losslessly — and stop at the node, ' + + 'because removing an authored page node changes the LAYOUT of a page the author ' + + 'composed, and a conversion cannot know whether the region should close up, hold a ' + + 'replacement, or keep its slot. That residue is no longer inert: both names are ' + + 'members of `RETIRED_PAGE_COMPONENT_TYPES`, so `PageComponentSchema.type` refuses ' + + 'them by name, and a stack that replays the chain and stops there is schema-INVALID. ' + + 'Mechanical where it can be, delegated where it cannot — this entry is the ' + + 'delegation, in writing', + acceptanceCriteria: + 'No `element:filter` and no `element:form` component remains in any page — regions, ' + + 'named slots and nested containers alike (the conversions walk all three, so every ' + + 'place they stripped properties is a place a bare node can be sitting). `os validate` ' + + 'is clean: the refusal is reported at the node\'s `type` path with ' + + '`params.retiredComponentType` naming the element, so a remaining node is named ' + + 'individually rather than as one page-level failure. Replaying the same 17 → 18 chain ' + + 'over the edited source then reports the migrated stack schema-valid — ' + + '`schemaValid: true` in `--json`, and the run closes with the schema-valid line ' + + 'rather than the manual-changes warning', + }, { id: 'element-number-filter-rule-array', surface: