From 7118fcf1cf755ed33b96e89421193297474620b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 20:17:06 +0000 Subject: [PATCH 1/2] feat(spec): a semantic migration names the D2 conversions whose applied edits it judges SemanticMigration gains an optional conversionIds list, the same ids MigrationApplication.conversionId carries, so a printer of a chain result can show an entry beside the applied edits it judges. flow-decision-edge-branching-first-match links flow-decision-mode-inclusive-explicit, and migrations.test.ts refuses a link that no step at or below the entry's own replays. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...low-decision-edge-branching-first-match.ts | 3 ++ .../spec/src/migrations/migrations.test.ts | 50 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 3 ++ packages/spec/src/migrations/types.ts | 22 ++++++++ 4 files changed, 78 insertions(+) diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts b/packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts index 2496cdc6cc4..440dc7bf773 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts @@ -84,4 +84,7 @@ export const entry: SemanticMigration = { + '`\'exclusive\' | \'inclusive\'`, is refused at registration and by `os validate` with the ' + 'schema\'s own sentence; nothing else about `conditions`-list decisions changes. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + // The edits this entry judges: every `mode: 'inclusive'` that conversion + // writes is one decision to keep, delete or narrow, per the criteria above. + conversionIds: ['flow-decision-mode-inclusive-explicit'], }; diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts index 7cb5da82a4d..dad2f9edb35 100644 --- a/packages/spec/src/migrations/migrations.test.ts +++ b/packages/spec/src/migrations/migrations.test.ts @@ -72,6 +72,56 @@ describe('migration chain (ADR-0087 D3)', () => { } }); + // `SemanticMigration.conversionIds` is the join between an applied edit and + // the entry that judges it, keyed on `MigrationApplication.conversionId`. + // An id the chain never replays at or before the entry's hop can never meet + // an applied edit, so the link would silently pair nothing: a typo, a + // conversion of a later major, or one whose step fell below the floor. + it('every `conversionIds` link on a semantic entry names a registered conversion that its own step or an earlier one replays', () => { + const dangling: string[] = []; + let links = 0; + for (const major of MIGRATION_MAJORS) { + const replayed = new Set( + MIGRATION_MAJORS.filter((m) => m <= major).flatMap((m) => MIGRATIONS_BY_MAJOR[m]!.conversionIds), + ); + for (const s of MIGRATIONS_BY_MAJOR[major]!.semantic) { + for (const id of s.conversionIds ?? []) { + links++; + if (!CONVERSION_IDS.has(id)) { + dangling.push(`protocol ${major}: ${s.id} → ${id} (no registered conversion has this id)`); + } else if (!replayed.has(id)) { + dangling.push(`protocol ${major}: ${s.id} → ${id} (registered, but no step at or below ${major} replays it)`); + } + } + } + } + expect( + dangling, + `semantic entry link(s) that can pair with no applied edit: ${dangling.join(', ')}. ` + + 'Remedy: correct the id in the entry file under `entries/semantic/` to the D2 conversion whose ' + + 'applied edits the entry judges, one graduated into the entry\'s own step or an earlier one, ' + + 'then `gen:migration-registry`; or drop the id if the entry judges no edit of that conversion.', + ).toEqual([]); + // Anti-vacuity: at least one link exists, so the loop above read something. + expect(links).toBeGreaterThan(0); + }); + + it('the decision-mode pair joins end to end: the chain carries the link onto the TODO, and it names the applied edits', () => { + const JUDGE = 'flow-decision-edge-branching-first-match'; + const CONVERSION = 'flow-decision-mode-inclusive-explicit'; + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION)!; + expect(conversion.toMajor).toBe(18); + + const result = applyMetaMigrations(conversion.fixture.before, 17, 18); + const todo = result.todos.find((t) => t.id === JUDGE); + expect(todo?.conversionIds).toEqual([CONVERSION]); + + // The join a printer makes: the applied edits whose `conversionId` the TODO names. + const judged = result.applied.filter((a) => todo!.conversionIds!.includes(a.conversionId)); + expect(judged.length).toBeGreaterThan(0); + expect(new Set(judged.map((a) => a.conversionId))).toEqual(new Set([CONVERSION])); + }); + it('a graduated conversion belongs to the step for its own major', () => { for (const [majorStr, step] of Object.entries(MIGRATIONS_BY_MAJOR)) { const major = Number(majorStr); diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 52bb6071512..f63e9ed216b 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -11450,6 +11450,9 @@ const step18: MigrationStep = { + '`\'exclusive\' | \'inclusive\'`, is refused at registration and by `os validate` with the ' + 'schema\'s own sentence; nothing else about `conditions`-list decisions changes. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + // The edits this entry judges: every `mode: 'inclusive'` that conversion + // writes is one decision to keep, delete or narrow, per the criteria above. + conversionIds: ['flow-decision-mode-inclusive-explicit'], }, // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code // span already, and a nested backtick would close it. diff --git a/packages/spec/src/migrations/types.ts b/packages/spec/src/migrations/types.ts index d72e1943ddd..1d7b1e30b15 100644 --- a/packages/spec/src/migrations/types.ts +++ b/packages/spec/src/migrations/types.ts @@ -49,6 +49,28 @@ export interface SemanticMigration { reason: string; /** How the consumer proves the hand-migration correct (their own verify loop). */ acceptanceCriteria: string; + /** + * Ids of the D2 conversions whose APPLIED edits this entry judges: the + * mechanical rewrites the chain replay makes, for which this entry states the + * judgment the consumer still owes (keep the written value, delete it, or + * narrow the source instead). Each id names a conversion that this entry's + * step or an earlier one replays (`MigrationStep.conversionIds`), and + * `migrations.test.ts` refuses one that does not. + * + * The join key is the same name on both sides: an id here is the + * `conversionId` of every {@link MigrationApplication} that conversion + * produces, and the chain copies this field onto the entry's + * {@link MigrationTodo} like every other field. So a printer of a chain + * result can show the entry beside each applied edit it judges, for review. + * The link moves nothing out of the chain result: the entry is reported as a + * TODO of its hop whether or not any edit it names was applied. + * + * Omit it when the entry judges no mechanical edit. Add an id only after + * reading the entry and confirming that it judges that conversion's output; + * ⛔ never derive one from the entry's prose naming the id, since prose also + * names incidental analogues. + */ + conversionIds?: readonly string[]; } /** From f9bd45c2cc89ff331bd2a4eaff1e6c84325f80e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 20:30:33 +0000 Subject: [PATCH 2/2] chore(changeset): spec minor for SemanticMigration.conversionIds Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...20697-semantic-migration-conversion-ids.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/20697-semantic-migration-conversion-ids.md diff --git a/.changeset/20697-semantic-migration-conversion-ids.md b/.changeset/20697-semantic-migration-conversion-ids.md new file mode 100644 index 00000000000..31403cca39c --- /dev/null +++ b/.changeset/20697-semantic-migration-conversion-ids.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): a semantic migration names the D2 conversions whose applied edits it judges + +Clause-②: yes (widening) + +`SemanticMigration`, the ADR-0087 D3 entry type exported by `@objectstack/spec`, +gains one optional member, `conversionIds?: readonly string[]`. It lists the ids +of the D2 conversions whose applied edits the entry judges. Each id is the same +`conversionId` that the conversion's `MigrationApplication` rows carry, so a +printer of an `applyMetaMigrations` result can show the entry beside those edits +for review. The chain copies the field onto the entry's `MigrationTodo`, so +`objectstack migrate meta --json` shows it on that todo. Nothing is removed or +renamed, and every entry is still reported as a todo of its hop. + +One link ships: `flow-decision-edge-branching-first-match` judges the +`mode: 'inclusive'` edits that `flow-decision-mode-inclusive-explicit` writes.