Skip to content

Commit a65a59b

Browse files
feat(spec)!: retire the ADR-0078 conversion-conflict channel — onConflict / reservedNodeTypes, the notice types, the conflicts arrays and the FLOW_CONVERSION_CONFLICT 409 (#24045)
Fixes #24001 Clause-②: yes (narrowing) ## What this does Retires ADR-0078's conversion-**conflict** channel, which has had no producer since #23543 F5-http (PR #23954) reserved the three old callout node types (`FLOW_RESERVED_NODE_TYPES`). Per the card's "Reader who acts" and triage `6221525633`: - **`@objectstack/spec`**: the root exports `CONVERSION_CONFLICT_CODE`, `ConversionConflictNotice`, `ConversionConflictDetail` leave; `ApplyConversionsOptions` loses `onConflict` / `reservedNodeTypes` (and so `StoredConversionOptions`), `ConversionContext` loses `reservedNodeTypes` / `reportConflict`, `IAutomationService.canonicalizeStoredFlow`'s result loses `conflicts`. `api-surface/root.json` and `export-origins/root.json` regenerated (−3 each); `declaration-map/` did not move (`check:generated`: all 14 current). - **Error code**: `FLOW_CONVERSION_CONFLICT` comes out of `ERROR_CODE_LEDGER` by the ledger's own "Retiring a code" rule ("A row whose last EMITTER is deleted comes out with it"; precedent `OVERLAY_PERSISTENCE_FAILED`). `retired-error-codes.ts` is NOT used: that table is for `StandardErrorCode` catalogue members only (#17707's `CONCURRENT_LIMIT_EXCEEDED`), and this is a ledger row. The error catalog's 409 row is the generated `content/docs/references/api/{contract,error-code-ledger}.mdx` entry (regenerated by `gen:docs`); `content/docs/api/error-catalog.mdx` never listed it. - **Dispatcher vocabulary**: the `OS_METADATA_CONVERSION_CONFLICT` `foreign-vocabulary` row (its stamp site in `apply.ts` is gone) is deleted; the running-log comments no longer spell the retired code. - **`@objectstack/service-automation`**: `canonicalizeFlow` / `canonicalizeStoredFlow` no longer build the live-name set or collect `conflicts`; `plugin.ts`'s inert-mode rationale is restated for what still needs the registry (the registration preview's descriptor checks). - **`@objectstack/metadata-protocol`**: `StoredFlowCanonicalization` loses `conflicts`; four consumer branches go — the save door's 409, the stored-migration pass's `failed` row, `duplicatePackage`'s failed item and `importUnmanagedPackage`'s 409 — with their docblocks. - **D3 semantic entry** `conversion-conflict-channel-removed` (no D2 conversion, no tombstone: no metadata document ever carried any of it). One BREAKING `minor` changeset with FROM → TO, marker `adr-0087: registered conversion-conflict-channel-removed`. `FLOW_RESERVED_NODE_TYPES` and the F5-http reservation are untouched. ## Hypothesis 1 — zero producers (measured on `origin/main` `fd9664ffac`, before any edit) `git grep -n -E 'reportConflict\(|onConflict|reservedNodeTypes|ConversionConflictNotice|ConversionConflictDetail|FLOW_CONVERSION_CONFLICT|ConversionConflict'` (excluding `CHANGELOG.md`), plus `CONVERSION_CONFLICT_CODE|OS_METADATA_CONVERSION_CONFLICT` and `reportConflict|\.conflicts\b`: | site | role | | --- | --- | | `packages/spec/src/conversions/types.ts:37` `CONVERSION_CONFLICT_CODE`, `:96` `ConversionConflictDetail`, `:110` `ConversionConflictNotice`, `:178`-`:180` context members | declaration | | `packages/spec/src/conversions/apply.ts:70` / `:96` options, `:139`, `:158`-`:168` the `reportConflict` bridge | bridge (sink wiring), not a producer | | `packages/spec/src/conversions/index.ts:13,17,18` | re-export | | `packages/spec/src/contracts/automation-service.ts:20,857` | declaration (contract field) | | `packages/spec/src/conversions/registry.ts:84` | comment (the guard's own retirement note) | | `packages/services/service-automation/src/engine.ts:70,4528,4575,4609-4628` | consumer (builds the set, collects into an array) | | `packages/metadata-protocol/src/protocol.ts:23077` (save door 409), `:24168` (stored pass), `:27555` (`duplicatePackage`), `:27829`/`:27853` (unmanaged import 409); `stored-migration.ts:60` | consumer | | `packages/runtime/src/dispatcher-error-vocabulary.ts:292,598,672` | gate ledger | | conversions / stored / metadata-protocol / service-automation tests | test doubles that CONSTRUCT a conflict (not producers) | | `onConflict` hits in `automation/execution.zod.ts`, `data/data-engine.zod.ts`, `driver-sql`, `types/unbacked-conflict-target.ts`, `check-tenant-chokepoint.mjs` | unrelated members of the same name | **Producer of a conflict = a conversion entry calling `context.reportConflict`: zero.** No `MetadataConversion.apply` reads `context` for it; `flow-node-http-callout-rename`'s `apply(stack, emit)` takes no context at all. No fork, so the hard delete proceeded. Cross-repo: `objectui` at the pinned `.objectui-sha` `f8267df9` and at its HEAD `360e27a` — **0** hits for all seven spellings, control `defineStack` lit (38 / 37 files). `cloud` is private and was **NOT MEASURED** (the ledger rule asks for it; see the report's open question). ## Pins (one per retired export / code), all in `packages/spec/src/conversions/conversion-conflict-channel-retirement.test.ts` - `§1 … every retired name has ZERO holders on any public entry, and the survivors still stand on the root` — `CONVERSION_CONFLICT_CODE`, `ConversionConflictNotice`, `ConversionConflictDetail` by resolved symbol identity (`export-origins/`), survivors `CONVERSION_TODO_CODE`, `ConversionTodoNotice`, `ConversionContext`, … present. - `§1 … the runtime barrel resolves without the retired constant and keeps its TODO twin`. - `§1 … the D3 semantic entry is registered on step 18`. - `§2 … the retired members are absent at the type level, the surviving ones present` — `onConflict`, `reservedNodeTypes` (options and context), `reportConflict`, the result's `conflicts`; compiled by `check:test-typecheck`. - `§3 … ErrorCode no longer parses it, and no owner lists it` — `FLOW_CONVERSION_CONFLICT`, with `DEFAULT_RECORD_PAGE_CONFLICT` / `CONCURRENT_UPDATE` as lit controls. - `tree-scoped absence … no retired spelling survives inside the declared radius outside the retirement kit` — all seven spellings over `packages` / `examples` / `skills` / `content` / `scripts` (the #15513 radius, this file added as a witness on each `heldBy` row in `scripts/cross-package-test-inputs.mjs`, and in `vitest.repo-tests.json`). Excluded: the pin itself, `packages/spec/src/migrations/` (the D3 entry), the git-ignored generated `content/docs/protocol-upgrade/` (the D3 registry's projection), `content/docs/releases/`, `.changeset/`, `CHANGELOG.md`. ## Ablation (one, on the pin file) Restored the deleted export in `packages/spec/src/conversions/index.ts` through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `630ed35f` → `2bec0047`), ran the pin file: **2 failed / 4 passed** — `the runtime barrel resolves without the retired constant…` and the tree-scoped absence (offenders `index.ts spells CONVERSION_CONFLICT_CODE` / `OS_METADATA_CONVERSION_CONFLICT`). The `export-origins` holder pin stayed green, as it must: it reads the committed artifact, which the source mutation does not regenerate (`check:export-origins` is that half's gate). Restored: blob == HEAD `630ed35f`, `git diff HEAD` empty; re-run **6 / 6 passed**. A first attempt was a no-op — its replacement contained its own anchor, so the tool refused (count 1 → 1) and ran nothing; it is not counted. The ablation also surfaced a real gap, fixed before push: the git-ignored upgrade-guide page `content/docs/protocol-upgrade/18.md`, once generated locally, prints the D3 entry verbatim, so the scan now excludes that projection like the registry it projects. ## Serial constraint (hypothesis 5) PR #23986 (#23903) also edits `protocol.ts`: its hunks sit at the `flowSaveReplaysRetired` docblock/body (~6612–6700) and the save door's comment and fallback lines (~23025–23115). This PR's save-door hunk is the `result.conflicts` branch below them (~23077–23094 at the base) plus three other doors. **Disjoint**: each lands on its own, and whichever lands second merges `main`. #23986's new test builds its canonicalizer stub with `conflicts: []` as a standalone arrow (no contextual type, so no excess-property check): it should still compile after this lands, as a dead key — whichever lands second re-runs that suite on the merge. #24010's three lines are in the package door, not touched here. ## Acceptance notes - File surface beyond the claim, comment/doc only: `packages/cli/src/commands/migrate/meta.ts`, `packages/cli/src/utils/data-migration-plugins.ts` (comments that justified the inert engine by the conflict guard) and `content/docs/deployment/cli.mdx` (the "flow whose rename the conflict guard refused" decline row and two sentences) — each described a failure mode this PR removes. `packages/metadata-protocol/src/stored-migration.ts` is the `StoredFlowCanonicalization` declaration the claim's "the contract's `conflicts` arrays" names. `scripts/cross-package-test-inputs.mjs` and `packages/spec/vitest.repo-tests.json` declare the new pin's radius. - Four tests that pinned the removed branches are deleted with them (`a refused rename fails the item…`, `a refused rename fails the save loudly…`, `the conflict path stays silent — the 409 IS the signal`, `fails the row loudly when the guard refuses a rename over a live name`): each constructed a conflict through a test double and asserted the branch this PR deletes. - The stored pass's `skipped` reason for a flow with no engine is reworded (it named the conflict guard); no test pins its prose. - ADR-0078's status note and the ADR-0087 addendum note are in a separate governed PR (`claude/issue-24001-adr-0078-note`). ## Verification Reported in full in the `os-dev-report` comment on #24001 (local gate union with exit codes, reconciled `--ran` 139/139; per-package `pnpm test`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WdZxJadrrYettvjrqiWeAV --- _Generated by [Claude Code](https://claude.ai/code/session_01WdZxJadrrYettvjrqiWeAV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e246f06 commit a65a59b

32 files changed

Lines changed: 486 additions & 436 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/metadata-protocol': minor
5+
---
6+
7+
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
8+
9+
Clause-②: yes (narrowing)
10+
11+
**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.
12+
13+
## FROM → TO
14+
15+
| you wrote | write instead |
16+
| --- | --- |
17+
| `import { CONVERSION_CONFLICT_CODE, type ConversionConflictNotice, type ConversionConflictDetail } from '@objectstack/spec'` | delete the import — no replacement |
18+
| `applyConversions(stack, { onConflict, reservedNodeTypes, … })` (likewise `applyConversionsToFlow`, `applyConversionsToStoredItem`, `collectConversionNotices`) | delete both options; keep `onNotice` / `onTodo` |
19+
| 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` |
20+
| 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` |
21+
| 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 |
22+
23+
**The one-line fix:** delete every reference; the branch it guarded was already dead, so deleting it changes no behaviour.
24+
25+
**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`.
26+
27+
**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.
28+
29+
## The retirement kit
30+
31+
- **`@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).
32+
- **`@objectstack/service-automation`:** `AutomationEngine.canonicalizeStoredFlow` no longer returns `conflicts`, and the engine no longer builds a live-name set for the conversion pass.
33+
- **`@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.
34+
- **The D3 entry `conversion-conflict-channel-removed`.** No D2 conversion and no tombstone: no metadata document ever carried any of it.
35+
- **No deprecation window**, per the project's startup-stage posture.
36+
37+
⚠️ **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.
38+
39+
<!-- adr-0087: registered conversion-conflict-channel-removed -->

‎content/docs/deployment/cli.mdx‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1610,7 +1610,6 @@ can produce, because both supply a live one.
16101610
| 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 |
16111611
| 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 |
16121612
| 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 |
1613-
| 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 |
16141613
| 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 |
16151614
| 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 |
16161615
| 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 |
@@ -1627,9 +1626,9 @@ an `--apply` run alike. The list changes no row, no count and no exit code. Wher
16271626
a node meant every branch, declare `mode: 'inclusive'` on it; declaring `mode`
16281627
either way takes it off the list.
16291628
1630-
**Flows are covered, and cost one extra plugin.** Flow-node conversions carry an
1631-
open-namespace conflict guard that has to consult the *live* executor registry
1632-
to tell a rename from a clobber, so this run boots the automation engine — in an
1629+
**Flows are covered, and cost one extra plugin.** A flow canonicalizes at the
1630+
automation engine, whose registration checks judge a plugin node against the
1631+
*live* node registry, so this run boots the automation engine — in an
16331632
inert mode that installs the node registry and then arms nothing: no flow
16341633
registered, no record trigger or scheduled job bound, no connector
16351634
materialized, no suspended run resumed. A migration process must not become a
@@ -1684,7 +1683,7 @@ It returns the same report the CLI renders, and takes the same posture:
16841683
`manage_metadata` capability — it rewrites every eligible row in the deployment,
16851684
not one item — and answers `403` otherwise. Flows need no extra setup on this
16861685
path: the server already holds a live automation engine, so the run resolves the
1687-
executor registry the conflict guard needs from the process it is running in.
1686+
node registry the registration checks need from the process it is running in.
16881687
16891688
#### `os migrate duplicates`
16901689

‎content/docs/references/api/contract.mdx‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **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) |
31+
| **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) |
3232
| **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) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **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);
193193
* `FILTER_TOKEN_UNRESOLVED`
194194
* `FIND_HOOK_RESULT_NOT_ARRAY`
195195
* `FIND_ONE_HOOK_RESULT_NOT_RECORD`
196-
* `FLOW_CONVERSION_CONFLICT`
197196
* `FLOW_DISABLED`
198197
* `FLOW_FAILED`
199198
* `FLOW_INPUT_SCHEMA_INVALID`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,6 @@ const result = ErrorCode.parse(data);
360360
* `FILTER_TOKEN_UNRESOLVED`
361361
* `FIND_HOOK_RESULT_NOT_ARRAY`
362362
* `FIND_ONE_HOOK_RESULT_NOT_RECORD`
363-
* `FLOW_CONVERSION_CONFLICT`
364363
* `FLOW_DISABLED`
365364
* `FLOW_FAILED`
366365
* `FLOW_INPUT_SCHEMA_INVALID`

‎packages/cli/src/commands/migrate/meta.ts‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1477,8 +1477,8 @@ export default class MigrateMeta extends Command {
14771477
try {
14781478
// `PlatformObjectsPlugin` for `sys_metadata` and its history/audit
14791479
// siblings, plus the automation engine in INERT mode so `flow` rows are
1480-
// covered too (#4454) — flow-node conversions need its executor registry
1481-
// for the conflict guard, and `armRuntime: false` means taking it arms
1480+
// covered too (#4454) — a flow canonicalizes at the engine, whose
1481+
// registration checks need its node registry, and `armRuntime: false` means taking it arms
14821482
// nothing. No storage adapter: unlike the file migration, nothing here
14831483
// reads bytes.
14841484
//
@@ -1520,8 +1520,8 @@ export default class MigrateMeta extends Command {
15201520
await import('@objectstack/metadata-protocol');
15211521

15221522
// No `canonicalizeFlow` is threaded from here. The automation engine
1523-
// canonicalizes `flow` rows — it holds the executor registry ADR-0078's
1524-
// conflict guard needs (#4454) — and the protocol resolves it from the
1523+
// canonicalizes `flow` rows — it holds the node registry its
1524+
// registration checks need (#4454) — and the protocol resolves it from the
15251525
// kernel's service table itself (#4498), which is the same table the
15261526
// inert engine this command boots registers into. Passing it again would
15271527
// be a second route to one capability, and the two would drift.
@@ -1591,13 +1591,14 @@ export default class MigrateMeta extends Command {
15911591
);
15921592
} else if (report.failed > 0) {
15931593
// [#23391] Only what holds for every failed kind, then what does not
1594-
// work. The stored pass marks a row `failed` four ways: a body that is
1595-
// not valid JSON, a flow that does not canonicalize, a flow rename
1596-
// refused over a live name, and (on --apply) a rewrite the store
1597-
// refused. It writes none of them, so each reads as it did before the
1598-
// run. This line used to add "they keep reading canonically through
1599-
// the chain, so nothing is broken", which is false for two of the
1600-
// four. A body that is not valid JSON loads at no read. A flow that
1594+
// work. The stored pass marks a row `failed` three ways: a body that
1595+
// is not valid JSON, a flow that does not canonicalize, and (on
1596+
// --apply) a rewrite the store refused (a fourth, a flow rename
1597+
// refused over a live name, left with the conversion-conflict channel
1598+
// in protocol 18, #24001). It writes none of them, so each reads as it
1599+
// did before the run. This line used to add "they keep reading
1600+
// canonically through the chain, so nothing is broken", which is
1601+
// false for two of them. A body that is not valid JSON loads at no read. A flow that
16011602
// does not canonicalize fails the same `canonicalizeFlow` parse inside
16021603
// `AutomationEngine.registerFlow`, so no boot step registers it.
16031604
// The report names no kind, so the flow sentence is gated on a failed

‎packages/cli/src/utils/data-migration-plugins.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,14 @@ export async function buildDataMigrationPlugins(
6868
}
6969
if (opts.automation === true) {
7070
// `os migrate meta --stored` needs the automation ENGINE, never the
71-
// automation RUNTIME (#4454). Flow-node conversions carry ADR-0078's
72-
// open-namespace conflict guard, which consults the live executor registry
73-
// to tell a rename from a clobber — and only this plugin has that registry.
71+
// automation RUNTIME (#4454). A flow canonicalizes at the engine, whose
72+
// registration checks judge a plugin node against the live node registry
73+
// — and only this plugin has that registry.
7474
//
7575
// `armRuntime: false` is what makes taking it safe: the engine and the full
7676
// node registry come up (built-ins plus whatever `automation:ready`
77-
// contributes, because a PARTIAL registry would make the guard rewrite over
78-
// a live custom node type instead of refusing), and then nothing is armed —
77+
// contributes, because a PARTIAL registry would make those checks judge a
78+
// plugin node against no descriptor), and then nothing is armed —
7979
// no flow registered, no record trigger or scheduled job bound, no
8080
// declarative connector materialized, no suspended run resumed. A migration
8181
// process must not become a second server.

‎packages/metadata-protocol/src/protocol.flow-canonicalizer.test.ts‎

Lines changed: 4 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
* #4498 — the flow-skip becomes ONE seam, and `duplicatePackage` stops minting
55
* pre-protocol flow rows.
66
*
7-
* `convertStoredItem` returns `flow` bodies untouched, because flow-node
8-
* conversions carry ADR-0078's open-namespace conflict guard and that needs the
9-
* automation engine's live executor registry. #4454 built the capability
7+
* `convertStoredItem` returns `flow` bodies untouched, because a flow
8+
* canonicalizes at the automation engine, whose registration checks need its
9+
* live node registry. #4454 built the capability
1010
* (`AutomationEngine.canonicalizeStoredFlow`) and handed it to
1111
* `migrateStoredMetadata` as an explicit hook, because the CLI has to boot an
1212
* engine of its own to hold one.
@@ -38,7 +38,7 @@ const flowBody = (config: Record<string, unknown>) => ({
3838
const canonicalizeStoredFlow = (_name: string, body: any) => {
3939
const node = body?.nodes?.[0];
4040
if (!node || !('filters' in (node.config ?? {}))) {
41-
return { storable: body, notices: [], conflicts: [] };
41+
return { storable: body, notices: [] };
4242
}
4343
const { filters, ...rest } = node.config;
4444
return {
@@ -51,7 +51,6 @@ const canonicalizeStoredFlow = (_name: string, body: any) => {
5151
path: 'flows[0].nodes[0].config',
5252
message: 'filters → filter',
5353
}],
54-
conflicts: [],
5554
};
5655
};
5756

@@ -155,7 +154,6 @@ describe('migrateStoredMetadata resolves the engine itself (#4498)', () => {
155154
const explicit = vi.fn((_n: string, body: any) => ({
156155
storable: { ...body, label: 'From the explicit hook' },
157156
notices: [],
158-
conflicts: [],
159157
}));
160158
const registryHook = vi.fn(canonicalizeStoredFlow);
161159
const { protocol, saveMetaItem } = makeProtocol(
@@ -250,33 +248,6 @@ describe('duplicatePackage canonicalizes flow rows (#4498)', () => {
250248
expect(flow.item.nodes[0].config.objectName).toBe('iojn2_repair_ticket');
251249
});
252250

253-
it('a refused rename fails the item and names the token — never a silent legacy copy', async () => {
254-
const conflicting = () => ({
255-
storable: {},
256-
notices: [],
257-
conflicts: [{
258-
conversionId: 'flow-node-type-open-namespace',
259-
token: 'http_request',
260-
path: 'flows[0].nodes[0].type',
261-
message: 'a custom executor owns this node type here',
262-
}],
263-
});
264-
const { protocol, saveMetaItem } = makeProtocol(
265-
[legacyFlowRow],
266-
new Map([['automation', { canonicalizeStoredFlow: conflicting }]]),
267-
);
268-
269-
const res = await duplicate(protocol);
270-
271-
expect(res.failedCount).toBe(1);
272-
expect(res.copiedCount).toBe(0);
273-
expect(res.failed[0].error).toContain('http_request');
274-
expect(res.failed[0].error).toContain('live name in this environment');
275-
// The point of failing: copying the un-renamed body would mint exactly
276-
// the row this fix exists to prevent.
277-
expect(saveMetaItem).not.toHaveBeenCalled();
278-
});
279-
280251
it('a flow that cannot canonicalize fails the item with the reason', async () => {
281252
const throwing = () => { throw new Error("Unrecognized key: '_uiPosition'"); };
282253
const { protocol, saveMetaItem } = makeProtocol(

0 commit comments

Comments
 (0)