diff --git a/.changeset/15212-180-guards-fresh-single-only.md b/.changeset/15212-180-guards-fresh-single-only.md
new file mode 100644
index 00000000000..8d8b0664003
--- /dev/null
+++ b/.changeset/15212-180-guards-fresh-single-only.md
@@ -0,0 +1,16 @@
+---
+'@objectstack/cli': minor
+---
+
+feat(cli)!: 18.0 supports fresh `single` installs only — `os migrate organization-ownership` refuses, and `os serve` refuses a walled posture at boot
+
+Clause-②: yes (narrowing)
+
+
+
+**BREAKING** (a command and a boot posture that used to run now refuse), shipped as `minor` under the launch-window convention for breaking changes. 18.0 supports fresh installs under the `single` tenancy posture only; upgrading a v17 database and the walled postures arrive in an 18.x release.
+
+- **FROM** `os migrate organization-ownership` (plan) and `os migrate organization-ownership --apply …` running the v18 ownership ceremony **TO** both refusing with exit `1` and the standard code `NOT_IMPLEMENTED` (under `--json`: `{ error: 'plan_refused' | 'apply_refused', reason: 'upgrade-unavailable', code: 'NOT_IMPLEMENTED', detail }`), reading and writing nothing. The 18.0 ceremony is knowingly incomplete, and an incomplete ceremony must never certify an old database. **Fix:** keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path. `os dev`'s in-place upgrade of a v17 development database runs this command, so it now stops with the same refusal and changes nothing.
+- **FROM** `os serve` (and so `os dev` and `os start`) booting with `OS_TENANCY_POSTURE=group` or `isolated`, or with `OS_MULTI_ORG_ENABLED=true`, **TO** a FATAL refusal at the tenancy-posture gate, exit `1`, naming `NOT_IMPLEMENTED` and the 18.x release, before a config is read. `OS_ALLOW_DEGRADED_TENANCY` does not lift it, and no flag or variable does. **Fix:** set `OS_TENANCY_POSTURE=single` (or unset it and `OS_MULTI_ORG_ENABLED`), or keep a walled deployment on a 17.x release.
+
+**What stays as it was.** A fresh install under `single` boots exactly as before. The boot guard that refuses a v17-shaped database is unchanged. Kernel-level boots (`@objectstack/verify`'s `bootStack`, embedded hosts) still accept every posture.
diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx
index dc780b3bcd6..4a9160f8908 100644
--- a/content/docs/deployment/cli.mdx
+++ b/content/docs/deployment/cli.mdx
@@ -203,6 +203,11 @@ organization-ownership shape until the [ownership ceremony](#os-migrate-organiza
has run on it. `os dev` runs that ceremony for you when the database is a local
SQLite file:
+
+18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 the ceremony refuses, so `os dev` stops at step 2 with its
+refusal and changes nothing.
+
+
1. It copies the database file and its `telemetry` sibling (each with its
`-wal` / `-shm`) into `/backups/organization-ownership-/`.
2. It runs `os migrate organization-ownership --dev` (the plan), then applies
@@ -1197,7 +1202,7 @@ where the data lives.
| `os migrate value-shapes` | Scan stored reference and structured-JSON field values against the platform's value contract, and record the deployment's migration flag when clean |
| `os migrate summary-nulls` | Backfill roll-up `count` / `sum` columns still stored as `NULL` on parent rows created before the insert-time seed. Repairs values; no flag, nothing depends on it having run |
| `os migrate comment-reactions` | Carry the emoji reactions a 17.x deployment stored in the retired `sys_comment.reactions` column into `sys_comment_reaction` records, list every stored value that does not read, and record the verified run that lets `os migrate apply --allow-destructive` drop the column |
-| `os migrate organization-ownership` | The v18 ownership ceremony (ADR-0131 D10): write a plan of what each table's rows will become, then `--apply` that reviewed plan. A v18 server refuses to start until it has run. See [below](#os-migrate-organization-ownership) |
+| `os migrate organization-ownership` | The v18 ownership ceremony (ADR-0131 D10): write a plan of what each table's rows will become, then `--apply` that reviewed plan. A v18 server refuses to start until it has run. 18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 the command refuses. See [below](#os-migrate-organization-ownership) |
| `os migrate activity-changed-fields` | Stamp the two read-rule columns (`changed_fields`, `milestone_key`) on the `sys_activity` rows written before they existed. Until it runs, those rows are withheld from every reader but the system, so run it at once after upgrading. No flag; idempotent |
| `os migrate comment-parent-pair` | Stamp the parent pair (`object_name`, `record_id`) on the `sys_comment` rows written before it existed. Until it runs, a broad comment read past 2,000 candidate rows omits comments the reader may see, and its total agrees with the short list, so run it at once after upgrading. No flag; idempotent |
| `os migrate meta --stored` | Replay the metadata conversion chain over this deployment's `sys_metadata` rows and rewrite the ones still carrying a pre-protocol shape. Hygiene, not a gate — nothing depends on it having run |
@@ -1476,6 +1481,13 @@ to start on a database still in the v17 organization-ownership shape until this
has run. Under `os dev` it runs on its own for a local SQLite database (see
[`os dev`](#os-dev)).
+
+18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 this command refuses, plan and apply alike: it exits `1` with
+`NOT_IMPLEMENTED` (under `--json`, `code` beside `error`) and reads and
+writes nothing. Keep a v17 database on a 17.x release until the 18.x release
+that carries the upgrade path.
+
+
```bash
os migrate organization-ownership --out plan.json # the plan: reads only
os migrate organization-ownership --apply --plan-file plan.json --backup-taken
diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx
index 08831aef155..8f1deae04aa 100644
--- a/content/docs/deployment/environment-variables.mdx
+++ b/content/docs/deployment/environment-variables.mdx
@@ -98,7 +98,7 @@ Read by `@objectstack/service-print` when `PrintServicePlugin` is composed; see
| `OS_AUTH_GOOGLE_ENABLED` | boolean | settings default | Settings env override for `auth.google_enabled`. Requires Google OAuth credentials from Settings or env. |
| `GOOGLE_CLIENT_ID` | string | — | Deployment-level Google OAuth client id for the open-source Google login implementation. |
| `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. |
-| `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = `, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. |
+| `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = `, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. 18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 `os serve` refuses `group` and `isolated` at boot. |
| `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. |
| `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` | boolean | `false` | Whether this deployment runs **package-authored scheduled work**: time-triggered flows (a `type: 'schedule'` flow with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. **OFF by default in every posture and every kernel** — a clock-driven workload's cost is a fact about the deployment, not about the flow, so an author cannot decide it and no metadata key asks them to. Accepts `true`/`1`/`on`/`yes` (case-insensitive); anything else, including unset, is off. While it is off nothing arms, and every such flow is listed by `getTriggerBindingAudit()` and the CLI startup summary as **disabled by deployment policy** — never as a binding failure, and nothing about the flow needs fixing. While it is on, what a time-triggered flow owes depends on the posture: under `isolated` it must declare `config.organization` on its start node or it is not armed; under `group` the declaration is optional and an undeclared sweep reads group-wide with each run acting as its own swept record's organization (a record-less cron there carries none, and its tenant-scoped writes are refused — declare one if it writes); under `single` it needs no declaration and its runs carry no organization. ⛔ Platform-internal scheduled work — approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill — is **not** gated by this: the boundary is *authored by a package*, not *runs on the job service*. `os doctor` prints the effective value. |
| `OS_PLATFORM_OWNER_EMAIL` | csv | — | The deployment's platform administrators: one email address, or a comma-separated list. A caller resolves `PLATFORM_ADMIN` when their own stored `sys_user` row carries a declared address **and** reads email-verified. Comparison is trimmed and case-insensitive; duplicates collapse and blank entries are dropped. One **unparseable** entry refuses the **whole** variable rather than just that entry — the deployment then has zero configured administrators, loudly — because a silently narrower administrator set is the worse failure. **Required under the walled postures** (`group` / `isolated`): unset or blank there **refuses to boot**, since the first self-registrant is not promoted and no grant row is written. Unset under `single` is normal — that posture still promotes the first human account. Read live per resolution, so revocation is a config change plus a process reload; there is no runtime endpoint that changes it. See [First boot: create the admin](/docs/deployment/self-hosting#first-boot-create-the-admin). |
diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx
index 61f911d836b..ef667678886 100644
--- a/content/docs/deployment/self-hosting.mdx
+++ b/content/docs/deployment/self-hosting.mdx
@@ -466,7 +466,8 @@ lock sign-up down via `OS_AUTH_SIGNUP_ENABLED` /
configuration.** First-registrant promotion is removed there: with
self-registration reachable, whoever posts to the sign-up endpoint first would
otherwise receive cross-tenant access. No grant row is written on those
-postures, ever. Declare the administrators **before first boot** instead:
+postures, ever. Declare the administrators **before first boot** instead
+(18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 `os serve` refuses a walled posture at boot):
```bash
# one address, or a comma-separated list
diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx
index fe893ba59e2..e5a1c3c704d 100644
--- a/content/docs/deployment/tenancy-modes.mdx
+++ b/content/docs/deployment/tenancy-modes.mdx
@@ -16,6 +16,11 @@ it generalizes the two-valued *tenancy mode* of
whose `tenancy` service, membership lifecycle and degraded-tenancy boot guard all
still apply — widened to the three postures.
+
+18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 `os serve` (and so `os dev` and `os start`) refuses `group` and
+`isolated` at boot with `NOT_IMPLEMENTED`, before it reads a config.
+
+
The posture formerly called `multi` is now `isolated`, and the authoritative knob
is `OS_TENANCY_POSTURE`, not `OS_MULTI_ORG_ENABLED`. Existing deployments keep
diff --git a/packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts b/packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts
index 3816bac4702..b17cdff8df0 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts
@@ -28,13 +28,13 @@ vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
probeMigrationTarget: vi.fn(async () => ({ status: 'free' })),
}));
-type Command = (typeof import('./organization-ownership.js'))['default'];
+type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
let MigrateOrganizationOwnership: Command;
// The command's module graph is large, and oclif reads the package's config on
// the first run: both are paid outside the 5 s default (CASE_TIMEOUT_MS).
beforeAll(async () => {
- ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
}, 60_000);
const CASE_TIMEOUT_MS = 60_000;
diff --git a/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts
index e5d14c95370..96eb4e942f2 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts
@@ -57,7 +57,7 @@ import { DECLARED_SHARING_RULE_SEEDER_RUNS } from '../../utils/organization-owne
import '@objectstack/objectql';
import '@objectstack/metadata-protocol';
-type Command = (typeof import('./organization-ownership.js'))['default'];
+type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
let MigrateOrganizationOwnership: Command;
const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
@@ -158,7 +158,7 @@ describe('os migrate organization-ownership on a database whose packages declare
savedEnv[key] = process.env[key];
delete process.env[key];
}
- ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
dir = mkdtempSync(join(tmpdir(), 'os-ceremony-declared-rules-'));
file = join(dir, 'fixture.db');
// The project the command runs in: the ceremony boots ITS composition, so
diff --git a/packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts b/packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts
index 7e0f8266b1b..7a4c21eeb45 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts
@@ -44,13 +44,13 @@ vi.mock('../../utils/organization-ownership-apply.js', async (importOriginal) =>
applyOrganizationOwnership: apply,
}));
-type Command = (typeof import('./organization-ownership.js'))['default'];
+type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
let MigrateOrganizationOwnership: Command;
// The command's module graph is large, and oclif reads the package's config on
// the first run: both are paid outside the 5 s default (CASE_TIMEOUT_MS).
beforeAll(async () => {
- ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
}, 60_000);
const CASE_TIMEOUT_MS = 60_000;
diff --git a/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts
index d300a7b90cf..fd185cdd090 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts
@@ -34,13 +34,13 @@ vi.mock('../../utils/organization-ownership-apply.js', async (importOriginal) =>
applyOrganizationOwnership: apply,
}));
-type Command = (typeof import('./organization-ownership.js'))['default'];
+type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
let MigrateOrganizationOwnership: Command;
// The command's module graph is large, and oclif reads the package's config on
// the first run: both are paid outside the 5 s default (CASE_TIMEOUT_MS).
beforeAll(async () => {
- ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
}, 60_000);
const CASE_TIMEOUT_MS = 60_000;
diff --git a/packages/cli/src/commands/migrate/organization-ownership.journal-mode.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.journal-mode.integration.test.ts
index e09e732bb5f..9a0b629c3e3 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.journal-mode.integration.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.journal-mode.integration.test.ts
@@ -32,13 +32,13 @@ import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';
import '@objectstack/runtime';
import '@objectstack/metadata-protocol';
-type Command = (typeof import('./organization-ownership.js'))['default'];
+type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
let MigrateOrganizationOwnership: Command;
// The command's module graph is large, and oclif reads the package's config on
// the first run: both are paid outside the case timeout.
beforeAll(async () => {
- ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
}, 60_000);
const CASE_TIMEOUT_MS = 60_000;
diff --git a/packages/cli/src/commands/migrate/organization-ownership.no-organization.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.no-organization.integration.test.ts
index 661896a2e69..7f95510d87a 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.no-organization.integration.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.no-organization.integration.test.ts
@@ -61,7 +61,7 @@ import '@objectstack/objectql';
import '@objectstack/metadata-protocol';
import '@objectstack/plugin-auth';
-type Command = (typeof import('./organization-ownership.js'))['default'];
+type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
let MigrateOrganizationOwnership: Command;
const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
@@ -162,7 +162,7 @@ describe('os migrate organization-ownership on a single-posture database that ho
savedEnv[key] = process.env[key];
delete process.env[key];
}
- ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
dir = mkdtempSync(join(tmpdir(), 'os-ceremony-no-organization-'));
file = join(dir, 'fixture.db');
diff --git a/packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts
index 179a65dfaa8..13832f2173f 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts
@@ -40,13 +40,13 @@ vi.mock('../../utils/schema-migrate.js', async (importOriginal) => ({
bootSchemaStack: boot,
}));
-type Command = (typeof import('./organization-ownership.js'))['default'];
+type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
let MigrateOrganizationOwnership: Command;
// The command's module graph is large, and oclif reads the package's config on
// the first run: both are paid outside the case timeout.
beforeAll(async () => {
- ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
}, 60_000);
const CASE_TIMEOUT_MS = 60_000;
diff --git a/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts b/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts
new file mode 100644
index 00000000000..20929cce72d
--- /dev/null
+++ b/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts
@@ -0,0 +1,151 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `os migrate organization-ownership` refuses in 18.0, plan and apply alike
+ * (ruling record 6259608309 on the v18 epic, item 1). 18.0 supports fresh
+ * installs only. Its ceremony is knowingly incomplete, and an incomplete
+ * ceremony must never certify an old database.
+ *
+ * The refusal comes first, so it reads nothing. These cases set every input
+ * the ceremony would judge before its boot to a value the ceremony refuses:
+ * an unrecognised posture, a fate supplement that does not exist, a plan file
+ * that does not exist. They also stub the occupancy probe and the boot so that
+ * either one throws. Only the release refusal can answer.
+ *
+ * The ceremony itself (`OrganizationOwnershipCeremony`) is pinned by the
+ * sibling `organization-ownership.*.test.ts` files, which run it directly.
+ */
+
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { existsSync, mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const boot = vi.hoisted(() => vi.fn(async () => {
+ throw new Error('the database was opened');
+}));
+const probe = vi.hoisted(() => vi.fn(async () => {
+ throw new Error('the database was probed');
+}));
+vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: boot }));
+vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
+ OCCUPANCY_STOP_HINT: 'occupancy hint',
+ probeMigrationTarget: probe,
+}));
+
+type Module = typeof import('./organization-ownership.js');
+let mod: Module;
+
+// The command's module graph is large, and oclif reads the package's config on
+// the first run: both are paid outside the 5 s default.
+beforeAll(async () => {
+ mod = await import('./organization-ownership.js');
+}, 60_000);
+
+const CASE_TIMEOUT_MS = 60_000;
+const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
+const DIR = mkdtempSync(join(tmpdir(), 'os-ownership-release-refusal-'));
+const DB_FILE = join(DIR, 'v17.db');
+const OUT = join(DIR, 'out.json');
+const MISSING_SUPPLEMENT = join(DIR, 'missing-supplement.json');
+const MISSING_PLAN = join(DIR, 'missing-plan.json');
+
+let savedPosture: string | undefined;
+beforeEach(() => {
+ savedPosture = process.env.OS_TENANCY_POSTURE;
+ // The ceremony refuses an unrecognised posture before anything else; the release refusal never reads it.
+ process.env.OS_TENANCY_POSTURE = 'not-a-posture';
+});
+afterEach(() => {
+ if (savedPosture === undefined) delete process.env.OS_TENANCY_POSTURE;
+ else process.env.OS_TENANCY_POSTURE = savedPosture;
+ boot.mockClear();
+ probe.mockClear();
+ vi.restoreAllMocks();
+});
+
+async function run(argv: string[]): Promise<{ stdout: string; exitCode: unknown }> {
+ const chunks: string[] = [];
+ const savedExit = process.exitCode;
+ vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => {
+ chunks.push(String(chunk));
+ const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
+ if (cb) cb();
+ return true;
+ }) as typeof process.stdout.write);
+ vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write);
+ vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
+ chunks.push(`${args.map(String).join(' ')}\n`);
+ });
+ let exitCode: unknown;
+ try {
+ await mod.default.run(argv, { root: CLI_ROOT });
+ exitCode = process.exitCode;
+ } catch (error) {
+ const oclif = (error as { oclif?: { exit?: unknown } }).oclif;
+ if (oclif === undefined) throw error;
+ exitCode = oclif.exit;
+ }
+ process.exitCode = savedExit;
+ vi.restoreAllMocks();
+ return { stdout: chunks.join(''), exitCode };
+}
+
+function expectNothingTouched(): void {
+ expect(boot).not.toHaveBeenCalled();
+ expect(probe).not.toHaveBeenCalled();
+ expect(existsSync(DB_FILE), 'the database file was brought into existence').toBe(false);
+ expect(existsSync(OUT), 'a plan or report file was written').toBe(false);
+}
+
+describe('os migrate organization-ownership — refused in 18.0', () => {
+ it('the plan refuses with NOT_IMPLEMENTED, exit 1, reading and writing nothing', async () => {
+ const { stdout, exitCode } = await run([
+ '--database-url', `file:${DB_FILE}`, '--out', OUT, '--fate-supplement', MISSING_SUPPLEMENT, '--json',
+ ]);
+ expect(JSON.parse(stdout.trim())).toEqual({
+ error: 'plan_refused',
+ reason: 'upgrade-unavailable',
+ code: 'NOT_IMPLEMENTED',
+ detail: mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE,
+ });
+ expect(exitCode).toBe(1);
+ expectNothingTouched();
+ }, CASE_TIMEOUT_MS);
+
+ it('the apply refuses with NOT_IMPLEMENTED, exit 1, reading and writing nothing', async () => {
+ const { stdout, exitCode } = await run([
+ '--apply', '--plan-file', MISSING_PLAN, '--backup-taken',
+ '--database-url', `file:${DB_FILE}`, '--out', OUT, '--fate-supplement', MISSING_SUPPLEMENT, '--json',
+ ]);
+ expect(JSON.parse(stdout.trim())).toMatchObject({
+ error: 'apply_refused',
+ reason: 'upgrade-unavailable',
+ code: 'NOT_IMPLEMENTED',
+ });
+ expect(exitCode).toBe(1);
+ expectNothingTouched();
+ }, CASE_TIMEOUT_MS);
+
+ it('without --json it prints the refusal with its code and exits 1', async () => {
+ const { stdout, exitCode } = await run(['--database-url', `file:${DB_FILE}`, '--out', OUT]);
+ expect(stdout).toContain('✗');
+ expect(stdout).toContain(mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE);
+ expect(stdout).toContain('(NOT_IMPLEMENTED)');
+ expect(exitCode).toBe(1);
+ expectNothingTouched();
+ }, CASE_TIMEOUT_MS);
+
+ it('the refusal names the release that will carry the upgrade and what 18.0 supports', () => {
+ expect(mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE).toMatch(/^os migrate organization-ownership is not available in 18\.0\./);
+ expect(mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE).toContain('fresh installs only');
+ expect(mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE).toContain('an 18.x release');
+ });
+
+ it('the registered command keeps the ceremony class whole underneath: same flags, its own run', () => {
+ expect(Object.getPrototypeOf(mod.default)).toBe(mod.OrganizationOwnershipCeremony);
+ expect(mod.default.flags).toBe(mod.OrganizationOwnershipCeremony.flags);
+ expect(mod.default.prototype.run).not.toBe(mod.OrganizationOwnershipCeremony.prototype.run);
+ });
+});
diff --git a/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts
index ad7fa489d5e..46889c5777c 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts
@@ -59,7 +59,7 @@ import { TursoDriver } from '@objectstack/driver-turso';
import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system';
import { bootSchemaStack } from '../../utils/schema-migrate.js';
import { isExitSignal } from '../../utils/format.js';
-import MigrateOrganizationOwnership from './organization-ownership.js';
+import { OrganizationOwnershipCeremony as MigrateOrganizationOwnership } from './organization-ownership.js';
// Paid at module load, not inside a clocked case (`check:test-source-alias`).
import '@objectstack/runtime';
diff --git a/packages/cli/src/commands/migrate/organization-ownership.remote-posture.test.ts b/packages/cli/src/commands/migrate/organization-ownership.remote-posture.test.ts
index fba272b1e5d..5ae3f969d55 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.remote-posture.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.remote-posture.test.ts
@@ -31,11 +31,11 @@ vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
probeMigrationTarget: vi.fn(async () => ({ status: 'free' })),
}));
-type Command = (typeof import('./organization-ownership.js'))['default'];
+type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
let MigrateOrganizationOwnership: Command;
beforeAll(async () => {
- ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
}, 60_000);
const CASE_TIMEOUT_MS = 60_000;
diff --git a/packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts
index 94900ed5a57..6db272bc3ef 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts
@@ -53,7 +53,7 @@ vi.mock('../../utils/schema-migrate.js', async (importOriginal) => {
};
});
-type Command = (typeof import('./organization-ownership.js'))['default'];
+type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
let MigrateOrganizationOwnership: Command;
const HERE = dirname(fileURLToPath(import.meta.url));
@@ -182,7 +182,7 @@ const apply = (prepared: Prepared, out: string): Promise =>
]);
beforeAll(async () => {
- ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
for (const key of [...OVERRIDING_ENV, 'NODE_ENV', 'OS_SECRET_KEY'] as const) savedEnv[key] = process.env[key];
for (const key of OVERRIDING_ENV) delete process.env[key];
// The deployed posture: no telemetry sibling, and the crypto provider's key.
diff --git a/packages/cli/src/commands/migrate/organization-ownership.report-reservation.test.ts b/packages/cli/src/commands/migrate/organization-ownership.report-reservation.test.ts
index cd533c69ccf..89b2f6d505c 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.report-reservation.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.report-reservation.test.ts
@@ -28,11 +28,11 @@ vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
probeMigrationTarget: vi.fn(async () => ({ status: 'free' })),
}));
-type Command = (typeof import('./organization-ownership.js'))['default'];
+type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
let MigrateOrganizationOwnership: Command;
beforeAll(async () => {
- ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
}, 60_000);
const CASE_TIMEOUT_MS = 60_000;
diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts
index 77572edb789..e9f30602b3a 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.ts
@@ -204,8 +204,13 @@ class ReportFileReservation {
}
/**
- * `os migrate organization-ownership` — the v18 organization-ownership
- * ceremony's preflight (ADR-0131 D10, ceremony item 1): per table, the fate,
+ * The v18 organization-ownership ceremony behind `os migrate
+ * organization-ownership` — in 18.0 the registered command refuses before
+ * this class runs (see {@link MigrateOrganizationOwnership} at the end of this
+ * file). This class is the ceremony itself, kept whole for the 18.x release
+ * that carries the upgrade path; its tests run it directly.
+ *
+ * The ceremony's preflight (ADR-0131 D10, ceremony item 1): per table, the fate,
* the row counts each fate will touch, the rows whose owner cannot be derived
* (listed by id), and the tables that will and will not receive NOT NULL.
*
@@ -311,7 +316,7 @@ class ReportFileReservation {
* the drops go through the remote face's own drift detector and migration
* door (`@objectstack/driver-turso`).
*/
-export default class MigrateOrganizationOwnership extends Command {
+export class OrganizationOwnershipCeremony extends Command {
static override description =
'The v18 organization-ownership ceremony (ADR-0131 D10). Without --apply, the read-only plan: per table, the fate, ' +
'the row counts, the rows whose owner cannot be derived (by id), and which tables will receive NOT NULL, written ' +
@@ -393,7 +398,7 @@ export default class MigrateOrganizationOwnership extends Command {
};
async run(): Promise {
- const { flags } = await this.parse(MigrateOrganizationOwnership);
+ const { flags } = await this.parse(OrganizationOwnershipCeremony);
const timer = createTimer();
const json = Boolean(flags.json);
const refuse = async (payload: Record, message: string): Promise => {
@@ -1088,3 +1093,54 @@ export default class MigrateOrganizationOwnership extends Command {
if (verdictCode !== 0) this.exit(1);
}
}
+
+/**
+ * What `os migrate organization-ownership` answers in 18.0, plan and apply
+ * alike (ruling record 6259608309 on the v18 epic, item 1).
+ *
+ * 18.0 supports fresh installs only. Its ceremony is knowingly incomplete:
+ * several fates, and the moves they carry, were deferred to 18.x. An
+ * incomplete ceremony must never certify an old database, because the boot
+ * guard (`OrganizationOwnershipBootGuardPlugin`) would then serve it. So the
+ * registered command refuses before it reads anything. It opens no database,
+ * reads no plan, supplement or report file, writes nothing, and boots nothing.
+ * The code is the standard `NOT_IMPLEMENTED` (ADR-0112): the capability is
+ * not in this release. No flag and no environment variable lifts the refusal.
+ */
+export const ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE =
+ 'os migrate organization-ownership is not available in 18.0. 18.0 supports fresh installs only; the v17 → v18 ' +
+ 'upgrade path ships in an 18.x release. Keep a v17 database on a 17.x release until then. Nothing was read or written.';
+
+/** The ADR-0112 code the 18.0 refusal carries: the standard code for a capability this release does not implement. */
+export const ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE = 'NOT_IMPLEMENTED' as const;
+
+/**
+ * `os migrate organization-ownership` in 18.0: the ceremony's flags and help,
+ * and a refusal instead of the ceremony
+ * ({@link ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE}). The 18.x release that
+ * carries the upgrade path deletes this class and registers the ceremony.
+ */
+export default class MigrateOrganizationOwnership extends OrganizationOwnershipCeremony {
+ static override description =
+ 'Refused in 18.0, which supports fresh installs only: the v17 → v18 upgrade path ships in an 18.x release. ' +
+ OrganizationOwnershipCeremony.description;
+
+ override async run(): Promise {
+ const { flags } = await this.parse(MigrateOrganizationOwnership);
+ if (flags.json) {
+ await emitJson(
+ {
+ error: flags.apply ? 'apply_refused' : 'plan_refused',
+ reason: 'upgrade-unavailable',
+ code: ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE,
+ detail: ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE,
+ },
+ 1,
+ { compact: true },
+ );
+ return;
+ }
+ printError(`${ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE} (${ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE})`);
+ this.exit(1);
+ }
+}
diff --git a/packages/cli/src/commands/migrate/sqlite-wasm.integration.test.ts b/packages/cli/src/commands/migrate/sqlite-wasm.integration.test.ts
index 4b1f4dc4d0d..7dde27230b6 100644
--- a/packages/cli/src/commands/migrate/sqlite-wasm.integration.test.ts
+++ b/packages/cli/src/commands/migrate/sqlite-wasm.integration.test.ts
@@ -36,7 +36,7 @@ import { fileURLToPath } from 'node:url';
import { isExitSignal } from '../../utils/format.js';
import MigrateApply from './apply.js';
import MigratePlan from './plan.js';
-import MigrateOrganizationOwnership from './organization-ownership.js';
+import { OrganizationOwnershipCeremony as MigrateOrganizationOwnership } from './organization-ownership.js';
// [#10126] Pay the first transform of the dist-resolved workspace deps the
// commands reach through dynamic `import()`s at MODULE LOAD, not inside a case.
diff --git a/packages/cli/src/commands/serve-walled-posture-refusal.test.ts b/packages/cli/src/commands/serve-walled-posture-refusal.test.ts
new file mode 100644
index 00000000000..e2288f0621f
--- /dev/null
+++ b/packages/cli/src/commands/serve-walled-posture-refusal.test.ts
@@ -0,0 +1,123 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * 18.0 supports the `single` posture only (ruling record 6259698357 on the v18
+ * epic). `os serve` refuses a walled posture (`group`, `isolated`) loudly,
+ * naming the 18.x release that will carry it. It refuses at its tenancy-posture
+ * gate, before a config is read. `os dev` and `os start` boot through `serve`.
+ *
+ * Under `single` nothing changes: `walledPostureRefusal` answers `null`, and
+ * every `serve` integration test that boots with the posture unset
+ * (`serve-no-server-banner.integration.test.ts`,
+ * `serve-no-storage-boot-guard.integration.test.ts`, …) boots as before.
+ *
+ * Kernel-level boots never pass through `serve` (`@objectstack/verify`'s
+ * `bootStack`, the dogfood wall suites), and they keep booting walled postures.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { TENANCY_POSTURES, postureEnforcesWall } from '@objectstack/spec/security';
+
+import Serve, { walledPostureRefusal } from './serve.js';
+
+const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
+
+/** SGR codes, written as `\x1b` (never the raw byte, which makes grep read the file as binary). */
+const SGR = /\x1b\[[0-9;]*m/g;
+const plain = (s: string) => s.replace(SGR, '');
+
+const TOUCHED = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED'] as const;
+let saved: Record = {};
+
+beforeEach(() => {
+ saved = Object.fromEntries(TOUCHED.map((k) => [k, process.env[k]]));
+ for (const k of TOUCHED) delete process.env[k];
+});
+
+afterEach(() => {
+ for (const k of TOUCHED) {
+ if (saved[k] === undefined) delete process.env[k];
+ else process.env[k] = saved[k];
+ }
+});
+
+const WALLED = TENANCY_POSTURES.filter(postureEnforcesWall);
+
+describe('walledPostureRefusal — the verdict', () => {
+ it('answers null for single, the one posture 18.0 supports', () => {
+ expect(walledPostureRefusal('single')).toBeNull();
+ });
+
+ it('refuses every walled posture the spec vocabulary declares', () => {
+ expect(WALLED.length).toBeGreaterThan(0);
+ for (const posture of WALLED) {
+ process.env.OS_TENANCY_POSTURE = posture;
+ const text = plain(walledPostureRefusal(posture) ?? '');
+ expect(text).toContain(`FATAL: tenancy posture '${posture}' is not supported in 18.0 (NOT_IMPLEMENTED).`);
+ expect(text).toContain('an 18.x release');
+ expect(text).toContain('set OS_TENANCY_POSTURE=single');
+ expect(text).toContain(`OS_TENANCY_POSTURE="${posture}"`);
+ }
+ });
+
+ it('names OS_MULTI_ORG_ENABLED as the source when the posture was derived from it', () => {
+ process.env.OS_MULTI_ORG_ENABLED = 'true';
+ const text = plain(walledPostureRefusal('isolated') ?? '');
+ expect(text).toContain('OS_MULTI_ORG_ENABLED="true" (OS_TENANCY_POSTURE is unset)');
+ });
+});
+
+/** Run the real `serve` in-process, its `process.exit` made catchable; the FATAL and every diagnostic captured. */
+async function runServe(): Promise<{ raised: unknown; stderr: string; diagnostics: string[] }> {
+ const savedNodeEnv = process.env.NODE_ENV;
+ const errors: string[] = [];
+ const diagnostics: string[] = [];
+ const errSpy = vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => {
+ errors.push(a.join(' '));
+ });
+ const stdoutWrite = process.stdout.write;
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
+ diagnostics.push(String(chunk));
+ return true;
+ }) as typeof process.stderr.write);
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
+ throw new Error(`__PROCESS_EXIT__:${code}`);
+ }) as never);
+ let raised: unknown;
+ try {
+ // `--dev` keeps the port probe on the auto-shift path, so a busy port cannot pre-empt the gate.
+ await Serve.run(['--dev', '--port', '39873'], { root: CLI_ROOT });
+ } catch (err) {
+ raised = err;
+ } finally {
+ errSpy.mockRestore();
+ stderrSpy.mockRestore();
+ exitSpy.mockRestore();
+ // `run()` reserves stdout for the process's lifetime; put it back for the next case.
+ process.stdout.write = stdoutWrite;
+ if (savedNodeEnv === undefined) delete process.env.NODE_ENV;
+ else process.env.NODE_ENV = savedNodeEnv;
+ }
+ return { raised, stderr: plain(errors.join('\n')), diagnostics };
+}
+
+describe('os serve refuses a walled posture before any boot work', () => {
+ it('OS_TENANCY_POSTURE=isolated: exit 1, the FATAL, and no config read', async () => {
+ process.env.OS_TENANCY_POSTURE = 'isolated';
+ const { raised, stderr, diagnostics } = await runServe();
+ expect((raised as Error | undefined)?.message).toBe('__PROCESS_EXIT__:1');
+ expect(stderr).toContain("FATAL: tenancy posture 'isolated' is not supported in 18.0");
+ // The config load is serve's first announced boot step; nothing at all was announced.
+ expect(diagnostics).toEqual([]);
+ }, 60_000);
+
+ it('OS_MULTI_ORG_ENABLED=true (the legacy switch, read as isolated) is refused the same way', async () => {
+ process.env.OS_MULTI_ORG_ENABLED = 'true';
+ const { raised, stderr, diagnostics } = await runServe();
+ expect((raised as Error | undefined)?.message).toBe('__PROCESS_EXIT__:1');
+ expect(stderr).toContain("FATAL: tenancy posture 'isolated' is not supported in 18.0");
+ expect(diagnostics).toEqual([]);
+ }, 60_000);
+});
diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts
index f28becea4a5..aad9925c748 100644
--- a/packages/cli/src/commands/serve.ts
+++ b/packages/cli/src/commands/serve.ts
@@ -52,7 +52,7 @@ import {
// The posture vocabulary, read from the package that DEFINES it (#5359) — the
// boot gate's fix list enumerates the accepted values, and a second literal
// list would be free to drift the day a posture is added.
-import { TENANCY_POSTURES, type TenancyPosture } from '@objectstack/spec/security';
+import { TENANCY_POSTURES, postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security';
// #17329 — the PUBLISHED settlement contract, READ rather than re-derived, so
// the `objectstack:seed-settled` announcement below asks the one component that
// knows whether this boot's seeding has come to rest. The service is registered
@@ -2408,6 +2408,20 @@ export default class Serve extends Command {
}
const tenancyPosture = postureGate.posture;
+ // ── 18.0: the `single` posture only (ruling record 6259698357) ─────
+ // A walled posture is refused here, at the same point and in the same
+ // shape as an unrecognized one: before a config is read, a plugin is
+ // mounted or anything is written. `os dev` and `os start` both boot
+ // through this command, so this is every operator's boot. Kernel-level
+ // boots (`@objectstack/verify`'s `bootStack`, the standalone stack)
+ // never pass here, which is what keeps the walled test suites running for
+ // the 18.x release that lifts this. No flag and no variable skips it.
+ const walledRefusal = walledPostureRefusal(tenancyPosture);
+ if (walledRefusal) {
+ console.error(walledRefusal);
+ process.exit(1);
+ }
+
// [#22371] `--dev`, or NODE_ENV exactly `development` — `isDevelopmentBoot`
// (`@objectstack/core`), the reading the auth secret's development fallback
// is decided by here and in `os migrate security-catalog-overlays --dev`.
@@ -6238,6 +6252,39 @@ export function resolveTenancyPostureOrRefusal(): TenancyPostureGateVerdict {
}
+/**
+ * The FATAL text `os serve` refuses a walled posture with in 18.0, or `null`
+ * for a posture 18.0 supports (ruling record 6259698357 on the v18 epic).
+ *
+ * 18.0 supports the `single` posture only, on fresh installs. The walled
+ * postures (`group`, `isolated`) arrive in an 18.x release: under `single`
+ * every system insert is stamped with the Default Organization, and the
+ * walled-only prerequisites were deferred. The code is the standard
+ * `NOT_IMPLEMENTED` (ADR-0112). The walled set is read off the spec's posture
+ * vocabulary (`postureEnforcesWall`), never listed here.
+ */
+export function walledPostureRefusal(posture: TenancyPosture): string | null {
+ if (!postureEnforcesWall(posture)) return null;
+ const walled = TENANCY_POSTURES.filter(postureEnforcesWall).join(', ');
+ const env = (globalThis as { process?: { env?: Record } }).process?.env ?? {};
+ const raw = env.OS_TENANCY_POSTURE;
+ const source = raw != null && String(raw).trim() !== ''
+ ? `OS_TENANCY_POSTURE=${JSON.stringify(String(raw))}`
+ : `OS_MULTI_ORG_ENABLED=${JSON.stringify(String(env.OS_MULTI_ORG_ENABLED ?? ''))} (OS_TENANCY_POSTURE is unset)`;
+ return chalk.red(
+ `\n ✖ FATAL: tenancy posture '${posture}' is not supported in 18.0 (NOT_IMPLEMENTED).\n`
+ + ` Refusing to boot. 18.0 supports the 'single' posture only, on fresh installs; the walled\n`
+ + ` postures (${walled}) arrive in an 18.x release.\n\n`
+ + ' No config has been loaded, no plugin has been mounted, and the HTTP server was\n'
+ + ' never started — this deployment has not served a single request.\n\n'
+ + ' Fix one of:\n'
+ + ' • set OS_TENANCY_POSTURE=single, or unset both OS_TENANCY_POSTURE and OS_MULTI_ORG_ENABLED;\n'
+ + ' • keep this deployment on a 17.x release until the 18.x release that carries walled postures.\n\n'
+ + ` The posture was read from ${source}, in the process environment or a .env file\n`
+ + ' dotenv-flow loaded for this mode.\n',
+ );
+}
+
/**
* The diagnosis the optional i18n service load prints when it fails — a
* DIAGNOSIS ahead of behaviour that does not change (#14042's rule, applied at
diff --git a/packages/cli/src/utils/organization-ownership-not-declared.integration.test.ts b/packages/cli/src/utils/organization-ownership-not-declared.integration.test.ts
index 75398dcd2fe..feccb913771 100644
--- a/packages/cli/src/utils/organization-ownership-not-declared.integration.test.ts
+++ b/packages/cli/src/utils/organization-ownership-not-declared.integration.test.ts
@@ -27,7 +27,7 @@ import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isExitSignal } from './format.js';
-import MigrateOrganizationOwnership from '../commands/migrate/organization-ownership.js';
+import { OrganizationOwnershipCeremony as MigrateOrganizationOwnership } from '../commands/migrate/organization-ownership.js';
import '@objectstack/runtime';
import '@objectstack/objectql';
diff --git a/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts b/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts
index 32d1da43618..9f7bd1482e9 100644
--- a/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts
+++ b/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts
@@ -67,7 +67,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system';
import { isExitSignal } from './format.js';
import MigrateApply from '../commands/migrate/apply.js';
-import MigrateOrganizationOwnership from '../commands/migrate/organization-ownership.js';
+import { OrganizationOwnershipCeremony as MigrateOrganizationOwnership } from '../commands/migrate/organization-ownership.js';
// Paid at module load, not inside a clocked case (`check:test-source-alias`).
import '@objectstack/runtime';
diff --git a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts
index defe0dace82..efbed27e1e0 100644
--- a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts
+++ b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts
@@ -64,7 +64,7 @@ import MigrateDuplicates from '../commands/migrate/duplicates.js';
import MigrateFilesToReferences from '../commands/migrate/files-to-references.js';
import MigrateMeta from '../commands/migrate/meta.js';
import MigrateMultiValueColumns from '../commands/migrate/multi-value-columns.js';
-import MigrateOrganizationOwnership from '../commands/migrate/organization-ownership.js';
+import { OrganizationOwnershipCeremony as MigrateOrganizationOwnership } from '../commands/migrate/organization-ownership.js';
import MigratePlan from '../commands/migrate/plan.js';
import MigrateRecordedBy from '../commands/migrate/recorded-by.js';
import MigrateResume from '../commands/migrate/resume.js';
diff --git a/packages/cli/src/utils/schema-migrate.sql-driver-lookup.test.ts b/packages/cli/src/utils/schema-migrate.sql-driver-lookup.test.ts
index 0ec2adc531d..b4a8932615f 100644
--- a/packages/cli/src/utils/schema-migrate.sql-driver-lookup.test.ts
+++ b/packages/cli/src/utils/schema-migrate.sql-driver-lookup.test.ts
@@ -53,13 +53,13 @@ import { findSqlDriverForKernel } from './schema-migrate.js';
import '@objectstack/metadata-protocol';
import '@objectstack/platform-objects/system';
-type Command = (typeof import('../commands/migrate/organization-ownership.js'))['default'];
+type Command = (typeof import('../commands/migrate/organization-ownership.js'))['OrganizationOwnershipCeremony'];
let MigrateOrganizationOwnership: Command;
// The command's module graph is large, and oclif reads the package's config on
// the first run: both are paid outside the case timeout.
beforeAll(async () => {
- ({ default: MigrateOrganizationOwnership } = await import('../commands/migrate/organization-ownership.js'));
+ ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('../commands/migrate/organization-ownership.js'));
}, 60_000);
const CASE_TIMEOUT_MS = 60_000;
diff --git a/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts b/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts
index 6980863636a..ca3984c8ca9 100644
--- a/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts
+++ b/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts
@@ -27,7 +27,20 @@
* that is the whole point), registering the same `org-scoping` service and
* posture entitlement the real one does. What is under test here is
* RESOLUTION, not the enterprise semantics: proof that the real plugin walls
- * tenants lives in cloud's security-enterprise multi-org integration test.
+ *
+ * ## 18.0: the walled postures are refused before this resolution runs
+ *
+ * 18.0 supports the `single` posture only (ruling record 6259698357 on the
+ * v18 epic). `os serve` refuses `group` and `isolated` at its
+ * tenancy-posture gate, before a config is read (`walledPostureRefusal`,
+ * pinned in-process in `src/commands/serve-walled-posture-refusal.test.ts`).
+ * So in 18.0 no walled boot reaches the stages this file was written for. The
+ * cases below keep its fixtures and pin, over the real process, that the 18.0
+ * refusal comes FIRST whatever the package state, and that
+ * `OS_ALLOW_DEGRADED_TENANCY` does not lift it. The 18.x release that carries
+ * the walled postures restores this file's resolution cases from git history
+ * (the parent of the commit that added this section), together with
+ * `serve-organizations-mount-failure.e2e.test.ts`.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -155,88 +168,33 @@ const SERVE_ENV = {
OS_PLATFORM_OWNER_EMAIL: 'operator@corp.example',
};
-describe('os serve — enterprise organizations resolution (cloud#1013)', () => {
- it(
- 'boots a walled posture with the package installed in the APP, not the framework',
- async () => {
- const { stdout, stderr } = await runServe(appWithPackage, [], {
- waitFor: /Press Ctrl\+C to stop/,
- env: { ...SERVE_ENV },
- timeoutMs: 240_000,
- });
-
- const seen = `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`;
-
- // The load-bearing assertion. Before the fix, the bare import resolved
- // from the CLI's realpath in the framework workspace, threw
- // MODULE_NOT_FOUND, and this boot died on the D5 fail-fast instead of
- // reaching the banner at all.
- expect(stderr, `the D5 fail-fast fired — the app-installed package was not found${seen}`)
- .not.toMatch(/could not be loaded/);
- expect(stderr, `serve never reached its banner${seen}`).toContain('Press Ctrl+C to stop');
- // …and it is the APP's package that got mounted: `Organizations` is
- // tracked only on the path that actually registered the plugin.
- expect(stderr, `OrganizationsPlugin was not registered${seen}`).toContain('Organizations');
- },
- 300_000,
- );
-
- it(
- 'still refuses to boot a walled posture when the app does not ship the package',
- async () => {
- // The other half of the contract: the fix must not turn the ADR-0093 D5
- // fail-fast into a lenient skip. An app that requests isolation without
- // the enterprise runtime must still die rather than serve traffic with
- // the organization wall inactive.
- const { stdout, stderr } = await runServe(appWithoutPackage, [], {
- waitFor: /Press Ctrl\+C to stop/,
- env: { ...SERVE_ENV },
- timeoutMs: 240_000,
- });
-
- const seen = `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`;
- expect(stderr, `the D5 fail-fast did not fire${seen}`).toMatch(
- /FATAL: tenancy posture 'isolated' was requested/,
- );
- // The remedy names the app, because that is where the package has to go.
- expect(stderr).toMatch(/to THIS APP/);
- expect(stderr, `serve served traffic without the wall${seen}`).not.toContain(
- 'Press Ctrl+C to stop',
- );
- },
- 300_000,
- );
-
- it(
- 'refuses when the package is reachable only through NODE_PATH — the pnpm shim shape (#4719)',
- async () => {
- // The #4719 defect, over a real process, with the launcher reproduced
- // exactly. Same app as the case above (declares nothing), same posture —
- // the only difference is the NODE_PATH every pnpm bin shim exports. Before
- // this change that single environment variable was enough to boot the
- // organization wall off a package the app had never declared, so whether
- // ADR-0093 D5 fired came down to HOW the process was started.
- const { stdout, stderr } = await runServe(appWithoutPackage, [], {
- waitFor: /Press Ctrl\+C to stop/,
- env: { ...SERVE_ENV, NODE_PATH: hoistedStore },
- timeoutMs: 240_000,
- });
-
- const seen = `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`;
- expect(
- stderr,
- `NODE_PATH got an undeclared app past the D5 wall — the #4719 defect${seen}`,
- ).toMatch(/FATAL: tenancy posture 'isolated' was requested/);
- // …and the remedy is the declaration one, naming why reachability lost.
- expect(stderr).toMatch(/to THIS APP/);
- expect(stderr).toMatch(/NODE_PATH/);
- expect(stderr, `serve served traffic without the wall${seen}`).not.toContain(
- 'Press Ctrl+C to stop',
- );
- expect(stderr, `the hoisted package was mounted anyway${seen}`).not.toContain(
- 'Organizations',
- );
- },
- 300_000,
- );
+describe('os serve — 18.0 refuses the walled posture before the organizations runtime resolves', () => {
+ const cases: ReadonlyArray<{ name: string; app: () => string; env: Record }> = [
+ { name: 'the package installed in the APP', app: () => appWithPackage, env: {} },
+ { name: 'no package in the app', app: () => appWithoutPackage, env: {} },
+ { name: 'the package reachable only through NODE_PATH', app: () => appWithoutPackage, env: { NODE_PATH: hoistedStore } },
+ ];
+ for (const c of cases) {
+ it(
+ `${c.name}: refused in 18.0, and no organizations stage ran`,
+ async () => {
+ const { stdout, stderr } = await runServe(c.app(), [], {
+ waitFor: /Press Ctrl\+C to stop/,
+ env: { ...SERVE_ENV, ...c.env },
+ timeoutMs: 240_000,
+ });
+ const seen = `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`;
+ expect(stderr, `the 18.0 walled-posture refusal did not fire${seen}`).toContain(
+ "FATAL: tenancy posture 'isolated' is not supported in 18.0 (NOT_IMPLEMENTED).",
+ );
+ expect(stderr, `serve served traffic under a walled posture${seen}`).not.toContain('Press Ctrl+C to stop');
+ // Neither organizations stage was reached: no absent-package fail-fast,
+ // no mount refusal, no degraded boot.
+ expect(stderr, `the organizations import ran before the refusal${seen}`).not.toMatch(/could not be loaded|was requested/);
+ expect(stderr, `the organizations mount ran before the refusal${seen}`).not.toMatch(/WAS found and loaded/);
+ expect(stderr, `a degraded boot ran before the refusal${seen}`).not.toMatch(/DEGRADED TENANCY/);
+ },
+ 300_000,
+ );
+ }
});
diff --git a/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts b/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts
index 1fb7b499a28..0e10ed78d56 100644
--- a/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts
+++ b/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts
@@ -34,6 +34,20 @@
* `packages/plugins/organizations` carries no licence check of any kind (the
* shape cloud#1020 gave its license gate). What is asserted is the CLI's
* CLASSIFICATION and its message, not any runtime's semantics.
+ *
+ * ## 18.0: the walled postures are refused before this resolution runs
+ *
+ * 18.0 supports the `single` posture only (ruling record 6259698357 on the
+ * v18 epic). `os serve` refuses `group` and `isolated` at its
+ * tenancy-posture gate, before a config is read (`walledPostureRefusal`,
+ * pinned in-process in `src/commands/serve-walled-posture-refusal.test.ts`).
+ * So in 18.0 no walled boot reaches the stages this file was written for. The
+ * cases below keep its fixtures and pin, over the real process, that the 18.0
+ * refusal comes FIRST whatever the package state, and that
+ * `OS_ALLOW_DEGRADED_TENANCY` does not lift it. The 18.x release that carries
+ * the walled postures restores this file's resolution cases from git history
+ * (the parent of the commit that added this section), together with
+ * `serve-organizations-host-resolution.e2e.test.ts`.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -144,118 +158,34 @@ const SERVE_ENV = {
OS_PLATFORM_OWNER_EMAIL: 'operator@corp.example',
};
-const BANNER = 'Press Ctrl+C to stop';
-const BANNER_RE = /Press Ctrl\+C to stop/;
-
-function seenOf(stdout: string, stderr: string): string {
- return `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`;
-}
-
-describe('os serve — organizations import stage vs mount stage (#4818)', () => {
- describe('import stage fails — the package is ABSENT (behaviour must be unchanged)', () => {
+describe('os serve — 18.0 refuses the walled posture before the organizations runtime resolves', () => {
+ const cases: ReadonlyArray<{ name: string; app: () => string; env: Record }> = [
+ { name: 'the package ABSENT', app: () => appAbsent, env: { OS_ALLOW_DEGRADED_TENANCY: undefined } },
+ { name: 'the package ABSENT, OS_ALLOW_DEGRADED_TENANCY=1', app: () => appAbsent, env: { OS_ALLOW_DEGRADED_TENANCY: '1' } },
+ { name: 'the package PRESENT and refusing', app: () => appRefusing, env: { OS_ALLOW_DEGRADED_TENANCY: undefined } },
+ { name: 'the package PRESENT and refusing, OS_ALLOW_DEGRADED_TENANCY=1', app: () => appRefusing, env: { OS_ALLOW_DEGRADED_TENANCY: '1' } },
+ ];
+ for (const c of cases) {
it(
- 'refuses to boot with the ADR-0093 D5 "could not be loaded" diagnosis',
+ `${c.name}: refused in 18.0, and no organizations stage ran`,
async () => {
- const { stdout, stderr } = await runServe(appAbsent, [], {
- waitFor: BANNER_RE,
- env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: undefined },
+ const { stdout, stderr } = await runServe(c.app(), [], {
+ waitFor: /Press Ctrl\+C to stop/,
+ env: { ...SERVE_ENV, ...c.env },
timeoutMs: 240_000,
});
- const seen = seenOf(stdout, stderr);
-
- expect(stderr, `the D5 fail-fast did not fire${seen}`).toMatch(
- /FATAL: tenancy posture 'isolated' was requested/,
+ const seen = `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`;
+ expect(stderr, `the 18.0 walled-posture refusal did not fire${seen}`).toContain(
+ "FATAL: tenancy posture 'isolated' is not supported in 18.0 (NOT_IMPLEMENTED).",
);
- // This wording is CORRECT here and must survive the stage split: the
- // package really is not on this machine.
- expect(stderr, `the absent-package diagnosis was lost${seen}`).toMatch(/could not be loaded/);
- // …and the escape hatch is still offered on the path it belongs to.
- expect(stderr).toMatch(/set OS_ALLOW_DEGRADED_TENANCY=1 to boot/);
- expect(stderr, `serve served traffic without the wall${seen}`).not.toContain(BANNER);
- },
- 300_000,
- );
-
- it(
- 'boots degraded when the operator explicitly sets OS_ALLOW_DEGRADED_TENANCY=1',
- async () => {
- // The escape hatch keeps its one legitimate meaning: "the capability is
- // absent and I accept running without it". Pinned so the stage split
- // cannot regress it.
- const { stdout, stderr } = await runServe(appAbsent, [], {
- waitFor: BANNER_RE,
- env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: '1' },
- timeoutMs: 240_000,
- });
- const seen = seenOf(stdout, stderr);
-
- expect(stderr, `serve never reached its banner${seen}`).toContain(BANNER);
- expect(stderr, `the degraded boot was not branded${seen}`).toMatch(/DEGRADED TENANCY/);
- expect(stderr, `the degraded opt-in still fired the fail-fast${seen}`).not.toMatch(/✖ FATAL/);
+ expect(stderr, `serve served traffic under a walled posture${seen}`).not.toContain('Press Ctrl+C to stop');
+ // Neither organizations stage was reached: no absent-package fail-fast,
+ // no mount refusal, no degraded boot.
+ expect(stderr, `the organizations import ran before the refusal${seen}`).not.toMatch(/could not be loaded|was requested/);
+ expect(stderr, `the organizations mount ran before the refusal${seen}`).not.toMatch(/WAS found and loaded/);
+ expect(stderr, `a degraded boot ran before the refusal${seen}`).not.toMatch(/DEGRADED TENANCY/);
},
300_000,
);
- });
-
- describe('mount stage fails — the package is PRESENT and its plugin refused', () => {
- it(
- "surfaces the plugin's own error verbatim and exits, without the absent-package wording",
- async () => {
- const { stdout, stderr } = await runServe(appRefusing, [], {
- waitFor: BANNER_RE,
- env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: undefined },
- timeoutMs: 240_000,
- });
- const seen = seenOf(stdout, stderr);
-
- // D5's posture is unchanged: isolation was requested and cannot be
- // delivered, so the boot still dies.
- expect(stderr, `serve served traffic without the wall${seen}`).not.toContain(BANNER);
- expect(stderr, `no fail-fast fired for a refusing plugin${seen}`).toMatch(/✖ FATAL/);
-
- // The crux: the operator is told the package IS there, and reads the
- // plugin's own words — not a fabricated cause, and not a module
- // resolution wild goose chase.
- expect(stderr, `the mount refusal was misreported as an absent package${seen}`).not.toMatch(
- /could not be loaded/,
- );
- expect(stderr, `the plugin's message was not surfaced verbatim${seen}`).toContain(REFUSAL_MESSAGE);
- expect(stderr, `the plugin's structured code was not surfaced${seen}`).toContain(REFUSAL_CODE);
- expect(stderr, `the message does not say the package was found${seen}`).toMatch(
- /WAS found and loaded/,
- );
- // Honest remaining alternative, and no dead-end suggestion.
- expect(stderr).toMatch(/OS_TENANCY_POSTURE=single/);
- expect(stderr, `the escape hatch was offered on a path it cannot fix${seen}`).toMatch(
- /OS_ALLOW_DEGRADED_TENANCY does NOT apply/,
- );
- },
- 300_000,
- );
-
- it(
- 'still exits 1 when OS_ALLOW_DEGRADED_TENANCY=1 is set — the hatch must not swallow a refusal',
- async () => {
- // THE issue. Before the fix this booted with a warning: an env var
- // silently overrode whatever gate the plugin was enforcing.
- const { stdout, stderr } = await runServe(appRefusing, [], {
- waitFor: BANNER_RE,
- env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: '1' },
- timeoutMs: 240_000,
- });
- const seen = seenOf(stdout, stderr);
-
- expect(
- stderr,
- `OS_ALLOW_DEGRADED_TENANCY swallowed a plugin refusal and served traffic${seen}`,
- ).not.toContain(BANNER);
- expect(stderr, `the refusal did not fail fast under the escape hatch${seen}`).toMatch(/✖ FATAL/);
- expect(stderr, `the refusal was downgraded to a degraded-boot warning${seen}`).not.toMatch(
- /DEGRADED TENANCY \(OS_ALLOW_DEGRADED_TENANCY=1\)/,
- );
- expect(stderr, `the plugin's message was not surfaced verbatim${seen}`).toContain(REFUSAL_MESSAGE);
- },
- 300_000,
- );
- });
+ }
});
diff --git a/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts b/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts
index 5c6eaa7234e..d6ce5afc1e8 100644
--- a/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts
+++ b/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts
@@ -22,20 +22,29 @@
// sync runs (and before every plugin that writes rows at start, so the
// refusal arrives as the `cause` of the kernel's start-failure error);
// 2. `os migrate organization-ownership` writes its plan, and
-// `--apply --plan-file PLAN` (the real CLI, as an operator runs it)
-// completes with no boot in between, and verifies its marker;
+// `--apply --plan-file PLAN` completes with no boot in between, and
+// verifies its marker;
// 3. the next v18 boot succeeds.
//
+// 18.0 ships for fresh installs only (ruling record 6259608309 on the v18
+// epic): the registered `os migrate organization-ownership` refuses, and
+// step 2a pins that through the real CLI binary, reading and writing nothing.
+// Step 2 runs the ceremony class the command keeps for 18.x
+// (`OrganizationOwnershipCeremony`), from the CLI's built module in its own
+// process, with the same arguments, working directory and environment an
+// operator's run has. The 18.x release that re-registers the ceremony puts
+// step 2 back on the binary and drops 2a.
+//
// ⚠ Needs the `sys_metadata` family's no-column schema beside the refusal:
// against a release whose family still declares the column, step 2 stops at
// `column-drops` (the declaration keeps it). The two land together.
import { spawnSync } from 'node:child_process';
-import { copyFileSync, mkdtempSync, rmSync } from 'node:fs';
+import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
-import { fileURLToPath } from 'node:url';
+import { fileURLToPath, pathToFileURL } from 'node:url';
import { DatabaseSync } from 'node:sqlite';
import { afterAll, describe, expect, it } from 'vitest';
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
@@ -46,7 +55,9 @@ import {
import { bootShowcase } from './showcase-boot.js';
const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url));
-const CLI_BIN = join(dirname(createRequire(import.meta.url).resolve('@objectstack/cli/package.json')), 'bin', 'run.js');
+const CLI_ROOT = dirname(createRequire(import.meta.url).resolve('@objectstack/cli/package.json'));
+const CLI_BIN = join(CLI_ROOT, 'bin', 'run.js');
+const CEREMONY_MODULE = pathToFileURL(join(CLI_ROOT, 'dist', 'commands', 'migrate', 'organization-ownership.js')).href;
const columnsOf = (file: string, table: string): string[] => {
const db = new DatabaseSync(file);
@@ -119,11 +130,32 @@ describe('[ADR-0131 D10 item 5] a 17.x database upgrades through the boot refusa
expect(columnsOf(file, 'sys_user_permission_set')).toContain('permission_set');
});
+ it('⭐ 2a. in 18.0 the real command refuses with NOT_IMPLEMENTED, and the database is untouched', () => {
+ const before = columnsOf(file, 'sys_metadata');
+ const refused = spawnSync(process.execPath, [
+ CLI_BIN, 'migrate', 'organization-ownership', '--database-url', `file:${file}`, '--out', join(dir, 'refused-plan.json'), '--json',
+ ], { cwd: SHOWCASE_DIR, encoding: 'utf8', timeout: 300_000, env: { ...process.env, NO_COLOR: '1' } });
+ expect(refused.status, `${refused.stdout}\n${refused.stderr}`).toBe(1);
+ expect(JSON.parse(refused.stdout.trim())).toMatchObject({ error: 'plan_refused', code: 'NOT_IMPLEMENTED' });
+ expect(existsSync(join(dir, 'refused-plan.json'))).toBe(false);
+ expect(columnsOf(file, 'sys_metadata')).toEqual(before);
+ }, 320_000);
+
it('⭐ 2. the ceremony completes with no boot in between, and verifies its marker', () => {
- // As an operator runs it (S4, #23437): the plan first, then the apply of
- // that reviewed plan file.
+ // As an operator's run has it (S4, #23437): the plan first, then the apply
+ // of that reviewed plan file — through the ceremony class (see the header).
const ceremony = (args: string[]) => spawnSync(process.execPath, [
- CLI_BIN, 'migrate', 'organization-ownership', '--database-url', `file:${file}`, ...args,
+ '--input-type=module', '-e',
+ // The built module, as `bin/run.js` runs it: oclif's auto-transpile off,
+ // so an inherited NODE_ENV=test cannot send it to `src/` under tsx.
+ 'const [root, url, ...argv] = process.argv.slice(1);' +
+ " const { createRequire } = await import('node:module');" +
+ " const { pathToFileURL } = await import('node:url');" +
+ " const oclif = await import(pathToFileURL(createRequire(root + '/package.json').resolve('@oclif/core')).href);" +
+ ' (oclif.settings ?? oclif.default.settings).enableAutoTranspile = false;' +
+ ' const { OrganizationOwnershipCeremony } = await import(url);' +
+ ' await OrganizationOwnershipCeremony.run(argv, { root });',
+ CLI_ROOT, CEREMONY_MODULE, '--database-url', `file:${file}`, ...args,
], { cwd: SHOWCASE_DIR, encoding: 'utf8', timeout: 300_000, env: { ...process.env, NO_COLOR: '1' } });
const planFile = join(dir, 'plan.json');
const plan = ceremony(['--out', planFile]);