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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/24001-conversion-conflict-channel-retired.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: registered conversion-conflict-channel-removed -->
9 changes: 4 additions & 5 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -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`

Expand Down
3 changes: 1 addition & 2 deletions content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -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`
Expand Down
1 change: 0 additions & 1 deletion content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
23 changes: 12 additions & 11 deletions packages/cli/src/commands/migrate/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions packages/cli/src/utils/data-migration-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 4 additions & 33 deletions packages/metadata-protocol/src/protocol.flow-canonicalizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -38,7 +38,7 @@ const flowBody = (config: Record<string, unknown>) => ({
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 {
Expand All @@ -51,7 +51,6 @@ const canonicalizeStoredFlow = (_name: string, body: any) => {
path: 'flows[0].nodes[0].config',
message: 'filters → filter',
}],
conflicts: [],
};
};

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading