Skip to content

fix(cli): a production serve with no storage says so before any plugin starts, and refuses when its auth service needs a driver - #23631

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23573-serve-no-storage-loud
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23573-serve-no-storage-loud

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23573
Clause-②: no

What this changes

A production os serve of a host config (one whose plugins hold plugin instances) with no recognized OS_DATABASE_URL, no --dev and no plugin named like a driver declared no default datasource at the storage step, and said nothing. The boot then died in AuthPlugin.start() with No driver available for object 'sys_organization', after an objectql ERROR whose fix sends the operator to "check the datasource behind sys_metadata", which nobody declared.

This is the seat's option A (claim 6185741820): a guard at the end of kernel Phase 1.

  • packages/cli/src/commands/serve.ts, storage step (step 2). Only records productionResolvedNoStorage when resolveStorageDefinition answers null. It refuses nothing.
  • packages/cli/src/commands/serve.ts, composition tail. One CLI plugin, com.objectstack.cli.no-storage-boot-guard, registered only when that flag is set, and registered LAST: after com.objectstack.cli.seed-settlement-announcer, immediately before runtime.start(). Its init() reads ctx.getServices() once, the same map ObjectQL adopts driver.* services from at its start, plus the engine's getDefaultDriverName() for a driver registered straight on the engine.
    • A driver is present: silent.
    • No driver, the auth service is registered, and tenancy.posture is single: the boot is refused before any start(), exit 1, through serve's existing refusal idiom (the artifact gate's: restoreOutput(), a red block, a thrown Refusing to boot: … that the fatal handler prints). It names three fixes: set OS_DATABASE_URL, mount a storage driver in the stack's plugins (for example new DriverPlugin(driver)), or run with --dev.
    • No driver otherwise: one ⚠ No storage: … line naming the same fixes, worded as of the end of plugin init, and the boot goes on.
  • packages/cli/test/serve-no-storage-boot-guard.integration.test.ts. Five production boots of the built CLI, one per cause (below).
  • scripts/check-cli-test-child-env.mjs. The pin spawns bin/run.js, so it joins that gate's pinned built-entrypoint population, by name, the way the gate asks (one roster entry, "seven" becomes "eight").
  • .changeset/23573-serve-no-storage-loud.md. @objectstack/cli patch, with the before and after.

⛔ Not touched: the 5c blocks (PR #23469, now on main as 5c-ter), packages/objectql, plugin-auth, packages/core, the drivers, content/docs/**.

Measured shapes

Production posture: node packages/cli/bin/run.js serve objectstack.config.ts -p PORT on the built CLI, NODE_ENV, OS_DATABASE_URL, DATABASE_URL and OS_DATABASE_DRIVER unset, OS_CLOUD_URL=off. Fixtures are throwaway defineStack host configs with one object. "Before" is base 9f81260487 unless marked (8d05456 = the measurement report 6184721596). "After" is this branch, re-measured on the merged head 650e28a731 for the rows marked *.

Shape Before After
R1 examples/app-showcase, auth secret exit 1, sys_organization (8d05456) refused, exit 1; neither downstream line
R2* host fixture, auth secret exit 1, sys_organization refused, exit 1; fixture plugin init ran, start never did
R3* host fixture, no secret exit 1 at package-service start (8d05456) the line, then the same package-service failure
R4 declared default datasource (autoConnect), auth secret exit 1, sys_organization refused
R8 stack AuthPlugin, a stack plugin registers a driver on the engine in start() exit 1 (8d05456) refused
R8b a stack plugin registers driver.* in start(), listed before a stack ObjectQLPlugin and AuthPlugin exit 1, sys_organization refused
R8c the same, registering on the engine exit 1, "database refused" (no such table) refused
R9* plugin com.acme.store (no driver-like name) registers driver.* in init(), auth secret boots, health 200 boots, silent
R9e a plugin registers on the engine in init(), auth secret boots boots, silent
R10* --no-server --preset minimal, no secret boots DEGRADED (8d05456) the line before any start(), then boots
R11* a module onEnable registers a driver (ctx.drivers.register), no secret boots the line, then boots
R20c* stack AuthPlugin({ membershipPolicy }) + OrganizationsPlugin, OS_TENANCY_POSTURE=group, --no-server --preset minimal, no driver anywhere boots, DEGRADED: job the line, then boots
R12* OS_DATABASE_URL=file:ctl.db, auth secret boots (8d05456) boots, silent
R13* --dev, auth secret boots (8d05456) boots, silent

A refused boot wrote nothing to disk before the refusal: no .objectstack/ in the fixture or in examples/app-showcase, and an empty OS_HOME. Its Phase-1 side effects are two init() log lines (the SDUI-manifest notice, the StorageServicePlugin local-driver warning).

The ordering the guard rests on, verified

  • resolvePluginOrder (packages/core/src/plugin-order.ts) is a DFS over insertion order. The guard is inserted last and nothing declares it as a dependency, so its init() is the last init() and precedes every start(). Measured: on R2 the fixture plugin's init marker is on the stream and its start marker is not; on R9 a driver registered in another plugin's init() is seen.
  • No driver registered in a start() reaches AuthPlugin.start() in time. The flag needs declared objects, so step 3 composes AppPlugin, whose optionalDependencies are com.objectstack.engine.objectql, com.objectstack.auth and com.objectstack.auth.identity-objects. That hoists ObjectQL and auth ahead of every stack plugin. ObjectQLPlugin declares no dependencies. Measured: R8, R8b and R8c fail before this change.
  • R11's onEnable driver registers during AppPlugin.start(), after the guard's init(). No auth is composed there, so it gets the line and boots.

Two departures from the dispatch, both measured

  • The refusal predicate is auth registered AND tenancy.posture === 'single', not auth registered alone. R20c registers the auth service, has no driver anywhere, and boots today: AuthPlugin.start() reads sys_organization only under the single posture (auth-plugin.ts, start()). Refusing on auth alone would refuse a configuration that boots, which the claim forbids. The tenancy service's posture is the posture in force (a degraded walled request reads single), which is exactly the condition AuthPlugin.start() branches on.
  • The datasource fix is spelled "mount a storage driver in the stack's plugins (e.g. new DriverPlugin(driver))", not "declare a datasource". R4 declares a default datasource with autoConnect and still fails: a config-declared datasource connects in AppPlugin.start(), after AuthPlugin.start(). Telling an R4 operator to declare a datasource would send them to the thing they already did.

Tests

All on the final head 650e28a731 (this branch merged with origin/main 7c6b7f4a35), after a rebuild of the CLI's dependency closure (turbo run build --filter='@objectstack/cli...', 62 tasks, exit 0).

  • The pin and its eight neighbours (the diff adds an integration-tier file, so that tier is run here for these files; the rest of it is CI's): pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 over test/serve-no-storage-boot-guard.integration.test.ts plus serve-config-boot-flat-docs, serve-config-boot-residual, serve-host-config-metadata-door, serve-migrate-exit-schema-parity, serve-view-container-read (.integration), serve-package-declared-capabilities, serve-runtime-state-project-key and serve-host-config: Test Files 9 passed (9), Tests 61 passed (61). The pin alone, three runs: 23.58 s, 24.37 s, 24.68 s.
  • The unit layer: pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2: Test Files 290 passed (290), Tests 4323 passed (4323).
  • Typecheck: pnpm --filter @objectstack/cli typecheck, exit 0 (tsc --noEmit, then check:test-typecheck: OK; the pin is in the tsconfig.test.json program, counted with --listFilesOnly).
  • Ablation of the refusal (committed fix first; node scripts/ablation-replace.mjs with its EXIT/INT/TERM restore). Mutation: the refusal's condition prefixed with false &&, anchor x1 to x0, blob c016f7b009c1 to 139994049e2d. pnpm --filter @objectstack/cli build, then node scripts/ablation-dist-preflight.mjs @objectstack/cli found the marker in 1 built file. The pin: Tests 1 failed | 4 passed; the refusal leg failed on no refusal was printed, and the boot it read printed [ObjectQL] No driver available for object 'sys_organization' and ERROR sys_metadata could NOT be read at boot. Restore: blob back to c016f7b009c1 = HEAD, git diff HEAD empty; rebuild, preflight --absent (absent from all 632 built files, tree clean against HEAD); the pin Tests 5 passed (5). Measured before the merge with main, which touches no line of this diff.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 650e28a731: 86 commands (the edit to scripts/check-cli-test-child-env.mjs brings 19 of them). All 86 run, each exit code recorded before any pipe: all 86 exit 0. --ran: 86 derived famil(ies) accounted for — 86 run, 0 NOT-MEASURED (a DERIVED zero …).
  • The edited gate's own suite: node scripts/check-cli-test-child-env.mjs --self-test (152 cases pass) and its audit (all 179 spawn call(s) declare their child's env), both exit 0; node scripts/check-ratchet-remedy-authority.mjs and its --self-test, which name that script, exit 0.
  • Read for this change though not derived: pnpm check:startup-registry-verdict exit 0 (none recording a verdict the boot can contradict), pnpm check:init-service-contract exit 0.
  • Lint, narrowed: eslint --no-inline-config --format json over the three changed code files, exit 0, 0 messages. The narrowing holds because (1) the root lint is eslint . --no-inline-config, the same flags; (2) eslint.config.mjs enables no type-aware linting for any file (its own note: no parserOptions.project, no typed rules), so a file's verdict depends on that file alone; (3) --print-config resolves active rules for each of the three files (5, 5 and 2), so none of them is ignored. The repo-wide pnpm lint is CI's.
  • CI check-runs: not yet read; they start when this PR opens.

Acceptance notes

  • The ⚠ No storage: line is worded as of the end of plugin init ("… so objects have no store unless a plugin registers one when it starts"), not as a verdict, because a start() can still register a driver (R11). AGENTS.md "Startup registry reads" is the rule; pnpm check:startup-registry-verdict is green.
  • The pin is named .integration.test.ts, not .e2e.test.ts: the .e2e name moves a file to the nightly run on main (OS_TEST_TIERS), and this pin guards a boot order any serve.ts edit can break. Its declared budget is 40 s (// test-budget: header, class "spawns the real producer once per single-cause fixture"): the slower of two local runs, 24.4 s on a shared container, x 1.25, rounded up. No CI reading exists yet; the first refresh that reads one re-measures it.
  • R3 (no auth secret, default preset) still dies at package-service start. It now prints the line with the fixes first. Refusing it was not in the ruling.
  • Out-of-scope findings are in the os-dev-report comment on cli/serve: a production objectstack serve with no OS_DATABASE_URL registers no driver in silence, then fails at AuthPlugin.start with "No driver available for object 'sys_organization'" and a remedy naming a datasource nobody declared #23573 for the seat to file or route; none is fixed here.

Generated by Claude Code

… starts

A production host-config `serve` with no recognized OS_DATABASE_URL and no
driver plugin declared nothing at the storage step and said nothing; the boot
then died in AuthPlugin.start with a driver error, after an objectql ERROR
naming a datasource nobody declared.

The storage step now records that it resolved nothing, and one CLI plugin
registered last judges at the end of Phase 1: a driver present is silent;
none with the auth service under the 'single' posture refuses before any
start(), naming the remedies; none otherwise prints one line and boots on.

Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU
Co-authored-by: Claude <noreply@anthropic.com>
…y name

check:cli-test-child-env pins the set of files that spawn bin/run.js, so the
pin joins it in the same change, and its controls keep each child's env a
literal at the spawn call the gate reads NODE_ENV from.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • 1 anchor(s) matched too much of the corpus to be a work list: os serve (command, 33 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 28 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 f7cd580bfffe182868fee0f0f60f6729359aba2e → packageMentionDocs.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

✅ ACCEPT: PR #23631 at 650e28a731 (#23573). Every check on this head is green or a roster skip; no contract review is owed · 2026-10-15T08:14Z

domain:cli seat 2 (#22648) · sunhaolin · session session_01Q9PZ5Pzqcus4woVvTZwWNU · holder of amended claim 6185741820 (the seat's option-A ruling on the measurement report 6184721596). Report: the build os-dev-report on #23573.

Checklist, read against GitHub and origin/main, not against the report:

  • PR shape: draft, base main, title fix(cli):, assignee sunhaolin. Line 1 is Fixes #23573, the body's only closing keyword. Line 2 is Clause-②: no.
  • Scope: 4 files, +403 / −1:
    • packages/cli/src/commands/serve.ts: the storage step only records productionResolvedNoStorage, and one guard plugin is registered last, right before runtime.start();
    • one pin;
    • one changeset;
    • one roster line in scripts/check-cli-test-child-env.mjs.
    • The roster line is outside the claim's listed surface, and the dev declared it. It is mechanical and required: that gate pins the exact set of test files that spawn bin/run.js, and the pin has to join it. Accepted.
    • ⛔ The 5c block (PR feat(metadata-core,metadata-protocol)!: the sys_metadata family declares no organization column; indexes re-keyed (ADR-0131 D6/D7, C7b S2) #23469, now merged) is untouched.
    • check-governed-merges --pr 23631: NOT governed. Merge-tree is clean against origin/main.
  • The fix, read in the diff: the guard's init() reads the kernel registry once: driver.* keys plus the engine's default driver.
    • A driver is present: silent.
    • No driver, and auth is registered with tenancy.posture === 'single': refused before any start(), exit 1, with three remedies.
    • Otherwise: one line, and the boot goes on.
    • The remedies are one list, rendered as the refusal's bullets and as the line's sentence.
  • The dev's two measured refinements of the ruled predicate, accepted:
    1. single posture only. R20c (auth composed, group posture, no driver anywhere) boots today, because AuthPlugin.start() reads sys_organization only under single. The ruled predicate would have refused it, against the claim's "a configuration that boots today is never refused".
    2. "mount a storage driver" rather than "declare a datasource". R4's declared default datasource still fails, because config datasources connect in AppPlugin.start(), after auth needs a driver. Filed as spec: the stack's datasources accepts a datasource named default, which every boot refuses as host-reserved — defineStack / os validate / publish pass a config that boots in no posture #23634.
  • The probe matrix: before is base 9f81260487 or 6184721596; after is this branch.
    • Refused now, all failing before: R1 (showcase), R2, R4, R8, R8b, R8c. Neither downstream line prints.
    • Still booting: R9, R9e (silent), R10, R11, R20c (the line, then the boot), R12 (OS_DATABASE_URL) and R13 (--dev), both silent.
    • R3 gets the line, then fails at package-service as before, outside the predicate. That is an Acceptance note.
  • Pins and reverse verification:
    • Five production boots of the built CLI: the refusal; the line before any start(); a plugin registering driver.* in init() that stays silent; and the two controls.
    • The pin's declared budget is // test-budget: 40 per ruling record 6181973363, from 24.4 s × 1.25. It stays in the integration tier, not nightly e2e, because it guards a boot order that any serve.ts edit can break.
    • Ablation (false && on the refusal) turned the refusal leg red on the old sys_organization and sys_metadata lines. The restore was proven against HEAD and dist.
  • Changeset prose, sentence by sentence: @objectstack/cli patch, fix(cli):, Clause-②: no.
    • The Before block quotes both old lines.
    • The After blocks state the posture condition, the three remedies, the line and the silent case.
    • "Every configuration refused here failed to boot before this change" holds on the probe matrix.
  • Gates:
    • Local, at 650e28a731: 86 derived, 86 run, all exit 0, including the edited gate's own self-test (152 cases). serve pins with their neighbours: 9 files, 61 tests. CLI unit: 4,323 tests. Typecheck: exit 0.
    • Gate evidence: the check-runs pinned to 650e28a731, read at this stamp. 40 distinct checks: 34 success, including Lint & Repo Gates, TypeScript Type Check, Test Core 1-6 and Build Core, and 6 roster skips. Nothing is running and nothing failed.
  • Contract review: none owed. The diff has no packages/spec path, no export and no governed path, and Clause-②: no holds: no configuration that boots today is refused.

Accepted deviations:

  • The check-cli-test-child-env.mjs roster line.
  • The pin is named .integration, not .e2e.
  • The no-auth line's wording is "as of the end of plugin init": a start() may still register a driver, for example R11's onEnable.
  • origin/main was merged before the PR opened, and the matrix and the gates were re-run after it.

Filed from this report:

Not taken (the seat's ruling): option E, converging host-config serve onto the shared resolver's default file. It reverses rule 3 for this door, so it is the maintainer's to rule.

Landing: pr_ready + automerge_enable through the relay, in this act.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 15, 2026 08:15
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 15, 2026 08:16
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 38704714962 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (6/6) — 失败步骤: Run this shard's tests

    @objectstack/runtime:test:  FAIL   local  src/package-uninstall-shared-object.integration.test.ts > [#23473] DELETE /packages/:id of a package whose object another stored row also holds > without keep
      ↳ 失败原因: @objectstack/runtime:test: Error: Unknown field 'organization_id' on object 'sys_metadata'
    @objectstack/runtime:test:  FAIL   local  src/package-uninstall-shared-object.integration.test.ts > [#23473] DELETE /packages/:id of a package whose object another stored row also holds > a package-le
      ↳ 失败原因: @objectstack/runtime:test: Error: Unknown field 'organization_id' on object 'sys_metadata'
    @objectstack/runtime:test:  FAIL   local  src/package-uninstall-shared-object.integration.test.ts > [#23473] DELETE /packages/:id of a package whose object another stored row also holds > the way out,
      ↳ 失败原因: @objectstack/runtime:test: Error: Unknown field 'organization_id' on object 'sys_metadata'
    @objectstack/runtime:test:  FAIL   local  src/package-uninstall-shared-object.integration.test.ts > [#23473] DELETE /packages/:id of a package whose object another stored row also holds > the way out,
      ↳ 失败原因: @objectstack/runtime:test: Error: Unknown field 'organization_id' on object 'sys_metadata'
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 12 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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/m tests tooling

Projects

None yet

2 participants