Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/save-meta-item-flow-canonicalization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@objectstack/metadata-protocol": patch
---

fix(metadata-protocol): `saveMetaItem` canonicalizes flow bodies on write — a Studio edit now heals a legacy flow row like every other type's (#4542)

The once-per-boot stored-conversion warning promises that re-saving a row
("Studio edit → save") persists the canonical shape. That held for every type
except `flow`: the read path serves stored flows verbatim (the ADR-0078
open-namespace conflict guard needs the engine's live executor registry, so
`convertStoredItem` skips them), and `FlowNodeSchema.config` is an open
`z.record`, so the legacy dialect an author was served (`config.filters`, pre-17
node aliases) sailed back through `saveMetaItem`'s schema gate and re-persisted
verbatim. A flow row stayed `pending` in `os migrate meta --stored` no matter
how many times an author edited it — only the migration itself could retire it.

`saveMetaItem` now runs the #4498 resolver (`resolveFlowCanonicalizer`) on flow
bodies **before** the schema gate and persists `storable` — conversions plus the
derived condition envelopes, deliberately not the schema's defaults (ADR-0087).
The pass is copy-on-write, so already-canonical bodies (including the ones
`migrateStoredMetadata` and `duplicatePackage` hand in) are untouched.

Failure postures, same as the duplication seam:

- **A refused node-type rename** (the old token is a live name owned by a custom
executor here) refuses the save with `409 FLOW_CONVERSION_CONFLICT`, naming
the token and path — never a silent legacy persist. 409 rather than 422
because the body may be perfectly valid: the refusal comes from environment
state, so resubmitting the same body cannot help.
- **A body the canonicalizer cannot parse** falls back to the raw save and
today's schema gate — in draft AND publish mode. `canonicalizeStoredFlow` is
stricter than the gate (cycle detection, control-flow regions), and a
work-in-progress draft with a temporary cycle must not become unsaveable;
`registerFlow` still refuses to arm a malformed flow either way.
- **No automation service reachable** (a control-plane or metadata-only host):
the save behaves exactly as before — a host must not start refusing flow
writes it accepted yesterday. `os migrate meta --stored` reports what it
could not canonicalize.

Reads are still unchanged — served bodies keep the stored dialect ("reads
diagnose, never drop"); the heal happens on the way back in.
26 changes: 26 additions & 0 deletions docs/adr/0087-metadata-protocol-upgrade-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,3 +532,29 @@ report still saying protocol N until the next run.

The premise is restored rather than restated: the stored pass shrinks because
every write path now canonicalizes, not because the sentence says so.

## Addendum (2026-08-02) — the save seam itself (#4542)

"Every write path now canonicalizes" above was still one short. `duplicatePackage`
was the *platform* producer; the ordinary Studio/REST save was a producer by
round-trip: reads serve stored flows verbatim (deliberately — see 2026-07-31),
`FlowNodeSchema.config` is an open `z.record`, so an author served the legacy
dialect who edited a label and saved re-persisted that dialect — and the row
stayed `pending` in the stored report no matter how many times it was edited.
That contradicted the boot warning's own remediation text ("re-save it (Studio
edit → save …) to persist the canonical shape"), which held for every type
except the one it never fires for.

`saveMetaItem` now runs `resolveFlowCanonicalizer` on flow bodies before its
schema gate and persists `storable`, with the same postures as the duplication
seam: a refused rename fails the save loudly (`409 FLOW_CONVERSION_CONFLICT`,
naming the token — the refusal comes from environment state, so it is not a 422
the author can fix by editing the body); a body the stricter canonicalizer
cannot parse (cycles, malformed regions) falls back to the raw save so a
work-in-progress draft stays saveable, in draft and publish mode alike —
`registerFlow` still refuses to arm it; no engine reachable saves as before.
The pass is copy-on-write, so `migrateStoredMetadata` and `duplicatePackage`
re-entering `saveMetaItem` with already-canonical bodies pay nothing.

Reads still skip flows, and now the loop is closed from the other side: a
served legacy body is healed the moment it is saved back.
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,70 @@ describe('os migrate meta --stored — the protocol resolves the engine itself (
}
}, 120_000);

it('a Studio edit heals the row — save persists the canonical dialect (#4542)', async () => {
// The other half of the acceptance: the migration is no longer the ONLY
// path that retires a legacy flow row. An author's ordinary round-trip —
// GET (served the legacy dialect, per the ADR-0078 read skip) → edit a
// label → PUT the body back — used to re-persist `config.filters`
// verbatim and leave the row `pending` forever; `saveMetaItem` now
// canonicalizes flow bodies before its schema gate.
const stack = await bootSchemaStack({
databaseUrl: `file:${dbFile}`,
projectRoot: dir,
extraPlugins: await buildDataMigrationPlugins({ automation: true }),
});
try {
const ql = engineOf(stack);
await ql.insert('sys_metadata', {
type: 'flow',
name: 'sfs_purge',
state: 'active',
metadata: JSON.stringify(LEGACY_FLOW),
}, SYSTEM);

const protocol: any = stack.kernel.getService('protocol');

// The read serves the stored (legacy) dialect — that skip is deliberate
// and unchanged; the heal happens on the way back in.
const served = await protocol.getMetaItem({ type: 'flow', name: 'sfs_purge' });
const item = served?.item ?? served;
expect(item.nodes.find((n: any) => n.id === 'n1').config).toHaveProperty('filters');

// Edit only the label — exactly the probe from #4542. Explicit
// `parentVersion: null`: a raw-seeded row has `checksum: null`, so the
// derived parent would disagree with the column and 409 (probe-only
// artifact; governed rows always carry a checksum).
await protocol.saveMetaItem({
type: 'flow',
name: 'sfs_purge',
item: { ...item, label: 'Purge Stale Leads (edited)' },
parentVersion: null,
actor: 'studio-roundtrip-probe',
});

const [row] = await ql.find('sys_metadata', {
where: { type: 'flow', name: 'sfs_purge', state: 'active' },
}, SYSTEM);
const stored = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata;
expect(stored.label).toBe('Purge Stale Leads (edited)');
const node = stored.nodes.find((n: any) => n.id === 'n1');
expect(node.config).toEqual({ objectName: 'sfs_lead', filter: { title: 'stale' } });
expect(node.config).not.toHaveProperty('filters');
// Still no schema defaults — the save persists `storable`, not `parsed`.
expect(stored).not.toHaveProperty('runAs');

// The row the edit healed is retired from the stored report: the
// `--stored` preview that stayed `pending` "no matter how many times an
// author edits it" now comes back canonical.
const preview = await protocol.migrateStoredMetadata({ types: ['flow'] });
expect(preview.scanned).toBe(1);
expect(preview.canonical).toBe(1);
expect(preview.pending).toBe(0);
} finally {
await stack.shutdown();
}
}, 120_000);

it('without the automation plugin the row is skipped with the reason, never counted done', async () => {
// The honest negative: the coverage comes from the engine being present,
// not from the report defaulting to optimistic.
Expand Down
Loading
Loading