From 66667249246ec0d59c243905a0163b7cc334c2ab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 19:01:18 +0000 Subject: [PATCH 1/4] feat(spec)!: retire the ADR-0078 conversion-conflict channel (WIP: baselines pending) Claude-Session: https://claude.ai/code/session_01WdZxJadrrYettvjrqiWeAV Co-authored-by: Claude --- ...001-conversion-conflict-channel-retired.md | 39 +++ content/docs/deployment/cli.mdx | 9 +- packages/cli/src/commands/migrate/meta.ts | 23 +- .../cli/src/utils/data-migration-plugins.ts | 10 +- .../src/protocol.flow-canonicalizer.test.ts | 37 +-- ...rotocol.save-flow-canonicalization.test.ts | 50 +--- .../src/protocol.stored-conversions.test.ts | 2 +- .../src/protocol.stored-migration.test.ts | 36 +-- packages/metadata-protocol/src/protocol.ts | 132 +++------- .../metadata-protocol/src/stored-migration.ts | 6 - .../src/dispatcher-error-vocabulary.ts | 25 +- .../src/canonicalize-stored-flow.test.ts | 4 +- .../services/service-automation/src/engine.ts | 29 +-- .../src/flow-load-conversion.test.ts | 4 +- .../service-automation/src/inert-mode.test.ts | 7 +- .../services/service-automation/src/plugin.ts | 14 +- .../spec/src/api/error-code-ledger.zod.ts | 19 +- .../spec/src/contracts/automation-service.ts | 16 +- packages/spec/src/conversions/apply.ts | 33 +-- ...ersion-conflict-channel-retirement.test.ts | 236 ++++++++++++++++++ .../spec/src/conversions/conversions.test.ts | 10 +- packages/spec/src/conversions/index.ts | 3 - packages/spec/src/conversions/registry.ts | 9 +- packages/spec/src/conversions/stored.test.ts | 16 +- packages/spec/src/conversions/types.ts | 63 +---- .../18.conversion-conflict-channel-removed.ts | 56 +++++ packages/spec/vitest.repo-tests.json | 1 + scripts/cross-package-test-inputs.mjs | 19 +- 28 files changed, 481 insertions(+), 427 deletions(-) create mode 100644 .changeset/24001-conversion-conflict-channel-retired.md create mode 100644 packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.conversion-conflict-channel-removed.ts diff --git a/.changeset/24001-conversion-conflict-channel-retired.md b/.changeset/24001-conversion-conflict-channel-retired.md new file mode 100644 index 00000000000..4cad979bd68 --- /dev/null +++ b/.changeset/24001-conversion-conflict-channel-retired.md @@ -0,0 +1,39 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +'@objectstack/metadata-protocol': minor +--- + +feat(spec)!: the ADR-0078 conversion-conflict channel is retired — `CONVERSION_CONFLICT_CODE`, `ConversionConflictNotice`, `ConversionConflictDetail`, the `onConflict` / `reservedNodeTypes` conversion options, the `conflicts` arrays and the `FLOW_CONVERSION_CONFLICT` 409 + +Clause-②: yes (narrowing) + +**BREAKING (published API):** the ADR-0087 conversion API carried a channel for a rename REFUSED because its old token was a live name in the environment: a conversion reported it through `ConversionContext.reportConflict`, `applyConversions` handed it to an `onConflict` sink as a `ConversionConflictNotice` (code `OS_METADATA_CONVERSION_CONFLICT`), the automation engine collected it in `canonicalizeStoredFlow`'s `conflicts` array, and the metadata protocol answered it with `409 FLOW_CONVERSION_CONFLICT`. Its only producer was the live-executor guard inside the flow-node HTTP callout rename. Protocol 18 reserved those three names (`FLOW_RESERVED_NODE_TYPES`), so the engine refuses an executor under one, no live owner can exist, and the guard went. Nothing can raise a conversion conflict any more, so the channel leaves the published surface rather than staying as a live-looking failure mode nothing triggers. + +## FROM → TO + +| you wrote | write instead | +| --- | --- | +| `import { CONVERSION_CONFLICT_CODE, type ConversionConflictNotice, type ConversionConflictDetail } from '@objectstack/spec'` | delete the import — no replacement | +| `applyConversions(stack, { onConflict, reservedNodeTypes, … })` (likewise `applyConversionsToFlow`, `applyConversionsToStoredItem`, `collectConversionNotices`) | delete both options; keep `onNotice` / `onTodo` | +| a conversion's `apply` reading `context.reservedNodeTypes` or calling `context.reportConflict` | a rename over an open namespace reserves its old names instead (the `FLOW_RESERVED_NODE_TYPES` pattern), so the refusal happens at registration; a site with no lossless rewrite goes to `context.reportTodo` | +| an `IAutomationService.canonicalizeStoredFlow` implementation returning `conflicts: […]` (or a `StoredFlowCanonicalization` from `@objectstack/metadata-protocol` carrying one) | drop the member; return `parsed`, `storable`, `notices` and optionally `todos` | +| a client branch on `error.code === 'FLOW_CONVERSION_CONFLICT'` / HTTP 409 from a flow save, a package duplication or an unmanaged package import | delete the branch — no request can produce it | + +**The one-line fix:** delete every reference; the branch it guarded was already dead, so deleting it changes no behaviour. + +**What an upgrader sees.** Importing a removed name is `TS2305`; passing a removed option, or returning a `conflicts` member from a typed canonicalizer, is an excess-property `tsc` error. `ErrorCode` no longer parses `FLOW_CONVERSION_CONFLICT`. + +**Runtime behaviour is unchanged.** The removed branches could not fire: a stored flow carrying a reserved callout name still loads as `http` (the rewrite has been unconditional since the names were reserved), and `saveMetaItem`, `duplicatePackage`, `importUnmanagedPackage` and `os migrate meta --stored` answer exactly as before. No metadata document needs editing. + +## The retirement kit + +- **`@objectstack/spec`:** the three exports leave the root; `ApplyConversionsOptions` loses `onConflict` and `reservedNodeTypes`, `ConversionContext` loses `reservedNodeTypes` and `reportConflict`, the `IAutomationService.canonicalizeStoredFlow` result loses `conflicts`; the error-code ledger drops `FLOW_CONVERSION_CONFLICT` by its own "Retiring a code" rule (a row comes out with its last emitter). +- **`@objectstack/service-automation`:** `AutomationEngine.canonicalizeStoredFlow` no longer returns `conflicts`, and the engine no longer builds a live-name set for the conversion pass. +- **`@objectstack/metadata-protocol`:** `StoredFlowCanonicalization` loses `conflicts`; the flow save door's 409, the stored-migration pass's `failed` row for a refused rename, `duplicatePackage`'s failed item and `importUnmanagedPackage`'s 409 are gone with it. +- **The D3 entry `conversion-conflict-channel-removed`.** No D2 conversion and no tombstone: no metadata document ever carried any of it. +- **No deprecation window**, per the project's startup-stage posture. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** The three packages are published, so this is breaking for consumers no telemetry was consulted for. + + diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 4e2b5821db9..e6574daf783 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1608,7 +1608,6 @@ can produce, because both supply a live one. | A row stored under a non-canonical metadata type (a plural or alias spelling, e.g. `fields`) | Canonicalizing bodies is an edit; rewriting a stored *type* spelling is an identity move — a new `(org, type, name, package_id)` key — which this pass is not ruled to make. Re-author the item under its canonical type and drop the old row | | Types with no repository write path (`agent`) | Their write path records no history and would force a draft live — a half-write is worse than leaving the row to the read path | | Rows that still fail the current schema after conversion | That is a genuine contract violation, not chain-owned history. The write path's rejection is correct; fix the row in Studio | -| A flow whose rename the conflict guard refused | The old node-type token is a live name something else owns here. Rewriting would clobber that owner, so the row fails loudly naming the token — never a silent skip | | A site the conversion chain leaves as stored because no lossless rewrite exists — above all a page filter carrying `$and` / `$or` / `$not` | Flattening a combinator changes which rows the page selects, so it is never done. Each site is printed as a `TODO` line under its row — path, block, and what blocks the rewrite — whatever the row's outcome; a row with nothing but TODOs is reported `skipped`. It does not fail the run, since no run of this pass can clear it: rewrite each site by hand | | A stored shape protocol 18 refuses and no conversion rewrites — a `controlled_by_parent` object without exactly one required `master_detail` (two masters, none, a required `lookup` only, an explicit `required: false`), a view bound to the retired view-level API data source or a page component whose `data` prop binds it (`object-grid`, `object-map`, `object-gantt`, `object-tree`), a flow value slot carrying a `{…}` token (a `get_record` / `update_record` / `delete_record` filter operand among them) | Each would need a choice only its owner can make — which master, which page, what an absent value should do — so it is never rewritten. Each site is printed as a `TODO` line under its row, naming the step-18 entry that refuses it, the object, view, page component or flow node, and the owner's choices; a binding's request headers are withheld. Until it is rewritten by hand, the row is refused: an object registers but derives no master, a view registers under `[metadata_spec_invalid]`, a page still loads, its next save is refused at the metadata save door (422), and `os validate`, `os build` and `os lint` refuse the binding in source, and a flow is not registered | | Any other site `registerFlow` refuses in a stored flow the parse accepts — a text slot's template hole, a decision's `mode`, a malformed predicate, a plugin node's undeclared config key, an `api` flow with no secret | The flow's own definition is wrong, and no conversion rewrites it. Each site is printed as a `TODO registerFlow` line under its row, in the words `registerFlow` refuses it with; a site already listed under a step-18 entry is not repeated. Until it is fixed, the flow is not registered at boot and never runs. A flow the parse itself refuses is reported among the rows that could not be rewritten, in the parse's words | @@ -1625,9 +1624,9 @@ an `--apply` run alike. The list changes no row, no count and no exit code. Wher a node meant every branch, declare `mode: 'inclusive'` on it; declaring `mode` either way takes it off the list. -**Flows are covered, and cost one extra plugin.** Flow-node conversions carry an -open-namespace conflict guard that has to consult the *live* executor registry -to tell a rename from a clobber, so this run boots the automation engine — in an +**Flows are covered, and cost one extra plugin.** A flow canonicalizes at the +automation engine, whose registration checks judge a plugin node against the +*live* node registry, so this run boots the automation engine — in an inert mode that installs the node registry and then arms nothing: no flow registered, no record trigger or scheduled job bound, no connector materialized, no suspended run resumed. A migration process must not become a @@ -1682,7 +1681,7 @@ It returns the same report the CLI renders, and takes the same posture: `manage_metadata` capability — it rewrites every eligible row in the deployment, not one item — and answers `403` otherwise. Flows need no extra setup on this path: the server already holds a live automation engine, so the run resolves the -executor registry the conflict guard needs from the process it is running in. +node registry the registration checks need from the process it is running in. #### `os migrate duplicates` diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts index cadd08dc0e5..50f5ce4f4a9 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -1477,8 +1477,8 @@ export default class MigrateMeta extends Command { try { // `PlatformObjectsPlugin` for `sys_metadata` and its history/audit // siblings, plus the automation engine in INERT mode so `flow` rows are - // covered too (#4454) — flow-node conversions need its executor registry - // for the conflict guard, and `armRuntime: false` means taking it arms + // covered too (#4454) — a flow canonicalizes at the engine, whose + // registration checks need its node registry, and `armRuntime: false` means taking it arms // nothing. No storage adapter: unlike the file migration, nothing here // reads bytes. // @@ -1520,8 +1520,8 @@ export default class MigrateMeta extends Command { await import('@objectstack/metadata-protocol'); // No `canonicalizeFlow` is threaded from here. The automation engine - // canonicalizes `flow` rows — it holds the executor registry ADR-0078's - // conflict guard needs (#4454) — and the protocol resolves it from the + // canonicalizes `flow` rows — it holds the node registry its + // registration checks need (#4454) — and the protocol resolves it from the // kernel's service table itself (#4498), which is the same table the // inert engine this command boots registers into. Passing it again would // be a second route to one capability, and the two would drift. @@ -1591,13 +1591,14 @@ export default class MigrateMeta extends Command { ); } else if (report.failed > 0) { // [#23391] Only what holds for every failed kind, then what does not - // work. The stored pass marks a row `failed` four ways: a body that is - // not valid JSON, a flow that does not canonicalize, a flow rename - // refused over a live name, and (on --apply) a rewrite the store - // refused. It writes none of them, so each reads as it did before the - // run. This line used to add "they keep reading canonically through - // the chain, so nothing is broken", which is false for two of the - // four. A body that is not valid JSON loads at no read. A flow that + // work. The stored pass marks a row `failed` three ways: a body that + // is not valid JSON, a flow that does not canonicalize, and (on + // --apply) a rewrite the store refused (a fourth, a flow rename + // refused over a live name, left with the conversion-conflict channel + // in protocol 18, #24001). It writes none of them, so each reads as it + // did before the run. This line used to add "they keep reading + // canonically through the chain, so nothing is broken", which is + // false for two of them. A body that is not valid JSON loads at no read. A flow that // does not canonicalize fails the same `canonicalizeFlow` parse inside // `AutomationEngine.registerFlow`, so no boot step registers it. // The report names no kind, so the flow sentence is gated on a failed diff --git a/packages/cli/src/utils/data-migration-plugins.ts b/packages/cli/src/utils/data-migration-plugins.ts index ba7a256a04c..f6ed6cc1245 100644 --- a/packages/cli/src/utils/data-migration-plugins.ts +++ b/packages/cli/src/utils/data-migration-plugins.ts @@ -68,14 +68,14 @@ export async function buildDataMigrationPlugins( } if (opts.automation === true) { // `os migrate meta --stored` needs the automation ENGINE, never the - // automation RUNTIME (#4454). Flow-node conversions carry ADR-0078's - // open-namespace conflict guard, which consults the live executor registry - // to tell a rename from a clobber — and only this plugin has that registry. + // automation RUNTIME (#4454). A flow canonicalizes at the engine, whose + // registration checks judge a plugin node against the live node registry + // — and only this plugin has that registry. // // `armRuntime: false` is what makes taking it safe: the engine and the full // node registry come up (built-ins plus whatever `automation:ready` - // contributes, because a PARTIAL registry would make the guard rewrite over - // a live custom node type instead of refusing), and then nothing is armed — + // contributes, because a PARTIAL registry would make those checks judge a + // plugin node against no descriptor), and then nothing is armed — // no flow registered, no record trigger or scheduled job bound, no // declarative connector materialized, no suspended run resumed. A migration // process must not become a second server. diff --git a/packages/metadata-protocol/src/protocol.flow-canonicalizer.test.ts b/packages/metadata-protocol/src/protocol.flow-canonicalizer.test.ts index d8bcaf43840..aa2ba780210 100644 --- a/packages/metadata-protocol/src/protocol.flow-canonicalizer.test.ts +++ b/packages/metadata-protocol/src/protocol.flow-canonicalizer.test.ts @@ -4,9 +4,9 @@ * #4498 — the flow-skip becomes ONE seam, and `duplicatePackage` stops minting * pre-protocol flow rows. * - * `convertStoredItem` returns `flow` bodies untouched, because flow-node - * conversions carry ADR-0078's open-namespace conflict guard and that needs the - * automation engine's live executor registry. #4454 built the capability + * `convertStoredItem` returns `flow` bodies untouched, because a flow + * canonicalizes at the automation engine, whose registration checks need its + * live node registry. #4454 built the capability * (`AutomationEngine.canonicalizeStoredFlow`) and handed it to * `migrateStoredMetadata` as an explicit hook, because the CLI has to boot an * engine of its own to hold one. @@ -38,7 +38,7 @@ const flowBody = (config: Record) => ({ const canonicalizeStoredFlow = (_name: string, body: any) => { const node = body?.nodes?.[0]; if (!node || !('filters' in (node.config ?? {}))) { - return { storable: body, notices: [], conflicts: [] }; + return { storable: body, notices: [] }; } const { filters, ...rest } = node.config; return { @@ -51,7 +51,6 @@ const canonicalizeStoredFlow = (_name: string, body: any) => { path: 'flows[0].nodes[0].config', message: 'filters → filter', }], - conflicts: [], }; }; @@ -155,7 +154,6 @@ describe('migrateStoredMetadata resolves the engine itself (#4498)', () => { const explicit = vi.fn((_n: string, body: any) => ({ storable: { ...body, label: 'From the explicit hook' }, notices: [], - conflicts: [], })); const registryHook = vi.fn(canonicalizeStoredFlow); const { protocol, saveMetaItem } = makeProtocol( @@ -250,33 +248,6 @@ describe('duplicatePackage canonicalizes flow rows (#4498)', () => { expect(flow.item.nodes[0].config.objectName).toBe('iojn2_repair_ticket'); }); - it('a refused rename fails the item and names the token — never a silent legacy copy', async () => { - const conflicting = () => ({ - storable: {}, - notices: [], - conflicts: [{ - conversionId: 'flow-node-type-open-namespace', - token: 'http_request', - path: 'flows[0].nodes[0].type', - message: 'a custom executor owns this node type here', - }], - }); - const { protocol, saveMetaItem } = makeProtocol( - [legacyFlowRow], - new Map([['automation', { canonicalizeStoredFlow: conflicting }]]), - ); - - const res = await duplicate(protocol); - - expect(res.failedCount).toBe(1); - expect(res.copiedCount).toBe(0); - expect(res.failed[0].error).toContain('http_request'); - expect(res.failed[0].error).toContain('live name in this environment'); - // The point of failing: copying the un-renamed body would mint exactly - // the row this fix exists to prevent. - expect(saveMetaItem).not.toHaveBeenCalled(); - }); - it('a flow that cannot canonicalize fails the item with the reason', async () => { const throwing = () => { throw new Error("Unrecognized key: '_uiPosition'"); }; const { protocol, saveMetaItem } = makeProtocol( diff --git a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts index 3a31f8c3823..e24aa55fe0c 100644 --- a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts +++ b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts @@ -3,8 +3,8 @@ /** * #4542 — `saveMetaItem` canonicalizes flow bodies on write. * - * The read path serves stored flows verbatim (the ADR-0078 conflict guard - * needs the engine's executor registry), and `FlowNodeSchema.config` is an + * The read path serves stored flows verbatim (a flow canonicalizes at the + * automation engine), and `FlowNodeSchema.config` is an * open record — so before this fix a Studio round-trip (GET legacy dialect → * edit label → PUT it back) re-persisted the pre-protocol shape and the row * stayed `pending` in `os migrate meta --stored` forever. Every other type is @@ -63,7 +63,7 @@ const flowBody = (config: Record) => ({ const canonicalizeStoredFlow = (_name: string, body: any, options: { includeRetired?: boolean } = {}) => { const node = body?.nodes?.[0]; if (!node || !('filters' in (node.config ?? {}))) { - return { storable: body, notices: [], conflicts: [] }; + return { storable: body, notices: [] }; } if (options.includeRetired === false) { throw new Error('Unrecognized key(s) on this delete_record node config: `filters`'); @@ -79,7 +79,6 @@ const canonicalizeStoredFlow = (_name: string, body: any, options: { includeReti path: 'flows[0].nodes[0].config', message: 'filters → filter', }], - conflicts: [], }; }; @@ -240,29 +239,6 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { expect(stored.row.checksum).toBe(hashSpec(stored.body)); }); - it('a refused rename fails the save loudly and persists NOTHING', async () => { - const conflicting = () => ({ - storable: {}, - notices: [], - conflicts: [{ - conversionId: 'flow-node-type-open-namespace', - token: 'http_request', - path: 'flows[0].nodes[0].type', - message: 'a custom executor owns this node type here', - }], - }); - const { protocol, rows } = makeProtocol( - new Map([['automation', { canonicalizeStoredFlow: conflicting }]]), - ); - - await expect(save(protocol, legacyBody())).rejects.toMatchObject({ - code: 'FLOW_CONVERSION_CONFLICT', - status: 409, - }); - await expect(save(protocol, legacyBody())).rejects.toThrow(/'http_request' at flows\[0\]\.nodes\[0\]\.type is a live name in this environment/); - expect(rows.size).toBe(0); - }); - it('a canonicalizer throw falls back to the raw save — today\'s gate stays the arbiter', async () => { // `canonicalizeStoredFlow` is stricter than the schema gate (cycle // detection, control-flow regions). A WIP draft that saves fine today @@ -417,26 +393,6 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { expect(fallbackWarnings()).toHaveLength(0); }); - it('the conflict path stays silent — the 409 IS the signal', async () => { - const conflicting = () => ({ - storable: {}, - notices: [], - conflicts: [{ - conversionId: 'flow-node-type-open-namespace', - token: 'http_request', - path: 'flows[0].nodes[0].type', - message: 'a custom executor owns this name here.', - }], - }); - const { protocol } = makeProtocol( - new Map([['automation', { canonicalizeStoredFlow: conflicting }]]), - ); - - await expect(save(protocol, legacyBody())).rejects.toMatchObject({ status: 409 }); - - expect(fallbackWarnings()).toHaveLength(0); - }); - it('a host with no automation service stays silent — nothing was skipped', async () => { // There is no canonicalizer to fall back FROM; `os migrate meta // --stored` is what reports these rows. diff --git a/packages/metadata-protocol/src/protocol.stored-conversions.test.ts b/packages/metadata-protocol/src/protocol.stored-conversions.test.ts index e1ea195ea27..492e7d4e90b 100644 --- a/packages/metadata-protocol/src/protocol.stored-conversions.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-conversions.test.ts @@ -136,7 +136,7 @@ describe('getMetaItems — stored rows are served canonical (#3903)', () => { expect('execute' in action).toBe(false); }); - it('deliberately does NOT convert flow rows — that seam is registerFlow (conflict guard needs the executor registry)', async () => { + it('deliberately does NOT convert flow rows — that seam is registerFlow (the engine canonicalizes flows)', async () => { const legacyFlow = { type: 'flow', name: 'purge_flow', diff --git a/packages/metadata-protocol/src/protocol.stored-migration.test.ts b/packages/metadata-protocol/src/protocol.stored-migration.test.ts index 0a1cc59e086..8e70ceb3f5f 100644 --- a/packages/metadata-protocol/src/protocol.stored-migration.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-migration.test.ts @@ -407,7 +407,7 @@ describe('migrateStoredMetadata — flow rows via the canonicalizeFlow hook (#44 if (!node || !('filters' in (node.config ?? {}))) { // Copy-on-write: an unchanged body comes back BY REFERENCE, which is // what the pass reads as "already canonical". - return { storable: body, notices: [], conflicts: [] }; + return { storable: body, notices: [] }; } const { filters, ...rest } = node.config; return { @@ -420,7 +420,6 @@ describe('migrateStoredMetadata — flow rows via the canonicalizeFlow hook (#44 path: 'flows[0].nodes[0].config', message: 'filters → filter', }], - conflicts: [], }; }; @@ -476,7 +475,6 @@ describe('migrateStoredMetadata — flow rows via the canonicalizeFlow hook (#44 edges: [{ ...body.edges[0], condition: { dialect: 'cel', source: "x == 'y'" } }], }, notices: [], - conflicts: [], }); const row = { type: 'flow', @@ -496,31 +494,6 @@ describe('migrateStoredMetadata — flow rows via the canonicalizeFlow hook (#44 .toEqual({ dialect: 'cel', source: "x == 'y'" }); }); - it('fails the row loudly when the guard refuses a rename over a live name', async () => { - const conflicting = (_n: string, body: any) => ({ - storable: body, - notices: [], - conflicts: [{ - conversionId: 'flow-node-type-rename', - token: 'webhook', - path: 'flows[0].nodes[0].type', - message: "'webhook' is registered by a custom executor in this environment.", - }], - }); - const { engine, tables } = makeStubEngine([flowRow]); - const protocol = new ObjectStackProtocolImplementation(engine); - - const report = await protocol.migrateStoredMetadata({ apply: true, canonicalizeFlow: conflicting }); - - expect(report.failed).toBe(1); - expect(report.rewritten).toBe(0); - expect(report.rows[0]!.reason).toMatch(/live name/); - expect(report.rows[0]!.reason).toMatch(/webhook/); - // Never a silent skip and never a clobber — the owner's node survives. - expect(historyRows(tables)).toHaveLength(0); - expect(storedMigrationClean(report)).toBe(false); - }); - it('reports a flow that cannot canonicalize instead of persisting a guess', async () => { const throwing = () => { throw new Error('Unrecognized key(s) on this flow: `_uiPosition`'); }; const { engine, tables } = makeStubEngine([flowRow]); @@ -938,7 +911,7 @@ describe('migrateStoredMetadata — the decision review list: stored rows take f */ const canonicalizeFlow = (_name: string, body: any) => { const purge = body?.nodes?.find((n: any) => n.id === 'purge'); - if (!purge || !('filters' in (purge.config ?? {}))) return { storable: body, notices: [], conflicts: [] }; + if (!purge || !('filters' in (purge.config ?? {}))) return { storable: body, notices: [] }; return { storable: { ...body, @@ -954,7 +927,6 @@ describe('migrateStoredMetadata — the decision review list: stored rows take f path: 'flows[0].nodes[0].config', message: 'filters → filter', }], - conflicts: [], }; }; @@ -1133,7 +1105,7 @@ describe('migrateStoredMetadata — the v18 stored shapes refused with no lossle const canonicalizeFlow = (_name: string, body: unknown) => { const todos: ConversionTodoNotice[] = []; const storable = applyConversionsToFlow(body, { includeRetired: true, onTodo: (t) => todos.push(t) }); - return { storable, notices: [], conflicts: [], todos }; + return { storable, notices: [], todos }; }; it('#23270 — each refused controlled_by_parent shape is listed by object; a conforming object is counted canonical', async () => { @@ -1266,7 +1238,7 @@ describe('migrateStoredMetadata — the v18 stored shapes refused with no lossle const protocol = new ObjectStackProtocolImplementation(engine); const report = await protocol.migrateStoredMetadata({ - canonicalizeFlow: (_n, body) => ({ storable: body, notices: [], conflicts: [] }), + canonicalizeFlow: (_n, body) => ({ storable: body, notices: [] }), }); expect(report.canonical).toBe(1); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 74fb23d7fcf..4bdc70ad298 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -6461,11 +6461,12 @@ export class ObjectStackProtocolImplementation implements * has always done, extended by the full-chain replay data at rest needs * (a stored row has no author for a tombstone to teach). * - * `flow` is deliberately skipped: flow-node conversions carry an - * open-namespace conflict guard that needs the automation engine's live - * executor registry (`reservedNodeTypes`), which this layer does not - * have. Flows canonicalize at `AutomationEngine.registerFlow` — the - * execution seam — with the same full-chain policy. + * `flow` is deliberately skipped: flows canonicalize at + * `AutomationEngine.registerFlow` — the execution seam — with the same + * full-chain policy. (The reason this was first written for — the callout + * rename's open-namespace conflict guard, which needed the engine's live + * executor registry — retired with that guard; the conflict channel left + * in protocol 18, #24001.) * * [#20101] …and a page that omits `type` is read with the default * `PageSchema` declares ({@link withDeclaredPageTypeDefault}). That fill is @@ -6531,10 +6532,9 @@ export class ObjectStackProtocolImplementation implements /** * Resolve a flow canonicalizer from the live services registry (#4498). * - * `convertStoredItem` skips `flow` because flow-node conversions carry - * ADR-0078's open-namespace conflict guard, which needs the automation - * engine's executor registry to tell a rename from a clobber. #4454 built - * that capability as `AutomationEngine.canonicalizeStoredFlow` and handed + * `convertStoredItem` skips `flow` because a flow canonicalizes at the + * automation engine, whose registration checks judge it against the live + * node registry. #4454 built that capability as `AutomationEngine.canonicalizeStoredFlow` and handed * it to `migrateStoredMetadata` as an explicit hook, because the CLI has * to boot an engine of its own to hold one. * @@ -22988,9 +22988,9 @@ export class ObjectStackProtocolImplementation implements // Canonicalize `flow` bodies BEFORE the schema gate, so an author's // save heals a pre-protocol row the way it heals every other type - // (#4542). The read path serves stored flows verbatim (the ADR-0078 - // conflict guard needs the engine's executor registry — see - // {@link resolveFlowCanonicalizer}), and `FlowNodeSchema.config` is an + // (#4542). The read path serves stored flows verbatim (a flow + // canonicalizes at the engine — see {@link resolveFlowCanonicalizer}), + // and `FlowNodeSchema.config` is an // open record, so without this pass the gate below accepts the legacy // dialect back and the row stays `pending` in `os migrate meta // --stored` forever. Persists `storable`, never the parsed shape — @@ -23011,10 +23011,9 @@ export class ObjectStackProtocolImplementation implements // verdict that depended on whether this host runs an automation // service. So the fallback replays the chain the canonicalizer replays // FOR THIS SAVE, minus the same default flip - // ({@link FLOW_FALLBACK_VERDICT_NOT_REPLAYED}). No `reservedNodeTypes` (no - // engine here): a node-type rename's conflict guard keeps its one home - // in the engine's canonicalizer, and this converted body is never - // persisted. A key no conversion rewrites is judged exactly as written. + // ({@link FLOW_FALLBACK_VERDICT_NOT_REPLAYED}). This converted body is + // never persisted. A key no conversion rewrites is judged exactly as + // written. // // [#23775, ADR-0087 addendum 2026-07-31] Which chain that is, is the // ruling's predicate ({@link flowSaveReplaysRetired}). A re-save of a @@ -23074,26 +23073,6 @@ export class ObjectStackProtocolImplementation implements result = undefined; } if (result) { - if (result.conflicts.length > 0) { - // ADR-0078's guard refused a node-type rename because - // the old token is a LIVE name owned by a custom - // executor here. Persisting the un-renamed body would - // mint exactly the row this pass exists to prevent - // (same posture as `duplicatePackage` / #4454). 409, - // not 422: the body may be perfectly valid — the - // refusal comes from environment state, so - // resubmitting the same body cannot help. - const first = result.conflicts[0]!; - const err = new Error( - `${request.type}/${request.name}: conversion refused — ` - + `'${first.token}' at ${first.path} is a live name in this environment ` - + `(${result.conflicts.length} conflict(s)). ${first.message}` - ); - (err as any).code = 'FLOW_CONVERSION_CONFLICT'; - (err as any).status = 409; - (err as any).conflicts = result.conflicts; - throw err; - } request.item = result.storable; canonicalized = true; } @@ -23847,21 +23826,12 @@ export class ObjectStackProtocolImplementation implements * four. #9271 ruled that split intentional: keep each list correct for * its own audience rather than reconciling them. * - * - **`flow` rows with no reachable automation engine.** Flow-node - * conversions carry ADR-0078's open-namespace conflict guard, which - * needs the engine's live executor registry. When one is reachable — + * - **`flow` rows with no reachable automation engine.** A flow + * canonicalizes at the engine, whose registration checks need the live + * node registry. When one is reachable — * passed as `canonicalizeFlow`, or resolved from the services registry * (#4498) — flows are migrated like anything else (#4454); when none is, * they are reported `skipped` with that reason, never counted done. - * - **A flow whose rename that guard refused.** With an engine reachable, - * the same open-namespace check can still find the old node-type token - * is a LIVE name something else owns here. The conversion is refused and - * the row reported `failed`, naming the token and its path: rewriting - * would clobber that owner, and a quiet skip would hide it. Unlike the - * bullet above this one IS reachable from every door — the CLI boots an - * inert engine to hold the registry, and `POST /meta/_migrate-stored` - * resolves a live one from the services registry — so - * `content/docs/deployment/cli.mdx` carries it too. * - **Types with no repository write path** (neither `allowEnvironmentOverlay` nor * `allowRuntimeCreate`). This pass declines them, and since #5086 it * would have no choice: `saveMetaItem` refuses a code-only type with @@ -23936,9 +23906,8 @@ export class ObjectStackProtocolImplementation implements * services registry (#4498, {@link resolveFlowCanonicalizer}). * * `AutomationEngine.canonicalizeStoredFlow` is the implementation. - * Flow conversions carry ADR-0078's open-namespace conflict guard, - * which needs the engine's executor registry to tell a rename from a - * clobber. A caller running next to a live engine (an admin route, a + * A flow canonicalizes at the engine, whose registration checks need + * the live node registry. A caller running next to a live engine (an admin route, a * server task) needs to pass nothing; the CLI passes its own because * it boots an inert engine specifically to hold one, and an explicit * hook is also what makes the flow branch testable without an engine. @@ -24136,18 +24105,19 @@ export class ObjectStackProtocolImplementation implements } } - // Flow rows need the automation engine's live executor registry for - // ADR-0078's open-namespace conflict guard — supplied by the caller - // (#4454) or resolved from the services registry (#4498), and - // reported `skipped` when neither can reach one. + // Flow rows need the automation engine's canonicalizer — its + // registration preview judges plugin nodes against the live + // registry — supplied by the caller (#4454) or resolved from the + // services registry (#4498), and reported `skipped` when neither + // can reach one. let flowResult: StoredFlowCanonicalization | undefined; if (singular === 'flow') { if (!canonicalizeFlow) { record({ ...base, outcome: 'skipped', - reason: 'flows canonicalize at AutomationEngine.registerFlow — the node-type ' - + 'conflict guard needs the live executor registry, and no automation service ' + reason: 'flows canonicalize at AutomationEngine.registerFlow — its registration ' + + 'checks need the live node registry, and no automation service ' + 'is reachable from this caller', }); continue; @@ -24165,19 +24135,6 @@ export class ObjectStackProtocolImplementation implements }); continue; } - if (flowResult.conflicts.length > 0) { - // A rename refused because its old token is a LIVE name owned - // by something else. Rewriting would clobber that owner, and - // skipping quietly would hide it — the guard exists to be loud. - const first = flowResult.conflicts[0]!; - record({ - ...base, - outcome: 'failed', - reason: `conversion refused — '${first.token}' at ${first.path} is a live name in ` - + `this environment (${flowResult.conflicts.length} conflict(s)). ${first.message}`, - }); - continue; - } } const overlayAllowed = ObjectStackProtocolImplementation.isOverlayAllowed(singular); @@ -27552,22 +27509,6 @@ export class ObjectStackProtocolImplementation implements } else { try { const result = canonicalizeFlow(String(row.name ?? ''), body); - if (result.conflicts.length > 0) { - // ADR-0078's guard refused a node-type rename because - // the old token is a LIVE name owned by something - // else here. Copying the un-renamed body anyway would - // mint exactly the row this fix exists to prevent, so - // the item fails and names the token (same posture as - // #4454's `failed` outcome). - const first = result.conflicts[0]!; - failed.push({ - type: row.type, - name: row.name, - error: `conversion refused — '${first.token}' at ${first.path} is a live name in ` - + `this environment (${result.conflicts.length} conflict(s)). ${first.message}`, - }); - continue; - } item = result.storable; } catch (e: any) { // `FlowSchema` is strict (#4001) and the region validator @@ -27728,8 +27669,7 @@ export class ObjectStackProtocolImplementation implements * 6. the target id names an installed package (`RESOURCE_CONFLICT` / 409; * Q3: ⛔ never into an existing base) or the namespace is owned; * 7. an item does not convert to today's protocol (`INVALID_METADATA` / - * 422), or a flow's conversion would clobber a live name - * (`FLOW_CONVERSION_CONFLICT` / 409); + * 422); * 8. a name collides with an environment row or an item a managed package * ships (`RESOURCE_CONFLICT` / 409). * @@ -27815,7 +27755,6 @@ export class ObjectStackProtocolImplementation implements const canonicalizeFlow = plan.items.some((i) => i.type === 'flow') ? this.resolveFlowCanonicalizer() : undefined; const prepared: Array<{ type: string; name: string; item: unknown }> = []; const unconvertible: string[] = []; - const flowConflicts: string[] = []; for (const planned of plan.items) { try { if (planned.type !== 'flow') { @@ -27826,12 +27765,7 @@ export class ObjectStackProtocolImplementation implements prepared.push({ type: planned.type, name: planned.name, item: planned.body }); } else { const result = canonicalizeFlow(planned.name, planned.body); - if (result.conflicts.length > 0) { - const first = result.conflicts[0]!; - flowConflicts.push(`flow '${planned.name}': '${first.token}' at ${first.path} — ${first.message}`); - } else { - prepared.push({ type: planned.type, name: planned.name, item: result.storable }); - } + prepared.push({ type: planned.type, name: planned.name, item: result.storable }); } } catch (e: any) { unconvertible.push(`${planned.type} '${planned.name}': ${e?.message ?? String(e)}`); @@ -27845,14 +27779,6 @@ export class ObjectStackProtocolImplementation implements + 'this protocol, then install again.', ), { code: 'INVALID_METADATA', status: 422 }); } - if (flowConflicts.length > 0) { - throw Object.assign(new Error( - `Package '${sourcePackageId}' cannot be installed unmanaged: converting its flows would overwrite live ` - + `names in this environment — ${flowConflicts.slice(0, 3).join('; ')}` - + `${flowConflicts.length > 3 ? ` (+${flowConflicts.length - 3} more)` : ''}.`, - ), { code: 'FLOW_CONVERSION_CONFLICT', status: 409 }); - } - // 8. Every name the copy would take, against the environment and the // managed packages — the save door would refuse or overlay them. const collisions = await this.unmanagedImportCollisions(plan.items); diff --git a/packages/metadata-protocol/src/stored-migration.ts b/packages/metadata-protocol/src/stored-migration.ts index 7771d25c452..e3e8477559d 100644 --- a/packages/metadata-protocol/src/stored-migration.ts +++ b/packages/metadata-protocol/src/stored-migration.ts @@ -52,12 +52,6 @@ export interface StoredFlowCanonicalization { path: string; message: string; }>; - /** - * Renames the guard REFUSED because the old token is a live name owned by - * something else. A non-empty list fails the row loudly — rewriting would - * clobber that owner, and skipping quietly would hide it. - */ - conflicts: Array<{ conversionId: string; token: string; path: string; message: string }>; /** * Sites the chain left as stored because no lossless rewrite exists (#23269) * — above all a value slot carrying a `{…}` template token, which diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 53daa3ec744..145a10231b6 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -288,10 +288,11 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ // `plugin-route` door (stamped through a constant in an object literal, the // shape `objlit` could not see), and #9246 registered it under // `@objectstack/cloud-connection`, ratcheting that row out the same way. - // #9460's further-widened scan then reported two sites at once — - // `FLOW_CONVERSION_CONFLICT` and `owd_widening_forbidden` — and #9567 - // registered the former under `@objectstack/metadata-protocol`, ratcheting - // only that row out; `owd_widening_forbidden`'s lowercase spelling is a + // #9460's further-widened scan then reported two sites at once — the flow + // save door's conversion-conflict 409 and `owd_widening_forbidden` — and + // #9567 registered the former under `@objectstack/metadata-protocol`, + // ratcheting only that row out (the code itself retired with its last + // producer in protocol 18, #24001); `owd_widening_forbidden`'s lowercase spelling is a // naming decision, not a plain admission, so it stays pending below. A // future unswept producer lands here as an `unclassified-site` finding and // gets a new row (then a spec-lane registration, then the row comes out @@ -594,18 +595,6 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ 'built; the mirror is pinned against the real declaration in both assignability directions ' + 'by `artifact-forward-conversion.test.ts`.', }, - { - code: 'OS_METADATA_CONVERSION_CONFLICT', - file: 'packages/spec/src/conversions/apply.ts', - shape: 'objlitconst', - door: 'none', - verdict: 'foreign-vocabulary', - why: - 'The conflict twin of the OS_METADATA_CONVERTED row: handed to `onConflict` when a rename ' + - "target is a live name owned by something else, so the conversion refuses to rewrite it and " + - 'surfaces a loud diagnostic instead (ADR-0078: never silent). A callback payload, not a ' + - 'thrown error and not a response body.', - }, { code: 'OS_METADATA_CONVERSION_TODO', file: 'packages/spec/src/conversions/apply.ts', @@ -669,8 +658,8 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ // not SEE the site before it learned the stamp shape below (a // code-carrying helper), so "no finding" meant "not looked at", which is // the failure this gate exists to prevent. Its sibling from the same - // #9460 batch, `FLOW_CONVERSION_CONFLICT`, ratcheted out via #9567 (see - // the running log above); this one stays pending because admitting it AS + // #9460 batch, the flow save door's conversion-conflict 409, ratcheted out + // via #9567 (see the running log above); this one stays pending because admitting it AS // SPELLED is what ADR-0112 D1 forbids — the rename-or-keep-the-#9106-demote // call is the spec lane's and is not resolved here. { diff --git a/packages/services/service-automation/src/canonicalize-stored-flow.test.ts b/packages/services/service-automation/src/canonicalize-stored-flow.test.ts index 49b9ede3669..cc0b3740d41 100644 --- a/packages/services/service-automation/src/canonicalize-stored-flow.test.ts +++ b/packages/services/service-automation/src/canonicalize-stored-flow.test.ts @@ -199,10 +199,9 @@ describe('canonicalizeStoredFlow — a site registerFlow refuses comes back as a it('a braced operand parses, is not rewritten, and is reported by node — and registerFlow refuses the same site', () => { const engine = new AutomationEngine(silentLogger); const flow = storedFlow('{record.id}'); - const { storable, notices, conflicts, todos } = engine.canonicalizeStoredFlow('fd_refused_filter', flow); + const { storable, notices, todos } = engine.canonicalizeStoredFlow('fd_refused_filter', flow); expect(notices).toEqual([]); - expect(conflicts).toEqual([]); expect((storable as any).nodes[1].config.filter).toEqual({ id: '{record.id}' }); expect(todos.map((t) => [t.conversionId, t.path])).toEqual([ ['flow-value-slot-template-dialect-refused', 'flows[0].nodes[1].config.filter.id'], @@ -357,7 +356,6 @@ describe('canonicalizeStoredFlow — every site registerFlow refuses after the p // What the save door, a duplication and the sync's held-flow comparison read. void result.parsed; void result.storable; - void result.conflicts; expect(checks).not.toHaveBeenCalled(); expect(result.todos).toHaveLength(1); expect(result.todos).toBe(result.todos); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 72f6c72c6cb..4f5ce54192b 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -67,7 +67,7 @@ import { builtinNodeConfigKeysJudged } from '@objectstack/spec/automation'; // hand-written `trim()` here is exactly the drift the #15662 campaign built the // shared refusal to prevent. See `checkStructuralCondition` in `registerFlow`. import { EvaluatedExpressionInputSchema, EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec'; -import { applyConversionsToFlow, CONVERSION_TODO_CODE, type ConversionNotice, type ConversionConflictNotice, type ConversionTodoNotice } from '@objectstack/spec'; +import { applyConversionsToFlow, CONVERSION_TODO_CODE, type ConversionNotice, type ConversionTodoNotice } from '@objectstack/spec'; // [ADR-0126 §7.3, #20761] "Does a code package ship this flow?" is NOT read // off a definition here. The subflow guards, the arming gate and the // activation door ask {@link AutomationEngine.packagedFlowOwner} — the loader's @@ -4525,10 +4525,9 @@ export class AutomationEngine implements IAutomationService { parsed: FlowParsed; storable: unknown; notices: ConversionNotice[]; - conflicts: ConversionConflictNotice[]; todos: ConversionTodoNotice[]; } { - const { parsed, storable, notices, conflicts, todos: chainTodos } = this.canonicalizeFlow(name, definition, options); + const { parsed, storable, notices, todos: chainTodos } = this.canonicalizeFlow(name, definition, options); // The registration checks run when `todos` is first READ, once: the // stored pass reads it, and the doors that only want `parsed` or // `storable` (the save door, a package duplication, the sync's @@ -4553,7 +4552,6 @@ export class AutomationEngine implements IAutomationService { parsed, storable, notices, - conflicts, get todos(): ConversionTodoNotice[] { listedTodos ??= listRegistrationRefusals(); return listedTodos; @@ -4572,7 +4570,6 @@ export class AutomationEngine implements IAutomationService { parsed: FlowParsed; storable: unknown; notices: ConversionNotice[]; - conflicts: ConversionConflictNotice[]; todos: ConversionTodoNotice[]; } { // ADR-0087 D2 — the runtime load seam. A stored flow authored against an @@ -4580,11 +4577,11 @@ export class AutomationEngine implements IAutomationService { // with `config.filters`) is canonicalized on rehydration, BEFORE parse + // execution, so the executor only ever sees the canonical shape and a // dropped-alias upgrade never silently changes behavior (e.g. an empty - // `filter` deleting a whole table). `reservedNodeTypes` is this engine's - // live executor registry, handed to any open-namespace node-type rename - // that guards against clobbering a live owner. The one such rename, the - // callout rename, no longer consults it (#23543 F5-http): its old names - // are reserved, and `registerNodeExecutor` refuses an executor under one. + // `filter` deleting a whole table). No live executor registry is handed + // to the pass: the one open-namespace rename, the callout rename, + // reserves its old names instead (#23543 F5-http), and + // `registerNodeExecutor` refuses an executor under one, so (#24001) the + // conversion-conflict channel it used to feed is gone. // // `includeRetired` (#3903): this seam rehydrates DATA AT REST — flows // stored in `sys_metadata` outlive any load window, and a row written @@ -4606,26 +4603,15 @@ export class AutomationEngine implements IAutomationService { // now means something else. This seam cannot say a body predates such // a flip, so it refuses those entries by id, each with its reason on // `CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION`. - const reservedNodeTypes = new Set([ - ...FLOW_STRUCTURAL_NODE_TYPES, - ...this.nodeExecutors.keys(), - ...this.actionDescriptors.keys(), - ]); const notices: ConversionNotice[] = []; - const conflicts: ConversionConflictNotice[] = []; const todos: ConversionTodoNotice[] = []; const converted = applyConversionsToFlow(definition, { - reservedNodeTypes, includeRetired: options.includeRetired ?? true, excludeConversionIds: CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION, onNotice: (n) => { notices.push(n); this.logger.warn(`[flow '${name}'] ${n.code}: ${n.message}`); }, - onConflict: (c) => { - conflicts.push(c); - this.logger.warn(`[flow '${name}'] ${c.code}: ${c.message}`); - }, // Collected, not logged: a site listed here is one `registerFlow` // refuses below with its own located message. onTodo: (t) => { @@ -4654,7 +4640,6 @@ export class AutomationEngine implements IAutomationService { parsed, storable: graftConditionEnvelopes(converted, parsed), notices, - conflicts, todos, }; } diff --git a/packages/services/service-automation/src/flow-load-conversion.test.ts b/packages/services/service-automation/src/flow-load-conversion.test.ts index f7f522a6ac9..286d30f2897 100644 --- a/packages/services/service-automation/src/flow-load-conversion.test.ts +++ b/packages/services/service-automation/src/flow-load-conversion.test.ts @@ -58,7 +58,7 @@ describe('registerFlow canonicalizes stored flows (ADR-0087 D2 runtime seam)', ( expect(warns.some((w) => w.includes('OS_METADATA_CONVERTED') || w.includes('→'))).toBe(false); }); - it('rewrites every reserved callout name in a stored flow, with no conflict', async () => { + it('rewrites every reserved callout name in a stored flow, one notice per site', async () => { const warns: string[] = []; const engine = new AutomationEngine(collectingLogger(warns)); engine.registerFlow('f', { @@ -78,7 +78,7 @@ describe('registerFlow canonicalizes stored flows (ADR-0087 D2 runtime seam)', ( const stored = await engine.getFlow('f'); expect(stored?.nodes.filter((n) => n.id === 'n' || n.id === 'm').map((n) => n.type)).toEqual(['http', 'http']); - expect(warns.some((w) => w.includes('OS_METADATA_CONVERSION_CONFLICT'))).toBe(false); + expect(warns.filter((w) => w.includes('OS_METADATA_CONVERTED'))).toHaveLength(2); }); }); diff --git a/packages/services/service-automation/src/inert-mode.test.ts b/packages/services/service-automation/src/inert-mode.test.ts index b2efdbd7093..7d11ad5d1a1 100644 --- a/packages/services/service-automation/src/inert-mode.test.ts +++ b/packages/services/service-automation/src/inert-mode.test.ts @@ -72,11 +72,10 @@ describe('armRuntime: false — nothing is armed (#4454)', () => { expect(onMetadataMutation).not.toHaveBeenCalled(); }); - it('still fires automation:ready — a partial registry would corrupt the guard', async () => { + it('still fires automation:ready — a partial registry would corrupt the registration preview', async () => { // This is the one thing inert mode must NOT skip. Third-party executors - // register on this hook; without them `reservedNodeTypes` is short, and - // the conflict guard silently rewrites over a live custom node type - // instead of refusing. + // and descriptors register on this hook; without them the stored pass's + // registration preview judges a plugin node against no descriptor. const { triggered } = await boot({ armRuntime: false, suspendedRunStore: 'memory' }); expect(triggered).toContain('automation:ready'); diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 29d687232ea..3db495e2739 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -1033,9 +1033,9 @@ export class AutomationServicePlugin implements Plugin { // ── Inert mode (#4454) — an engine, and nothing armed ───────────────── // A one-shot tool (`os migrate meta --stored`) needs this engine for one - // read-only thing: `reservedNodeTypes`, the live executor registry that - // ADR-0078's open-namespace conflict guard consults to tell a rename - // from a clobber. It does NOT want the runtime this plugin normally + // read-only thing: `canonicalizeStoredFlow`, whose registration preview + // judges a plugin node's config against the action descriptor that + // node's plugin registered. It does NOT want the runtime this plugin normally // brings up, and everything below this line arms something: // // • the flow pull calls `registerFlow`, which calls @@ -1049,10 +1049,10 @@ export class AutomationServicePlugin implements Plugin { // that silently continues someone's paused approval is indefensible. // // The return is placed AFTER `automation:ready` deliberately: that hook - // is how third-party plugins contribute node executors, and a partial - // registry would make the conflict guard's answer wrong — it would read - // a live custom node type as unowned and rewrite over it. Registry - // population is the one thing inert mode must NOT skip. + // is how third-party plugins contribute node executors and descriptors, + // and a partial registry would make the registration preview's answer + // wrong — it would judge a plugin node against no descriptor at all. + // Registry population is the one thing inert mode must NOT skip. if (this.options.armRuntime === false) { ctx.logger.info( '[Automation] inert mode (armRuntime: false) — engine and node registry are up; ' diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index dce268ce9df..02b830b4cc4 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -639,19 +639,10 @@ export const ERROR_CODE_LEDGER = { // string is spelled in each). Served by the `/meta` write doors through // `errorFromThrown`. 'DEFAULT_RECORD_PAGE_CONFLICT', - // [#9567] ADR-0078's rename guard refuses a metadata write whose `type`/ - // `name` token is already a LIVE name owned by something else in the - // environment — persisting the un-renamed body would mint exactly the row - // that guard exists to prevent (`saveMetaItem`, `protocol.ts`, same posture - // as `duplicatePackage` / #4454). 409, not 422: the body may be perfectly - // valid — the refusal comes from environment state, so resubmitting the - // same body cannot help. Surfaced by the widened - // `check:dispatcher-error-vocabulary` scan (#9460, half 1); NOTE the - // producer's message prefix spells the code lowercase - // (`[flow_conversion_conflict]`) while the wire stamp is SCREAMING_SNAKE — - // a human-log inconsistency worth a glance, left as-is because the - // producer is outside this ledger's surface. - 'FLOW_CONVERSION_CONFLICT', + // [#24001] The flow save door's 409 for a refused conversion rename came + // out with its last producer (this header's "Retiring a code"): protocol + // 18 reserved the old HTTP-callout node types, so no live owner can + // collide with a rename and no conversion reports a conflict. 'INVALID_METADATA', 'INVALID_REQUEST', 'ITEM_LOCKED', // _lock refuses the write/delete (ADR-0010 §3.3) @@ -725,7 +716,7 @@ export const ERROR_CODE_LEDGER = { // never satisfy, and the remedy is a body change // (`objectstack migrate meta --from N`). It matches this package's own // METADATA_SCHEMA_INVALID. It is not 409, which this ledger keeps for - // refusals from environment state (FLOW_CONVERSION_CONFLICT). The two HTTP + // refusals from environment state (CONCURRENT_UPDATE). The two HTTP // doors that reach the throw, `POST /api/v1/packages` and (since #21805) // `POST /api/v1/marketplace/install-local`, answer the same bytes through // the shared `protocolIncompatibleAnswer`, carrying `requiredRange`, diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 4498d729cba..373b1f24337 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -17,7 +17,7 @@ import type { FlowParsed } from '../automation/flow.zod'; import type { ExecutionLog, ExecutionStatus, FlowRunSummary } from '../automation/execution.zod'; import type { ActionDescriptor } from '../automation/node-executor.zod'; import type { ConnectorDescriptor } from '../integration/connector-descriptor'; -import type { ConversionNotice, ConversionConflictNotice, ConversionTodoNotice } from '../conversions/types'; +import type { ConversionNotice, ConversionTodoNotice } from '../conversions/types'; import type { ExecutionContext } from '../kernel/execution-context.zod'; import type { FlowRegistration } from '../api/protocol.zod'; @@ -831,11 +831,12 @@ export interface IAutomationService { * `os migrate meta --stored` rewriting stored `sys_metadata` rows is the * reason this is on the contract rather than only on the implementation. * - * Only an implementation holding the live executor registry can offer this: - * flow-node conversions carry ADR-0078's open-namespace conflict guard, and - * deciding a rename from a clobber requires knowing which node types are - * actually owned here. Hence optional — a caller falls back to leaving flow - * rows alone rather than guessing. + * Only the implementation that registers flows can offer this: the + * `todos` preview is {@link registerFlow}'s own verdict, judged by the + * engine that holds the judges. Hence optional — a caller falls back to + * leaving flow rows alone rather than guessing. [#24001] There is no + * conflict list: protocol 18 reserves the old node types a rename reads, + * so no conversion can collide with a live executor any more. * * @param name - Flow name (snake_case), used for diagnostics * @param definition - The stored/authored flow body @@ -843,7 +844,7 @@ export interface IAutomationService { * `storable` (persistence shape — conversions plus the schema's * `condition` envelopes, deliberately WITHOUT schema defaults, so a * written-back row is not frozen on today's default values), plus the - * conversions applied, any rewrite the guard refused, and (`todos`) the + * conversions applied, and (`todos`) the * sites left as stored for want of a lossless rewrite, and the sites * {@link registerFlow} refuses once the parse has accepted the flow. * @throws when the definition cannot be canonicalized at all (a strict-schema @@ -854,7 +855,6 @@ export interface IAutomationService { parsed: FlowParsed; storable: unknown; notices: ConversionNotice[]; - conflicts: ConversionConflictNotice[]; /** * The sites the conversion chain left as stored because no lossless * rewrite exists — a value slot carrying a `{…}` token among them, which diff --git a/packages/spec/src/conversions/apply.ts b/packages/spec/src/conversions/apply.ts index e0d9473cfca..ab1f7d64c8d 100644 --- a/packages/spec/src/conversions/apply.ts +++ b/packages/spec/src/conversions/apply.ts @@ -15,10 +15,8 @@ import { ALL_CONVERSIONS } from './registry.js'; import { reportRefusedStoredShapes } from './refused-shapes.js'; import { - CONVERSION_CONFLICT_CODE, CONVERSION_NOTICE_CODE, CONVERSION_TODO_CODE, - type ConversionConflictNotice, type ConversionContext, type ConversionNotice, type ConversionTodoNotice, @@ -62,12 +60,6 @@ export interface ApplyConversionsOptions { * this option at all. */ includeRetired?: boolean; - /** - * Sink for each structured **conflict** — a rename refused because its old - * token is a live name in this environment (see {@link ConversionContext}). - * Populated by the runtime load seam; absent on the build/validate seam. - */ - onConflict?: (notice: ConversionConflictNotice) => void; /** * Sink for each structured **TODO** — a site a conversion recognised as a * pre-protocol shape and left as stored, because no lossless rewrite exists @@ -88,12 +80,6 @@ export interface ApplyConversionsOptions { * nothing of it runs, and in either case nothing is rewritten. */ onTodo?: (todo: ConversionTodoNotice) => void; - /** - * Node types that are live in this environment. Supplied by the runtime load - * seam so open-namespace renames can detect a collision with a live owner - * rather than silently clobber it. - */ - reservedNodeTypes?: ReadonlySet; /** * Conversion ids this seam refuses to replay, whatever `includeRetired` * says. Empty/absent by default: every seam replays the whole window it @@ -136,7 +122,7 @@ export function applyConversions( stack: Record, options: ApplyConversionsOptions = {}, ): Record { - const { onNotice, onConflict, onTodo, reservedNodeTypes, includeRetired = false, excludeConversionIds } = options; + const { onNotice, onTodo, includeRetired = false, excludeConversionIds } = options; const excluded = excludeConversionIds && excludeConversionIds.length > 0 ? new Set(excludeConversionIds) : null; @@ -156,18 +142,6 @@ export function applyConversions( if (conversion.retiredFromLoadPath && !includeRetired) continue; const retiresIn = conversion.toMajor + 1; const context: ConversionContext = { - reservedNodeTypes, - reportConflict: onConflict - ? (detail) => - onConflict({ - code: CONVERSION_CONFLICT_CODE, - conversionId: conversion.id, - surface: conversion.surface, - token: detail.token, - path: detail.path, - message: `[protocol] ${detail.reason} (ADR-0087 conversion '${conversion.id}').`, - }) - : undefined, reportTodo: onTodo ? (detail) => onTodo({ @@ -236,10 +210,7 @@ export function applyConversions( * This is the runtime load seam ADR-0087 D2 calls for: a stored flow authored * against an old shape (e.g. a `delete_record` node with `config.filters`, or a * `webhook` callout node) is canonicalized on rehydration, so the executor only - * ever sees the canonical shape. Callers pass `reservedNodeTypes` (their live - * executor registry) so an open-namespace rename over a live custom node becomes - * a reported conflict, not a silent clobber. Non-object input is returned - * unchanged. + * ever sees the canonical shape. Non-object input is returned unchanged. */ export function applyConversionsToFlow(flow: T, options: ApplyConversionsOptions = {}): T { if (flow == null || typeof flow !== 'object' || Array.isArray(flow)) return flow; diff --git a/packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts b/packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts new file mode 100644 index 00000000000..c714afd0d13 --- /dev/null +++ b/packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts @@ -0,0 +1,236 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + EXPORT_ENTRY_POINTS, + exportNamesOf, + holdersOf, +} from '../../scripts/lib/export-origins-testkit'; +import { ERROR_CODE_LEDGER, ErrorCode } from '../api/error-code-ledger.zod'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; +import type { IAutomationService } from '../contracts/automation-service'; +import type { ApplyConversionsOptions } from './apply'; +import type { ConversionContext } from './types'; + +// ─── [#24001] ADR-0078's conversion-CONFLICT channel is RETIRED ───────────── +// +// ADR-0049 enforce-or-remove, under the startup-stage rule that a published +// surface with no producer retires at once (no sunk-cost exemption). The +// channel's only producer was the live-executor guard inside the +// `flow-node-http-callout-rename` conversion; protocol 18 reserved the three +// old callout names (`FLOW_RESERVED_NODE_TYPES`), so the engine refuses an +// executor under one, no live owner can collide with the rename, and the guard +// went. Measured on `origin/main` fd9664ffac before removal: no conversion +// entry called the context's conflict sink — the only references were the +// bridge in `apply.ts`, its declarations, the engine's empty plumbing and four +// consumer branches in `metadata-protocol`, none of which could fire. +// +// What left, and where each pin below holds it: +// +// 1. three root exports — the notice code constant and the two notice +// types — by resolved symbol identity over every public entry (§1); +// 2. the two conversion-option members, the two context members and the +// canonicalize result's `conflicts` array — by key absence at the type +// level, which `check:test-typecheck` compiles (§2); +// 3. the error code, by the ledger's own "Retiring a code" rule: its row +// came out with its last emitter, so `ErrorCode` no longer parses it (§3); +// 4. every spelling of all of it, by a tree-scoped absence scan over the +// declared radius, so the channel cannot come back one consumer at a +// time (§4). +// +// No D2 conversion and no tombstone: no metadata document ever carried any of +// it. The D3 semantic entry `conversion-conflict-channel-removed` is the +// declaration, and §1 checks it is registered on step 18. + +/** The exports that left. */ +const RETIRED_EXPORTS = [ + 'CONVERSION_CONFLICT_CODE', + 'ConversionConflictNotice', + 'ConversionConflictDetail', +] as const; + +/** The retired error code. */ +const RETIRED_CODE = 'FLOW_CONVERSION_CONFLICT'; + +describe('§1 the retired exports have zero holders; the TODO channel stands', () => { + it('every retired name has ZERO holders on any public entry, and the survivors still stand on the root', () => { + // Anti-vacuity: the baseline covers the real surface. + expect(EXPORT_ENTRY_POINTS).toContain('.'); + expect(exportNamesOf('.').length, 'the root must export a non-trivial surface').toBeGreaterThan(500); + + for (const name of RETIRED_EXPORTS) { + expect(holdersOf(name), `${name} must have zero holders after its retirement`).toEqual([]); + } + + // The conversion API keeps its notice and TODO channels: a too-wide sweep + // that took them with the conflict channel is the failure this catches. + const root = exportNamesOf('.'); + for (const survivor of [ + 'CONVERSION_NOTICE_CODE', + 'CONVERSION_TODO_CODE', + 'ConversionNotice', + 'ConversionTodoNotice', + 'ConversionTodoDetail', + 'ConversionContext', + 'ApplyConversionsOptions', + 'applyConversions', + ]) { + expect(root, `${survivor} must SURVIVE this retirement on the root`).toContain(survivor); + } + }); + + it('the runtime barrel resolves without the retired constant and keeps its TODO twin', async () => { + const conversions = await import('./index'); + expect(conversions).not.toHaveProperty('CONVERSION_CONFLICT_CODE'); + // Anti-vacuity: the barrel really resolved. + expect(conversions).toHaveProperty('CONVERSION_TODO_CODE', 'OS_METADATA_CONVERSION_TODO'); + expect(conversions).toHaveProperty('applyConversions'); + }); + + it('the D3 semantic entry is registered on step 18', () => { + const ids = (MIGRATIONS_BY_MAJOR[18]?.semantic ?? []).map((entry) => entry.id); + expect(ids).toContain('conversion-conflict-channel-removed'); + }); +}); + +describe('§2 the conversion options, the context and the contract carry no conflict member', () => { + /** `true` when `K` is a key of `T` — compiled by `check:test-typecheck`. */ + type Has = K extends keyof T ? true : false; + type CanonicalizeResult = ReturnType>; + + it('the retired members are absent at the type level, the surviving ones present', () => { + // Each tuple element's TYPE is the assertion: a member coming back flips + // its `Has<…>` to `true` and the `false` literal stops compiling. + const absent: [ + Has, + Has, + Has, + Has, + Has, + ] = [false, false, false, false, false]; + const present: [ + Has, + Has, + Has, + Has, + Has, + ] = [true, true, true, true, true]; + expect(absent.every((v) => v === false)).toBe(true); + expect(present.every((v) => v === true)).toBe(true); + }); +}); + +describe('§3 the error code left the ledger with its last emitter', () => { + it('ErrorCode no longer parses it, and no owner lists it', () => { + expect(ErrorCode.safeParse(RETIRED_CODE).success).toBe(false); + for (const [owner, codes] of Object.entries(ERROR_CODE_LEDGER)) { + expect(codes as readonly string[], `${owner} must not register ${RETIRED_CODE}`).not.toContain(RETIRED_CODE); + } + // Control: the same owner's sibling 409/422 rows still parse. + expect(ErrorCode.safeParse('DEFAULT_RECORD_PAGE_CONFLICT').success).toBe(true); + expect(ErrorCode.safeParse('CONCURRENT_UPDATE').success).toBe(true); + }); +}); + +// ─── §4 tree-scoped absence ──────────────────────────────────────────────── +// +// Every identifier the channel published, plus the notice code's wire value and +// the save door's old lowercase message prefix. None of these spellings has a +// live meaning anywhere else in the tree (measured before removal), so a bare +// substring match is the matcher; `onConflict` and a bare `conflicts` are NOT +// on the list — both name live, unrelated members elsewhere (a flow +// concurrency policy, a query alias fold, the package and security-catalog +// refusals), and §2 holds this channel's spellings of them at the type level. +// +// Radius, declared in `scripts/cross-package-test-inputs.mjs` under +// `@objectstack/spec` (the #15513 pin's rows; this file is a further witness +// on each): `packages/` per extension (never `.tsx` — a typed `.tsx` +// resurrection fails `tsc` in its own package), `examples/` for the non-code +// extensions only (every example has its own `typecheck`), and `skills/`, +// `content/`, `scripts/` whole. +describe('tree-scoped absence: no spelling of the conversion-conflict channel survives inside the declared radius', () => { + 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('/'); + + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + /** tsup's transient bundle of `tsup.config.ts` (see the #15513 pin for the measured crash). */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + const SPELLINGS = [ + ...RETIRED_EXPORTS, + RETIRED_CODE, + 'OS_METADATA_CONVERSION_CONFLICT', + 'flow_conversion_conflict', + 'reservedNodeTypes', + 'reportConflict', + ]; + + /** Structural exclusions, each with its reason. NOT an allowlist file: these are the retirement kit and its projections. */ + const EXCLUDED = new Set([ + // This pin spells the retired names to assert their absence. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // Registers the retirement (the D3 entry's prose names what left; the + // generated, git-ignored `registry.ts` concatenates it). + 'packages/spec/src/migrations/', + // Release prose records the removal (release-owned; never edited by a code PR). + 'content/docs/releases/', + '.changeset/', + ]; + + const vanished: string[] = []; + const readIfPresent = (full: string, rel: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + vanished.push(rel); + return undefined; + } + }; + + it('no retired spelling survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 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); + if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(ext)) 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; + const text = readIfPresent(full, rel); + if (text === undefined) continue; + for (const spelling of SPELLINGS) { + if (text.includes(spelling)) offenders.push(`${rel} spells \`${spelling}\``); + } + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree. + expect(visited).toBeGreaterThan(1000); + expect( + offenders, + 'the ADR-0078 conversion-conflict channel has no producer since protocol 18 reserved the old callout names — a rename over an open namespace reserves its old names instead of reporting a conflict', + ).toEqual([]); + }); +}); diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index 19229e46957..7afc0e3b859 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -163,25 +163,21 @@ describe('conversion layer (ADR-0087 D2)', () => { * F5-http): it skipped a node whose old name a live executor owned. The * engine now refuses to register an executor under a reserved name * (`AutomationEngine.registerNodeExecutor`), so no live owner can exist, and - * the rewrite is unconditional — a caller's `reservedNodeTypes` naming one of - * the three no longer stops it, and no conflict is reported. + * the rewrite is unconditional. Protocol 18 removed the conflict channel with + * it (#24001): a pass has no live-name set to consult and no conflict sink. */ describe('flow-node-http-callout-rename — the rewrite is unconditional', () => { - it('rewrites a reserved name even when the caller lists it as live, reporting no conflict', () => { + it('rewrites a reserved name with one notice and nothing else', () => { const notices: ConversionNotice[] = []; - const conflicts: unknown[] = []; const out = applyConversions( { flows: [{ name: 'f', nodes: [{ id: 'a', type: 'webhook' }] }] }, { includeRetired: true, onNotice: (n) => notices.push(n), - onConflict: (c) => conflicts.push(c), - reservedNodeTypes: new Set(['webhook']), }, ); expect((out.flows as any[])[0].nodes[0].type).toBe('http'); expect(notices.map((n) => n.conversionId)).toEqual(['flow-node-http-callout-rename']); - expect(conflicts).toEqual([]); }); it('reads the reserved-name table the flow parse refuses with', () => { diff --git a/packages/spec/src/conversions/index.ts b/packages/spec/src/conversions/index.ts index e286559ee95..630ed35fa95 100644 --- a/packages/spec/src/conversions/index.ts +++ b/packages/spec/src/conversions/index.ts @@ -10,12 +10,9 @@ */ export { - CONVERSION_CONFLICT_CODE, CONVERSION_NOTICE_CODE, CONVERSION_TODO_CODE, type ConversionApplication, - type ConversionConflictDetail, - type ConversionConflictNotice, type ConversionContext, type ConversionFixture, type ConversionNotice, diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 6cbb02d31d9..982ae6a2d28 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -80,10 +80,11 @@ import { FLOW_RESERVED_NODE_TYPES } from '../automation/flow-reserved-node-types * `flow-node-http-callout-types-reserved`, states the edit for authored sources. * * The rewrite is unconditional. Until 18 it skipped a node whose old name a - * live executor owned and reported a conflict instead (ADR-0078's guard, - * through `ConversionContext.reservedNodeTypes`); the engine now refuses that - * registration, so no live executor can own a reserved name and the guard had - * no case left to judge. + * live executor owned and reported a conflict instead (ADR-0078's guard); the + * engine now refuses that registration, so no live executor can own a reserved + * name and the guard had no case left to judge. Protocol 18 removed the + * conflict channel itself with it (#24001, D3 entry + * `conversion-conflict-channel-removed`). */ const flowNodeHttpRename: MetadataConversion = { id: 'flow-node-http-callout-rename', diff --git a/packages/spec/src/conversions/stored.test.ts b/packages/spec/src/conversions/stored.test.ts index 7049ddb78e7..7dc1f15b304 100644 --- a/packages/spec/src/conversions/stored.test.ts +++ b/packages/spec/src/conversions/stored.test.ts @@ -198,10 +198,10 @@ describe('applyConversionsToStoredItem (stored sys_metadata rows)', () => { // [#23543 F5-http] A stored flow row written with a retired callout name // still loads, as `http`: the rename left the authoring funnel, not this - // seam. Until 18 a caller's `reservedNodeTypes` naming the old token made it - // a refused conflict instead (ADR-0078's guard); the engine now refuses an - // executor under a reserved name, so the guard is gone and the rewrite is - // unconditional. + // seam. Until 18 a caller's live-name set naming the old token made it a + // refused conflict instead (ADR-0078's guard); the engine now refuses an + // executor under a reserved name, so the guard is gone, the rewrite is + // unconditional, and (#24001) the conflict channel left with it. it('rewrites a stored flow\'s retired callout node type to http, unconditionally', () => { const flow = { name: 'notify_flow', @@ -211,11 +211,8 @@ describe('applyConversionsToStoredItem (stored sys_metadata rows)', () => { { id: 'n3', type: 'http_call', config: { url: 'https://example.com' } }, ], }; - const conflicts: string[] = []; const notices: ConversionNotice[] = []; const out = applyConversionsToStoredItem('flow', flow, { - reservedNodeTypes: new Set(['webhook']), - onConflict: (c) => conflicts.push(c.token), onNotice: (n) => notices.push(n), }) as typeof flow; expect(out.nodes.map((n) => n.type)).toEqual(['http', 'http', 'http']); @@ -225,11 +222,10 @@ describe('applyConversionsToStoredItem (stored sys_metadata rows)', () => { ['flow-node-http-callout-rename', 'http_request', 'http'], ['flow-node-http-callout-rename', 'http_call', 'http'], ]); - expect(conflicts).toEqual([]); }); - // [#17321] The TODO lane rides through the stored seam the way the conflict - // lane does: `StoredConversionOptions` omits only `includeRetired`, so + // [#17321] The TODO lane rides through the stored seam: + // `StoredConversionOptions` omits only `includeRetired`, so // `onTodo` reaches the (retired, replayed-at-rest) filter conversion. it('threads the TODO sink through — a stored page filter left as stored is reported, not silent', () => { const page = { diff --git a/packages/spec/src/conversions/types.ts b/packages/spec/src/conversions/types.ts index 2cfd26bac6a..a277a9112e5 100644 --- a/packages/spec/src/conversions/types.ts +++ b/packages/spec/src/conversions/types.ts @@ -27,14 +27,15 @@ /** Stable code stamped on every conversion notice — greppable, MCP-serializable. */ export const CONVERSION_NOTICE_CODE = 'OS_METADATA_CONVERTED' as const; -/** - * Stable code for a **conversion conflict** — a rename whose old token is, in - * this environment, a *live* name owned by something else (e.g. a third-party - * flow-node executor registered under a since-retired official node type). The - * conversion refuses to rewrite it (which would silently break that owner) and - * instead surfaces this loud, actionable diagnostic (ADR-0078: never silent). - */ -export const CONVERSION_CONFLICT_CODE = 'OS_METADATA_CONVERSION_CONFLICT' as const; +// [#24001] There is no conversion-CONFLICT channel (ADR-0078's "refuse to +// rewrite a live name" guard) any more: its code, its two notice types and the +// context's live-name set and sink were removed in protocol 18. Its only +// producer, the live-executor guard on the flow-node HTTP callout rename, went +// when protocol 18 reserved the three old names (`FLOW_RESERVED_NODE_TYPES`), +// so no live owner can collide with a rename. A future rename over an open +// namespace reserves its old names the same way: a registration refusal, never +// a conversion conflict. The D3 entry `conversion-conflict-channel-removed` +// names what left. /** * Stable code for a **conversion TODO** — a site a conversion recognised as a @@ -92,30 +93,6 @@ export interface ConversionApplication { path: string; } -/** The per-conflict detail a conversion reports when it refuses to rewrite a live name. */ -export interface ConversionConflictDetail { - /** The reserved/retired token that is currently a live name owned by something else. */ - token: string; - /** Where the conflicting site is, e.g. `flows[0].nodes[2].type`. */ - path: string; - /** Why the rewrite was refused (actionable — tells the owner what to do). */ - reason: string; -} - -/** - * A structured conflict notice: a rename was **refused** because its old token - * is a live name in this environment. Same machine-first shape as - * {@link ConversionNotice}; different code. - */ -export interface ConversionConflictNotice { - code: typeof CONVERSION_CONFLICT_CODE; - conversionId: string; - surface: string; - token: string; - path: string; - message: string; -} - /** * The per-site detail a conversion reports when it recognises a pre-protocol * shape on its surface and leaves it as stored, because no lossless rewrite @@ -129,7 +106,7 @@ export interface ConversionTodoDetail { /** * Why no lossless rewrite exists — naming the part that blocks it (the * combinator, the operator, the key) and the node it sits on — and what the - * hand rewrite has to decide. Actionable, like a conflict's `reason`. + * hand rewrite has to decide. Actionable: it tells the owner what to do. */ reason: string; } @@ -163,21 +140,10 @@ export interface ConversionTodoNotice { } /** - * Environment-supplied context for a conversion pass. Empty on the pure - * build/validate seam (no runtime registry to consult); populated on the - * runtime load seam (`engine.registerFlow`) so a rename over an *open* - * namespace (flow node types) can detect a collision with a live owner instead - * of silently clobbering it. + * Caller-supplied context for a conversion pass: the sinks a conversion may + * report through besides `emit`. Empty unless the caller asked for TODOs. */ export interface ConversionContext { - /** - * Node types that are *live* in this environment (registered executors + - * action descriptors + structural types). A node-type rename whose old token - * is in this set is a conflict, not a conversion. - */ - reservedNodeTypes?: ReadonlySet; - /** Sink for a refused rewrite (see {@link ConversionConflictDetail}). */ - reportConflict?: (detail: ConversionConflictDetail) => void; /** * Sink for a site left as stored because no lossless rewrite exists (see * {@link ConversionTodoDetail}). Absent unless the caller asked for TODOs; a @@ -217,10 +183,7 @@ interface MetadataConversionBody { summary: string; /** * Apply the conversion to a normalized stack, immutably. Returns the (possibly - * new) stack and calls `emit` once per rewritten site. A conversion over an - * open namespace consults `context` (when supplied) to refuse — and report via - * `context.reportConflict` — a rewrite whose old token is a live name; a - * conversion over a closed surface ignores `context`. A conversion that + * new) stack and calls `emit` once per rewritten site. A conversion that * recognises a pre-protocol shape on its surface but has no lossless rewrite * for it leaves the site unchanged and reports it via `context.reportTodo`. */ diff --git a/packages/spec/src/migrations/entries/semantic/18.conversion-conflict-channel-removed.ts b/packages/spec/src/migrations/entries/semantic/18.conversion-conflict-channel-removed.ts new file mode 100644 index 00000000000..9df031ed83f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.conversion-conflict-channel-removed.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// [#24001] ADR-0078's conversion-conflict channel loses its last producer in +// protocol 18 and leaves the published surface with it. Exports and contract +// members only: no metadata document ever carried any of it, so there is no D2 +// conversion and no tombstone, and this entry is the declaration. +export const entry: SemanticMigration = { + id: 'conversion-conflict-channel-removed', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the ADR-0078 conversion-conflict channel of the ADR-0087 conversion API: the root exports ' + + 'CONVERSION_CONFLICT_CODE, ConversionConflictNotice and ConversionConflictDetail; the ' + + 'onConflict and reservedNodeTypes options of ApplyConversionsOptions (and so of ' + + 'StoredConversionOptions); the reservedNodeTypes and reportConflict members of ' + + 'ConversionContext; the conflicts array of the IAutomationService canonicalizeStoredFlow ' + + 'result (and of the metadata protocol StoredFlowCanonicalization); and the ' + + 'FLOW_CONVERSION_CONFLICT error code, with the HTTP 409 the flow save door, package ' + + 'duplication and the unmanaged package import answered with it', + replacement: + '(removed — there is no replacement, because nothing can raise a conversion conflict any ' + + 'more. Delete the onConflict and reservedNodeTypes options from any applyConversions, ' + + 'applyConversionsToFlow or applyConversionsToStoredItem call, delete reads of the ' + + 'conflicts array, and delete any branch on the OS_METADATA_CONVERSION_CONFLICT notice code ' + + 'or on a FLOW_CONVERSION_CONFLICT / 409 response. A conversion reports through emit and, ' + + 'for a site it cannot rewrite losslessly, through the TODO sink (onTodo, ' + + 'ConversionTodoNotice). A rename over an open namespace such as flow node types reserves ' + + 'its old names instead (FLOW_RESERVED_NODE_TYPES): the engine refuses to register an ' + + 'executor or action descriptor under a reserved name, so the refusal happens at ' + + 'registration, loudly, and the stored rename is unconditional)', + reason: + 'ADR-0049 enforce-or-remove, under the startup-stage rule that a published surface with no ' + + 'producer retires at once. The channel existed for one case: a stored flow whose old ' + + 'HTTP-callout node type (http_request, http_call, webhook) collided with a live executor a ' + + 'plugin registered under that same name, which the callout rename then refused to rewrite ' + + 'and reported as a conflict. Protocol 18 reserved those three names, so the automation ' + + 'engine refuses such an executor and no live owner can exist; the guard inside the rename ' + + 'went with it, and no conversion calls the conflict sink. What stayed published was a ' + + 'live-looking way to report a conflict that nothing can raise: two sink options, a notice ' + + 'type and its code, a contract array that is always empty, and a 409 that no request can ' + + 'trigger — which a client author, or an AI writing one, reads as a failure mode to handle. ' + + 'An upgrader owes a judgment only where their own code branched on it: such a branch was ' + + 'already dead, so deleting it changes no behaviour.', + acceptanceCriteria: + 'No code imports CONVERSION_CONFLICT_CODE, ConversionConflictNotice or ' + + 'ConversionConflictDetail from @objectstack/spec — each is TS2305 after upgrade, pinned by ' + + 'resolved symbol identity in conversions/conversion-conflict-channel-retirement.test.ts. No ' + + 'call passes onConflict or reservedNodeTypes to a conversion function, and no ' + + 'canonicalizeStoredFlow implementation returns a conflicts array: each is an excess-property ' + + 'tsc error. ErrorCode no longer parses FLOW_CONVERSION_CONFLICT. No metadata document ' + + 'needs editing, and runtime behaviour is unchanged: the conflict branch could not fire, so ' + + 'a stored flow carrying a reserved callout name still loads as http, and a flow save, a ' + + 'package duplication and an unmanaged import answer exactly as before.', +}; diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 1903b6bd6d4..aabb3f273e2 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -31,6 +31,7 @@ "src/api/export-job-family-retirement.test.ts", "src/api/rest-api-config-dead-keys-retirement.test.ts", "src/contracts/record-share-guest-recipient-census.test.ts", + "src/conversions/conversion-conflict-channel-retirement.test.ts", "src/data/api-methods-batch-conformance.test.ts", "src/data/cube-member-inner-name-retirement.test.ts", "src/data/cube-refresh-key-retirement.test.ts", diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index c991ca96325..d3528765177 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -251,66 +251,83 @@ export const CROSS_PACKAGE_TEST_INPUTS = { // one witness per glob no literal path on this package's roster holds. // src/ui/view-data-api-provider-retirement.test.ts (the view-level // `api` data-source retirement) walks the SAME five roots with the same - // per-extension rule, so it is the second witness on every row. + // per-extension rule, so it is the second witness on every row, and + // src/conversions/conversion-conflict-channel-retirement.test.ts (the + // conversion-conflict channel's retirement) the third, for the same reason. 'packages/**/*.mts': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', ], 'packages/**/*.cts': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', ], 'packages/**/*.js': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', ], 'packages/**/*.mjs': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', ], 'packages/**/*.cjs': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', ], 'packages/**/*.json': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', ], 'packages/**/*.md': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', ], 'packages/**/*.mdx': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', ], 'packages/**/*.yaml': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', ], 'packages/**/*.yml': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', ], 'examples/**/*.json': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', ], 'examples/**/*.md': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', ], 'examples/**/*.mdx': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', ], 'examples/**/*.yaml': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', ], 'examples/**/*.yml': [ 'packages/spec/src/system/compliance-families-retirement.test.ts', 'packages/spec/src/ui/view-data-api-provider-retirement.test.ts', + 'packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts', ], }, }, From d6539fabad6ff09a170d7fc9cbdf9b6ee6a3bf01 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 19:06:57 +0000 Subject: [PATCH 2/4] chore(spec): regenerate api-surface, export-origins and reference docs for the conflict-channel retirement Claude-Session: https://claude.ai/code/session_01WdZxJadrrYettvjrqiWeAV Co-authored-by: Claude --- content/docs/references/api/contract.mdx | 3 +-- content/docs/references/api/error-code-ledger.mdx | 1 - packages/spec/api-surface/root.json | 3 --- packages/spec/export-origins/root.json | 3 --- 4 files changed, 1 insertion(+), 9 deletions(-) diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 555afd86bd5..5c74e376ea4 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +333 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +332 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -193,7 +193,6 @@ const result = ApiErrorSchema.parse(data); * `FILTER_TOKEN_UNRESOLVED` * `FIND_HOOK_RESULT_NOT_ARRAY` * `FIND_ONE_HOOK_RESULT_NOT_RECORD` -* `FLOW_CONVERSION_CONFLICT` * `FLOW_DISABLED` * `FLOW_FAILED` * `FLOW_INPUT_SCHEMA_INVALID` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index fdca0fb0c27..6d7beb19e30 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -360,7 +360,6 @@ const result = ErrorCode.parse(data); * `FILTER_TOKEN_UNRESOLVED` * `FIND_HOOK_RESULT_NOT_ARRAY` * `FIND_ONE_HOOK_RESULT_NOT_RECORD` -* `FLOW_CONVERSION_CONFLICT` * `FLOW_DISABLED` * `FLOW_FAILED` * `FLOW_INPUT_SCHEMA_INVALID` diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index f60c675efc7..ac7f089c00d 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -40,7 +40,6 @@ "BuiltinMembershipRole (type)", "COMPOSE_KEY_DISPOSITIONS (const)", "CONVERSIONS_BY_MAJOR (const)", - "CONVERSION_CONFLICT_CODE (const)", "CONVERSION_NOTICE_CODE (const)", "CONVERSION_TODO_CODE (const)", "CapabilityClassification (interface)", @@ -53,8 +52,6 @@ "ConflictStrategy (type)", "ConflictStrategySchema (const)", "ConversionApplication (interface)", - "ConversionConflictDetail (interface)", - "ConversionConflictNotice (interface)", "ConversionContext (interface)", "ConversionFixture (interface)", "ConversionNotice (interface)", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index 9fa33cf1223..6d869fa2700 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -40,7 +40,6 @@ "BuiltinMembershipRole": "src/identity/membership-role.ts#BuiltinMembershipRole (type)", "COMPOSE_KEY_DISPOSITIONS": "src/stack.zod.ts#COMPOSE_KEY_DISPOSITIONS (const)", "CONVERSIONS_BY_MAJOR": "src/conversions/registry.ts#CONVERSIONS_BY_MAJOR (const)", - "CONVERSION_CONFLICT_CODE": "src/conversions/types.ts#CONVERSION_CONFLICT_CODE (const)", "CONVERSION_NOTICE_CODE": "src/conversions/types.ts#CONVERSION_NOTICE_CODE (const)", "CONVERSION_TODO_CODE": "src/conversions/types.ts#CONVERSION_TODO_CODE (const)", "CapabilityClassification": "src/kernel/platform-capabilities.ts#CapabilityClassification (interface)", @@ -53,8 +52,6 @@ "ConflictStrategy": "src/stack.zod.ts#ConflictStrategy (type)", "ConflictStrategySchema": "src/stack.zod.ts#ConflictStrategySchema (const)", "ConversionApplication": "src/conversions/types.ts#ConversionApplication (interface)", - "ConversionConflictDetail": "src/conversions/types.ts#ConversionConflictDetail (interface)", - "ConversionConflictNotice": "src/conversions/types.ts#ConversionConflictNotice (interface)", "ConversionContext": "src/conversions/types.ts#ConversionContext (interface)", "ConversionFixture": "src/conversions/types.ts#ConversionFixture (interface)", "ConversionNotice": "src/conversions/types.ts#ConversionNotice (interface)", From 0284cfd0b7390275046d17763cb26b9b414fef31 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 19:08:26 +0000 Subject: [PATCH 3/4] test(spec): root export floor sized to the measured surface Claude-Session: https://claude.ai/code/session_01WdZxJadrrYettvjrqiWeAV Co-authored-by: Claude --- .../conversions/conversion-conflict-channel-retirement.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts b/packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts index c714afd0d13..36798488919 100644 --- a/packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts +++ b/packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts @@ -61,7 +61,7 @@ describe('§1 the retired exports have zero holders; the TODO channel stands', ( it('every retired name has ZERO holders on any public entry, and the survivors still stand on the root', () => { // Anti-vacuity: the baseline covers the real surface. expect(EXPORT_ENTRY_POINTS).toContain('.'); - expect(exportNamesOf('.').length, 'the root must export a non-trivial surface').toBeGreaterThan(500); + expect(exportNamesOf('.').length, 'the root must export a non-trivial surface').toBeGreaterThan(100); for (const name of RETIRED_EXPORTS) { expect(holdersOf(name), `${name} must have zero holders after its retirement`).toEqual([]); From 8c5af03b1831a1e2902f8fd333de5e023eb8d500 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 20:18:53 +0000 Subject: [PATCH 4/4] test(spec): the conflict-channel absence scan excludes the generated upgrade-guide projection Claude-Session: https://claude.ai/code/session_01WdZxJadrrYettvjrqiWeAV Co-authored-by: Claude --- .../conversion-conflict-channel-retirement.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts b/packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts index 36798488919..3c3c6a75078 100644 --- a/packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts +++ b/packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts @@ -183,6 +183,10 @@ describe('tree-scoped absence: no spelling of the conversion-conflict channel su // Registers the retirement (the D3 entry's prose names what left; the // generated, git-ignored `registry.ts` concatenates it). 'packages/spec/src/migrations/', + // The protocol upgrade guide is that registry's projection — git-ignored, + // written by `gen:upgrade-guide` (and the docs build) into this tree — so + // once generated it prints the D3 entry's prose verbatim. + 'content/docs/protocol-upgrade/', // Release prose records the removal (release-owned; never edited by a code PR). 'content/docs/releases/', '.changeset/',