From ce2f04de2f333fb231246c49e3aac06112be9bc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 10:32:10 +0000 Subject: [PATCH 1/2] fix(spec): the 17 to 18 chain names the bare element node it leaves standing The two ADR-0087 D2 conversions element-filter-removed and element-form-removed strip every authorable key and deliberately leave the bare component node, because deleting an authored page node is a layout decision a mechanical conversion must not make. Once both names joined RETIRED_PAGE_COMPONENT_TYPES the parse began refusing them, so deleting the node stopped being optional and became a required upgrade step with no structured TODO naming it: measured on a stack carrying both nodes, the replay ended schemaValid false and none of the 115 step-18 todos named either element. Adds the ADR-0087 D3 semantic entry element-filter-and-form-node-refused (one file under migrations/entries/semantic, regenerated into the marked region by gen:migration-registry), pins it against the replay, and moves the node-refusal changeset's ADR-0087 disposition onto it. Neither conversion's behaviour changes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .../15110-retired-element-node-refusal.md | 2 +- .changeset/17594-step18-element-node-todo.md | 40 ++++++++ ...18.element-filter-and-form-node-refused.ts | 62 +++++++++++++ .../spec/src/migrations/migrations.test.ts | 93 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 35 +++++++ 5 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 .changeset/17594-step18-element-node-todo.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.element-filter-and-form-node-refused.ts 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..48c9acbf9b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.element-filter-and-form-node-refused.ts @@ -0,0 +1,62 @@ +// 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. Re-running `os migrate meta ' + + '--from 17` then reports the migrated stack schema-valid instead of asking for the ' + + 'manual changes again', +}; 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..b457da53ce 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -7208,6 +7208,41 @@ 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. Re-running `os migrate meta ' + + '--from 17` then reports the migrated stack schema-valid instead of asking for the ' + + 'manual changes again', + }, { id: 'element-number-filter-rule-array', surface: From 95f4fb4b9d40c640d778fd4d0ad219c751cd2504 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 12:31:29 +0000 Subject: [PATCH 2/2] fix(spec): keep the new D3 entry out of the house migrate-sentence form retired-key-migrate-sentence.test.ts scans every string literal under packages/spec/src for a backticked `os migrate meta --from N` and requires the house tombstone sentence at that marker. The new entry's acceptance criterion named the command that way to say the upgrade is done, so the scanner judged it as a tombstone prescription and refused it; the sentence was also split across a concatenation seam, which the scanner reconstructs, so both halves were read as one. No entry file in migrations/entries spells that marker: nine name the bare command or a flagged variant, none names --from N. The acceptance criterion now says the same checkable thing without the marker -- replay the same chain over the edited source and the run reports the stack schema-valid rather than the manual-changes warning. Regenerated; the pin cases, the entry id and the ADR-0087 disposition are unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .../semantic/18.element-filter-and-form-node-refused.ts | 7 ++++--- packages/spec/src/migrations/registry.ts | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) 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 index 48c9acbf9b..42addb1c0b 100644 --- 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 @@ -56,7 +56,8 @@ export const entry: SemanticMigration = { + '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. Re-running `os migrate meta ' - + '--from 17` then reports the migrated stack schema-valid instead of asking for the ' - + 'manual changes again', + + '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/registry.ts b/packages/spec/src/migrations/registry.ts index b457da53ce..9957951748 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -7239,9 +7239,10 @@ const step18: MigrationStep = { + '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. Re-running `os migrate meta ' - + '--from 17` then reports the migrated stack schema-valid instead of asking for the ' - + 'manual changes again', + + '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',