Skip to content

Commit 3b98866

Browse files
committed
fix(metadata-protocol): a flow save that skipped canonicalization says so (#4580)
`saveMetaItem` canonicalizes flow bodies before the schema gate (#4542). When the canonicalizer throws — it is stricter than the gate: strict parse, cycle detection, control-flow region validation — the save falls back to the raw body so a WIP draft with a temporary cycle stays saveable. That fallback is correct and unchanged here. It was also completely silent. Three of the four postures at this seam announce themselves: a clean pass heals the row, a refused rename fails with 409 FLOW_CONVERSION_CONFLICT naming the token, and a host with no automation service is reported by `os migrate meta --stored`. The throw-fallback said nothing — so a save that skipped canonicalization was indistinguishable from one that healed the row, and a body that is BOTH a legacy dialect and unparseable re-persisted verbatim. That is the #4542 symptom arriving silently, against a boot warning that tells the author re-saving is the remedy. The fallback now warns, naming the flow and the canonicalizer's own error, deduped once per flow per process (the `convertStoredItem` pattern — Studio autosaves the same draft repeatedly and a WIP cycle throws on every write). This aligns the write seam with ADR-0087 D2's "loud" posture. No behavior change: the body still saves, the gate stays the arbiter, and `registerFlow` still refuses to arm a malformed flow. Refusing the save in publish mode was considered and rejected — publish is the default mode, so it would silently tighten validation for every existing caller, and it can only be enforced where an automation service exists, making the same body saveable on a control-plane host and a 422 on an automation host. Tests: 5 new cases in protocol.save-flow-canonicalization.test.ts — warns with the flow name and reason; deduped across repeat saves; silent on the clean, conflict, and no-service paths. Full metadata-protocol suite green (206). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDmJ5ASMSzcw1q17vh32MG
1 parent dafb390 commit 3b98866

3 files changed

Lines changed: 159 additions & 2 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): a flow save that skipped canonicalization says so (#4580)
6+
7+
`saveMetaItem` canonicalizes flow bodies before the schema gate (#4542). When the
8+
canonicalizer throws — it is stricter than the gate: strict parse, cycle
9+
detection, control-flow region validation — the save falls back to the raw body
10+
so a work-in-progress draft with a temporary cycle stays saveable. That fallback
11+
is correct and unchanged. It was also completely silent.
12+
13+
Of the four postures at this seam, three announce themselves: a clean
14+
canonicalization heals the row, a refused rename fails with `409
15+
FLOW_CONVERSION_CONFLICT` naming the token, and a host with no automation service
16+
is reported by `os migrate meta --stored`. The throw-fallback said nothing, so a
17+
save that skipped canonicalization was indistinguishable from one that healed the
18+
row — and a body that is *both* a legacy dialect and unparseable by the strict
19+
canonicalizer re-persisted verbatim. That is the exact #4542 symptom, arriving
20+
silently, while the boot warning for legacy stored rows tells the author that
21+
re-saving is the remedy.
22+
23+
The fallback now emits a `console.warn` naming the flow and the canonicalizer's
24+
own error, deduped once per flow per process (the `convertStoredItem` pattern —
25+
Studio autosaves the same draft repeatedly, and a WIP cycle throws on every
26+
write). This aligns the write seam with ADR-0087 D2's "loud" posture, where
27+
conversions emit notices, reads warn once per row, and `migrateStoredMetadata`
28+
reports `failed` with the message.
29+
30+
No behavior change: the body still saves, the schema gate stays the arbiter, and
31+
`registerFlow` still refuses to arm a malformed flow. Refusing the save in
32+
publish mode was considered and rejected — publish is the default mode, so it
33+
would silently tighten validation for every existing caller, and it could only be
34+
enforced on hosts that have an automation service, making the same body saveable
35+
on a control-plane host and a 422 on an automation host.

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

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* — the existing flow-canonicalizer harness mocks `saveMetaItem` itself, which
1717
* a fix INSIDE `saveMetaItem` cannot use.
1818
*/
19-
import { describe, expect, it, vi } from 'vitest';
19+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2020
import { hashSpec } from '@objectstack/metadata-core';
2121
import { ObjectStackProtocolImplementation } from './protocol.js';
2222

@@ -143,6 +143,20 @@ const save = (protocol: any, item: unknown, extra: Record<string, unknown> = {})
143143
describe('saveMetaItem canonicalizes flow bodies (#4542)', () => {
144144
const legacyBody = () => flowBody({ objectName: 'lead', filters: { status: 'stale' } });
145145

146+
let warn: ReturnType<typeof vi.spyOn>;
147+
beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); });
148+
afterEach(() => { warn.mockRestore(); });
149+
150+
/**
151+
* Only the throw-fallback's own warnings. The protocol emits unrelated
152+
* one-shot warnings (e.g. #3770's "engine has no schema registry"), and
153+
* whether one has already fired depends on test order — matching on the
154+
* message keeps these assertions immune to that.
155+
*/
156+
const fallbackWarnings = (): string[] => (warn.mock.calls as unknown[][])
157+
.map((c: unknown[]) => String(c[0]))
158+
.filter((m: string) => m.includes('WITHOUT canonicalization'));
159+
146160
it('a legacy dialect is healed by the save — the promise every other type already keeps', async () => {
147161
const { protocol, rows } = makeProtocol(
148162
new Map([['automation', { canonicalizeStoredFlow }]]),
@@ -278,6 +292,81 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => {
278292
expect(spy.mock.calls[0][0]).toBe('purge_flow');
279293
});
280294

295+
// ── #4580: the throw-fallback is correct, but it must not be silent ──
296+
297+
it('a throw-fallback SAYS SO — naming the flow and the canonicalizer\'s own reason', async () => {
298+
// Before #4580 this posture was the only one with no signal at all: a
299+
// save that skipped canonicalization looked exactly like one that
300+
// healed the row, and a body that is BOTH legacy and unparseable
301+
// re-persisted verbatim while the boot warning told the author that
302+
// re-saving would fix it.
303+
const throwing = () => { throw new Error('cycle detected: n1 → n1'); };
304+
const { protocol } = makeProtocol(
305+
new Map([['automation', { canonicalizeStoredFlow: throwing }]]),
306+
);
307+
308+
await save(protocol, legacyBody());
309+
310+
expect(fallbackWarnings()).toHaveLength(1);
311+
const msg = fallbackWarnings()[0];
312+
expect(msg).toContain('flow/purge_flow');
313+
expect(msg).toContain('cycle detected: n1 → n1');
314+
expect(msg).toContain('os migrate meta --stored');
315+
});
316+
317+
it('the fallback warning is deduped per flow — Studio autosave must not spam', async () => {
318+
const throwing = () => { throw new Error('cycle detected: n1 → n1'); };
319+
const { protocol } = makeProtocol(
320+
new Map([['automation', { canonicalizeStoredFlow: throwing }]]),
321+
);
322+
323+
await save(protocol, legacyBody());
324+
await save(protocol, legacyBody());
325+
await save(protocol, legacyBody());
326+
327+
expect(fallbackWarnings()).toHaveLength(1);
328+
});
329+
330+
it('the clean path stays silent — a healed row needs no warning', async () => {
331+
const { protocol } = makeProtocol(
332+
new Map([['automation', { canonicalizeStoredFlow }]]),
333+
);
334+
335+
await save(protocol, legacyBody());
336+
337+
expect(fallbackWarnings()).toHaveLength(0);
338+
});
339+
340+
it('the conflict path stays silent — the 409 IS the signal', async () => {
341+
const conflicting = () => ({
342+
storable: {},
343+
notices: [],
344+
conflicts: [{
345+
conversionId: 'flow-node-type-open-namespace',
346+
token: 'http_request',
347+
path: 'flows[0].nodes[0].type',
348+
message: 'a custom executor owns this name here.',
349+
}],
350+
});
351+
const { protocol } = makeProtocol(
352+
new Map([['automation', { canonicalizeStoredFlow: conflicting }]]),
353+
);
354+
355+
await expect(save(protocol, legacyBody())).rejects.toMatchObject({ status: 409 });
356+
357+
expect(fallbackWarnings()).toHaveLength(0);
358+
});
359+
360+
it('a host with no automation service stays silent — nothing was skipped', async () => {
361+
// There is no canonicalizer to fall back FROM; `os migrate meta
362+
// --stored` is what reports these rows.
363+
const { protocol } = makeProtocol(new Map());
364+
365+
await save(protocol, legacyBody());
366+
367+
expect(fallbackWarnings()).toHaveLength(0);
368+
});
369+
281370
it('non-flow saves never consult the canonicalizer', async () => {
282371
const spy = vi.fn(canonicalizeStoredFlow);
283372
const { protocol } = makeProtocol(

packages/metadata-protocol/src/protocol.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1776,6 +1776,14 @@ export class ObjectStackProtocolImplementation implements
17761776
*/
17771777
private storedConversionWarned = new Set<string>();
17781778

1779+
/**
1780+
* Once-per-process dedupe (`type|name`) for the warning `saveMetaItem`
1781+
* emits when the flow canonicalizer throws and the save falls back to the
1782+
* raw body (#4580). Studio autosaves the same draft over and over, and a
1783+
* WIP cycle throws on every one of them.
1784+
*/
1785+
private flowCanonicalizeFallbackWarned = new Set<string>();
1786+
17791787
/**
17801788
* Canonicalize a stored `sys_metadata` body on rehydration (#3903;
17811789
* ADR-0087 addendum "stored metadata replays the chain").
@@ -6307,14 +6315,39 @@ export class ObjectStackProtocolImplementation implements
63076315
let result: StoredFlowCanonicalization | undefined;
63086316
try {
63096317
result = canonicalizeFlow(request.name, request.item);
6310-
} catch {
6318+
} catch (e: any) {
63116319
// `canonicalizeStoredFlow` is STRICTER than the gate below
63126320
// (strict parse + cycle detection + control-flow region
63136321
// validation). A work-in-progress draft with a temporary
63146322
// cycle must stay saveable, so fall back to the raw body
63156323
// and let today's gate stay the arbiter — in draft AND
63166324
// publish mode; `registerFlow` refuses to arm a malformed
63176325
// flow either way.
6326+
//
6327+
// Say so (#4580). The fallback is correct but it is the one
6328+
// posture here with no signal of its own: a save that
6329+
// skipped canonicalization is otherwise indistinguishable
6330+
// from one that healed the row, and a body that is BOTH a
6331+
// legacy dialect and unparseable re-persists verbatim —
6332+
// the #4542 symptom, silently, against a boot warning that
6333+
// told the author re-saving would fix it. Every other link
6334+
// in the chain is loud (ADR-0087 D2): conversions emit
6335+
// notices, `convertStoredItem` warns on read,
6336+
// `migrateStoredMetadata` reports `failed`.
6337+
//
6338+
// Deduped per flow per process, like {@link
6339+
// storedConversionWarned} — Studio autosave writes the same
6340+
// draft repeatedly and this must not become a spam loop.
6341+
const key = `${singularType}|${request.name}`;
6342+
if (!this.flowCanonicalizeFallbackWarned.has(key)) {
6343+
this.flowCanonicalizeFallbackWarned.add(key);
6344+
console.warn(
6345+
`[Protocol] flow/${request.name} was saved WITHOUT canonicalization: `
6346+
+ `${e?.message ?? String(e)} The body was persisted as submitted, so a `
6347+
+ `pre-protocol shape in it stays legacy on disk. Run `
6348+
+ `"os migrate meta --stored" to see the row's status.`,
6349+
);
6350+
}
63186351
result = undefined;
63196352
}
63206353
if (result) {

0 commit comments

Comments
 (0)