Skip to content

fix(cli,runtime): one-shot CLI boots run no seed loader and arm no lifecycle sweep; every no-write mode boots read-only - #21432

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-21391-one-shot-boot-read-only
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-21391-one-shot-boot-read-only

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21391
Clause-②: yes (narrowing)

What changed

The family ruling on #21391 (triage 5950851232), built in the bootSchemaStack funnel and its callers:

  • No one-shot CLI boot runs the seed loader. bootSchemaStack passes skipSeedData: true on every boot, keyed like runPlatformMigrations: false (unconditional, not on deferSchemaDdl). That covers the --apply, --delete, --run and --yes paths. Seeding stays with os dev and os serve.
  • Every no-write mode boots read-only (deferSchemaDdl: true, readOnlyProbe: true, the boot os migrate plan takes): os migrate value-shapes (scan), summary-nulls, files-to-references and recorded-by (dry run), os migrate resume (list), os secret orphans (report), os storage orphans (its only mode), and os meta resync when it can never reach its write (no --yes, and --json or no TTY). The enumeration pin found os meta resync as an eighth member. Write modes keep the plain boot, minus the seed.
  • No lifecycle sweep is armed on a one-shot boot. New runtime key createStandaloneStack({ armLifecycleSweep }), default true. With false, ObjectQLPlugin gets lifecycle: { enabled: false } and the ADR-0057 timers are never created. bootSchemaStack passes false.
  • DDL deferral covers every SQL datasource the boot connects. DeferSchemaDdlPlugin used to arm the first driver.* SQL service only. It now arms every driver.* service, every driver the engine already holds (the default by name, and the driver each registered object resolves to), and every driver registered later. The last is done by a shadow on the engine instance's registerDriver that arms the driver before forwarding it, the seam the declaration-boot write guard already uses. pendingSchemaWork and flushSchemaDdl cover every armed driver. No driver change.
  • The two comments that listed os migrate meta among the non-deferred boots are corrected: the runPlatformMigrations block in schema-migrate.ts, and platform-migrations-arming.integration.test.ts.
  • Two --json faces the read-only boot newly reaches on a database without the app's tables. os secret orphans had no catch for a scan error, so --json printed nothing; it now answers {"error":"scan_failed","message":…,"code":…} with exit 1. os migrate value-shapes re-reported its own this.exit(1) as a second document, {"error":"EEXIT: 1"}; its catch now rethrows exit signals, as summary-nulls and files-to-references already do.

The fenced files are untouched: migrate/audit-metadata-bodies.ts and plugin-audit's stored-metadata-body-migration.ts. The family keys reach that command through bootSchemaStack.

Measured: the #21349 repro on examples/app-crm

Setup at base 1d0600bf66: os build, then os dev --seed-admin -d file:base.db. The seed loaded 28 rows across 5 app tables and the dev admin was seeded (82 tables). The server was stopped. Each command ran on its own copy with --json, and the state was read on a separate read-only connection. "28/28" means 28 of the 28 seeded rows changed (updated_at bumped, organization_id stamped). The PR readings use the CLI built at 3f61ebcdb6.

no-write mode base this PR
migrate value-shapes / summary-nulls / files-to-references / recorded-by / resume, secret orphans, storage orphans, meta resync (no --yes) each 28/28, schema identical, seeder "updated":28 each 0/28, schema and every row identical, [Seeder] skipSeedData
controls: account-issuer, multi-value-columns, plus plan, duplicates, meta --stored, audit-metadata-bodies each 0/28, identical each 0/28, identical
write mode base this PR
--apply of value-shapes / summary-nulls / files-to-references / recorded-by / meta --stored / audit-metadata-bodies, resume --run, secret orphans --delete, meta resync --yes each 28/28 (the seed ran alongside) each 0/28; the only other tables that changed are the command's own (sys_migration for the value-shapes and files-to-references applies, sys_permission_set for meta resync --yes)
controls: multi-value-columns --apply, apply --yes (deferred boots) 0/28 0/28

A no-write mode pointed at a SQLite file that does not exist:

command base this PR
value-shapes exit 0, file created exit 1 (gate fails on unreadable objects), no file
summary-nulls, files-to-references exit 0, file created exit 0, no file
recorded-by, resume, storage orphans exit 0, file created exit 1, the driver's refusal names the table, no file
secret orphans exit 0, file created exit 1, scan_failed, no file
meta resync (no --yes) exit 0, file created exit 0 (confirmation_required), no file
meta --stored, audit-metadata-bodies, account-issuer exit 1, no file exit 1, no file

The exit-0-to-exit-1 rows are the declared narrowing: Clause-②: yes (narrowing), a minor changeset with the BREAKING banner and the ADR-0087 disposition not-required (no-migration-prescription).

Pins (each measured red before the fix, at 5e7fd69bc3)

  • packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts (new, 44 cases). The family is derived from source: every module under src/ that value-imports bootSchemaStack, held equal to a table of each caller's no-write and write modes. A new caller fails by file name until it is declared. Against a database a served boot seeded (and an operator then edited), with the artifact one release ahead:
    • every no-write mode leaves the schema and every row identical (14);
    • every no-write mode at a missing file creates no file and still answers with one JSON document (14);
    • every write mode runs no seed write (11);
    • neither the read-only nor the plain one-shot boot arms the sweep, and the served boot does (3);
    • the enumeration itself (2).
    • Before the fix: 27 failed, 17 passed (the passes are the controls and the enumeration).
  • schema-migrate.deferred-ddl.integration.test.ts: two cases with a second SQL datasource an artifact declares, connected through the real DatasourceAdminServicePlugin and driver factory. The deferred boot creates nothing there and reports its create_table; flushSchemaDdl creates it. Before the fix: 2 failed (the boot created defer_remote on the second database).
  • packages/runtime/src/standalone-stack-lifecycle-sweep.test.ts (new): the key's declaration on ObjectQLPlugin, and its effect on a started kernel's timers. Before the fix: 2 failed.
  • preview-read-only.integration.test.ts: the 17.6.0: os migrate meta --stored (preview) and os migrate audit-metadata-bodies (dry run) boot the app's seed loader and write to application tables #21349 exit-1 edge is pinned for both commands, with the exit code and the refusal (DATABASE_ERROR naming sys_metadata; failures: 2, scanned: 0 over sys_audit_log and sys_activity). Its fixture now seeds through a served boot, since the funnel no longer seeds.
  • schema-migrate.teardown.integration.test.ts: a first case pins that a one-shot stack arms no sweep and that its teardown still closes the kernel and the pool. The 每个 os migrate 子命令关停时,悬空引用巡检都会把 sys_metadata / sys_view_definition 报成 unreadableObjects(连接已关闭) #4747 pair (audits while live, reads nothing once down) now runs on the standalone stack booted without the one-shot policy (see acceptance note 1).
  • multi-value-columns.no-auto-run.test.ts names the family pin as the one other reader of that module.

Ablations

The fix was committed first. Each leg ran through scripts/ablation-replace.mjs in WRAP mode: the anchor hit once, the mutation landed (anchor count 1 to 0, blob changed), and the restore was proven (blob == HEAD, git diff HEAD empty), at 0758b2330d. Every pin imports its subject by relative path, so it resolves to src; no rebuild was needed between legs.

leg mutation red
A1 schema-migrate.ts: skipSeedData: false 23 (every no-write identity case and every write case except plan and apply --yes, whose composed boots refuse row writes through the declaration-boot guard)
A2 schema-migrate.ts: armLifecycleSweep line removed 3 (both family lifecycle cases, the teardown first case)
B1-B8 each command's read-only spread removed, one leg per command 2 each (that command's identity and missing-file cases)
C1 registerDriver shadow not installed 2 (both second-datasource cases)
C2 flush over the first armed driver only 1 (the flush case)
C3 preview over the first armed driver only 1 (the deferred-boot case)
D1 runtime: the lifecycle: { enabled: false } passthrough removed 2 (both runtime cases)
E1 schema-migrate.ts: readOnlyProbe mapping removed 2 (both no-file cases; the exit-1 pins stay green because the refusal comes from the deferral)
E2 schema-migrate.ts: deferral never armed 2 (both exit-1 edge pins)
F1 secret orphans: the scan_failed emit removed 1 (its missing-file JSON case)
F2 value-shapes: the exit-signal rethrow removed 1 (its missing-file JSON case)

Verification

  • @objectstack/cli unit tier at 11d48f07f7: 248 files, 3552 passed.
  • @objectstack/cli integration tier, in 4 shards: shards 1-3 at 5cfaffbc87 (19 + 19 + 19 files; one failure, the 每个 os migrate 子命令关停时,悬空引用巡检都会把 sys_metadata / sys_view_definition 报成 unreadableObjects(连接已关闭) #4747 teardown pin, reworked in 0758b2330d), shard 4 and the teardown file at 0758b2330d (19 files, 190 passed; 2 passed). After merging origin/main (39a912ea73), at 77a89b7b53: the six touched suites, 72 passed and 1 named skip (the live PostgreSQL cell).
  • @objectstack/runtime at 0758b2330d: 309 files, 5094 passed, 11 skipped.
  • test/json-stdout-purity.e2e.test.ts (nightly tier, OS_TEST_TIERS=nightly) at 0758b2330d: 44 passed.
  • pnpm --filter @objectstack/runtime typecheck and pnpm --filter @objectstack/cli typecheck at 77a89b7b53: exit 0, check:test-typecheck OK for both. Commit 11d48f07f7 adds one module-top side-effect import to a test file. (Seat correction at landing: the final commit is now 753bec1955. It gives the one-shot family pin its own OS_SECRET_KEY, 32 random bytes saved and restored around the file, so the pin runs on a clean CI runner. The integration-tier readings above predate it; CI's Test Core shard 4/6 on 753bec1955 is the reading for that file.)
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 11d48f07f7 derived 95 families; all 95 ran with exit 0 recorded before any pipe. --ran reports 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero). Three of them first answered exit 3 (PREREQUISITE NOT MET: eight packages outside the CLI closure had no dist); after a turbo build of those eight they exited 0.
  • Lint, as a proven narrowing, at 11d48f07f7: eslint with the repo config and --no-inline-config over the 18 changed TypeScript files (--format json) reported 18 files, 0 errors, 0 warnings, and no file reported as ignored. The config enables no type-aware linting (eslint.config.mjs says so: no parserOptions.project, no typed rules), so this diff cannot move the verdict on any untouched file. The full pnpm lint is left to CI.

Acceptance notes

  1. The lifecycle key also makes an explicit sweep() inert on a one-shot stack. lifecycle.enabled is LifecycleService's master switch: with it off, start() arms nothing and sweep() returns an empty report. 每个 os migrate 子命令关停时,悬空引用巡检都会把 sys_metadata / sys_view_definition 报成 unreadableObjects(连接已关闭) #4747's triage declined "one-shot commands skip the audit" (its option C) as the fix for shutdown pollution, and its pin asserted that an explicit sweep() on a bootSchemaStack stack audits. Under this card's ruling the scheduled sweep, and the audit riding its clock, is off every one-shot boot; no code in this repo calls sweep() on a one-shot stack. The 每个 os migrate 子命令关停时,悬空引用巡检都会把 sys_metadata / sys_view_definition 报成 unreadableObjects(连接已关闭) #4747 pair still runs, on the composition that still sweeps. Keeping an explicit sweep() alive on a one-shot stack while its schedule stays unarmed needs an ObjectQLPlugin option that separates "arm the schedule" from the master switch, which is a packages/objectql change outside this claim.
  2. ObjectQLPlugin's lifecycle option doc says that with enabled: false "the lifecycle service stays registered so tooling can still run sweep() explicitly". LifecycleService.sweep() returns an empty report when the service is not enabled. No caller in this repo depends on the sentence.
  3. os migrate recorded-by --apply --yes --json on a database with one sentinel row converts the row, prints its result document, then prints {"error":"EEXIT: 0"} and exits 1. Its catch re-reports the this.exit(0) that follows a completed run (measured with the CLI built at 77a89b7b53, on a copy of the app-crm database). os migrate resume --run has the same shape by reading (not measured). This is a write path this change does not reach, so it is reported, not fixed here.
  4. The read-only boot keeps a missing SQLite file of the default datasource from being created. A second SQLite datasource's file is still opened by its connect; the ruling asks for DDL deferral on every datasource, and that holds.
  5. The family pin is SQLite-only. The read-only boot is one code path for every dialect, and the live PostgreSQL CI leg is not this card (triage).
  6. content/docs/deployment/cli.mdx gains a paragraph under Data migrations and one in the os secret orphans entry. os storage orphans, os meta resync, os migrate recorded-by and os migrate resume have no entry in that page.

Generated by Claude Code

claude added 9 commits October 2, 2026 12:47
…ee (red before the fix)

The enumeration pin derives every bootSchemaStack caller from source and runs
each no-write mode against a served database, each write mode for seed
writes, and both boots for an armed lifecycle sweep. The deferred-DDL pin
gains a second SQL datasource, the runtime gains the lifecycle-sweep key's
declaration and effect pins, and the #21349 preview pin gains its exit-1 edge.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…s no lifecycle sweep; every no-write mode boots read-only

bootSchemaStack now passes skipSeedData unconditionally (keyed like
runPlatformMigrations: false, not on deferSchemaDdl) and the new runtime key
armLifecycleSweep: false, so the ADR-0057 sweep is never armed on a one-shot
boot. The deferral arms every SQL driver the boot connects: driver.*
services, drivers the engine already holds, and every later registerDriver
through a shadow on the engine instance; pendingSchemaWork and
flushSchemaDdl cover all of them. The no-write modes of value-shapes,
summary-nulls, files-to-references, recorded-by, resume, secret orphans,
storage orphans and meta resync boot read-only (deferSchemaDdl +
readOnlyProbe); their write modes keep the plain boot.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
… JSON refusal

The report now boots read-only, so a database that lacks sys_secret is
refused at the read instead of having the table created for it. The run had
no catch for a scan error, so --json printed nothing on stdout; it now emits
{ error: 'scan_failed', message, code } with exit 1, and the enumeration pin
asserts every no-write mode on a missing database still answers with a
JSON document.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
… gate fails

this.exit(1) after the scan's own payload throws oclif's ExitError, and the
catch below re-reported it as a second document, {"error":"EEXIT: 1"}. The
catch now rethrows exit signals, as summary-nulls and files-to-references
already do. The read-only scan of a database without the app's tables fails
the gate on unreadable objects, so the scan reaches this on a fresh project.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…in as its one other reader

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
A one-shot stack now arms no lifecycle sweep, and lifecycle.enabled is the
service's master switch, so an explicit sweep() on it is inert too. The
first case pins that, and that its teardown still closes the kernel and the
pool. The #4747 pair (audits while live, reads nothing once down) moves to
the standalone stack booted without the one-shot policy, torn down through
the same kernel.shutdown() path. The runtime key's doc and the changeset no
longer claim an explicit sweep still runs.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
check:test-source-alias: the second-datasource cases import it inside a
clocked it() body, so its first transform was paid against the test timeout.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/runtime, touching 27 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/cli/src/utils/schema-migration-plugins.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/environment-routing.mdx (via createStandaloneStack (symbol, a top-level function))
  • content/docs/api/error-catalog.mdx (via os migrate summary-nulls (command, read off packages/cli/src/commands/migrate/summary-nulls.ts))
  • content/docs/data-modeling/drivers.mdx (via createStandaloneStack (symbol, a top-level function), os meta resync (command, read off packages/cli/src/commands/meta/resync.ts))
  • content/docs/deployment/cli.mdx (via scan_failed (literal, a string literal in SecretOrphans), os migrate files-to-references (command, read off packages/cli/src/commands/migrate/files-to-references.ts), os migrate summary-nulls (command, read off packages/cli/src/commands/migrate/summary-nulls.ts), os migrate value-shapes (command, read off packages/cli/src/commands/migrate/value-shapes.ts), os secret orphans (command, read off packages/cli/src/commands/secret/orphans.ts))
  • content/docs/deployment/single-project-mode.mdx (via createStandaloneStack (symbol, a top-level function))
  • content/docs/plugins/index.mdx (via createStandaloneStack (symbol, a top-level function))
  • content/docs/protocol/objectql/types.mdx (via os migrate files-to-references (command, read off packages/cli/src/commands/migrate/files-to-references.ts), os migrate value-shapes (command, read off packages/cli/src/commands/migrate/value-shapes.ts))
  • content/docs/upgrading.mdx (via os migrate files-to-references (command, read off packages/cli/src/commands/migrate/files-to-references.ts), os migrate value-shapes (command, read off packages/cli/src/commands/migrate/value-shapes.ts))

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

  • content/docs/releases/v17/17-0.mdx (via os migrate files-to-references (command, read off packages/cli/src/commands/migrate/files-to-references.ts), os migrate value-shapes (command, read off packages/cli/src/commands/migrate/value-shapes.ts))
  • content/docs/releases/v17/17-1.mdx (via os meta resync (command, read off packages/cli/src/commands/meta/resync.ts))
  • content/docs/releases/v17/17-5.mdx (via os migrate files-to-references (command, read off packages/cli/src/commands/migrate/files-to-references.ts))

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
  • 1 changed file(s) yielded no anchor (packages/cli/src/utils/schema-migration-plugins.ts) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 43 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 3a6d92f78bb6a160b762dfee0738fd3b0b7ae6c2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d3d17ae2cc36e0f53494e72e488698ee514bfee6 — the merge of head 753bec1955ad9b34ca381d07eacc364afc02d9c8 into base 3a6d92f78bb6a160b762dfee0738fd3b0b7ae6c2, 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 d3d17ae2cc36e0f53494e72e488698ee514bfee6 && git checkout d3d17ae2cc36e0f53494e72e488698ee514bfee6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3a6d92f78bb6a160b762dfee0738fd3b0b7ae6c2 753bec1955ad9b34ca381d07eacc364afc02d9c8 && git checkout -B drift-repro 3a6d92f78bb6a160b762dfee0738fd3b0b7ae6c2 && git merge --no-ff 753bec1955ad9b34ca381d07eacc364afc02d9c8

node scripts/docs-audit/affected-docs.mjs --json 3a6d92f78bb6a160b762dfee0738fd3b0b7ae6c2

⚠️ 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 3a6d92f78bb6a160b762dfee0738fd3b0b7ae6c2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Test Core (4/6) failed the file in beforeAll: the served boot composes
SettingsServicePlugin, whose LocalCryptoProvider refuses to start in
production without a key, and a CI runner has no persisted
$HOME/.objectstack/dev-crypto-key. The file now sets a fresh key for its
whole run and restores the variable afterwards, the convention
orphans.driver-contract.test.ts already follows.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 753bec1955ad9b34ca381d07eacc364afc02d9c8
Local-runs: none

The head moved during this review: dispatched on 11d48f07f7dea9c555ef7574615b5e132479165a, which was reviewed in full and whose check-runs converged red (③, last bullet); the dev's patch round pushed 753bec1955 (one commit, test(cli): the one-shot family pin declares its own OS_SECRET_KEY, one test file, 11 insertions, 1 deletion). This record is rendered on 753bec1955: the delta was read in full and the net diff against the unchanged merge base 39a912ea73 is still the same 20 files (1202 insertions, 77 deletions). Reviewed against card #21391 (body; the seat's scope addition 5950599226; triage's family-wide ruling 5950851232; the unlock 5950921168; the claim 5952232742; the os-dev-report 5956238556), card #4747 (body, the claim 5163416053 declining option C, the PM review 5164027222 holding "the audit still runs in the CLI scenario"), PR #21389's record 5950560226, PR #21432 (body, unchanged between the two heads; the 20-file list; the net diff against the merge base; 10 commits including the origin/main merge 77a89b7b53), the code the diff calls read at the head sha (schema-migrate.ts, schema-migration-plugins.ts, standalone-stack.ts, objectql/src/plugin.ts, lifecycle-service.ts, app-plugin.ts, datasource-connection-service.ts, core/src/plugin-order.ts, serve.ts, every bootSchemaStack caller under packages/cli/src/commands, content/docs/deployment/cli.mdx), and the head's check-runs collapsed latest-per-name. Nothing was built, run or re-run.

① Derived judgments

(a) The family-wide ruling 5950851232, point by point — delivered, with two residues named.

  • Seed off on every one-shot boot, --apply/--delete included — right. bootSchemaStack passes skipSeedData: true unconditionally to createStandaloneStack (schema-migrate.ts L438) and to buildSchemaMigrationPlugins (L494), no longer keyed on deferSchemaDdl; createStandaloneStack hands it to AppPlugin (standalone-stack.ts, default false). Every one-shot CLI boot goes through this funnel: at the head the only other kernel boot under packages/cli/src/commands is serve.ts (createStandaloneStack at L2735, new Runtime at L3081), and os dev imports from serve. Pinned by the 11 write-mode cases of the family pin and ablation A1 (23 red).
  • Every no-write mode on the read-only boot — right; no caller missed. Fifteen modules value-import bootSchemaStack at the head: meta/resync, migrate/{account-issuer, apply, audit-metadata-bodies, duplicates, files-to-references, meta, multi-value-columns, plan, recorded-by, resume, summary-nulls, value-shapes}, secret/orphans, storage/orphans. Already read-only at the merge base: plan, duplicates, account-issuer, multi-value-columns, meta --stored and audit-metadata-bodies (the last two since 17.6.0: os migrate meta --stored (preview) and os migrate audit-metadata-bodies (dry run) boot the app's seed loader and write to application tables #21349); apply has no no-write mode. The eight the diff converts: value-shapes (scan), summary-nulls, files-to-references, recorded-by (dry run), resume (list), secret orphans (report), storage orphans (its only mode: its flags are database-url, max-candidates, samples, json) and meta resync when it can never reach its write (mayWrite = flags.yes || (!flags.json && stdin.isTTY), resync.ts L136; the confirmation_required answer at L183-189 writes nothing). meta resync is the eighth member the card's seven-row table lacked; the claim's "any other caller the enumeration pin finds" covers it. The enumeration pin derives the family from source and fails by file name on a new caller. Residue, not a breach: the prompt-then-write modes (--apply without --yes on the data commands, os meta resync at a TTY without --yes) boot plain before the operator answers, so a declined prompt leaves boot schema sync applied (no seed, no rows). The ruling and the predecessor record both treat --apply/--yes as the write mode, and the deferred-then-flush shape os migrate apply takes exists if the seat wants it closed later.
  • Lifecycle sweep not armed on a one-shot boot — right, structural. armLifecycleSweep: false (schema-migrate.ts L443) becomes lifecycle: { enabled: false } on ObjectQLPlugin (standalone-stack.ts L815, passed only on an explicit false); LifecycleService.start() returns before creating either timer when !this.enabled (lifecycle-service.ts L446). Pinned on the timers themselves, in the CLI (both one-shot boots unarmed, the served control armed) and in the runtime (declaration and effect).
  • DDL deferral over every SQL datasource — right. Judged under (c). pendingSchemaWork and flushSchemaDdl now aggregate over deferral.drivers; pinned with a real DatasourceAdminServicePlugin and driver factory on a second SQLite file that carries its own marker table.
  • The exit-1 edge pinned — right. preview-read-only.integration.test.ts asserts exit 1 and the refusal for both 17.6.0: os migrate meta --stored (preview) and os migrate audit-metadata-bodies (dry run) boot the app's seed loader and write to application tables #21349 commands (DATABASE_ERROR naming sys_metadata; failures: 2, scanned: 0 over sys_activity/sys_audit_log); the family pin asserts, for all 14 no-write modes at a missing file, no file and one JSON document.
  • The two comments corrected — right. The runPlatformMigrations block in schema-migrate.ts and the comment in platform-migrations-arming.integration.test.ts now list the write modes as the non-deferred boots; the latter's assertion is unchanged.
  • No serving boot loses its seed or sweep. serve.ts builds standaloneInput as { ...config.standalone, projectRoot, dev } (L2728-2734) and passes neither key; AppPlugin seeds by default and ObjectQLPlugin receives no lifecycle option unless the key is literally false. Both pins carry a served control that arms.

(b) The open question: armLifecycleSweep: false is lifecycle.enabled: false, the master switch, so an explicit sweep() is inert on a one-shot stack — answer A, keep as landed.

(c) The registerDriver shadow — an own-property shadow on a per-boot engine instance, released; not a leaking monkey-patch, and no race in any composition of the family.

  • Scope: DeferSchemaDdlPlugin.shadowEngine defines an own property on the engine instance the one-shot kernel publishes under objectql/data (one instance, shadowed once), no prototype edit and no module state; the instance is per boot (ObjectQLPlugin.init builds new ObjectQL(hostCtx), plugin.ts L411). release() restores the saved descriptor, or deletes the own property so prototype lookup resumes, on flushSchemaDdl and on shutdown(), and only while its own shadow is still on top. Serving boots never compose the plugin. It is the seam createDeclarationBootWriteGuard already uses (The declaration-boot write guard has two named coverage boundaries left — engine-held non-default drivers, and immediate DDL (dropTable / rotateShards) #14126, schema-migration-plugins.ts L982-1010), and the two stack correctly: composition plugins init after the deferral (insertion order is preserved by resolvePluginOrder for plugins without edges between them), so the guard captures the deferral's shadow as both its forward target and its "original", and disarms right after runtime.start(), before any release().
  • Timing: the plugin's only edge is dependencies = ['com.objectstack.runtime.default-datasource'], so with insertion order preserved its init runs after ObjectQLPlugin.init (which registers objectql/data at L415-417) and before any start(). Drivers present at that moment are armed by the driver.* scan (SQL_DRIVER_SERVICES plus ctx.getServices(), which PluginContext exposes, core/src/types.ts L59) and by armHeld (the default by name, and each registered object's driver). Drivers that arrive later pass through the shadow before the engine holds them: DatasourceConnectionService.connect() registers at L635 and calls syncObjectSchema at L657, and ObjectQLPlugin.start() hands later driver.* services to registerDriver at L741. The one theoretical gap, a non-default driver registered on the engine before the deferral's init, not published as driver.*, with no object bound to it until later, is reached by no plugin in the standalone stack or in any family composition (DefaultDatasource publishes driver.*; ObjectQL holds the default; App registers objects in init and connects datasources in start). A SqlDriver-shaped driver without setDeferredDdl throws inside the shadow, which connect()'s guarded registration catches and warns on: loud, not silent. The pre-existing The composed-host-stack plan sees 8 of ~80 control-plane tables: objectstack#12952's start()-registration residue is the dominant case, not a zero-instance one #13028 case of a host bringing its own engine plugin displaces the shadowed instance, but the driver.* arming (the old path) still covers the default, so no regression.
  • sortPendingSchemaWork orders the aggregated work by table then kind; the driver's own preview iterates its deferred map, so for one driver the plan's order can differ from before only where insertion order differed from name order. Cosmetic; no test or consumer reads positions.

(d) The two error-path changes — within surface, right.

  • secret/orphans.ts: the scan body was try/finally with no catch, so a read the deferred boot refuses (sys_secret on a database never booted with the platform objects) escaped run() into oclif's generic handler and --json printed nothing. The new catch rethrows exit signals (every existing this.exit(1) path is unchanged), answers --json with one document {"error":"scan_failed", message, code} at exit 1, and prints the error at exit 1 otherwise. The read-only boot newly reaches that path, the file is on the claim's surface, and the family pin's one-document invariant needs it. Ablated (F1).
  • value-shapes.ts: this.exit(1) inside the try throws oclif's ExitError, which the catch re-reported as {"error":"EEXIT: 1"} and exited again. The catch now rethrows exit signals, the shape summary-nulls and files-to-references already had. Reached by the read-only scan (unreadable objects fail the gate). Ablated (F2). Neither widens any accept set beyond the declared exit-1 edge.

(e) The pins — none weakened.

  • schema-migrate.one-shot-family.integration.test.ts (new, 44): the family is read off src/ by a value-import regex and held equal to CALLERS, with plan.ts as the non-vacuity anchor; 14 no-write identities compare the full schema and every row read on a probe connection of the test's own, and refuse a boot_failed payload; 14 missing-file cases assert no .db/-wal/-shm/-journal and one JSON document; 11 write modes assert the seeded row's served columns unchanged (schema sync is allowed to add phone, so the projection is the right comparand); 3 lifecycle cases read the timers. Measured 27 red before the fix. SQLite-only, declared. The delta commit 753bec1955 is the one change since the dispatched head: the suite's beforeAll now sets OS_SECRET_KEY to 32 fresh random bytes (hex) for the whole file, saved and restored with the other env keys. Right, and rightly scoped: the suite runs under NODE_ENV=production, where LocalCryptoProvider refuses to mint a key (local-crypto-provider.ts L424), and SettingsServicePlugin constructs one at kernel:ready on every boot that composes it (settings-service-plugin.ts L234): the served control, both secret orphans cases, and the storage arm of files-to-references and storage orphans (data-migration-plugins.ts L62-65). The dispatched head's local 44/44 had rested on a persisted ~/.objectstack/dev-crypto-key in the developer's home, an input outside the repository that no CI runner has; the file now declares its own, written nowhere. No product change.
  • schema-migrate.deferred-ddl.integration.test.ts: additive, two second-datasource cases with the real connection service and factory.
  • standalone-stack-lifecycle-sweep.test.ts (new): the declaration on ObjectQLPlugin and the timers after bootstrap().
  • preview-read-only.integration.test.ts: the fixture now seeds through a served boot, which is forced (the funnel no longer seeds); the preview identity cases and the --apply controls are unchanged; the two exit-1 pins are added. Strengthened.
  • platform-migrations-arming.integration.test.ts: comment only; the non-deferred byte-identical assertion stands.
  • multi-value-columns.no-auto-run.test.ts: the first assertion (no non-test importer) is unchanged; the second admits one named test file with its reason. Not a weakening.
  • schema-migrate.teardown.integration.test.ts: judged under (b). The first case pins the one-shot (no timers, explicit sweep() inert, teardown closes the kernel and the pool); the second keeps the 每个 os migrate 子命令关停时,悬空引用巡检都会把 sys_metadata / sys_view_definition 报成 unreadableObjects(连接已关闭) #4747 pair on the composition that sweeps.

(f) The prose — true at the head, two sentences qualified.

  • Changeset: the BREAKING line names exactly the five commands whose no-write mode moves from exit 0 to exit 1 (value-shapes, recorded-by, resume, secret orphans, storage orphans; summary-nulls, files-to-references and meta resync stay at exit 0). "os migrate plan lists a second datasource's pending tables, and os migrate apply creates them after you confirm" holds where the deployment composes the datasource-connection service (DatasourceAdminServicePlugin, which serve composes unless the host config brings its own, and which plan/apply reach only through the host config under composeHostStack); without it a declared datasource stays metadata-only on every boot, serving included (app-plugin.ts L829). True by construction; measured on bootSchemaStack with the service as an extra plugin, not on plan/apply themselves. "Every mode that writes nothing boots the way os migrate plan does" is true under the ruling's mode-based reading (the prompt-then-write residue in (a)). The embedder paragraph (armLifecycleSweep, default true, explicit sweep() returns an empty report) is exact.
  • cli.mdx: the os secret orphans paragraph matches the code (scan_failed, exit 1, no file). The Data migrations paragraph is true for the five commands in its table; "can fail and exit 1, naming the table" is the right hedge (value-shapes fails its gate, recorded-by/resume carry the driver's refusal, summary-nulls/files-to-references exit 0). The "writes nothing" lines at L1067 and L1119 are now true. os storage orphans, os meta resync, os migrate recorded-by and os migrate resume have no entry (acceptance note 6: true).
  • PR body: the caller table matches the 15 modules; "used to arm the first driver.* SQL service only" is what findSqlDriverVia did; acceptance notes 1-6 are each true; the measured tables are the dev's readings on examples/app-crm and were not re-run here, and every row is consistent with the mechanism traced above. One sentence is stale at this head: under Verification, "The final commit adds one module-top side-effect import to a test file" described 11d48f07f7; the final commit is now the OS_SECRET_KEY declaration, and the body's integration-tier readings (5cfaffbc87, 0758b2330d, 77a89b7b53) predate the fix that made the family pin runnable on a clean runner. Owed: one body edit by the seat before landing, no re-measurement.

② Semver level

@objectstack/cli minor with the BREAKING banner, @objectstack/runtime minor, Clause-②: yes (narrowing) on the PR's line 2 and in the changeset, ADR-0087 not-required (no-migration-prescription) — right. The CLI's accept set shrank on a public door: five commands' no-write modes at a database lacking a table they read answered exit 0 and now refuse with exit 1, observable to any script; under the declaration rule yes takes at least minor and (narrowing) is BREAKING, and the launch-window convention grades it minor (the same grade PR #21389 took). The runtime's bump is a widening: a new optional key on StandaloneStackConfigSchema, a public builder's input. The ADR-0087 category is the honest one: no authorable key, export or stored shape moves, the remedy is operational (point --database-url at the deployment's database, or boot it once), and the body carries no FROM/TO for the detector to refuse; Lint & Repo Gates, which carries check:adr-0087-registration and the changeset gates, is success on this head. The commit type fix without a bang follows #21389; a commit type can raise a bump, never lower it.

③ Boundary flags

  • os migrate recorded-by --apply --yes --json prints its result, then {"error":"EEXIT: 0"}, and exits 1 (class a, public door, measured by the dev at 77a89b7b53; resume --run has the shape by reading). A write path this card does not reach; answered as the dev reported it. The seat files it as its own card.
  • ObjectQLPlugin's lifecycle option doc (packages/objectql/src/plugin.ts L197-202) says tooling can still run sweep() with enabled: false; sweep() returns an empty report when not enabled (L610), and has since before this card (OS_LIFECYCLE_DISABLED is the same getter). The runtime key's doc and the changeset now state the opposite in writing. Outside this claim's surface. ESCALATE: a one-line doc correction in packages/objectql, on the next PR that touches that file or a docs-only PR; folded into B if the maintainer takes B.
  • readOnlyProbe covers the default datasource only: a second SQLite datasource an artifact declares is opened by its connect on a read-only boot, so an absent file is created there, while its DDL is deferred as the ruling asked. sqliteAbsentFile is a createStandaloneStack option on the default datasource; the declared path goes through service-datasource's driver factory. ESCALATE as a follow-up card (the 17.6.0: os migrate meta --stored (preview) and os migrate audit-metadata-bodies (dry run) boot the app's seed loader and write to application tables #21349 missing-file class, one datasource out); no carrier today, not a breach of the ruling's wording.
  • The prompt-then-write modes schema-sync before the operator answers (①(a)). Noted; the ruling's mode-based reading accepts it; a follow-up at most.
  • Dev deviations: Clause-② corrected to yes (narrowing) (the claim anticipated it; right); the two error-path edits (judged (d)); the reworked pins (judged (e)); the origin/main merge 77a89b7b53 (the net diff against the merge base is exactly the 20 files); one ~1s single-file vitest run outside os-verify-lock (a process note with no effect on the diff); the title without a bang (fix(cli): os migrate meta --stored and audit-metadata-bodies preview on a read-only boot #21389 precedent, the changeset carries the banner). All answered.
  • Fences and stop clause held: migrate/audit-metadata-bodies.ts and plugin-audit's stored-metadata-body-migration.ts are not in the file list; no packages/drivers change. The live PostgreSQL CI leg is not this card; the family pin is SQLite-only by declaration, and preview-read-only's PG cell stays a named skip.
  • Fixes #21391: right to close. Every line of the ruling is delivered (seed, read-only boot, sweep, per-datasource deferral, the exit-1 pin, the two comments), every triage pin is present (the enumeration with both controls, the --apply/--delete cases), and the two residues from 5950599226 are answered structurally. The open question is answered A above and needs no card unless the maintainer wants B. The card this PR closes must claim this branch and Part-of PR must not also close its card are both success on this head.
  • Governed surfaces: none of the 20 files (.changeset/, content/docs/, packages/cli/, packages/runtime/); 1,279 changed lines; head repo equals base repo. No record is required for the landing; this one is rendered because the seat asked. The PR is a draft; readying and arming are the seat's, on green.
  • The dispatched head 11d48f07f7, for the record: its check-runs converged at 2026-10-02T16:15:02Z with 35 names, 31 success, 2 skipped, 2 failure (Test Core (4/6) and its rollup Test Core, a required context). Read from the job log, not from the dispatching seat's note: the family pin failed as a suite in beforeAll at bootServedStack L333 (runtime.start()) with [LocalCryptoProvider] Refusing to start in production without a stable encryption key (1 suite failed, 161 passed, 2181 tests passed), so on that head none of the ruling's pins had run in CI. That is the red 753bec1955 answers (①(e)); the seat's "test-only, give the suite a key" diagnosis was confirmed independently here before the patch was read.
  • Check-runs on 753bec1955ad9b34ca381d07eacc364afc02d9c8, collapsed latest-per-name, converged at 2026-10-02T16:38:56Z (polled every 5 minutes from the push at 16:21; the head had not moved again at 16:39): 35 names, 33 success, 2 skipped (Console Pin Gate, Packed-tarball smoke), 0 failed, 0 pending. All seven required contexts are success: Lint & Repo Gates (carrying check:adr-0087-registration, the changeset gates, check:test-source-alias, check:cross-package-test-inputs), TypeScript Type Check, Test Core (all six shards; shard 4/6, the one that carried the family pin red on 11d48f07f7, now success at 16:38:27Z), Dogfood Regression Gate (three shards and the rollup), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Also success: Check Changeset, Dogfood Verify CLI, the four Type Check lanes, Spec property liveness, and the claim, single-writer and part-of checks. Cited from the converged snapshot only.

Implemented-by: claude/issue-21391-one-shot-boot-read-only
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 16:46
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 16:46
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit b206403 Oct 2, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21391-one-shot-boot-read-only branch October 2, 2026 17:10
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.

[finding] seven more dry-run / report-only CLI commands boot the app seed loader and rewrite seeded rows (the family of #21349)

2 participants