Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/15212-180-guards-fresh-single-only.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes, and no stored shape an author owns, is added, removed, renamed or converted: a CLI command and two deployment postures that used to run now refuse, with no spec key, export, config field or error code removed or renamed. The remedy is an operator's one, stated below: stay on 17.x, or run 18.0 under `single` on a fresh database. -->

**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.
14 changes: 13 additions & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<Callout type="warn">
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.
</Callout>

1. It copies the database file and its `telemetry` sibling (each with its
`-wal` / `-shm`) into `<data dir>/backups/organization-ownership-<timestamp>/`.
2. It runs `os migrate organization-ownership --dev` (the plan), then applies
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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)).

<Callout type="warn">
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.
</Callout>

```bash
os migrate organization-ownership --out plan.json # the plan: reads only
os migrate organization-ownership --apply --plan-file plan.json --backup-taken
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <active organization>`, 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 = <active organization>`, 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). |
Expand Down
3 changes: 2 additions & 1 deletion content/docs/deployment/self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions content/docs/deployment/tenancy-modes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Callout type="warn" title="18.0: single only">
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.
</Callout>

<Callout type="info" title="Renamed, not re-configured">
The posture formerly called `multi` is now `isolated`, and the authoritative knob
is `OS_TENANCY_POSTURE`, not `OS_MULTI_ORG_ENABLED`. Existing deployments keep
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading