Skip to content

Commit 6e18016

Browse files
feat(metadata-protocol)!: a flow re-save that newly introduces a retired spelling is refused like a new flow's (#23986)
Fixes #23903 Clause-②: yes (narrowing) Ruling-ref: 6216220018 (letter B′ (i) yes · C (ii) no). Claim: 6218148190. Session: `session_01TQLy3Yq6vF4SDpTXdr3Uwi`. Extends the `flowSaveReplaysRetired` predicate PR #23902 added (`cf071687f1`). **Governed (Tier H):** the diff touches `docs/adr/0087-metadata-protocol-upgrade-contract.md` (one dated note), so this PR stays draft until an authorised human approves it. Contract review at `CONTRACT_REVIEW_TIER` (ruling item 5) belongs to the seat. ## What changes `flowSaveReplaysRetired` (`packages/metadata-protocol/src/protocol.ts`), the predicate `saveMetaItem`'s flow branch asks before it canonicalizes, now compares conversion hits when a stored row exists: - **A retired hit in the request that no stored row carries means authored input.** The predicate answers `false`, so the engine's canonicalizer and the door's own fallback verdict both run without the retired entries, exactly as for a new flow. The retired spelling meets the flow parse's refusal: `422 INVALID_METADATA`, an issue located at the key, the text `os validate` prints. Draft and publish mode alike. - **Every retired hit in the request carried by a stored row means the full chain**, as PR #23902 has it. The served legacy body re-saves and is healed. - **Unchanged:** a flow with no stored row answers `false`; the server-stated writes (`source` set, `writeFace: 'package-duplicate'` and `'package-unmanaged-import'`) answer `true` before any read; the engine's rehydration and the read path are untouched. Ruling item 3 (C) is not taken: `convertStoredItem` still skips `flow`. The whole body is judged in the authoring posture (ruling item 2: "judged with `includeRetired: false`"), so the 422 also lists the retired sites the stored row already carries. Correcting the added site alone is enough to save; the full chain then heals the rest. **A hit** is one firing of a retired entry: its `conversionId`, the notice's `from` (the spelling it rewrote), and its site, which is the notice's `path` with every array element on it that carries an `id` named `[#id]` instead of by its index. "Retired" is `retiredFromLoadPath` on the entry in `ALL_CONVERSIONS`. **The rows that may carry a hit** are PR #23902's baseline (the row the save overwrites, `repo.headAt`), and for a draft save also the active row the draft overlays. PR #23902 reads that row only when no draft row exists; this PR also reads it when the draft row does not carry a request hit. See "Readings the approval decides". The edit stays inside the claim's surface: the predicate, two comment paragraphs in the flow branch that calls it, and one import (`ALL_CONVERSIONS` from `@objectstack/spec`). `packages/services/service-automation/src/engine.ts` is not touched. ## The hit report, measured (the dispatch's assumptions 1 to 5) Tree: base `6d1660dc3`, `origin/main` `0b6956490` merged in, head `c8e2d674a`. 1. **Both hosts report hits, identically.** The engine's `canonicalizeStoredFlow(name, body, { includeRetired: true })` returns `notices` with keys `code, conversionId, surface, toMajor, retiresIn, from, to, path, message`; the spec's `applyConversionsToFlow(body, { includeRetired: true, onNotice })` emits the same notices. Measured on one body with four retired sites (a `wait` node's loose `config.duration`, two `config.filters` of which one sits inside a loop region, and `errorHandling.retryDelayMs`): the same `conversionId`, `from` and `path` on both, for example `flow-node-crud-filter-alias`, `filters`, `flows[0].nodes[3].config.body.nodes[0].config.filter`. Neither carries a retired flag; `ALL_CONVERSIONS` answers it by id. The predicate scans with the spec's pass on BOTH hosts (the full chain minus `FLOW_FALLBACK_VERDICT_NOT_REPLAYED`, which equals the engine's `CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION`), so its answer cannot depend on the host (#21982), and a canonicalizer throw cannot hide a hit. 2. **The stored row is already read.** `repo.headAt(...).head.body` is the stored body (`rowToItem`, `storedRowBody`); no new repository call. The one added read is the active row for a draft save whose draft row exists and does not carry a request hit: the address `headAt(ref, { state: 'active', packageId: target.packageId, member })`, the one PR #23902 reads for its fallback. 3. **Location.** The notice `path` is index-based (`flows[0].nodes[2].config.filter`). An entry id alone cannot see a node added with the spelling another node already carries (ablation leg 2: 9 protocol pins and 3 REST pins red), and a raw index path reads a node inserted in front of a carried one as new (leg 3: 2 pins red). The finest identity the report carries is entry, `from` and `path`; the predicate resolves each indexed element on the path to its `id` on the converted body. No engine change, no invented location. 4. **Pin spellings.** No node TYPE is retired at this base: the only type rename in the chain, `flow-node-http-callout-rename`, is live, and it is the one entry PR #23954 (F5-http) retires (its registry diff is one hunk, that entry). So the pins use two node-level spellings F5-http does not move: a `delete_record` node's `config.filters` (`flow-node-crud-filter-alias`, refused naming `filter`) and a `wait` node's loose `config.duration` beside its block (`flow-node-wait-event-config-lift`, refused naming `waitEventConfig.timerDuration`, the spelling the REST file already used). When F5-http lands, its three names fall under this predicate with no change here, because it reads `retiredFromLoadPath`. 5. **Other doors.** The server-stated writes still answer before any read. The runtime flow-definition door (`packages/runtime/src/domains/automation.ts`) saves an authored body with no `source` or `writeFace`, so it meets the same predicate, and it restores the engine registration on a refusal. The organization-ownership ceremony's conflict re-save sends the body the row was just written with, so its hits are carried by construction. `publishMetaItem` promotes a draft and never reaches the predicate. No metadata-write MCP tool is registered in this repository (PR #23902's reading). ## Readings the approval decides - **For a draft save, the active row carries hits even when a draft row exists.** Studio's flow designer keeps its buffer after a draft save: objectui `63d00da`, `DraftVersionGuard.save` holds only the returned version, and the buffer is replaced only on a conflict reload. On an engine host the first draft save heals the draft row, so judged against the draft row alone, Studio's SECOND autosave of a legacy flow would be refused, against ruling #23775's "a legacy row stays editable in Studio". The pin "Studio's draft autosaves" holds this; ablation leg 4 turns it red on the engine host. - **A publish-mode client that re-sends a stale legacy body after a heal is refused on an engine host and accepted on a host with no automation service.** Measured with a one-off probe (not committed): two publish saves of the same legacy body over a seeded legacy row. Engine host: first `saved`, row stored as `filter`, second `422 INVALID_METADATA` at `nodes.0.config.filters`. No automation service: first `saved`, row stored as `filters`, second `saved`. The predicate gives one answer for one request and one stored row; the two hosts store different rows (the engine heals at rest, the fallback stores the raw body), and the ruling's letter reads the stored row. Studio publishes by promotion and reloads, so it never meets this. Raised as an open question in the report on the card. ## Pins (ruling item 4) | Pin | Protocol door, both hosts (`protocol.save-flow-canonicalization.test.ts`) | REST door (`meta-flow-save-retired-spelling.test.ts`, real stack, no automation service) | |:--|:--|:--| | Adding a node with a retired spelling the stored row does not carry: 422 `INVALID_METADATA` at the new node's key, naming the new key; the stored row untouched | publish (`nodes.1.config.duration`, `waitEventConfig.timerDuration`) | | | The stored row carries the SAME spelling on another node; the added node's is still refused (the location pin) | publish (`nodes.1.config.filters`, `filter`) | publish (issues `nodes.1.config.duration` and `nodes.2.config.duration`, the new one naming `waitEventConfig.timerDuration`) | | A retired spelling newly written on a node the stored row holds canonical | publish (`nodes.0.config.filters`) | | | Draft mode with no draft row (the active row is the baseline) | refused | refused, nothing stored | | Draft mode over a stored draft row | refused, draft untouched | refused, draft untouched | | Re-saving the served legacy body unchanged | saves; engine host stores it healed, the other stores it as written | `GET` then `PUT` of the item as served: 200 | | A canonical node added in front of a carried one | saves (the site is the id) | | | Studio's repeated draft autosaves of a legacy flow after a healing first save; an added node still refused | both hosts | | | The canonicalizer is asked `includeRetired: false` on an introduced hit, `true` on a carried one | engine stub | | | A new flow with a retired spelling: 422 (unchanged from B) | PR #23902's pins, unchanged, green | PR #23902's pins, green | The engine host is the spec's own pass behind the canonicalizer's option, so a carried re-save is stored canonical as `AutomationEngine.canonicalizeStoredFlow` stores it. With the window closed it hands the retired spelling back and the gate refuses it; the real engine's strict parse throws first, and the door's fallback verdict refuses the same body with the same issues. ## Ablation (mutation through `scripts/ablation-replace.mjs`, wrap mode; restore proven by blob hash equal to HEAD and an empty `git diff HEAD`) | Leg | Mutation | Red | Green after restore | |:--|:--|:--|:--| | 1 comparison | the request's hits forced empty: any stored row means the full chain (PR #23902's predicate) | protocol 13 failed / 38 passed (both hosts' five introduce pins, the Studio pin's third save, the option pin); REST 3 failed / 7 passed (the three refusals); dist rebuilt, `ablation-dist-preflight` marker present, exit 0 | yes | | 2 location | a hit is entry and spelling only, the site dropped | protocol 9 failed / 42 passed; REST 3 failed / 7 passed; dist rebuilt, marker present, exit 0 | yes | | 3 node id | the raw index path, ids not resolved | protocol 2 failed / 49 passed (the node-in-front pin, both hosts) | yes | | 4 draft evidence | the active row not read once a draft row exists | protocol 1 failed / 50 passed (Studio's second autosave, engine host) | yes | - Leg 3's first attempt was a no-op the tool refused: its replacement was a substring of its anchor, so the replacement count stayed 1 to 1 and nothing ran. It was re-run with a non-overlapping replacement, and run again after `f40fd1543` changed the line it mutates: 2 failed both times. - REST leg 2's first attempt produced no reading: the mutated build failed its declaration step (TS6133, `siteOf` unused). It was re-run with a replacement that keeps the locals used. - Legs 1, 2 and 4 were read on `protocol.ts` blob `cd5449d4e`; `f40fd1543` touches none of their anchors. - Restore: `metadata-protocol` rebuilt from HEAD and `ablation-dist-preflight --absent` exit 0 for both markers (working tree clean); REST 10/10, protocol 51/51. ## Tests - `@objectstack/metadata-protocol`, full suite at `c8e2d674a`: 242 files passed, 3 skipped; 20629 tests passed, 19 skipped (`VERDICT command-exit 0`). An earlier full run at `0cef5ed02` had one failure: `protocol.bracketed-refusal-opener-absence.test.ts` read the new line's template literal opening with an interpolated bracket as a refusal tag. `f40fd1543` spells that branch with concatenation; the two files then passed 58/58. - `protocol.save-flow-canonicalization.test.ts`: 51/51 (34 before, 17 new). - `@objectstack/rest` `src/meta-flow-save-retired-spelling.test.ts` at `c8e2d674a`, `metadata-protocol` rebuilt: 10/10 (6 before, 4 new). - `typecheck`: `metadata-protocol` exit 0 (the test file is in its tsc program, `--listFiles` count 1); `rest` exit 0, its `check:test-typecheck` compiling the test layer under `tsconfig.test.json`. - ESLint on the three touched source and test files: exit 0. Repo-wide `pnpm lint` and `test:repo` belong to CI. ## Gates - `dispatch-gates.mjs --commands` at `c8e2d674a` (merge base `0b6956490`): 74 commands, byte-identical to the PM's list at `6d1660dc3`. All 74 ran, every exit captured before any pipe: 74 exit 0. `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET, unbuilt workspace) and was re-run once `check:type-check-debt` had built the closure: exit 0. `--ran` reconciliation: 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN, a derived zero. - The three artifact-roster rows whose roster sits in a directory this diff touches: `check-changeset-fixed` exit 0, `check:authz-resolver` exit 0, `check:error-code-casing` exit 0. - The 11 declared wide-population families, the type-check lanes and the five path-scheduled CI jobs are CI's; the PR's check-runs are the reading. ## Acceptance notes - The refusal lists the retired sites the stored row carries beside the introduced one: the ruling judges the whole body with `includeRetired: false`. The changeset says that correcting the added site alone is enough. - A node whose `id` is renamed while it keeps a retired spelling reads as a new node and is refused. - `dispatch-gates.mjs` prints that a card which only narrows is `Clause-②: no`; AGENTS.md's grammar and the ruling say `yes (narrowing)`. This PR follows the ruling, as PR #23902 did. Noted, not filed. - MCP door: no pin constructible in this repository (PR #23902's reading stands). ## 维护者速读(草稿) **改了什么** 通过元数据接口(Studio 保存、REST `/meta`、运行时的 flow 接口)再次保存一个已存在的 flow 时,如果请求里出现了库里原行没有的旧拼写,比如往旧 flow 里新加一个节点仍用旧写法,或者在原来已是新写法的节点上改回旧写法,现在会像新建 flow 一样被拒收:返回 422,指出具体位置和应改成的新写法,与 `os validate` 说的一样。原行本来就带的旧拼写照旧在保存时自愈,原样保存读到的旧 flow 不受影响。 **为什么改** 执行裁决 6216220018(B′):再存时新引入的旧拼写是作者输入,应与新建 flow 一样拒收。#23902 只拦新建 flow,往旧 flow 里新加的旧写法仍被悄悄改写,写入方(尤其是 AI)学不到正确写法。 **风险与代价(含回滚)** - 拒收时会把原行已有的旧拼写也一并列出(裁决要求整体按作者口径判定);只改新加的那一处就能保存。 - 本 PR 的一个解读:草稿保存时,草稿下面的已发布行也算"原行"。否则在带自动化服务的主机上,Studio 第一次自动保存把草稿行治愈后,第二次自动保存会被误拒。请审批时确认。 - 已知差异:API 客户端(不是 Studio)在一次保存治愈原行之后,又用自己手里的旧副本以发布模式再存:带自动化服务的主机拒收(422,附改法),不带自动化服务的主机接受(它存的是原文,原行仍带旧拼写)。Studio 走"草稿保存 + 发布",发布后重新加载,碰不到这种情况。 - 回滚:还原本 PR 即回到 #23902 的行为(有原行就整条转换链自愈),不涉及数据迁移。 **席位意见** **你要做的** 审阅并批准(Tier H:ADR-0087 加了一条日期注记)。请顺带确认上面两条解读:草稿保存把已发布行算作原行;发布模式下旧副本在两类主机上的差异按裁决字面处理。 --- _Generated by [Claude Code](https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent be076da commit 6e18016

5 files changed

Lines changed: 379 additions & 7 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
feat(metadata-protocol)!: a flow re-save that newly introduces a retired spelling is refused like a new flow's
6+
7+
Clause-②: yes (narrowing)
8+
9+
**BREAKING (API save door), the re-save case:** a flow saved through `saveMetaItem` (`PUT /api/v1/meta/flow/:name`, the runtime dispatcher, and every caller of the protocol) over a stored row is now judged as authored input when its body introduces a spelling retired from the authoring funnel that the stored row does not carry. Adding a node with an old spelling to a legacy flow, or writing one on a node the stored row holds in the canonical spelling, is no longer rewritten and stored: the save meets the flow parse's refusal, `422 INVALID_METADATA`, with an issue located at the key and the same prescription `os validate` and `os build` print, exactly as a new flow's does. Draft and publish mode alike. The whole body is judged that way, so the refusal also lists the retired spellings the stored row already carries; fixing the added one alone is enough to save. Before, a re-save healed every retired spelling in the body, the new one included, and said nothing.
10+
11+
What counts as introduced: a retired conversion that fires on the request at a site where it does not fire on the stored row. A site is the key's path, with each node (and edge) named by its `id` rather than its position, so moving, adding or removing nodes does not change it. The stored rows compared are the row the save overwrites, and for a draft save the active row the draft overlays as well, so Studio's repeated draft saves of a legacy flow keep saving.
12+
13+
Not changed:
14+
15+
- A re-save whose retired spellings the stored row all carries, such as the served legacy body saved back unchanged, keeps replaying the full chain and is healed as before.
16+
- A new flow with a retired spelling is refused, as it was.
17+
- The server's own writes of bodies at rest keep the full chain: the stored-metadata migration (`os migrate meta --stored`), a package duplicate and an unmanaged package import.
18+
- The automation engine's flow rehydration and the read path, which still serves a stored flow verbatim.
19+
20+
Migration, for a client that edits flows through the API: write the spellings below on any node or key you add or change. The legacy spellings already on the stored row may stay until the row is healed.
21+
22+
- `wait` node loose `config.duration` / `config.timerDuration` → `waitEventConfig.timerDuration`; `config.signal` / `config.signalName` → `waitEventConfig.signalName`; `config.eventType` → `waitEventConfig.eventType`. On the block itself, `timeoutMs` → `timerDuration` and `onTimeout` is removed.
23+
- `connector_action` node loose `config.connectorId` / `config.actionId` / `config.input` → its `connectorConfig` block.
24+
- CRUD node `config.filters` → `config.filter`; `config.object` → `config.objectName`.
25+
- `map` and `subflow` node `config.flow` → `config.flowName`.
26+
- `notify` node `config.to` → `recipients`, `subject` → `title`, `body` → `message`, `url` → `actionUrl`, `source: { object, id }` → `sourceObject` / `sourceId`.
27+
- `script` node `config.functionName` → `function`, `input` → `inputs`; `actionType` / `template` / `recipients` / `variables` / `script` are removed.
28+
- `errorHandling.retryDelayMs` and a `try_catch` node's `retry.retryDelayMs` → `backoffMs`; the flow keys `active` / `template`, a node's `outputSchema` and `errorHandling.fallbackNodeId` are removed.
29+
30+
Each refusal names the key to write.
31+
32+
<!-- adr-0087: not-required (already-registered flow-node-crud-filter-alias, flow-node-crud-object-alias, flow-node-notify-config-aliases, flow-node-wait-event-config-lift, flow-node-map-flow-alias, flow-node-subflow-flow-alias, flow-node-connector-config-lift, flow-node-script-config-aliases, flow-inert-keys-removed, flow-node-wait-timeout-keys-removed, flow-node-script-branch-keys-removed, retry-policy-converged) these D2 entries already record every spelling the save door now refuses when a re-save introduces it; this change only stops the door replaying them for a re-save's new authored input, and their own migrations are the ones above -->

‎docs/adr/0087-metadata-protocol-upgrade-contract.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,8 @@ diverged (#3903). This addendum extends the contract to data at rest:
573573

574574
> **2026-10-16 note** (#23775, ruling B) — the flow save door draws this line by one predicate: `saveMetaItem` canonicalizes a flow with **no stored row at the save's address** (the row the save overwrites, or for a draft with none yet the active row it overlays) **without** retired entries, so a retired spelling meets the parse refusal (`422 INVALID_METADATA`, located, the text `os validate` prints), while a re-save of a stored row — and the server's own writes of bodies at rest — keep the full chain.
575575
576+
> **2026-10-16 note** (#23903, ruling B′) — a re-save is judged as authored input too when its request fires a retired entry the stored row does not: a retired hit (the entry, the spelling it rewrote, and its site, with a node's `id` standing for its index) that no stored row of the save's baseline carries (for a draft, the active row it overlays as well as its own) sends the whole body through the chain **without** retired entries and so to the same refusal, while a re-save whose retired hits the stored row all carries keeps the full chain.
577+
576578
## Addendum (2026-08-01) — the stored chain gets a finish line (#4327)
577579

578580
The addendum above makes a legacy row read canonical *forever*, which is the

‎packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts‎

Lines changed: 187 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
assertEngineDeleteDispatch,
3838
assertEngineUpdateDispatch, assertEngineFindOnePredicate,
3939
} from '@objectstack/metadata-core';
40-
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
40+
import { ObjectStackDefinitionSchema, applyConversionsToFlow, normalizeStackInput } from '@objectstack/spec';
4141
import { ObjectStackProtocolImplementation } from './protocol.js';
4242

4343
/** A flow body that passes `saveMetaItem`'s schema gate. */
@@ -618,3 +618,189 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => {
618618
}
619619
});
620620
});
621+
622+
/**
623+
* [#23903, ruling B′] A re-save whose body NEWLY INTRODUCES a retired spelling
624+
* is authored input, refused like a new flow's: a retired entry that fires on
625+
* the request at a site where no stored row carries it ⇒ the save is judged
626+
* without the retired entries and meets the parse refusal (`422
627+
* INVALID_METADATA`, located, the key the spelling moved to). A request whose
628+
* retired hits the stored row all carries keeps the full chain.
629+
*
630+
* The spellings are two of the seven #23487 retired, neither moved by F5-http:
631+
* a `delete_record` node's `config.filters` (`flow-node-crud-filter-alias`,
632+
* refused at the key, naming `filter`) and a `wait` node's loose
633+
* `config.duration` beside its block (`flow-node-wait-event-config-lift`,
634+
* refused at the key, naming `waitEventConfig.timerDuration`). No node TYPE is
635+
* retired at this base: the one type rename, the callout rename, is what
636+
* F5-http retires.
637+
*
638+
* Pinned on both hosts. The engine is the spec's own pass behind the
639+
* canonicalizer's option (`includeRetired`), so a carried re-save is healed and
640+
* stored canonical, as `AutomationEngine.canonicalizeStoredFlow` stores it. With
641+
* the window closed it hands the retired spelling back and the gate refuses it;
642+
* the real engine's strict parse throws first, and the door's fallback verdict
643+
* refuses the same body with the same issues.
644+
*/
645+
describe('[#23903] a re-save that newly introduces a retired spelling is judged as authored input', () => {
646+
const specCanonicalizer = (_name: string, body: unknown, options: { includeRetired?: boolean } = {}) => {
647+
const notices: unknown[] = [];
648+
const storable = applyConversionsToFlow(body, {
649+
includeRetired: options.includeRetired ?? true,
650+
excludeConversionIds: ['flow-decision-mode-inclusive-explicit'],
651+
onNotice: (n) => notices.push(n),
652+
});
653+
return { storable, notices, conflicts: [] };
654+
};
655+
const hosts = {
656+
'an automation service': () => new Map([['automation', { canonicalizeStoredFlow: specCanonicalizer }]]),
657+
'no automation service': () => new Map(),
658+
} as const;
659+
660+
const purge = (id: string, config: Record<string, unknown>) =>
661+
({ id, type: 'delete_record', label: `Purge ${id}`, config });
662+
const hold = (id: string) => ({
663+
id, type: 'wait', label: `Hold ${id}`,
664+
waitEventConfig: { eventType: 'timer', timerDuration: 'PT5M' },
665+
config: { duration: 'PT9M' },
666+
});
667+
const flowOf = (nodes: unknown[], label = 'Purge Stale Leads') =>
668+
({ name: 'purge_flow', label, type: 'autolaunched', status: 'active', nodes, edges: [] });
669+
const legacyNode = (id: string) => purge(id, { objectName: 'lead', filters: { status: 'stale' } });
670+
const canonicalNode = (id: string) => purge(id, { objectName: 'lead', filter: { status: 'stale' } });
671+
672+
const refusalOf = async (promise: Promise<unknown>) => {
673+
try {
674+
await promise;
675+
} catch (err) {
676+
return err as { code?: unknown; status?: unknown; issues?: Array<{ path: string; message: string }> };
677+
}
678+
throw new Error('expected the save to be refused');
679+
};
680+
/** The ADR-0112 `code` and status, and the issue at `path`, which must name `key`. */
681+
const expectRefusedAt = (
682+
err: { code?: unknown; status?: unknown; issues?: Array<{ path: string; message: string }> },
683+
path: string,
684+
key: string,
685+
) => {
686+
expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
687+
const issue = (err.issues ?? []).find((i) => i.path === path);
688+
expect(issue, JSON.stringify(err.issues)).toBeDefined();
689+
expect(issue!.message).toContain(key);
690+
};
691+
692+
for (const [host, services] of Object.entries(hosts)) {
693+
it(`${host}: adding a node with a retired spelling the stored row does not carry is refused 422 at the new node's key, naming the new key; the stored row is untouched`, async () => {
694+
const { protocol, rows, engine } = makeProtocol(services());
695+
const stored = flowOf([legacyNode('n1')]);
696+
await seedStoredFlow(engine, stored);
697+
698+
const err = await refusalOf(save(protocol, flowOf([legacyNode('n1'), hold('h1')])));
699+
700+
expectRefusedAt(err, 'nodes.1.config.duration', '`waitEventConfig.timerDuration`');
701+
expect(storedFlow(rows)!.body).toEqual(stored);
702+
});
703+
704+
it(`${host}: the stored row carrying the SAME retired spelling on another node does not carry the added node's: refused at the new node's key`, async () => {
705+
const { protocol, rows, engine } = makeProtocol(services());
706+
const stored = flowOf([legacyNode('n1')]);
707+
await seedStoredFlow(engine, stored);
708+
709+
const err = await refusalOf(save(protocol, flowOf([legacyNode('n1'), legacyNode('n2')])));
710+
711+
expectRefusedAt(err, 'nodes.1.config.filters', '`filter`');
712+
expect(storedFlow(rows)!.body).toEqual(stored);
713+
});
714+
715+
it(`${host}: a retired spelling newly written on a node the stored row already has is refused at that key`, async () => {
716+
const { protocol, rows, engine } = makeProtocol(services());
717+
const stored = flowOf([canonicalNode('n1'), legacyNode('n2')]);
718+
await seedStoredFlow(engine, stored);
719+
720+
const err = await refusalOf(save(protocol, flowOf([legacyNode('n1'), legacyNode('n2')])));
721+
722+
expectRefusedAt(err, 'nodes.0.config.filters', '`filter`');
723+
expect(storedFlow(rows)!.body).toEqual(stored);
724+
});
725+
726+
it(`${host}: draft mode with no draft row yet — the active row it overlays is the baseline — refuses the added node`, async () => {
727+
const { protocol, rows, engine } = makeProtocol(services());
728+
await seedStoredFlow(engine, flowOf([legacyNode('n1')]), 'active');
729+
730+
const err = await refusalOf(save(protocol, flowOf([legacyNode('n1'), legacyNode('n2')]), { mode: 'draft' }));
731+
732+
expectRefusedAt(err, 'nodes.1.config.filters', '`filter`');
733+
expect(storedFlow(rows, 'draft')).toBeUndefined();
734+
});
735+
736+
it(`${host}: draft mode over a stored draft row refuses the added node`, async () => {
737+
const { protocol, rows, engine } = makeProtocol(services());
738+
const draft = flowOf([legacyNode('n1')], 'Purge Stale Leads (draft)');
739+
await seedStoredFlow(engine, draft, 'draft');
740+
741+
const err = await refusalOf(save(protocol, flowOf([legacyNode('n1'), hold('h1')]), { mode: 'draft' }));
742+
743+
expectRefusedAt(err, 'nodes.1.config.duration', '`waitEventConfig.timerDuration`');
744+
expect(storedFlow(rows, 'draft')!.body).toEqual(draft);
745+
});
746+
747+
it(`${host}: re-saving the served legacy body unchanged saves, healed as before`, async () => {
748+
const { protocol, rows, engine } = makeProtocol(services());
749+
await seedStoredFlow(engine, flowOf([legacyNode('n1'), hold('h1')]));
750+
const served = (await protocol.getMetaItem({ type: 'flow', name: 'purge_flow' }) as any).item;
751+
752+
const result = await save(protocol, served);
753+
754+
expect(result.success).toBe(true);
755+
const body = storedFlow(rows)!.body;
756+
if (host === 'an automation service') {
757+
expect(body.nodes[0].config).toEqual({ objectName: 'lead', filter: { status: 'stale' } });
758+
expect(body.nodes[1].config ?? {}).not.toHaveProperty('duration');
759+
} else {
760+
// No canonicalizer here: judged converted, stored as written
761+
// (the read's own `_diagnostics` envelope is never stored).
762+
const { _diagnostics: _served, ...asWritten } = served;
763+
expect(body).toEqual(asWritten);
764+
}
765+
});
766+
767+
it(`${host}: a canonical node added in front of a carried one keeps it carried — the site is the node's id, not its index`, async () => {
768+
const { protocol, rows, engine } = makeProtocol(services());
769+
await seedStoredFlow(engine, flowOf([legacyNode('n1')]));
770+
771+
const result = await save(protocol, flowOf([canonicalNode('n0'), legacyNode('n1')]));
772+
773+
expect(result.success).toBe(true);
774+
expect(storedFlow(rows)!.body.nodes.map((n: { id: string }) => n.id)).toEqual(['n0', 'n1']);
775+
});
776+
777+
it(`${host}: Studio's draft autosaves of a legacy flow keep saving after the first one healed the draft row — the active row still carries the spelling — and an added node is still refused`, async () => {
778+
const { protocol, rows, engine } = makeProtocol(services());
779+
await seedStoredFlow(engine, flowOf([legacyNode('n1')]), 'active');
780+
781+
// Studio's buffer is the served legacy body; a save does not replace it.
782+
expect((await save(protocol, flowOf([legacyNode('n1')], 'First'), { mode: 'draft' })).success).toBe(true);
783+
if (host === 'an automation service') {
784+
expect(storedFlow(rows, 'draft')!.body.nodes[0].config).toEqual({ objectName: 'lead', filter: { status: 'stale' } });
785+
}
786+
expect((await save(protocol, flowOf([legacyNode('n1')], 'Second'), { mode: 'draft' })).success).toBe(true);
787+
expect(storedFlow(rows, 'draft')!.body.label).toBe('Second');
788+
789+
const err = await refusalOf(save(protocol, flowOf([legacyNode('n1'), legacyNode('n2')], 'Third'), { mode: 'draft' }));
790+
expectRefusedAt(err, 'nodes.1.config.filters', '`filter`');
791+
expect(storedFlow(rows, 'draft')!.body.label).toBe('Second');
792+
});
793+
}
794+
795+
it('the canonicalizer is asked for the authoring posture when a re-save introduces a retired spelling, and for the full chain when the stored row carries it', async () => {
796+
const spy = vi.fn(specCanonicalizer);
797+
const { protocol, engine } = makeProtocol(new Map([['automation', { canonicalizeStoredFlow: spy }]]));
798+
await seedStoredFlow(engine, flowOf([legacyNode('n1')]));
799+
800+
await refusalOf(save(protocol, flowOf([legacyNode('n1'), legacyNode('n2')])));
801+
expect(spy.mock.lastCall?.[2]).toEqual({ includeRetired: false });
802+
803+
await save(protocol, flowOf([legacyNode('n1')], 'Edited'));
804+
expect(spy.mock.lastCall?.[2]).toEqual({ includeRetired: true });
805+
});
806+
});

0 commit comments

Comments
 (0)