You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Follow-up to #4498 (PR #4504), verified empirically before filing. Probe: boot the real stack, seed one pre-17 flow (delete_record + config.filters) and one pre-17 object (conditionalRequired, retired #3855), read each via getMetaItem, edit only the label, save back exactly what was served.
Measured result
READ serves
after edit+save, DISK holds
--stored preview after the edit
object
requiredWhen (canonical)
requiredWhen — edit healed the row
—
flow
filters (legacy, unconverted)
filters — edit changed nothing
still pending
Two seams compose into the asymmetry:
Read: getMetaItem canonicalizes every type through convertStoredItem — which returns flow untouched (the ADR-0078 conflict-guard skip). Studio is served the pre-protocol dialect.
Write: saveMetaItem's schema gate accepts it back — FlowNodeSchema.config is an open z.record, so config.filters is not off-spec, just legacy. No conversion runs on the write.
So the implicit contract "an author's explicit save leaves the row canonical" — which is exactly what the once-per-boot stored-conversion warning promises for every other type ("re-save it (Studio edit → save …) to persist the canonical shape") — holds for everything except flows. A flow row stays pending in the --stored report no matter how many times an author edits it; only the migration itself can retire it.
Why the write gate is the right seam (not the read)
The obvious-looking fix — canonicalize flow reads via resolveFlowCanonicalizer (#4498) — is the wrong one:
Reads are hot and list-shaped; canonicalizeStoredFlow runs a full parse + cycle detection + control-flow validation per body.
It throws on an invalid flow, and the read path's contract is availability over purity ("reads diagnose, never drop") — a broken row must stay readable and fixable in Studio.
Execution is already covered: registerFlow canonicalizes at the load seam.
The write gate is where ADR-0087's premise ("new rows are always canonical") lives, and it is the same seam #4498 already fixed for duplicatePackage. saveMetaItem is the one remaining producer-shaped path that accepts a legacy flow dialect — both on the Studio roundtrip and for any API caller that authors config.filters directly today.
Sketch
In saveMetaItem, for singular === 'flow' with an item present:
Parse throw → fall back to saving the raw item and let today's gate decide. canonicalizeStoredFlow is stricter than the schema alone (cycle detection, region validation); a work-in-progress draft with a temporary cycle must not become unsaveable when it saves fine today.
Idempotence keeps the other callers cheap: migrateStoredMetadata and duplicatePackage hand in already-canonical bodies, and the pass is copy-on-write, so the second run changes nothing.
Decision to settle at review: whether step 4's fallback also applies in publish mode or only for drafts — publish-refusing an unparseable flow is defensible (it cannot register anyway), but it is a behaviour change for bodies that save fine today.
Evidence trail
Probe harness identical to meta.stored-flow-resolution.integration.test.ts (real bootSchemaStack, inert automation plugin, SQLite on disk). One probe artifact worth knowing about for whoever picks this up: rows seeded raw carry checksum: null, and saveMetaItem's derived parentVersion then disagrees with the column, so the save 409s — pass an explicit parentVersion in probes. (Real rows written by any governed or loader path carry a checksum; not a production issue.)
Follow-up to #4498 (PR #4504), verified empirically before filing. Probe: boot the real stack, seed one pre-17
flow(delete_record+config.filters) and one pre-17object(conditionalRequired, retired #3855), read each viagetMetaItem, edit only thelabel, save back exactly what was served.Measured result
--storedpreview after the editobjectrequiredWhen(canonical)requiredWhen— edit healed the rowflowfilters(legacy, unconverted)filters— edit changed nothingpendingTwo seams compose into the asymmetry:
getMetaItemcanonicalizes every type throughconvertStoredItem— which returnsflowuntouched (the ADR-0078 conflict-guard skip). Studio is served the pre-protocol dialect.saveMetaItem's schema gate accepts it back —FlowNodeSchema.configis an openz.record, soconfig.filtersis not off-spec, just legacy. No conversion runs on the write.So the implicit contract "an author's explicit save leaves the row canonical" — which is exactly what the once-per-boot stored-conversion warning promises for every other type ("re-save it (Studio edit → save …) to persist the canonical shape") — holds for everything except flows. A flow row stays
pendingin the--storedreport no matter how many times an author edits it; only the migration itself can retire it.Why the write gate is the right seam (not the read)
The obvious-looking fix — canonicalize flow reads via
resolveFlowCanonicalizer(#4498) — is the wrong one:canonicalizeStoredFlowruns a full parse + cycle detection + control-flow validation per body.registerFlowcanonicalizes at the load seam.The write gate is where ADR-0087's premise ("new rows are always canonical") lives, and it is the same seam #4498 already fixed for
duplicatePackage.saveMetaItemis the one remaining producer-shaped path that accepts a legacy flow dialect — both on the Studio roundtrip and for any API caller that authorsconfig.filtersdirectly today.Sketch
In
saveMetaItem, forsingular === 'flow'with an item present:resolveFlowCanonicalizer()([P2]duplicatePackagestill mints pre-protocol flow rows — the "strictly shrinking" premise does not hold for flows #4498 — already lazy, already tested). Absent → save as today: a control-plane host must not start refusing flow writes it accepted yesterday.storable.duplicatePackage/[P3]os migrate meta --storedskips flow rows — the finish line stops short of the one type that needs the executor registry #4454.canonicalizeStoredFlowis stricter than the schema alone (cycle detection, region validation); a work-in-progress draft with a temporary cycle must not become unsaveable when it saves fine today.migrateStoredMetadataandduplicatePackagehand in already-canonical bodies, and the pass is copy-on-write, so the second run changes nothing.Decision to settle at review: whether step 4's fallback also applies in
publishmode or only for drafts — publish-refusing an unparseable flow is defensible (it cannot register anyway), but it is a behaviour change for bodies that save fine today.Evidence trail
Probe harness identical to
meta.stored-flow-resolution.integration.test.ts(realbootSchemaStack, inert automation plugin, SQLite on disk). One probe artifact worth knowing about for whoever picks this up: rows seeded raw carrychecksum: null, andsaveMetaItem's derivedparentVersionthen disagrees with the column, so the save 409s — pass an explicitparentVersionin probes. (Real rows written by any governed or loader path carry a checksum; not a production issue.)Refs #4498 (PR #4504), #4454, #4327, #3903, ADR-0087, ADR-0078.