Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/15110-retired-element-node-refusal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- adr-0087: not-required (already-registered element-filter-removed, element-form-removed) both elements' retirement is already in the protocol-18 ledger — these two conversions plus all twelve retired-key tombstones; this change registers no new retirement, it closes the node-level half of those same entries and reuses their prescriptions verbatim -->
<!-- adr-0087: registered element-filter-and-form-node-refused -->
40 changes: 40 additions & 0 deletions .changeset/17594-step18-element-node-todo.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: registered element-filter-and-form-node-refused -->
Original file line number Diff line number Diff line change
@@ -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',
};
93 changes: 93 additions & 0 deletions packages/spec/src/migrations/migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
36 changes: 36 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading