fix(cli,runtime): one-shot CLI boots run no seed loader and arm no lifecycle sweep; every no-write mode boots read-only - #21432
Conversation
…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>
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>
…e-shot-boot-read-only
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>
📓 Docs Drift CheckThis PR changes 2 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 43 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
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>
Contract reviewServed-tier: The head moved during this review: dispatched on ① Derived judgments(a) The family-wide ruling
(b) The open question:
(c) The
(d) The two error-path changes — within surface, right.
(e) The pins — none weakened.
(f) The prose — true at the head, two sentences qualified.
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #21391
Clause-②: yes (narrowing)
What changed
The family ruling on #21391 (triage
5950851232), built in thebootSchemaStackfunnel and its callers:bootSchemaStackpassesskipSeedData: trueon every boot, keyed likerunPlatformMigrations: false(unconditional, not ondeferSchemaDdl). That covers the--apply,--delete,--runand--yespaths. Seeding stays withos devandos serve.deferSchemaDdl: true, readOnlyProbe: true, the bootos migrate plantakes):os migrate value-shapes(scan),summary-nulls,files-to-referencesandrecorded-by(dry run),os migrate resume(list),os secret orphans(report),os storage orphans(its only mode), andos meta resyncwhen it can never reach its write (no--yes, and--jsonor no TTY). The enumeration pin foundos meta resyncas an eighth member. Write modes keep the plain boot, minus the seed.createStandaloneStack({ armLifecycleSweep }), defaulttrue. Withfalse,ObjectQLPlugingetslifecycle: { enabled: false }and the ADR-0057 timers are never created.bootSchemaStackpassesfalse.DeferSchemaDdlPluginused to arm the firstdriver.*SQL service only. It now arms everydriver.*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'sregisterDriverthat arms the driver before forwarding it, the seam the declaration-boot write guard already uses.pendingSchemaWorkandflushSchemaDdlcover every armed driver. No driver change.os migrate metaamong the non-deferred boots are corrected: therunPlatformMigrationsblock inschema-migrate.ts, andplatform-migrations-arming.integration.test.ts.--jsonfaces the read-only boot newly reaches on a database without the app's tables.os secret orphanshad no catch for a scan error, so--jsonprinted nothing; it now answers{"error":"scan_failed","message":…,"code":…}with exit 1.os migrate value-shapesre-reported its ownthis.exit(1)as a second document,{"error":"EEXIT: 1"}; its catch now rethrows exit signals, assummary-nullsandfiles-to-referencesalready do.The fenced files are untouched:
migrate/audit-metadata-bodies.tsand plugin-audit'sstored-metadata-body-migration.ts. The family keys reach that command throughbootSchemaStack.Measured: the #21349 repro on
examples/app-crmSetup at base
1d0600bf66:os build, thenos 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_atbumped,organization_idstamped). The PR readings use the CLI built at3f61ebcdb6.migrate value-shapes/summary-nulls/files-to-references/recorded-by/resume,secret orphans,storage orphans,meta resync(no--yes)"updated":28[Seeder] skipSeedDataaccount-issuer,multi-value-columns, plusplan,duplicates,meta --stored,audit-metadata-bodies--applyofvalue-shapes/summary-nulls/files-to-references/recorded-by/meta --stored/audit-metadata-bodies,resume --run,secret orphans --delete,meta resync --yessys_migrationfor the value-shapes and files-to-references applies,sys_permission_setformeta resync --yes)multi-value-columns --apply,apply --yes(deferred boots)A no-write mode pointed at a SQLite file that does not exist:
value-shapessummary-nulls,files-to-referencesrecorded-by,resume,storage orphanssecret orphansscan_failed, no filemeta resync(no--yes)confirmation_required), no filemeta --stored,audit-metadata-bodies,account-issuerThe exit-0-to-exit-1 rows are the declared narrowing:
Clause-②: yes (narrowing), aminorchangeset with the BREAKING banner and the ADR-0087 dispositionnot-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 undersrc/that value-importsbootSchemaStack, 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:schema-migrate.deferred-ddl.integration.test.ts: two cases with a second SQL datasource an artifact declares, connected through the realDatasourceAdminServicePluginand driver factory. The deferred boot creates nothing there and reports itscreate_table;flushSchemaDdlcreates it. Before the fix: 2 failed (the boot createddefer_remoteon the second database).packages/runtime/src/standalone-stack-lifecycle-sweep.test.ts(new): the key's declaration onObjectQLPlugin, 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) andos 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_ERRORnamingsys_metadata;failures: 2, scanned: 0oversys_audit_logandsys_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.tsnames the family pin as the one other reader of that module.Ablations
The fix was committed first. Each leg ran through
scripts/ablation-replace.mjsin 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 HEADempty), at0758b2330d. Every pin imports its subject by relative path, so it resolves tosrc; no rebuild was needed between legs.schema-migrate.ts:skipSeedData: falseplanandapply --yes, whose composed boots refuse row writes through the declaration-boot guard)schema-migrate.ts:armLifecycleSweepline removedregisterDrivershadow not installedlifecycle: { enabled: false }passthrough removedschema-migrate.ts:readOnlyProbemapping removedschema-migrate.ts: deferral never armedsecret orphans: thescan_failedemit removedvalue-shapes: the exit-signal rethrow removedVerification
@objectstack/cliunit tier at11d48f07f7: 248 files, 3552 passed.@objectstack/cliintegration tier, in 4 shards: shards 1-3 at5cfaffbc87(19 + 19 + 19 files; one failure, the 每个os migrate子命令关停时,悬空引用巡检都会把sys_metadata/sys_view_definition报成unreadableObjects(连接已关闭) #4747 teardown pin, reworked in0758b2330d), shard 4 and the teardown file at0758b2330d(19 files, 190 passed; 2 passed). After mergingorigin/main(39a912ea73), at77a89b7b53: the six touched suites, 72 passed and 1 named skip (the live PostgreSQL cell).@objectstack/runtimeat0758b2330d: 309 files, 5094 passed, 11 skipped.test/json-stdout-purity.e2e.test.ts(nightly tier,OS_TEST_TIERS=nightly) at0758b2330d: 44 passed.pnpm --filter @objectstack/runtime typecheckandpnpm --filter @objectstack/cli typecheckat77a89b7b53: exit 0,check:test-typecheckOK for both. Commit11d48f07f7adds one module-top side-effect import to a test file. (Seat correction at landing: the final commit is now753bec1955. It gives the one-shot family pin its ownOS_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 on753bec1955is the reading for that file.)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsat11d48f07f7derived 95 families; all 95 ran with exit 0 recorded before any pipe.--ranreports 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 nodist); after a turbo build of those eight they exited 0.11d48f07f7: eslint with the repo config and--no-inline-configover 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.mjssays so: noparserOptions.project, no typed rules), so this diff cannot move the verdict on any untouched file. The fullpnpm lintis left to CI.Acceptance notes
sweep()inert on a one-shot stack.lifecycle.enabledisLifecycleService's master switch: with it off,start()arms nothing andsweep()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 explicitsweep()on abootSchemaStackstack 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 callssweep()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 explicitsweep()alive on a one-shot stack while its schedule stays unarmed needs anObjectQLPluginoption that separates "arm the schedule" from the master switch, which is apackages/objectqlchange outside this claim.ObjectQLPlugin'slifecycleoption doc says that withenabled: false"thelifecycleservice stays registered so tooling can still runsweep()explicitly".LifecycleService.sweep()returns an empty report when the service is not enabled. No caller in this repo depends on the sentence.os migrate recorded-by --apply --yes --jsonon 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 thethis.exit(0)that follows a completed run (measured with the CLI built at77a89b7b53, on a copy of the app-crm database).os migrate resume --runhas the same shape by reading (not measured). This is a write path this change does not reach, so it is reported, not fixed here.content/docs/deployment/cli.mdxgains a paragraph under Data migrations and one in theos secret orphansentry.os storage orphans,os meta resync,os migrate recorded-byandos migrate resumehave no entry in that page.Generated by Claude Code