Skip to content

fix(cli): the composed migrate plan examines the host's declared object set, reports what it could not, and exits - #13057

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-13028-composition-seam-family
Aug 29, 2026
Merged

fix(cli): the composed migrate plan examines the host's declared object set, reports what it could not, and exits#13057
hotlong merged 2 commits into
mainfrom
claude/issue-13028-composition-seam-family

Conversation

@hotlong

@hotlong hotlong commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #13028
Fixes #13027
Part of objectstack-ai/cloud#1653

Two consequences of one seam: the declaration-phase host composition #12952 introduced runs init() and replaces start() with a no-op. One of them makes os migrate plan report coverage it does not have; the other makes the process never return.

⚠️ The measured diagnosis differs from both cards, and the difference decides the fix

Both cards infer, from "36 plugins composed → 8 tables, all messaging", that 35 of 36 host plugins register their objects in start(). #13028's triage flags that inference as its own confidence gap, and the reviewer correction on #13028 asks for it to be enumerated before anything is chosen. Enumerated — read out of the source on both sides rather than reasoned from the count — it does not hold.

Where host plugins actually declare their objects. Every registration goes through ctx.getService('manifest').register({ … objects … }). In THIS repo, sixteen plugins make such a call and all sixteen make it from init()plugin-security (security-plugin.ts:1007, inside async init at 983, objects: securityObjects), plugin-auth, plugin-audit, plugin-approvals, plugin-sharing, plugin-email, plugin-reports, plugin-webhooks, service-settings, service-storage, service-job, service-queue, service-realtime, service-datasource, service-messaging, platform-objects. The start()-phase manifest.register calls that do exist carry no objects: apps/studio, apps/setup, apps/account register apps and navigation, and mcp / cloud-connection register UI bundles.

On the consumer side, checked the same way against objectstack-ai/cloud: organizations (init at 174), service-ai (init at 653) and service-tenant (init at 46) all declare from init(); app-cloud and app-cloud-admin register from start() but carry apps and docs, not objects. Exactly one composed plugin registers an OBJECT from start()security-enterprise's AI-governance plugin (start at 76 → registerManifest, objects: [AiAuditLogObject]), a single object.

So the residue is 1, not 35, and dispatch option (c) — "move the registrations to init() in the consumer repo" — has essentially nothing to move. There is no consumer-side phase migration worth enumerating, and #12952's ACCEPT premise ("zero measured instances") was off by one, not by thirty-five.

What was actually missing. A plugin declaring an object in init() puts it in the ObjectQL registry. What fills the SQL driver's managedObjectFields — the map detectManagedDrift() diffs the physical schema against, and the thing managedTables counts — is a separate pass: installRegisteredSchemas(), called from ObjectQLPlugin.start() (packages/objectql/src/plugin.ts:683,706). One pass, one start(), for the whole deployment.

And on a real control plane that start() never runs at all. ObjectStack Cloud's createControlPlanePlugins() wraps each plugin in a lazyPlugin(...) and names the wrapper 'com.objectstack.engine.objectql' — the framework's own plugin name, deliberately, so the CLI's capability injector de-dups against it (the preset's own comment: "it MUST EQUAL one of those identities"). Duplicate plugin registration overwrites by name (packages/core/src/plugin-registration.ts), so the host's wrapper displaces the standalone stack's ObjectQLPlugin — and composeForDeclarations then suppresses the wrapper's start(). Net: no ObjectQLPlugin.start() in the boot, every host plugin's declarations stranded in a registry, and the driver told about nothing.

That predicts the measurement exactly, including the part the cards found strangest — why the 8 were all messaging. service-messaging is the one service that does not rely on that pass: provisionSystemTables() calls engine.syncObjectSchema() for its own eight objects from a kernel:ready hook (messaging-service-plugin.ts:224-238, 352-381), which is what put sys_notification, sys_inbox_message, sys_http_delivery and the rest into managedObjectFields while nothing else got there.

Reproduced locally, deterministically. A fixture with that shape — a lazy wrapper around the framework's own ObjectQLPlugin under the framework's own name, plus SecurityPlugin — on this branch's base:

  ℹ Examined 0 managed table(s).
  ✓ Physical schema is in sync with metadata — nothing to migrate.

Zero tables, and the green sentence. That is #13028's "one composed plugin away from printing again", printing. ⚠️ The plugin names in the fixture are load-bearing: rename com.objectstack.engine.objectql and the two plugins coexist, both init()s run, and the boot dies on Service 'objectql' already registered — a different defect.

#13028 — the plan examines the declared set, and reports the boundary either way

Dispatch shape (a): the composition drives a narrow, measured-safe registration path. ⛔ Not option (b) — no kernel contract change, no declarationsOnly boot mode, nothing added to packages/spec.

  • The pass is the framework's own, driven over the deferral this boot already armed. measureComposedCoverage() calls engine.syncObjectSchema(name) per declared object — the same public IDataEngine entry point service-messaging already uses — which reaches SqlDriver.initObjects exactly as the suppressed start() would have. No host code runs. With DDL deferred, initObjects registers the metadata in memory, records the create-table work as PENDING, and returns.
  • It runs only on a boot that deferred. deferred is a parameter, not a deduction (Route and surface ownership §2): the same call on a non-deferred boot would take the DDL path and a "plan" would create tables. A non-deferred composed boot reports UNMEASURED coverage instead.
  • driver-sql: initObjects no longer calls ensureDatabaseExists() while DDL is deferred. It is the one line in that method that can WRITE — mkdir -p for a sqlite parent directory, and on Postgres/MySQL a SELECT 1 that CREATEs the database on 3D000 / ER_BAD_DB_ERROR. Under the deferral every DDL branch is skipped, so there is nothing for a database to exist for; cli/driver-sql: os migrate plan 自称 dry-run,却仍会在全新项目上创建空数据库文件(#6469 的残余写副作用) #6743 closed the sqlite half of this one layer up, in the CLI. It is also the cost half: ~80 objects, one call each, would otherwise be ~80 round-trips against a database the command never touches. flushDeferredSchemaDdl() clears the flag before re-entering, so the confirmed os migrate apply still ensures the database ahead of the first CREATE TABLE — pinned in both directions.

Coverage honesty, deliverable independently of the coverage itself (the triage's option C):

plan --json and apply --json carry composition.coverage:

"coverage": {
  "registeredObjects": 16, "examinedObjects": 16, "unexaminedObjects": 0,
  "reasons": { "federated": 0, "unbound": 0, "unsupported": 0, "otherDriver": 0, "failed": 0 }
}

unexaminedObjects is the discriminator a consumer gate needs (cloud#1710's): managedTables cannot tell a genuinely small deployment apart from a mostly-unexamined one, and both raise the count above the artifact-less baseline. The counts are of OBJECTS, not plugins — which plugin an object came from is not observable at this seam (a manifest registration carries a package id, not a plugin instance), and inventing that attribution would be a second thing that reads like coverage.

When unexaminedObjects is above zero the human output refuses the unqualified success line. Measured end to end on a fixture declaring one object on a datasource nothing provides:

  ℹ Examined 9 managed table(s).
      Coverage: 9 of 10 declared object(s) are in the diffed set; 1 are NOT (1 bound to no
      driver). The plan below is PARTIAL — an empty result over those objects is UNMEASURED,
      not "in sync".

os migrate apply gets the same treatment, including an in_sync_partial message on its --json payload where it used to say in_sync.

#12952's byte-pinned artifact-less baseline is unchanged. A project with neither an objectstack.config.* nor a compiled artifact composes nothing, carries no composition key and diffs the same five tables; its pin passes untouched, and the ablation below leaves it green.

#13027 — the process ends when the work does

Measured: 4.3s of work, Graceful shutdown complete, then 78 minutes of nothing until the run was cancelled by hand. A host plugin that arms something during init() whose release would have been installed by start() has no release path, so the event loop never drains while the kernel reports a clean shutdown.

plan and apply now exit deliberately once their document is written, after the kernel teardown they already ran. Chasing the handle would mean auditing host code this repo cannot see — the same argument that made the composition declaration-only in the first place, and the card's own second candidate.

  • exitOneShotCommand() drains stdout and stderr first. process.exit on an undrained pipe truncates: exactly the defect emitJson exists to prevent, re-introduced one statement later, invisible on a TTY.
  • The drain is bounded. A pipe whose reader has gone away never drains, and this function must not become a second way for the command not to return.
  • Failure paths are untouched. this.exit(n) throws an oclif ExitError that oclif's own handle() already turns into a process.exit; catching them here to exit "tidily" would swallow the report with them. The body moved into a private method so every early return on the SUCCESS path funnels through one exit.

Consumer acceptance — cloud#1710's stage-2 shapes, on this branch

A fixture carrying both halves at once (lazy-wrapped framework ObjectQLPlugin under the framework's own name + SecurityPlugin + a plugin holding a ref'd interval from init()), os migrate plan --json:

cli-exit=0  wall=2s
managedTables: 16   pending: 16   drift: 0
coverage: registeredObjects 16 · examinedObjects 16 · unexaminedObjects 0
has sys_position: true    has sys_permission_set: true

Both tables #12938 named by hand are in the plan; the process returns in two seconds. Against the same fixture on the base, the same command reported Examined 0 managed table(s) and Physical schema is in sync, and (with the interval fixture) did not return at all.

⚠️ Not run against cloud's real apps/cloud/objectstack.config.ts, and deliberately. That checkout's node_modules/@objectstack/* symlink into the SHARED framework checkout, so a run from there would load two different copies of the framework in one process — the CLI's from here, the config's from there. Any number it produced would be about a tree nobody is on. The fixture above reproduces the seam mechanically instead, and its plugin names are copied from cloud's preset.

Verification

Declared narrowing — verification ran UNLOCKED. scripts/pm/os-verify-lock.sh could not take the shared verify lock on this host: no usable flock. The shared verify lock is declared Linux-only (flock is util-linux, and a stock macOS does not ship it), so the commands below were run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for these runs, nor for any sibling agent in this container while they ran.

Exit codes captured before any pipe; every result quotes the gate's own verdict line. Final head 557697df6, tree clean, and the union below was run on that head.

Tests. pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/utils/schema-migrate src/utils/schema-migration-plugins src/utils/one-shot-exit src/commands/migrate test/migrate-plan-exits.e2e.test.tsTest Files 21 passed (21), Tests 117 passed (117) — that is #12952's whole migrate surface plus the four new suites. pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2 src/sql-driver-deferred-ddl*.test.ts src/sql-driver-deferred-datetime-convergence.test.tsTest Files 3 passed (3), Tests 37 passed (37). pnpm --filter @objectstack/cli typecheck && pnpm --filter @objectstack/driver-sql typecheck: exit 0.

Ablations — one per member, disk-proven, absolute paths, EXIT INT TERM trap, restored from the COMMITTED implementation. No build step is involved on either leg: vitest resolves these workspace imports to source, and the e2e's child is bin/run-dev.js (tsx over src/), so both legs measure the tree on disk.

#13028 — the coverage call in schema-migrate.ts disarmed:

BEFORE_HASH = 82c0fc22e365e8f0ec775077507fa7a825280851   (= HEAD blob)
anchor occurrences BEFORE / AFTER : 1 / 0
injected occurrences AFTER        : 1
AFTER_HASH  = 5a659267d6df89e71bed46ba0c95ef752ffb2f87
MUTATION CONFIRMED ON DISK
→ Tests  1 failed | 6 passed (7)
   × examines the objects its host DECLARED, and says so in the coverage payload
     AssertionError: a composed boot must report its own boundary: expected null not to be null

The artifact-less baseline pin and #12938's four host-composition cases stay green through it — the point: they must not depend on this fix. The same ablation, driving the CLI by hand against the cloud-shaped fixture, is what produced the Examined 0 managed table(s) / Physical schema is in sync reading quoted above. Restore leg: git checkout HEAD -- <abs path>, anchor back to 1 occurrence, injected text to 0, git diff HEAD --stat empty, hash back to 82c0fc22….

#13027 — the deliberate exit in plan.ts disarmed:

BEFORE_HASH = ae9e5e25c16237189e9a37502ea91860d32da530   (= HEAD blob)
anchor occurrences BEFORE / AFTER : 1 / 0
injected occurrences AFTER        : 1
AFTER_HASH  = 96cd913e1e94f05361e536bd0cadce7c583bb5fd
MUTATION CONFIRMED ON DISK
→ × returns from a composed host stack that armed an unreleasable handle in init()  90053ms
     AssertionError: os migrate plan did not exit within 90000ms.

90 seconds and killed, against 2 seconds and exit 0 with the fix. Restore leg proved the same way, hash back to ae9e5e25….

Gates, derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no hand-written path list), all exit 0: check:nul-bytes, check:cli-test-child-env, check:cross-package-test-inputs, check:test-source-alias, check:objectql-double-limit, check:page-declaration-shape, check:slot-lookup, check:published-files, check:type-source-resolution, check:changeset-gate-self-tests, check:objectui-changeset, check:pm-half-states, check:type-check-coverage, check:type-check-debt, check:driver-conformance, check:i18n, check:i18n-coverage, plus scripts/check-adr-0087-registration.mjs and scripts/check-changeset-no-major.mjs. Sample verdict lines: "check-nul-bytes: OK (scanned 7244 text file(s) … no raw ASCII control bytes)." · "check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt." · "check-type-check-coverage --re-measure: OK — 31 ledger entr(ies) re-measured in 65.7s, 1570 raw tsc error(s) total, none above its recorded number." · "check:cli-test-child-env: 37 spawner source(s) … all 43 spawn call(s) declare their child's env."

⚠️ check:type-check-debt first refused with --re-measure cannot run: 1 workspace dependenc(ies) … have no built type entry point on disk. That is PREREQUISITE NOT MET, not a red gate — it was re-run green after pnpm exec turbo run build --concurrency=2 --filter='./packages/*' --filter='./packages/*/*' (70 successful, 70 total), exactly as lint.yml does before that step.

Repo-wide pnpm lint was RUN, not narrowed: node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config, exit 0, 20s.

Out of scope — one verified consumer-side finding, for the PM to file

Not filed from here (it lands in objectstack-ai/cloud, and this seat files in the repo the fix lands in).

Cloud's lazyPlugin wrapper never forwards destroy(), so no lazily-wrapped control-plane plugin is ever torn down — in production os serve too, not only under the CLI. packages/service-cloud/src/control-plane-preset.ts builds each wrapper with init / start / stop, and the com.objectstack.driver wrapper below it does the same. The kernel's ONLY teardown entry point is plugin.destroy()packages/core/src/kernel.ts:718,776 and kernel-base.ts:242; nothing in packages/core/src ever calls plugin.stop(), and messaging-service-plugin.ts carries the same lesson learned the hard way ("IT USED TO BE stop(), WHICH NOTHING CALLED", #9371). Consequence: roughly twenty control-plane plugins never release anything on shutdown — service-messaging's notification and HTTP dispatchers keep their intervals, the queue and job services keep theirs. Renaming stop to destroy on both wrappers is the whole fix. It is the cloud-side root of #13027 and is NOT addressed by this PR, which fixes the framework half (the command exits regardless of what the host left running).

Generated by Claude Code

…and says what it could not

A declaration-phase host composition runs `init()` and suppresses `start()`.
The pass that hands registered objects to their driver — the one that fills
`managedObjectFields`, which `detectManagedDrift()` diffs — lives in
`ObjectQLPlugin.start()`. A host that brings its own `ObjectQLPlugin` under the
framework's own plugin name DISPLACES the standalone one (duplicate
registration overwrites by name), so no `ObjectQLPlugin.start()` ran at all:
every host plugin declared its objects and none reached a driver.

Measured on ObjectStack Cloud's staging control plane: 36 plugins composed,
~80 `sys_*` tables declared, 8 examined — all eight belonging to the one
service that provisions its own tables from a `kernel:ready` hook.

The composed boot now drives that pass itself over the deferral it already
armed (`engine.syncObjectSchema` per declared object, reaching
`SqlDriver.initObjects` exactly as the suppressed `start()` would have), and
reports what it could not reach: `composition.coverage` on the `--json`
payloads, and a refusal to print the unqualified "in sync" line when the
plan is partial.

`driver-sql`: `initObjects` no longer calls `ensureDatabaseExists()` while DDL
is deferred — the one line there that can write, for a phase that runs no DDL.
The flush clears the flag before re-entering, so real DDL still ensures first.
…s written

Measured on ObjectStack Cloud's staging control plane inside `docker run --rm`:
the CLI finished in 4.3s, printed `Graceful shutdown complete`, and the run was
cancelled by hand 78 minutes later with the shell still blocked on it.

The declaration-phase host composition runs `init()` and replaces `start()`
with a no-op, so anything a host plugin armed during Phase 1 whose release
would have been installed by Phase 2 has no release path and the event loop
never drains. Chasing the handle means auditing host code this repo cannot
see — the same argument that made the composition declaration-only.

Both commands now exit deliberately after the teardown they already ran,
draining stdout/stderr first (a `--json` payload on a pipe must not be
truncated) under a bounded wait (a pipe whose reader is gone must not become a
second way not to return). Failure paths are unchanged: `this.exit(n)` throws
an oclif ExitError that oclif's own handler already turns into a process exit.
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Aug 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/driver-sql, touching 19 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via os migrate apply (command))
  • content/docs/data-modeling/indexing.mdx (via os migrate plan (command))
  • content/docs/deployment/cli.mdx (via os migrate apply (command), os migrate plan (command))
  • content/docs/deployment/index.mdx (via os migrate apply (command), os migrate plan (command))
  • content/docs/deployment/self-hosting.mdx (via os migrate apply (command))
  • content/docs/kernel/services-checklist.mdx (via os migrate apply (command), os migrate plan (command))
  • content/docs/protocol/kernel/lifecycle.mdx (via os migrate apply (command), os migrate plan (command))
  • content/docs/upgrading.mdx (via os migrate apply (command), os migrate plan (command))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via os migrate plan (command))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 30 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json df59de0d69130fee44602f4cb1368b07639a886cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 27f41fdb33ab64af36e2f25a059a1b08e9739a58 — the merge of head 557697df64ce61c09df5b64b20ee7bac07526a2c into base df59de0d69130fee44602f4cb1368b07639a886c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 27f41fdb33ab64af36e2f25a059a1b08e9739a58 && git checkout 27f41fdb33ab64af36e2f25a059a1b08e9739a58
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin df59de0d69130fee44602f4cb1368b07639a886c 557697df64ce61c09df5b64b20ee7bac07526a2c && git checkout -B drift-repro df59de0d69130fee44602f4cb1368b07639a886c && git merge --no-ff 557697df64ce61c09df5b64b20ee7bac07526a2c

node scripts/docs-audit/affected-docs.mjs --json df59de0d69130fee44602f4cb1368b07639a886c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs df59de0d69130fee44602f4cb1368b07639a886c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@hotlong
hotlong marked this pull request as ready for review August 29, 2026 01:48
@hotlong
hotlong enabled auto-merge August 29, 2026 01:48
@hotlong
hotlong added this pull request to the merge queue Aug 29, 2026
Merged via the queue into main with commit 6c6157a Aug 29, 2026
34 checks passed
@hotlong
hotlong deleted the claude/issue-13028-composition-seam-family branch August 29, 2026 02:26
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

1 participant