Skip to content

Commit 304423e

Browse files
os-zhuangclaude
andauthored
feat(automation,migrate): os migrate meta --stored covers flow rows (#4454) (#4492)
* feat(automation,metadata-protocol): one flow canonicalization policy, reusable off the load seam (#4454) `os migrate meta --stored` (#4327) covers every metadata type except `flow`, because flow-node conversions carry ADR-0078's open-namespace conflict guard and that needs the automation engine's live executor registry. This is the half both possible hosts need. `AutomationEngine.canonicalizeStoredFlow` is now the single policy, and `registerFlow` calls it — so the load seam and any stored-row migration can never disagree about what "canonical" means. It returns two shapes from one pass: - `parsed` for EXECUTION — FlowSchema.parse + the #4347 region pass, schema defaults materialized. What registerFlow runs. - `storable` for PERSISTENCE — conversions plus the `{dialect, source}` envelopes the schema derives for edge conditions, and deliberately nothing else. Excluding schema defaults is the load-bearing decision, and it was measured rather than assumed. Driving a pre-17 flow through all three steps shows parse + normalize REMOVE nothing (FlowSchema is strict since #4001, so an unknown key throws instead of being dropped — the graftNormalizedOperators precedent does not transfer) and ADD only defaults: `version`, `runAs`, per-edge `type` / `isDefault`. Persisting a default the author never wrote would pin every migrated row to today's value while untouched rows follow tomorrow's — two populations with different behaviour, which is the drift this pass exists to remove. A migration must not become a source of it. `migrateStoredMetadata` gains an optional `canonicalizeFlow` hook. Without it, flow rows keep reporting `skipped` with the reason. With it: conversions are applied and reported per row, a refused rename (the guard firing over a live third-party node type) fails the row loudly naming the token, and a flow that cannot canonicalize at all is reported rather than persisted as a guess. One subtlety the tests pin: the condition envelope is a schema transform, not a conversion, so it emits NO notice while still changing the body. Reading notices alone — correct for every other type — would call such a row canonical and leave it re-deriving on every boot. Both passes are copy-on-write, so identity (`storable === body`) is the exact test for flows. No host is wired yet: booting the automation plugin in the CLI would arm triggers and schedulers (registerFlow activates them), so that needs either an inert plugin mode or the admin route. Tracked in #4454. Refs #4327, #3903, #4001, #4347, ADR-0078, ADR-0087 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f * feat(automation,migrate): os migrate meta --stored covers flow rows (#4454) The stored-metadata pass (#4327) skipped `flow` — the one type where the most stored dialect actually lives, since the graduated conversions `flow-node-crud-filter-alias`, `-object-alias`, `-notify-config-aliases` and `-script-config-aliases` are all flow-node entries. Flow-node conversions carry ADR-0078's open-namespace conflict guard, which must consult the LIVE executor registry to tell a rename from a clobber, and the metadata layer cannot obtain one. This wires the engine in without wiring in a server. `AutomationServicePluginOptions.armRuntime` (default true — every existing host is unaffected). With `false` the plugin brings up the engine and the COMPLETE node registry — built-ins plus whatever `automation:ready` contributes, because a partial registry would make the guard read a live custom node type as unowned and rewrite over it — then stops before anything is armed: - no flow registered (registerFlow calls activateFlowTrigger, so record triggers and scheduled jobs would go live); - no kernel:ready / metadata:reloaded re-sync (skipping only the boot pull would arm them a moment later); - no declarative connector materialized (an MCP provider spawns a process); - no suspended wait-timer re-armed (it RESUMES paused runs — a migration that silently continues someone's approval is indefensible). `os migrate meta --stored` boots it in that mode and passes `canonicalizeStoredFlow` as the protocol's `canonicalizeFlow` hook. A migration process must not become a second server. `IAutomationService.canonicalizeStoredFlow` is declared on the contract rather than only on the implementation, because the CLI consumes it through the `automation` slot — which is exactly what the slot-lookup lint rule asks for instead of erasing the lookup to `any`. Verified against a real database, not only unit tests: a legacy flow row (`config.filters`) seeded into examples/app-todo migrates preview(exit 1) → apply → re-run(exit 0), the row comes back with `config.filter`, a `sys_metadata_history` entry sourced `migrate-stored`, and — the load-bearing part — NO schema defaults written (`version`, `runAs`, per-edge `type` are absent), so the row is not frozen on today's default values. Boot logs confirm 17 executors registered and then "inert mode … no trigger or schedule armed". Refs #4327, #3903, #4001, #4347, ADR-0078, ADR-0087 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d449b0c commit 304423e

14 files changed

Lines changed: 894 additions & 26 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
"@objectstack/metadata-protocol": minor
4+
"@objectstack/cli": patch
5+
---
6+
7+
feat(automation,migrate): `os migrate meta --stored` now covers flow rows too (#4454)
8+
9+
#4327 gave the stored-metadata conversion chain a finish line for every
10+
metadata type except `flow` — the one type where the most stored dialect
11+
actually lives, since the graduated conversions `flow-node-crud-filter-alias`,
12+
`flow-node-crud-object-alias`, `flow-node-notify-config-aliases` and
13+
`flow-node-script-config-aliases` are all flow-node entries. Flow-node
14+
conversions carry ADR-0078's open-namespace conflict guard, which has to consult
15+
the *live* executor registry to tell a rename from a clobber, and the metadata
16+
layer has no way to obtain one. Flows were reported `skipped` with that reason.
17+
They are now converted.
18+
19+
**One canonicalization policy, two shapes.**
20+
`AutomationEngine.canonicalizeStoredFlow` is the single implementation and
21+
`registerFlow` calls it, so the load seam and the migration can never disagree
22+
about what "canonical" means. It returns `parsed` (for execution — the
23+
`FlowSchema.parse` + #4347 region output, schema defaults materialized) and
24+
`storable` (for persistence).
25+
26+
**`storable` excludes schema defaults, and that is the load-bearing decision.**
27+
Measured rather than assumed: driving a pre-17 flow through all three steps
28+
*removes* nothing — `FlowSchema` is strict since #4001, so an unrecognized key
29+
throws instead of being silently dropped, which means the
30+
`graftNormalizedOperators` precedent (it exists because the *view* parse strips
31+
Studio-only auxiliary keys) does not transfer — and *adds* only defaults:
32+
`version`, `runAs`, per-edge `type` / `isDefault`. Persisting a default the
33+
author never wrote would pin every migrated row to today's value while untouched
34+
rows follow tomorrow's: two populations with different behaviour, which is
35+
exactly the drift this pass exists to remove. So the write-back is the
36+
conversion result plus the `{dialect, source}` envelopes the schema derives for
37+
edge conditions, and nothing else.
38+
39+
One subtlety worth knowing if you extend this: that envelope is a schema
40+
transform, not a conversion, so it emits **no** notice while still changing the
41+
body. Reading notices alone — correct for every other metadata type — would call
42+
such a row canonical and leave it re-deriving on every boot. Both passes are
43+
copy-on-write, so identity is the exact test for flows.
44+
45+
**New: `AutomationServicePluginOptions.armRuntime`** (default `true`, so every
46+
server, dev stack and test host is unaffected). Set `false` and the plugin
47+
brings up the engine and the complete node registry — built-ins plus whatever
48+
`automation:ready` contributes, because a *partial* registry would make the
49+
conflict guard read a live custom node type as unowned and rewrite over it — and
50+
then stops before anything is armed:
51+
52+
| Skipped when `armRuntime: false` | Why it must be |
53+
|---|---|
54+
| flow pull + `kernel:ready` / `metadata:reloaded` re-sync | `registerFlow` calls `activateFlowTrigger` — record triggers and scheduled jobs would go live |
55+
| declarative connector materialization | opens real connections; an MCP provider spawns a child process |
56+
| suspended-run wait-timer re-arm | would resume someone's paused approval mid-migration |
57+
58+
`os migrate meta --stored` boots the plugin in that mode. A migration process
59+
must not become a second server.
60+
61+
A refused rename — the guard firing because the old node-type token is a live
62+
name something else owns in this environment — fails that row loudly, naming the
63+
token and its owner. Never a silent skip, never a clobber. A flow that cannot
64+
canonicalize at all (a strict-schema violation, a malformed control-flow region)
65+
is reported as failed with the parse message rather than persisted as a guess;
66+
such a row cannot register today either, so the report is telling you about a
67+
flow that is already broken at runtime.

content/docs/deployment/cli.mdx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -809,9 +809,21 @@ counting as done:
809809

810810
| Not rewritten | Why |
811811
| :--- | :--- |
812-
| `flow` rows | Flow-node conversions carry a conflict guard that needs the automation engine's live executor registry; flows canonicalize at their own seam when the engine loads them |
813812
| Types with no repository write path (`agent`) | Their write path records no history and would force a draft live — a half-write is worse than leaving the row to the read path |
814813
| Rows that still fail the current schema after conversion | That is a genuine contract violation, not chain-owned history. The write path's rejection is correct; fix the row in Studio |
814+
| A flow whose rename the conflict guard refused | The old node-type token is a live name something else owns here. Rewriting would clobber that owner, so the row fails loudly naming the token — never a silent skip |
815+
816+
**Flows are covered, and cost one extra plugin.** Flow-node conversions carry an
817+
open-namespace conflict guard that has to consult the *live* executor registry
818+
to tell a rename from a clobber, so this run boots the automation engine — in an
819+
inert mode that installs the node registry and then arms nothing: no flow
820+
registered, no record trigger or scheduled job bound, no connector
821+
materialized, no suspended run resumed. A migration process must not become a
822+
second server. What gets written back for a flow is the conversion result plus
823+
the `condition` envelopes the schema derives, and deliberately **not** the
824+
schema's defaults (`version`, `runAs`, per-edge `type`) — persisting a default
825+
the author never wrote would pin that row to today's value while untouched rows
826+
follow tomorrow's, which is the drift this command exists to remove.
815827

816828
<Callout type="warn">
817829
`--apply` is the only writing mode, and it rewrites **metadata** — each affected

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

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -450,10 +450,40 @@ writing mode.
450450
never rewritten. Canonicalizing a past version's body would break the
451451
checksum↔body pairing this contract depends on — the migration is a new
452452
commit, not a rewrite of history.
453-
- **What the pass does not cover, it names.** Flows (their seam is
454-
`AutomationEngine.registerFlow`, which holds the executor registry the
455-
conflict guard needs) and types with no repository write path are reported as
456-
`skipped` with the reason, never counted as done. Giving flows the same finish
457-
line needs a canonicalization entry point on the automation engine — tracked
458-
as #4454, and worth doing precisely because the graduated flow-node
459-
conversions are where the most stored dialect lives.
453+
- **What the pass does not cover, it names.** Types with no repository write
454+
path are reported as `skipped` with the reason, never counted as done.
455+
456+
## Addendum (2026-08-01b) — flows reach the finish line too (#4454)
457+
458+
The pass above initially skipped `flow` rows, which was the largest hole in it:
459+
the graduated flow-node conversions are where the most stored dialect lives.
460+
Closing it needed three decisions.
461+
462+
- **One canonicalization policy, two shapes.**
463+
`AutomationEngine.canonicalizeStoredFlow` is now the single implementation and
464+
`registerFlow` calls it, so the load seam and the migration cannot disagree
465+
about what canonical means. It returns `parsed` (for execution — schema
466+
defaults materialized) and `storable` (for persistence).
467+
- **`storable` excludes schema defaults, and this is load-bearing.** Measured,
468+
not assumed: driving a pre-17 flow through parse + the region pass *removes*
469+
nothing (`FlowSchema` is strict since #4001 — an unknown key throws rather
470+
than being dropped, so the `graftNormalizedOperators` precedent does not
471+
transfer) and *adds* only defaults: `version`, `runAs`, per-edge `type` /
472+
`isDefault`. Persisting a default the author never wrote would pin every
473+
migrated row to today's value while untouched rows follow tomorrow's — two
474+
populations with different behaviour, which is the drift this pass exists to
475+
remove. So the write-back is conversions plus the schema's `condition`
476+
envelopes, and nothing else.
477+
- **The engine is borrowed, not started.** `AutomationServicePlugin` gains
478+
`armRuntime: false`: built-in nodes installed and `automation:ready` fired
479+
(the registry must be COMPLETE, or the conflict guard reads a live custom node
480+
type as unowned and rewrites over it), then a hard stop before anything is
481+
armed — no flow registered, no trigger or schedule bound, no connector
482+
materialized, no suspended run resumed. `registerFlow` arms triggers as a side
483+
effect, so skipping only the boot pull would not have been enough; the
484+
`kernel:ready` and `metadata:reloaded` re-syncs are skipped for the same
485+
reason. A migration process must not become a second server.
486+
487+
A refused rename — the guard firing because the old token is a live name owned
488+
by something else — fails that row loudly with the token and its owner. Never a
489+
silent skip, never a clobber; that is the whole reason the guard exists.

packages/cli/src/commands/migrate/meta.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { bootSchemaStack } from '../../utils/schema-migrate.js';
3030
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
3131
import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
3232
import { describeOccupancy } from '../../utils/sqlite-occupancy.js';
33+
import type { IAutomationService } from '@objectstack/spec/contracts';
3334

3435
async function confirm(question: string): Promise<boolean> {
3536
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
@@ -511,12 +512,15 @@ export default class MigrateMeta extends Command {
511512

512513
let stack;
513514
try {
514-
// `PlatformObjectsPlugin` only — this pass needs `sys_metadata` and its
515-
// history/audit siblings, which the protocol assembly registers itself.
516-
// No storage adapter: unlike the file migration, nothing here reads bytes.
515+
// `PlatformObjectsPlugin` for `sys_metadata` and its history/audit
516+
// siblings, plus the automation engine in INERT mode so `flow` rows are
517+
// covered too (#4454) — flow-node conversions need its executor registry
518+
// for the conflict guard, and `armRuntime: false` means taking it arms
519+
// nothing. No storage adapter: unlike the file migration, nothing here
520+
// reads bytes.
517521
stack = await bootSchemaStack({
518522
...(flags['database-url'] ? { databaseUrl: flags['database-url'] } : {}),
519-
extraPlugins: await buildDataMigrationPlugins(),
523+
extraPlugins: await buildDataMigrationPlugins({ automation: true }),
520524
});
521525
} catch (error: any) {
522526
if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); return; }
@@ -541,10 +545,23 @@ export default class MigrateMeta extends Command {
541545
const { formatStoredMigrationReport, storedMigrationClean } =
542546
await import('@objectstack/metadata-protocol');
543547

548+
// The automation engine canonicalizes `flow` rows — it holds the executor
549+
// registry ADR-0078's conflict guard needs (#4454). It is booted inert, so
550+
// this is the only thing it does in this process. Absent (an older stack,
551+
// or a boot that skipped it), flow rows keep reporting `skipped` with the
552+
// reason rather than being silently counted done.
553+
// `SchemaStack.kernel` is untyped, so the slot's contract is stated on the
554+
// result rather than as a type argument — narrowing, not erasing.
555+
let automation: IAutomationService | undefined;
556+
try { automation = stack.kernel.getService('automation') as IAutomationService | undefined; }
557+
catch { /* not registered — flows stay reported as skipped */ }
558+
const canonicalize = automation?.canonicalizeStoredFlow?.bind(automation);
559+
544560
const report = await protocol.migrateStoredMetadata({
545561
apply,
546562
...(flags.type && flags.type.length > 0 ? { types: flags.type } : {}),
547563
actor: 'os migrate meta --stored',
564+
...(canonicalize ? { canonicalizeFlow: canonicalize } : {}),
548565
});
549566
const clean = storedMigrationClean(report);
550567
if (!clean) exitCode = 1;

packages/cli/src/utils/data-migration-plugins.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,27 @@ import { resolveStorageCapabilityArg } from '../commands/serve.js';
2424
* where the server would.
2525
*/
2626
export async function buildDataMigrationPlugins(
27-
opts: { storage?: boolean } = {},
27+
opts: { storage?: boolean; automation?: boolean } = {},
2828
): Promise<unknown[]> {
2929
const plugins: unknown[] = [];
3030
const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin');
3131
plugins.push(new PlatformObjectsPlugin());
32+
if (opts.automation === true) {
33+
// `os migrate meta --stored` needs the automation ENGINE, never the
34+
// automation RUNTIME (#4454). Flow-node conversions carry ADR-0078's
35+
// open-namespace conflict guard, which consults the live executor registry
36+
// to tell a rename from a clobber — and only this plugin has that registry.
37+
//
38+
// `armRuntime: false` is what makes taking it safe: the engine and the full
39+
// node registry come up (built-ins plus whatever `automation:ready`
40+
// contributes, because a PARTIAL registry would make the guard rewrite over
41+
// a live custom node type instead of refusing), and then nothing is armed —
42+
// no flow registered, no record trigger or scheduled job bound, no
43+
// declarative connector materialized, no suspended run resumed. A migration
44+
// process must not become a second server.
45+
const { AutomationServicePlugin } = await import('@objectstack/service-automation');
46+
plugins.push(new AutomationServicePlugin({ armRuntime: false, suspendedRunStore: 'memory' }));
47+
}
3248
if (opts.storage === true) {
3349
try {
3450
const { SettingsServicePlugin } = await import('@objectstack/service-settings');

packages/metadata-protocol/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type {
1717

1818
export { formatStoredMigrationReport, storedMigrationClean } from './stored-migration.js';
1919
export type {
20+
StoredFlowCanonicalization,
2021
StoredMigrationNotice,
2122
StoredMigrationOutcome,
2223
StoredMigrationReport,

0 commit comments

Comments
 (0)