Skip to content

fix(metadata-protocol,rest): one seam for flow canonicalization — duplicatePackage (#4498) + an admin route for --stored (#4327) - #4504

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

fix(metadata-protocol,rest): one seam for flow canonicalization — duplicatePackage (#4498) + an admin route for --stored (#4327)#4504
os-zhuang merged 3 commits into
mainfrom
claude/metadata-migration-stored-rewrite-jxlz2k

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #4498. Completes the #4327 / #4454 line by giving the stored-metadata migration a server-side entry point.

The bug (#4498)

duplicatePackage canonicalizes each source row before re-saving, under a stated guarantee:

// … duplication never mints new rows in a pre-protocol dialect.
item = this.convertStoredItem(String(row.type), );

convertStoredItem opens with if (singular === 'flow') return { item: data, notices: [] }. So for flows the guarantee was not delivered — and it did not fail loudly either: FlowNodeSchema.config is an open z.record, so a pre-17 body (a delete_record carrying config.filters) sails through saveMetaItem's schema gate and lands verbatim in a brand-new row.

That falsifies the premise ADR-0087 rests the whole stored-metadata design on — "new rows are always canonical, so the stored pass is a strictly shrinking concern". An operator could run os migrate meta --stored --apply, get a clean report, duplicate a package, and be back to having pre-protocol rows, with the report still saying protocol N until the next run.

The fix: the wiring already existed

The reason for the flow skip is real — flow-node conversions carry ADR-0078's open-namespace conflict guard, which needs the automation engine's live executor registry to tell a rename from a clobber. #4454 built that capability (AutomationEngine.canonicalizeStoredFlow) and handed it to migrateStoredMetadata as an explicit hook, because the CLI has to boot an engine of its own to hold one.

But inside a server there is nothing to thread: the protocol is constructed with an accessor for the kernel's service table — the same one analytics and package are already read from — and the automation service registers under automation. One private resolveFlowCanonicalizer reads canonicalizeStoredFlow off it, and three call sites share it:

Call site Before After
duplicatePackage flow copied verbatim canonicalized; refused rename → failed[] naming the token
migrateStoredMetadata canonicalizeFlow required from the caller defaults to the resolver; the hook is an override
POST /meta/_migrate-stored did not exist needs no hook parameter at all

Resolution is lazy, per call. Plugin init order does not guarantee automation is in the table when the protocol is assembled (the CLI's buildDataMigrationPlugins adds it after ObjectQL by design), so caching undefined from a too-early read would silently disable flow canonicalization for the life of the process.

The CLI stopped passing its own hook. It boots the inert engine into the same kernel, so both routes reached the same instance — two routes to one capability is how they drift.

Failure posture

Two smaller honesty fixes ride along: a source item that fails conversion (a tombstoned key throws) is reported as such rather than as unparseable metadata, and the "no engine" skip reason says no automation service is reachable rather than blaming the caller for not supplying one.

The admin route (#4327's remaining gap)

os migrate meta --stored needs shell access to the deployment's database. A hosted operator has none, so on a managed deployment ADR-0087's chain had no finish line — only the per-read conversion, running forever, with no way to assert what protocol the rows are on.

const preview = await client.meta.migrateStored();               // writes nothing
const result  = await client.meta.migrateStored({ apply: true });
  • Preview by defaultapply must be literally true; an empty body, a missing body and "apply": "yes" all preview.
  • Gated on manage_metadata (ADR-0066 D1) rather than on merely holding a session, because unlike the single-item PUT /meta/:type/:name next door it rewrites every eligible row in the deployment. The gate runs before the protocol is probed, so an unauthorized caller cannot use 403-vs-501 to learn which kernels can be migrated. /meta's anonymous-deny umbrella still closes it first.
  • Attributed to the caller on the history + audit rows.
  • Mounted on the REST server and the runtime dispatcher, ledgered in both route ledgers, registered before /:type so the leading-underscore segment is never read as a metadata type name.

Flows need no extra setup on this path — the server already holds a live engine.

Reads were deliberately not changed

getMetaItems / getMetaItem / getMetaItemLayered / loadMetaFromDb still skip flows. They are reads, covered by registerFlow canonicalizing at execution, and are not producing bad data. Duplication was the one that writes. The resolver is the seam they would adopt if that changes.

Verification

The riskiest change here is removing the CLI's explicit hook — every flag unit test still passes if the protocol silently fails to find the engine, and the only symptom is flow rows quietly reporting skipped again. So that one is pinned by an integration test against the real stack: bootSchemaStack + buildDataMigrationPlugins({ automation: true }), a pre-17 flow row seeded into sys_metadata, no hook threaded, asserting the rewrite lands on disk, that no schema defaults were persisted, and that a second pass reports the row canonical. Plus the negative — dropping the automation plugin reports skipped with the reason rather than counting the row done.

  • packages/metadata-protocol/src/protocol.flow-canonicalizer.test.ts — 14 tests (resolver + duplicatePackage)
  • packages/rest/src/rest-meta-migrate-stored.test.ts — 15 tests (mount order, gate, preview posture)
  • packages/runtime/src/domains/meta-migrate-stored.test.ts — 13 tests
  • packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts — 2 integration tests

Full suites green: metadata-protocol 177, runtime 1039, rest 553, cli 694, objectql 1534, client 204. eslint, typecheck, and the route-envelope / slot-lookup / error-code-casing / wildcard-fallthrough / doc-authoring gates all pass.

Docs: an ADR-0087 addendum recording that "strictly shrinking" was false and how it is restored, and a content/docs/deployment/cli.mdx section for the HTTP form. Two changesets.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f


Generated by Claude Code

…plicatePackage` (#4498) and an admin route for `--stored` (#4327)

`duplicatePackage` promised "duplication never mints new rows in a
pre-protocol dialect" and delivered it through `convertStoredItem`, which
returns `flow` bodies untouched. `FlowNodeSchema.config` is an open
`z.record`, so a pre-17 body sailed through `saveMetaItem`'s gate and landed
verbatim in a brand-new row — making ADR-0087's "strictly shrinking" premise
false for flows: run the migration, get a clean report, duplicate a package,
and the population is back.

The capability was already reachable. The protocol is constructed with an
accessor for the kernel's service table (the same one `analytics` and
`package` are read from) and the automation service registers under
`automation`, so one private `resolveFlowCanonicalizer` serves every caller
running next to a live engine:

- `duplicatePackage` canonicalizes flow rows through it. A refused rename
  fails the item into the existing `failed[]` naming the token; a flow that
  cannot canonicalize fails the same way; with no engine reachable the source
  body is copied as-is.
- `migrateStoredMetadata`'s `canonicalizeFlow` defaults to it, so the CLI
  stopped passing one — it booted the inert engine into the same kernel, so
  both routes reached the same instance.
- `POST /meta/_migrate-stored` therefore needs no hook at all: gated on
  `manage_metadata`, preview unless `apply` is literally `true`, attributed to
  the caller, mounted on both the REST server and the runtime dispatcher and
  ledgered in both, plus `client.meta.migrateStored()`. Operators without
  shell access finally have the finish line the CLI form gives everyone else.

Resolution is lazy per call: plugin init order does not guarantee `automation`
is in the table when the protocol is assembled, and caching `undefined` from a
too-early read would disable flow canonicalization for the process.

An integration test boots the real CLI stack against a real database, seeds a
pre-17 flow row, and asserts the rewrite lands with no hook threaded — plus the
negative, that dropping the automation plugin reports `skipped` with the reason
rather than counting the row done.

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 1:36pm

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 5 package(s): @objectstack/cli, @objectstack/client, @objectstack/metadata-protocol, @objectstack/rest, @objectstack/runtime.

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

  • content/docs/ai/connect-mcp.mdx (via @objectstack/rest)
  • content/docs/ai/skills-reference.mdx (via packages/cli, packages/client)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli, @objectstack/client, packages/runtime)
  • content/docs/api/data-flow.mdx (via @objectstack/cli, @objectstack/client)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli, @objectstack/client)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli, @objectstack/client)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest, @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via packages/cli, @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol, @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/validating-metadata.mdx (via packages/cli)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli, @objectstack/client, @objectstack/runtime)
  • content/docs/kernel/cluster.mdx (via @objectstack/runtime)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/cli, packages/client)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli, packages/client)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli, @objectstack/client, @objectstack/rest, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/plugins/index.mdx (via @objectstack/cli, @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/cli, @objectstack/client, @objectstack/rest, @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli, @objectstack/client)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli, @objectstack/client, @objectstack/rest, @objectstack/runtime)
  • content/docs/releases/v12.mdx (via @objectstack/rest)
  • content/docs/releases/v16.mdx (via @objectstack/cli, @objectstack/client)
  • content/docs/releases/v17.mdx (via @objectstack/rest, @objectstack/runtime)
  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol)

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.

claude added 2 commits August 1, 2026 13:28
…or metadata calls

The SDK reference's `client.meta` sample covers the governance/lifecycle
family (publish, rollback, diff, diagnostics, audit); the new stored-row
canonicalization call belongs in the same neighbourhood. The full behaviour —
capability gate, preview posture, the CLI's `--stored` equivalent — stays in
`deployment/cli.mdx` rather than being duplicated here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f
…s lookups

The new stored-flow integration test reached the engine via
`const ql: any = stack.kernel.getService('objectql')`, which the `slot-lookup`
rule refuses — and correctly: `: any` switches off checking for every `ql.*`
call below it while reading identically to code that has it. `SchemaStack.kernel`
is untyped, so a type argument is a TS2347; the contract is stated on the
RESULT instead, via one `engineOf()` helper the two tests share.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f
@os-zhuang
os-zhuang marked this pull request as ready for review August 1, 2026 13:52
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 1, 2026
Merged via the queue into main with commit 8aacf94 Aug 1, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the claude/metadata-migration-stored-rewrite-jxlz2k branch August 1, 2026 14:05
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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2] duplicatePackage still mints pre-protocol flow rows — the "strictly shrinking" premise does not hold for flows

2 participants