Skip to content

Commit 5b14b7f

Browse files
docs(tenancy): 18.0 states the three ownership rules and its fresh-install, single-only scope; the operator messages say so too (#24506)
Part of #15214 Clause-②: no — docs, a driver docblock and three operator-facing message strings (one a new `os doctor` warning row); no API, schema, rule, permission or exit-code face The open-source docs half of C11. It runs in parallel to the 18.0 release and never holds it (ruling record 6261853191 on #15194). The ADR-0131 D14 dated note is a separate PR on `claude/issue-15214-c11-d14-note`, because `docs/adr/**` is Tier H. Card close-outs stay with the epic seat: #13564, #13636, PR #14923, #11611 and #10103 remain open from this PR. ## What changes **Docs: the three sentences** (ADR-0131 D1–D7), stated as 18.0 delivers them. - `content/docs/deployment/tenancy-modes.mdx` gets a new section, "Who owns a row", with the three rules: - a managed package's metadata is not in the database; - a row in a table with an organization column was written by that organization, and `NULL` is not an owner; - a table with no organization column is deployment-level or code-level, protected by permissions. - The same section states the 18.0 `single` behaviour of 8b's ruling: a system write that names no organization is stamped with the Default Organization, or refused loudly when there are zero or two or more organizations. - A callout answers the platform-tables question of #11611. A platform table has one of the two shapes and never a third. Organization-less rows a v17 install left are what the 18.x ceremony resolves, so until then the database stays on 17.x. - The posture table's "`organization_id` filled in on write" cell for `single` read "no". It now reads "yes — the Default Organization" (ADR-0131 D11: `single` is never a reason for a row to be organization-less). **Docs: the 18.0 scope.** The existing "18.0: single only" callout on the tenancy page is where an operator chooses a posture. It gains two facts: - a database an 18.0 install creates takes the same 18.x upgrade path; - the walled refusal covers the `os` boot path only. The one-shot `os migrate` / `os verify` / `os secret` / `os storage` boots and embedded hosts (`createStandaloneStack`, `@objectstack/adapter-hono`) are not refused, and are not a supported walled deployment in 18.0. The six sentences #24480 added (`cli.mdx` ×3, `environment-variables.mdx:101`, `self-hosting.mdx:470`, `tenancy-modes.mdx:20-21`) agree with this text and are not repeated. **Docs: the replaced mechanism.** Wording that read `NULL` as a global or platform-wide row is gone from the tenancy and permissions pages: - `sharing-rules.mdx`: the scope was "this organization ∪ the platform-global rows". It is now the rule rows that organization wrote. - `sharing-rules.mdx`, `permissions-matrix.mdx`, `rls.mdx`: `tenancy.enabled: false` was called "platform-global". It is now "deployment-level: no organization column". - `system-context.mdx` row 23d: "a row stored with no organization is a global grant". It now says such a row is not judged, and that under `single` 18.0 stamps a new grant. **SQL driver docblock.** The `applyTenantScope` comment called the `IS NULL` arm "a GLOBAL/platform row". It now names that as the v17 reading and states the three rules. It says the arm matches nothing on a fresh 18.0 `single` install, and is removed on the 18.x line (ADR-0131 D14). The comment is the only change; the code is not touched. **Riders** (review 6261404044 ③, ruling record 6261853191 item 2): 1. **Boot guard** (`packages/runtime/src/organization-ownership-boot-guard.ts`, `organizationOwnershipRefusalMessage`). One sentence goes ahead of the ceremony command on a v17-shaped database: "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." The operator no longer follows two hops. 2. **`TENANCY_POSTURE_FIX_HINTS`** (`packages/cli/src/utils/tenancy-posture-hints.ts`). This was the smaller truthful change. `group` and `isolated` stay listed, because they are the vocabulary, with a suffix `; refused at boot in 18.0, which supports single only` (`WALLED_POSTURE_RELEASE_NOTE`, which the 18.x restore deletes). `os serve` and `os doctor` both render it. 3. **`os doctor`.** A valid posture used to print nothing. Under `group` / `isolated` it now prints a `Tenancy posture` **warning**: 18.0 supports `single` only, so the next `os serve` (and so `os dev` / `os start`) refuses it at boot. The row is a warning, not an error, so doctor's exit code is unchanged. The one-shot commands doctor also fronts still boot under a walled posture. **Changeset** `.changeset/15214-c11-fresh-install-messages.md`: `patch` for `@objectstack/cli` and `@objectstack/runtime`. `driver-sql` changes a comment only and publishes no string. Nothing is touched under `content/docs/releases/` or any `CHANGELOG.md`. ## File surface - Measured against the claim 6261901354. - 8b's branch (`claude/issue-15212-8b-stamp-or-refuse`, fetched at `af977a6`) touches 36 files under `packages/objectql`, `packages/core/src/security`, `plugin-security`, `plugin-auth` and others. None of them is in this diff, so no rider was dropped. - None of the four files open PR #24482 touches is in this diff. - `serve.ts` and `migrate/organization-ownership.ts` are not touched. ## Tests Measured at `77a7a78ab`; its tree is byte-identical to the tree the runs below read. | command | result | |---|---| | `pnpm --filter '@objectstack/cli^...' build` (lock) | VERDICT command-exit 0 | | `pnpm --filter @objectstack/cli exec vitest run --project unit` | 308/312 files passed in the first run. The 4 others are unrelated to the diff: 2 timed out at 5 s while the gate sweep loaded the box, and 2 needed `packages/cli/dist`, which the first build did not make. After `pnpm --filter @objectstack/cli build` they were rerun: `published-subpath-console` and `published-subpath-hook-body` passed 2/2 files; `boot-preparation-parity` and `hook-timeout-override-refusal` passed 2/2 files, 41/41 tests, with the box idle | | `pnpm --filter @objectstack/cli typecheck` | exit 0 | | `pnpm --filter @objectstack/runtime test` | 421/421 files, 6643 passed, 19 skipped | | `pnpm --filter @objectstack/runtime typecheck` | exit 0 | Pins moved, and why: - `serve-organizations-message-spelling.test.ts` site 6 and `doctor-organizations-message-spelling.test.ts` legs (i) and the CONTROL now carry the 18.0 suffix. The `group` legs read the shared table and move with it. - `doctor-tenancy-posture-report.test.ts` and `doctor-env-provenance.test.ts` used `isolated` as the "a posture that can start" control. In 18.0 that premise is false, so the control is now `single`. - New: a unit sweep of `walledPostureFinding` over the posture vocabulary, and an in-process `os doctor` run under `group` (warning row, exit 0). - `organization-ownership-boot-guard.test.ts` asserts that the 18.0 / 17.x answer appears ahead of the command. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` named 102 families, plus 48 roster commands on stderr. All 150 were run, exit codes recorded before any pipe. - `--ran` reconciliation: "102 derived famil(ies) accounted for — 102 run, 0 NOT-MEASURED (a DERIVED zero — all 102 recorded an exit code and none of them is 3)". - Seven first answered `PREREQUISITE NOT MET` (exit 3): missing `dist/`, or a shallow-clone fixture. After building `client-react`, `studio` and the six packages `check:published-readme-exports` named, and fetching the fixture commit, all seven exit 0: `check-plugin-teardown-shape --self-test`, `check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`, `check:published-readme-exports`. - Four roster gates need a PR or workflow context and say so (NOT WIRED / NOT MEASURED, exit 2 or 1): `check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths`, `check-governed-queue-guard`. CI runs them on this PR. - NOT MEASURED locally, CI's: `Build Docs`, `Build Core`, the `Test Core` shards, the type-check lanes. ## Acceptance notes - **The card's sentence 2 says "the column is `NOT NULL`".** The docs say every row carries its organization and that `NULL` is not an owner. They do not claim the DDL constraint, because 8e (NOT NULL) moved to 18.x (ruling record 6259698357 item 3). An 18.0 database therefore has no such constraint, and a page claiming one would be false in 18.0. - **8b has no PR yet.** The `single` stamping is described as ruled (6259698357, 6259899486), not from its internals. - Docs outside the tenancy and permissions pages still describe the `COALESCE(organization_id, '__global__')` index key and the NULL partition: `data-modeling/indexing.mdx`, `protocol/objectql/schema.mdx`, `deployment/seed-tenancy-repair.mdx`, `deployment/cli.mdx`. The mechanism is still live in 18.0 (8d moved to 18.x), so these pages are true today. They are this card's family for the 18.x docs pass. Successor: none. - `system-context.mdx` row 62 describes the enterprise organizations plugin's elevated insert under a wall. It was not changed: 18.0 refuses walled boots at `os serve`, and the walled half is 18.x. Successor: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9ZWcgyzEyyKR2nnDzto54)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f4755a6 commit 5b14b7f

15 files changed

Lines changed: 168 additions & 28 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(cli, runtime): the 18.0 operator messages say that 18.0 supports fresh `single` installs only
7+
8+
Clause-②: no
9+
10+
- **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.
11+
- **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.
12+
- **`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.
13+
14+
Nothing else changes. The refusals themselves, their codes and their exit codes are as before.

‎content/docs/deployment/tenancy-modes.mdx‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ still apply — widened to the three postures.
1919
<Callout type="warn" title="18.0: single only">
2020
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
2121
`isolated` at boot with `NOT_IMPLEMENTED`, before it reads a config.
22+
A database an 18.0 install creates takes the same 18.x upgrade path as a v17 one.
23+
The refusal covers the `os` boot path only. The one-shot `os migrate`, `os verify`,
24+
`os secret` and `os storage` boots and embedded hosts (`createStandaloneStack`,
25+
`@objectstack/adapter-hono`) are not refused, and none of them is a supported
26+
walled deployment in 18.0.
2227
</Callout>
2328

2429
<Callout type="info" title="Renamed, not re-configured">
@@ -38,7 +43,7 @@ fallback **input** to posture resolution. See
3843
| Layer 0 organization wall | none — the layer is inert | `organization_id IN accessible_org_ids` (union / MOAC) | `organization_id = <active organization>` |
3944
| Read reach | the whole dataset, subject to RBAC | every organization the caller currently holds a membership in | the active organization only |
4045
| 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 |
41-
| `organization_id` filled in on write | no | yes | yes |
46+
| `organization_id` filled in on write | yes — the Default Organization | yes | yes |
4247
| Enterprise `@objectstack/organizations` | not needed | **required** | **required** |
4348
| Default organization | one bootstrapped "Default Organization" | owned by the enterprise runtime | owned by the enterprise runtime |
4449
| 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.
101106

102107
---
103108

109+
## Who owns a row
110+
111+
Ownership is the same under every posture; a posture changes only whether the
112+
wall is enforced (ADR-0131 D11). Three rules decide where a fact lives
113+
(ADR-0131 D1–D7):
114+
115+
1. **A managed package's metadata is not in the database.** Its definitions
116+
(objects, fields, views, positions, permission sets, capabilities, sharing
117+
rules, templates, flows) are sealed code. What a customer changes is
118+
environment-level state beside them: an overlay, an on/off switch, a clone
119+
or an extension.
120+
2. **A row in a table with an organization column was written by that
121+
organization.** Every such row carries its organization, and refers to a
122+
declared item by its machine name. `NULL` is not an owner: no row is
123+
"global" or platform-wide because its `organization_id` is empty.
124+
3. **A table with no organization column is deployment-level or code-level.**
125+
Permissions protect it, not the wall.
126+
127+
Under `single` a write into a table with an organization column takes the
128+
Default Organization, the only one there is. A system write that names no
129+
organization is stamped with it, or refused loudly when the deployment holds
130+
zero organizations or two or more. Nothing writes an organization-less row
131+
there.
132+
133+
<Callout type="info" title="Platform tables with no organization">
134+
A platform table (logs, job runs, messaging, configuration) has one of the two
135+
shapes above, never a third. With no organization column, it is deployment-level
136+
by design, and its rows carry no organization on any posture. With one, every
137+
row belongs to an organization. A v17 install could leave organization-less rows
138+
in such a table. Those rows are what the 18.x upgrade ceremony resolves, table
139+
by table, before an 18.x server will serve that database (ADR-0131 D10). Until
140+
that release, keep the database on 17.x.
141+
</Callout>
142+
143+
---
144+
104145
## How the posture resolves
105146

106147
`OS_TENANCY_POSTURE` is the authoritative knob; `OS_MULTI_ORG_ENABLED` is a

‎content/docs/permissions/permissions-matrix.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ segregation-of-duties case the axis exists for. The built-in
4141
The bypass is **posture-gated for row-level security** (ADR-0066 D2 ①): RLS
4242
policies are short-circuited only on objects whose posture permits it —
4343
`access: { default: 'private' }`, `tenancy.enabled: false`
44-
(platform-global), or better-auth-managed. On an ordinary tenant business
44+
(deployment-level: no organization column), or better-auth-managed. On an ordinary tenant business
4545
object, authored and baseline RLS still applies (e.g. `member_default`'s
4646
owner-scoped write policies keep `org_member` holders owner-scoped on
4747
`update`/`delete` even when another set grants `modifyAllRecords`), and the

‎content/docs/permissions/rls.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ derivation off for that class: an authored `update` predicate then decides
8181
the `update` *filter* alone, widening past the platform's owner-scoped write
8282
floor exactly as written. Nothing is derived for `insert`
8383
(there is no pre-existing row to be visible), and a caller holding the
84-
read-side superuser bypass (`viewAllRecords` on a private or platform-global
84+
read-side superuser bypass (`viewAllRecords` on a private or deployment-level
8585
object) is not narrowed, because their readable set is already unbounded.
8686

8787
**A write widener never reaches a row the caller cannot read.** "You may not

‎content/docs/permissions/sharing-rules.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ Two things the bits do **not** override, regardless of the OWD:
6161
`org_member`), so rank-and-file members stay owner-scoped on writes even
6262
under `public_read_write`. The super-user bits short-circuit RLS **only
6363
where the object's access posture permits it** — `access: { default:
64-
'private' }`, `tenancy.enabled: false` (platform-global), or better-auth
64+
'private' }`, `tenancy.enabled: false` (deployment-level: no organization column), or better-auth
6565
managed objects (ADR-0066 D2 ①). On an ordinary tenant business object,
6666
`modifyAllRecords` does *not* lift those policies
6767
(`plugin-security/src/security-plugin.ts` `computeLayeredRlsFilter`).
@@ -195,7 +195,7 @@ unaffected.
195195
**…and an organization to be scoped by.** `manage_sharing` is declared
196196
`scope: 'org'`, so the capability alone is not enough: the caller's session
197197
must also resolve an **active organization**, which is what scopes every rule
198-
read to "this organization ∪ the platform-global rows". A session that carries
198+
read to the rule rows that organization wrote. A session that carries
199199
none — a user who has not selected an organization, or whose active
200200
organization was cleared — is refused with the same `403 PERMISSION_DENIED`,
201201
naming the missing organization rather than answering with an empty list.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ that silently does not happen.
129129
| 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` |
130130
| 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` |
131131
| 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` |
132-
| 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` |
132+
| 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` |
133133
| 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` |
134134
| 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` |
135135
| 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` |

‎packages/cli/src/commands/doctor-env-provenance.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,9 @@ describe('os doctor, end to end, against a posture that only exists in .env', ()
455455

456456
it('names the file, refuses to call the environment functional, and exits 1', async () => {
457457
// ── Control: the same .env, a posture that parses ─────────────────────
458-
fs.writeFileSync(path.join(tmp, '.env'), 'OS_TENANCY_POSTURE=isolated\n');
458+
// `single`, not `isolated`: 18.0 refuses a walled posture at `os serve`, so
459+
// doctor reports one, and a walled control would carry a posture row.
460+
fs.writeFileSync(path.join(tmp, '.env'), 'OS_TENANCY_POSTURE=single\n');
459461
const healthy = await runDoctor();
460462

461463
expect(healthy.exitCode).toBeUndefined();

‎packages/cli/src/commands/doctor-organizations-message-spelling.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ describe('doctor — the posture description an operator reads names the declara
168168
it('leg (i) — the `isolated` fix-list bullet names it, with the spacing intact', () => {
169169
expect(renderIsolatedBullet()).toBe(
170170
` • OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime `
171-
+ "(the legacy spelling 'multi' is accepted and normalizes to this)",
171+
+ "(the legacy spelling 'multi' is accepted and normalizes to this); refused at boot in 18.0, which supports single only",
172172
);
173173
});
174174

@@ -242,14 +242,14 @@ describe('#12464 CONTROL — these pins can say no', () => {
242242
// decorative. Anchored on the exact regression interpolation invites.
243243
const expected =
244244
` • OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime `
245-
+ "(the legacy spelling 'multi' is accepted and normalizes to this)";
245+
+ "(the legacy spelling 'multi' is accepted and normalizes to this); refused at boot in 18.0, which supports single only";
246246
expect(
247247
` • OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime`
248-
+ "(the legacy spelling 'multi' is accepted and normalizes to this)",
248+
+ "(the legacy spelling 'multi' is accepted and normalizes to this); refused at boot in 18.0, which supports single only",
249249
).not.toBe(expected);
250250
expect(
251251
` • OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime `
252-
+ "(the legacy spelling 'multi' is accepted and normalizes to this)",
252+
+ "(the legacy spelling 'multi' is accepted and normalizes to this); refused at boot in 18.0, which supports single only",
253253
).not.toBe(expected);
254254
// …and says yes to the real thing, so the two `not.toBe`s above are a
255255
// reading rather than a pair of vacuous truths.

‎packages/cli/src/commands/doctor-tenancy-posture-report.test.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import { TENANCY_POSTURES } from '@objectstack/spec/security';
6161

6262
import Doctor, {
6363
resolveTenancyPostureOrFinding,
64+
walledPostureFinding,
6465
readDotenvFiles,
6566
type DotenvReading,
6667
} from './doctor.js';
@@ -290,7 +291,9 @@ describe('os doctor reports an unrecognized posture and exits non-zero', () => {
290291

291292
it('names OS_TENANCY_POSTURE, refuses to call the environment functional, and exits 1', async () => {
292293
// ── Control: the same environment with a posture that parses ──────────
293-
process.env.OS_TENANCY_POSTURE = 'isolated';
294+
// `single`, not `isolated`: 18.0 refuses a walled posture at `os serve`, so
295+
// doctor reports one (`walledPostureFinding`, below) and it is no control.
296+
process.env.OS_TENANCY_POSTURE = 'single';
294297
const healthy = await runDoctor();
295298

296299
// 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', () => {
343346
// `}, 60_000)` cases in this package (`utils/sqlite-occupancy.test.ts`,
344347
// `utils/schema-migrate.deferred-ddl.integration.test.ts`).
345348
}, 60_000);
349+
350+
it('18.0 — a walled posture is reported as a warning naming the `os serve` refusal, and exits 0', async () => {
351+
process.env.OS_TENANCY_POSTURE = 'group';
352+
const walled = await runDoctor();
353+
expect(walled.out).toContain('Tenancy posture');
354+
expect(walled.out).toContain("the tenancy posture is 'group'");
355+
expect(walled.out).toContain('`os serve`');
356+
expect(walled.out).toContain('Environment is functional but has some warnings');
357+
expect(walled.exitCode).toBeUndefined();
358+
}, 60_000);
359+
});
360+
361+
describe('walledPostureFinding — 18.0 supports `single` only', () => {
362+
it('answers null for `single`, a warning row for each walled posture', () => {
363+
for (const posture of TENANCY_POSTURES) {
364+
const row = walledPostureFinding(posture);
365+
if (posture === 'single') {
366+
expect(row).toBeNull();
367+
continue;
368+
}
369+
expect(row?.name).toBe('Tenancy posture');
370+
expect(row?.status).toBe('warning');
371+
expect(row?.message).toContain(`'${posture}'`);
372+
expect(row?.message).toContain('18.0');
373+
expect(row?.fix).toContain('OS_TENANCY_POSTURE=single');
374+
}
375+
expect(TENANCY_POSTURES).toContain('group');
376+
expect(TENANCY_POSTURES).toContain('isolated');
377+
});
346378
});

0 commit comments

Comments
 (0)