From 345cac96785ecab5b547cdeccf0ee9480715ea85 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:12:14 +0000 Subject: [PATCH 1/4] wip(metadata-protocol): flow save predicate compares retired hits against the stored row (#23903) Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude --- ...rotocol.save-flow-canonicalization.test.ts | 186 +++++++++++++++++- packages/metadata-protocol/src/protocol.ts | 88 ++++++++- 2 files changed, 267 insertions(+), 7 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts index 3a31f8c3823..89fad592d25 100644 --- a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts +++ b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts @@ -37,7 +37,7 @@ import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, } from '@objectstack/metadata-core'; -import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; +import { ObjectStackDefinitionSchema, applyConversionsToFlow, normalizeStackInput } from '@objectstack/spec'; import { ObjectStackProtocolImplementation } from './protocol.js'; /** A flow body that passes `saveMetaItem`'s schema gate. */ @@ -662,3 +662,187 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { } }); }); + +/** + * [#23903, ruling B′] A re-save whose body NEWLY INTRODUCES a retired spelling + * is authored input, refused like a new flow's: a retired entry that fires on + * the request at a site where no stored row carries it ⇒ the save is judged + * without the retired entries and meets the parse refusal (`422 + * INVALID_METADATA`, located, the key the spelling moved to). A request whose + * retired hits the stored row all carries keeps the full chain. + * + * The spellings are two of the seven #23487 retired, neither moved by F5-http: + * a `delete_record` node's `config.filters` (`flow-node-crud-filter-alias`, + * refused at the key, naming `filter`) and a `wait` node's loose + * `config.duration` beside its block (`flow-node-wait-event-config-lift`, + * refused at the key, naming `waitEventConfig.timerDuration`). No node TYPE is + * retired at this base: the one type rename, the callout rename, is what + * F5-http retires. + * + * Pinned on both hosts. The engine is the spec's own pass behind the + * canonicalizer's option (`includeRetired`), so a carried re-save is healed and + * 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. + */ +describe('[#23903] a re-save that newly introduces a retired spelling is judged as authored input', () => { + const specCanonicalizer = (_name: string, body: unknown, options: { includeRetired?: boolean } = {}) => { + const notices: unknown[] = []; + const storable = applyConversionsToFlow(body, { + includeRetired: options.includeRetired ?? true, + excludeConversionIds: ['flow-decision-mode-inclusive-explicit'], + onNotice: (n) => notices.push(n), + }); + return { storable, notices, conflicts: [] }; + }; + const hosts = { + 'an automation service': () => new Map([['automation', { canonicalizeStoredFlow: specCanonicalizer }]]), + 'no automation service': () => new Map(), + } as const; + + const purge = (id: string, config: Record) => + ({ id, type: 'delete_record', label: `Purge ${id}`, config }); + const hold = (id: string) => ({ + id, type: 'wait', label: `Hold ${id}`, + waitEventConfig: { eventType: 'timer', timerDuration: 'PT5M' }, + config: { duration: 'PT9M' }, + }); + const flowOf = (nodes: unknown[], label = 'Purge Stale Leads') => + ({ name: 'purge_flow', label, type: 'autolaunched', status: 'active', nodes, edges: [] }); + const legacyNode = (id: string) => purge(id, { objectName: 'lead', filters: { status: 'stale' } }); + const canonicalNode = (id: string) => purge(id, { objectName: 'lead', filter: { status: 'stale' } }); + + 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'); + }; + /** The ADR-0112 `code` and status, and the issue at `path`, which must name `key`. */ + const expectRefusedAt = ( + err: { code?: unknown; status?: unknown; issues?: Array<{ path: string; message: string }> }, + path: string, + key: string, + ) => { + expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const issue = (err.issues ?? []).find((i) => i.path === path); + expect(issue, JSON.stringify(err.issues)).toBeDefined(); + expect(issue!.message).toContain(key); + }; + + for (const [host, services] of Object.entries(hosts)) { + 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 () => { + const { protocol, rows, engine } = makeProtocol(services()); + const stored = flowOf([legacyNode('n1')]); + await seedStoredFlow(engine, stored); + + const err = await refusalOf(save(protocol, flowOf([legacyNode('n1'), hold('h1')]))); + + expectRefusedAt(err, 'nodes.1.config.duration', '`waitEventConfig.timerDuration`'); + expect(storedFlow(rows)!.body).toEqual(stored); + }); + + 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 () => { + const { protocol, rows, engine } = makeProtocol(services()); + const stored = flowOf([legacyNode('n1')]); + await seedStoredFlow(engine, stored); + + const err = await refusalOf(save(protocol, flowOf([legacyNode('n1'), legacyNode('n2')]))); + + expectRefusedAt(err, 'nodes.1.config.filters', '`filter`'); + expect(storedFlow(rows)!.body).toEqual(stored); + }); + + it(`${host}: a retired spelling newly written on a node the stored row already has is refused at that key`, async () => { + const { protocol, rows, engine } = makeProtocol(services()); + const stored = flowOf([canonicalNode('n1'), legacyNode('n2')]); + await seedStoredFlow(engine, stored); + + const err = await refusalOf(save(protocol, flowOf([legacyNode('n1'), legacyNode('n2')]))); + + expectRefusedAt(err, 'nodes.0.config.filters', '`filter`'); + expect(storedFlow(rows)!.body).toEqual(stored); + }); + + it(`${host}: draft mode with no draft row yet — the active row it overlays is the baseline — refuses the added node`, async () => { + const { protocol, rows, engine } = makeProtocol(services()); + await seedStoredFlow(engine, flowOf([legacyNode('n1')]), 'active'); + + const err = await refusalOf(save(protocol, flowOf([legacyNode('n1'), legacyNode('n2')]), { mode: 'draft' })); + + expectRefusedAt(err, 'nodes.1.config.filters', '`filter`'); + expect(storedFlow(rows, 'draft')).toBeUndefined(); + }); + + it(`${host}: draft mode over a stored draft row refuses the added node`, async () => { + const { protocol, rows, engine } = makeProtocol(services()); + const draft = flowOf([legacyNode('n1')], 'Purge Stale Leads (draft)'); + await seedStoredFlow(engine, draft, 'draft'); + + const err = await refusalOf(save(protocol, flowOf([legacyNode('n1'), hold('h1')]), { mode: 'draft' })); + + expectRefusedAt(err, 'nodes.1.config.duration', '`waitEventConfig.timerDuration`'); + expect(storedFlow(rows, 'draft')!.body).toEqual(draft); + }); + + it(`${host}: re-saving the served legacy body unchanged saves, healed as before`, async () => { + const { protocol, rows, engine } = makeProtocol(services()); + await seedStoredFlow(engine, flowOf([legacyNode('n1'), hold('h1')])); + const served = (await protocol.getMetaItem({ type: 'flow', name: 'purge_flow' }) as any).item; + + const result = await save(protocol, served); + + expect(result.success).toBe(true); + const body = storedFlow(rows)!.body; + if (host === 'an automation service') { + expect(body.nodes[0].config).toEqual({ objectName: 'lead', filter: { status: 'stale' } }); + expect(body.nodes[1].config ?? {}).not.toHaveProperty('duration'); + } else { + // No canonicalizer here: judged converted, stored as written. + expect(body).toEqual(served); + } + }); + + 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 () => { + const { protocol, rows, engine } = makeProtocol(services()); + await seedStoredFlow(engine, flowOf([legacyNode('n1')])); + + const result = await save(protocol, flowOf([canonicalNode('n0'), legacyNode('n1')])); + + expect(result.success).toBe(true); + expect(storedFlow(rows)!.body.nodes.map((n: { id: string }) => n.id)).toEqual(['n0', 'n1']); + }); + + 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 () => { + const { protocol, rows, engine } = makeProtocol(services()); + await seedStoredFlow(engine, flowOf([legacyNode('n1')]), 'active'); + + // Studio's buffer is the served legacy body; a save does not replace it. + expect((await save(protocol, flowOf([legacyNode('n1')], 'First'), { mode: 'draft' })).success).toBe(true); + if (host === 'an automation service') { + expect(storedFlow(rows, 'draft')!.body.nodes[0].config).toEqual({ objectName: 'lead', filter: { status: 'stale' } }); + } + expect((await save(protocol, flowOf([legacyNode('n1')], 'Second'), { mode: 'draft' })).success).toBe(true); + expect(storedFlow(rows, 'draft')!.body.label).toBe('Second'); + + const err = await refusalOf(save(protocol, flowOf([legacyNode('n1'), legacyNode('n2')], 'Third'), { mode: 'draft' })); + expectRefusedAt(err, 'nodes.1.config.filters', '`filter`'); + expect(storedFlow(rows, 'draft')!.body.label).toBe('Second'); + }); + } + + 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 () => { + const spy = vi.fn(specCanonicalizer); + const { protocol, engine } = makeProtocol(new Map([['automation', { canonicalizeStoredFlow: spy }]])); + await seedStoredFlow(engine, flowOf([legacyNode('n1')])); + + await refusalOf(save(protocol, flowOf([legacyNode('n1'), legacyNode('n2')]))); + expect(spy.mock.lastCall?.[2]).toEqual({ includeRetired: false }); + + await save(protocol, flowOf([legacyNode('n1')], 'Edited')); + expect(spy.mock.lastCall?.[2]).toEqual({ includeRetired: true }); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 74fb23d7fcf..cd5449d4e55 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -177,7 +177,7 @@ import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSp // depends on `@objectstack/service-cluster`; a bridge plugin there hands the // live transport in through `attachMetadataMutationPubSub`. import type { IObjectQLEngine, IPubSub, ISecurityService } from '@objectstack/spec/contracts'; -import { applyConversionsToFlow, applyConversionsToStoredItem, type ConversionNotice, type ConversionTodoNotice } from '@objectstack/spec'; +import { ALL_CONVERSIONS, applyConversionsToFlow, applyConversionsToStoredItem, type ConversionNotice, type ConversionTodoNotice } from '@objectstack/spec'; import { type FormView, type I18nLabel, isAggregatedViewContainer, expandViewContainer, resolveI18nLabel, defaultRecordPageObject } from '@objectstack/spec/ui'; // [commit ece4dad31] Emitted-specifier pin. This module's inferred public declarations // structurally mention `FormFieldInput` (FormView `sections[].fields`), and @@ -6612,6 +6612,32 @@ export class ObjectStackProtocolImplementation implements * * 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. + * + * [#23903, ruling B′] A re-save is authored input too where its body NEWLY + * INTRODUCES a retired spelling — an author or an AI adding a node with an + * old spelling to a legacy flow. The rule compares conversion hits: a + * retired entry that fires on the request at a site where it does not fire + * on a stored row answers `false`, exactly as a new flow does; a request + * whose every retired hit a stored row carries keeps the full chain, so a + * served legacy body re-saves and is healed as before. + * + * A hit is one firing of a retired entry, named by the entry, the spelling + * it rewrote (the notice's `from`) and its site: the notice's `path`, with + * each array element on the way that carries an `id` (a node, a region's + * node, an edge) named by that id instead of its index. So the same retired + * spelling on a node the stored row does not have is a new hit even when + * another node carries it, and adding, removing or moving a node never turns + * a carried hit into a new one. Both bodies are scanned by the spec's own + * pass (`applyConversionsToFlow`, the full chain minus + * {@link FLOW_FALLBACK_VERDICT_NOT_REPLAYED}), never by the engine, so a + * host with no automation service reaches the same answer (#21982). + * + * The rows that may carry a hit are the save's baseline above, and for a + * DRAFT save the active row the draft overlays even when a draft row + * exists. Studio keeps its buffer after a draft save: on an engine host the + * first save heals the draft row, so the next autosave re-sends spellings + * only the active row still carries, and judged against the draft row + * alone Studio's second save of a legacy flow would be refused. */ private async flowSaveReplaysRetired(request: { name: string; @@ -6622,14 +6648,57 @@ export class ObjectStackProtocolImplementation implements }, mode: 'draft' | 'publish'): Promise { if (request.source !== undefined) return true; if (request.writeFace === 'package-duplicate' || request.writeFace === 'package-unmanaged-import') return true; + const retired = new Set(ALL_CONVERSIONS.filter((c) => c.retiredFromLoadPath).map((c) => c.id)); + // The site of a notice's path, ids standing in for the indexes of the + // array elements that carry one, read on the converted body it names. + const siteOf = (root: unknown, path: string): string => { + let at: unknown = root; + let site = ''; + for (const [, key, index] of path.matchAll(/([^.[\]]+)|\[(\d+)\]/g)) { + if (key !== undefined) { + site += site === '' ? key : `.${key}`; + at = at !== null && typeof at === 'object' ? (at as Record)[key] : undefined; + continue; + } + const element: unknown = Array.isArray(at) ? at[Number(index)] : undefined; + const id = element !== null && typeof element === 'object' ? (element as { id?: unknown }).id : undefined; + site += typeof id === 'string' && id !== '' ? `[#${id}]` : `[${index}]`; + at = element; + } + return site; + }; + const retiredHits = (body: unknown): Set => { + const fired: ConversionNotice[] = []; + const converted = applyConversionsToFlow(body, { + includeRetired: true, + excludeConversionIds: FLOW_FALLBACK_VERDICT_NOT_REPLAYED, + onNotice: (n) => { if (retired.has(n.conversionId)) fired.push(n); }, + }); + const root = { flows: [converted] }; + return new Set(fired.map((n) => `${n.conversionId}|${n.from}|${siteOf(root, n.path)}`)); + }; + // The request's retired hits; each one a stored row carries is struck off. + const uncarried = retiredHits(request.item); + const strikeCarried = (body: unknown): void => { + if (uncarried.size === 0) return; + for (const hit of retiredHits(body)) uncarried.delete(hit); + }; 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; + if (target.head) { + strikeCarried(target.head.body); + if (uncarried.size === 0) return true; + if (state !== 'draft') return false; + } else if (state !== 'draft') { + return false; + } + const active = (await repo.headAt(ref, { state: 'active', packageId: target.packageId, member })).head; + if (!active) return false; + strikeCarried(active.body); + return uncarried.size === 0; } /** @@ -23025,6 +23094,12 @@ export class ObjectStackProtocolImplementation implements // 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. + // + // [#23903, ruling B′] So does a re-save whose body NEWLY INTRODUCES a + // retired spelling — a retired entry firing at a site where no stored + // row carries it (a node added with an old spelling): authored input, + // judged whole without the retired entries. A re-save whose retired + // hits the stored row all carries keeps the full chain, as above. 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. @@ -23034,8 +23109,9 @@ export class ObjectStackProtocolImplementation implements // No automation service reachable (control-plane / metadata-only // 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. + // yesterday, save the retired spelling on a new flow (#23775) or one + // a re-save newly introduces (#23903), which an engine host refuses + // too. const canonicalizeFlow = this.resolveFlowCanonicalizer(); let canonicalized = false; if (canonicalizeFlow) { From c3f5dec36c3c0e2dff99c1eb2acd72e939a762ce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:14:12 +0000 Subject: [PATCH 2/4] wip(rest): REST-door pins for a node added with a retired spelling (#23903) Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude --- ...rotocol.save-flow-canonicalization.test.ts | 6 +- .../meta-flow-save-retired-spelling.test.ts | 76 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) 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 89fad592d25..a6a401b27d2 100644 --- a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts +++ b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts @@ -801,8 +801,10 @@ describe('[#23903] a re-save that newly introduces a retired spelling is judged expect(body.nodes[0].config).toEqual({ objectName: 'lead', filter: { status: 'stale' } }); expect(body.nodes[1].config ?? {}).not.toHaveProperty('duration'); } else { - // No canonicalizer here: judged converted, stored as written. - expect(body).toEqual(served); + // No canonicalizer here: judged converted, stored as written + // (the read's own `_diagnostics` envelope is never stored). + const { _diagnostics: _served, ...asWritten } = served; + expect(body).toEqual(asWritten); } }); diff --git a/packages/rest/src/meta-flow-save-retired-spelling.test.ts b/packages/rest/src/meta-flow-save-retired-spelling.test.ts index 3b390f54431..cf1ae10dc97 100644 --- a/packages/rest/src/meta-flow-save-retired-spelling.test.ts +++ b/packages/rest/src/meta-flow-save-retired-spelling.test.ts @@ -224,3 +224,79 @@ describe('[#23775] PUT /meta/flow/:name — a retired spelling on a NEW flow is expect((await h.stored('draft')).map((b) => b.label)).toEqual(['Settle Order (draft)']); }); }); + +/** + * [#23903, ruling B′] A re-save that NEWLY INTRODUCES a retired spelling is + * refused like a new flow's: here a client adds a second `wait` node carrying + * the same loose `config.duration` the stored row already carries on `hold`. + * The stored row carries `hold`'s hit, not the added node's, so the save is + * judged without the retired entries — the whole body, so the refusal lists + * `hold`'s site beside the new one, and fixing either the new node alone or + * both lets it through. The served legacy body re-saves unchanged. + */ +describe('[#23903] PUT /meta/flow/:name — a node added with a retired spelling to a stored legacy flow is refused', () => { + /** The added `wait` as a client writes it: the same loose key, beside its own block. */ + const looseAdded: Record = { waitEventConfig: { eventType: 'timer', timerDuration: 'PT2M' }, config: { duration: 'PT3M' } }; + /** The stored legacy flow with a second `wait`, `hold_again`, between `hold` and `end`. */ + const withAddedHold = (label = 'Settle Order', added: Record = looseAdded) => { + const base = looseBesideBlock(); + return { + ...base, + label, + nodes: [ + base.nodes[0], + base.nodes[1], + { id: 'hold_again', type: 'wait', label: 'Hold again', ...added }, + base.nodes[2], + ], + edges: [ + { id: 'e1', source: 'start', target: 'hold' }, + { id: 'e2', source: 'hold', target: 'hold_again' }, + { id: 'e3', source: 'hold_again', target: 'end' }, + ], + }; + }; + /** The refusal names the added node's key and the key it moved to. */ + const expectAddedNodeRefused = (issues: Array<{ path: string; message: string }>) => { + expect(issues.map((i) => i.path)).toEqual(['nodes.1.config.duration', 'nodes.2.config.duration']); + expect(issues[1]!.message).toContain('waitEventConfig.timerDuration'); + }; + + it('publish: 422 INVALID_METADATA at the added node\'s `config.duration`, naming `waitEventConfig.timerDuration`; the stored row is untouched', async () => { + const h = await boot(); + await h.seed('active', looseBesideBlock()); + + expectAddedNodeRefused(refusedInvalid(await h.save(withAddedHold()))); + + expect(await h.stored('active')).toEqual([looseBesideBlock()]); + }); + + it('draft mode with no draft row yet (the active row is the baseline): refused the same way, nothing stored', async () => { + const h = await boot(); + await h.seed('active', looseBesideBlock()); + + expectAddedNodeRefused(refusedInvalid(await h.save(withAddedHold(), { mode: 'draft' }))); + + expect(await h.stored('draft')).toEqual([]); + }); + + it('draft mode over a stored draft row: refused the same way, the draft untouched', async () => { + const h = await boot(); + await h.seed('draft', looseBesideBlock()); + + expectAddedNodeRefused(refusedInvalid(await h.save(withAddedHold(), { mode: 'draft' }))); + + expect(await h.stored('draft')).toEqual([looseBesideBlock()]); + }); + + it('the served legacy body re-saves unchanged, and the added node saves once it is written in the canonical spelling', async () => { + const h = await boot(); + await h.seed('active', looseBesideBlock()); + const served = ok(await h.read()).item; + + ok(await h.save(served)); + + ok(await h.save(withAddedHold('Settle Order (edited)', { waitEventConfig: { eventType: 'timer', timerDuration: 'PT2M' } }))); + expect((await h.stored('active')).map((b) => b.label)).toEqual(['Settle Order (edited)']); + }); +}); From 0cef5ed0232f8f0c2e95cc0b5eb23a1fbc582271 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:23:29 +0000 Subject: [PATCH 3/4] docs(adr-0087): dated note for ruling B-prime; changeset for the flow re-save narrowing (#23903) Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude --- .../23903-flow-save-reintroduced-retired.md | 32 +++++++++++++++++++ ...0087-metadata-protocol-upgrade-contract.md | 2 ++ 2 files changed, 34 insertions(+) create mode 100644 .changeset/23903-flow-save-reintroduced-retired.md diff --git a/.changeset/23903-flow-save-reintroduced-retired.md b/.changeset/23903-flow-save-reintroduced-retired.md new file mode 100644 index 00000000000..3be44555b48 --- /dev/null +++ b/.changeset/23903-flow-save-reintroduced-retired.md @@ -0,0 +1,32 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +feat(metadata-protocol)!: a flow re-save that newly introduces a retired spelling is refused like a new flow's + +Clause-②: yes (narrowing) + +**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. + +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. + +Not changed: + +- 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. +- A new flow with a retired spelling is refused, as it was. +- 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 and the read path, which still serves a stored flow verbatim. + +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. + +- `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. + + diff --git a/docs/adr/0087-metadata-protocol-upgrade-contract.md b/docs/adr/0087-metadata-protocol-upgrade-contract.md index 5c2e851ea61..b441a3011cf 100644 --- a/docs/adr/0087-metadata-protocol-upgrade-contract.md +++ b/docs/adr/0087-metadata-protocol-upgrade-contract.md @@ -573,6 +573,8 @@ diverged (#3903). This addendum extends the contract to data at rest: > **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. +> **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. + ## 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 From f40fd15437dfa37b9cfb401e4d265d40f9863c74 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:34:03 +0000 Subject: [PATCH 4/4] fix(metadata-protocol): spell the index site without a bracket-opening template literal (#23903) Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index cd5449d4e55..a4c1e9696b4 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -6662,7 +6662,7 @@ export class ObjectStackProtocolImplementation implements } const element: unknown = Array.isArray(at) ? at[Number(index)] : undefined; const id = element !== null && typeof element === 'object' ? (element as { id?: unknown }).id : undefined; - site += typeof id === 'string' && id !== '' ? `[#${id}]` : `[${index}]`; + site += typeof id === 'string' && id !== '' ? `[#${id}]` : '[' + index + ']'; at = element; } return site;