Skip to content

feat(automation,migrate): os migrate meta --stored covers flow rows (#4454) - #4492

Merged
os-zhuang merged 4 commits into
mainfrom
claude/metadata-migration-stored-rewrite-jxlz2k
Aug 1, 2026
Merged

feat(automation,migrate): os migrate meta --stored covers flow rows (#4454)#4492
os-zhuang merged 4 commits into
mainfrom
claude/metadata-migration-stored-rewrite-jxlz2k

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #4454. Follow-up to #4327 (merged as #4464).

Why

os migrate meta --stored gave the stored-metadata conversion chain a finish line for every type except flow — which is the type where the most stored dialect actually lives: the graduated conversions flow-node-crud-filter-alias, flow-node-crud-object-alias, flow-node-notify-config-aliases and flow-node-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 has no way to obtain one. So flows were reported skipped.

This wires the engine in — without wiring in a server.

Two findings that shaped the design, both measured rather than assumed

1. "Canonical flow" is three steps, and writing back the third would fossilize defaults. registerFlow runs applyConversionsToFlowFlowSchema.parsenormalizeControlFlowRegions. Driving a pre-17 flow (legacy filters at top level and inside a loop body, bare-string edge conditions in both) through all three:

result
keys removed by parse+normalize none
keys added version, runAs, per-edge type / isDefault — all schema defaults
edge condition both sites lowered to {dialect:'cel', source}

This corrected my own earlier analysis on the issue. The graftNormalizedOperators precedent does not transfer: it exists because the view parse strips Studio-only auxiliary keys, but FlowSchema is strict since #4001 — an unrecognized key throws rather than being dropped (verified with _uiPosition). There is nothing for a graft to protect. The real hazard runs the other way: persisting a default the author never wrote pins that row to today's value while untouched rows follow tomorrow's — two populations with different behaviour, which is exactly the drift this pass exists to remove. A migration must not become a source of the drift it cleans up.

So canonicalizeStoredFlow returns two shapes from one pass: parsed for execution (defaults materialized — what registerFlow runs) and storable for persistence (conversions + condition envelopes, no defaults).

2. The CLI could not host this as-is. AutomationServicePlugin looks cheap to boot, but registerFlow calls activateFlowTrigger, and start() pulls and registers every flow. Booting it for a one-shot migration would arm record triggers and scheduled jobs, materialize declarative connectors (an MCP provider spawns a child process), and re-arm suspended wait timers — which resumes paused runs. A migration that silently continues someone's pending approval is indefensible.

What changed

AutomationServicePluginOptions.armRuntime (default true; every server, dev stack and test 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. The early return is placed deliberately after automation:ready for that reason.

Skipped when armRuntime: false Why it must be
flow pull + kernel:ready / metadata:reloaded re-sync registerFlow arms triggers; skipping only the boot pull would arm them a moment later
declarative connector materialization opens real connections / spawns processes
suspended-run wait-timer re-arm would resume someone's paused approval

AutomationEngine.canonicalizeStoredFlow is now the single policy and registerFlow calls it, so the load seam and the migration can never disagree about what canonical means. migrateStoredMetadata gains the canonicalizeFlow hook; os migrate meta --stored boots the plugin inert and passes it.

IAutomationService.canonicalizeStoredFlow is declared on the contract, not only the implementation — the CLI consumes it through the automation slot, and that is precisely what the slot-lookup/no-any-assignment rule asks for instead of erasing the lookup to any. (The rule caught my first attempt; the fix was to state the contract, not to silence it.)

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 metadata 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.

Failure modes are loud, never silent

  • A refused rename (guard fires because the old token is a live name something else owns) → row failed, naming the token and its owner. Never a skip, never a clobber.
  • A flow that cannot canonicalize (strict-schema violation, malformed region) → failed with the parse message. Such a row cannot register today either, so the report is telling you about a flow already broken at runtime.

Verification

  • 15 new tests — 8 in canonicalize-stored-flow.test.ts (both nesting depths; defaults excluded while parsed keeps them; idempotent; node-config predicate untouched; strict-key throw; registerFlow unchanged), 7 in inert-mode.test.ts (nothing armed, automation:ready still fires, registry populated, and the default path still arms — the control), plus 6 flow-hook cases in protocol.stored-migration.test.ts including the no-notice envelope case and the conflict case.
  • spec · service-automation · metadata-protocol · cli · objectql · runtime · plugin-approvals all green after merging main (which carried 6 commits, three of them breaking ! refactors touching these packages).
  • pnpm lint, tsc --noEmit, and spec's api-surface / generated (all 8 artifacts) / exported-any gates pass.
  • Real database, not only unit tests: a legacy flow row seeded into examples/app-todo → preview exits 1 naming the conversion → --apply → re-run exits 0. The row comes back with config.filter, a sys_metadata_history entry sourced migrate-stored, and no schema defaults written. Boot logs confirm 17 executors registered, then inert mode … no trigger or schedule armed.

Docs: the --stored section in cli.mdx no longer claims flows are uncovered, and ADR-0087 gains a second 2026-08-01 addendum recording all three decisions. Per the newly-added CLAUDE.md rule, this PR does not touch content/docs/releases/ — the changeset carries the release-notes input.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f


Generated by Claude Code

claude added 3 commits August 1, 2026 09:53
… 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
…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
@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 1, 2026 12:13pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 1, 2026
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/cli, @objectstack/metadata-protocol, @objectstack/service-automation, @objectstack/spec.

113 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via packages/cli, @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/service-automation, @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/cli, packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol, packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via packages/cli, @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/cli)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol, @objectstack/service-automation, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/cli, @objectstack/service-automation, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli, @objectstack/service-automation, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/cli, @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol, @objectstack/service-automation, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added the size/l label Aug 1, 2026
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 1, 2026
Merged via the queue into main with commit 304423e Aug 1, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the claude/metadata-migration-stored-rewrite-jxlz2k branch August 1, 2026 12:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P3] os migrate meta --stored skips flow rows — the finish line stops short of the one type that needs the executor registry

2 participants