diff --git a/.changeset/15212-8b-single-platform-insert-stamped.md b/.changeset/15212-8b-single-platform-insert-stamped.md
index 482d7ecf440..a04dc9de9c6 100644
--- a/.changeset/15212-8b-single-platform-insert-stamped.md
+++ b/.changeset/15212-8b-single-platform-insert-stamped.md
@@ -12,6 +12,6 @@ Clause-②: no (narrowing)
- **Before.** 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 entirely, under every posture. The row landed with `organization_id` NULL. Thirty-five platform objects in this repository were in that population, among them `sys_business_unit`, `sys_business_unit_member`, `sys_member`, `sys_team`, `sys_user_permission_set`, `sys_user_position`, `sys_sharing_rule`, `sys_share_link`, `sys_comment`, `sys_attachment`, `sys_activity`, `sys_email`, `sys_inbox_message`, `sys_secret`, `sys_setting`, `sys_setting_audit`, `sys_webhook` and `sys_flow_credential`.
- **Now, under `single`.** That insert goes through the same decision every application object already gets. With exactly one organization (the Default Organization), the row is stamped with it. With none, or with several, the insert is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), and the message names the missing organization. Nothing is written.
-- **Unchanged.** Under a walled posture (`group` / `isolated`) the per-object exclusion stands as before; the walled half of this change ships in a later 18.x release. 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, objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. Updates are untouched. The engine's diagnostics-only tenant-audit mute is unchanged.
+- **Unchanged.** Under a walled posture (`group` / `isolated`) the per-object exclusion stands as before; the walled half of this change is the companion `@objectstack/objectql` changeset on system inserts under a walled posture. 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, objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. Updates are untouched. The engine's diagnostics-only tenant-audit mute is unchanged.
**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 } }`. On a fresh `single` deployment the auth plugin creates the Default Organization at boot, so a refusal reading `no-organization` means the write ran before it, or in a composition that registers `sys_organization` but mounts no auth plugin to create the Default Organization.
diff --git a/.changeset/15212-8b-walled-platform-insert-refused.md b/.changeset/15212-8b-walled-platform-insert-refused.md
index 592df71f840..ff9e5806edd 100644
--- a/.changeset/15212-8b-walled-platform-insert-refused.md
+++ b/.changeset/15212-8b-walled-platform-insert-refused.md
@@ -6,12 +6,12 @@ feat(objectql)!: under a walled posture (`group` / `isolated`), a system insert
Clause-②: yes (narrowing)
-
+
-**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.
+**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 is the companion `@objectstack/objectql` changeset on system inserts under `single`.
- **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.
-- **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.
-- **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.
+- **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. The posture no longer changes which objects the rule covers.
+- **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` are among them: a global-scope encrypted setting, a datasource credential and a flow credential are deployment-level, so both declare `systemFields: { tenant: false }` and their organization-less system inserts land under a wall. Updates are untouched. The engine's diagnostics-only tenant-audit mute still reads the inventory.
**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 }`.
diff --git a/.changeset/15212-walled-posture-boots.md b/.changeset/15212-walled-posture-boots.md
index 1d3c0737e5d..5db963c0385 100644
--- a/.changeset/15212-walled-posture-boots.md
+++ b/.changeset/15212-walled-posture-boots.md
@@ -6,8 +6,8 @@ feat(cli): `os serve` (and so `os dev` and `os start`) boots a walled tenancy po
Clause-②: yes (widening)
-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.
+The 18.0 pre-releases 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.
- **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.
-- **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.
-- **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.
+- **Now.** A walled posture boots like `single`, under the same prerequisites as before the 18.0 pre-releases: 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 refusal note.
+- **Unchanged.** An unrecognised posture is still refused at boot. Upgrading an existing database is the ownership ceremony's: `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN --backup-taken`.
diff --git a/.changeset/15214-c11-fresh-install-messages.md b/.changeset/15214-c11-fresh-install-messages.md
deleted file mode 100644
index d3e3a6b38ee..00000000000
--- a/.changeset/15214-c11-fresh-install-messages.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@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/.changeset/24529-declared-webhooks-from-registry.md b/.changeset/24529-declared-webhooks-from-registry.md
index 4fad5d6836d..0162a7c1e4b 100644
--- a/.changeset/24529-declared-webhooks-from-registry.md
+++ b/.changeset/24529-declared-webhooks-from-registry.md
@@ -27,4 +27,4 @@ On a database an 18.0 install created (expand first — nothing is moved or dele
- A seeded row whose declaration was removed keeps delivering, as it did on 18.0.
- Redelivering an old delivery of a pristine seeded row is judged by the declaration that now answers for it, so a webhook switched off through the ledger is not re-sent.
-Deleting the seeded rows, and re-pointing their `sys_http_delivery` history at the declaration, is a step of the ownership migration in a later 18.x release.
+Deleting the seeded rows, and re-pointing their `sys_http_delivery` history at the declaration, is the ownership ceremony's `declared-webhooks` step (`os migrate organization-ownership`).
diff --git a/.changeset/24567-drop-gate-refusal-18-0.md b/.changeset/24567-drop-gate-refusal-18-0.md
index e28c7313491..6da5a00994c 100644
--- a/.changeset/24567-drop-gate-refusal-18-0.md
+++ b/.changeset/24567-drop-gate-refusal-18-0.md
@@ -2,12 +2,10 @@
'@objectstack/cli': patch
---
-fix(cli): `os migrate apply --allow-destructive`'s organization-ownership refusal says what 18.0 supports for a v17 database, and names no step of the command 18.0 refuses
+fix(cli): `os migrate apply --allow-destructive`'s organization-ownership refusal states the ceremony's record in its `--json` `detail`
Clause-②: no
-- **`os migrate apply --allow-destructive` on a v17-shaped database.** When the run would drop `organization_id` from a table the organization-ownership ceremony owns, and the ceremony has not verified on the database, the refusal now says that 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path, whose ceremony drops these columns itself. The v18 boot refusal and `os migrate organization-ownership`'s own refusal say the same. Before, it told the operator to run `os migrate organization-ownership --out PLAN` and then `--apply --plan-file PLAN`, which 18.0 refuses on every database (`NOT_IMPLEMENTED`). The `--json` refusal's `detail` no longer names that command either: it reads "No run of the organization-ownership ceremony is recorded on this database.", or names the last unverified run with its date and blocking count.
-- **`os migrate organization-ownership --help`** shows what the command answers in 18.0: exit `1` with `NOT_IMPLEMENTED`, nothing read or written, and the same refusal as JSON. Before, it showed six plan and apply examples that 18.0 refuses.
-- **`os dev` on a v17-shaped development database.** When it does not upgrade the database in place (the ceremony's plan is refused, or the database is not a local SQLite file), it now ends with the same position: keep a v17 development database on a 17.x release until the 18.x release that carries the upgrade path, and upgrade it there. Before, it told the developer to run `os migrate organization-ownership --dev` and then the same command with `--apply`.
+- **`os migrate apply --allow-destructive --json` on a database the organization-ownership ceremony has not verified.** When the run would drop `organization_id` from a table the ceremony owns, the refusal's `detail` reads "No run of the organization-ownership ceremony is recorded on this database.", or names the last unverified run with its date and blocking count. The human message names the ceremony's plan and apply, which drop these columns themselves.
-Nothing else changes. The refusals still hold back the whole run, and their message code (`refused_organization_ownership_unmigrated`), `tables`, `marker`, exit code `1` and every flag are as before.
+Nothing else changes. The refusal still holds back the whole run, and its message code (`refused_organization_ownership_unmigrated`), `tables`, `marker`, exit code `1` and every flag are as before.
diff --git a/.changeset/24581-organization-ownership-ceremony-open.md b/.changeset/24581-organization-ownership-ceremony-open.md
new file mode 100644
index 00000000000..16f888daa70
--- /dev/null
+++ b/.changeset/24581-organization-ownership-ceremony-open.md
@@ -0,0 +1,30 @@
+---
+'@objectstack/cli': minor
+'@objectstack/runtime': minor
+'@objectstack/spec': minor
+'@objectstack/driver-sql': minor
+'@objectstack/driver-turso': minor
+'@objectstack/objectql': minor
+'@objectstack/plugin-webhooks': minor
+'@objectstack/platform-objects': minor
+'@objectstack/service-automation': minor
+'@objectstack/service-settings': minor
+---
+
+feat(cli,runtime)!: `os migrate organization-ownership` upgrades a database 17.x or an 18.0 pre-release created; a server serves only a database it created or one the ceremony certified
+
+Clause-②: yes (narrowing)
+
+
+
+**BREAKING** for a database with no birth marker — one 17.x or an 18.0 pre-release (`18.0.0-next.*`) created — shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D10).
+
+- **The ceremony is open.** `os migrate organization-ownership` runs again (the 18.0 pre-releases refused it with `NOT_IMPLEMENTED`): a bare run writes the plan, and `--apply --plan-file PLAN --backup-taken` applies that reviewed plan. Its new steps carry what an 18.0 pre-release database still owes, each selected by the rows' shape, never by a NULL organization:
+ - `sibling-moves`: the global settings-audit rows move to `sys_platform_setting_audit`, and identity mail (reset, verification, account invitation, change-email notice, magic link) to `sys_identity_email`, id for id. Their queued mail jobs and their activity rows follow them. The organization invitation stays in `sys_email`.
+ - `declared-webhooks`: each `sys_webhook` copy a package's seeder wrote under a name a package still declares is settled. A pristine copy is deleted once its deliveries name the declaration (`declared:NAME`), with the cipher it referenced. A copy customized only in its switch becomes the declaration's activation row. Any other edit becomes the administrator's own webhook under a new name (`NAME_custom`), and the declaration is switched off.
+ - `declared-rule-copies`: each stored sharing rule under a declared rule's name becomes a clone under a new name, its grants kept, and the declaration is switched off.
+ - `not-null`: after the column drops, every table the post-step plan clears takes `organization_id` NOT NULL. The ceremony owns that nullability, so the schema differ no longer reports it as `relax_not_null` drift.
+- **The boot guard decides on a marker.** The first boot that creates a datastore from empty writes a birth marker (`sys_migration` row `adr-0131-organization-ownership`, `details.birth.shapeGeneration`). Every CLI data boot writes it too, so a fresh install whose first command is `os migrate apply` or `os seed` still boots. On the Turso remote face (`libsql://`), `getSchemaSyncStats` now answers what its schema doors created and found (it answered `{ created: 0, existing: 0 }` before), so a fresh remote database is observed as one and gets its marker. The ceremony's verified marker carries the same `shapeGeneration` (now `1`, `ORGANIZATION_OWNERSHIP_SHAPE_GENERATION` in `@objectstack/spec/system`). A server refuses a database with no marker (one 17.x or an 18.0 pre-release created), one certified at an older generation, or an uncertified one. The refusal names the command and no longer says to stay on 17.x. The v17-shape probe also reads `tenancy: { enabled: false }` and `systemFields: false`.
+- **`sys_secret` and `sys_flow_credential` are deployment-level** (`systemFields: { tenant: false }`). Their `organization_id` is dropped by the ceremony's column-drops step. The settings service no longer writes one, and `SettingsSecretStore.insert` no longer takes it. With no organization column, their organization-less system inserts land under a walled posture on the engine's own rule, so `@objectstack/objectql` no longer exports `WALLED_ORGANIZATION_LESS_BRIDGE` or `isWalledOrganizationLessBridgeObject`, and the system-write refusal's remedy names `systemFields: { tenant: false }`.
+
+**What an operator does.** Fresh installs are supported under every tenancy posture and need nothing. Before serving a database 17.x or an 18.0 pre-release created with this release: start this release against it once (the boot is refused, and its schema sync has already created the tables this release adds, which the ceremony moves rows into), take a backup, run `os migrate organization-ownership --out plan.json`, review the plan, then run `os migrate organization-ownership --apply --plan-file plan.json --backup-taken`, and start the server. `os dev` does this for a local SQLite development database. On a walled database, a customized copy of a declared webhook or sharing rule blocks its step, naming the row: rename it to a name no package declares, or delete it, then re-run.
diff --git a/.changeset/24637-spec-upgrade-entries-fresh-install-only.md b/.changeset/24637-spec-upgrade-entries-fresh-install-only.md
deleted file mode 100644
index 6107e9ce91e..00000000000
--- a/.changeset/24637-spec-upgrade-entries-fresh-install-only.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@objectstack/spec': patch
----
-
-fix(spec): the 18.0 upgrade-guide entries that name `os migrate organization-ownership` say first that 18.0 supports fresh installs only
-
-Clause-②: no
-
-In 18.0, `os migrate organization-ownership` refuses plan and apply alike on every database (exit 1, `NOT_IMPLEMENTED`, nothing read or written). Seven ADR-0087 semantic entries still told an operator to run `--out PLAN`, then `--apply --plan-file PLAN`, as if 18.0 upgraded a v17 database. Each now says, in the same words as the CLI and the boot refusal: 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path. The plan and apply steps stay, marked as the 18.x release's ceremony.
-
-- `sys-metadata-organization-column-retired`, `sys-metadata-audit-organization-column-retired`, `sys-metadata-commit-organization-column-retired`, `sys-metadata-history-organization-column-retired`, `ups-permission-set-id-column-retired`: the replacement and the acceptance criteria.
-- `sharing-rule-declared-mirror-retired`: the replacement and the acceptance criteria.
-- `cli-organization-ownership-apply-plan-file-required`: the replacement now says that 18.0 refuses this invocation, as it refuses every invocation of the command, and keeps the plan-file rule as the 18.x release's.
-
-Only the text changes. Every entry keeps its `id`, its surface and its step.
diff --git a/.changeset/24651-boot-refusal-fresh-installs-only.md b/.changeset/24651-boot-refusal-fresh-installs-only.md
deleted file mode 100644
index a3a38f2f744..00000000000
--- a/.changeset/24651-boot-refusal-fresh-installs-only.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@objectstack/runtime': patch
----
-
-fix(runtime): the organization-ownership boot refusals say what 18.0 supports for a v17 database and name no step of `os migrate organization-ownership`, which 18.0 refuses on every database
-
-Clause-②: no (refusal text only: the refusal code `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`, the refused tables and the boot outcome do not move)
-
-In 18.0, `os migrate organization-ownership` refuses plan and apply on every database (`NOT_IMPLEMENTED`): 18.0 supports fresh installs only. Three branches of the v18 boot refusal still told the operator to run that command's plan and its `--apply --plan-file PLAN --backup-taken`, so following them led to a second refusal.
-
-Each branch now closes on the sentence `os migrate apply`'s drop gate and `os dev` already use: "18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path, and upgrade it there."
-
-- **A table still in the v17 shape** (an object that declares no organization column, over a table that still carries `organization_id`). This branch already said that 18.0 does not upgrade a v17 database. It no longer follows that with the command's plan and apply steps.
-- **No table left in the v17 shape, and the ceremony's marker is not verified.** The refusal no longer prescribes a re-plan and a re-apply, which 18.0 cannot run.
-- **No table left in the v17 shape, and the marker could not be read.** The refusal now says to fix the read and start again: a database no ceremony ran on boots once the read answers. It no longer prescribes a re-plan and a re-apply.
-- **The column read itself failed.** The refusal still says to fix the read. It no longer offers the command as the alternative.
-
-A development boot (`OrganizationOwnershipBootGuardPlugin({ dev: true })`, as `os serve --dev` composes it) says "a v17 development database" in place of "a v17 database". Before, it named the command with `--dev`.
diff --git a/.changeset/24652-report-v18-upgrade-text.md b/.changeset/24652-report-v18-upgrade-text.md
deleted file mode 100644
index 49839d91ac2..00000000000
--- a/.changeset/24652-report-v18-upgrade-text.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@objectstack/plugin-security': patch
----
-
-fix(plugin-security): the boot report's unnamed-grant line says what 18.0 supports for a v17 database, not a command 18.0 refuses
-
-Clause-②: no
-
-- **The error line for `sys_user_permission_set` grants whose `permission_set` is empty** used to end with a fix: plan with `os migrate organization-ownership --out plan.json`, then run `os migrate organization-ownership --apply --backup-taken --plan-file plan.json`, restart, with a warning that granting the set again by name before it runs leaves a second grant for the ceremony to report. 18.0 refuses that command on every database, so the operator was sent to a refusal. The line now ends with the sentence the drop-gate refusal of `os migrate apply --allow-destructive` carries: "18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path."
-- **Unchanged:** the count, the "permission_set is empty" fact, the ADR-0131 D4 reason ("each confers nothing"), "Nothing was changed.", the structured meta, the log level and the report's result. Such a grant still confers nothing and is still listed.
-- **Removed from the line:** the two command steps and the restart step, and the advice about granting the set again by name. They described the 18.x upgrade ceremony, which 18.0 does not carry.
diff --git a/.changeset/24709-platform-admin-withheld-18-text.md b/.changeset/24709-platform-admin-withheld-18-text.md
deleted file mode 100644
index 2dc210351ba..00000000000
--- a/.changeset/24709-platform-admin-withheld-18-text.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@objectstack/plugin-security': patch
----
-
-fix(plugin-security): the platform-admin promotion WITHHELD warning says what 18.0 supports for a v17 database, not a migration 18.0 refuses
-
-Clause-②: no
-
-- **The boot warning "[security] platform-admin promotion WITHHELD: an unscoped grant exists whose permission-set name (sys_user_permission_set.permission_set) was never written"** used to end with a way on: "The organization-ownership migration's id-to-name rewrite (ADR-0131 D10) names it; the next pass after that reads it by name." 18.0 refuses that migration on every database, so the operator was sent to a refusal. The warning now ends with the sentence the boot refusal and the drop-gate refusal of `os migrate apply --allow-destructive` carry: "18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path."
-- **Unchanged:** the withholding itself (an unscoped grant that names no set is still read as an administrator who may already exist, and no second platform-admin grant is minted), the facts the warning states (which column was never written, that the grant predates it, that the set it holds can no longer be read), the log level and the `info` fallback when the logger has no `warn`, and the returned `admin_grant_unnamed` result.
-- **Removed from the warning:** the pointer to the organization-ownership migration's rewrite and the promise that the next pass reads the grant by name afterwards. Both described the 18.x upgrade path, which 18.0 does not carry.
diff --git a/.changeset/24710-ownership-refusal-text-fresh-installs-only.md b/.changeset/24710-ownership-refusal-text-fresh-installs-only.md
deleted file mode 100644
index a8afad52c74..00000000000
--- a/.changeset/24710-ownership-refusal-text-fresh-installs-only.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@objectstack/metadata-core': patch
-'@objectstack/metadata-protocol': patch
----
-
-fix(metadata-core, metadata-protocol): two refusals say what 18.0 supports for a v17 database, not `os migrate organization-ownership`, which 18.0 refuses on every database
-
-Clause-②: no (text only: no refusal code, outcome, accept set or served shape moves)
-
-In 18.0, `os migrate organization-ownership` refuses plan and apply on every database (`NOT_IMPLEMENTED`): 18.0 supports fresh installs only. Two refusals still told the operator to run it, so following them led to a second refusal.
-
-- **`LEGACY_ORGANIZATION_ROWS_UNREADABLE`** (`LegacyOrganizationRowsUnreadable`, `@objectstack/metadata-core`). It used to close with "Run `os migrate organization-ownership` on this database first." This read runs on every SQL database, a fresh 18.0 one included, and several of its refusals are the driver's (a dialect it cannot name, no raw-SQL seam, a column list that is not a list or comes back empty). For those, the command was never the remedy. The message now closes: "A fresh 18.0 install holds no legacy organization rows: there, the cause is the one named above, and the fix is in the SQL driver or the caller that met it. A database that does hold them is a v17 one. 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path."
-- **The seed refusal for a natural key held by a row with no organization** (`SeedLoaderService`, `@objectstack/metadata-protocol`). It used to close with "Attribute the unowned rows first (`os migrate organization-ownership`, then the same command with --apply), then re-run this seed". Even before 18.0, that bare `--apply` was refused, since the command requires `--plan-file`. The message now closes: "A row with no organization predates organization stamping, so it is a v17 database's. 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path."
-- **Unchanged:** both refusal codes, "Nothing was listed or written.", the seed refusal's opening sentences (what was refused and why), its `errored` count, and the dropped record.
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index a6ac802cfea..6b7f4c3f059 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -1081,6 +1081,18 @@ jobs:
continue-on-error: true
run: pnpm check:watch-hint-literal
+ # No shipped refusal, upgrade text or pending changeset says the
+ # organization-ownership ceremony is closed (#24581). The 18.0
+ # pre-releases refused the command and taught a family of surfaces to say
+ # "fresh installs only / keep it on 17.x"; the ceremony is open, and one
+ # such sentence left anywhere sends an operator away from the command
+ # that upgrades their database. Tests are out of the population (they
+ # spell the sentence to pin its absence). Repo-wide text sweep; ~1s.
+ - name: No shipped text says the ownership ceremony is closed
+ id: closed-ceremony-text
+ continue-on-error: true
+ run: pnpm check:closed-ceremony-text
+
# ADR-0087 D4's per-release correctness gate (#17080). The REAL run needs
# two published tarballs and npm, so it lives in the release lane and
# cannot run on a PR; what runs here is its `--self-test`, which is
diff --git a/content/docs/automation/webhooks.mdx b/content/docs/automation/webhooks.mdx
index 2b0499a3c0d..5b642798588 100644
--- a/content/docs/automation/webhooks.mdx
+++ b/content/docs/automation/webhooks.mdx
@@ -100,7 +100,7 @@ A webhook reaches the dispatcher one of two ways:
- **Authored by an organization** — a `sys_webhook` row (§3.1), switched by its
own `active` field.
-On a database an 18.0 install created, the rows that release's boot seeder
+On a database an 18.0 pre-release (`18.0.0-next.*`) created, the rows that release's boot seeder
wrote for declared webhooks (`managed_by: 'package'`) are still there. Until the
ownership migration removes them, a pristine one is ignored in favour of its
declaration, so the webhook is never delivered twice; one an administrator
diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx
index a3f1b64ef78..a562fd41b0d 100644
--- a/content/docs/concepts/metadata-lifecycle.mdx
+++ b/content/docs/concepts/metadata-lifecycle.mdx
@@ -106,7 +106,7 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m
**Most metadata types must not be overlaid by an environment** — overlaying a packaged item would break the physical schema or the behaviour its package ships. The whitelist lives in **one** place: `MetadataTypeRegistryEntry.allowEnvironmentOverlay` in `packages/spec/src/kernel/metadata-plugin.zod.ts` (spelled `allowOrgOverride` before protocol 18; `GET /api/v1/meta/types` serves it per entry as `allowEnvironmentOverlay`).
-> **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (since ADR-0131 D7 the `sys_metadata` family declares no organization column) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). The protocol itself refuses every organization-scoped write, and every read — item, list, layers, cached, history, diff, audit, drafts and the commit timeline — is environment → code, whatever organization a caller names. An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is served by no read, and on a SQL database (SQLite, PostgreSQL, MySQL) still holding one a v18 `os serve` refuses to start. 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path. In that release, the v18 migration ceremony (ADR-0131 C7: `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN`) carries such a row to the environment layer and drops the column; nothing deletes or rewrites it before then. The anonymous form doors read the environment alone too: in that release the ceremony carries every organization-layer public-form withdrawal onto the environment, fail-closed. The table still records which types take an overlay of a shipped item.
+> **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (since ADR-0131 D7 the `sys_metadata` family declares no organization column) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). The protocol itself refuses every organization-scoped write, and every read — item, list, layers, cached, history, diff, audit, drafts and the commit timeline — is environment → code, whatever organization a caller names. An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is served by no read, and on a SQL database (SQLite, PostgreSQL, MySQL) still holding one a v18 `os serve` refuses to start until the v18 migration ceremony (ADR-0131 C7: `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN`) has carried such a row to the environment layer and dropped the column; nothing deletes or rewrites it before then. The anonymous form doors read the environment alone too: the ceremony carries every organization-layer public-form withdrawal onto the environment, fail-closed. The table still records which types take an overlay of a shipped item.
| Type | `allowEnvironmentOverlay` | Rationale |
| :--- | :---: | :--- |
diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx
index 7d2d4adba97..8227b1fcfb2 100644
--- a/content/docs/deployment/cli.mdx
+++ b/content/docs/deployment/cli.mdx
@@ -196,24 +196,13 @@ os dev --database file:./data/test.db --auth-secret $(openssl rand -hex 32)
| `--seed-admin` / `--no-seed-admin` | — | Seed a dev admin (`admin@objectos.ai` / `admin123`) on an empty DB — default on; override with `--admin-email` / `--admin-password` |
| `-v, --verbose` | — | Verbose output |
-##### A 17.x development database
+##### A 17.x or 18.0 pre-release development database is upgraded in place
-A v18 server refuses to start on a database still in the v17
-organization-ownership shape until the [ownership ceremony](#os-migrate-organization-ownership)
-has run on it.
-
-
-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
-refusal and changes nothing.
-18.0 does not upgrade a v17 database: keep a v17 database on a 17.x release
-until the 18.x release that carries the upgrade path. For a local SQLite file,
-`os dev` in 18.0 still takes step 1's copy first and prints where it is,
-beside the ceremony's `NOT_IMPLEMENTED` refusal; any other database gets no
-copy and the same answer.
-
-
-In the 18.x release that carries the upgrade path, `os dev` runs that ceremony
-for you when the database is a local SQLite file:
+Fresh installs are supported under every tenancy posture. A v18 server
+refuses to start on a database with no birth marker, which 17.x and the 18.0
+pre-releases (`18.0.0-next.*`) created, until the [ownership ceremony](#os-migrate-organization-ownership)
+has run on it. `os dev` runs that ceremony for you when the database is a local
+SQLite file:
1. It copies the database file and its `telemetry` sibling (each with its
`-wal` / `-shm`) into `/backups/organization-ownership-/`.
@@ -222,14 +211,14 @@ for you when the database is a local SQLite file:
beside the copy.
3. It prints the ceremony's warnings and starts the server again.
-In that release, if a step blocks, or the post-check still holds rows (a row whose owner cannot
+If a step blocks, or the post-check still holds rows (a row whose owner cannot
be derived, named per table with its remedy), `os dev` stops and prints what
holds it, where the copy is, and the command that resumes the run. It tries once per session. A database that is
not a local SQLite file (PostgreSQL, a remote libSQL, `:memory:`) is not
upgraded in place: run the ceremony yourself.
-`os serve` and `os start` never run the ceremony: they refuse to start, and on
-a v17 database their 18.0 refusal says that 18.0 does not upgrade it.
+`os serve` and `os start` never run the ceremony: they refuse to start, and the
+refusal names the command.
##### Serving dev over https
@@ -1040,12 +1029,9 @@ safe. No flag skips these refusals:
`sys_metadata` family among them. Dropping one first would turn every legacy
organization row into an environment row. Under `--json` the refusal answers
`message: 'refused_organization_ownership_unmigrated'` and lists the `tables`.
- 18.0 itself never records a verified run, so on a v17 database this refusal
- holds on every run. 18.0 supports fresh installs only and does not upgrade a
- v17 database: keep a v17 database on a 17.x release until the 18.x release
- that carries the upgrade path. In that release, the ceremony's
- `os migrate organization-ownership --apply --plan-file FILE` run drops these
- columns itself, in its column-drops step, before it records the run verified.
+ The ceremony's `os migrate organization-ownership --apply --plan-file FILE`
+ run drops these columns itself, in its column-drops step, before it records
+ the run verified.
Each refusal holds the whole `--allow-destructive` run: nothing in it is
created, applied or dropped, the run's other changes included. A run without
@@ -1216,7 +1202,7 @@ where the data lives.
| `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 |
| `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 |
| `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 |
-| `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) |
+| `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. It upgrades a database 17.x or an 18.0 pre-release (`18.0.0-next.*`) created; a v18 server refuses to start on one until it has run. See [below](#os-migrate-organization-ownership) |
| `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 |
| `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) |
| `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 |
@@ -1521,18 +1507,32 @@ upgrading rather than wait for that.
#### `os migrate organization-ownership`
-The v18 ownership ceremony (ADR-0131 D10). A v18 `os serve` / `os start` refuses
-to start on a database still in the v17 organization-ownership shape until this
-has run. In the 18.x release that carries the upgrade path, `os dev` runs it on
-its own for a local SQLite database (see [`os dev`](#os-dev)).
-
-
-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
-`NOT_IMPLEMENTED` (under `--json`, `code` beside `error`) and reads and
-writes nothing. Keep a v17 database on a 17.x release until the 18.x release
-that carries the upgrade path. The invocations below, and what the list after
-them says the ceremony does, are that 18.x release's.
-
+The v18 ownership ceremony (ADR-0131 D10), and the upgrade path of a database
+17.x or an 18.0 pre-release (`18.0.0-next.*`) created.
+Fresh installs are supported under every tenancy posture and never need it.
+A v18 `os serve` / `os start` serves a database this release created itself, or one this ceremony
+certified; it refuses any other until the ceremony has run. Under `os dev` it
+runs on its own for a local SQLite database (see [`os dev`](#os-dev)).
+
+How a database is recognised: the first boot that creates a datastore from
+empty writes a **birth marker** into `sys_migration` (row
+`adr-0131-organization-ownership`) naming the ownership shape generation it was
+born in. The ceremony writes the same row, verified, when its post-check
+passes. A server decides on that generation alone: a database with no birth
+marker, which 17.x and the 18.0 pre-releases created, or one a shorter ceremony
+certified at an older generation, is refused, and the refusal names this
+command.
+
+The sequence on an existing database:
+
+1. Start this release against it once (`os serve`). The boot is refused, but
+ its schema sync has already created the tables this release adds, the
+ sibling logs the ceremony moves rows into among them. A plan drawn before
+ that boot blocks its `sibling-moves` step, naming the missing table and this
+ remedy.
+2. Take a backup.
+3. Write the plan, review it, then apply that reviewed plan (below).
+4. Start the server again.
```bash
os migrate organization-ownership --out plan.json # the plan: reads only
diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx
index ffc0d6cdc49..4f17d88f413 100644
--- a/content/docs/deployment/tenancy-modes.mdx
+++ b/content/docs/deployment/tenancy-modes.mdx
@@ -16,10 +16,12 @@ it generalizes the two-valued *tenancy mode* of
whose `tenancy` service, membership lifecycle and degraded-tenancy boot guard all
still apply — widened to the three postures.
-
+
Fresh installs are supported under every posture: `single`, `group` and
-`isolated`. The upgrade path for an existing database arrives in a later 18.x
-release, and a database an 18.0 install created takes the same path as a v17 one.
+`isolated`. A database with no birth marker, which 17.x and the 18.0
+pre-releases (`18.0.0-next.*`) created, is refused at boot until
+[`os migrate organization-ownership`](/docs/deployment/cli#os-migrate-organization-ownership)
+has upgraded it.
diff --git a/content/docs/kernel/contracts/metadata-service.mdx b/content/docs/kernel/contracts/metadata-service.mdx
index c082d2d5b3d..4e2ddfd6eb2 100644
--- a/content/docs/kernel/contracts/metadata-service.mdx
+++ b/content/docs/kernel/contracts/metadata-service.mdx
@@ -415,13 +415,10 @@ read is environment → code, whatever organization a caller names. An overlay
row an earlier release stored under an organization is served by no read. On a
SQL database (SQLite, PostgreSQL, MySQL) still holding one, a v18 `os serve`
refuses to start, because the `sys_metadata` family there still carries its
-`organization_id` column. 18.0 supports fresh installs only and does not
-upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x
-release that carries the upgrade path. In that release, the v18 migration
-ceremony (ADR-0131 C7) carries such a row to the environment layer. The
-anonymous form doors read the environment alone too; in that release the
-ceremony carries every organization-layer public-form withdrawal onto the
-environment, fail-closed. The per-user,
+`organization_id` column, until the v18 migration ceremony (ADR-0131 C7) has
+carried such a row to the environment layer. The anonymous form doors read the
+environment alone too; the ceremony carries every organization-layer
+public-form withdrawal onto the environment, fail-closed. The per-user,
per-field patch overlay a previous revision of this page taught here was
removed in #13135 — it was never served by any route.
diff --git a/content/docs/permissions/capabilities.mdx b/content/docs/permissions/capabilities.mdx
index 847f32c0bb1..2e9220c786f 100644
--- a/content/docs/permissions/capabilities.mdx
+++ b/content/docs/permissions/capabilities.mdx
@@ -217,10 +217,7 @@ and resource `requiredPermissions` match capability names **as strings**. The
boot writes no row for a capability, declared or curated: both are served by
the registry. The `sys_capability` object itself is retired and no longer
registered (ADR-0131 D3): a row an older database still holds is served by no
-route and read by nothing. In the 18.x release that carries the upgrade path
-(18.0 does not upgrade a v17 database: see
-[`os migrate organization-ownership`](/docs/deployment/cli#os-migrate-organization-ownership)),
-`os migrate organization-ownership --apply` reports the authored rows and
+route and read by nothing. [`os migrate organization-ownership --apply`](/docs/deployment/cli#os-migrate-organization-ownership) reports the authored rows and
deletes the seeded copies (ADR-0131 D10).
Withdraw a capability by withdrawing the **grant**.
diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx
index f3710de1cc1..a0a2e189b8f 100644
--- a/content/docs/permissions/permission-sets.mdx
+++ b/content/docs/permissions/permission-sets.mdx
@@ -396,10 +396,7 @@ posture, the platform operator. This has three author-visible consequences:
package. Uninstall the owning package to remove a packaged set entirely.
An older database keeps the `sys_permission_set` table and its rows, and
-nothing serves or reads them. In the 18.x release that carries the upgrade
-path (18.0 does not upgrade a v17 database: see
-[`os migrate organization-ownership`](/docs/deployment/cli#os-migrate-organization-ownership)),
-`os migrate organization-ownership --apply`
+nothing serves or reads them. [`os migrate organization-ownership --apply`](/docs/deployment/cli#os-migrate-organization-ownership)
(ADR-0131 D10) converts the sets an organization authored in Setup into
environment metadata under `single` and deletes the seeded copies. The shipped
sets that an install created before 2026-08-15 stored as `managed_by: 'admin'`
diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx
index 178df51d814..7dad531e931 100644
--- a/content/docs/references/index.mdx
+++ b/content/docs/references/index.mdx
@@ -1,7 +1,7 @@
---
title: Protocol reference — every schema by module
navTitle: Protocol Reference
-description: Every schema published by @objectstack/spec — 1541 schemas across 14 protocol modules
+description: Every schema published by @objectstack/spec — 1542 schemas across 14 protocol modules
---
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -32,9 +32,9 @@ counts are sums of the rows they head. Regenerate with
| [Security Protocol](/docs/references/security) | 5 | 31 | Permission sets, row-level security, sharing rules, tenancy posture. |
| [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
-| [System Protocol](/docs/references/system) | 36 | 284 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
+| [System Protocol](/docs/references/system) | 36 | 285 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 169 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
-| **Total** | **197** | **1541** | 14 protocol modules |
+| **Total** | **197** | **1542** | 14 protocol modules |
---
@@ -317,7 +317,7 @@ Studio designer metadata — the authoring surfaces for the protocols above.
## System Protocol
-**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **36 pages, 284 schemas**
+**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **36 pages, 285 schemas**
The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance.
@@ -344,7 +344,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a
| [`logging.zod.ts`](/docs/references/system/logging) | `ConsoleDestinationConfig`, `ExtendedLogLevel`, `ExternalServiceDestinationConfig`, `FileDestinationConfig`, `HttpDestinationConfig`, `LogDestination`, `LogDestinationType`, `LogEnrichmentConfig`, `LogEntry`, `LogFormat`, `LogLevel`, `LoggerConfig`, `LoggingConfig`, `StructuredLogEntry` |
| [`metadata-persistence.zod.ts`](/docs/references/system/metadata-persistence) | `MetadataCollectionInfo`, `MetadataDiffResult`, `MetadataFallbackStrategy`, `MetadataFormat`, `MetadataHistoryQueryOptions`, `MetadataHistoryQueryResult`, `MetadataHistoryRecord`, `MetadataHistoryRetentionPolicy`, `MetadataLoadOptions`, `MetadataLoadResult`, `MetadataLoaderContract`, `MetadataManagerConfig`, `MetadataRecord`, `MetadataSaveOptions`, `MetadataSaveResult`, `MetadataScope`, `MetadataSource`, `MetadataState`, `MetadataStats`, `MetadataWatchEvent`, `PackagePublishResult` |
| [`metrics.zod.ts`](/docs/references/system/metrics) | `HistogramBucketConfig`, `MetricAggregationConfig`, `MetricAggregationType`, `MetricDataPoint`, `MetricDefinition`, `MetricExportConfig`, `MetricLabels`, `MetricType`, `MetricUnit`, `MetricsConfig`, `ServiceLevelIndicator`, `ServiceLevelObjective`, `TimeSeries`, `TimeSeriesDataPoint` |
-| [`migration.zod.ts`](/docs/references/system/migration) | `AddFieldOperation`, `ChangeSet`, `CreateObjectOperation`, `DataMigrationFlag`, `DeleteObjectOperation`, `ExecuteSqlOperation`, `MigrationDependency`, `MigrationJournalEvent`, `MigrationOperation`, `ModifyFieldOperation`, `RemoveFieldOperation`, `RenameObjectOperation` |
+| [`migration.zod.ts`](/docs/references/system/migration) | `AddFieldOperation`, `ChangeSet`, `CreateObjectOperation`, `DataMigrationFlag`, `DeleteObjectOperation`, `ExecuteSqlOperation`, `MigrationDependency`, `MigrationJournalEvent`, `MigrationOperation`, `ModifyFieldOperation`, `OrganizationOwnershipBirthMarker`, `RemoveFieldOperation`, `RenameObjectOperation` |
| [`notification.zod.ts`](/docs/references/system/notification) | `NotificationChannel` |
| [`object-storage.zod.ts`](/docs/references/system/object-storage) | `AccessControlConfig`, `BucketConfig`, `FileMetadata`, `LifecycleAction`, `LifecyclePolicyConfig`, `LifecyclePolicyRule`, `MultipartUploadConfig`, `ObjectMetadata`, `ObjectStorageConfig`, `PresignedUrlConfig`, `StorageAcl`, `StorageClass`, `StorageConnection`, `StorageProvider`, `StorageScope` |
| [`registry-config.zod.ts`](/docs/references/system/registry-config) | `RegistryConfig`, `RegistrySyncPolicy`, `RegistryUpstream` |
diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx
index 63ac5b26c39..04854497121 100644
--- a/content/docs/references/system/migration.mdx
+++ b/content/docs/references/system/migration.mdx
@@ -27,8 +27,8 @@ irreversibly on migrated data gate on the flag instead of the version.
## TypeScript Usage
```typescript
-import { AddFieldOperation, ChangeSetSchema, CreateObjectOperation, DataMigrationFlagSchema, DeleteObjectOperation, ExecuteSqlOperation, MigrationDependencySchema, MigrationJournalEventSchema, MigrationOperationSchema, ModifyFieldOperation, RemoveFieldOperation, RenameObjectOperation } from '@objectstack/spec/system';
-import type { ChangeSet, DataMigrationFlag, DeleteObjectOperation, ExecuteSqlOperation, MigrationDependency, MigrationJournalEvent, MigrationOperation, ModifyFieldOperation, RemoveFieldOperation, RenameObjectOperation } from '@objectstack/spec/system';
+import { AddFieldOperation, ChangeSetSchema, CreateObjectOperation, DataMigrationFlagSchema, DeleteObjectOperation, ExecuteSqlOperation, MigrationDependencySchema, MigrationJournalEventSchema, MigrationOperationSchema, ModifyFieldOperation, OrganizationOwnershipBirthMarkerSchema, RemoveFieldOperation, RenameObjectOperation } from '@objectstack/spec/system';
+import type { ChangeSet, DataMigrationFlag, DeleteObjectOperation, ExecuteSqlOperation, MigrationDependency, MigrationJournalEvent, MigrationOperation, ModifyFieldOperation, OrganizationOwnershipBirthMarker, RemoveFieldOperation, RenameObjectOperation } from '@objectstack/spec/system';
// Validate data
const result = AddFieldOperation.parse(data);
@@ -728,6 +728,22 @@ Modify properties of an existing field
| **changes** | `Record` | ✅ | Partial field definition updates |
+---
+
+## OrganizationOwnershipBirthMarker
+
+Birth marker of a datastore created from empty: the ownership shape generation it was born in
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **shapeGeneration** | `integer` | ✅ | The organization-ownership shape generation the store was created in; the only field the boot guard decides on |
+| **platformVersion** | `string` | ✅ | The exact platform version that created the store (provenance only, never compared) |
+| **commit** | `string` | optional | The source commit of that build, when known (provenance only) |
+| **createdAt** | `string` | ✅ | When the guard observed the store created from empty |
+
+
---
## RemoveFieldOperation
diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md
index 6aaedaaf246..fee2a67d822 100644
--- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md
+++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
| Dir | Sites |
|---|---|
-| `system/` | 374 |
+| `system/` | 375 |
diff --git a/docs/qa/platform-checklist/areas/cli.json b/docs/qa/platform-checklist/areas/cli.json
index f5d45a286b3..b85f263afc3 100644
--- a/docs/qa/platform-checklist/areas/cli.json
+++ b/docs/qa/platform-checklist/areas/cli.json
@@ -1871,10 +1871,10 @@
},
{
"id": "cli.organization-ownership-ceremony",
- "title": "`os migrate organization-ownership` in 18.0, which supports fresh installs only: plan and apply alike refuse NOT_IMPLEMENTED before reading anything and leave the database unchanged, and `os migrate apply --allow-destructive` still withholds every organization_id drop on a v17-shaped database; the ceremony class the refusal keeps underneath (plan, refusals, checkpoints, drop gate) and `os migrate comment-reactions --apply` stay pinned — the v17 → v18 ceremony itself ships in an 18.x release",
+ "title": "`os migrate organization-ownership` upgrades a database 17.x or an 18.0 pre-release (18.0.0-next.*) created (18.x): a bare run writes the reviewed plan and changes nothing; `--apply --plan-file PLAN --backup-taken` runs every step — attribution through the moves, the declared webhook and sharing-rule settlements, the column drops and NOT NULL — and certifies the marker at this release's ownership shape generation; the served boot refuses an uncertified database, naming the command, and boots a certified one; `os migrate apply --allow-destructive` still withholds every organization_id drop the ceremony owns, and `os migrate comment-reactions --apply` stays pinned",
"since": "v18",
"status": "active",
- "revision": 2,
+ "revision": 3,
"priority": "P0",
"surface": "cli",
"personas": [
@@ -1887,55 +1887,62 @@
"a COPY of a v17-shaped SQLite DB (platform-core.v18-boot-refusals names how to get one) — never the original"
],
"knownGaps": [
- "WITHDRAWN IN 18.0 and deliberately not scored here: the ceremony's own arms — the plan file a bare run writes, its plan-file-required / plan-*-mismatch / database-busy refusals, the sys_migration adr-0131-organization-ownership checkpoint a reviewed apply writes, and a migrated copy booting under v18 — are not clauses of this revision. QA run #24551: \"ruling records 6259608309 (item 1) and 6259698357 then cut 18.0 to fresh installs on the single posture\" — carried, the record goes on, by the changeset 15212-180-guards-fresh-single-only (consumed into 18.0.0; source lists it with the others), the ADR-0131 release-scope note and organization-ownership.ts; the registered command (packages/cli/src/commands/migrate/organization-ownership.ts#MigrateOrganizationOwnership) refuses before the ceremony runs. A runner scores none of these arms — neither `fail` (QA run #24551's reading of revision 1) nor `skipped` (QA run #24556's, c1/c2/c4): they are not on the item. The 18.x release that ships the upgrade path restores them as clauses in the revision that re-registers the ceremony; their revision-1 wording is in this file's git history",
- "acceptance[3] scores the drop gate's exit code, `message` and `tables`, not its prose: that prose still prescribes `os migrate organization-ownership --out PLAN` and `--apply --plan-file PLAN`, which 18.0 refuses — a product defect tracked on #24567, not a fail of this clause"
+ "a walled database (group or isolated posture) holding a CUSTOMIZED copy of a declared webhook or sharing rule blocks at its settlement step, by design: the declaration's switch is deployment-wide, so the ceremony does not switch it off for every organization on the operator's behalf — that arm is scored by the unit pins (organization-ownership-apply.generation-1.test.ts), not by a step here"
]
},
"steps": [
- "`os migrate organization-ownership --database-url file:COPY` on the v17-shaped copy, plain and with `--json`; checksum the DB before and after, and list the working directory for organization-ownership-plan-*.json",
- "`--apply` without --plan-file; `--apply --plan-file P --backup-taken` with P any path (18.0 writes no plan, and the refusal reads none); `--apply` with the DB held open by a second process; each with `--json` too",
- "`os migrate apply --allow-destructive --yes --json` on the v17-shaped copy; checksum the DB before and after",
+ "`os migrate organization-ownership --database-url file:COPY --out /tmp/RUN/plan.json` on the v17-shaped copy, in the showcase's directory; checksum the DB before and after",
+ "`--apply` without --plan-file; then `--apply --plan-file /tmp/RUN/plan.json --backup-taken --out /tmp/RUN/apply.json --json`; then read `SELECT verified_at, blocking, details FROM sys_migration WHERE id = 'adr-0131-organization-ownership'`",
+ "boot the showcase (`os serve`) over the migrated copy, and over a second, unmigrated copy",
+ "`os migrate apply --allow-destructive --yes --json` on a fresh unmigrated v17-shaped copy; checksum the DB before and after",
"`os migrate comment-reactions --apply` twice"
],
"acceptance": [
{
- "clause": "the 18.0 release refusal of the registered command, and the ceremony class it keeps whole underneath (plan, refusals, checkpoints, drop gate, reactions conversion) — the permanent pins pass at the run subject sha (RUNNER rule 6: cite their output, never re-derive by hand)",
+ "clause": "the ceremony and the guard — plan, refusals, checkpoints, the generation-1 steps, the drop gate, the birth marker and the reactions conversion — the permanent pins pass at the run subject sha (RUNNER rule 6: cite their output, never re-derive by hand)",
"oracle": "test",
"verify": "run each file named in automated.ref with its package's vitest (`pnpm --filter PKG exec vitest run --maxWorkers=2 FILE`, never through a bare `--`); every named file reports ≥1 test and 0 failed. A file that reports 0 tests or is not found is NOT a pass — it is a stale ref, fix the item",
"evidence": "the vitest summary line per pin file, with the package name and the subject sha"
},
{
- "clause": "a bare run (plan mode, plain or `--json`) is refused before it reads anything: exit 1 with NOT_IMPLEMENTED — `--json` answers {error: plan_refused, reason: upgrade-unavailable, code: NOT_IMPLEMENTED, detail}, the plain form prints the refusal with `(NOT_IMPLEMENTED)` — no organization-ownership-plan-*.json is written, and the DB checksum is unchanged",
+ "clause": "a bare run writes the plan file and changes nothing: exit 0, the plan JSON names every table's fate, and the DB checksum is unchanged; `--apply` without --plan-file is refused before the boot (exit 1, plan-file-required)",
"oracle": "build",
- "verify": "exit code + the JSON object (or the printed refusal) per form + a directory listing + checksums before/after",
- "evidence": "the refusal per form, the listing, the checksum pair"
+ "verify": "exit codes + the plan file's kind/ceremony/inventoryDigest + checksums before/after",
+ "evidence": "the plan excerpt, the refusal line, the checksum pair"
},
{
- "clause": "every --apply arm — no --plan-file, a --plan-file with --backup-taken, the DB held open by a second process — is refused the same way before reading anything: exit 1, `--json` {error: apply_refused, reason: upgrade-unavailable, code: NOT_IMPLEMENTED}; none answers the ceremony's own plan-file-required, plan-*-mismatch or database-busy, which nothing reaches in 18.0, and nothing is applied",
+ "clause": "`--apply --plan-file PLAN --backup-taken` completes: exit 0, every step done (the report's steps), blocking 0, and the sys_migration marker is verified with details.checkpoint.shapeGeneration equal to this release's generation",
"oracle": "build",
- "verify": "exit code + error / reason / code per arm + checksums before/after",
- "evidence": "per-arm lines"
+ "verify": "the apply report's steps/complete/blocking + the marker row",
+ "evidence": "the report excerpt and the marker row"
+ },
+ {
+ "clause": "the served boot refuses the unmigrated copy with ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED, naming `os migrate organization-ownership` and no 17.x release to stay on, and boots the migrated copy",
+ "oracle": "build",
+ "verify": "the refused boot's error line + the migrated boot's ready line",
+ "evidence": "both lines"
},
{
- "clause": "the drop gate still holds on a v17-shaped DB: `os migrate apply --allow-destructive` refuses every organization_id drop the ceremony owns — exit 1, `--json` message refused_organization_ownership_unmigrated with `tables` naming them — and applies nothing",
+ "clause": "the drop gate still holds on an unmigrated v17-shaped DB: `os migrate apply --allow-destructive` refuses every organization_id drop the ceremony owns — exit 1, `--json` message refused_organization_ownership_unmigrated with `tables` naming them — and applies nothing",
"oracle": "build",
"verify": "exit code + the JSON message and tables + checksums before/after",
"evidence": "the excerpt and the checksum pair"
}
],
"negative": [
- "any ceremony arm that alters the DB while refusing is a data-integrity FAIL"
+ "any ceremony arm that alters the DB while refusing, or a plan run that alters it at all, is a data-integrity FAIL"
],
"traps": [
"destructive-in-place"
],
"automated": {
"kind": "unit",
- "ref": "packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts + packages/cli/src/utils/organization-ownership-apply.test.ts + packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts + packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts + packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts + packages/cli/src/commands/migrate/comment-reactions.integration.test.ts + packages/cli/src/utils/organization-ownership-host-supplement.integration.test.ts + packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts"
+ "ref": "packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts + packages/cli/src/utils/organization-ownership-apply.test.ts + packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts + packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts + packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts + packages/cli/src/commands/migrate/comment-reactions.integration.test.ts + packages/cli/src/utils/organization-ownership-host-supplement.integration.test.ts + packages/cli/src/utils/organization-ownership-apply.generation-1.test.ts + packages/runtime/src/organization-ownership-boot-guard.test.ts"
},
"source": [
"packages/cli/src/commands/migrate/organization-ownership.ts",
- "packages/cli/src/commands/migrate/organization-ownership.ts#ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE",
+ "packages/runtime/src/organization-ownership-boot-guard.ts",
+ "packages/spec/src/system/migration.zod.ts#ORGANIZATION_OWNERSHIP_SHAPE_GENERATION",
"packages/spec/src/system/fate-supplement.zod.ts#OrganizationOwnershipSupplementSchema",
"packages/cli/src/utils/organization-ownership-drop-gate.ts",
"docs/adr/0131-total-organization-ownership-no-null-organization-id.md",
@@ -1953,6 +1960,12 @@
"date": "2026-10-19",
"change": "re-pointed to the 18.0 release refusal (QA run #24551 acceptance[1]–[4] fail, disposition stale-spec, RUNNER rule 7 agreeing; parked on #24579 §1). 18.0 was narrowed to fresh installs by ruling — #24551: \"ruling records 6259608309 (item 1) and 6259698357 then cut 18.0 to fresh installs on the single posture\" — and the registered `os migrate organization-ownership` refuses plan and apply alike with NOT_IMPLEMENTED before reading anything. acceptance[1] and [2] now score that refusal (no plan written, the DB unchanged, every apply arm refused the same way); acceptance[3] keeps the drop-gate half and drops the sys_migration checkpoint half; revision 1's acceptance[4] (the migrated copy boots) is removed. The ledger has no clause-level skip, so the ceremony's own arms are recorded in fixtures.knownGaps[0] as withdrawn and deliberately not scored, the shape knownGaps already carries for a half an item does not score. That is the one reading for both runs: a runner scores those arms neither `fail` (#24551, revision 1) nor `skipped` (QA run #24556, c1/c2/c4), and both runs captured the refusal each re-pointed clause now asserts. automated.ref gains organization-ownership.release-refusal.test.ts, the pin of the registered command (the other pins drive the ceremony class beneath it); acceptance[0], the title, the steps and source follow; re-derived from main 5a871e4e41, where every source cited here is byte-identical to the run subject 5b14b7f3",
"ref": "#24622"
+ },
+ {
+ "revision": 3,
+ "date": "2026-10-19",
+ "change": "re-pointed to the opened ceremony (18.x steps 5 and 6, one landing by ruling record 6270035914 on the v18 epic): the registered command runs the ceremony again, the 18.0 refusal and its pin (organization-ownership.release-refusal.test.ts) are deleted, and the boot guard recognises a database by its birth marker and its ownership shape generation (ruling record 6269955919, ① A). Revision 1's arms are restated against it: the plan written, the reviewed apply certifying at the current generation, the served boot refusing an unmigrated copy and booting the migrated one; the drop-gate clause is kept; fixtures.knownGaps now records the one deliberate block (a walled customized copy). automated.ref drops the deleted refusal pin and gains the generation-1 steps' pins and the guard's",
+ "ref": "#24681"
}
]
},
diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json
index bed72a18245..3a59e9b9694 100644
--- a/docs/qa/platform-checklist/areas/platform-core.json
+++ b/docs/qa/platform-checklist/areas/platform-core.json
@@ -3043,10 +3043,10 @@
},
{
"id": "platform-core.v18-boot-refusals",
- "title": "A v18 server refuses to boot rather than serve wrongly: against a 17.x-shaped database (a physical organization_id on a non-tenant table, no verified ADR-0131 ceremony marker) it stops before listening with ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED, naming the tables and saying that 18.0 supports fresh installs only, and with security on but no identity objects it stops with AuthzIdentityObjectsMissingError",
+ "title": "A v18 server refuses to boot rather than serve wrongly: against a 17.x-shaped database (a physical organization_id on a non-tenant table, no verified ADR-0131 ceremony marker) it stops before listening with ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED, naming the tables and the ownership ceremony's plan and apply, and with security on but no identity objects it stops with AuthzIdentityObjectsMissingError",
"since": "v18",
"status": "active",
- "revision": 4,
+ "revision": 5,
"priority": "P0",
"surface": "cli",
"personas": [
@@ -3071,9 +3071,9 @@
"evidence": "the vitest summary line per pin file, with the package name and the subject sha"
},
{
- "clause": "the v18 serve against the v17 DB exits 1 before kernel:listening with the organization-ownership refusal, which names the tables and ends with the 18.0 sentence (\"18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path, and upgrade it there.\"), and names neither `os migrate organization-ownership` nor any step of it (no `--out PLAN`, no `--apply --plan-file`): 18.0 refuses that command on every database (NOT_IMPLEMENTED). ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED is that refusal's `code` (packages/runtime/src/organization-ownership-boot-guard.ts#OrganizationOwnershipBootRefusal) and the boot never prints it — `os serve` prints the refusal's message only — so the code is the pins' evidence (acceptance[0]), not this log's",
+ "clause": "the v18 serve against the v17 DB exits 1 before kernel:listening with the organization-ownership refusal, which opens \"This database carries no organization-ownership marker\", names the tables still carrying organization_id, and names the ceremony's two steps (`os migrate organization-ownership --out PLAN`, then `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`). It does not tell the operator to stay on 17.x. ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED is that refusal's `code` (packages/runtime/src/organization-ownership-boot-guard.ts#OrganizationOwnershipBootRefusal) and the boot never prints it — `os serve` prints the refusal's message only — so the code is the pins' evidence (acceptance[0]), not this log's",
"oracle": "log",
- "verify": "boot output + exit code + no listening line: the printed refusal names each table still carrying organization_id and ends with \"18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path, and upgrade it there.\"; the refusal excerpt has 0 hits each for `--out PLAN`, `--plan-file` and `os migrate organization-ownership`. ⛔ Do not grep the log for the code literal — it is not printed (packages/cli/src/commands/serve.ts prints the refusal's message)",
+ "verify": "boot output + exit code + no listening line: the printed refusal opens \"This database carries no organization-ownership marker\", names each table still carrying organization_id, and contains `os migrate organization-ownership --out PLAN` and `--apply --plan-file PLAN --backup-taken`; the refusal excerpt has 0 hits for `17.x release` and for `fresh installs only`. ⛔ Do not grep the log for the code literal — it is not printed (packages/cli/src/commands/serve.ts prints the refusal's message)",
"evidence": "the refusal excerpt"
},
{
@@ -3130,6 +3130,12 @@
"date": "2026-10-19",
"change": "title and acceptance[1] (clause, verify) re-worded for 18.0's boot refusal text (card #24651). In 18.0 `os migrate organization-ownership` refuses plan and apply on every database (NOT_IMPLEMENTED: 18.0 supports fresh installs only), so the boot refusal no longer names that command or its `--out PLAN` / `--apply --plan-file PLAN --backup-taken` steps. Every branch now ends with \"18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path, and upgrade it there.\" (packages/runtime/src/organization-ownership-boot-guard.ts, freshInstallsOnly; `os serve --dev` says \"a v17 development database\"). The clause asserted that the refusal names the command, so a run against the new text would have recorded FAIL on a correct product. It now asserts the tables, that sentence, and the absence of the command and its steps. The ⛔ on the code literal is kept. acceptance[0], acceptance[2], acceptance[3] and both steps are unchanged",
"ref": "#24682"
+ },
+ {
+ "revision": 5,
+ "date": "2026-10-20",
+ "change": "title and acceptance[1] (clause, verify) re-worded for the open ownership ceremony (#24581). `os migrate organization-ownership` plans and applies again, and the boot guard decides on the ownership marker (a birth marker a fresh datastore writes, or the ceremony's verified one): a v17 database has none, so the refusal opens \"This database carries no organization-ownership marker\", names the tables still carrying organization_id, and names the ceremony's plan and apply (`--out PLAN`, then `--apply --plan-file PLAN --backup-taken`). Revision 4 asserted the 18.0 sentence and the absence of the command, so a run against the new text would have recorded FAIL on a correct product. It now asserts the marker sentence, the tables and the two steps, and that the refusal says nothing of staying on 17.x. The ⛔ on the code literal is kept. acceptance[0], acceptance[2], acceptance[3] and both steps are unchanged",
+ "ref": "#24681"
}
]
},
diff --git a/package.json b/package.json
index 6457158513a..c059066f759 100644
--- a/package.json
+++ b/package.json
@@ -78,6 +78,7 @@
"check:pm-label-desc-cap": "node scripts/pm/check-label-desc-cap.mjs --self-test && node scripts/pm/check-label-desc-cap.mjs",
"check:pm-dispatch-gates": "node scripts/pm/check-dispatch-gates.mjs --self-test && node scripts/pm/check-dispatch-gates.mjs",
"check:watch-hint-literal": "node scripts/check-watch-hint-literal.mjs --self-test && node scripts/check-watch-hint-literal.mjs",
+ "check:closed-ceremony-text": "node scripts/check-closed-ceremony-text.mjs --self-test && node scripts/check-closed-ceremony-text.mjs",
"check:declared-population-live": "node scripts/check-declared-population-live.mjs --self-test && node scripts/check-declared-population-live.mjs",
"check:pm-half-states": "node scripts/pm/check-half-states.mjs --self-test",
"check:issue-citations": "node scripts/check-issue-citations.mjs --self-test",
diff --git a/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts b/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts
index 9510bb0aa3b..38d867f9be2 100644
--- a/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts
+++ b/packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts
@@ -104,8 +104,6 @@ function caseDb(columns: readonly string[]): string {
const dropArgv = (file: string) => ['--database-url', `file:${file}`, '--allow-destructive', '--yes', '--json'];
-/** A step of `os migrate organization-ownership`, which 18.0 refuses on every database. */
-const WITHDRAWN_COMMAND = /os migrate organization-ownership|--plan-file|organization-ownership --out/;
beforeAll(async () => {
for (const key of [...OVERRIDING_ENV, 'NODE_ENV'] as const) savedEnv[key] = process.env[key];
@@ -155,8 +153,6 @@ describe('os migrate apply --allow-destructive waits for the organization-owners
marker: null,
});
expect(payload.detail).toMatch(/organization-ownership ceremony/);
- // 18.0 refuses `os migrate organization-ownership` on every database: the refusal names no step of it.
- expect(payload.detail).not.toMatch(WITHDRAWN_COMMAND);
expect(columnsOf(file, 'sys_platform_setting')).toContain('organization_id');
}, CASE_TIMEOUT_MS);
@@ -169,7 +165,6 @@ describe('os migrate apply --allow-destructive waits for the organization-owners
expect(exitCode).toBe(1);
expect(payload).toMatchObject({ message: 'refused_organization_ownership_unmigrated', tables: ['sys_platform_setting'] });
expect(payload.detail).toMatch(/is not verified \(2 blocking/);
- expect(payload.detail).not.toMatch(WITHDRAWN_COMMAND);
expect(columnsOf(file, 'sys_platform_setting')).toContain('organization_id');
}, CASE_TIMEOUT_MS);
diff --git a/packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts b/packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts
index 0cd96b6978c..08fa2c97e86 100644
--- a/packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts
+++ b/packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts
@@ -3,15 +3,13 @@
/**
* [ADR-0131 D10 item 5] What `os migrate apply --allow-destructive` tells the
* operator when it holds back an `organization_id` drop the organization-ownership
- * ceremony owns, in 18.0.
+ * ceremony owns.
*
- * 18.0 supports fresh installs only, and its registered
- * `os migrate organization-ownership` refuses on every database, plan and apply
- * alike (`organization-ownership.release-refusal.test.ts`). So the drop gate's
- * refusal, the human face and the `--json` `detail` both, names no step of that
- * command: it says what 18.0 supports for a v17 database, the 17.x release to
- * keep it on and the 18.x release that carries the upgrade path, as the boot
- * refusal does (`organizationOwnershipRefusalMessage`, `@objectstack/runtime`).
+ * The ceremony is the way on (18.x step 6 opened it), so the human face names
+ * its steps — the plan, then the reviewed apply with the backup acknowledged —
+ * as the boot refusal does (`organizationOwnershipRefusalMessage`,
+ * `@objectstack/runtime`), and names no release line to stay on. The `--json`
+ * `detail` names the ceremony and states the marker it read.
*
* ## What is real here
*
@@ -69,8 +67,11 @@ const ORGANIZATION_DROP = {
description: 'sys_platform_setting.organization_id: column exists in the database but not in metadata (orphaned)',
};
-/** A step of `os migrate organization-ownership`, which 18.0 refuses on every database. */
-const WITHDRAWN_COMMAND = /os migrate organization-ownership|--plan-file|organization-ownership --out/;
+/** The ceremony's two steps, as the refusal prescribes them. */
+const CEREMONY_STEPS = [
+ 'os migrate organization-ownership --out PLAN',
+ 'os migrate organization-ownership --apply --plan-file PLAN --backup-taken',
+];
const UNVERIFIED = { id: 'adr-0131-organization-ownership', last_run_at: '2026-10-01T00:00:00.000Z', verified_at: null, blocking: 2 };
const VERIFIED = { ...UNVERIFIED, verified_at: '2026-10-01T00:00:00.000Z', blocking: 0 };
@@ -141,9 +142,9 @@ async function runApply(argv: string[]) {
const ARGV = ['--database-url', 'file:v17.db', '--allow-destructive', '--yes'];
-describe('os migrate apply --allow-destructive: the organization-ownership refusal names no command 18.0 refuses', () => {
+describe('os migrate apply --allow-destructive: the organization-ownership refusal names the ceremony\'s steps', () => {
for (const [state, flag] of [['no ceremony run recorded', null], ['an unverified run recorded', UNVERIFIED]] as const) {
- it(`${state}: the human face refuses, exit 1, saying what 18.0 supports, and nothing is written`, async () => {
+ it(`${state}: the human face refuses, exit 1, naming the ceremony's steps, and nothing is written`, async () => {
bootWithOrganizationDrop();
mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: false, flag });
@@ -152,15 +153,14 @@ describe('os migrate apply --allow-destructive: the organization-ownership refus
expect(exit).toBe(1);
expect(refusal, 'the gate printed its refusal').toBeDefined();
expect(refusal).toContain('sys_platform_setting');
- expect(refusal).not.toMatch(WITHDRAWN_COMMAND);
- expect(refusal).toContain('17.x release');
- expect(refusal).toContain('18.x release');
+ for (const step of CEREMONY_STEPS) expect(refusal).toContain(step);
+ expect(refusal).not.toMatch(/17\.x|18\.0 supports/);
if (flag) expect(refusal).toContain('2 blocking');
expect(flushSchemaDdl).not.toHaveBeenCalled();
expect(applyMigrationEntries).not.toHaveBeenCalled();
}, RUN_TIMEOUT);
- it(`${state}: under --json the refusal's detail names no command 18.0 refuses either`, async () => {
+ it(`${state}: under --json the refusal's detail names the ceremony and the marker it read`, async () => {
bootWithOrganizationDrop();
mocks.readOrganizationOwnershipMarker.mockResolvedValue({ verified: false, flag });
@@ -174,7 +174,7 @@ describe('os migrate apply --allow-destructive: the organization-ownership refus
created: [],
marker: flag,
});
- expect(payload.detail).not.toMatch(WITHDRAWN_COMMAND);
+ expect(payload.detail).toMatch(/organization-ownership ceremony/);
expect(applyMigrationEntries).not.toHaveBeenCalled();
}, RUN_TIMEOUT);
}
diff --git a/packages/cli/src/commands/migrate/apply.ts b/packages/cli/src/commands/migrate/apply.ts
index 7a6414669b7..2de13adab01 100644
--- a/packages/cli/src/commands/migrate/apply.ts
+++ b/packages/cli/src/commands/migrate/apply.ts
@@ -571,15 +571,9 @@ export default class MigrateApply extends Command {
);
const marker = await readOrganizationOwnershipMarker(engine as never);
if (!marker.verified) {
- // 18.0 supports fresh installs only, and its `os migrate
- // organization-ownership` refuses on every database, plan and apply
- // alike (`ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE`, ruling record
- // 6259608309 on the v18 epic, item 1). So neither the `detail` nor the
- // refusal names a step of that command: they say what 18.0 supports
- // for a v17 database, in the boot refusal's terms
- // (`organizationOwnershipRefusalMessage`, `@objectstack/runtime`).
- // The 18.x release that registers the ceremony names its steps here
- // again.
+ // The ceremony is the way on (18.x step 6): the refusal names its
+ // steps, as the boot refusal does (`organizationOwnershipRefusalMessage`,
+ // `@objectstack/runtime`).
const detail = marker.flag
? `The last run of the organization-ownership ceremony recorded on this database (${marker.flag.last_run_at}) `
+ `is not verified (${marker.flag.blocking} blocking item(s)).`
@@ -594,10 +588,10 @@ export default class MigrateApply extends Command {
printError(
`Refusing this --allow-destructive run: it would drop organization_id from ${organizationDrops.join(', ')}, a drop the organization-ownership ceremony `
+ `owns, and the ceremony has not verified on this database. ${detail} Dropping the column first would turn `
- + 'every legacy organization row into an environment row without the ceremony\'s promotion or report. 18.0 '
- + 'supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release '
- + 'until the 18.x release that carries the upgrade path, whose ceremony drops these columns itself, in its '
- + 'column-drops step, before it records the run verified. Nothing in this run was applied.',
+ + 'every legacy organization row into an environment row without the ceremony\'s promotion or report. Run '
+ + '"os migrate organization-ownership --out PLAN" to write and review its plan, take a backup, then '
+ + '"os migrate organization-ownership --apply --plan-file PLAN --backup-taken"; it drops these columns '
+ + 'itself, in its column-drops step, before it records the run verified. Nothing in this run was applied.',
);
this.exit(1);
return;
diff --git a/packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts b/packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts
index b17cdff8df0..3816bac4702 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts
@@ -28,13 +28,13 @@ vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
probeMigrationTarget: vi.fn(async () => ({ status: 'free' })),
}));
-type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
+type Command = (typeof import('./organization-ownership.js'))['default'];
let MigrateOrganizationOwnership: Command;
// The command's module graph is large, and oclif reads the package's config on
// the first run: both are paid outside the 5 s default (CASE_TIMEOUT_MS).
beforeAll(async () => {
- ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
}, 60_000);
const CASE_TIMEOUT_MS = 60_000;
diff --git a/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts
index 96eb4e942f2..e5d14c95370 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts
@@ -57,7 +57,7 @@ import { DECLARED_SHARING_RULE_SEEDER_RUNS } from '../../utils/organization-owne
import '@objectstack/objectql';
import '@objectstack/metadata-protocol';
-type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
+type Command = (typeof import('./organization-ownership.js'))['default'];
let MigrateOrganizationOwnership: Command;
const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
@@ -158,7 +158,7 @@ describe('os migrate organization-ownership on a database whose packages declare
savedEnv[key] = process.env[key];
delete process.env[key];
}
- ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
dir = mkdtempSync(join(tmpdir(), 'os-ceremony-declared-rules-'));
file = join(dir, 'fixture.db');
// The project the command runs in: the ceremony boots ITS composition, so
diff --git a/packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts b/packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts
index 7a4c21eeb45..7e0f8266b1b 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts
@@ -44,13 +44,13 @@ vi.mock('../../utils/organization-ownership-apply.js', async (importOriginal) =>
applyOrganizationOwnership: apply,
}));
-type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
+type Command = (typeof import('./organization-ownership.js'))['default'];
let MigrateOrganizationOwnership: Command;
// The command's module graph is large, and oclif reads the package's config on
// the first run: both are paid outside the 5 s default (CASE_TIMEOUT_MS).
beforeAll(async () => {
- ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
}, 60_000);
const CASE_TIMEOUT_MS = 60_000;
diff --git a/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts
index fd185cdd090..d300a7b90cf 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts
@@ -34,13 +34,13 @@ vi.mock('../../utils/organization-ownership-apply.js', async (importOriginal) =>
applyOrganizationOwnership: apply,
}));
-type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
+type Command = (typeof import('./organization-ownership.js'))['default'];
let MigrateOrganizationOwnership: Command;
// The command's module graph is large, and oclif reads the package's config on
// the first run: both are paid outside the 5 s default (CASE_TIMEOUT_MS).
beforeAll(async () => {
- ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
}, 60_000);
const CASE_TIMEOUT_MS = 60_000;
diff --git a/packages/cli/src/commands/migrate/organization-ownership.journal-mode.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.journal-mode.integration.test.ts
index 9a0b629c3e3..e09e732bb5f 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.journal-mode.integration.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.journal-mode.integration.test.ts
@@ -32,13 +32,13 @@ import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';
import '@objectstack/runtime';
import '@objectstack/metadata-protocol';
-type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
+type Command = (typeof import('./organization-ownership.js'))['default'];
let MigrateOrganizationOwnership: Command;
// The command's module graph is large, and oclif reads the package's config on
// the first run: both are paid outside the case timeout.
beforeAll(async () => {
- ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
}, 60_000);
const CASE_TIMEOUT_MS = 60_000;
diff --git a/packages/cli/src/commands/migrate/organization-ownership.no-organization.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.no-organization.integration.test.ts
index 7f95510d87a..661896a2e69 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.no-organization.integration.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.no-organization.integration.test.ts
@@ -61,7 +61,7 @@ import '@objectstack/objectql';
import '@objectstack/metadata-protocol';
import '@objectstack/plugin-auth';
-type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
+type Command = (typeof import('./organization-ownership.js'))['default'];
let MigrateOrganizationOwnership: Command;
const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
@@ -162,7 +162,7 @@ describe('os migrate organization-ownership on a single-posture database that ho
savedEnv[key] = process.env[key];
delete process.env[key];
}
- ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
dir = mkdtempSync(join(tmpdir(), 'os-ceremony-no-organization-'));
file = join(dir, 'fixture.db');
diff --git a/packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts
index 13832f2173f..179a65dfaa8 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts
@@ -40,13 +40,13 @@ vi.mock('../../utils/schema-migrate.js', async (importOriginal) => ({
bootSchemaStack: boot,
}));
-type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
+type Command = (typeof import('./organization-ownership.js'))['default'];
let MigrateOrganizationOwnership: Command;
// The command's module graph is large, and oclif reads the package's config on
// the first run: both are paid outside the case timeout.
beforeAll(async () => {
- ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
}, 60_000);
const CASE_TIMEOUT_MS = 60_000;
diff --git a/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts b/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts
deleted file mode 100644
index d700cba367b..00000000000
--- a/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts
+++ /dev/null
@@ -1,163 +0,0 @@
-// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
-
-/**
- * `os migrate organization-ownership` refuses in 18.0, plan and apply alike
- * (ruling record 6259608309 on the v18 epic, item 1). 18.0 supports fresh
- * installs only. Its ceremony is knowingly incomplete, and an incomplete
- * ceremony must never certify an old database.
- *
- * The refusal comes first, so it reads nothing. These cases set every input
- * the ceremony would judge before its boot to a value the ceremony refuses:
- * an unrecognised posture, a fate supplement that does not exist, a plan file
- * that does not exist. They also stub the occupancy probe and the boot so that
- * either one throws. Only the release refusal can answer.
- *
- * The ceremony itself (`OrganizationOwnershipCeremony`) is pinned by the
- * sibling `organization-ownership.*.test.ts` files, which run it directly.
- */
-
-import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { existsSync, mkdtempSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-const boot = vi.hoisted(() => vi.fn(async () => {
- throw new Error('the database was opened');
-}));
-const probe = vi.hoisted(() => vi.fn(async () => {
- throw new Error('the database was probed');
-}));
-vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: boot }));
-vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
- OCCUPANCY_STOP_HINT: 'occupancy hint',
- probeMigrationTarget: probe,
-}));
-
-type Module = typeof import('./organization-ownership.js');
-let mod: Module;
-
-// The command's module graph is large, and oclif reads the package's config on
-// the first run: both are paid outside the 5 s default.
-beforeAll(async () => {
- mod = await import('./organization-ownership.js');
-}, 60_000);
-
-const CASE_TIMEOUT_MS = 60_000;
-const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
-const DIR = mkdtempSync(join(tmpdir(), 'os-ownership-release-refusal-'));
-const DB_FILE = join(DIR, 'v17.db');
-const OUT = join(DIR, 'out.json');
-const MISSING_SUPPLEMENT = join(DIR, 'missing-supplement.json');
-const MISSING_PLAN = join(DIR, 'missing-plan.json');
-
-let savedPosture: string | undefined;
-beforeEach(() => {
- savedPosture = process.env.OS_TENANCY_POSTURE;
- // The ceremony refuses an unrecognised posture before anything else; the release refusal never reads it.
- process.env.OS_TENANCY_POSTURE = 'not-a-posture';
-});
-afterEach(() => {
- if (savedPosture === undefined) delete process.env.OS_TENANCY_POSTURE;
- else process.env.OS_TENANCY_POSTURE = savedPosture;
- boot.mockClear();
- probe.mockClear();
- vi.restoreAllMocks();
-});
-
-async function run(argv: string[]): Promise<{ stdout: string; exitCode: unknown }> {
- const chunks: string[] = [];
- const savedExit = process.exitCode;
- vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => {
- chunks.push(String(chunk));
- const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
- if (cb) cb();
- return true;
- }) as typeof process.stdout.write);
- vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write);
- vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
- chunks.push(`${args.map(String).join(' ')}\n`);
- });
- let exitCode: unknown;
- try {
- await mod.default.run(argv, { root: CLI_ROOT });
- exitCode = process.exitCode;
- } catch (error) {
- const oclif = (error as { oclif?: { exit?: unknown } }).oclif;
- if (oclif === undefined) throw error;
- exitCode = oclif.exit;
- }
- process.exitCode = savedExit;
- vi.restoreAllMocks();
- return { stdout: chunks.join(''), exitCode };
-}
-
-function expectNothingTouched(): void {
- expect(boot).not.toHaveBeenCalled();
- expect(probe).not.toHaveBeenCalled();
- expect(existsSync(DB_FILE), 'the database file was brought into existence').toBe(false);
- expect(existsSync(OUT), 'a plan or report file was written').toBe(false);
-}
-
-describe('os migrate organization-ownership — refused in 18.0', () => {
- it('the plan refuses with NOT_IMPLEMENTED, exit 1, reading and writing nothing', async () => {
- const { stdout, exitCode } = await run([
- '--database-url', `file:${DB_FILE}`, '--out', OUT, '--fate-supplement', MISSING_SUPPLEMENT, '--json',
- ]);
- expect(JSON.parse(stdout.trim())).toEqual({
- error: 'plan_refused',
- reason: 'upgrade-unavailable',
- code: 'NOT_IMPLEMENTED',
- detail: mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE,
- });
- expect(exitCode).toBe(1);
- expectNothingTouched();
- }, CASE_TIMEOUT_MS);
-
- it('the apply refuses with NOT_IMPLEMENTED, exit 1, reading and writing nothing', async () => {
- const { stdout, exitCode } = await run([
- '--apply', '--plan-file', MISSING_PLAN, '--backup-taken',
- '--database-url', `file:${DB_FILE}`, '--out', OUT, '--fate-supplement', MISSING_SUPPLEMENT, '--json',
- ]);
- expect(JSON.parse(stdout.trim())).toMatchObject({
- error: 'apply_refused',
- reason: 'upgrade-unavailable',
- code: 'NOT_IMPLEMENTED',
- });
- expect(exitCode).toBe(1);
- expectNothingTouched();
- }, CASE_TIMEOUT_MS);
-
- it('without --json it prints the refusal with its code and exits 1', async () => {
- const { stdout, exitCode } = await run(['--database-url', `file:${DB_FILE}`, '--out', OUT]);
- expect(stdout).toContain('✗');
- expect(stdout).toContain(mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE);
- expect(stdout).toContain('(NOT_IMPLEMENTED)');
- expect(exitCode).toBe(1);
- expectNothingTouched();
- }, CASE_TIMEOUT_MS);
-
- it('the refusal names the release that will carry the upgrade and what 18.0 supports', () => {
- expect(mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE).toMatch(/^os migrate organization-ownership is not available in 18\.0\./);
- expect(mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE).toContain('fresh installs only');
- expect(mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE).toContain('an 18.x release');
- });
-
- it('its --help examples show the refusal: none is a plan or an apply 18.0 refuses on every database', () => {
- const examples = mod.default.examples.map((e) => (typeof e === 'string' ? { command: e, description: '' } : e));
- expect(examples.length).toBeGreaterThan(0);
- for (const { command, description } of examples) {
- expect(command).toMatch(/migrate organization-ownership/);
- expect(command).not.toMatch(/--out|--apply|--plan-file|--fate-supplement/);
- expect(description).toContain('NOT_IMPLEMENTED');
- }
- // The ceremony class keeps its own examples, for the 18.x release that registers it.
- expect(mod.OrganizationOwnershipCeremony.examples.some((e) => String(e).includes('--apply --plan-file'))).toBe(true);
- });
-
- it('the registered command keeps the ceremony class whole underneath: same flags, its own run', () => {
- expect(Object.getPrototypeOf(mod.default)).toBe(mod.OrganizationOwnershipCeremony);
- expect(mod.default.flags).toBe(mod.OrganizationOwnershipCeremony.flags);
- expect(mod.default.prototype.run).not.toBe(mod.OrganizationOwnershipCeremony.prototype.run);
- });
-});
diff --git a/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts
index 46889c5777c..ad7fa489d5e 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts
@@ -59,7 +59,7 @@ import { TursoDriver } from '@objectstack/driver-turso';
import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system';
import { bootSchemaStack } from '../../utils/schema-migrate.js';
import { isExitSignal } from '../../utils/format.js';
-import { OrganizationOwnershipCeremony as MigrateOrganizationOwnership } from './organization-ownership.js';
+import MigrateOrganizationOwnership from './organization-ownership.js';
// Paid at module load, not inside a clocked case (`check:test-source-alias`).
import '@objectstack/runtime';
diff --git a/packages/cli/src/commands/migrate/organization-ownership.remote-posture.test.ts b/packages/cli/src/commands/migrate/organization-ownership.remote-posture.test.ts
index 5ae3f969d55..fba272b1e5d 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.remote-posture.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.remote-posture.test.ts
@@ -31,11 +31,11 @@ vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
probeMigrationTarget: vi.fn(async () => ({ status: 'free' })),
}));
-type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
+type Command = (typeof import('./organization-ownership.js'))['default'];
let MigrateOrganizationOwnership: Command;
beforeAll(async () => {
- ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
}, 60_000);
const CASE_TIMEOUT_MS = 60_000;
diff --git a/packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts
index 6db272bc3ef..2e3756a30bd 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts
@@ -53,7 +53,7 @@ vi.mock('../../utils/schema-migrate.js', async (importOriginal) => {
};
});
-type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
+type Command = (typeof import('./organization-ownership.js'))['default'];
let MigrateOrganizationOwnership: Command;
const HERE = dirname(fileURLToPath(import.meta.url));
@@ -114,6 +114,15 @@ async function bootServed(projectDir: string, dbFile: string): Promise {
await kernel.use(new PlatformObjectsPlugin() as any);
await runtime.start();
await kernel.shutdown();
+ // This boot of THIS tree created the store from empty, so it wrote this
+ // release's birth marker; the database it stands in for (an earlier
+ // release's) carries none, and the cases below read the marker's absence.
+ const db = new Database(dbFile);
+ try {
+ db.prepare('DELETE FROM sys_migration WHERE id = ?').run(ORGANIZATION_OWNERSHIP_MIGRATION_ID);
+ } finally {
+ db.close();
+ }
}
interface Prepared {
@@ -182,7 +191,7 @@ const apply = (prepared: Prepared, out: string): Promise =>
]);
beforeAll(async () => {
- ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
for (const key of [...OVERRIDING_ENV, 'NODE_ENV', 'OS_SECRET_KEY'] as const) savedEnv[key] = process.env[key];
for (const key of OVERRIDING_ENV) delete process.env[key];
// The deployed posture: no telemetry sibling, and the crypto provider's key.
diff --git a/packages/cli/src/commands/migrate/organization-ownership.report-reservation.test.ts b/packages/cli/src/commands/migrate/organization-ownership.report-reservation.test.ts
index 89b2f6d505c..cd533c69ccf 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.report-reservation.test.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.report-reservation.test.ts
@@ -28,11 +28,11 @@ vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
probeMigrationTarget: vi.fn(async () => ({ status: 'free' })),
}));
-type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony'];
+type Command = (typeof import('./organization-ownership.js'))['default'];
let MigrateOrganizationOwnership: Command;
beforeAll(async () => {
- ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+ ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
}, 60_000);
const CASE_TIMEOUT_MS = 60_000;
diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts
index 0eac0d5700b..ea223b9e5d7 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.ts
@@ -204,11 +204,10 @@ class ReportFileReservation {
}
/**
- * The v18 organization-ownership ceremony behind `os migrate
- * organization-ownership` — in 18.0 the registered command refuses before
- * this class runs (see {@link MigrateOrganizationOwnership} at the end of this
- * file). This class is the ceremony itself, kept whole for the 18.x release
- * that carries the upgrade path; its tests run it directly.
+ * `os migrate organization-ownership` — the v18 organization-ownership
+ * ceremony, the upgrade path of a v17 or an 18.0 database (18.x step 6): the
+ * boot guard refuses such a database until this ceremony has verified it at the
+ * current ownership shape generation.
*
* The ceremony's preflight (ADR-0131 D10, ceremony item 1): per table, the fate,
* the row counts each fate will touch, the rows whose owner cannot be derived
@@ -261,8 +260,10 @@ class ReportFileReservation {
* through plugin-auth's boot invariant, as the plan's warnings say; ADR-0131
* D3), the authored catalog conversion,
* the promotion of organization customizations and public-form withdrawals,
- * the template promotion, the settings global-rung move, the verified id→name
- * rewrite, mirror deletion, column drops — through `../../utils/organization-ownership-apply.ts`,
+ * the template promotion, the settings global-rung move, the sibling moves,
+ * the settlement of declared webhooks and of declared sharing rules' stored
+ * copies, the verified id→name rewrite, mirror deletion, column drops, NOT
+ * NULL on `organization_id` — through `../../utils/organization-ownership-apply.ts`,
* checkpointing each finished step in the deployment ledger (`sys_migration`,
* row {@link ORGANIZATION_OWNERSHIP_MIGRATION_ID}) so an interrupted run
* resumes where it stopped, and ends with the post-check. The ledger row is
@@ -316,16 +317,16 @@ class ReportFileReservation {
* the drops go through the remote face's own drift detector and migration
* door (`@objectstack/driver-turso`).
*/
-export class OrganizationOwnershipCeremony extends Command {
+export default class OrganizationOwnershipCeremony extends Command {
static override description =
'The v18 organization-ownership ceremony (ADR-0131 D10). Without --apply, the read-only plan: per table, the fate, ' +
'the row counts, the rows whose owner cannot be derived (by id), and which tables will receive NOT NULL, written ' +
'to a file — never a row or a column changed; it refuses, naming it, any table it cannot enumerate. With --apply ' +
'(the reviewed plan file in --plan-file, and a backup), only that plan: attribution, the authored catalog ' +
'conversion, the promotion of organization customizations and form withdrawals, the template promotion, the ' +
- 'settings move, the ' +
- 'verified id-to-name rewrite, mirror deletion and column drops, in that order, ' +
- 'resumable from a recorded checkpoint, then the post-check.';
+ 'settings move, the sibling moves (global settings audit, identity mail), the settlement of declared webhooks and ' +
+ 'of declared sharing rules\' stored copies, the verified id-to-name rewrite, mirror deletion, column drops and ' +
+ 'NOT NULL on organization_id, in that order, resumable from a recorded checkpoint, then the post-check.';
static override examples: Command.Example[] = [
'$ os migrate organization-ownership',
@@ -357,8 +358,9 @@ export class OrganizationOwnershipCeremony extends Command {
}),
apply: Flags.boolean({
description:
- 'Perform the ceremony: attribution, the verified id-to-name rewrite, mirror deletion, column drops, then the ' +
- 'post-check. Idempotent; an interrupted run resumes from its recorded checkpoint. Requires a backup',
+ 'Perform the ceremony: attribution, the moves and settlements, the verified id-to-name rewrite, mirror deletion, ' +
+ 'column drops and NOT NULL, then the post-check. Idempotent; an interrupted run resumes from its recorded ' +
+ 'checkpoint. Requires a backup',
default: false,
}),
'plan-file': Flags.string({
@@ -830,6 +832,60 @@ export class OrganizationOwnershipCeremony extends Command {
return judgeDeclaredSharingRules(items);
},
},
+ declaredWebhooks: {
+ // [18.x step 5a] What plugin-webhooks serves: the registry's declared
+ // webhooks, else the metadata service's, each parsed by the spec's
+ // own schema — an item the plugin cannot parse is not served, so a
+ // copy is never settled onto it.
+ list: async () => {
+ const { WebhookSchema } = await import('@objectstack/spec/automation');
+ const registry = (engine.registry ?? (engine as { _registry?: unknown })._registry) as
+ | { listItems?(type: string): unknown[] | undefined }
+ | undefined;
+ let items: unknown[] = registry?.listItems?.('webhook') ?? [];
+ if (items.length === 0) {
+ const metadata = kernel.getService('metadata') as { list?(type: string): unknown } | undefined;
+ const listed = await metadata?.list?.('webhook');
+ items = Array.isArray(listed) ? listed : [];
+ }
+ const out = new Map();
+ for (const item of items) {
+ const parsed = WebhookSchema.safeParse(item);
+ if (!parsed.success || out.has(parsed.data.name)) continue;
+ const packageId = (item as { _packageId?: unknown })._packageId;
+ out.set(parsed.data.name, typeof packageId === 'string' && packageId ? packageId : null);
+ }
+ return [...out].map(([name, packageId]) => ({ name, packageId }));
+ },
+ },
+ notNull: {
+ // [18.x step 5d] NOT NULL through the drift apply door of the driver
+ // that holds the table, for a table this composition declares.
+ apply: async (columns) => {
+ const { StorageNameMapping } = await import('@objectstack/spec/system');
+ const { rotationBaseOf } = await import('../../utils/unmanaged-tables.js');
+ const { applyNotNullThroughDrift } = await import('../../utils/organization-ownership-not-null.js');
+ const declared = new Set(
+ (liveStack.allObjects() as Array<{ name?: unknown } | null>)
+ .map((o) => o?.name)
+ .filter((name): name is string => typeof name === 'string' && name.length > 0)
+ .map((name) => StorageNameMapping.resolveTableName({ name })),
+ );
+ return applyNotNullThroughDrift(
+ {
+ isDeclared: (table) => declared.has(rotationBaseOf(table) ?? table),
+ shardBaseOf: (table) => rotationBaseOf(table) ?? null,
+ driverFor: (database) =>
+ (database === undefined
+ ? liveStack.driver
+ : database === TELEMETRY_DATASOURCE && liveStack.telemetryDatabase
+ ? liveStack.drivers[1]
+ : undefined) as never,
+ },
+ columns,
+ );
+ },
+ },
door: {
save: async (type, name, body, target) => {
if (typeof protocol?.saveMetaItem !== 'function') {
@@ -1093,73 +1149,3 @@ export class OrganizationOwnershipCeremony extends Command {
if (verdictCode !== 0) this.exit(1);
}
}
-
-/**
- * What `os migrate organization-ownership` answers in 18.0, plan and apply
- * alike (ruling record 6259608309 on the v18 epic, item 1).
- *
- * 18.0 supports fresh installs only. Its ceremony is knowingly incomplete:
- * several fates, and the moves they carry, were deferred to 18.x. An
- * incomplete ceremony must never certify an old database, because the boot
- * guard (`OrganizationOwnershipBootGuardPlugin`) would then serve it. So the
- * registered command refuses before it reads anything. It opens no database,
- * reads no plan, supplement or report file, writes nothing, and boots nothing.
- * The code is the standard `NOT_IMPLEMENTED` (ADR-0112): the capability is
- * not in this release. No flag and no environment variable lifts the refusal.
- */
-export const ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE =
- 'os migrate organization-ownership is not available in 18.0. 18.0 supports fresh installs only; the v17 → v18 ' +
- 'upgrade path ships in an 18.x release. Keep a v17 database on a 17.x release until then. Nothing was read or written.';
-
-/** The ADR-0112 code the 18.0 refusal carries: the standard code for a capability this release does not implement. */
-export const ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE = 'NOT_IMPLEMENTED' as const;
-
-/**
- * `os migrate organization-ownership` in 18.0: the ceremony's flags and help,
- * and a refusal instead of the ceremony
- * ({@link ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE}). The 18.x release that
- * carries the upgrade path deletes this class and registers the ceremony.
- */
-export default class MigrateOrganizationOwnership extends OrganizationOwnershipCeremony {
- static override description =
- 'Refused in 18.0, which supports fresh installs only: the v17 → v18 upgrade path ships in an 18.x release. ' +
- OrganizationOwnershipCeremony.description;
-
- /**
- * The ceremony's own examples show a plan and an apply, which 18.0 refuses on
- * every database, fresh or not, so `--help` shows what an invocation answers
- * in this release instead. The ceremony class keeps its examples for the 18.x
- * release that registers it.
- */
- static override examples: Command.Example[] = [
- {
- description:
- 'Refused in 18.0, plan and apply alike: exit 1 with NOT_IMPLEMENTED, nothing read or written. Keep a v17 ' +
- 'database on a 17.x release until the 18.x release that carries the upgrade path',
- command: '<%= config.bin %> migrate organization-ownership',
- },
- {
- description: 'The same refusal as JSON: error, reason upgrade-unavailable, code NOT_IMPLEMENTED, and the detail',
- command: '<%= config.bin %> migrate organization-ownership --json',
- },
- ];
-
- override async run(): Promise {
- const { flags } = await this.parse(MigrateOrganizationOwnership);
- if (flags.json) {
- await emitJson(
- {
- error: flags.apply ? 'apply_refused' : 'plan_refused',
- reason: 'upgrade-unavailable',
- code: ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE,
- detail: ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE,
- },
- 1,
- { compact: true },
- );
- return;
- }
- printError(`${ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE} (${ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE})`);
- this.exit(1);
- }
-}
diff --git a/packages/cli/src/commands/migrate/sqlite-wasm.integration.test.ts b/packages/cli/src/commands/migrate/sqlite-wasm.integration.test.ts
index 7dde27230b6..4b1f4dc4d0d 100644
--- a/packages/cli/src/commands/migrate/sqlite-wasm.integration.test.ts
+++ b/packages/cli/src/commands/migrate/sqlite-wasm.integration.test.ts
@@ -36,7 +36,7 @@ import { fileURLToPath } from 'node:url';
import { isExitSignal } from '../../utils/format.js';
import MigrateApply from './apply.js';
import MigratePlan from './plan.js';
-import { OrganizationOwnershipCeremony as MigrateOrganizationOwnership } from './organization-ownership.js';
+import MigrateOrganizationOwnership from './organization-ownership.js';
// [#10126] Pay the first transform of the dist-resolved workspace deps the
// commands reach through dynamic `import()`s at MODULE LOAD, not inside a case.
diff --git a/packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts b/packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts
index 16980324527..932c82412a4 100644
--- a/packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts
+++ b/packages/cli/src/utils/dev-organization-ownership-upgrade.test.ts
@@ -86,8 +86,9 @@ describe('os dev — the in-place upgrade of a v17-shape development database',
expect(result.lines[0]).toContain('✗ Refused (sys_mystery)');
expect(result.lines.at(-1)).toContain('nothing was changed');
expect(result.lines.at(-1)).toContain(BACKUP());
- expect(result.lines.at(-1)).toContain('17.x release');
- expect(result.lines.at(-1)).not.toContain('--plan-file');
+ // The ceremony is open (18.x step 6): the way on is running it by hand.
+ expect(result.lines.at(-1)).toContain('os migrate organization-ownership --dev');
+ expect(result.lines.at(-1)).not.toContain('17.x');
});
it('a blocked step boots nothing: each blocker is printed, and how to resume', () => {
@@ -120,15 +121,13 @@ describe('os dev — the in-place upgrade of a v17-shape development database',
expect(result.lines.at(-1)).toContain(`--apply --plan-file ${join(BACKUP(), 'plan.json')} --backup-taken`);
});
- it('a database that is not a local SQLite file is not upgraded in place: 18.0\'s position is named instead', () => {
+ it('a database that is not a local SQLite file is not upgraded in place: the ceremony is named instead', () => {
const result = upgradeDevelopmentDatabase(input(fakeRun({}), { sqliteFile: null, databaseUrl: 'postgres://db/app' }));
expect(result.ok).toBe(false);
expect(calls).toEqual([]);
expect(existsSync(join(dir, 'backups'))).toBe(false);
- expect(result.lines[0]).toContain('17.x release');
- // 18.0 refuses `os migrate organization-ownership` on every database: the way on names no step of it.
- expect(result.lines[0]).not.toContain('os migrate organization-ownership');
- expect(result.lines[0]).not.toContain('--plan-file');
+ expect(result.lines[0]).toContain('os migrate organization-ownership --dev');
+ expect(result.lines[0]).not.toContain('17.x');
});
it('the IPC message is recognised by its type alone', () => {
diff --git a/packages/cli/src/utils/dev-organization-ownership-upgrade.ts b/packages/cli/src/utils/dev-organization-ownership-upgrade.ts
index 47a5a0318ec..c9f652245df 100644
--- a/packages/cli/src/utils/dev-organization-ownership-upgrade.ts
+++ b/packages/cli/src/utils/dev-organization-ownership-upgrade.ts
@@ -118,15 +118,11 @@ function refusalLines(logFile: string): string[] {
* Synchronous on purpose — nothing else may open the files while it runs.
*/
export function upgradeDevelopmentDatabase(input: DevelopmentUpgradeInput): DevelopmentUpgradeResult {
- // The way on, when os dev does not upgrade the database itself. 18.0 supports
- // fresh installs only, and its `os migrate organization-ownership` refuses on
- // every database (`ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE`), so this names
- // no step of that command: it says what 18.0 supports for a v17 database, in
- // the boot refusal's terms. The 18.x release that registers the ceremony names
- // its steps here again.
+ // The way on, when os dev does not upgrade the database itself: the ceremony,
+ // run by hand (18.x step 6 opened it).
const manual =
- '18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 development database on a ' +
- '17.x release until the 18.x release that carries the upgrade path, and upgrade it there.';
+ 'Run `os migrate organization-ownership --dev` (review its plan), then the same command with --apply ' +
+ '--plan-file FILE --backup-taken, and start os dev again.';
if (!input.sqliteFile) {
return {
ok: false,
diff --git a/packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts b/packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts
index b2f5abbf81e..bc68b8c32fa 100644
--- a/packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts
+++ b/packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts
@@ -9,8 +9,11 @@
*
* The fixture is HotCRM's measured shape: eleven uncustomized declared rules
* the seeder wrote organization-less (`managed_by 'package'`, `customized 0`),
- * beside an organization-authored rule and a customized declared rule with no
- * organization, plus a column-drop table still carrying `organization_id` —
+ * beside an organization-authored rule and a customized seeded rule with no
+ * organization whose declaration its package has since dropped (a customized
+ * copy of a rule still declared is the `declared-rule-copies` step's, pinned in
+ * `organization-ownership-apply.declared-rule-copies.test.ts`), plus a
+ * column-drop table still carrying `organization_id` —
* and the grants those rules materialised, each naming its rule row by id.
*
* - The mirror-deletion step renames every grant naming a mirror to the
@@ -123,8 +126,8 @@ function ledger(): CeremonyLedgerPort & { verdicts: Verdict[] } {
};
}
-/** Every rule the fixture's packages declare: the eleven mirrors' names and the customized one's. */
-const DECLARED = [...Array.from({ length: MIRRORS }, (_, i) => `share_declared_${i + 1}`), 'share_custom'];
+/** Every rule the fixture's packages declare: the eleven mirrors' names (the customized rule's is no longer declared). */
+const DECLARED = Array.from({ length: MIRRORS }, (_, i) => `share_declared_${i + 1}`);
/**
* Plan, then apply that reviewed plan, under `single`, as the command does.
@@ -352,11 +355,26 @@ describe('the declared sharing-rule mirrors, once their seeder has retired', ()
const l = ledger();
const report = await apply(l, undefined, null);
expect(report.complete).toBe(false);
+ // The customized seeded rule stops it first: whether it is a copy of a
+ // declared rule is read from the same registry.
+ expect(report.steps.find((s) => s.step === 'declared-rule-copies')).toMatchObject({
+ status: 'blocked',
+ blockers: [expect.stringMatching(/no registry to read them from/)],
+ });
+ expect(report.steps.find((s) => s.step === 'mirror-deletion')).toMatchObject({ status: 'not-reached' });
+ expect(rows(`SELECT COUNT(*) AS n FROM sys_sharing_rule`)).toEqual([{ n: MIRRORS + 2 }]);
+ });
+
+ it('with no customized copy to stop it first, the mirror-deletion step blocks on the missing registry itself', async () => {
+ db.exec(`DELETE FROM sys_sharing_rule WHERE id = 'rule_customized'`);
+ const report = await apply(ledger(), undefined, null);
+ expect(report.complete).toBe(false);
+ expect(report.steps.find((s) => s.step === 'declared-rule-copies')).toMatchObject({ status: 'done' });
expect(report.steps.find((s) => s.step === 'mirror-deletion')).toMatchObject({
status: 'blocked',
blockers: [expect.stringMatching(/no registry to read the declared sharing rules from/)],
});
- expect(rows(`SELECT COUNT(*) AS n FROM sys_sharing_rule`)).toEqual([{ n: MIRRORS + 2 }]);
+ expect(rows(`SELECT COUNT(*) AS n FROM sys_sharing_rule`)).toEqual([{ n: MIRRORS + 1 }]);
});
it('an organization-stamped mirror (a walled seeder\'s copy) goes too, its grant renamed; a customized one stays', async () => {
diff --git a/packages/cli/src/utils/organization-ownership-apply.generation-1.test.ts b/packages/cli/src/utils/organization-ownership-apply.generation-1.test.ts
new file mode 100644
index 00000000000..a58296de0e4
--- /dev/null
+++ b/packages/cli/src/utils/organization-ownership-apply.generation-1.test.ts
@@ -0,0 +1,433 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The four ceremony steps of ownership shape generation 1 (18.x steps 5a–5d),
+ * pinned in the ceremony's own terms, over an 18.0-SHAPED database: every row
+ * stamped with the Default Organization (line fact F3), so each step must
+ * select by shape — a NULL selector would find nothing here.
+ *
+ * - `sibling-moves` (5b): the global settings-audit rows and identity mail
+ * (reset by `related_object`, the magic link by its sign-in link) move id
+ * for id into their siblings, the organization invitation stays; a queued
+ * 18.0 mail job is pointed at the sibling first; the activity rows about a
+ * moved row name its new object.
+ * - `declared-webhooks` (5a): a pristine copy is deleted, its deliveries
+ * re-sourced to `declared:NAME` and its orphaned cipher deleted; a
+ * switch-only edit becomes the declaration's activation row; any other
+ * edit becomes a clone under a new name, the declaration switched off, its
+ * deliveries still its own; a seeded row no package declares stays.
+ * - `declared-rule-copies` (5c, 9c): a customized copy and an admin rule under
+ * a declared name become clones, the declaration switched off, their grants
+ * kept; the stale name-keyed grant goes.
+ * - `not-null` (5d, 8e): every cleared table receives NOT NULL through the
+ * port, after the column drops.
+ *
+ * Plus: the checkpoint carries this release's shape generation; a walled
+ * posture blocks on a customized copy rather than switch a declaration off
+ * for every organization; every step is idempotent.
+ */
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import Database from 'better-sqlite3';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { ORGANIZATION_OWNERSHIP_SHAPE_GENERATION } from '@objectstack/spec/system';
+import { declaredWebhookRefId } from '@objectstack/plugin-webhooks';
+import { buildOrganizationOwnershipPlan, inventoryDigest } from './organization-ownership-plan.js';
+import {
+ applyOrganizationOwnership,
+ CEREMONY_STEPS,
+ DECLARED_WEBHOOK_REF_PREFIX,
+ reviewedConflictsOf,
+ type ApplyExecutor,
+ type CeremonyCheckpoint,
+ type CeremonyLedgerPort,
+ type ColumnDropPort,
+ type NotNullColumn,
+ type NotNullPort,
+ type OrganizationOwnershipApplyReport,
+} from './organization-ownership-apply.js';
+
+const ORG = 'org_default';
+
+let dir: string;
+let db: Database.Database;
+let writes: string[];
+let tightened: NotNullColumn[];
+
+function executor(): ApplyExecutor {
+ return {
+ dialect: 'sqlite',
+ client: 'better-sqlite3',
+ async query(sql, params = []) {
+ return db.prepare(sql).all(...(params as unknown[])) as Record[];
+ },
+ async execute(sql, params = []) {
+ writes.push(sql);
+ db.prepare(sql).run(...(params as unknown[]));
+ },
+ };
+}
+
+const columnDrops: ColumnDropPort = {
+ async drop(columns) {
+ return columns.map(({ table, column }) => {
+ writes.push(`ALTER TABLE ${table} DROP COLUMN ${column}`);
+ db.exec(`ALTER TABLE "${table}" DROP COLUMN "${column}"`);
+ return { table, column, outcome: 'dropped' as const };
+ });
+ },
+};
+
+/** The schema door: records what it was asked, and answers `applied`. */
+const notNull: NotNullPort = {
+ async apply(columns) {
+ tightened.push(...columns);
+ return columns.map((c) => ({ ...c, outcome: 'applied' as const }));
+ },
+};
+
+function ledger(): CeremonyLedgerPort & { stored: () => CeremonyCheckpoint | null } {
+ let stored: CeremonyCheckpoint | null = null;
+ return {
+ stored: () => stored,
+ async read() {
+ return stored ? (JSON.parse(JSON.stringify(stored)) as CeremonyCheckpoint) : null;
+ },
+ async write(checkpoint) {
+ stored = JSON.parse(JSON.stringify(checkpoint)) as CeremonyCheckpoint;
+ },
+ };
+}
+
+/** The webhooks and sharing rules the fixture's packages declare. */
+const WEBHOOKS = [
+ { name: 'task_changed', packageId: 'com.example.app' },
+ { name: 'deal_won', packageId: 'com.example.app' },
+ { name: 'lead_scored', packageId: 'com.example.app' },
+];
+const RULES = ['share_custom', 'share_collides'];
+
+async function apply(
+ l: ReturnType = ledger(),
+ opts: { posture?: string; webhooks?: boolean; rules?: boolean; notNull?: boolean } = {},
+): Promise {
+ const posture = opts.posture ?? 'single';
+ const database = join(dir, 'fixture.db');
+ const reviewed = await buildOrganizationOwnershipPlan({ reader: executor(), posture, database });
+ return applyOrganizationOwnership({
+ executor: executor(),
+ posture,
+ database,
+ reviewedPlan: {
+ file: 'plan.json',
+ generatedAt: reviewed.generatedAt,
+ database,
+ inventoryDigest: inventoryDigest(),
+ posture,
+ conflicts: reviewedConflictsOf(reviewed),
+ },
+ catalog: { async resolve() { return undefined; } },
+ ...(opts.webhooks === false ? {} : { declaredWebhooks: { async list() { return WEBHOOKS; } } }),
+ ...(opts.rules === false
+ ? {}
+ : { declaredSharingRules: { async rules() { return RULES.map((name) => ({ name, refused: null, packageId: 'com.example.app' })); } } }),
+ ...(opts.notNull === false ? {} : { notNull }),
+ door: {
+ async save(type, name) {
+ throw new Error(`no environment save is expected here (${type} ${name})`);
+ },
+ },
+ templates: { async find() { return undefined; } },
+ views: { async list() { return []; } },
+ lock: { async verdict() { return { editable: true }; } },
+ columnDrops,
+ ledger: l,
+ now: () => new Date('2026-10-19T00:00:00Z'),
+ });
+}
+
+const rows = (sql: string): Record[] => db.prepare(sql).all() as Record[];
+const step = (report: OrganizationOwnershipApplyReport, name: string) => report.steps.find((s) => s.step === name)!;
+
+/** A seeded 18.0 webhook row: the seeder's mapping of the envelope, plus the snapshot it wrote. */
+function seededWebhook(id: string, name: string, over: Record = {}): void {
+ const envelope = { name, label: `Label ${name}`, object: 'crm_deal', triggers: ['create', 'update'], url: `https://hooks.example/${name}`, method: 'POST', timeoutMs: 30000, isActive: false, _provenance: 'package', _packageId: 'com.example.app', _packageVersion: '1.0.0' };
+ const row: Record = {
+ id, organization_id: ORG, name, label: envelope.label, object_name: 'crm_deal', triggers: JSON.stringify(envelope.triggers),
+ url: envelope.url, method: 'post', description: null, active: 0, definition_json: JSON.stringify(envelope),
+ headers_secret: null, signing_secret: null, managed_by: 'package', customized: 0, ...over,
+ };
+ const keys = Object.keys(row);
+ db.prepare(`INSERT INTO sys_webhook (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`).run(...keys.map((k) => row[k]));
+}
+
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'os-d10-generation-1-'));
+ db = new Database(join(dir, 'fixture.db'));
+ writes = [];
+ tightened = [];
+ db.exec(`
+ CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT);
+ INSERT INTO sys_organization VALUES ('${ORG}', 'default');
+ CREATE TABLE crm_deal (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT);
+ INSERT INTO crm_deal VALUES ('deal_1', '${ORG}', 'Acme');
+ CREATE TABLE sys_user (id TEXT PRIMARY KEY, email TEXT);
+ INSERT INTO sys_user VALUES ('usr_b', 'b@example.test');
+
+ CREATE TABLE sys_metadata_activation (id TEXT PRIMARY KEY, created_at TEXT, updated_at TEXT, metadata_type TEXT,
+ name TEXT, package_id TEXT, active INTEGER DEFAULT 1, UNIQUE (metadata_type, name));
+
+ CREATE TABLE sys_setting_audit (id TEXT PRIMARY KEY, created_at TEXT, organization_id TEXT, namespace TEXT, key TEXT,
+ scope TEXT, action TEXT, new_hash TEXT, encrypted INTEGER);
+ INSERT INTO sys_setting_audit VALUES
+ ('aud_tenant', '2026-10-19T12:00:00Z', '${ORG}', 'branding', 'workspace_name', 'tenant', 'set', 'fnv32:1', 0),
+ ('aud_global', '2026-10-19T12:00:01Z', '${ORG}', 'auth', 'api_key_policy', 'global', 'set', 'fnv32:2', 0),
+ ('aud_global_enc', '2026-10-19T12:00:02Z', '${ORG}', 'ai', 'gateway_api_key', 'global', 'set', 'hmac:3', 1);
+ CREATE TABLE sys_platform_setting_audit (id TEXT PRIMARY KEY, created_at TEXT, namespace TEXT, key TEXT, scope TEXT,
+ action TEXT, new_hash TEXT, encrypted INTEGER);
+
+ CREATE TABLE sys_email (id TEXT PRIMARY KEY, organization_id TEXT, subject TEXT, body_text TEXT, body_html TEXT,
+ status TEXT, related_object TEXT, related_id TEXT);
+ INSERT INTO sys_email VALUES
+ ('mail_invite', '${ORG}', 'You are invited to Default Organization', 'join', NULL, 'sent', 'sys_invitation', 'inv_1'),
+ ('mail_reset', '${ORG}', 'Reset your password', 'reset at /reset-password/tok', NULL, 'sent', 'sys_user', 'usr_b'),
+ ('mail_magic', '${ORG}', 'Your sign-in link', 'open https://app.example/api/auth/magic-link/verify?token=abc', NULL, 'pending', NULL, NULL),
+ ('mail_app', '${ORG}', 'Your invoice', 'see attached', NULL, 'sent', 'crm_deal', 'deal_1');
+ CREATE TABLE sys_identity_email (id TEXT PRIMARY KEY, subject TEXT, body_text TEXT, body_html TEXT, status TEXT,
+ related_object TEXT, related_id TEXT);
+
+ CREATE TABLE sys_job_queue (id TEXT PRIMARY KEY, queue TEXT, idempotency_key TEXT, payload_json TEXT, metadata_json TEXT, status TEXT);
+ INSERT INTO sys_job_queue VALUES
+ ('job_magic', 'email.send.async', 'sys_email:mail_magic', '{"rowId":"mail_magic"}', '{"object":"sys_email","rowId":"mail_magic"}', 'pending'),
+ ('job_app', 'email.send.async', 'sys_email:mail_app', '{"rowId":"mail_app"}', '{"object":"sys_email","rowId":"mail_app"}', 'pending'),
+ ('job_done', 'email.attachment.reclaim', 'sys_email_attachments:mail_reset', '{"rowId":"mail_reset","keys":[]}', NULL, 'completed');
+
+ CREATE TABLE sys_activity (id TEXT PRIMARY KEY, organization_id TEXT, object_name TEXT, record_id TEXT, summary TEXT);
+ INSERT INTO sys_activity VALUES
+ ('act_reset', '${ORG}', 'sys_email', 'mail_reset', 'mail sent'),
+ ('act_audit', '${ORG}', 'sys_setting_audit', 'aud_global', 'setting changed'),
+ ('act_deal', '${ORG}', 'crm_deal', 'deal_1', 'deal updated');
+
+ CREATE TABLE sys_webhook (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, label TEXT, object_name TEXT,
+ triggers TEXT, url TEXT, method TEXT, description TEXT, active INTEGER, definition_json TEXT, headers_secret TEXT,
+ signing_secret TEXT, managed_by TEXT, customized INTEGER);
+ CREATE TABLE sys_http_delivery (id TEXT PRIMARY KEY, organization_id TEXT, source TEXT, ref_id TEXT, dedup_key TEXT, status TEXT);
+ CREATE TABLE sys_secret (id TEXT PRIMARY KEY, organization_id TEXT, namespace TEXT, key TEXT, ciphertext TEXT);
+ INSERT INTO sys_secret VALUES
+ ('sec_pristine', '${ORG}', 'sys_webhook', 'signing_secret', 'c1'),
+ ('sec_setting', '${ORG}', 'ai', 'gateway_api_key', 'c2');
+
+ CREATE TABLE sys_sharing_rule (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, object_name TEXT, managed_by TEXT, customized INTEGER);
+ INSERT INTO sys_sharing_rule VALUES
+ ('rule_custom', '${ORG}', 'share_custom', 'crm_deal', 'package', 1),
+ ('rule_collides', '${ORG}', 'share_collides', 'crm_deal', 'admin', 0),
+ ('rule_own', '${ORG}', 'mine', 'crm_deal', 'admin', 0);
+ CREATE TABLE sys_record_share (id TEXT PRIMARY KEY, organization_id TEXT, object_name TEXT, record_id TEXT, source TEXT, source_id TEXT);
+ INSERT INTO sys_record_share VALUES
+ ('grant_custom', '${ORG}', 'crm_deal', 'deal_1', 'rule', 'rule_custom'),
+ ('grant_stale', '${ORG}', 'crm_deal', 'deal_1', 'rule', 'share_custom'),
+ ('grant_own', '${ORG}', 'crm_deal', 'deal_1', 'rule', 'rule_own');
+ `);
+ // A pristine copy holding a cipher; a switch-only edit; an edit beyond the switch; a row no package declares.
+ seededWebhook('whk_pristine', 'task_changed', { signing_secret: 'secret:sec_pristine' });
+ seededWebhook('whk_switched', 'deal_won', { active: 1, customized: 1 });
+ seededWebhook('whk_edited', 'lead_scored', { url: 'https://hooks.example/admin-moved-it', customized: 1 });
+ seededWebhook('whk_orphan', 'retired_hook');
+ db.exec(`
+ INSERT INTO sys_http_delivery VALUES
+ ('dlv_1', '${ORG}', 'webhook', 'whk_pristine', 'whk_pristine:e1', 'pending'),
+ ('dlv_2', '${ORG}', 'webhook', 'whk_switched', 'whk_switched:e2', 'failed'),
+ ('dlv_3', '${ORG}', 'webhook', 'whk_edited', 'whk_edited:e3', 'sent'),
+ ('dlv_4', '${ORG}', 'webhook', 'whk_orphan', 'whk_orphan:e4', 'sent');
+ `);
+});
+
+afterEach(() => {
+ db.close();
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe('ownership shape generation 1 — the ceremony steps an 18.0 database owes', () => {
+ it('runs every step in order, certifies, and records this release\'s shape generation in the checkpoint', async () => {
+ const l = ledger();
+ const report = await apply(l);
+ expect(report.steps.map((s) => [s.step, s.status])).toEqual(CEREMONY_STEPS.map((s) => [s, 'done']));
+ expect([report.complete, report.blocking]).toEqual([true, 0]);
+ expect(l.stored()?.shapeGeneration).toBe(ORGANIZATION_OWNERSHIP_SHAPE_GENERATION);
+ // The new steps run where the ruling put them: the moves after settings-move, NOT NULL after the column drops.
+ expect(CEREMONY_STEPS.indexOf('sibling-moves')).toBe(CEREMONY_STEPS.indexOf('settings-move') + 1);
+ expect(CEREMONY_STEPS.at(-1)).toBe('not-null');
+ expect(CEREMONY_STEPS.indexOf('not-null')).toBeGreaterThan(CEREMONY_STEPS.indexOf('column-drops'));
+ });
+
+ it('5b ⭐ the global audit rows and identity mail move id for id — selected by shape, though stamped — and the rest stays', async () => {
+ const report = await apply();
+ expect(step(report, 'sibling-moves')).toMatchObject({ status: 'done', summary: { moved: 4, alreadyMoved: 0, jobsRewritten: 1, activityRepointed: 2 } });
+ expect(rows(`SELECT id FROM sys_platform_setting_audit ORDER BY id`).map((r) => r.id)).toEqual(['aud_global', 'aud_global_enc']);
+ expect(rows(`SELECT id FROM sys_setting_audit`).map((r) => r.id)).toEqual(['aud_tenant']);
+ expect(rows(`SELECT encrypted, new_hash FROM sys_platform_setting_audit WHERE id = 'aud_global_enc'`)).toEqual([{ encrypted: 1, new_hash: 'hmac:3' }]);
+ // Identity mail: the reset (a person's mail) and the magic link (its sign-in link); the invitation and app mail stay.
+ expect(rows(`SELECT id FROM sys_identity_email ORDER BY id`).map((r) => r.id)).toEqual(['mail_magic', 'mail_reset']);
+ expect(rows(`SELECT id FROM sys_email ORDER BY id`).map((r) => r.id)).toEqual(['mail_app', 'mail_invite']);
+ });
+
+ it('5b ⭐ a queued 18.0 mail job names its moved row\'s sibling; jobs of mail that stays, and finished jobs, are untouched', async () => {
+ await apply();
+ expect(rows(`SELECT id, idempotency_key, payload_json, metadata_json FROM sys_job_queue ORDER BY id`)).toEqual([
+ { id: 'job_app', idempotency_key: 'sys_email:mail_app', payload_json: '{"rowId":"mail_app"}', metadata_json: '{"object":"sys_email","rowId":"mail_app"}' },
+ { id: 'job_done', idempotency_key: 'sys_email_attachments:mail_reset', payload_json: '{"rowId":"mail_reset","keys":[]}', metadata_json: null },
+ {
+ id: 'job_magic',
+ idempotency_key: 'sys_identity_email:mail_magic',
+ payload_json: '{"rowId":"mail_magic","object":"sys_identity_email"}',
+ metadata_json: '{"object":"sys_identity_email","rowId":"mail_magic"}',
+ },
+ ]);
+ // The job is rewritten BEFORE its row leaves sys_email.
+ const job = writes.findIndex((w) => /^UPDATE "sys_job_queue"/.test(w));
+ const leave = writes.findIndex((w) => /^DELETE FROM "sys_email"/.test(w));
+ expect(job).toBeGreaterThan(-1);
+ expect(job).toBeLessThan(leave);
+ });
+
+ it('5b the activity rows about a moved row name its new object; the rest of the timeline is untouched', async () => {
+ await apply();
+ expect(rows(`SELECT id, object_name FROM sys_activity ORDER BY id`)).toEqual([
+ { id: 'act_audit', object_name: 'sys_platform_setting_audit' },
+ { id: 'act_deal', object_name: 'crm_deal' },
+ { id: 'act_reset', object_name: 'sys_identity_email' },
+ ]);
+ });
+
+ it('5b a run stopped between copy and delete finishes: the row already in the sibling is not copied twice', async () => {
+ db.exec(`INSERT INTO sys_identity_email (id, subject, status, related_object, related_id) VALUES ('mail_reset', 'Reset your password', 'sent', 'sys_user', 'usr_b')`);
+ const report = await apply();
+ expect(step(report, 'sibling-moves')).toMatchObject({ status: 'done', summary: { moved: 3, alreadyMoved: 1 } });
+ expect(rows(`SELECT COUNT(*) AS n FROM sys_identity_email WHERE id = 'mail_reset'`)).toEqual([{ n: 1 }]);
+ expect(rows(`SELECT COUNT(*) AS n FROM sys_email WHERE id = 'mail_reset'`)).toEqual([{ n: 0 }]);
+ });
+
+ it('5a ⭐ a pristine copy is deleted once its deliveries name the declaration, and its orphaned cipher goes with it', async () => {
+ const report = await apply();
+ expect(step(report, 'declared-webhooks')).toMatchObject({
+ status: 'done',
+ summary: { mirrorsDeleted: 2, activationRows: 2, clones: 1, deliveriesResourced: 2, secretsDeleted: 1, undeclaredKept: 1 },
+ });
+ expect(rows(`SELECT ref_id FROM sys_http_delivery WHERE id = 'dlv_1'`)).toEqual([{ ref_id: declaredWebhookRefId('task_changed') }]);
+ expect(rows(`SELECT id FROM sys_secret ORDER BY id`).map((r) => r.id)).toEqual(['sec_setting']);
+ // The re-source precedes the delete.
+ const resource = writes.findIndex((w) => /^UPDATE "sys_http_delivery"/.test(w));
+ const remove = writes.findIndex((w) => /^DELETE FROM "sys_webhook"/.test(w));
+ expect(resource).toBeGreaterThan(-1);
+ expect(resource).toBeLessThan(remove);
+ });
+
+ it('5a ⭐ a switch-only edit becomes the declaration\'s activation row; an edit beyond it a clone, the declaration switched off', async () => {
+ await apply();
+ expect(rows(`SELECT metadata_type, name, package_id, active FROM sys_metadata_activation ORDER BY name`)).toEqual([
+ { metadata_type: 'webhook', name: 'deal_won', package_id: 'com.example.app', active: 1 },
+ { metadata_type: 'webhook', name: 'lead_scored', package_id: 'com.example.app', active: 0 },
+ { metadata_type: 'sharing_rule', name: 'share_collides', package_id: 'com.example.app', active: 0 },
+ { metadata_type: 'sharing_rule', name: 'share_custom', package_id: 'com.example.app', active: 0 },
+ ].sort((a, b) => a.name.localeCompare(b.name)));
+ expect(rows(`SELECT ref_id FROM sys_http_delivery WHERE id = 'dlv_2'`)).toEqual([{ ref_id: 'declared:deal_won' }]);
+ // The clone keeps its id, its edit and its deliveries; it is the administrator's own now.
+ const [clone] = rows(`SELECT id, name, url, managed_by, customized, definition_json FROM sys_webhook WHERE id = 'whk_edited'`);
+ expect(clone).toMatchObject({ name: 'lead_scored_custom', url: 'https://hooks.example/admin-moved-it', managed_by: 'admin', customized: 0 });
+ const def = JSON.parse(String(clone!.definition_json));
+ expect(def.name).toBe('lead_scored_custom');
+ expect(def).not.toHaveProperty('_packageId');
+ expect(rows(`SELECT ref_id FROM sys_http_delivery WHERE id = 'dlv_3'`)).toEqual([{ ref_id: 'whk_edited' }]);
+ // A seeded row no package declares stays as it is, dispatching as itself.
+ expect(rows(`SELECT id, name, managed_by FROM sys_webhook ORDER BY id`)).toEqual([
+ { id: 'whk_edited', name: 'lead_scored_custom', managed_by: 'admin' },
+ { id: 'whk_orphan', name: 'retired_hook', managed_by: 'package' },
+ ]);
+ expect(rows(`SELECT ref_id FROM sys_http_delivery WHERE id = 'dlv_4'`)).toEqual([{ ref_id: 'whk_orphan' }]);
+ });
+
+ it('5a the delivery spelling is plugin-webhooks\' own', () => {
+ expect(`${DECLARED_WEBHOOK_REF_PREFIX}x`).toBe(declaredWebhookRefId('x'));
+ });
+
+ it('5a a credential on a customized copy is never read as switch-only: it is cloned, which keeps it', async () => {
+ db.exec(`UPDATE sys_webhook SET headers_secret = 'secret:sec_setting' WHERE id = 'whk_switched'`);
+ await apply();
+ expect(rows(`SELECT name, headers_secret FROM sys_webhook WHERE id = 'whk_switched'`)).toEqual([{ name: 'deal_won_custom', headers_secret: 'secret:sec_setting' }]);
+ expect(rows(`SELECT id FROM sys_secret WHERE id = 'sec_setting'`)).toHaveLength(1);
+ });
+
+ it('5c ⭐ a customized copy and an admin rule under a declared name become clones, their grants theirs; the stale name grant goes', async () => {
+ const report = await apply();
+ expect(step(report, 'declared-rule-copies')).toMatchObject({ status: 'done', summary: { clones: 2, activationRows: 2, grantsRemoved: 1 } });
+ expect(rows(`SELECT id, name, managed_by, customized FROM sys_sharing_rule ORDER BY id`)).toEqual([
+ { id: 'rule_collides', name: 'share_collides_custom', managed_by: 'admin', customized: 0 },
+ { id: 'rule_custom', name: 'share_custom_custom', managed_by: 'admin', customized: 0 },
+ { id: 'rule_own', name: 'mine', managed_by: 'admin', customized: 0 },
+ ]);
+ expect(rows(`SELECT id, source_id FROM sys_record_share ORDER BY id`)).toEqual([
+ { id: 'grant_custom', source_id: 'rule_custom' },
+ { id: 'grant_own', source_id: 'rule_own' },
+ ]);
+ });
+
+ it('5c/5a under a wall a customized copy blocks, named, and nothing of it is written: the switch is deployment-wide', async () => {
+ db.exec(`DELETE FROM sys_webhook WHERE customized = 1`);
+ const report = await apply(ledger(), { posture: 'isolated' });
+ expect(report.complete).toBe(false);
+ const rules = step(report, 'declared-rule-copies');
+ expect(rules.status).toBe('blocked');
+ expect(rules.blockers[0]).toMatch(/Under the isolated posture the declaration's switch is deployment-wide/);
+ expect(rows(`SELECT name FROM sys_sharing_rule WHERE id = 'rule_custom'`)).toEqual([{ name: 'share_custom' }]);
+ expect(rows(`SELECT COUNT(*) AS n FROM sys_metadata_activation WHERE metadata_type = 'sharing_rule'`)).toEqual([{ n: 0 }]);
+ });
+
+ it('5a without the declared webhooks to read, a seeded row blocks the step and nothing is deleted', async () => {
+ const report = await apply(ledger(), { webhooks: false });
+ expect(step(report, 'declared-webhooks')).toMatchObject({ status: 'blocked', blockers: [expect.stringMatching(/no registry to read them from/)] });
+ expect(rows(`SELECT COUNT(*) AS n FROM sys_webhook`)).toEqual([{ n: 4 }]);
+ });
+
+ it('5d ⭐ after the column drops, every cleared table is handed to the schema door for NOT NULL — the moved-from tables among them', async () => {
+ const report = await apply();
+ expect(step(report, 'not-null').status).toBe('done');
+ const tables = tightened.map((c) => c.table).sort();
+ for (const table of ['crm_deal', 'sys_email', 'sys_setting_audit', 'sys_http_delivery', 'sys_webhook', 'sys_sharing_rule', 'sys_record_share', 'sys_activity']) {
+ expect(tables, table).toContain(table);
+ }
+ expect(tightened.every((c) => c.column === 'organization_id')).toBe(true);
+ // A column-drop table is never handed to it: its column is gone.
+ expect(tables).not.toContain('sys_secret');
+ expect(rows(`SELECT name FROM pragma_table_info('sys_secret') WHERE name = 'organization_id'`)).toEqual([]);
+ // The door is asked only once the drops have run.
+ expect(writes.some((w) => /^ALTER TABLE sys_secret DROP COLUMN organization_id$/.test(w))).toBe(true);
+ });
+
+ it('5d a table that still holds a NULL organization row is not handed to the door; the cleared ones are', async () => {
+ // Under a wall nothing owns an orphan row; the copies a wall would block on are gone.
+ db.exec(`
+ INSERT INTO crm_deal VALUES ('deal_orphan', NULL, 'nobody');
+ DELETE FROM sys_webhook WHERE customized = 1;
+ DELETE FROM sys_sharing_rule WHERE id IN ('rule_custom', 'rule_collides');
+ `);
+ const report = await apply(ledger(), { posture: 'isolated' });
+ expect(step(report, 'not-null').status).toBe('done');
+ expect(tightened.map((c) => c.table)).not.toContain('crm_deal');
+ expect(tightened.map((c) => c.table)).toContain('sys_email');
+ });
+
+ it('5d a run with no schema door applies nothing and says so; it does not block', async () => {
+ const report = await apply(ledger(), { notNull: false });
+ expect(step(report, 'not-null')).toMatchObject({ status: 'done', notes: [expect.stringMatching(/no schema door to apply it: nothing was applied/)] });
+ });
+
+ it('every new step is idempotent: a second run on a fresh checkpoint writes nothing to the tables they own', async () => {
+ await apply();
+ writes = [];
+ const again = await apply();
+ expect([again.complete, again.blocking]).toEqual([true, 0]);
+ expect(writes.filter((w) => /sys_webhook|sys_http_delivery|sys_identity_email|sys_platform_setting_audit|sys_job_queue|sys_sharing_rule|sys_metadata_activation/.test(w))).toEqual([]);
+ });
+});
diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts
index 82ee81fae79..40b3a0181d7 100644
--- a/packages/cli/src/utils/organization-ownership-apply.test.ts
+++ b/packages/cli/src/utils/organization-ownership-apply.test.ts
@@ -530,10 +530,14 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => {
['metadata-promotion', 'done'],
['template-promotion', 'done'],
['settings-move', 'done'],
+ ['sibling-moves', 'done'],
+ ['declared-webhooks', 'done'],
+ ['declared-rule-copies', 'done'],
['rewrite', 'done'],
['mirror-deletion', 'done'],
['history-renumber', 'done'],
['column-drops', 'done'],
+ ['not-null', 'done'],
]);
expect(report.complete).toBe(true);
// Ruling C: the fate-4 orphan is the one discrepancy — the marker is not verified.
@@ -781,7 +785,7 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => {
const again = await apply(c, l);
expect(again.resumedFrom).toEqual([
- 'attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion', 'history-renumber', 'column-drops',
+ 'attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'sibling-moves', 'declared-webhooks', 'declared-rule-copies', 'rewrite', 'mirror-deletion', 'history-renumber', 'column-drops', 'not-null',
]);
expect(again.steps.every((s) => s.status === 'checkpointed')).toBe(true);
expect(again.complete).toBe(true);
@@ -793,14 +797,14 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => {
const c = catalog();
const l = ledger();
await expect(apply(c, l, { executor: executor({ failOnceOn: /^DELETE FROM "sys_capability"/ }) })).rejects.toThrow(/killed mid-step/);
- expect(Object.keys(l.checkpoint!.completed)).toEqual(['attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite']);
+ expect(Object.keys(l.checkpoint!.completed)).toEqual(['attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'sibling-moves', 'declared-webhooks', 'declared-rule-copies', 'rewrite']);
expect(rows('SELECT COUNT(*) AS n FROM sys_position_permission_set')).toEqual([{ n: 3 }]);
writes = [];
const resumed = await apply(c, l);
- expect(resumed.resumedFrom).toEqual(['attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite']);
- expect(resumed.steps.map((s) => s.status)).toEqual(['checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'done', 'done', 'done']);
+ expect(resumed.resumedFrom).toEqual(['attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'sibling-moves', 'declared-webhooks', 'declared-rule-copies', 'rewrite']);
+ expect(resumed.steps.map((s) => s.status)).toEqual([...Array(9).fill('checkpointed'), 'done', 'done', 'done', 'done']);
expect(writes.some((w) => /^UPDATE/.test(w))).toBe(false);
expect(resumed.complete).toBe(true);
expect(rows('SELECT COUNT(*) AS n FROM sys_position_permission_set')).toEqual([{ n: 0 }]);
@@ -1067,7 +1071,7 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => {
// (a declared object the differ proposed no drop for).
const notProposed = await answering('not-proposed');
expect(notProposed.resumedFrom).toEqual([
- 'attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion', 'history-renumber',
+ 'attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'sibling-moves', 'declared-webhooks', 'declared-rule-copies', 'rewrite', 'mirror-deletion', 'history-renumber',
]);
const control = notProposed.steps.find((s) => s.step === 'column-drops')!;
expect(control.status).toBe('blocked');
diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts
index 2fdb0c395bd..380ef3b0ae2 100644
--- a/packages/cli/src/utils/organization-ownership-apply.ts
+++ b/packages/cli/src/utils/organization-ownership-apply.ts
@@ -1,6 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
-import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system';
+import { randomUUID } from 'node:crypto';
+import { ORGANIZATION_OWNERSHIP_MIGRATION_ID, ORGANIZATION_OWNERSHIP_SHAPE_GENERATION } from '@objectstack/spec/system';
import {
convertPositionBindingRows,
permissionSetDefinitionFromRow,
@@ -27,6 +28,7 @@ import {
columnNameOf,
predicateSql,
PRIMARY_DATABASE,
+ requireColumn,
Sql,
underivableDepartureOf,
type AttributionSink,
@@ -103,6 +105,19 @@ import type { DeclaredSharingRuleSkip } from '@objectstack/plugin-sharing';
* copied, by the database itself, into `sys_platform_setting`, read back,
* and only then removed from `sys_setting`.
* A key the new table already holds with a different value is a blocker.
+ * 2d. `sibling-moves` (18.x step 5b) — the deployment-level rows an 18.0
+ * database keeps in a tenant table move, id for id, into the sibling this
+ * release writes them to: the settings audit trail's global rung and
+ * identity mail, selected by shape, the mail's queued jobs and the rows'
+ * activity timeline pointed at the sibling first ({@link runSiblingMoves}).
+ * 2e. `declared-webhooks` (18.x step 5a) — each `sys_webhook` copy a seeder
+ * wrote under a declared name is settled: a pristine one deleted once its
+ * deliveries name the declaration, a switch-only edit kept as the
+ * declaration's activation row, any other edit kept as a clone under a new
+ * name with the declaration switched off ({@link runDeclaredWebhooks}).
+ * 2f. `declared-rule-copies` (18.x step 5c, C3 stage 9c) — each stored
+ * sharing rule under a declared rule's name becomes a clone under a new
+ * name, the declaration switched off ({@link runDeclaredRuleCopies}).
* 3. `rewrite` — the verified id→name rewrite (D4):
* `sys_user_permission_set.permission_set` is named from the set row its
* `permission_set_id` points at, then EVERY grant is verified to name a set
@@ -137,6 +152,10 @@ import type { DeclaredSharingRuleSkip } from '@objectstack/plugin-sharing';
* (C7b), once the rewrite has read it and no two grants hold one set for
* one user in one organization (the unique index that replaces the old
* one would refuse them; they are reported, never deleted).
+ * 6. `not-null` (18.x step 5d, 8e) — every table the plan now clears
+ * receives NOT NULL on `organization_id`, through the schema door
+ * ({@link NotNullPort}); the ceremony owns that nullability and the
+ * differ reads it as no drift (ruling record 6269955919, ② A).
*
* Then the post-check re-runs the plan and reports, per table, what remains.
* A departure no step of a release can perform — the declared sharing-rule
@@ -189,7 +208,9 @@ import type { DeclaredSharingRuleSkip } from '@objectstack/plugin-sharing';
*
* ## What it never does
*
- * ⛔ Delete a row outside fate 2 and a declared `underivableDeparture`. ⛔ Give an unattributable row an owner:
+ * ⛔ Delete a row outside fate 2, a declared `underivableDeparture`, a moved
+ * row once its copy reads back, a declared webhook's copy, and the grants of a
+ * declaration it switched off. ⛔ Give an unattributable row an owner:
* owners come only from the plan's anchors and, under `single`, the Default
* Organization. ⛔ Create any organization but the Default Organization, and
* that one only under `single` on a database that holds none. ⛔ Run at boot:
@@ -205,10 +226,14 @@ export const CEREMONY_STEPS = [
'metadata-promotion',
'template-promotion',
'settings-move',
+ 'sibling-moves',
+ 'declared-webhooks',
+ 'declared-rule-copies',
'rewrite',
'mirror-deletion',
'history-renumber',
'column-drops',
+ 'not-null',
] as const;
export type CeremonyStep = (typeof CEREMONY_STEPS)[number];
@@ -269,7 +294,8 @@ export interface MetadataLockPort {
* reason.
*/
export interface DeclaredSharingRulesPort {
- rules(): Promise>;
+ /** `packageId`: the declaring package, for the switch row a settled copy writes (`declared-rule-copies`). */
+ rules(): Promise>;
}
/**
@@ -278,14 +304,17 @@ export interface DeclaredSharingRulesPort {
* {@link DeclaredSharingRulesPort}: a nameless item is no rule to rename onto
* and is left out.
*/
-export async function judgeDeclaredSharingRules(items: readonly unknown[]): Promise> {
+export async function judgeDeclaredSharingRules(
+ items: readonly unknown[],
+): Promise> {
const { translateDeclaredSharingRule } = await import('@objectstack/plugin-sharing');
- const out: Array<{ name: string; refused: string | null }> = [];
+ const out: Array<{ name: string; refused: string | null; packageId: string | null }> = [];
for (const item of items) {
const name = (item as { name?: unknown } | null)?.name;
if (typeof name !== 'string' || name === '') continue;
const { skip } = translateDeclaredSharingRule(item);
- out.push({ name, refused: skip ? describeDeclaredRuleSkip(skip) : null });
+ const packageId = (item as { _packageId?: unknown })._packageId;
+ out.push({ name, refused: skip ? describeDeclaredRuleSkip(skip) : null, packageId: typeof packageId === 'string' && packageId ? packageId : null });
}
return out;
}
@@ -345,6 +374,13 @@ export interface ColumnDropPort {
export interface CeremonyCheckpoint {
ceremony: 'ADR-0131 D10';
inventoryDigest: string;
+ /**
+ * The organization-ownership shape generation this ceremony's step set
+ * reaches ({@link ORGANIZATION_OWNERSHIP_SHAPE_GENERATION}): what the boot
+ * guard decides on once the marker is verified. A checkpoint an older
+ * ceremony wrote carries none and reads as generation 0.
+ */
+ shapeGeneration?: number;
completed: Partial }>>;
/**
* The `--metadata-choice` rows whose conflict this ceremony resolved, recorded
@@ -596,6 +632,13 @@ export interface ApplyOptions {
* that finds a declared-rule mirror standing blocks instead of deleting it.
*/
declaredSharingRules?: DeclaredSharingRulesPort;
+ /**
+ * [18.x step 5a] The declared webhooks, by name. Absent, a run that finds a
+ * seeded `sys_webhook` row blocks instead of settling it.
+ */
+ declaredWebhooks?: DeclaredWebhooksPort;
+ /** [18.x step 5d] The schema door the NOT NULL step applies through. Absent, it applies nothing and says so. */
+ notNull?: NotNullPort;
/** The served views, for carrying form withdrawals. */
views: ServedViewsPort;
/** The door's lock verdict, for the one write the ceremony makes in place. */
@@ -2925,6 +2968,797 @@ async function runColumnDrops(run: Run): Promise {
};
}
+// ── 18.x, shape generation 1: what an 18.0 database owes too ────────────────
+//
+// The four steps below select their rows BY SHAPE, never by `organization_id
+// IS NULL`: an 18.0 single-posture database stamped every one of them with the
+// Default Organization (line fact F3 of the 18.x plan), so a NULL selector
+// would find nothing there and certify a database that still owes the step.
+
+const WEBHOOKS = 'sys_webhook';
+const DELIVERIES = 'sys_http_delivery';
+const SECRETS = 'sys_secret';
+const ACTIVATIONS = 'sys_metadata_activation';
+const JOB_QUEUE = 'sys_job_queue';
+const ACTIVITY = 'sys_activity';
+/** The provenance a seeded copy of a declaration carries (`managed_by`). */
+const SEEDED_PROVENANCE = ['package', 'platform'] as const;
+/**
+ * How a delivery names a declared webhook: plugin-webhooks'
+ * `declaredWebhookRefId` (`declared:NAME`). Spelled here because the CLI does
+ * not load the plugin to re-source rows; `organization-ownership-apply.declared-webhooks.test.ts`
+ * holds the two equal.
+ */
+export const DECLARED_WEBHOOK_REF_PREFIX = 'declared:';
+/** The `name` column's length on `sys_webhook` and `sys_sharing_rule`. */
+const NAME_MAX = 100;
+/** The stored form of an encrypted field's value: `secret:` (objectql's `SECRET_REF_PREFIX`). */
+const SECRET_REF_PREFIX = 'secret:';
+
+const truthy = (value: unknown): boolean => value === true || value === 1 || value === '1' || value === 'true' || value === 't';
+
+/** The physical tables of `object` the plan this run started from found present. */
+function presentTables(run: Run, object: string): string[] {
+ const table = run.plan.tables.find((t) => t.object === object);
+ return table?.physical === 'present' ? table.tables : [];
+}
+
+const blockedStep = (step: CeremonyStep, summary: Record, blockers: string[], notes: string[] = []): StepReport =>
+ ({ step, status: 'blocked', summary, blockers, notes });
+
+/**
+ * Set the deployment's switch for a declared item — the `sys_metadata_activation`
+ * row (ADR-0131 D14) keyed `(metadata_type, name)` — to `active`, read back.
+ * `false` when the row already said so (nothing written).
+ */
+async function setActivation(run: Run, type: string, name: string, packageId: string | null, active: boolean): Promise {
+ const [table] = presentTables(run, ACTIVATIONS);
+ if (!table) {
+ throw new Error(
+ `${ACTIVATIONS} does not exist on this database, so the switch of the declared ${type} ${name} cannot be written. ` +
+ 'Boot this release against it once (its schema sync creates the table), then re-run --apply.',
+ );
+ }
+ const cols = (await columnsOf(run, table))!;
+ const { sql } = databaseFor(run, table);
+ const q = (c: string): string => sql.q(c, table);
+ const read = (): Promise[]> =>
+ sql.read(
+ table,
+ `SELECT ${q('id')} AS ${q('id')}, ${q('active')} AS ${q('active')} FROM ${q(table)} WHERE ${q('metadata_type')} = ? AND ${q('name')} = ?`,
+ [type, name],
+ );
+ const [row] = await read();
+ if (row && truthy(row.active) === active) return false;
+ const at = (run.opts.now ?? (() => new Date()))().toISOString();
+ if (row) {
+ await execute(
+ run,
+ table,
+ `UPDATE ${q(table)} SET ${q('active')} = ?${cols.has('updated_at') ? `, ${q('updated_at')} = ?` : ''} WHERE ${q('id')} = ?`,
+ [sql.bind(active), ...(cols.has('updated_at') ? [at] : []), row.id],
+ );
+ } else {
+ const values: Record = {
+ id: `act_${randomUUID()}`,
+ metadata_type: type,
+ name,
+ package_id: packageId,
+ active: sql.bind(active),
+ created_at: at,
+ updated_at: at,
+ };
+ const keys = Object.keys(values).filter((c) => cols.has(c));
+ await execute(
+ run,
+ table,
+ `INSERT INTO ${q(table)} (${keys.map(q).join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
+ keys.map((k) => values[k]),
+ );
+ }
+ const [after] = await read();
+ if (!after || truthy(after.active) !== active) {
+ throw new Error(`The switch of the declared ${type} ${name} did not read back ${active ? 'on' : 'off'} in ${ACTIVATIONS}.`);
+ }
+ return true;
+}
+
+/**
+ * A name for a clone: `_custom`, then `_custom_2`, … — one no row of
+ * `table` holds and no package declares (`taken`), within {@link NAME_MAX}.
+ */
+async function freeName(run: Run, table: string, base: string, taken: Set): Promise {
+ const { sql } = databaseFor(run, table);
+ for (let n = 1; ; n++) {
+ const suffix = n === 1 ? '_custom' : `_custom_${n}`;
+ const name = `${base.slice(0, NAME_MAX - suffix.length)}${suffix}`;
+ if (taken.has(name)) continue;
+ const held = await sql.count(table, `SELECT COUNT(*) AS n FROM ${sql.q(table, table)} WHERE ${sql.q('name', table)} = ?`, [name]);
+ if (held === 0) {
+ taken.add(name);
+ return name;
+ }
+ }
+}
+
+// ── Step 2e: declared webhooks (18.x step 5a; ADR-0131 D2, D10 fate 2) ──────
+
+/**
+ * [ADR-0131 D2] The webhooks the deployment's packages DECLARE, by name, with
+ * the package that declares each — what plugin-webhooks serves from the
+ * registry (the command: the booted engine's registry, else the metadata
+ * service).
+ */
+export interface DeclaredWebhooksPort {
+ list(): Promise>;
+}
+
+/** A `sys_webhook` row's `definition_json`, parsed, or `null`. */
+function webhookDefinition(row: Record): Record | null {
+ const raw = row.definition_json;
+ if (typeof raw !== 'string' || raw === '') return null;
+ try {
+ const parsed = JSON.parse(raw) as unknown;
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : null;
+ } catch {
+ return null;
+ }
+}
+
+const triggerList = (value: unknown): string => {
+ let list: unknown = value;
+ if (typeof value === 'string') {
+ try {
+ list = JSON.parse(value);
+ } catch {
+ list = [value];
+ }
+ }
+ return JSON.stringify(Array.isArray(list) ? list.map(String).sort() : []);
+};
+
+/**
+ * Whether a customized seeded webhook differs from the copy the 18.0 seeder
+ * last wrote ONLY in its switch. `definition_json` is that copy: the seeder
+ * rewrote it on every boot while the row was pristine, and the first edit
+ * stopped it. Every column the seeder mapped from the envelope must still
+ * read as the envelope says. A row holding a credential is never read as
+ * switch-only: a stored secret cannot be compared without decrypting it, so it
+ * is settled as a clone, which keeps it — the conservative reading.
+ */
+function isSwitchOnlyEdit(row: Record): boolean {
+ const def = webhookDefinition(row);
+ if (!def) return false;
+ if (text(row.headers_secret) !== null || text(row.signing_secret) !== null) return false;
+ const same = (a: unknown, b: unknown): boolean => text(a) === text(b);
+ return (
+ same(row.label, def.label ?? def.name) &&
+ same(row.object_name, def.object ?? null) &&
+ triggerList(row.triggers) === triggerList(def.triggers) &&
+ same(row.url, def.url) &&
+ same(String(row.method ?? 'post').toLowerCase(), String(def.method ?? 'POST').toLowerCase()) &&
+ same(row.description, def.description ?? null)
+ );
+}
+
+const secretIdsOf = (row: Record): string[] =>
+ [row.headers_secret, row.signing_secret]
+ .map((v) => (typeof v === 'string' && v.startsWith(SECRET_REF_PREFIX) ? v.slice(SECRET_REF_PREFIX.length) : ''))
+ .filter((id) => id !== '');
+
+/**
+ * [18.x step 5a; ADR-0131 D2, D10 fate 2] Settle every `sys_webhook` row a
+ * package's seeder wrote (`managed_by` `package` or `platform`) under a name a
+ * package still declares — plugin-webhooks serves that declaration from the
+ * registry, so the row is a copy:
+ *
+ * - a PRISTINE copy (not `customized`) is deleted, after every delivery that
+ * names it (`sys_http_delivery.ref_id` = its row id) is re-sourced to the
+ * declaration (`declared:NAME`), so history and redelivery follow the
+ * declaration;
+ * - a copy customized only in its switch ({@link isSwitchOnlyEdit}) becomes
+ * the declaration's activation row with that switch (regime C, ruling Q2 A
+ * on the declared-webhook card: an activation row for an `active` change),
+ * its deliveries are re-sourced, and it is deleted;
+ * - a copy customized in anything else becomes a CLONE under a new name —
+ * renamed in place, `managed_by 'admin'`, so its id, its credentials and
+ * every delivery naming it stay its own (the deliveries' tail already names
+ * the clone's row id) — and the declaration is switched off.
+ *
+ * The `sys_secret` rows a deleted copy's credential columns referenced are
+ * deleted with it, once no other webhook row references them. A seeded row
+ * whose name no package declares any more is an ordinary webhook — it
+ * dispatches as itself, as it did in 18.0 — and stays, reported.
+ *
+ * Under a wall the declaration's switch is deployment-wide (there is no
+ * per-organization switch-off), so a customized copy is not settled on the
+ * operator's behalf: it blocks, named, with the remedy. Idempotent: a second
+ * run finds no seeded row under a declared name.
+ */
+async function runDeclaredWebhooks(run: Run): Promise {
+ const step: CeremonyStep = 'declared-webhooks';
+ const summary: Record = { mirrorsDeleted: 0, activationRows: 0, clones: 0, deliveriesResourced: 0, secretsDeleted: 0, undeclaredKept: 0 };
+ const notes: string[] = [];
+ const [table] = presentTables(run, WEBHOOKS);
+ const cols = table ? await columnsOf(run, table) : null;
+ if (!table || !cols?.has('managed_by') || !cols.has('name')) return { step, status: 'done', summary, blockers: [], notes };
+ const { sql } = databaseFor(run, table);
+ const q = (c: string): string => sql.q(c, table);
+ const seeded = predicateSql(sql, table, cols, [{ column: 'managed_by', in: SEEDED_PROVENANCE }]);
+ const select = ['name', 'managed_by', 'customized', 'active', 'label', 'object_name', 'triggers', 'url', 'method', 'description', 'definition_json', 'headers_secret', 'signing_secret'];
+ const rows = await readAll(run, table, select.filter((c) => cols.has(c)), seeded);
+ if (rows.length === 0) return { step, status: 'done', summary, blockers: [], notes };
+
+ if (!run.opts.declaredWebhooks) {
+ return blockedStep(step, summary, [
+ `${table} holds ${rows.length} row(s) a package's seeder wrote, settled only against the webhooks the ` +
+ 'deployment declares, and this run has no registry to read them from. Run the command against the ' +
+ "deployment's own composition, then re-run.",
+ ]);
+ }
+ const declared = new Map((await run.opts.declaredWebhooks.list()).map((d) => [d.name, d.packageId] as const));
+ const held = rows.filter((r) => declared.has(String(r.name)));
+ summary.undeclaredKept = rows.length - held.length;
+ if (summary.undeclaredKept > 0) {
+ notes.push(
+ `${table}: ${summary.undeclaredKept} seeded row(s) name a webhook no installed package declares any more ` +
+ `(${rows.filter((r) => !declared.has(String(r.name))).map((r) => String(r.id)).sort().join(', ')}). Each is an ` +
+ 'ordinary webhook — it dispatches as itself, as it did in 18.0 — and stays.',
+ );
+ }
+ const customized = held.filter((r) => truthy(r.customized));
+ if (customized.length > 0 && run.opts.posture !== 'single') {
+ return blockedStep(step, summary, [
+ `${table} holds ${customized.length} customized copy(ies) of a declared webhook ` +
+ `(${customized.map((r) => `${String(r.id)} ${String(r.name)}`).sort().join(', ')}). Under the ${run.opts.posture} ` +
+ 'posture the declaration\'s switch is deployment-wide — there is no per-organization switch-off — so which ' +
+ 'organizations keep the declared webhook is not decided on the operator\'s behalf, and nothing was written. ' +
+ 'Rename each copy to a name no package declares (it becomes that organization\'s own webhook), or delete it; ' +
+ 'switch the declared webhook on or off through the activation door as the deployment wants; then re-run.',
+ ], notes);
+ }
+
+ const deliveryTables: string[] = [];
+ for (const physical of presentTables(run, DELIVERIES)) {
+ const dcols = await columnsOf(run, physical);
+ if (dcols?.has('source') && dcols.has('ref_id')) deliveryTables.push(physical);
+ }
+ const resource = async (from: string, to: string): Promise => {
+ let n = 0;
+ for (const physical of deliveryTables) {
+ const { sql: dsql } = databaseFor(run, physical);
+ const where = `${dsql.q('source', physical)} = ? AND ${dsql.q('ref_id', physical)} = ?`;
+ const count = (): Promise => dsql.count(physical, `SELECT COUNT(*) AS n FROM ${dsql.q(physical, physical)} WHERE ${where}`, ['webhook', from]);
+ const before = await count();
+ if (before === 0) continue;
+ await execute(run, physical, `UPDATE ${dsql.q(physical, physical)} SET ${dsql.q('ref_id', physical)} = ? WHERE ${where}`, [to, 'webhook', from]);
+ const left = await count();
+ if (left !== 0) throw new Error(`${left} delivery(ies) on ${physical} still name the webhook row ${from} after the re-source; nothing was deleted.`);
+ n += before;
+ }
+ return n;
+ };
+ const deleteCopy = async (row: Record): Promise => {
+ const where = `${q('id')} = ? AND (${seeded.text})`;
+ await execute(run, table, `DELETE FROM ${q(table)} WHERE ${where}`, [row.id, ...seeded.params]);
+ if ((await sql.count(table, `SELECT COUNT(*) AS n FROM ${q(table)} WHERE ${q('id')} = ?`, [row.id])) !== 0) {
+ throw new Error(`The declared-webhook copy ${String(row.id)} on ${table} was not deleted; nothing past it was.`);
+ }
+ };
+
+ const orphaned: string[] = [];
+ const taken = new Set(declared.keys());
+ for (const row of held) {
+ const name = String(row.name);
+ const id = String(row.id);
+ const def = webhookDefinition(row);
+ const packageId = (typeof def?._packageId === 'string' && def._packageId ? def._packageId : null) ?? declared.get(name) ?? null;
+ if (!truthy(row.customized) || isSwitchOnlyEdit(row)) {
+ if (truthy(row.customized) && (await setActivation(run, 'webhook', name, packageId, truthy(row.active)))) summary.activationRows++;
+ summary.deliveriesResourced += await resource(id, `${DECLARED_WEBHOOK_REF_PREFIX}${name}`);
+ await deleteCopy(row);
+ orphaned.push(...secretIdsOf(row));
+ summary.mirrorsDeleted++;
+ continue;
+ }
+ const clone = await freeName(run, table, name, taken);
+ const { _provenance: _p, _packageId: _k, _packageVersion: _v, ...own } = def ?? {};
+ const sets: Array<[string, unknown]> = [
+ ['name', clone],
+ ['managed_by', 'admin'],
+ ...(cols.has('customized') ? [['customized', sql.bind(false)] as [string, unknown]] : []),
+ ...(def && cols.has('definition_json') ? [['definition_json', JSON.stringify({ ...own, name: clone })] as [string, unknown]] : []),
+ ];
+ await execute(
+ run,
+ table,
+ `UPDATE ${q(table)} SET ${sets.map(([c]) => `${q(c)} = ?`).join(', ')} WHERE ${q('id')} = ? AND (${seeded.text})`,
+ [...sets.map(([, v]) => v), row.id, ...seeded.params],
+ );
+ const [landed] = await sql.read(table, `SELECT ${q('name')} AS ${q('name')} FROM ${q(table)} WHERE ${q('id')} = ?`, [row.id]);
+ if (text(landed?.name) !== clone) throw new Error(`The clone of the declared webhook ${name} (${id}) did not read back as ${clone}.`);
+ if (await setActivation(run, 'webhook', name, packageId, false)) summary.activationRows++;
+ summary.clones++;
+ notes.push(
+ `${table} ${id}: customized beyond its switch, so it is now the administrator's own webhook ${clone} (its ` +
+ `credentials and deliveries with it), and the declared ${name} is switched off.`,
+ );
+ }
+
+ // The ciphers the deleted copies referenced, once nothing else does.
+ const [secrets] = presentTables(run, SECRETS);
+ if (secrets && orphaned.length > 0) {
+ const { sql: ssql } = databaseFor(run, secrets);
+ for (const secretId of [...new Set(orphaned)].sort()) {
+ const ref = `${SECRET_REF_PREFIX}${secretId}`;
+ const credentialCols = ['headers_secret', 'signing_secret'].filter((c) => cols.has(c));
+ const still = credentialCols.length === 0
+ ? 0
+ : await sql.count(table, `SELECT COUNT(*) AS n FROM ${q(table)} WHERE ${credentialCols.map((c) => `${q(c)} = ?`).join(' OR ')}`, credentialCols.map(() => ref));
+ if (still > 0) continue;
+ const where = `${ssql.q('id', secrets)} = ?`;
+ const before = await ssql.count(secrets, `SELECT COUNT(*) AS n FROM ${ssql.q(secrets, secrets)} WHERE ${where}`, [secretId]);
+ if (before === 0) continue;
+ await execute(run, secrets, `DELETE FROM ${ssql.q(secrets, secrets)} WHERE ${where}`, [secretId]);
+ summary.secretsDeleted += before;
+ }
+ }
+
+ // Verified: no seeded row holds a declared name any more.
+ const names = [...declared.keys()];
+ for (let i = 0; i < names.length; i += IN_CHUNK) {
+ const chunk = names.slice(i, i + IN_CHUNK);
+ const left = await sql.count(
+ table,
+ `SELECT COUNT(*) AS n FROM ${q(table)} WHERE (${seeded.text}) AND ${q('name')} IN (${chunk.map(() => '?').join(', ')})`,
+ [...seeded.params, ...chunk],
+ );
+ if (left !== 0) throw new Error(`${left} seeded ${table} row(s) still hold a declared webhook's name after the settlement.`);
+ }
+ run.deleted.set(WEBHOOKS, (run.deleted.get(WEBHOOKS) ?? 0) + summary.mirrorsDeleted);
+ if (summary.deliveriesResourced > 0) {
+ notes.push(
+ `${DELIVERIES}: ${summary.deliveriesResourced} delivery(ies) now name their declared webhook (declared:NAME). A ` +
+ 'pending one keeps the partition it was enqueued under: it is still claimed and sent, by that partition\'s worker.',
+ );
+ }
+ run.opts.onProgress?.(
+ `declared webhooks: ${summary.mirrorsDeleted} copy(ies) deleted, ${summary.clones} cloned, ` +
+ `${summary.deliveriesResourced} delivery(ies) re-sourced`,
+ );
+ return { step, status: 'done', summary, blockers: [], notes };
+}
+
+// ── Step 2d: the sibling moves (18.x step 5b; ADR-0131 D7) ──────────────────
+
+/** One id-preserving move of deployment-level rows into the sibling that holds them now. */
+interface SiblingMove {
+ /** The inventory departure on `from` that names these rows. */
+ id: string;
+ from: string;
+ to: string;
+ /** The rows that move, by SHAPE — never by a NULL organization (F3). */
+ where(sql: Sql, table: string, cols: ReadonlySet): { text: string; params: unknown[] };
+}
+
+/**
+ * The body of a magic-link mail: the sign-in URL better-auth's magic-link
+ * plugin builds (`/magic-link/verify?token=`). An 18.0 magic-link row carries
+ * no related record and no template column, so its link is the one structural
+ * trace of what it is; every rendering of `auth.magic_link`, customized or
+ * not, carries it, because the mail exists to deliver it.
+ */
+const MAGIC_LINK_BODY = '%/magic-link/verify?token=%';
+
+export const SIBLING_MOVES: readonly SiblingMove[] = [
+ {
+ id: 'global-audit-move',
+ from: 'sys_setting_audit',
+ to: 'sys_platform_setting_audit',
+ where: (sql, table, cols) => predicateSql(sql, table, cols, [{ column: 'scope', equals: 'global' }]),
+ },
+ {
+ // Identity mail: mail ABOUT a person (reset, verification, account
+ // invitation, change-email notice — `related_object = 'sys_user'`, the
+ // four plugin-auth sites that name one) and the magic link, which names no
+ // record. The organization invitation (`related_object = 'sys_invitation'`)
+ // is tenant mail and stays.
+ id: 'identity-mail-move',
+ from: 'sys_email',
+ to: 'sys_identity_email',
+ where: (sql, table, cols) => {
+ requireColumn(table, cols, 'related_object');
+ const c = (name: string): string => sql.q(name, table);
+ const bodies = ['body_text', 'body_html'].filter((b) => cols.has(b));
+ const magic = bodies.length && cols.has('related_id')
+ ? ` OR (${c('related_object')} IS NULL AND ${c('related_id')} IS NULL AND (${bodies.map((b) => `${c(b)} LIKE ?`).join(' OR ')}))`
+ : '';
+ return { text: `(${c('related_object')} = ?${magic})`, params: ['sys_user', ...(magic ? bodies.map(() => MAGIC_LINK_BODY) : [])] };
+ },
+ },
+];
+
+/** The mail queues whose 18.0 jobs name a `sys_email` row by id alone (plugin-email). */
+const MAIL_JOB_QUEUES = ['email.send.async', 'email.attachment.reclaim'] as const;
+
+/**
+ * Before identity mail leaves `sys_email`: point every pending or running mail
+ * job that names a moving row at the sibling. An 18.0 job carries `{ rowId }`
+ * with no `object`, which its consumer reads as `sys_email` — a send would find
+ * no row and never deliver, and an attachment reclaim would take the row-gone
+ * branch and delete the mail's attachment content. The job gains
+ * `object: 'sys_identity_email'`, in its payload and metadata, and its
+ * idempotency key the sibling's spelling (`