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
14 changes: 14 additions & 0 deletions .changeset/15214-c11-fresh-install-messages.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 42 additions & 1 deletion content/docs/deployment/tenancy-modes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ 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.
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.
</Callout>

<Callout type="info" title="Renamed, not re-configured">
Expand All @@ -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 = <active organization>` |
| 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 |
Expand Down Expand Up @@ -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.

<Callout type="info" title="Platform tables with no organization">
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.
</Callout>

---

## How the posture resolves

`OS_TENANCY_POSTURE` is the authoritative knob; `OS_MULTI_ORG_ENABLED` is a
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/permissions-matrix.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/rls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions content/docs/permissions/sharing-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/doctor-env-provenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});

Expand Down Expand Up @@ -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.
Expand Down
34 changes: 33 additions & 1 deletion packages/cli/src/commands/doctor-tenancy-posture-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { TENANCY_POSTURES } from '@objectstack/spec/security';

import Doctor, {
resolveTenancyPostureOrFinding,
walledPostureFinding,
readDotenvFiles,
type DotenvReading,
} from './doctor.js';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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');
});
});
Loading
Loading