From ede023858607e0a3fec1d471ff04981cfeef65c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 04:52:02 +0000 Subject: [PATCH 1/3] =?UTF-8?q?wip(cli):=2018.0=20guards=20=E2=80=94=20cer?= =?UTF-8?q?emony=20command=20and=20walled=20posture=20refused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Q4U6vXvHNpcDZr24aXeJo2 Co-authored-by: Claude --- ...organization-ownership.apply-flags.test.ts | 4 +- ....declared-rule-mirrors.integration.test.ts | 4 +- .../organization-ownership.dialect.test.ts | 4 +- ...ership.fate-supplement.integration.test.ts | 4 +- ...ownership.journal-mode.integration.test.ts | 4 +- ...ership.no-organization.integration.test.ts | 4 +- ...on-ownership.occupancy.integration.test.ts | 4 +- ...nization-ownership.release-refusal.test.ts | 151 ++++++++++++++++++ ...-ownership.remote-face.integration.test.ts | 2 +- ...anization-ownership.remote-posture.test.ts | 4 +- ...-ownership.report-file.integration.test.ts | 4 +- ...ation-ownership.report-reservation.test.ts | 4 +- .../migrate/organization-ownership.ts | 64 +++++++- .../migrate/sqlite-wasm.integration.test.ts | 2 +- .../serve-walled-posture-refusal.test.ts | 123 ++++++++++++++ packages/cli/src/commands/serve.ts | 49 +++++- ...ownership-not-declared.integration.test.ts | 2 +- ...orm-global-column-drop.integration.test.ts | 2 +- ...igrate.one-shot-family.integration.test.ts | 2 +- .../schema-migrate.sql-driver-lookup.test.ts | 4 +- ...-organizations-host-resolution.e2e.test.ts | 128 +++++---------- ...ve-organizations-mount-failure.e2e.test.ts | 142 +++++----------- ...nership-boot-refusal-order.dogfood.test.ts | 42 ++++- 23 files changed, 522 insertions(+), 231 deletions(-) create mode 100644 packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts create mode 100644 packages/cli/src/commands/serve-walled-posture-refusal.test.ts 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..d90847d8cd4 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,26 @@ 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', + 'const [root, url, ...argv] = process.argv.slice(1);' + + ' 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]); From b89f7a9540413930dd375c144abb9957027adcac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 05:11:06 +0000 Subject: [PATCH 2/3] wip(dogfood): run the 18.x ceremony class from the built module Claude-Session: https://claude.ai/code/session_01Q4U6vXvHNpcDZr24aXeJo2 Co-authored-by: Claude --- ...rganization-ownership-boot-refusal-order.dogfood.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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 d90847d8cd4..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 @@ -146,7 +146,13 @@ describe('[ADR-0131 D10 item 5] a 17.x database upgrades through the boot refusa // of that reviewed plan file — through the ceremony class (see the header). const ceremony = (args: string[]) => spawnSync(process.execPath, [ '--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, From 9141352b728cee0353afab848532d12c2f9a6887 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 05:12:30 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs(cli):=2018.0=20fresh=20single=20instal?= =?UTF-8?q?ls=20=E2=80=94=20the=20refused=20upgrade=20command=20and=20wall?= =?UTF-8?q?ed=20postures;=20changeset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Q4U6vXvHNpcDZr24aXeJo2 Co-authored-by: Claude --- .changeset/15212-180-guards-fresh-single-only.md | 16 ++++++++++++++++ content/docs/deployment/cli.mdx | 14 +++++++++++++- .../docs/deployment/environment-variables.mdx | 2 +- content/docs/deployment/self-hosting.mdx | 3 ++- content/docs/deployment/tenancy-modes.mdx | 5 +++++ 5 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 .changeset/15212-180-guards-fresh-single-only.md 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