Skip to content

Commit fc34d00

Browse files
feat(objectql,cli,plugin-webhooks)!: 18.x step 4: the walled lift (8b walled half, the os serve lift, the declared-webhook name guard, declared webhooks deployment-wide) (#24667)
Clause-②: yes (narrowing) Closes #24580 (18.x step 4, the walled lift). Every item of the card is in: 8b's walled half (with the cited `sys_secret` / `sys_flow_credential` bridge, contract review B1), #24489 items 2 and 3 (the `os serve` lift, R4 restated), the declared-name guard, and item 4 under ruling A. **Base.** #24588 (step 1) has merged into `main` (`191e0430e`), and `origin/main` (`3a931fca1`) is merged into this branch at `fdfc84238` with a merge commit. The four plugin-webhooks files conflicted only because #24588 was squash-merged: main's copy of each equals the step-1 head this branch had merged (`b4ae8e68`), and the resolution keeps this branch's side. That is proven per file: the branch's delta against main equals its own delta against `b4ae8e68`, byte for byte. No regenerable artifact conflicted. The diff is now this card's own files only. ## What changes 1. **8b's walled half (`packages/objectql`).** The platform-object exemption in `Engine.resolveSystemInsertOrganization` is gone under `group` / `isolated` too. A system insert into a `sys_` / `cloud_` / `ai_` object with an organization column that names no organization is now refused under a wall with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (500, reason `walled-posture`), instead of landing `organization_id` NULL. `PLATFORM_OBJECT_TENANCY` keeps its every-posture reader, the `bypassTenantAudit` mute; only the walled write-path reader retired. The docblocks in `platform-object-tenancy.ts`, `system-write-organization.ts` and `engine.ts` now say this. 2. **The declared-name guard on `sys_webhook` (`packages/plugins/plugin-webhooks`, seat note 6271514301 item 1).** `bindDeclaredWebhookNameGuard` has the shape of `bindDeclaredRuleNameGuard`. A non-system insert whose `name` is declared (switched on or off) is refused, and so is an update that changes `name` onto a declared one. The refusal is `DeclaredWebhookNameConflictError`: `RESOURCE_CONFLICT`, 409, naming the webhook and the remedy (clone under another name, switch the declaration off through the activation door). System writes pass. An update that keeps its name passes, so the 18.0 mirror rows and the rows customised there stay editable. `WebhookOutboxPlugin` binds the guard beside the reader and unbinds it on `destroy()`. The system-context census page gains the anchor. 3. **Walled test rigs.** 164 walled-posture test files, measured by a posture grep. Two failed after the lift, both fixtures, and both are fixed by carrying the organization a walled system write needs. No assertion moved. - `plugin-security/src/builtin-positions.boot.test.ts`: the census positive-control insert. - `plugin-security/src/grant-holder-membership-refusal.test.ts`: the `sys_position` seed. 4. **The objectql pins of the exemption itself flip.** They pinned the behaviour this card retires. They now pin the walled refusal and its discriminating control (an object with no tenant column is still not refused): - `system-write-organization.test.ts` - `tenancy-by-object-classification.test.ts` 5. Two changesets: `15212-8b-walled-platform-insert-refused.md` (objectql, `minor`, breaking) and `15212-webhook-declared-name-guard.md` (plugin-webhooks, `minor`, breaking). ## #24489 items 2 and 3: the `os serve` lift (R4 restated) The session owner granted the permission the classifier had held, and these went in at `45898dbf6`: - `walledPostureRefusal` and its call in `serve.ts` are deleted, together with `serve-walled-posture-refusal.test.ts`. The diff is the exact inverse of `ae6bd1a6`'s `serve.ts` hunks. - The seven walled e2e cases are restored whole from `ae6bd1a6^`. Neither file had changed since. There are 3 cases in `serve-organizations-host-resolution.e2e.test.ts` and 4 in `serve-organizations-mount-failure.e2e.test.ts`. - `walledPostureFinding` (doctor) and `WALLED_POSTURE_RELEASE_NOTE` (the fix-list note) are removed. Their four test files are restored from `5b14b7f^`; none had changed since, and `5b14b7f`'s hunks in them were all walled. - The 18.0 walled docs sentences are removed: - `tenancy-modes.mdx`: the callout now says only that the upgrade path arrives in a later 18.x release; - `environment-variables.mdx`: the `OS_TENANCY_POSTURE` row; - `self-hosting.mdx`: byte-identical to before `ae6bd1a6`; - `cli.mdx`, three sites. Each sentence's upgrade half stays, because it belongs to steps 5 and 6. Nothing under `content/docs/releases/` or `docs/adr/` is touched. - One changeset: `15212-walled-posture-boots.md` (`@objectstack/cli`, `minor`, `Clause-②: yes (widening)`). ## Item 4 (seat note 6271514301 item 2): ruling A, implemented The maintainer ruled **A** (ruling record 6278609968 on #24580). Under `group` and `isolated` a declared webhook is deployment-wide, exactly as under `single` since step 1: - It receives every organization's events, and each `sys_http_delivery` row is stamped with its event's organization. - The switch stays the deployment-level activation-ledger row, operator-gated under a wall. - Today's organization-less refusal under a wall is removed. - No ADR change. What changed: - `AutoEnqueuer` marks every declared subscription `deploymentWide`. The posture-reading option `declaredWebhooksDeploymentWide` is removed (added by #24588 and never released). - `WebhookOutboxPlugin` drops `postureBuildsNoWall` and no longer reads the posture for declared webhooks. - Docblocks (`CachedSubscription.deploymentWide`, `admitsOrganization`, the `declared-webhooks.ts` module doc) cite the ruling. - One changeset: `15212-declared-webhook-walled-deployment-wide.md` (`minor`, `Clause-②: yes (widening)`). The pins: - **New:** `packages/qa/dogfood/test/wall-declared-webhook-deployment-wide.dogfood.test.ts`. It runs a real walled boot, under both `isolated` and `group`, with two organizations created through the auth API and the engine's own record events reaching the plugin's dispatcher. It pins three things: 1. an organization administrator holding `manage_metadata` in its own organization is refused `403 PERMISSION_DENIED` at the activation door, nothing is written, and the platform operator switches the webhook on; 2. one delivery per event, each stamped with that event's organization; 3. each organization reads only its own deliveries, through `GET /data/sys_http_delivery` with an organization-scoped read grant. - **Flipped (unit):** in `declared-webhooks.test.ts`, the walled-refusal case becomes the two-organization delivery case, and the posture-read case becomes "deployment-wide under `single` / `group` / `isolated` / no tenancy service". Reverse-verified, each through `ablation-replace.mjs`, with the package rebuilt so the mutation reached `dist` and the source restored to `git diff HEAD` empty: - `sub.deploymentWide = true` changed to `false`: 3 unit cases and 4 dogfood cases red (stamping and reading, under both postures). - The event-organization stamp replaced by the subscription's: the same 3 + 4 red. - The door's posture refusal removed in `catalog-activation.ts` (runtime dist confirmed mutated, 2 → 1 occurrences): the dogfood door case red under both postures, and the runtime door unit test 12 red. - A first run of this pin had org A without `manage_metadata`, so the door refused it on authority, not on posture, and the ablation stayed green. The fixture now grants `manage_metadata` inside org A, so only the posture rule can refuse it. ## Contract review B1 (comment 6280853341): the walled bridge for `sys_secret` and `sys_flow_credential` The walled lift reached three production writers that are organization-less by ruling until step 6: - a global-scope encrypted setting (`settings-service.ts`, inserted by `settings-service-plugin.ts`); - a datasource credential bind (`datasource-secret-binder.ts`); - a flow credential save (`flow-credential-channel.ts`). Under a wall each became a 500 `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. The fix is the seat's choice (i): 1. **A cited bridge.** `WALLED_ORGANIZATION_LESS_BRIDGE` (`objectql/src/tenancy/system-write-organization.ts`) names exactly `sys_secret` and `sys_flow_credential`. `Engine.resolveSystemInsertOrganization` lets their organization-less system inserts land under `group` / `isolated`, as before the lift; under `single` they take the Default Organization as before. It cites ruling 6270035914 item 2 and #24582. 2. **A tripwire.** `service-automation/src/walled-organization-less-bridge.test.ts` asserts the list is exactly those two tables, and that each still carries its tenant column. **Note for the #24681 dev:** the PR that flips either table to `systemFields: { tenant: false }` turns this red. Its message says to delete the list, its reader in `Engine.resolveSystemInsertOrganization` and the tripwire itself. 3. **Three real-engine walled pins**, each under `isolated` and `group`: - `service-settings/src/settings-global-secret-walled.test.ts`: the real `SettingsService` plus the plugin's real secret store; - `service-datasource/src/datasource-secret-binder.walled.test.ts`: the real binder on SQLite. The package gains `@objectstack/objectql` and `@objectstack/platform-objects` as devDependencies (6 lockfile lines, no build-graph cycle), aliased to source; - `service-automation/src/walled-organization-less-bridge.test.ts`: the real `FlowCredentialChannel` on SQLite, which writes both tables. Reverse-verified through `ablation-replace.mjs`: - bridge disabled, objectql rebuilt: all 6 walled cases red across the three packages; - `systemFields: { tenant: false }` added to `sys_flow_credential`, then to `sys_secret` (platform-objects rebuilt): the tripwire red, each time with its remedy message. Each restored to `git diff HEAD` empty. 4. **The changeset** `15212-8b-walled-platform-insert-refused.md`: - its "Unchanged" list names the two bridged tables; - the remedy spells `systemFields: { tenant: false }`; - the ADR-0087 marker records the two added exports. 5. **The QA checklist.** `origin/main` (with #24687) is merged again, at `3f839a98c`, cleanly. Then: - `RUNNER.md`'s two environment facts are rewritten: "A walled boot is a fixture of its own" (the three prerequisites, measured), and the one-organization entry, which now names the walled boot's organization create; - seven items re-pointed, each with a revision bump and a history entry: the five in `access-security.json`, `platform-core.settings-organization-isolation-and-platform-rung`, and `attachments-storage.chunked-upload-integrity`. An unprovisioned walled leg is now `blocked(fixture)`; - `check-platform-checklist`, full and `--changed-from`: OK. Re-verified on `f73d8891a` (after a fresh install and a full `pnpm build`): | reading | result | |:--|:--| | R1, fresh `single` boot | discovery 200, 0 refusals | | R2 + R3, the harness with `webhook-materialization` and the walled declared-webhook pin | 3 files / 15 passed | | R4, `isolated` `os serve` with the real Organizations runtime | discovery 200, `Organizations` mounted, 0 refusals | | service-settings | 53 files / 894 passed | | service-datasource | 46 files / 840 passed | | service-automation | 196 files / 2600 passed | | plugin-security | 189 files / 3786 passed, 45 skipped | | plugin-webhooks | 221 passed | | plugin-sharing | 1163 passed | | plugin-auth | 3168 passed, 10 skipped | | objectql walled pins | 68 passed | | CLI unit + the 7 walled e2e cases | 4639 passed; 7/7 | | `typecheck` (all nine packages) | exit 0 | | `dispatch-gates --commands` | 124 / 124 exit 0, after one fix: `check:test-source-alias` asked for the new service-datasource imports to be aliased, and they are | | CI on `f73d8891a` | 37 success, 5 skipped, 0 failures | ## Re-verified after merging `main` (head `fdfc84238`) The install was refreshed and `pnpm build` rerun in full (exit 0). Then: | reading | result | |:--|:--| | R1: `pnpm dev -- --fresh` (showcase, `single`) | ready, discovery 200, 0 refusals | | R2 + R3: `upgrade-18x-harness` (with `webhook-materialization` and the walled declared-webhook pin) | 3 files / 15 passed | | plugin-webhooks, whole package | 20 files / 221 passed | | objectql walled pins + plugin-security walled fixtures | 68 + 76 passed | | CLI `--project unit` + the 7 restored e2e cases | 314 files / 4639 passed; 7/7 | | `typecheck`: objectql, plugin-webhooks, plugin-security, cli | exit 0 | | `dispatch-gates --commands`: every derived command, run in full | 110 / 110 exit 0 | **CI red after the merge, fixed at `1412586b4` (fixtures only, no assertion changed).** `fdfc84238` was red in Test Core 2/6 (plugin-sharing) and 3/6 (plugin-auth). The 164-file walled grep had missed both files, because they set the posture another way. Both fixtures wrote organization-less legacy rows under a wall through the engine, which the walled lift now refuses: - `plugin-sharing/src/reconcile-refused-grant-continues.test.ts` (9 cases): the "platform-global rule" (`organization_id` null) is now seeded through the driver, as the fixture already seeds its organization-less records. That is the row an older database holds. - `plugin-auth/src/last-admin-guard.config-anchor.test.ts` (3 cases): the legacy unscoped `admin_full_access` grant and its catalog row are seeded through the driver in the L5 block. The fixture's `sys_metadata_activation` now declares `systemFields: { tenant: false }`, as the platform object does (no organization column). - Full suites after the fix: plugin-sharing 49 files / 1161 passed; plugin-auth 165 files / 3168 passed, 10 skipped; typecheck exit 0 for both. - CI on `1412586b4`: 36 success, 5 skipped, 0 failures, all six Test Core shards included. The readings below were taken before the merge. ## Proof (head `45898dbf6`) | reading | result | |:--|:--| | objectql, whole package | 449 files / 8454 tests passed | | plugin-webhooks build closure + `typecheck` + whole package | 20 files / 221 tests passed | | plugin-security, whole package | 188 files / 3754 passed, 45 skipped | | walled rigs (164 files over 17 packages that depend on objectql) | all green after the two fixture fixes; dogfood 12 passed, 1 skipped | | R2: `upgrade-18x-harness.dogfood.test.ts` | 6/6, rerun after ruling A together with `webhook-materialization` and the new walled pin: 3 files / 15 passed | | plugin-webhooks after ruling A: `typecheck` + whole package | exit 0; 20 files / 221 passed | | R3 | covered by the harness's v17 case (the ceremony still refuses) | | R1: `pnpm dev -- --fresh` (showcase, `single`) after a full `pnpm build` | ready, `GET /api/v1/discovery` 200, no system-write refusal in the log | | R4 as restated: a walled `os serve` boots | MET. The built CLI (`bin/run-dev.js serve`) under `OS_TENANCY_POSTURE=isolated` (plus the walled prerequisites `OS_PLATFORM_OWNER_EMAIL` and `OS_AUTH_MEMBERSHIP_POLICY=invite-only`), on a throwaway app that declares the real `@objectstack/organizations`, reached `Press Ctrl+C to stop`. `Organizations` was mounted, `GET /api/v1/discovery` answered 200, and the log holds 0 system-write refusals | | CLI after the lift: build + `typecheck` + `--project unit` | exit 0; 313 files / 4633 passed | | the seven restored e2e cases (`OS_TEST_TIERS=nightly`) | 7/7 | **Ablations,** each through `scripts/ablation-replace.mjs`, with mutation landed and restored to `git diff HEAD` empty: - The walled exemption re-inserted in the engine: 6 walled cases red (`sys_audit_entry` and `sys_permission_set`, × `isolated` / `group`, in both objectql files). - The guard's `throw` replaced by `return`: 4 cases red (insert, switched-off, rename, plugin wiring). - The guard's system pass and rename check removed: 3 control cases red. **Ablation of the lift:** a walled refusal re-inserted at `serve`'s posture gate turns all 7 restored e2e cases red; restored to `git diff HEAD` empty. **Local gates for the `os serve` half**, each exit 0: `check-platform-checklist --changed-from origin/main` (for the removed exports), `adr-0087-registration`, `changeset-no-major`, `empty-changeset`, `closing-keyword-parity`, `check-affected-docs`, `check-drift-comment`, `check-doc-frontmatter`, `doc-anchors`, `doc-authoring`, `docs-spec-enumerations`, `docs-transcript-drift`, `doc-security-posture`, `cli-command-ids`, `issue-citations`, `nul-bytes`, `cross-package-test-inputs`, `tier-file-adoption`, `pm-prior-rulings`, `platform-checklist`. Targeted eslint exit 0. **Local gates** (`dispatch-gates --commands`, narrowed to the families reading this card's files; CI runs the farm), all exit 0 at the end: - the three census gates (`system-context`, `tenant-audit`, `platform-object-tenancy`); - `adr-0087-registration`, `changeset-no-major`, `empty-changeset`, `closing-keyword-parity`, `issue-citations`, `doc-authoring`; - `engine-double-contract`, `objectql-double-limit`, `query-options-erasure`, `where-matcher`; - `nul-bytes`, `cross-package-test-inputs`, `test-source-alias`, `durability-log-level`, `error-status-conformance`, `route-envelope`, `platform-checklist`, `engine-split-ratio`. `check-system-context-census` and `where-matcher` were red on first run, on this card's own new code; both are fixed in the branch. ## Acceptance notes - **Size.** `git diff --shortstat origin/main...HEAD` on head `f73d8891a`: 45 files, +1431 / -602 (2033 changed lines), under the 3000 human-merge line, with 0 governed paths. - **Walled 18.0 databases from embedded hosts.** These were never refused. Their NULL rows are step 5's (ceremony attribution and report fates), as the card records. Nothing here moves or rewrites stored rows. --- _Generated by [Claude Code](https://claude.ai/code/session_01XA1XHtvWd8Q1Q1gyWvhZZz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f7005cb commit fc34d00

45 files changed

Lines changed: 1439 additions & 604 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
feat(objectql)!: under a walled posture (`group` / `isolated`), a system insert into a platform object that names no organization is refused; it no longer lands with `organization_id` NULL
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing at the engine's system-insert door: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. No export, type or error code is removed or renamed: `PLATFORM_OBJECT_TENANCY`, `isPlatformObjectOutOfTenantAuditScope` and `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` keep their shapes; two exports are added, `WALLED_ORGANIZATION_LESS_BRIDGE` and `isWalledOrganizationLessBridgeObject`, the temporary bridge named below. The remedy is a run-time one, stated below. -->
10+
11+
**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, D11). This is the walled half of the change whose `single` half shipped in 18.0.
12+
13+
- **Before.** Under `group` or `isolated`, a system-context insert (`isSystem`, no `tenantId`) into a `sys_` / `cloud_` / `ai_` object that carries an organization column, but that the tenancy inventory had not admitted, skipped the organization rule. The row landed with `organization_id` NULL, a row no organization owns and the wall hides from every organization.
14+
- **Now.** That insert goes through the same decision every application object already gets under a wall: no organization is derivable, so it is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500, reason `walled-posture`), and nothing is written. Apart from the two tables named below, the posture no longer changes which objects the rule covers.
15+
- **Unchanged.** A write that carries an organization, on the execution context or on the record, is never re-stamped or refused. Objects with no organization column (`systemFields: { tenant: false }`), objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. `sys_secret` and `sys_flow_credential` keep their organization-less system inserts under a wall, as before: a global-scope encrypted setting, a datasource credential and a flow credential are deployment-level, and both tables drop their organization column in a later 18.x release, which retires this bridge (`WALLED_ORGANIZATION_LESS_BRIDGE`). Updates are untouched. The engine's diagnostics-only tenant-audit mute still reads the inventory.
16+
17+
**The remedy.** If a refusal names your insert, carry the organization on the write: `ql.insert(object, { ...row, organization_id: orgId }, { context: { isSystem: true } })`, or `{ context: { isSystem: true, tenantId: orgId } }`. If the row is deployment-level by design, the object should declare no organization column: `systemFields: { tenant: false }`.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-webhooks': minor
3+
---
4+
5+
feat(plugin-webhooks): under `group` and `isolated`, a declared webhook is deployment-wide, as it is under `single`; it receives every organization's events, and each delivery is stamped with its event's organization
6+
7+
Clause-②: yes (widening)
8+
9+
A webhook declared with `defineStack({ webhooks })` belongs to the deployment under every tenancy posture (ADR-0131 D2/D6, the epic's ruling A on the walled shape of a declared webhook).
10+
11+
- **Before.** Under a walled posture (`group` / `isolated`) a declared webhook had no organization, so the dispatcher refused every organization-stamped event it matched. It logged one warning per webhook and delivered nothing.
12+
- **Now.** Switched on, it receives every organization's matching events, as under `single`. Each `sys_http_delivery` row is stamped with its event's organization, so an organization reads only its own deliveries. The switch is unchanged: one deployment-level `sys_metadata_activation` row through `POST /api/v1/security/_activation/webhook/<name>`. Under a wall only the platform operator may flip it; an organization administrator gets `403 PERMISSION_DENIED`.
13+
- **Unchanged.** Webhooks an organization authors as `sys_webhook` rows still receive only their own organization's events.
14+
15+
**If you relied on the old behaviour.** A walled deployment that declared a webhook it did not want delivered across organizations should switch it off: `POST /api/v1/security/_activation/webhook/<name>` with `{ "enabled": false }`, as the platform operator.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
feat(cli): `os serve` (and so `os dev` and `os start`) boots a walled tenancy posture again; `group` and `isolated` are supported on fresh installs
6+
7+
Clause-②: yes (widening)
8+
9+
18.0 refused a walled posture at boot, because the walled half of the platform-object organization rule had not shipped. It ships in this release (see the `@objectstack/objectql` changeset on walled system inserts), so the refusal is removed.
10+
11+
- **Before.** With `OS_TENANCY_POSTURE=group` or `isolated`, or `OS_MULTI_ORG_ENABLED=true`, `os serve` exited `1` with a FATAL `NOT_IMPLEMENTED` before it read a config. `os doctor` printed a `Tenancy posture` warning saying so, and the unrecognised-posture fix list marked both walled values as refused in 18.0.
12+
- **Now.** A walled posture boots like `single`, under the same prerequisites as before 18.0: the enterprise `@objectstack/organizations` runtime, and `OS_PLATFORM_OWNER_EMAIL`. `os doctor` reports no row for a valid posture, and the fix list offers `group` and `isolated` without the 18.0 note.
13+
- **Unchanged.** An unrecognised posture is still refused at boot. Upgrading an existing database still waits for the upgrade ceremony, which still refuses in this release.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-webhooks': minor
3+
---
4+
5+
feat(plugin-webhooks)!: a `sys_webhook` row can no longer take a declared webhook's name — an insert of one, or a rename onto one, is refused with `409 RESOURCE_CONFLICT`
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A write-door narrowing on one platform table: no spec key, export of the spec, option or stored metadata shape is added, removed or renamed, so `objectstack migrate meta` has nothing to rewrite; rows already stored are never touched, and the remedy is a run-time one, stated below -->
10+
11+
**BREAKING** accept-set narrowing, shipped as `minor` under the launch-window convention for breaking changes (ADR-0131 D6 regime C).
12+
13+
A declared webhook (`defineStack({ webhooks })`) is read-only, and a `sys_webhook` row holding its name withholds the declaration for the whole deployment. Under a walled posture one organization's same-name row would silence the declaration for every organization. So no write may take a declared name, as declared sharing rules already do.
14+
15+
- **Refused now.** A non-system insert into `sys_webhook` whose `name` is declared, switched on or off, and an update that changes `name` onto a declared one. Every door is covered (Setup, the `/data` API, scripts, AI tools): the refusal is `DeclaredWebhookNameConflictError`, `code: 'RESOURCE_CONFLICT'`, `status: 409`, naming the declared webhook.
16+
- **Unchanged.** System writes (the platform's own sweeps and migrations). An update that keeps its name, so the rows an 18.0 install's seeder wrote, and the ones an administrator customised there, stay editable as before. Names nothing declares.
17+
18+
**The remedy.** To change a declared webhook, author your own `sys_webhook` row under another name, and switch the declared one off: `POST /api/v1/security/_activation/webhook/<name>` with `{ "enabled": false }`.

‎content/docs/deployment/cli.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ organization-ownership shape until the [ownership ceremony](#os-migrate-organiza
203203
has run on it.
204204

205205
<Callout type="warn">
206-
18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 the ceremony refuses, so `os dev` stops at step 2 with its
206+
Fresh installs are supported under every tenancy posture; the upgrade path arrives in a later 18.x release. Until then the ceremony refuses, so `os dev` stops at step 2 with its
207207
refusal and changes nothing.
208208
18.0 does not upgrade a v17 database: keep a v17 database on a 17.x release
209209
until the 18.x release that carries the upgrade path. For a local SQLite file,
@@ -1216,7 +1216,7 @@ where the data lives.
12161216
| `os migrate value-shapes` | Scan stored reference and structured-JSON field values against the platform's value contract, and record the deployment's migration flag when clean |
12171217
| `os migrate summary-nulls` | Backfill roll-up `count` / `sum` columns still stored as `NULL` on parent rows created before the insert-time seed. Repairs values; no flag, nothing depends on it having run |
12181218
| `os migrate comment-reactions` | Carry the emoji reactions a 17.x deployment stored in the retired `sys_comment.reactions` column into `sys_comment_reaction` records, list every stored value that does not read, and record the verified run that lets `os migrate apply --allow-destructive` drop the column |
1219-
| `os migrate organization-ownership` | The v18 ownership ceremony (ADR-0131 D10): write a plan of what each table's rows will become, then `--apply` that reviewed plan. A v18 server refuses to start until it has run. 18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 the command refuses. See [below](#os-migrate-organization-ownership) |
1219+
| `os migrate organization-ownership` | The v18 ownership ceremony (ADR-0131 D10): write a plan of what each table's rows will become, then `--apply` that reviewed plan. A v18 server refuses to start until it has run. Fresh installs are supported under every tenancy posture; the upgrade path arrives in a later 18.x release. Until then the command refuses. See [below](#os-migrate-organization-ownership) |
12201220
| `os migrate activity-changed-fields` | Stamp the two read-rule columns (`changed_fields`, `milestone_key`) on the `sys_activity` rows written before they existed. Until it runs, those rows are withheld from every reader but the system, so run it at once after upgrading. No flag; idempotent |
12211221
| `os migrate audit-mail-bodies` | Mask the mail bodies (`body_text`, `body_html`) that `sys_audit_log` and `sys_activity` copies of `sys_email` / `sys_identity_email` rows still hold in clear, written before the audit writer masked them. Reports counts only. No flag; idempotent. See [below](#os-migrate-audit-mail-bodies) |
12221222
| `os migrate comment-parent-pair` | Stamp the parent pair (`object_name`, `record_id`) on the `sys_comment` rows written before it existed. Until it runs, a broad comment read past 2,000 candidate rows omits comments the reader may see, and its total agrees with the short list, so run it at once after upgrading. No flag; idempotent |
@@ -1527,7 +1527,7 @@ has run. In the 18.x release that carries the upgrade path, `os dev` runs it on
15271527
its own for a local SQLite database (see [`os dev`](#os-dev)).
15281528
15291529
<Callout type="warn">
1530-
18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 this command refuses, plan and apply alike: it exits `1` with
1530+
Fresh installs are supported under every tenancy posture; the upgrade path arrives in a later 18.x release. Until then this command refuses, plan and apply alike: it exits `1` with
15311531
`NOT_IMPLEMENTED` (under `--json`, `code` beside `error`) and reads and
15321532
writes nothing. Keep a v17 database on a 17.x release until the 18.x release
15331533
that carries the upgrade path. The invocations below, and what the list after

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ Read by `@objectstack/service-print` when `PrintServicePlugin` is composed; see
9898
| `OS_AUTH_GOOGLE_ENABLED` | boolean | settings default | Settings env override for `auth.google_enabled`. Requires Google OAuth credentials from Settings or env. |
9999
| `GOOGLE_CLIENT_ID` | string | — | Deployment-level Google OAuth client id for the open-source Google login implementation. |
100100
| `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. |
101-
| `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = <active organization>`, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. 18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 `os serve` refuses `group` and `isolated` at boot. |
101+
| `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = <active organization>`, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. |
102102
| `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. |
103103
| `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` | boolean | `false` | Whether this deployment runs **package-authored scheduled work**: time-triggered flows (a `type: 'schedule'` flow with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. **OFF by default in every posture and every kernel** — a clock-driven workload's cost is a fact about the deployment, not about the flow, so an author cannot decide it and no metadata key asks them to. Accepts `true`/`1`/`on`/`yes` (case-insensitive); anything else, including unset, is off. While it is off nothing arms, and every such flow is listed by `getTriggerBindingAudit()` and the CLI startup summary as **disabled by deployment policy** — never as a binding failure, and nothing about the flow needs fixing. While it is on, what a time-triggered flow owes depends on the posture: under `isolated` it must declare `config.organization` on its start node or it is not armed; under `group` the declaration is optional and an undeclared sweep reads group-wide with each run acting as its own swept record's organization (a record-less cron there carries none, and its tenant-scoped writes are refused — declare one if it writes); under `single` it needs no declaration and its runs carry no organization. ⛔ Platform-internal scheduled work — approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill — is **not** gated by this: the boundary is *authored by a package*, not *runs on the job service*. `os doctor` prints the effective value. |
104104
| `OS_PLATFORM_OWNER_EMAIL` | csv | — | The deployment's platform administrators: one email address, or a comma-separated list. A caller resolves `PLATFORM_ADMIN` when their own stored `sys_user` row carries a declared address **and** reads email-verified. Comparison is trimmed and case-insensitive; duplicates collapse and blank entries are dropped. One **unparseable** entry refuses the **whole** variable rather than just that entry — the deployment then has zero configured administrators, loudly — because a silently narrower administrator set is the worse failure. **Required under the walled postures** (`group` / `isolated`): unset or blank there **refuses to boot**, since the first self-registrant is not promoted and no grant row is written. Unset under `single` is normal — that posture still promotes the first human account. Read live per resolution, so revocation is a config change plus a process reload; there is no runtime endpoint that changes it. See [First boot: create the admin](/docs/deployment/self-hosting#first-boot-create-the-admin). |

‎content/docs/deployment/self-hosting.mdx‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -466,8 +466,7 @@ lock sign-up down via `OS_AUTH_SIGNUP_ENABLED` /
466466
configuration.** First-registrant promotion is removed there: with
467467
self-registration reachable, whoever posts to the sign-up endpoint first would
468468
otherwise receive cross-tenant access. No grant row is written on those
469-
postures, ever. Declare the administrators **before first boot** instead
470-
(18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 `os serve` refuses a walled posture at boot):
469+
postures, ever. Declare the administrators **before first boot** instead:
471470

472471
```bash
473472
# one address, or a comma-separated list

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

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,10 @@ it generalizes the two-valued *tenancy mode* of
1616
whose `tenancy` service, membership lifecycle and degraded-tenancy boot guard all
1717
still apply — widened to the three postures.
1818

19-
<Callout type="warn" title="18.0: single only">
20-
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
21-
`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.
19+
<Callout type="warn" title="Upgrades arrive in a later 18.x release">
20+
Fresh installs are supported under every posture: `single`, `group` and
21+
`isolated`. The upgrade path for an existing database arrives in a later 18.x
22+
release, and a database an 18.0 install created takes the same path as a v17 one.
2723
</Callout>
2824

2925
<Callout type="info" title="Renamed, not re-configured">

0 commit comments

Comments
 (0)