Skip to content

Commit f61edce

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): saveMetaItem canonicalizes flow bodies on write (#4542) (#4548)
A Studio edit healed every legacy row except a flow's: reads serve stored flows verbatim (the ADR-0078 conflict guard needs the live executor registry), and FlowNodeSchema.config is an open z.record, so the served legacy dialect sailed back through the schema gate and re-persisted verbatim — the row stayed `pending` in `os migrate meta --stored` no matter how many times an author edited it. saveMetaItem now runs resolveFlowCanonicalizer (#4498) on flow bodies before the schema gate and persists `storable` (never the parsed shape — schema defaults stay excluded, ADR-0087). A refused node-type rename fails the save with 409 FLOW_CONVERSION_CONFLICT naming the token; a body the stricter canonicalizer cannot parse (cycles, regions) falls back to the raw save in draft and publish mode alike, so WIP drafts stay saveable; with no automation service reachable the save behaves exactly as before. Copy-on-write keeps migrateStoredMetadata / duplicatePackage re-entry free. Claude-Session: https://claude.ai/code/session_01NDmJ5ASMSzcw1q17vh32MG Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1261cda commit f61edce

5 files changed

Lines changed: 482 additions & 0 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): `saveMetaItem` canonicalizes flow bodies on write — a Studio edit now heals a legacy flow row like every other type's (#4542)
6+
7+
The once-per-boot stored-conversion warning promises that re-saving a row
8+
("Studio edit → save") persists the canonical shape. That held for every type
9+
except `flow`: the read path serves stored flows verbatim (the ADR-0078
10+
open-namespace conflict guard needs the engine's live executor registry, so
11+
`convertStoredItem` skips them), and `FlowNodeSchema.config` is an open
12+
`z.record`, so the legacy dialect an author was served (`config.filters`, pre-17
13+
node aliases) sailed back through `saveMetaItem`'s schema gate and re-persisted
14+
verbatim. A flow row stayed `pending` in `os migrate meta --stored` no matter
15+
how many times an author edited it — only the migration itself could retire it.
16+
17+
`saveMetaItem` now runs the #4498 resolver (`resolveFlowCanonicalizer`) on flow
18+
bodies **before** the schema gate and persists `storable` — conversions plus the
19+
derived condition envelopes, deliberately not the schema's defaults (ADR-0087).
20+
The pass is copy-on-write, so already-canonical bodies (including the ones
21+
`migrateStoredMetadata` and `duplicatePackage` hand in) are untouched.
22+
23+
Failure postures, same as the duplication seam:
24+
25+
- **A refused node-type rename** (the old token is a live name owned by a custom
26+
executor here) refuses the save with `409 FLOW_CONVERSION_CONFLICT`, naming
27+
the token and path — never a silent legacy persist. 409 rather than 422
28+
because the body may be perfectly valid: the refusal comes from environment
29+
state, so resubmitting the same body cannot help.
30+
- **A body the canonicalizer cannot parse** falls back to the raw save and
31+
today's schema gate — in draft AND publish mode. `canonicalizeStoredFlow` is
32+
stricter than the gate (cycle detection, control-flow regions), and a
33+
work-in-progress draft with a temporary cycle must not become unsaveable;
34+
`registerFlow` still refuses to arm a malformed flow either way.
35+
- **No automation service reachable** (a control-plane or metadata-only host):
36+
the save behaves exactly as before — a host must not start refusing flow
37+
writes it accepted yesterday. `os migrate meta --stored` reports what it
38+
could not canonicalize.
39+
40+
Reads are still unchanged — served bodies keep the stored dialect ("reads
41+
diagnose, never drop"); the heal happens on the way back in.

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,3 +532,29 @@ report still saying protocol N until the next run.
532532

533533
The premise is restored rather than restated: the stored pass shrinks because
534534
every write path now canonicalizes, not because the sentence says so.
535+
536+
## Addendum (2026-08-02) — the save seam itself (#4542)
537+
538+
"Every write path now canonicalizes" above was still one short. `duplicatePackage`
539+
was the *platform* producer; the ordinary Studio/REST save was a producer by
540+
round-trip: reads serve stored flows verbatim (deliberately — see 2026-07-31),
541+
`FlowNodeSchema.config` is an open `z.record`, so an author served the legacy
542+
dialect who edited a label and saved re-persisted that dialect — and the row
543+
stayed `pending` in the stored report no matter how many times it was edited.
544+
That contradicted the boot warning's own remediation text ("re-save it (Studio
545+
edit → save …) to persist the canonical shape"), which held for every type
546+
except the one it never fires for.
547+
548+
`saveMetaItem` now runs `resolveFlowCanonicalizer` on flow bodies before its
549+
schema gate and persists `storable`, with the same postures as the duplication
550+
seam: a refused rename fails the save loudly (`409 FLOW_CONVERSION_CONFLICT`,
551+
naming the token — the refusal comes from environment state, so it is not a 422
552+
the author can fix by editing the body); a body the stricter canonicalizer
553+
cannot parse (cycles, malformed regions) falls back to the raw save so a
554+
work-in-progress draft stays saveable, in draft and publish mode alike —
555+
`registerFlow` still refuses to arm it; no engine reachable saves as before.
556+
The pass is copy-on-write, so `migrateStoredMetadata` and `duplicatePackage`
557+
re-entering `saveMetaItem` with already-canonical bodies pay nothing.
558+
559+
Reads still skip flows, and now the loop is closed from the other side: a
560+
served legacy body is healed the moment it is saved back.

packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,70 @@ describe('os migrate meta --stored — the protocol resolves the engine itself (
155155
}
156156
}, 120_000);
157157

158+
it('a Studio edit heals the row — save persists the canonical dialect (#4542)', async () => {
159+
// The other half of the acceptance: the migration is no longer the ONLY
160+
// path that retires a legacy flow row. An author's ordinary round-trip —
161+
// GET (served the legacy dialect, per the ADR-0078 read skip) → edit a
162+
// label → PUT the body back — used to re-persist `config.filters`
163+
// verbatim and leave the row `pending` forever; `saveMetaItem` now
164+
// canonicalizes flow bodies before its schema gate.
165+
const stack = await bootSchemaStack({
166+
databaseUrl: `file:${dbFile}`,
167+
projectRoot: dir,
168+
extraPlugins: await buildDataMigrationPlugins({ automation: true }),
169+
});
170+
try {
171+
const ql = engineOf(stack);
172+
await ql.insert('sys_metadata', {
173+
type: 'flow',
174+
name: 'sfs_purge',
175+
state: 'active',
176+
metadata: JSON.stringify(LEGACY_FLOW),
177+
}, SYSTEM);
178+
179+
const protocol: any = stack.kernel.getService('protocol');
180+
181+
// The read serves the stored (legacy) dialect — that skip is deliberate
182+
// and unchanged; the heal happens on the way back in.
183+
const served = await protocol.getMetaItem({ type: 'flow', name: 'sfs_purge' });
184+
const item = served?.item ?? served;
185+
expect(item.nodes.find((n: any) => n.id === 'n1').config).toHaveProperty('filters');
186+
187+
// Edit only the label — exactly the probe from #4542. Explicit
188+
// `parentVersion: null`: a raw-seeded row has `checksum: null`, so the
189+
// derived parent would disagree with the column and 409 (probe-only
190+
// artifact; governed rows always carry a checksum).
191+
await protocol.saveMetaItem({
192+
type: 'flow',
193+
name: 'sfs_purge',
194+
item: { ...item, label: 'Purge Stale Leads (edited)' },
195+
parentVersion: null,
196+
actor: 'studio-roundtrip-probe',
197+
});
198+
199+
const [row] = await ql.find('sys_metadata', {
200+
where: { type: 'flow', name: 'sfs_purge', state: 'active' },
201+
}, SYSTEM);
202+
const stored = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata;
203+
expect(stored.label).toBe('Purge Stale Leads (edited)');
204+
const node = stored.nodes.find((n: any) => n.id === 'n1');
205+
expect(node.config).toEqual({ objectName: 'sfs_lead', filter: { title: 'stale' } });
206+
expect(node.config).not.toHaveProperty('filters');
207+
// Still no schema defaults — the save persists `storable`, not `parsed`.
208+
expect(stored).not.toHaveProperty('runAs');
209+
210+
// The row the edit healed is retired from the stored report: the
211+
// `--stored` preview that stayed `pending` "no matter how many times an
212+
// author edits it" now comes back canonical.
213+
const preview = await protocol.migrateStoredMetadata({ types: ['flow'] });
214+
expect(preview.scanned).toBe(1);
215+
expect(preview.canonical).toBe(1);
216+
expect(preview.pending).toBe(0);
217+
} finally {
218+
await stack.shutdown();
219+
}
220+
}, 120_000);
221+
158222
it('without the automation plugin the row is skipped with the reason, never counted done', async () => {
159223
// The honest negative: the coverage comes from the engine being present,
160224
// not from the report defaulting to optimistic.

0 commit comments

Comments
 (0)