diff --git a/.changeset/15214-c11-fresh-install-messages.md b/.changeset/15214-c11-fresh-install-messages.md
new file mode 100644
index 0000000000..d3e3a6b38e
--- /dev/null
+++ b/.changeset/15214-c11-fresh-install-messages.md
@@ -0,0 +1,14 @@
+---
+'@objectstack/cli': patch
+'@objectstack/runtime': patch
+---
+
+fix(cli, runtime): the 18.0 operator messages say that 18.0 supports fresh `single` installs only
+
+Clause-②: no
+
+- **The v18 boot refusal on a v17-shaped database** now says first that 18.0 does not upgrade a v17 database: keep it on a 17.x release until the 18.x release that carries the upgrade path. Before, it named only `os migrate organization-ownership`, which refuses in 18.0 as well, so the operator reached the answer in two hops.
+- **The fix list for an unrecognized `OS_TENANCY_POSTURE`** (printed by `os serve` and `os doctor`) still lists `group` and `isolated`, and now says beside each that 18.0 refuses it at boot.
+- **`os doctor` reports a walled posture.** Under `group` or `isolated` it prints a `Tenancy posture` warning: 18.0 supports `single` only, so the next `os serve` (and so `os dev` and `os start`) refuses the posture at boot. The row is a warning, so doctor's exit code does not change. Before, doctor printed nothing about a valid posture.
+
+Nothing else changes. The refusals themselves, their codes and their exit codes are as before.
diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx
index e5a1c3c704..0828424cc6 100644
--- a/content/docs/deployment/tenancy-modes.mdx
+++ b/content/docs/deployment/tenancy-modes.mdx
@@ -19,6 +19,11 @@ 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.
+A database an 18.0 install creates takes the same 18.x upgrade path as a v17 one.
+The refusal covers the `os` boot path only. The one-shot `os migrate`, `os verify`,
+`os secret` and `os storage` boots and embedded hosts (`createStandaloneStack`,
+`@objectstack/adapter-hono`) are not refused, and none of them is a supported
+walled deployment in 18.0.
@@ -38,7 +43,7 @@ fallback **input** to posture resolution. See
| Layer 0 organization wall | none — the layer is inert | `organization_id IN accessible_org_ids` (union / MOAC) | `organization_id = ` |
| Read reach | the whole dataset, subject to RBAC | every organization the caller currently holds a membership in | the active organization only |
| Shape | one logical tenant; sub-units modelled as business units in one tree | organizations are membership / invitation boundaries over one shared dataset | legal-entity / sovereignty isolation — the hard wall |
-| `organization_id` filled in on write | no | yes | yes |
+| `organization_id` filled in on write | yes — the Default Organization | yes | yes |
| Enterprise `@objectstack/organizations` | not needed | **required** | **required** |
| Default organization | one bootstrapped "Default Organization" | owned by the enterprise runtime | owned by the enterprise runtime |
| Org-management UI (create / switch org) | hidden | shown | shown |
@@ -101,6 +106,42 @@ posture it does not list. A runtime that declares nothing entitles both.
---
+## Who owns a row
+
+Ownership is the same under every posture; a posture changes only whether the
+wall is enforced (ADR-0131 D11). Three rules decide where a fact lives
+(ADR-0131 D1–D7):
+
+1. **A managed package's metadata is not in the database.** Its definitions
+ (objects, fields, views, positions, permission sets, capabilities, sharing
+ rules, templates, flows) are sealed code. What a customer changes is
+ environment-level state beside them: an overlay, an on/off switch, a clone
+ or an extension.
+2. **A row in a table with an organization column was written by that
+ organization.** Every such row carries its organization, and refers to a
+ declared item by its machine name. `NULL` is not an owner: no row is
+ "global" or platform-wide because its `organization_id` is empty.
+3. **A table with no organization column is deployment-level or code-level.**
+ Permissions protect it, not the wall.
+
+Under `single` a write into a table with an organization column takes the
+Default Organization, the only one there is. A system write that names no
+organization is stamped with it, or refused loudly when the deployment holds
+zero organizations or two or more. Nothing writes an organization-less row
+there.
+
+
+A platform table (logs, job runs, messaging, configuration) has one of the two
+shapes above, never a third. With no organization column, it is deployment-level
+by design, and its rows carry no organization on any posture. With one, every
+row belongs to an organization. A v17 install could leave organization-less rows
+in such a table. Those rows are what the 18.x upgrade ceremony resolves, table
+by table, before an 18.x server will serve that database (ADR-0131 D10). Until
+that release, keep the database on 17.x.
+
+
+---
+
## How the posture resolves
`OS_TENANCY_POSTURE` is the authoritative knob; `OS_MULTI_ORG_ENABLED` is a
diff --git a/content/docs/permissions/permissions-matrix.mdx b/content/docs/permissions/permissions-matrix.mdx
index bc4a8f3601..89727bc294 100644
--- a/content/docs/permissions/permissions-matrix.mdx
+++ b/content/docs/permissions/permissions-matrix.mdx
@@ -41,7 +41,7 @@ segregation-of-duties case the axis exists for. The built-in
The bypass is **posture-gated for row-level security** (ADR-0066 D2 ①): RLS
policies are short-circuited only on objects whose posture permits it —
`access: { default: 'private' }`, `tenancy.enabled: false`
-(platform-global), or better-auth-managed. On an ordinary tenant business
+(deployment-level: no organization column), or better-auth-managed. On an ordinary tenant business
object, authored and baseline RLS still applies (e.g. `member_default`'s
owner-scoped write policies keep `org_member` holders owner-scoped on
`update`/`delete` even when another set grants `modifyAllRecords`), and the
diff --git a/content/docs/permissions/rls.mdx b/content/docs/permissions/rls.mdx
index c647a71db3..c4f9093409 100644
--- a/content/docs/permissions/rls.mdx
+++ b/content/docs/permissions/rls.mdx
@@ -81,7 +81,7 @@ derivation off for that class: an authored `update` predicate then decides
the `update` *filter* alone, widening past the platform's owner-scoped write
floor exactly as written. Nothing is derived for `insert`
(there is no pre-existing row to be visible), and a caller holding the
-read-side superuser bypass (`viewAllRecords` on a private or platform-global
+read-side superuser bypass (`viewAllRecords` on a private or deployment-level
object) is not narrowed, because their readable set is already unbounded.
**A write widener never reaches a row the caller cannot read.** "You may not
diff --git a/content/docs/permissions/sharing-rules.mdx b/content/docs/permissions/sharing-rules.mdx
index 6c66a16342..e5fcfb18e4 100644
--- a/content/docs/permissions/sharing-rules.mdx
+++ b/content/docs/permissions/sharing-rules.mdx
@@ -61,7 +61,7 @@ Two things the bits do **not** override, regardless of the OWD:
`org_member`), so rank-and-file members stay owner-scoped on writes even
under `public_read_write`. The super-user bits short-circuit RLS **only
where the object's access posture permits it** — `access: { default:
- 'private' }`, `tenancy.enabled: false` (platform-global), or better-auth
+ 'private' }`, `tenancy.enabled: false` (deployment-level: no organization column), or better-auth
managed objects (ADR-0066 D2 ①). On an ordinary tenant business object,
`modifyAllRecords` does *not* lift those policies
(`plugin-security/src/security-plugin.ts` `computeLayeredRlsFilter`).
@@ -195,7 +195,7 @@ unaffected.
**…and an organization to be scoped by.** `manage_sharing` is declared
`scope: 'org'`, so the capability alone is not enough: the caller's session
must also resolve an **active organization**, which is what scopes every rule
-read to "this organization ∪ the platform-global rows". A session that carries
+read to the rule rows that organization wrote. A session that carries
none — a user who has not selected an organization, or whose active
organization was cleared — is refused with the same `403 PERMISSION_DENIED`,
naming the missing organization rather than answering with an empty list.
diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx
index 9e8ad5362f..6a4fd22525 100644
--- a/content/docs/permissions/system-context.mdx
+++ b/content/docs/permissions/system-context.mdx
@@ -129,7 +129,7 @@ that silently does not happen.
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `packages/objectql/src/engine.ts#assertReferencesResolve` |
| 23b | **Position-catalog check skipped** — a `sys_user_position` write whose `position` names no position in the security catalog is not refused | plugin-security | Get: a system writer can store an assignment naming no position — the seed loader writes `stack.data` from `AppPlugin.start()` under a system context, as invitation acceptance and the platform's own bootstraps do. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `position`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no position of that name: the environment registry (ADR-0131 D3), one per deployment, which takes no organization, so a name only a `sys_position` row carries — any organization's — is refused like one nobody carries. It is the same stand-down as the engine's referential-integrity check for a lookup column | `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` |
| 23c | **Grant-name catalog check skipped** — a `sys_user_permission_set` write whose `permission_set` names no permission set in the security catalog is not refused | plugin-security | Get: a system writer can store a grant naming a set not yet registered — seed replay and boot provisioning may write the grant before the definition it names is registered. Lose: such a row grants nothing and nothing says so. A non-system insert, or a non-system update that changes `permission_set`, is refused `400 VALIDATION_FAILED` / `reference_not_found` instead when the security catalog holds no set by that name; a grant that names no set at all is refused `required` for every caller. The grant names its set by name alone, and nothing derives the name from `permission_set_id` (ADR-0131 D4, D10) | `packages/plugins/plugin-security/src/grant-permission-set-name.ts#onInsert`, `#onUpdate` |
-| 23d | **Grant-holder membership check skipped** — a `sys_user_position` or `sys_user_permission_set` write naming a user with no `sys_member` row in the row's organization is not refused | plugin-security | Get: a system writer can store such a grant — invitation acceptance writes the membership and its placement together, and seed replay, the organization-admin reconcile and the platform bootstraps write grants as the system. Lose: such a row takes effect once its user is admitted to the organization. A non-system insert, or a non-system update that moves `user_id` or `organization_id`, is refused `400 VALIDATION_FAILED` / `reference_not_found` at `user_id` instead, for every caller, a platform administrator included; the membership is read as `{ isSystem: true, tenantId }` with `tenantId` the row's own organization, never another one. A row stored with no organization is a global grant and is not judged | `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert`, `#onUpdate` |
+| 23d | **Grant-holder membership check skipped** — a `sys_user_position` or `sys_user_permission_set` write naming a user with no `sys_member` row in the row's organization is not refused | plugin-security | Get: a system writer can store such a grant — invitation acceptance writes the membership and its placement together, and seed replay, the organization-admin reconcile and the platform bootstraps write grants as the system. Lose: such a row takes effect once its user is admitted to the organization. A non-system insert, or a non-system update that moves `user_id` or `organization_id`, is refused `400 VALIDATION_FAILED` / `reference_not_found` at `user_id` instead, for every caller, a platform administrator included; the membership is read as `{ isSystem: true, tenantId }` with `tenantId` the row's own organization, never another one. A row stored with no organization is not judged. Under `single`, 18.0 stamps a new grant with the Default Organization, so such a row is one an earlier release wrote | `packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#onInsert`, `#onUpdate` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `packages/objectql/src/engine.ts#buildDriverOptions` |
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite`, `#assertEngineOwnedWriteAllowed` |
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite` |
diff --git a/packages/cli/src/commands/doctor-env-provenance.test.ts b/packages/cli/src/commands/doctor-env-provenance.test.ts
index d85da1eef5..ddd6058e53 100644
--- a/packages/cli/src/commands/doctor-env-provenance.test.ts
+++ b/packages/cli/src/commands/doctor-env-provenance.test.ts
@@ -455,7 +455,9 @@ describe('os doctor, end to end, against a posture that only exists in .env', ()
it('names the file, refuses to call the environment functional, and exits 1', async () => {
// ── Control: the same .env, a posture that parses ─────────────────────
- fs.writeFileSync(path.join(tmp, '.env'), 'OS_TENANCY_POSTURE=isolated\n');
+ // `single`, not `isolated`: 18.0 refuses a walled posture at `os serve`, so
+ // doctor reports one, and a walled control would carry a posture row.
+ fs.writeFileSync(path.join(tmp, '.env'), 'OS_TENANCY_POSTURE=single\n');
const healthy = await runDoctor();
expect(healthy.exitCode).toBeUndefined();
diff --git a/packages/cli/src/commands/doctor-organizations-message-spelling.test.ts b/packages/cli/src/commands/doctor-organizations-message-spelling.test.ts
index ec0e5f84e8..f078a2b669 100644
--- a/packages/cli/src/commands/doctor-organizations-message-spelling.test.ts
+++ b/packages/cli/src/commands/doctor-organizations-message-spelling.test.ts
@@ -168,7 +168,7 @@ describe('doctor — the posture description an operator reads names the declara
it('leg (i) — the `isolated` fix-list bullet names it, with the spacing intact', () => {
expect(renderIsolatedBullet()).toBe(
` • OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime `
- + "(the legacy spelling 'multi' is accepted and normalizes to this)",
+ + "(the legacy spelling 'multi' is accepted and normalizes to this); refused at boot in 18.0, which supports single only",
);
});
@@ -242,14 +242,14 @@ describe('#12464 CONTROL — these pins can say no', () => {
// decorative. Anchored on the exact regression interpolation invites.
const expected =
` • OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime `
- + "(the legacy spelling 'multi' is accepted and normalizes to this)";
+ + "(the legacy spelling 'multi' is accepted and normalizes to this); refused at boot in 18.0, which supports single only";
expect(
` • OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime`
- + "(the legacy spelling 'multi' is accepted and normalizes to this)",
+ + "(the legacy spelling 'multi' is accepted and normalizes to this); refused at boot in 18.0, which supports single only",
).not.toBe(expected);
expect(
` • OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime `
- + "(the legacy spelling 'multi' is accepted and normalizes to this)",
+ + "(the legacy spelling 'multi' is accepted and normalizes to this); refused at boot in 18.0, which supports single only",
).not.toBe(expected);
// …and says yes to the real thing, so the two `not.toBe`s above are a
// reading rather than a pair of vacuous truths.
diff --git a/packages/cli/src/commands/doctor-tenancy-posture-report.test.ts b/packages/cli/src/commands/doctor-tenancy-posture-report.test.ts
index 2466fcf656..0b15a5beac 100644
--- a/packages/cli/src/commands/doctor-tenancy-posture-report.test.ts
+++ b/packages/cli/src/commands/doctor-tenancy-posture-report.test.ts
@@ -61,6 +61,7 @@ import { TENANCY_POSTURES } from '@objectstack/spec/security';
import Doctor, {
resolveTenancyPostureOrFinding,
+ walledPostureFinding,
readDotenvFiles,
type DotenvReading,
} from './doctor.js';
@@ -290,7 +291,9 @@ describe('os doctor reports an unrecognized posture and exits non-zero', () => {
it('names OS_TENANCY_POSTURE, refuses to call the environment functional, and exits 1', async () => {
// ── Control: the same environment with a posture that parses ──────────
- process.env.OS_TENANCY_POSTURE = 'isolated';
+ // `single`, not `isolated`: 18.0 refuses a walled posture at `os serve`, so
+ // doctor reports one (`walledPostureFinding`, below) and it is no control.
+ process.env.OS_TENANCY_POSTURE = 'single';
const healthy = await runDoctor();
// Doctor completes normally. This is the sentence #5382 quoted, and here it
@@ -343,4 +346,33 @@ describe('os doctor reports an unrecognized posture and exits non-zero', () => {
// `}, 60_000)` cases in this package (`utils/sqlite-occupancy.test.ts`,
// `utils/schema-migrate.deferred-ddl.integration.test.ts`).
}, 60_000);
+
+ it('18.0 — a walled posture is reported as a warning naming the `os serve` refusal, and exits 0', async () => {
+ process.env.OS_TENANCY_POSTURE = 'group';
+ const walled = await runDoctor();
+ expect(walled.out).toContain('Tenancy posture');
+ expect(walled.out).toContain("the tenancy posture is 'group'");
+ expect(walled.out).toContain('`os serve`');
+ expect(walled.out).toContain('Environment is functional but has some warnings');
+ expect(walled.exitCode).toBeUndefined();
+ }, 60_000);
+});
+
+describe('walledPostureFinding — 18.0 supports `single` only', () => {
+ it('answers null for `single`, a warning row for each walled posture', () => {
+ for (const posture of TENANCY_POSTURES) {
+ const row = walledPostureFinding(posture);
+ if (posture === 'single') {
+ expect(row).toBeNull();
+ continue;
+ }
+ expect(row?.name).toBe('Tenancy posture');
+ expect(row?.status).toBe('warning');
+ expect(row?.message).toContain(`'${posture}'`);
+ expect(row?.message).toContain('18.0');
+ expect(row?.fix).toContain('OS_TENANCY_POSTURE=single');
+ }
+ expect(TENANCY_POSTURES).toContain('group');
+ expect(TENANCY_POSTURES).toContain('isolated');
+ });
});
diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts
index 01415add7d..460ae08ac5 100644
--- a/packages/cli/src/commands/doctor.ts
+++ b/packages/cli/src/commands/doctor.ts
@@ -34,7 +34,7 @@ import {
// The posture vocabulary, read from the package that DEFINES it (#5382) — the
// fix list below 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';
interface HealthCheckResult {
name: string;
@@ -825,6 +825,32 @@ export function resolveTenancyPostureOrFinding(reading: DotenvReading): TenancyP
}
}
+/**
+ * The row doctor reports for a walled posture (`group` / `isolated`) in 18.0,
+ * or `null` for any other.
+ *
+ * 18.0 supports the `single` posture only, and `os serve` (so `os dev` and
+ * `os start`) refuses a walled one before it reads a config
+ * (`walledPostureRefusal` in `serve.ts`). Without this row doctor printed
+ * nothing about a valid posture, so it said nothing about a boot that will be
+ * refused. A `warning`, not an `error`: the one-shot commands doctor also
+ * fronts (`os migrate`, `os verify`, `os secret`, `os storage`) still boot
+ * under it. The 18.x release that restores walled postures deletes this row.
+ */
+export function walledPostureFinding(posture: TenancyPosture): HealthCheckResult | null {
+ if (!postureEnforcesWall(posture)) return null;
+ return {
+ name: 'Tenancy posture',
+ status: 'warning',
+ message:
+ `the tenancy posture is '${posture}' — 18.0 supports the 'single' posture only, so the next`
+ + ' `os serve` (and so `os dev` and `os start`) refuses it at boot',
+ fix:
+ 'Set OS_TENANCY_POSTURE=single, or unset both OS_TENANCY_POSTURE and OS_MULTI_ORG_ENABLED;\n'
+ + ' or keep this deployment on a 17.x release until the 18.x release that carries walled postures.',
+ };
+}
+
// ─── Config-Aware Checks ────────────────────────────────────────────
// Exported for the pin on its carrier reading below; `doctor` itself is the
@@ -2238,9 +2264,13 @@ export default class Doctor extends Command {
// #5382 — the posture verdict resolved at the top of `run()`, reported here
// among the other environment facts. Only an unrecognized value produces a
// row: a valid posture is not a finding, and doctor's output for every
- // environment that can actually start is unchanged.
+ // environment that can actually start is unchanged. In 18.0 a walled
+ // posture cannot start under `os serve`, so it gets a row too.
if (!postureReading.ok) {
results.push(postureReading.result);
+ } else {
+ const walled = walledPostureFinding(postureReading.posture);
+ if (walled) results.push(walled);
}
// ── Installed-package ledger readability (#5429) ─────────────────
diff --git a/packages/cli/src/commands/serve-organizations-message-spelling.test.ts b/packages/cli/src/commands/serve-organizations-message-spelling.test.ts
index d98957d73e..cd8367bf52 100644
--- a/packages/cli/src/commands/serve-organizations-message-spelling.test.ts
+++ b/packages/cli/src/commands/serve-organizations-message-spelling.test.ts
@@ -316,7 +316,7 @@ describe('serve — the posture description an operator reads names the declarat
if (verdict.ok) return;
expect(lines(verdict.fatal)).toContain(
` • set OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime `
- + "(the legacy spelling 'multi' is accepted and normalizes to this)",
+ + "(the legacy spelling 'multi' is accepted and normalizes to this); refused at boot in 18.0, which supports single only",
);
});
diff --git a/packages/cli/src/utils/tenancy-posture-hints.ts b/packages/cli/src/utils/tenancy-posture-hints.ts
index e53a486b57..28c7649b61 100644
--- a/packages/cli/src/utils/tenancy-posture-hints.ts
+++ b/packages/cli/src/utils/tenancy-posture-hints.ts
@@ -131,11 +131,18 @@ export const ORGANIZATIONS_RUNTIME_PKG = '@objectstack/organizations';
* hold hard-coded expectations of this prose, so a reword here reddens a pin on
* each command from one edit. If only one ever reddened, the table would be
* shared in name only.
+ *
+ * The two walled entries say that 18.0 refuses them, because `os serve` does,
+ * before it reads a config: a fix list that offered them bare would send the
+ * operator from one refusal to the next. The 18.x release that restores walled
+ * postures deletes {@link WALLED_POSTURE_RELEASE_NOTE}.
*/
+export const WALLED_POSTURE_RELEASE_NOTE = '; refused at boot in 18.0, which supports single only';
+
export const TENANCY_POSTURE_FIX_HINTS: Readonly> = {
single: 'one organization, no organization wall — the default',
- group: 'organization wall enforced by the open engine, one shared database',
+ group: `organization wall enforced by the open engine, one shared database${WALLED_POSTURE_RELEASE_NOTE}`,
isolated:
`organization wall + the ${ORGANIZATIONS_RUNTIME_PKG} runtime `
- + "(the legacy spelling 'multi' is accepted and normalizes to this)",
+ + `(the legacy spelling 'multi' is accepted and normalizes to this)${WALLED_POSTURE_RELEASE_NOTE}`,
};
diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts
index 4349b8b5bd..7cf751abe1 100644
--- a/packages/drivers/driver-sql/src/sql-driver.ts
+++ b/packages/drivers/driver-sql/src/sql-driver.ts
@@ -15601,15 +15601,20 @@ export class SqlDriver implements IDataDriver {
void b.whereIn(field, tenantIds.map(String)).orWhereNull(field);
});
}
- // `(field = :tenantId OR field IS NULL)` — a NULL tenant column marks a
- // GLOBAL/platform row (bootstrap-seeded positions and permission sets,
- // business units, pre-org first-boot seeds). Such a row belongs to no
- // OTHER tenant, so the cross-tenant wall must not hide it: with strict
- // equality every tenant admin saw ZERO RBAC rows on a fresh deployment,
- // because every platform row is org-less (#2734). Rows stamped with a
- // DIFFERENT tenant stay invisible exactly as before; authorization on
- // global rows remains the job of the layers above (RBAC, RLS,
- // managed_by gates).
+ // `(field = :tenantId OR field IS NULL)`. The NULL arm is the v17 reading
+ // (#2734), in which an org-less row stood for platform state (seeded
+ // positions and permission sets, business units, first-boot seeds).
+ // ADR-0131 replaces that reading with three rules. A managed package's
+ // metadata is not in the database: its definitions are sealed, and
+ // customization is environment-level state (D2, D6). A row with an
+ // organization column was written by that organization, and NULL is not
+ // an owner (D1); references to declared items are by machine name (D4). A
+ // table with no organization column is deployment-level or code-level,
+ // protected by permissions rather than by this wall (D7). So on a fresh
+ // 18.0 `single` install, where every insert carries its organization, the
+ // arm matches nothing. The arm is removed on the 18.x line, after the
+ // upgrade ceremony (ADR-0131 D14). Rows stamped with a DIFFERENT tenant
+ // stay invisible either way.
return builder.where((b) => {
void b.where(field, String(tenantId)).orWhereNull(field);
});
diff --git a/packages/runtime/src/organization-ownership-boot-guard.test.ts b/packages/runtime/src/organization-ownership-boot-guard.test.ts
index cc86add9ff..921e49e8d1 100644
--- a/packages/runtime/src/organization-ownership-boot-guard.test.ts
+++ b/packages/runtime/src/organization-ownership-boot-guard.test.ts
@@ -95,6 +95,11 @@ describe('the v18 boot refusal (ADR-0131 D10 item 5)', () => {
// The apply the refusal names is one the ceremony accepts: a reviewed plan file and the backup acknowledged.
expect(err.message).toMatch(/os migrate organization-ownership --apply --plan-file PLAN --backup-taken/);
expect(err.message).toMatch(/the ceremony has not run on it/);
+ // In 18.0 the command it names refuses too, so the refusal names the release answer itself, ahead of the command.
+ const release = err.message.indexOf('18.0 does not upgrade a v17 database');
+ expect(release).toBeGreaterThan(-1);
+ expect(err.message.indexOf('17.x release until the 18.x release', release)).toBeGreaterThan(release);
+ expect(release).toBeLessThan(err.message.indexOf("Run 'os migrate organization-ownership --out PLAN'"));
});
it('a development boot names the ceremony with --dev, so it boots the way the refused boot did', async () => {
diff --git a/packages/runtime/src/organization-ownership-boot-guard.ts b/packages/runtime/src/organization-ownership-boot-guard.ts
index adaa4911f7..957e0de7c0 100644
--- a/packages/runtime/src/organization-ownership-boot-guard.ts
+++ b/packages/runtime/src/organization-ownership-boot-guard.ts
@@ -219,7 +219,9 @@ export async function findV17ShapeTables(engine: GuardEngine): Promise
/**
* The refusal text: what was found, that the server refuses to start, the
- * command, and the 17.x way out. With no table left in the v17 shape (`tables`
+ * command, and the 17.x way out. On a v17-shaped database it says first that
+ * 18.0 does not upgrade it, because in 18.0 the command it names refuses too,
+ * so the operator does not reach that answer in two hops. With no table left in the v17 shape (`tables`
* empty), the ceremony ran every step and its marker is not verified: the
* text says so and names the re-run instead.
*/
@@ -249,7 +251,9 @@ export function organizationOwnershipRefusalMessage(
return (
`This database is still in the v17 organization-ownership shape: ${tables.join(', ')} still carr` +
`${tables.length === 1 ? 'ies' : 'y'} a physical organization_id column, and ${state}. The server refuses to ` +
- `start rather than serve v17 data with v18 semantics. Run '${ceremony} --out PLAN' to write and review its ` +
+ 'start rather than serve v17 data with v18 semantics. 18.0 does not upgrade a v17 database: keep this one on a ' +
+ '17.x release until the 18.x release that carries the upgrade path, and run these steps there. ' +
+ `Run '${ceremony} --out PLAN' to write and review its ` +
`plan, take a backup, then run '${ceremony} --apply --plan-file PLAN --backup-taken', and start again. ` +
'To keep 17.x semantics, run a 17.x runtime.'
);