Skip to content

Commit 705e5c8

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): a flow save that skipped canonicalization says so (#4580) (#4582)
`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). Claude-Session: https://claude.ai/code/session_01NDmJ5ASMSzcw1q17vh32MG Co-authored-by: Claude <noreply@anthropic.com>
1 parent 355e951 commit 705e5c8

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
@@ -1719,6 +1719,14 @@ export class ObjectStackProtocolImplementation implements
17191719
*/
17201720
private storedConversionWarned = new Set<string>();
17211721

1722+
/**
1723+
* Once-per-process dedupe (`type|name`) for the warning `saveMetaItem`
1724+
* emits when the flow canonicalizer throws and the save falls back to the
1725+
* raw body (#4580). Studio autosaves the same draft over and over, and a
1726+
* WIP cycle throws on every one of them.
1727+
*/
1728+
private flowCanonicalizeFallbackWarned = new Set<string>();
1729+
17221730
/**
17231731
* Canonicalize a stored `sys_metadata` body on rehydration (#3903;
17241732
* ADR-0087 addendum "stored metadata replays the chain").
@@ -6250,14 +6258,39 @@ export class ObjectStackProtocolImplementation implements
62506258
let result: StoredFlowCanonicalization | undefined;
62516259
try {
62526260
result = canonicalizeFlow(request.name, request.item);
6253-
} catch {
6261+
} catch (e: any) {
62546262
// `canonicalizeStoredFlow` is STRICTER than the gate below
62556263
// (strict parse + cycle detection + control-flow region
62566264
// validation). A work-in-progress draft with a temporary
62576265
// cycle must stay saveable, so fall back to the raw body
62586266
// and let today's gate stay the arbiter — in draft AND
62596267
// publish mode; `registerFlow` refuses to arm a malformed
62606268
// flow either way.
6269+
//
6270+
// Say so (#4580). The fallback is correct but it is the one
6271+
// posture here with no signal of its own: a save that
6272+
// skipped canonicalization is otherwise indistinguishable
6273+
// from one that healed the row, and a body that is BOTH a
6274+
// legacy dialect and unparseable re-persists verbatim —
6275+
// the #4542 symptom, silently, against a boot warning that
6276+
// told the author re-saving would fix it. Every other link
6277+
// in the chain is loud (ADR-0087 D2): conversions emit
6278+
// notices, `convertStoredItem` warns on read,
6279+
// `migrateStoredMetadata` reports `failed`.
6280+
//
6281+
// Deduped per flow per process, like {@link
6282+
// storedConversionWarned} — Studio autosave writes the same
6283+
// draft repeatedly and this must not become a spam loop.
6284+
const key = `${singularType}|${request.name}`;
6285+
if (!this.flowCanonicalizeFallbackWarned.has(key)) {
6286+
this.flowCanonicalizeFallbackWarned.add(key);
6287+
console.warn(
6288+
`[Protocol] flow/${request.name} was saved WITHOUT canonicalization: `
6289+
+ `${e?.message ?? String(e)} The body was persisted as submitted, so a `
6290+
+ `pre-protocol shape in it stays legacy on disk. Run `
6291+
+ `"os migrate meta --stored" to see the row's status.`,
6292+
);
6293+
}
62616294
result = undefined;
62626295
}
62636296
if (result) {

0 commit comments

Comments
 (0)