Repository navigation
fix(cli): serve --no-server with OS_MIGRATE_AND_EXIT=1 provisions the server boot's table set - #22304
Merged
objectstack-fleet[bot] merged 4 commits intoOct 8, 2026
Conversation
…ver provisions sys_import_job The REST plugin's init() registers sys_import_job; schema sync creates tables only for objects registered in that boot. While the whole plugin rode flags.server, an OS_MIGRATE_AND_EXIT=1 run with --no-server provisioned one table fewer than the server boot it prepares for. Only the dispatcher and the no-auth refusal now ride the listener flag. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude <noreply@anthropic.com>
…and the server boot Boots os serve twice on one fixture config, each on a fresh SQLite file with OS_MIGRATE_AND_EXIT=1, and asserts the --no-server run leaves the same table set as the server boot, with sys_import_job in the server boot's set as the control. Integration tier, so it gates pull requests. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude <noreply@anthropic.com>
…parity Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude <noreply@anthropic.com>
…ed it Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude <noreply@anthropic.com>
Contributor
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
This was referenced Oct 8, 2026
objectstack-fleet
Bot
deleted the
claude/issue-22202-migrate-exit-schema-parity
branch
October 8, 2026 14:46
This was referenced Oct 8, 2026
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Oct 9, 2026
…fig's package-owned keys through its package bodies (objectstack-ai#22321) Fixes objectstack-ai#22288 Clause-②: no ## What this changes A `composeStacks([…], { manifest: 'preserve' })` config carries every package-owned key (`requires`, `tiers`, `analyticsCubes`, `flows`, `objects`, …) once, inside the body of the package that declared it. Its top level carries none of them. `os serve` read the capability providers to mount from the top-level `requires` only (`serve.ts:2847` on `6729e107`), so a capability a package declares was not mounted. The card named two sibling readers. The enumeration this PR adds found the same top-level read in seven more readers in `packages/cli`. All of them are fixed here. Every reader now uses one rule: `resolveStackCollection` (`utils/stack-collections.ts`, the rule objectstack-ai#22285 gave the build preflight), which takes the top-level value when the stack carries one and otherwise every package body's. Or it folds the stack with `authoringRuleUnionStack` where the stack is loaded. A stack with no `packages[]` reads exactly as before. `serve` imports only `stack-collections.ts` (core plus spec), so it stays off `artifact-packages.ts` and `@objectstack/lint`. | reader | file | change | |---|---|---| | `os serve` providers (`requires`) | `commands/serve.ts` | `stackDeclaredCapabilities(config)`, a new one-line export over `resolveStackCollection(…, 'requires')` | | `os serve` `tiers` | `commands/serve.ts` | `resolveStackCollection(config, 'tiers')` | | `os serve` analytics cubes | `commands/serve.ts` | the top level first (its array, then the legacy `cubes` spelling), then the bodies | | `os serve` declared-flow count (automation line) | `commands/serve.ts` | `resolveStackCollection(config, 'flows').length` | | `os migrate plan` / `apply` providers | `utils/schema-migration-plugins.ts` | `stackDeclaredCapabilities(config)` | | `os generate` missing-capability hint | `utils/scaffold-wiring.ts` | `declaredCapabilities` reads by the same rule; JSDoc rewritten (below) | | `os generate types` / `client` / `migration` | `commands/generate.ts` | the four readers fold the loaded config on entry | | `os doctor` | `commands/doctor.ts` | folds once where it normalizes the config | | `os diff` | `commands/diff.ts` | folds each side where it is loaded | | `os migrate meta` data-migration advice | `commands/migrate/meta.ts` | folds at the one call of `pendingDataMigrations` | ## Measured, through the doors Each door was run with bounded boots/runs of two-package fixtures and their one-package controls. BEFORE is `6729e107`, AFTER is this branch. The fixtures are the ones in the pins below. | door | two packages, before | two packages, after | one package (control, before = after) | |---|---|---|---| | `os serve` (config, no artifact), package `requires: ['automation']` | `Info: Optional service not present: automation`; banner 31 plugins, no `AutomationServicePlugin` | `Plugin loaded: com.objectstack.service-automation`; banner 32 plugins incl. `AutomationServicePlugin` | `Plugin loaded: com.objectstack.service-automation` | | `os serve`, package `requires: ['ai']` (no open-edition provider) | `✓ Server is ready` | exit 1 `✗ Capability "ai" resolves to @objectstack/service-ai, which is not available in the open edition …` | the same exit 1 line | | `os dev`, package `analyticsCubes` | `[Analytics] Service started with 0 cubes: (none)` | `… with 1 cubes: prb_note_cube` | `… with 1 cubes: prb_note_cube` | | `os dev`, package screen flow, no automation | no flow line | `⚠ Flows: 1 flow(s) declared but the automation engine is not enabled …` | the same line | | `os serve`, package `tiers` without `auth` | ignored: `com.objectstack.auth` mounted, ready | exit 1 `✗ This stack mounts no auth, …` | the same exit 1 | | `os migrate plan`, host plugin hard-depending on automation, package `requires: ['automation']` | exit 1 `✗ [Kernel] Dependency 'com.objectstack.service-automation' not found for plugin 'com.probe.connector'` | exit 0, `Composed AutomationServicePlugin for requires: ['automation'] …` | exit 0, same note | | `os generate flow done --object prb_ticket`, app package declares `['automation','triggers']` | `⚠ It will not run yet: objectstack.config.ts does not require 'automation', 'triggers'` and a `requires:` line to add | `✓ Reaches the stack`, no warning | no warning | | `os generate types --dry-run` | 0 record interfaces | 2 | 2 | | `os generate migration --format sql --dry-run` | 0 `CREATE TABLE` | 2 | 2 | | `os doctor` | no metadata check ran; `✅ Environment is healthy` | circular-dependency and unused-object checks ran (2 unused) | the same 2 | | `os diff --json`, one object added to the service package | `total: 0` | `total: 1`, `prb_extra` added | `total: 1` | | `os migrate meta --from 16 --json`, a `file` field in the service package | `dataMigrations: []` | `adr-0104-file-references` | `adr-0104-file-references` | ## The PM readings (H1–H5) - **H1, reproduced, and narrower than the card says.** Bare `os serve` on a two-package config reads `[]` (table, row 1). **`os dev` and `os start` reach the same line of code, but they do NOT reproduce the defect.** Both boot a compiled artifact: `os dev` always compiles, and `os start` auto-compiles when no artifact exists. `createStandaloneStack` resolves the artifact's `packages[]` (`resolveArtifactCollections`), and `mergeBootConfig` lays the resolved `requires` over the top level. Measured on `6729e107` with the same two-package fixture: `os dev` and `os start` each log `Plugin loaded: com.objectstack.service-automation` once and `Optional service not present` zero times. So does `os serve` with a built `dist/objectstack.json` beside the config. The `requires` defect is therefore confined to a config boot with no compiled artifact. Triage graded p1 on `os dev` / `os start` reach. On this measurement, the `requires` half does not reach them. The `tiers`, `analyticsCubes` and flow-count halves do reach `os dev` (table, rows 3–5), because the artifact path does not carry those keys. - **H2.** Serve calls the existing rule (`resolveStackCollection`) and does not copy it. The import stays off `artifact-packages.ts`. - **H3, measured, with two narrowings rather than one.** (1) A capability a package declares with no installed provider now stops an `os serve` config boot. This is table row 2, and the same exit 1 the one-package app always gave. `os dev` / `os start` already stopped there, since the artifact path made those tokens "declared". (2) A package's own `tiers` now apply on `os serve`, `os dev` and `os start` (row 5). The claim named one narrowing. Both are named in the changeset. Nothing in this repo boots a multi-package config whose packages declare `requires` or `tiers`. `examples/app-multi-package` declares neither, and no test boots a `preserve` config with either. - **H4.** Both readers read `[]` on a multi-package config, and both are fixed (rows 6 and 7). The `os generate` hint now reads the list a server mounts. Its JSDoc keeps the `defineStack` half true. On a one-package stack the list is its top-level `requires`, which is also what `defineStack`'s trigger rule reads. On a multi-package stack, `defineStack` judged each package against its own `requires` when the config loaded. A refusal there is the `load-failed` branch, reached before the hint reads anything, so the union is what decides whether the item runs. - **H5.** `test/normalized-call-sites.test.ts` gains a second table. It enumerates every member read of a package-owned key off a stack-named receiver in `packages/cli/src`. The key set comes from `packageOwnedCollectionKeys()` (now exported from `stack-collections.ts`, derived from the two schemas, 37 keys, envelope keys excluded). Every read is classified. A `resolved` row carries `evidence`: code that must be present in the file (comments and strings blanked), which proves the fold. A `top-level` row carries the reason. 53 reads in 15 files after the fix: 6 `top-level` rows (the build preflight's `config.requires` ×2, serve's `config.analyticsCubes` first leg and `config.docs`, `collect-docs`' artifact `stack.docs`, `scaffold-wiring`'s presence probe) and 12 `resolved` receivers. The grammar's bounds are written in the file: a stack held under another name, a computed key, a member chain, and a cast whose type has parentheses. None of those spellings reads a package-owned key in `src` today. ## Pins, and the tier each runs in - `test/serve-package-declared-capabilities.test.ts`: **integration tier (per-PR)**, `runServe(` boots. Covers `automation` mounted on two packages, the one-package control, the `ai` narrowing, cubes plus the flow line, and the `tiers` narrowing. 5 boots, about 60s under a shared box. Not named `*.e2e.test.ts`, so it gates PRs. - `test/package-owned-command-readers.test.ts`: **integration tier (per-PR)**, spawns `os doctor`, `os diff --json` and `os migrate meta --json`, two packages vs one. - `src/utils/schema-migrate.requires-providers.integration.test.ts`: **integration tier**, a second case where a package declares `requires`, on the real kernel boot. - `test/package-owned-readers.test.ts`: **unit tier**. `stackDeclaredCapabilities`, the `os generate` hint and the types/migration generators against real `composeStacks` output, each paired with one `defineStack`. - `test/normalized-call-sites.test.ts`: **unit tier**, the enumeration, with a scanner self-test (read forms, writes skipped, envelope keys and prose ignored) and a positive control on the real tree. ## Ablation (one-time; nothing permanent left behind) Run with `scripts/ablation-replace.mjs` (WRAP mode, the anchor must hit, restore on EXIT/INT/TERM), at `6a30bcff0`. No build leg: the scanner reads `src` and the spawned CLI runs `src` through tsx (`bin/run-dev.js`). - **serve.ts back to `Array.isArray((config as any).requires) ? … : []`**: anchor 1→0, blob `7a588141d660`→`4bf8b068950a`. Three tests red: `no read is unclassified`, naming `commands/serve.ts :: config.requires`; `two packages: the service package's automation is mounted`; and the `ai` narrowing. The one-package control and the cubes/tiers cases stayed green. Restored: blob `7a588141d660` == HEAD, `git diff HEAD` empty. - **scaffold-wiring.ts back to a top-level-only read**: two hint tests red in `package-owned-readers.test.ts`. Restored: blob `3ec828a90838` == HEAD, `git diff HEAD` empty. Direction observed: red, as expected. ## Verification Everything below ran at the merged head `a58d828b0`. That is this branch merged with `origin/main` at `dc4a5c630`, which brought objectstack-ai#22304's `serve.ts` change. The merge was clean, and all four edits to `serve.ts` were checked present after it. The dists were rebuilt after the merge. - `pnpm --filter @objectstack/cli typecheck`: exit 0. That is `tsc --noEmit`, then `check:test-typecheck: OK … 3 file(s) / 28 error(s) / 6 pinned signature(s) held`, so no new signature. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 267 files, 3943 tests passed. - `pnpm --filter @objectstack/cli exec vitest run --project integration`: 97 files, 922 passed, 2 skipped. This tier is owed locally because the diff adds integration-tier files and touches the serve boot path. - Gate union: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derives the same 67 commands as the dispatch order lists. All 67 ran and exited 0. The `--ran` reconciliation reads `67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN`. On the pre-merge tree, `check:dual-build-cjs-loads` and `check:i18n-coverage` first refused with `PREREQUISITE NOT MET` because some packages had no dist. Those dists were built and both gates re-ran green. - `pnpm lint` (full, `eslint . --no-inline-config`): exit 0. - Before the merge, at `d12d93891`: the unit tier (267/3943), the integration tier (96 files, 919 passed, 2 skipped), full lint and the gate union were all green as well. ## Acceptance notes (observations, not filed) - **Artifact-only boots.** `createStandaloneStack`'s result carries `requires`, `objects`, `manifest`, `permissions`, `positions` and `i18n`, but not `tiers`, `analyticsCubes` or `flows`. An `os start` with an artifact and no config therefore reads those three as empty even for a one-package artifact. Inferred from the code, not measured. That is a different shape from this card (not multi-package specific). Carrier: none. - **Doc embed lint.** `collectAndLintDocs`' comment says a doc already on a package body "is also in `docs` above, where `lintMetadataEmbeds` has judged it". That was true of the additive shape, but the 2026-09-22 addendum emits a body's docs only in the body, so a package body's inline docs may not reach the embed lint. Inferred, not measured. Carrier: none. - **Legacy spellings kept as they were.** Serve's `cubes` fallback, and `os generate`'s `data?.objects` fallback, are consumer-side spellings that predate this card. They are kept byte-for-byte in precedence, not endorsed. - **Wiring lines.** On a multi-package config, the `os generate` "Not wired" advice still says `inside defineStack({ … })` without naming which package. The `requires:` line it prints is the union of what the packages declare plus what is missing, so it never drops a declared token. - **objectstack-ai#22190**, the producer-side member of this family, is not addressed here and remains open. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #22202
Clause-②: no
What was wrong
os servecomposedcreateRestApiPluginonly underflags.server. That plugin'sinit()registerssys_import_jobthrough themanifestservice (packages/rest/src/rest-api-plugin.ts), and schema sync creates tables only for the objects registered in that boot. Soserve --no-serverwithOS_MIGRATE_AND_EXIT=1created one table fewer than the same config's server boot.Reproduced first (H1) on
origin/mainfbcbcf12withexamples/app-todo. Each run used a fresh SQLite file andOS_MIGRATE_AND_EXIT=1;--no-serverwas the only difference.--no-serversys_import_job, and nothing elseThe card measured 113 against 114 on cloud's control-plane config, with the same single difference. The cloud report it cites was not readable from this session (see Acceptance notes), so that number is the card's, not re-read here.
What changed
packages/cli/src/commands/serve.tsnow composes the REST API plugin on every boot. Only the dispatcher and the no-auth refusal rideflags.server. On a server boot thekernel.usesequence is unchanged: the no-auth refusal, then REST, then the dispatcher.After the fix, with the same config and procedure:
--no-serverWith no HTTP server,
RestApiPlugin.start()mounts nothing and prints one warn:RestApiPlugin: HTTP Server service 'http.server' not found. REST routes skipped.About a dozen service plugins on that path already do the same: in the same--no-serverboot log, storage, settings, sharing, auth, i18n, marketplace and api-trigger each print a "no HTTP server, routes not registered" line.Where the registration lives, and why it did not move (H3)
The PM's lean was to move
sys_import_jobto the plugin that owns its neighbouring@objectstack/platform-objects/auditobjects, or to the boot path that registers platform objects on every boot. I measured both:sys_job/sys_job_runbyJobServicePlugin,sys_job_queuebyQueueServicePlugin,sys_notificationbyMessagingServicePlugin,sys_attachmentbyStorageServicePlugin, andsys_email/sys_email_templatebyEmailPlugin.RestApiPluginalready follows that pattern forsys_import_job; its own comment says the REST plugin owns the import feature, so it owns this object.servecomposes those owners whatever the flag, and without a listener they only skip their routes. So the listener-independent home is the owner itself, composed on every boot. This is the claim's second option: register it from the serve path on every boot.PlatformObjectsPlugininpackages/platform-objects. It belongs to another lane, and the order makes a move there a stop-and-report. It would also change the owning manifest.sys_import_jobridescom.objectstack.rest.apiwithscope: 'system'anddefaultDatasource: 'cloud', andObjectQL.resolveDatasourceBindingroutes an object by its owning package'sdefaultDatasource. That is whyPlatformObjectsPluginkeeps a separate manifest for the activation ledger. Keeping the owner keeps routing and ownership unchanged.@objectstack/restexport for the object half would widen the public surface, contrary toClause-②: no. It would also either break every other composer ofcreateRestApiPlugin(plugin-dev, theverifyharness, embedders) or leave two registrars.Nothing registers it twice. It has one registrar,
RestApiPlugin.init(), composed once per boot.The other flag-gated plugins (H4)
servecomposes three plugins only under--server. Only the REST plugin registers an object:createRestApiPluginregisterssys_import_job(onemanifestregistration). This PR fixes it.createDispatcherPluginregisters nothing: itsinit()is a no-op ("Consumer-only plugin — no services registered"), and the file never reads themanifestservice.HonoServerPluginregisters nothing: zeromanifestreads.After the fix the table sets are equal on both
examples/app-todoand the pin's fixture, so no other object is missing.H5 and docs
The "refuse loudly" fallback is not used, because H3 found a home. No docs edit is owed:
cli.mdx's "Skip HTTP server (kernel only)" and "Toggle HTTP server plugin" stay true, since--no-serverstill adds no HTTP server plugin and no dispatcher.The pin
packages/cli/test/serve-migrate-exit-schema-parity.integration.test.tsbootsos servetwice on one fixture config, each run on a fresh SQLite file withOS_MIGRATE_AND_EXIT=1. It asserts:sys_import_job;--no-servertable set equals the server boot's.Tier: integration. The file spawns the CLI, so it runs in
pnpm test's integration project on every PR (Test Core), not in the nightly. Locally the file took 22s.Ablation
I put the REST composition back under
flags.serverwithscripts/ablation-replace.mjs, changingtry {toif (flags.server) try {at thecreateRestApiPluginimport. The anchor went 1 → 0 and the blob6102920f→62abf481. The pin spawnsbin/run-dev.js, which runsserve.tsfromsrc/through tsx, so the mutation reached the code under test with no build.The equality went red and both controls stayed green:
After the restore, the blob equals HEAD's (
6102920f), and bothgit diff HEADandgit status --porcelainare empty.Verification, at
f0a27d66f2264e62;f0a27d66adds only the changeset.@objectstack/cli,--project unit: 264 of 264 files and 3886 tests passed (262 files on the first run; the 2published-subpath-*pins neededpackages/cli/distand passed once it was built), plus 29 skipped.test/vitest-tiers-partition.test.tspassed.@objectstack/cli,--project integration: 94 of 94 files and 903 tests passed, 2 skipped (exit 0). This was run locally because the diff touches the serve boot path and adds an integration-tier file.@objectstack/clitypecheck: exit 0. The new test file is in thetsconfig.test.jsonprogram (--listFiles, 1 hit) and adds no error;check:test-typecheckreports the 28 existing ledgered errors held.@objectstack/resttests: 262 of 262 files and 4938 tests passed; typecheck exit 0. The package is unchanged; the order named it.pnpm lint(full,eslint . --no-inline-config): exit 0.dispatch-gates.mjs --commands, the same set the order listed): all exit 0. Four of them first answeredPREREQUISITE NOT MET(check:dual-build-cjs-loads,check:i18n,check:i18n-coverageandcheck:i18n-walk-parity), and each was re-run green after the build.dispatch-gates --ran: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.Acceptance notes
HonoServerPlugininpluginsand runs--no-servernow gets the REST routes on that server. I measured a fixture of that shape on HEAD and under the ablation above.GET /api/v1/discoverywent from 404 to 200, andGET /api/v1/data/OBJECTfrom 404 to 401 (anonymous access is denied)./api/settings(200) and/api/v1/auth/get-session(401) answered the same in both, so those plugins already mounted on that server. No config in this repo has that shape. Cloud's measured boot differs by three plugins (Hono, REST, dispatcher), so it composes no Hono of its own. The no-auth boot refusal stays keyed on--server, as before.--no-serverboots now print one more warn line: the REST plugin's "no HTTP server" line.add_repowas refused.Generated by Claude Code