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.' );