diff --git a/.changeset/23775-flow-save-door-new-refusal.md b/.changeset/23775-flow-save-door-new-refusal.md new file mode 100644 index 00000000000..890ddfafe97 --- /dev/null +++ b/.changeset/23775-flow-save-door-new-refusal.md @@ -0,0 +1,34 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/service-automation': minor +--- + +feat(metadata-protocol)!: the metadata save door refuses a retired flow spelling on a NEW flow, and a stored flow keeps re-saving + +Clause-②: yes (narrowing) + +**BREAKING (API save door):** a flow saved through `saveMetaItem` (`PUT /api/v1/meta/flow/:name`, the runtime dispatcher, and every caller of the protocol) with no stored row of that name at the save's address is now judged as authored input. It is canonicalized without the ADR-0087 D2 conversions retired from the authoring funnel, so a spelling the loader stopped lifting at 18 is no longer rewritten and stored: it 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. Draft and publish mode alike. Before, the door lifted the old spelling, stored the new one and said nothing, so the client that wrote it (an AI writing flows through MCP or REST above all) was never told and wrote it again. + +What counts as a stored row: the row the save overwrites, at its state and package, and for a draft save with no draft yet the active row the draft overlays, so Studio's first draft edit of a stored flow is a re-save. + +Not changed: + +- A re-save of a stored flow keeps replaying the full chain, retired entries included, so a legacy row stays editable and is healed by the save, exactly as before. +- 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. +- The automation engine's flow rehydration (`registerFlow`) and the read path, which still serves a stored flow verbatim. + +Migration, for a client that creates flows through the API: + +- `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. +- `connector_action` node loose `config.connectorId` / `config.actionId` / `config.input` → its `connectorConfig` block. +- CRUD node `config.filters` → `config.filter`; `config.object` → `config.objectName`. +- `map` and `subflow` node `config.flow` → `config.flowName`. +- `notify` node `config.to` → `recipients`, `subject` → `title`, `body` → `message`, `url` → `actionUrl`, `source: { object, id }` → `sourceObject` / `sourceId`. +- `script` node `config.functionName` → `function`, `input` → `inputs`; `actionType` / `template` / `recipients` / `variables` / `script` are removed. +- `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. + +Each refusal names the key to write. `os migrate meta --from 16` lists the same edits for source files. Rows already stored need nothing. + +`@objectstack/service-automation`: `AutomationEngine.canonicalizeStoredFlow(name, definition, options?)` takes `{ includeRetired?: boolean }`. Omitted, it is `true` (the full chain, as every caller holding a body at rest wants). `false` is the authoring posture the save door asks for. `registerFlow` does not take the option. + + diff --git a/docs/adr/0087-metadata-protocol-upgrade-contract.md b/docs/adr/0087-metadata-protocol-upgrade-contract.md index 32ab5e83469..5c2e851ea61 100644 --- a/docs/adr/0087-metadata-protocol-upgrade-contract.md +++ b/docs/adr/0087-metadata-protocol-upgrade-contract.md @@ -571,6 +571,8 @@ diverged (#3903). This addendum extends the contract to data at rest: (availability over purity for data at rest; the same verdict reaches Studio as `_diagnostics` on every read). +> **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. + ## Addendum (2026-08-01) — the stored chain gets a finish line (#4327) The addendum above makes a legacy row read canonical *forever*, which is the 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 547aabafa7c..3a31f8c3823 100644 --- a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts +++ b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts @@ -15,6 +15,13 @@ * the services-table canonicalizer stub from protocol.flow-canonicalizer.test * — the existing flow-canonicalizer harness mocks `saveMetaItem` itself, which * a fix INSIDE `saveMetaItem` cannot use. + * + * [#23775, ruling B] The healing is a RE-SAVE's: a flow with no stored row at + * the save's address is authored input, canonicalized without the entries + * retired from the authoring funnel, so a retired spelling on a new flow is + * refused (the last describe block). The pins below that heal or keep a legacy + * spelling therefore seed the stored row they re-save ({@link seedStoredFlow}), + * the only way a retired spelling is still at rest today. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // [#5619] `assertEngine{Delete,Update}Dispatch` are the producer's OWN write-verb @@ -30,6 +37,7 @@ import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, } from '@objectstack/metadata-core'; +import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; import { ObjectStackProtocolImplementation } from './protocol.js'; /** A flow body that passes `saveMetaItem`'s schema gate. */ @@ -45,12 +53,21 @@ const flowBody = (config: Record) => ({ /** * Stands in for `AutomationEngine.canonicalizeStoredFlow` — same contract, * including the copy-on-write identity an unchanged body comes back with. + * + * [#23775] …and its `includeRetired` option. `filters` → `filter` is retired + * from the authoring funnel (`flow-node-crud-filter-alias`), so with the window + * closed the engine leaves `filters` in place and its strict parse throws, as + * `service-automation`'s `canonicalize-stored-flow.test.ts` pins for the real + * engine. The door then judges the body itself, with the spec's own schema. */ -const canonicalizeStoredFlow = (_name: string, body: any) => { +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: [] }; } + if (options.includeRetired === false) { + throw new Error('Unrecognized key(s) on this delete_record node config: `filters`'); + } const { filters, ...rest } = node.config; return { storable: { ...body, nodes: [{ ...node, config: { ...rest, filter: filters } }] }, @@ -159,14 +176,33 @@ function makeStubEngine() { function makeProtocol(services: Map = new Map()) { const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine, () => services); - return { protocol, rows, services }; + return { protocol, rows, services, engine }; } -const storedFlow = (rows: Map) => { - const row = Array.from(rows.values()).find((r) => r.type === 'flow'); +/** The flow row at one lifecycle (the active one unless asked). */ +const storedFlow = (rows: Map, state: 'active' | 'draft' = 'active') => { + const row = Array.from(rows.values()).find((r) => r.type === 'flow' && r.state === state); return row ? { row, body: JSON.parse(row.metadata) } : undefined; }; +/** + * [#23775] A flow row AT REST, written straight to the store as `put` stamps + * one — how a retired spelling is still stored: the door refuses it on a new + * flow, so no save can mint one. + */ +async function seedStoredFlow(engine: any, body: Record, state: 'active' | 'draft' = 'active') { + await engine.insert('sys_metadata', { + type: 'flow', + name: 'purge_flow', + organization_id: null, + package_id: null, + state, + metadata: JSON.stringify(body), + checksum: hashSpec(body, 'flow'), + version: 1, + }); +} + const save = (protocol: any, item: unknown, extra: Record = {}) => protocol.saveMetaItem({ type: 'flow', name: 'purge_flow', item, ...extra }); @@ -187,10 +223,11 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { .map((c: unknown[]) => String(c[0])) .filter((m: string) => m.includes('WITHOUT canonicalization')); - it('a legacy dialect is healed by the save — the promise every other type already keeps', async () => { - const { protocol, rows } = makeProtocol( + it('a stored legacy dialect is healed by its re-save — the promise every other type already keeps', async () => { + const { protocol, rows, engine } = makeProtocol( new Map([['automation', { canonicalizeStoredFlow }]]), ); + await seedStoredFlow(engine, legacyBody()); const result = await save(protocol, legacyBody()); @@ -231,9 +268,10 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { // detection, control-flow regions). A WIP draft that saves fine today // must not become unsaveable. const throwing = () => { throw new Error('cycle detected: n1 → n1'); }; - const { protocol, rows } = makeProtocol( + const { protocol, rows, engine } = makeProtocol( new Map([['automation', { canonicalizeStoredFlow: throwing }]]), ); + await seedStoredFlow(engine, legacyBody()); const result = await save(protocol, legacyBody()); @@ -253,11 +291,13 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { expect(rows.size).toBe(0); }); - it('no automation service reachable → saved exactly as today', async () => { + it('no automation service reachable → a stored row re-saves exactly as today', async () => { // A control-plane / metadata-only host must not start refusing flow // writes it accepted yesterday. The row is then no better than before — - // `os migrate meta --stored` reports it. - const { protocol, rows } = makeProtocol(new Map()); + // `os migrate meta --stored` reports it. [#23775] A NEW flow's retired + // spelling is refused here as on an engine host (the last block). + const { protocol, rows, engine } = makeProtocol(new Map()); + await seedStoredFlow(engine, legacyBody()); const result = await save(protocol, legacyBody()); @@ -278,35 +318,41 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { expect(storedFlow(rows)!.body).toEqual(body); }); - it('draft mode canonicalizes the same way', async () => { - const { protocol, rows } = makeProtocol( + it('draft mode canonicalizes the same way — Studio\'s first draft over a stored active row', async () => { + // [#23775] Studio's flow designer saves drafts: its first edit of a + // stored flow has no draft row yet, and the active row the draft + // overlays is what makes it a re-save rather than a new flow. + const { protocol, rows, engine } = makeProtocol( new Map([['automation', { canonicalizeStoredFlow }]]), ); + await seedStoredFlow(engine, legacyBody(), 'active'); const result = await save(protocol, legacyBody(), { mode: 'draft' }); expect(result.success).toBe(true); - const stored = storedFlow(rows)!; + const stored = storedFlow(rows, 'draft')!; expect(stored.row.state).toBe('draft'); expect(stored.body.nodes[0].config).toEqual({ objectName: 'lead', filter: { status: 'stale' } }); }); it('draft mode keeps the throw-fallback too — a WIP cycle stays saveable', async () => { const throwing = () => { throw new Error('cycle detected: n1 → n1'); }; - const { protocol, rows } = makeProtocol( + const { protocol, rows, engine } = makeProtocol( new Map([['automation', { canonicalizeStoredFlow: throwing }]]), ); + await seedStoredFlow(engine, legacyBody(), 'active'); const result = await save(protocol, legacyBody(), { mode: 'draft' }); expect(result.success).toBe(true); - expect(storedFlow(rows)!.body.nodes[0].config).toHaveProperty('filters'); + expect(storedFlow(rows, 'draft')!.body.nodes[0].config).toHaveProperty('filters'); }); it('resolution is LAZY — a service registered after construction is still found', async () => { const services = new Map(); - const { protocol, rows } = makeProtocol(services); + const { protocol, rows, engine } = makeProtocol(services); services.set('automation', { canonicalizeStoredFlow }); + await seedStoredFlow(engine, legacyBody()); await save(protocol, legacyBody()); @@ -315,9 +361,10 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { it('the canonicalizer is called with the request NAME, not the body', async () => { const spy = vi.fn(canonicalizeStoredFlow); - const { protocol } = makeProtocol( + const { protocol, engine } = makeProtocol( new Map([['automation', { canonicalizeStoredFlow: spy }]]), ); + await seedStoredFlow(engine, legacyBody()); await save(protocol, legacyBody()); expect(spy.mock.calls[0][0]).toBe('purge_flow'); }); @@ -331,9 +378,10 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { // re-persisted verbatim while the boot warning told the author that // re-saving would fix it. const throwing = () => { throw new Error('cycle detected: n1 → n1'); }; - const { protocol } = makeProtocol( + const { protocol, engine } = makeProtocol( new Map([['automation', { canonicalizeStoredFlow: throwing }]]), ); + await seedStoredFlow(engine, legacyBody()); await save(protocol, legacyBody()); @@ -346,9 +394,10 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { it('the fallback warning is deduped per flow — Studio autosave must not spam', async () => { const throwing = () => { throw new Error('cycle detected: n1 → n1'); }; - const { protocol } = makeProtocol( + const { protocol, engine } = makeProtocol( new Map([['automation', { canonicalizeStoredFlow: throwing }]]), ); + await seedStoredFlow(engine, legacyBody()); await save(protocol, legacyBody()); await save(protocol, legacyBody()); @@ -358,9 +407,10 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { }); it('the clean path stays silent — a healed row needs no warning', async () => { - const { protocol } = makeProtocol( + const { protocol, engine } = makeProtocol( new Map([['automation', { canonicalizeStoredFlow }]]), ); + await seedStoredFlow(engine, legacyBody()); await save(protocol, legacyBody()); @@ -390,7 +440,8 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { 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. - const { protocol } = makeProtocol(new Map()); + const { protocol, engine } = makeProtocol(new Map()); + await seedStoredFlow(engine, legacyBody()); await save(protocol, legacyBody()); @@ -405,9 +456,11 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { * canonicalizer converts first (`registerFlow`'s full chain, retired * entries included) would be refused here, and only here: a verdict that * depended on whether the host runs an automation service. So the fallback - * replays that same full chain (#23487) — for the seven flow spellings - * retired from the authoring funnel at 18 and for those retired before - * them alike. What is STORED stays the raw body. + * replays that same full chain (#23487) for a re-save of a stored row — for + * the seven flow spellings retired from the authoring funnel at 18 and for + * those retired before them alike. What is STORED stays the raw body. + * [#23775] Each pin re-saves the stored row it seeds: a new flow's retired + * spelling is refused by both hosts alike (the last block). */ describe('[#21982] the fallback gate judges the D2-converted body, and stores the raw one', () => { const scriptBody = (config: Record) => ({ @@ -424,7 +477,8 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { }; it('a legacy script `functionName` body saves on a host with no automation service, stored raw', async () => { - const { protocol, rows } = makeProtocol(new Map()); + const { protocol, rows, engine } = makeProtocol(new Map()); + await seedStoredFlow(engine, scriptBody({ functionName: 'helpdesk.triage' })); const result = await save(protocol, scriptBody({ functionName: 'helpdesk.triage' })); @@ -439,35 +493,40 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { // fallback used to judge it in the authoring posture and refuse it // 422; the service-backed path replays the entry and heals it. Since // #23487 the fallback replays the same chain, so both hosts agree. - const { protocol, rows } = makeProtocol(new Map()); + const { protocol, rows, engine } = makeProtocol(new Map()); + const body = { ...flowBody({ objectName: 'lead', filter: { status: 'stale' } }), active: false }; + await seedStoredFlow(engine, body); - const result = await save(protocol, { ...flowBody({ objectName: 'lead', filter: { status: 'stale' } }), active: false }); + const result = await save(protocol, body); expect(result.success).toBe(true); expect(storedFlow(rows)!.body.active).toBe(false); }); it('an undeclared key on delete_record is refused 422 INVALID_METADATA at its path — the alias beside it is not', async () => { - const { protocol, rows } = makeProtocol(new Map()); + const { protocol, rows, engine } = makeProtocol(new Map()); + await seedStoredFlow(engine, legacyBody()); const err = await issuesOf(save(protocol, flowBody({ objectName: 'lead', filters: { status: 'stale' }, bogusKey: 1 }))); expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); expect((err.issues ?? []).map((i) => i.path)).toEqual(['nodes.0.config.bogusKey']); - expect(rows.size).toBe(0); + // The stored row is the one seeded: the refused save wrote nothing. + expect(storedFlow(rows)!.body).toEqual(legacyBody()); }); it('a canonicalizer throw still refuses a body the gate rejects after conversion', async () => { const throwing = () => { throw new Error('cycle detected: n1 → n1'); }; - const { protocol, rows } = makeProtocol( + const { protocol, rows, engine } = makeProtocol( new Map([['automation', { canonicalizeStoredFlow: throwing }]]), ); + await seedStoredFlow(engine, legacyBody()); const err = await issuesOf(save(protocol, flowBody({ objectName: 'lead', filters: { status: 'stale' }, bogusKey: 1 }))); expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); expect((err.issues ?? []).map((i) => i.path)).toEqual(['nodes.0.config.bogusKey']); - expect(rows.size).toBe(0); + expect(storedFlow(rows)!.body).toEqual(legacyBody()); }); }); @@ -484,4 +543,122 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { }); expect(spy).not.toHaveBeenCalled(); }); + + /** + * [#23775, ruling B; ADR-0087 addendum 2026-07-31] A flow with no stored + * row at the save's address is authored input: the canonicalizer runs + * without the retired entries, so a retired spelling meets the parse + * refusal — `422 INVALID_METADATA`, located, naming the key it moved to, + * the text the authoring funnel (`os validate`) prints — while a re-save + * of a stored row keeps the full chain. Pinned on both hosts: an engine's + * canonicalizer (the option-faithful stub) and none (the fallback). + */ + describe('[#23775] a NEW flow is judged as authored input; a stored row keeps the full chain', () => { + const hosts = { + 'an automation service': () => new Map([['automation', { canonicalizeStoredFlow }]]), + 'no automation service': () => new Map(), + } as const; + const refusalOf = async (promise: Promise) => { + try { + await promise; + } catch (err) { + return err as { code?: unknown; status?: unknown; issues?: Array<{ path: string; message: string }> }; + } + throw new Error('expected the save to be refused'); + }; + const filtersIssue = (err: { issues?: Array<{ path: string; message: string }> }) => + (err.issues ?? []).find((i) => i.path === 'nodes.0.config.filters'); + + for (const [host, services] of Object.entries(hosts)) { + it(`${host}: a retired spelling on a new flow is refused 422 INVALID_METADATA at its path, naming the new key, and nothing is stored`, async () => { + const { protocol, rows } = makeProtocol(services()); + + const err = await refusalOf(save(protocol, legacyBody())); + + expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const hit = filtersIssue(err); + expect(hit, JSON.stringify(err.issues)).toBeDefined(); + expect(hit!.message).toContain('`filter`'); + expect(rows.size).toBe(0); + // Refused, so never "saved WITHOUT canonicalization" (#4580's line). + expect(fallbackWarnings()).toHaveLength(0); + }); + + it(`${host}: draft mode does not change it — a new draft with a retired spelling is refused too`, async () => { + const { protocol, rows } = makeProtocol(services()); + + const err = await refusalOf(save(protocol, legacyBody(), { mode: 'draft' })); + + expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(filtersIssue(err)).toBeDefined(); + expect(rows.size).toBe(0); + }); + + it(`${host}: a new flow in the canonical spelling saves`, async () => { + const { protocol, rows } = makeProtocol(services()); + const body = flowBody({ objectName: 'lead', filter: { status: 'stale' } }); + + const result = await save(protocol, body); + + expect(result.success).toBe(true); + expect(storedFlow(rows)!.body).toEqual(body); + }); + + it(`${host}: a stored legacy row re-saves, and is served`, async () => { + const { protocol, engine } = makeProtocol(services()); + await seedStoredFlow(engine, legacyBody()); + + const result = await save(protocol, { ...legacyBody(), label: 'Purge Stale Leads (edited)' }); + + expect(result.success).toBe(true); + const served = (await protocol.getMetaItem({ type: 'flow', name: 'purge_flow' }) as any).item; + expect(served.label).toBe('Purge Stale Leads (edited)'); + }); + + it(`${host}: a stored legacy DRAFT re-saves in draft mode`, async () => { + const { protocol, rows, engine } = makeProtocol(services()); + await seedStoredFlow(engine, legacyBody(), 'draft'); + + const result = await save(protocol, legacyBody(), { mode: 'draft' }); + + expect(result.success).toBe(true); + expect(storedFlow(rows, 'draft')).toBeDefined(); + }); + } + + it('the canonicalizer is asked for the authoring posture on a new flow and for the full chain on a re-save', async () => { + const spy = vi.fn(canonicalizeStoredFlow); + const { protocol, engine } = makeProtocol(new Map([['automation', { canonicalizeStoredFlow: spy }]])); + const body = flowBody({ objectName: 'lead', filter: { status: 'stale' } }); + + await save(protocol, body); + expect(spy.mock.lastCall?.[2]).toEqual({ includeRetired: false }); + + await seedStoredFlow(engine, legacyBody(), 'draft'); + await save(protocol, legacyBody(), { mode: 'draft' }); + expect(spy.mock.lastCall?.[2]).toEqual({ includeRetired: true }); + }); + + it('the refusal is the authoring funnel\'s own text: the issue `os validate` gets for the same flow', async () => { + const { protocol } = makeProtocol(new Map()); + const err = await refusalOf(save(protocol, legacyBody())); + + const authored = ObjectStackDefinitionSchema.safeParse(normalizeStackInput({ flows: [legacyBody()] })); + expect(authored.success).toBe(false); + const funnel = (authored.error?.issues ?? []).find((i) => i.path.join('.') === 'flows.0.nodes.0.config.filters'); + expect(funnel, JSON.stringify(authored.error?.issues)).toBeDefined(); + expect(filtersIssue(err)!.message).toBe(funnel!.message); + }); + + for (const writeFace of ['package-duplicate', 'package-unmanaged-import'] as const) { + it(`a server-stated write (\`${writeFace}\`) carries a body at rest: judged in the full chain on a host with no automation service, as an engine host's caller canonicalizes it`, async () => { + const { protocol, rows } = makeProtocol(new Map()); + + const result = await save(protocol, legacyBody(), { writeFace }); + + expect(result.success).toBe(true); + expect(storedFlow(rows)!.body.nodes[0].config).toHaveProperty('filters'); + }); + } + }); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index cccb1a93dbf..38d594aaba7 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -6555,15 +6555,79 @@ export class ObjectStackProtocolImplementation implements * a control-plane or metadata-only host has no automation service — and * every caller must decide what it means for them rather than assume a * flow was handled. + * + * [#23775] `options` is handed through as given. Only the save door passes + * one ({@link flowSaveReplaysRetired}); every other caller canonicalizes a + * row or an artifact at rest and passes none, so the chain it gets replays + * the retired entries, as before. */ private resolveFlowCanonicalizer(): - ((name: string, body: unknown) => StoredFlowCanonicalization) | undefined { + ((name: string, body: unknown, options?: { includeRetired?: boolean }) => StoredFlowCanonicalization) | undefined { const automation = this.getServicesRegistry?.().get('automation') as - | { canonicalizeStoredFlow?: (name: string, definition: unknown) => StoredFlowCanonicalization } + | { + canonicalizeStoredFlow?: ( + name: string, definition: unknown, options?: { includeRetired?: boolean }, + ) => StoredFlowCanonicalization; + } | undefined; const canonicalize = automation?.canonicalizeStoredFlow; if (typeof canonicalize !== 'function') return undefined; - return (name, body) => canonicalize.call(automation, name, body); + return (name, body, options) => (options === undefined + ? canonicalize.call(automation, name, body) + : canonicalize.call(automation, name, body, options)); + } + + /** + * [#23775, ADR-0087 addendum 2026-07-31] Does this flow save replay the + * conversions retired from the authoring funnel? The predicate of the + * maintainer's ruling B: a flow with NO stored row at the save's address is + * authored input, judged as `normalizeStackInput` judges it — a retired + * spelling stays in place and meets the parse refusal and its prescription + * (`422 INVALID_METADATA`, located, the text `os validate` prints) — while a + * re-save of a stored row keeps the full chain, so a legacy row stays + * editable in Studio. The automation engine's rehydration is not asked. + * + * "A stored row at the save's address" is the row the save overwrites, as + * the repository's `put` resolves it ({@link SysMetadataRepository.headAt}: + * the state, the package, the inherited draft binding and the bundle + * member), and for a draft save with no draft row yet the ACTIVE row the + * draft overlays — the same baseline {@link authoredLockFamily} and + * {@link storedBodyForCarryForward} read. The fallback is load-bearing: + * Studio's flow designer saves drafts, so its first edit of a stored flow + * has no draft row and would otherwise be judged as new. The mode does not + * change the predicate, and the refusal it leads to is asked in draft and + * publish mode alike. A flow a managed package ships never reaches here (it + * is sealed, {@link refusePackagedBaseOverride}), so no code layer is read. + * + * Server-stated writes answer `true` without a read: their body is a row or + * an artifact at rest, never a caller's. `source` set is a rewrite of a row + * the store holds (the stored-metadata migration); a package duplicate + * copies stored rows and an unmanaged import writes a built package's + * flows, both canonicalized by their caller with the full chain first + * wherever an engine is reachable. Judging them as authored would refuse, + * on a host with no automation service only, the same body an engine host + * accepts — the verdict split #21982 closed. + * + * Read with no `try`/`catch`, as the lock family's baseline is: a stored row + * that could not be read is not an absent one. + */ + private async flowSaveReplaysRetired(request: { + name: string; + item?: unknown; + packageId?: string | null; + source?: string; + writeFace?: MetadataWriteFace; + }, mode: 'draft' | 'publish'): Promise { + if (request.source !== undefined) return true; + if (request.writeFace === 'package-duplicate' || request.writeFace === 'package-unmanaged-import') return true; + const repo = this.getOverlayRepo(); + const ref = { type: 'flow', name: request.name, org: 'env' } as Parameters[0]; + const member = itemDiscriminator('flow', request.item); + const state: 'active' | 'draft' = mode === 'draft' ? 'draft' : 'active'; + const target = await repo.headAt(ref, { state, packageId: request.packageId ?? null, member }); + if (target.head) return true; + if (state !== 'draft') return false; + return (await repo.headAt(ref, { state: 'active', packageId: target.packageId, member })).head !== null; } /** @@ -22944,23 +23008,38 @@ export class ObjectStackProtocolImplementation implements // here and accepted by `registerFlow`, which the canonicalizer is: a // verdict that depended on whether this host runs an automation // service. So the fallback replays the chain the canonicalizer replays - // — the FULL chain, retired entries included (#23487 retired those - // spellings from the authoring funnel, not from this seam), minus the - // same default flip ({@link FLOW_FALLBACK_VERDICT_NOT_REPLAYED}). No `reservedNodeTypes` (no + // 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. + // + // [#23775, ADR-0087 addendum 2026-07-31] Which chain that is, is the + // ruling's predicate ({@link flowSaveReplaysRetired}). A re-save of a + // stored row replays the FULL chain, retired entries included — the + // row is data at rest, and a legacy row stays editable. A flow with no + // stored row at the save's address is authored input: the + // canonicalizer and this fallback both run WITHOUT the retired entries, + // so a spelling retired from the authoring funnel (#23487, #23543) is + // not lifted here either and meets the parse refusal below, with the + // prescription `os validate` prints, on both hosts alike. let flowGateVerdictBody: unknown; + // [#4580, #23775] The fallback's warning, said once the gate below has + // accepted the body it falls back to — never for a save it refuses. + let flowCanonicalizeFallback: string | undefined; if (singularType === 'flow' && request.item) { + const includeRetired = await this.flowSaveReplaysRetired(request, mode); // No automation service reachable (control-plane / metadata-only - // host): save exactly as today — a host must not start refusing - // flow writes it accepted yesterday. + // host): the fallback below judges the body and it is saved as + // written — a host must not start refusing flow writes it accepted + // yesterday, save the retired spelling on a new flow (#23775), which + // an engine host refuses too. const canonicalizeFlow = this.resolveFlowCanonicalizer(); let canonicalized = false; if (canonicalizeFlow) { let result: StoredFlowCanonicalization | undefined; try { - result = canonicalizeFlow(request.name, request.item); + result = canonicalizeFlow(request.name, request.item, { includeRetired }); } catch (e: any) { // `canonicalizeStoredFlow` is STRICTER than the gate below // (strict parse + cycle detection + control-flow region @@ -22984,16 +23063,12 @@ export class ObjectStackProtocolImplementation implements // Deduped per flow per process, like {@link // storedConversionWarned} — Studio autosave writes the same // draft repeatedly and this must not become a spam loop. - const key = `${singularType}|${request.name}`; - if (!this.flowCanonicalizeFallbackWarned.has(key)) { - this.flowCanonicalizeFallbackWarned.add(key); - console.warn( - `[Protocol] flow/${request.name} was saved WITHOUT canonicalization: ` - + `${e?.message ?? String(e)} The body was persisted as submitted, so a ` - + `pre-protocol shape in it stays legacy on disk. Run ` - + `"os migrate meta --stored" to see the row's status.`, - ); - } + // + // [#23775] Said after the schema gate, not here: on a new + // flow the canonicalizer's own throw is often the retired + // spelling's refusal, which the gate then answers with a + // 422, and "was saved" would be false. + flowCanonicalizeFallback = e?.message ?? String(e); result = undefined; } if (result) { @@ -23023,7 +23098,7 @@ export class ObjectStackProtocolImplementation implements } if (!canonicalized) { flowGateVerdictBody = applyConversionsToFlow(request.item, { - includeRetired: true, + includeRetired, excludeConversionIds: FLOW_FALLBACK_VERDICT_NOT_REPLAYED, }); } @@ -23138,6 +23213,22 @@ export class ObjectStackProtocolImplementation implements } } + // [#4580, #23775] The flow canonicalizer's throw-fallback, said now that + // the gate it fell back to has accepted the body (see the flow branch + // above for why it is not said where the throw is caught). + if (flowCanonicalizeFallback !== undefined) { + const key = `${singularType}|${request.name}`; + if (!this.flowCanonicalizeFallbackWarned.has(key)) { + this.flowCanonicalizeFallbackWarned.add(key); + console.warn( + `[Protocol] flow/${request.name} was saved WITHOUT canonicalization: ` + + `${flowCanonicalizeFallback} The body was persisted as submitted, so a ` + + `pre-protocol shape in it stays legacy on disk. Run ` + + `"os migrate meta --stored" to see the row's status.`, + ); + } + } + // [#21658, #21689] A hook that carries no `body` can never run once // stored here, whether its `handler` names a function or it has // neither field: a stored hook ships with no code package, so its diff --git a/packages/rest/src/meta-flow-save-retired-spelling.test.ts b/packages/rest/src/meta-flow-save-retired-spelling.test.ts new file mode 100644 index 00000000000..3b390f54431 --- /dev/null +++ b/packages/rest/src/meta-flow-save-retired-spelling.test.ts @@ -0,0 +1,226 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23775, ruling B] `PUT /api/v1/meta/flow/:name` — a NEW flow carrying a + * spelling retired from the authoring funnel is refused at the REST door, and a + * stored row that carries one keeps re-saving. + * + * The protocol decides it (`saveMetaItem`'s flow branch, the predicate + * `flowSaveReplaysRetired`; its pins are + * `packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts`). + * This file answers what a protocol test cannot: the real `/meta` route relays + * the refusal as an author reads it — `422`, `INVALID_METADATA`, and the + * located `issues` naming the key the spelling moved to — and lets a legacy + * row through on both of Studio's save modes. + * + * The spelling is the card's example: a `wait` node's loose `config.duration`, + * which the loader lifted to `waitEventConfig.timerDuration` until 18 + * (`flow-node-wait-event-config-lift`, retired from the load path). + * + * Driven at the HTTP door on the real stack: a better-sqlite3 `:memory:` + * engine, the real `sys_metadata*` objects, a real + * `ObjectStackProtocolImplementation` and the real routes. No automation + * service is registered, so the door's own fallback judges the flow — the + * same verdict an engine host reaches (#21982). The stub is the auth boundary + * (`resolveExecCtx`). + * + * ⚠️ `@objectstack/metadata-protocol` resolves through `exports` to its + * **`dist/`** (`check-test-source-alias.mjs`, `KNOWN_UNALIASED_TEST_IMPORTS`): + * rebuild it before reading a result here after touching `protocol.ts`. + * + * Every refusal pin asserts the ADR-0112 `code`, the HTTP status and the + * located issue, and reads the store to show the refused write did not land. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata'; +import { hashSpec } from '@objectstack/metadata-core'; +import { RestServer } from './rest-server.js'; + +/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */ +const TEST_PACKAGE_ID = 'objectstack-test'; +const META = '/api/v1/meta'; +const FLOW = { type: 'flow', name: 'settle_order' }; + +/** A flow whose second node is the `wait` under test. */ +const waitFlow = (wait: Record, label = 'Settle Order') => ({ + name: FLOW.name, + label, + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'hold', type: 'wait', label: 'Hold', ...wait }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'hold' }, + { id: 'e2', source: 'hold', target: 'end' }, + ], +}); +/** The card's literal shape: the loose key alone, no block. */ +const looseOnly = (label?: string) => waitFlow({ config: { duration: 'PT1M' } }, label); +/** The loose key beside a declared block. */ +const looseBesideBlock = () => waitFlow({ waitEventConfig: { eventType: 'timer', timerDuration: 'PT5M' }, config: { duration: 'PT9M' } }); +/** The canonical spelling. */ +const canonical = () => waitFlow({ waitEventConfig: { eventType: 'timer', timerDuration: 'PT1M' } }); + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = (code: number) => { res.statusCode = code; return res; }; + res.json = (body: any) => { res.body = body; return res; }; + res.send = () => res; + res.end = () => res; + res.header = (k: string, v: string) => { res.headers[k] = v; return res; }; + res.setHeader = () => {}; + res.write = () => true; + return res; +} + +async function boot() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); + await engine.syncSchemas(); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const config = { api: { requireAuth: false }, metadata: { enableCache: false } }; + const rest: any = new RestServer(createMockServer() as any, protocol as any, config as any); + // An author: `manage_metadata` saves and reads drafts. + rest.resolveExecCtx = async () => ({ userId: 'u_author', systemPermissions: ['manage_metadata'] }); + rest.registerRoutes(); + + const route = (method: string, path: string) => { + const found = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + if (!found) throw new Error(`${method} ${path} is not registered`); + return found; + }; + const call = async (method: string, routePath: string, req: Record) => { + const res = makeRes(); + await route(method, routePath).handler({ method, headers: {}, query: {}, params: {}, ...req }, res); + return res; + }; + const path = `${META}/${FLOW.type}/${FLOW.name}`; + + /** `GET /meta/flow/settle_order`. */ + const read = (query: Record = {}) => + call('GET', `${META}/:type/:name`, { path, params: FLOW, query }); + /** `PUT /meta/flow/settle_order`. */ + const save = (body: unknown, query: Record = {}) => + call('PUT', `${META}/:type/:name`, { path, params: FLOW, query, body }); + + /** The stored bodies of the flow at one lifecycle — the store, not the door's word for it. */ + const stored = async (state: 'active' | 'draft') => { + const rows = await engine.find('sys_metadata', { where: { type: FLOW.type, name: FLOW.name, state } }); + return (rows ?? []).map((r: any) => JSON.parse(String(r.metadata)) as Record); + }; + + /** + * A row as it exists at rest from before the retirement — the only way a + * retired spelling is still stored, since the door refuses it on a new + * flow — written straight to the store, stamped as `put` stamps a row. + */ + const seed = async (state: 'active' | 'draft', body: Record) => { + const now = new Date().toISOString(); + await engine.insert('sys_metadata', { + id: `seed_${state}`, type: FLOW.type, name: FLOW.name, + package_id: null, state, metadata: JSON.stringify(body), checksum: hashSpec(body, FLOW.type), + version: 1, created_at: now, updated_at: now, + }, { context: { isSystem: true } } as any); + }; + + return { read, save, stored, seed }; +} + +const ok = (res: any) => { + expect(res.statusCode, JSON.stringify(res.body)).toBe(200); + return res.body; +}; + +/** A refusal: the ADR-0112 `code`, the status and the located issues, all asserted. */ +const refusedInvalid = (res: any): Array<{ path: string; message: string }> => { + expect(res.statusCode, JSON.stringify(res.body)).toBe(422); + expect(res.body.code).toBe('INVALID_METADATA'); + return (res.body.issues ?? []) as Array<{ path: string; message: string }>; +}; + +describe('[#23775] PUT /meta/flow/:name — a retired spelling on a NEW flow is refused, a stored one re-saves', () => { + it('a new flow with the loose key beside its block: 422 INVALID_METADATA at `config.duration`, naming `waitEventConfig.timerDuration`; nothing stored', async () => { + const h = await boot(); + + const issues = refusedInvalid(await h.save(looseBesideBlock())); + + expect(issues.map((i) => i.path)).toEqual(['nodes.1.config.duration']); + expect(issues[0]!.message).toContain('waitEventConfig.timerDuration'); + expect(await h.stored('active')).toEqual([]); + }); + + it('a new flow with the loose key alone (the card\'s shape): 422 INVALID_METADATA at the block the lift wrote, naming it; nothing stored', async () => { + const h = await boot(); + + const issues = refusedInvalid(await h.save(looseOnly())); + + expect(issues.map((i) => i.path)).toEqual(['nodes.1.waitEventConfig']); + expect(issues[0]!.message).toContain('timerDuration'); + expect(await h.stored('active')).toEqual([]); + }); + + it('draft mode does not change it: a new draft with the retired spelling is refused the same way', async () => { + const h = await boot(); + + const issues = refusedInvalid(await h.save(looseBesideBlock(), { mode: 'draft' })); + + expect(issues.map((i) => i.path)).toEqual(['nodes.1.config.duration']); + expect(await h.stored('draft')).toEqual([]); + }); + + it('a new flow in the canonical spelling saves', async () => { + const h = await boot(); + + ok(await h.save(canonical())); + + expect(await h.stored('active')).toEqual([canonical()]); + }); + + it('a stored legacy row re-saves, and is served', async () => { + const h = await boot(); + await h.seed('active', looseOnly()); + + ok(await h.save(looseOnly('Settle Order (edited)'))); + + expect((await h.stored('active')).map((b) => b.label)).toEqual(['Settle Order (edited)']); + expect(ok(await h.read()).item.label).toBe('Settle Order (edited)'); + }); + + it('Studio\'s first draft over a stored legacy row saves: the active row the draft overlays makes it a re-save', async () => { + const h = await boot(); + await h.seed('active', looseOnly()); + + ok(await h.save(looseOnly('Settle Order (draft)'), { mode: 'draft' })); + + expect((await h.stored('draft')).map((b) => b.label)).toEqual(['Settle Order (draft)']); + }); +}); 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 cdfe1c2ca2d..49b9ede3669 100644 --- a/packages/services/service-automation/src/canonicalize-stored-flow.test.ts +++ b/packages/services/service-automation/src/canonicalize-stored-flow.test.ts @@ -419,3 +419,92 @@ describe('canonicalizeStoredFlow — every site registerFlow refuses after the p expect(warn).toHaveBeenCalled(); }); }); + +/** + * [#23775, ruling B] `canonicalizeStoredFlow`'s `includeRetired` option: the + * metadata save door closes the retired window for a flow with no stored row, + * so an authored retired spelling meets the parse refusal instead of being + * lifted. Every other caller passes nothing and keeps the full chain, and + * `registerFlow` (the rehydration seam) is not changed by the option at all. + * + * The spelling is the card's own example: a `wait` node's loose + * `config.duration`, lifted to `waitEventConfig.timerDuration` by the entry + * retired from the load path at 18 (`flow-node-wait-event-config-lift`). + */ +describe('[#23775] canonicalizeStoredFlow — the authoring posture is an option, the rehydration is unchanged', () => { + const waitFlow = (wait: Record) => ({ + name: 'settle_order', + label: 'Settle Order', + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'hold', type: 'wait', label: 'Hold', ...wait }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'hold' }, + { id: 'e2', source: 'hold', target: 'end' }, + ], + }); + const legacyWait = () => waitFlow({ config: { duration: 'PT1M' } }); + + /** The parse's refusal, read as data: the ZodError's issues, or a failure. */ + const refusalOf = (fn: () => unknown): Array<{ path: PropertyKey[]; message: string }> => { + try { + fn(); + } catch (err) { + return ((err as { issues?: Array<{ path: PropertyKey[]; message: string }> }).issues ?? []); + } + throw new Error('expected the canonicalization to be refused'); + }; + + it('with no option, the retired spelling is lifted: the full chain, as every at-rest caller gets it', () => { + const { storable } = new AutomationEngine(silentLogger).canonicalizeStoredFlow('settle_order', legacyWait()); + const hold = (storable as any).nodes[1]; + expect(hold.waitEventConfig).toEqual({ timerDuration: 'PT1M', eventType: 'timer' }); + expect(hold.config).toEqual({}); + }); + + it('`includeRetired: false` leaves it in place, and the parse refuses it where it is written, naming the new key', () => { + // Beside a declared block the loose key is the only finding: the full + // chain would delete it (the block shadows it), the authoring posture + // refuses it at `config.duration`. + const flow = waitFlow({ waitEventConfig: { eventType: 'timer', timerDuration: 'PT5M' }, config: { duration: 'PT9M' } }); + const issues = refusalOf(() => new AutomationEngine(silentLogger) + .canonicalizeStoredFlow('settle_order', flow, { includeRetired: false })); + expect(issues.map((i) => i.path.join('.'))).toEqual(['nodes.1.config.duration']); + expect(issues[0]!.message).toContain('waitEventConfig.timerDuration'); + }); + + it('`includeRetired: false` on the bare loose key: refused at the block the lift would have written, naming it', () => { + // The card's literal shape. Without the lift the node has no + // `waitEventConfig` at all, and the node's own rule answers first — + // exactly what the authoring funnel answers for the same body. + const issues = refusalOf(() => new AutomationEngine(silentLogger) + .canonicalizeStoredFlow('settle_order', legacyWait(), { includeRetired: false })); + expect(issues.map((i) => i.path.join('.'))).toEqual(['nodes.1.waitEventConfig']); + expect(issues[0]!.message).toContain('timerDuration'); + }); + + it('`includeRetired: false` changes nothing for a body already in the canonical spelling', () => { + const engine = new AutomationEngine(silentLogger); + const canonical = waitFlow({ waitEventConfig: { eventType: 'timer', timerDuration: 'PT1M' } }); + const closed = engine.canonicalizeStoredFlow('settle_order', canonical, { includeRetired: false }); + const open = engine.canonicalizeStoredFlow('settle_order', canonical); + expect(closed.storable).toEqual(open.storable); + expect(closed.storable).toBe(canonical); + }); + + it('`includeRetired: true` is the default spelled out', () => { + const engine = new AutomationEngine(silentLogger); + expect(engine.canonicalizeStoredFlow('settle_order', legacyWait(), { includeRetired: true }).storable) + .toEqual(engine.canonicalizeStoredFlow('settle_order', legacyWait()).storable); + }); + + it('registerFlow, the rehydration seam, still registers the stored legacy spelling', () => { + const parsed = new AutomationEngine(silentLogger).registerFlow('settle_order', legacyWait()) as any; + const hold = parsed.nodes.find((n: { id: string }) => n.id === 'hold'); + expect(hold.waitEventConfig).toMatchObject({ timerDuration: 'PT1M', eventType: 'timer' }); + }); +}); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 4c735c08881..218cccea474 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -4486,15 +4486,26 @@ export class AutomationEngine implements IAutomationService { * none (the save door, a package duplication, the sync's comparison of a * held flow) does no more work than before. `registerFlow` reads none * either: it runs the same checks itself and throws the first refusal. - */ - canonicalizeStoredFlow(name: string, definition: unknown): { + * + * [#23775, ADR-0087 addendum 2026-07-31] `options.includeRetired` — whether + * the chain replays the entries retired from the authoring funnel + * (`retiredFromLoadPath`). Omitted, it is `true`: a body at rest has no + * author to teach, so every caller that hands this method a stored row, a + * copied row or a built artifact passes nothing. `false` is the AUTHORING + * posture `normalizeStackInput` takes: the metadata save door passes it for + * a flow with no stored row at the save's address, so a retired spelling is + * left in place and meets the parse refusal, with its prescription, instead + * of being lifted. {@link registerFlow}, the rehydration seam, never passes + * it. The default-flip ids stay excluded either way. + */ + canonicalizeStoredFlow(name: string, definition: unknown, options: { includeRetired?: boolean } = {}): { parsed: FlowParsed; storable: unknown; notices: ConversionNotice[]; conflicts: ConversionConflictNotice[]; todos: ConversionTodoNotice[]; } { - const { parsed, storable, notices, conflicts, todos: chainTodos } = this.canonicalizeFlow(name, definition); + const { parsed, storable, notices, conflicts, 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 @@ -4531,9 +4542,10 @@ export class AutomationEngine implements IAutomationService { * {@link canonicalizeStoredFlow}'s pass without the registration checks: * convert, parse, and refuse what the parse, the cycle check and the * region validator refuse. {@link registerFlow} calls this and then runs - * the checks itself, so registration judges a flow once. + * the checks itself, so registration judges a flow once. `options` is + * {@link canonicalizeStoredFlow}'s, and only that method passes it. */ - private canonicalizeFlow(name: string, definition: unknown): { + private canonicalizeFlow(name: string, definition: unknown, options: { includeRetired?: boolean } = {}): { parsed: FlowParsed; storable: unknown; notices: ConversionNotice[]; @@ -4559,6 +4571,10 @@ export class AutomationEngine implements IAutomationService { // exact hazard this seam exists to prevent. Authored sources keep // window semantics at their own seam (`normalizeStackInput` applies // live-window entries only; the schema tombstones the retired shape). + // [#23775] The one caller that closes the window is the metadata save + // door, for a flow with no stored row (an authored body, not one at + // rest), through `canonicalizeStoredFlow`'s option; the rehydration + // passes none and replays the full chain, as it always has. // // `excludeConversionIds`: the retired window is opened for a CLASS of // caller, and one kind of entry inside it is not a rescue but a @@ -4576,7 +4592,7 @@ export class AutomationEngine implements IAutomationService { const todos: ConversionTodoNotice[] = []; const converted = applyConversionsToFlow(definition, { reservedNodeTypes, - includeRetired: true, + includeRetired: options.includeRetired ?? true, excludeConversionIds: CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION, onNotice: (n) => { notices.push(n);