You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit ab488d9
Browse filesBrowse the repository at this point in the historyBrowse files
fix(cli)!: refuse a production os serve that composes no organization object, --server or not (#25155)
Fixes#25133
Clause-②: yes (narrowing)
Metadata-protocol: none
Executes the maintainer's ruling 6328987873 (letter A) under the claim
6329345390. Dispatched by the epic seat (`epic:#15194`,
`session_01Y5583bs2RDd53MH6kM1ca3`). Ships as an 18.x patch. Review
tier: `CONTRACT_REVIEW_TIER`, because the diff declares a narrowing.
## What changed
`packages/cli/src/commands/serve.ts`: a production `os serve` / `os
start` (no `--dev`) that composes no organization object is now refused
**whether or not `--server`**, before any plugin starts:
- **The `auth` tier is off and no `AuthPlugin` is mounted (A1).** The
condition goes from `flags.server && noAuth` to `(flags.server ||
!isDev) && noAuth`. A development boot keeps the old shape: it is
refused only when it serves.
- **The tier is on but no secret resolved (`no-secret`).** The condition
drops `flags.server`. `no-secret` is only ever the reason on a
production boot, because a development boot resolves the fallback
secret, so this needs no posture check of its own.
- **Both refusal texts** drop the "run with --no-server" remedy and the
"drop the REST/dispatcher plugins" remedy. Both were offered as ways to
serve without the data API, and both still write ownerless rows. Each
text now says that kernel-only does not avoid the refusal. The no-secret
text also names the migrate-and-exit job.
- **Unchanged:**
- the `host-kernel` reason (the cloud runtime's warn-only path);
- `os dev` and `--dev`;
- programmatic embedding, which never runs this command.
- **Ruling item 2:** no "tenant-scoped objects present" qualifier is
written. I measured no composition it would spare (see Acceptance
notes).
## Ruling item 5: the count
15 test files name `--no-server`. 8 of them carry neither `--dev` nor an
auth secret, not 9:
- **7 are unit-level** and boot nothing:
`serve-datasource-admin-routes`, `serve-http-server-composition`,
`serve-kernel-only-hold`, `serve-no-transport-announcements` (unit),
`serve-observability-registration`, `serve-unknown-hostname-guard`,
`format.server-ready-no-http-server`. Each reads `serve.ts` source or
calls a pure function.
- **1 spawns, but not in production:**
`serve-migrate-exit-schema-parity.integration`. It spawns through
`bin/run-dev.js`, which pins `NODE_ENV=development`, so it is a dev boot
and is untouched.
The one test that really boots production `--no-server` without a secret
is `test/serve-ready-degraded-boot.e2e.test.ts` (nightly tier). It
carries `OS_AUTH_SECRET` in its healthy leg only, which is why the
flag/secret count misses it. Its degraded leg is re-fixtured onto the
one production composition that still skips auth with a warning: a
host-kernel stub, a plugin registered under
`ObjectOSEnvironmentPlugin`'s name.
**Missed by that count, found by CI on `5996c531`:** four cli
integration files booted production `--no-server --preset minimal`.
`minimal` composes no auth (tiers `['core']`), so ruling item 1's
no-auth refusal now reaches them: `serve-no-server-banner`,
`serve-no-transport-announcements`, `serve-kernel-only-lifetime` and
`serve-no-storage-boot-guard` (6 tests). They are re-fixtured in
`6531b332`. The banner and transport legs boot today's production
kernel-only command (auth composed, a store). The lifetime and
no-storage legs, whose shape is no store and no auth, boot the
host-kernel stub with tiers `['core','auth']` and still pass `--preset
minimal`. **Consequence:** a production `os serve --preset minimal` is
now refused with or without `--server`, unless the stack mounts
AuthPlugin itself. It was already refused with `--server`. This is
ruling item 1 as written (report 6330837413).
Non-test callers: no workflow, Dockerfile, example or script in the repo
runs `--no-server`. `os start` always hands its `serve` child a secret.
## Pins:
`packages/cli/test/serve-no-auth-secret-refusal.integration.test.ts` (10
legs)
All legs spawn `bin/run.js` with `NODE_ENV` unset.
- **Refused, with the database never opened:**
- no-secret with `--server`;
- no-secret with `--no-server`;
- no-secret `--no-server` with `OS_MIGRATE_AND_EXIT=1`;
- no-auth with `--server`;
- no-auth with `--no-server` (with a secret).
- **Boots, reaching ready and opening the database:**
- a configured secret with `--server`;
- a configured secret with `--no-server`;
- `--dev --no-server` with no secret;
- the host kernel with no secret, `--no-server` (asserts the warn-only
line);
- no-auth with `--dev --no-server`.
## Proof (head `6531b332`; CI green: 42 check runs, 0 failing, Test Core
6/6)
- **The changed files, locally:**
- the pin file: 10/10;
- `serve-ready-degraded-boot.e2e` (`OS_TEST_TIERS=nightly`): 2/2.
- **cli unit layer:** `vitest run --project unit` reports 321/321 files
and 4746/4746 tests. Lock `VERDICT command-exit 0`.
- **cli typecheck:** exit 0.
- **cli integration layer:** the rest of it is declared to CI (`Test
Core`).
- **Ablation 1:** `ablation-replace.mjs` turns the no-secret condition
back to `flags.server && …`, the cli is rebuilt, and
`ablation-dist-preflight` confirms the marker is in `dist/`.
- Result: 2 failed and 8 passed. The red pins are no-secret
`--no-server` ("the boot was not refused") and the migrate-and-exit leg
(it exited 0).
- Restore: blob == HEAD.
- **Ablation 2:** the no-auth condition goes back to `flags.server &&
…`.
- Result: 1 failed and 9 passed. The red pin is no-auth `--no-server`.
- Restore: blob == HEAD. After a rebuild, `--absent` confirms both
markers are gone from all 680 built files and the tree is clean.
- **`node scripts/pm/dispatch-gates.mjs --commands`:** 101 families,
every exit code captured before any pipe.
- Five were non-zero on the first sweep: four prerequisite refusals
(exit 3: a shallow clone, and unbuilt packages) and
`check:cli-test-child-env` (exit 1, a real finding).
- The finding is fixed in `5996c531`: `NODE_ENV: undefined` now sits
after each leg's env spread.
- All five re-ran at exit 0 after a full build and a fetch of the pinned
fixture commit. `--ran` accounts for 101 of 101.
- **`check-governed-merges --branch`:** exit 0, NOT governed (0 of 6
paths), +276 / -63.
- **R1:**
- `pnpm dev:crm -- --fresh` reached ready, health 200;
- a production `os serve` of `examples/app-crm` with `OS_AUTH_SECRET`
reached ready, health 200;
- the same app with `--no-server` and no secret exits 1 with the new
text, and its database is never opened.
- **R4:** a walled production `os serve` (`OS_TENANCY_POSTURE=group`,
`invite-only`, `OS_PLATFORM_OWNER_EMAIL`, a secret) on a scratch app
that declares `@objectstack/organizations` reached ready with
`Organizations` composed, health 200.
## Docs
- `content/docs/deployment/cli.mdx`: the `--no-server` example and the
flag description now say a production kernel-only boot is held to the
same auth rule.
- `content/docs/upgrading.mdx`: the 18.0 auth-secret paragraph now
covers no-auth, `--no-server`, migrate-and-exit, and the unaffected
boots.
- `content/docs/releases/**` is not touched (ruling item 4). The
changeset carries the correction for the v18 page's `--no-server`
sentence.
## Acceptance notes
- **The `tenancy.enabled: false` remedy was left out on purpose.** The
dispatch asked for it as an operator remedy, but ruling item 2 writes no
qualifier, so declaring it does not lift the no-auth refusal. Offering
it in the refusal text would advertise a fix that does not work. The
changeset says so explicitly.
- **The pending changeset
`.changeset/24583-7b3-organizationless-counter-retired.md`** (already
shipped in 18.0.0's `dist`, still pending in `.changeset/`) contradicts
this change twice: "Unchanged: `--no-server` (kernel only) still boots
with the warning" and "or run kernel-only with `--no-server`". It is
outside the claimed file surface and was not edited. This PR's changeset
states that it supersedes both sentences. The seat may want that file
corrected before the next version cut.
- **The degraded-boot e2e's healthy and degraded legs** now differ in
config (bare vs host-kernel stub) as well as secret. Its header says so.
- **The pin file now spawns 10 boots,** about 34 s of wall time locally.
It declares no `test-budget:`, because there is no CI reading yet.
- **Edits were first made on the session's primary checkout** (the
claim's "the cloud session's own checkout"). They moved to a linked
worktree once the guard hooks refused; no content was lost.
---
_Generated by [Claude
Code](https://claude.ai/code/session_019pgvPFjtU47ULgfY7bZc6Q)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: .changeset/24583-7b3-organizationless-counter-retired.md
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -15,13 +15,13 @@ Clause-②: yes (narrowing)
15
15
16
16
**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, stage 8d).
17
17
18
-
- **`os serve`: a production server boot whose auth did not compose for a missing secret is refused.** With the `auth` tier on and no `OS_AUTH_SECRET` (outside `--dev`), `serve` used to skip AuthPlugin with a warning and serve anyway. That boot had no security model: anonymous object data is always denied, so its data API was unusable. It also had no organization object, so its inline seed wrote rows with `organization_id` NULL and drew their autonumbers from the `__global__` counter. Measured on `examples/app-showcase` before this change: health 200, two NULL-organization rows and a `__global__` counter row on a fresh install. The boot now stops before the kernel starts, so nothing is written, and the message names both fixes. Unchanged: `--no-server` (kernel only) still boots with the warning, a development boot still uses its fallback secret, and the cloud host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's) still only warns. `os start` never reaches the refusal: it always supplies a secret to the `serve` child it spawns.
18
+
- **`os serve`: a production server boot whose auth did not compose for a missing secret is refused.** With the `auth` tier on and no `OS_AUTH_SECRET` (outside `--dev`), `serve` used to skip AuthPlugin with a warning and serve anyway. That boot had no security model: anonymous object data is always denied, so its data API was unusable. It also had no organization object, so its inline seed wrote rows with `organization_id` NULL and drew their autonumbers from the `__global__` counter. Measured on `examples/app-showcase` before this change: health 200, two NULL-organization rows and a `__global__` counter row on a fresh install. The boot now stops before the kernel starts, so nothing is written, and the message names both fixes. A kernel-only boot (`--no-server`) is refused the same way: it runs the same seed, so it would write the same ownerless rows. Unchanged: a development boot still uses its fallback secret, and the cloud host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's) still only warns. `os start` never reaches the refusal: it always supplies a secret to the `serve` child it spawns.
19
19
- **`@objectstack/driver-sql` (and `@objectstack/driver-turso`'s remote face, which calls the same rule): the counter refuses an organization-less row on a tenant table.** Wherever the driver registered the organization object (`sys_organization`), a `create` / `bulkCreate` / `upsert` on an object with a tenant column, whose row carries no organization and whose options carry no `tenantId`, is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), the code the engine already answers for the same write. It is refused before any counter row is read or written, and nothing is written. New exports: `OrganizationlessCounterRefusal`, `ORGANIZATIONLESS_COUNTER_REFUSAL_CODE`, `ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS`, `renderOrganizationlessCounterRefusal`. Unchanged: `__global__` stays the counter key of an object with no tenant column, and of a composition that registers no organization object on the driver (a lean `@objectstack/objectql/core` embedding), where no organization can exist. Counter rows a 17.x install filed under `__global__` for a tenant table stay in `_objectstack_sequences`; the counter no longer reads them, and `os migrate duplicates` still reports them.
20
20
-**`@objectstack/objectql`: `GLOBAL_TENANT` is no longer exported**, and `SystemWriteOrganizationRequiredError`'s message no longer describes the `__global__` counter as where the write would land. The code, status and fields are unchanged.
21
21
-**Texts.** The lint explanation of `unique/legacy-organization-composite`, the `UniqueScopeSchema` TSDoc and the error-code ledger now say that a NULL-organization row is residue an earlier release wrote, not something a new write produces.
22
22
23
23
**The remedy.**
24
-
- A production boot refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes), or run kernel-only with `--no-server`.
24
+
- A production boot refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes), or compose auth by other means (mount `AuthPlugin` in `plugins`). Running kernel-only with `--no-server` does not avoid the refusal.
25
25
- A direct driver write refused by the counter: carry the organization — `driver.create(object, { ...row, organization_id: orgId })` or `driver.create(object, row, { tenantId: orgId })` — or write through the engine, which derives it under `single`.
fix(cli)!: a production `os serve` that composes no organization object is refused whether or not `--server`, so `--no-server` no longer writes ownerless rows
6
+
7
+
Clause-②: yes (narrowing)
8
+
Metadata-protocol: none
9
+
10
+
<!-- adr-0087: not-required (no-migration-prescription) A boot-time refusal in `os serve`: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. No export, type, flag or error code is removed or renamed; `--no-server` keeps its meaning (no HTTP server plugin) and only stops being a way round the auth refusal. The remedy is a deployment one, stated below. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this boot (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
11
+
12
+
**BREAKING** boot-refusal narrowing (ADR-0131 D1, "NULL is not a state", and D9). It changes which production boots start, not what a metadata author can write, so it ships as `patch` with this banner.
13
+
14
+
- FROM: a production `os serve --no-server` (no `--dev`; `os start` has no `--no-server` flag) booted the kernel when the stack's `auth` tier was on and no auth secret resolved, or when the stack mounted no auth at all (the `auth` tier off, `--preset minimal` included, with no `AuthPlugin` of its own), warning `⚠ AuthPlugin skipped — set OS_AUTH_SECRET …`. Neither boot composes an organization object, so every row it wrote, its inline seed first, landed with no organization. Measured on an 18.0.0-born store: a deleted seed row was written back with `organization_id` NULL. On a fresh store the seed failed instead, every write refused by the NOT NULL tenant column. Both refusal texts named "run with --no-server" as a remedy.
15
+
- TO: both boots are refused whether or not `--server`, before any plugin starts. That includes a production `os serve --preset minimal`: the minimal preset composes no `auth` tier, so unless the stack mounts `AuthPlugin` itself it is refused, kernel-only or not (with `--server` it already was), so nothing is written and the database is never opened. That covers a `--no-server` migrate-and-exit job (`OS_MIGRATE_AND_EXIT=1`), which runs the same kernel bootstrap. Neither refusal text offers `--no-server` any more: each says a kernel-only boot does not avoid it.
16
+
17
+
**Unchanged:**`os dev` and `--dev` (a development boot resolves its fallback secret, and a no-auth development boot is still refused only when it serves); an embedded kernel (`createStandaloneStack`, `@objectstack/objectql/core`), which never runs this command; and the cloud runtime's host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's), which keeps its warn-only path. `os start` always hands its `serve` child an auth secret, so it reaches the no-auth case only.
18
+
19
+
**The remedy.**
20
+
- Refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes, e.g. `OS_AUTH_SECRET=$(openssl rand -hex 32)`).
21
+
- Refused because the stack mounts no auth: enable auth (add the `auth` tier, or mount `AuthPlugin` in `plugins`) and set `OS_AUTH_SECRET`. Declaring the stack's own objects `tenancy: { enabled: false }` does not lift this refusal: the platform's own system objects are tenant-scoped, so a production stack with no organization object would still write ownerless rows.
22
+
- A cloud migrate-and-exit job (`OS_MIGRATE_AND_EXIT=1`) must carry `OS_AUTH_SECRET` in production; without it the job is now refused, naming the variable.
23
+
24
+
**Release-notes correction.** The 18.0 release page says a production `os serve` with no auth secret can "serve without the data API (`--no-server`)". That remedy is withdrawn: `--no-server` is refused the same way. The pending changeset `24583-7b3-organizationless-counter-retired` is corrected in this change to match.
Copy file name to clipboardExpand all lines: packages/cli/src/commands/serve-kernel-only-lifetime.integration.test.ts
+28-6Lines changed: 28 additions & 6 deletions
Original file line number
Diff line number
Diff line change
@@ -1,5 +1,5 @@
1
1
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
// test-budget: 40 — spawns the real producer once per single-cause fixture: three boots of the built CLI (production --no-server --preset minimal, --dev --no-server, a default composed boot), each differing only in the input under test, held 3 s past its banner and then stopped by SIGTERM; 27.0 s, the slower of two local runs on a shared container, x 1.25, no CI reading yet
2
+
// test-budget: 40 — spawns the real producer once per single-cause fixture: three boots of the built CLI (production --no-server on the host kernel, --dev --no-server, a default composed boot), each differing only in the input under test, held 3 s past its banner and then stopped by SIGTERM; 27.0 s, the slower of two local runs on a shared container, x 1.25, no CI reading yet
0 commit comments