diff --git a/.changeset/permission-restore-purge-retired.md b/.changeset/permission-restore-purge-retired.md
new file mode 100644
index 0000000000..179f483e84
--- /dev/null
+++ b/.changeset/permission-restore-purge-retired.md
@@ -0,0 +1,96 @@
+---
+"@objectstack/spec": minor
+"@objectstack/plugin-security": patch
+---
+
+feat(spec): retire the `allowRestore` / `allowPurge` object-permission bits — declared gates on operations that do not exist (#12497, ADR-0049)
+
+**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
+launch-window convention ships it as `minor`; the migration prescription is
+registered under protocol major 18, where `os migrate meta` users will look).
+Maintainer ruling 2026-08-26 (decision-inbox batch 5) accepting #1883's
+recommendation B; **the keys return with the M2 lifecycle initiative** (feature
++ RBAC in one batch) — anchor card #1883 stays open.
+
+`allowRestore` and `allowPurge` claimed to gate `restore` (undelete) and
+`purge` (hard-delete / GDPR erase) ObjectQL operations that have never
+existed: no destructive lifecycle verb is in the engine's dispatch vocabulary
+(pinned by objectql's `engine-middleware-operation-vocabulary.test.ts`, #8106).
+Authoring the bits granted nothing — and in the `allowPurge: false` direction
+the failure was ADR-0049's worst false-compliance shape: an admin believed a
+lock on permanent deletion existed when the operation itself did not. The
+sibling `allowTransfer` is **enforced** (#3004, the insert/update `owner_id`
+door) and is untouched.
+
+**What is refused:** authoring either key, with any value — both are
+`retiredKey()` tombstones (`ObjectPermissionSchema` is reachable from the
+`permission` metadata root, so the tombstone route keeps the removal audible:
+a tsc `never` on the input type plus a parse-time prescription). The former
+`restore` / `purge` bare-verb aliases now answer with the same prescription
+instead of a rename onto a tombstone. The tombstone rides the `.extend()`
+clone into `EffectiveObjectPermissionSchema`, so the response-side def carries
+the same `[RETIRED]` rows.
+
+**What stays accepted:** every other object-permission bit parses
+byte-identically (`allow*` CRUD, `allowExport`, `allowTransfer`,
+`viewAllRecords`, `modifyAllRecords`, `readScope` / `writeScope`).
+
+**Runtime (plugin-security):** the evaluator's pre-mapping rows
+(`OPERATION_TO_PERMISSION` restore→allowRestore / purge→allowPurge) retired in
+the same batch — with the bits unwritable, a mapping onto them was a claim
+about a surface that rejects authoring. Behaviour is deny-before and
+deny-after: a dispatched `restore` / `purge` is refused fail-closed by the
+`DESTRUCTIVE_OPERATIONS` backstop, now unconditionally (not even
+`modifyAllRecords` reaches an unmapped destructive op — the bypass re-covers
+them only when the M2 batch re-adds the rows). `transfer` keeps its row and
+its bypass. `describeHighPrivilegeBits` stopped reading `allowPurge` (a legacy
+stored value grants nothing, so flagging it guarded nothing real); the
+delete/purge/transfer class message is unchanged.
+
+The retirement kit:
+
+- `retiredKey()` tombstones + former-alias `guidance` prescriptions at the
+ schema (`packages/spec/src/security/permission.zod.ts`)
+- ADR-0087 registration: retired-key entries
+ `security/ObjectPermission:allowRestore` / `:allowPurge` (and the
+ `security/EffectiveObjectPermission` pair for the cloned rows) and the D2
+ conversion `permission-allow-restore-purge-removed` (protocol 18), wired
+ into the step-18 chain — `os migrate meta --from 17` strips the keys from
+ every object grant in `permissions[].objects` (pure lossless delete; they
+ never had an effect to lose)
+- liveness ledger: both entries flipped to `dead` with the retiredKey evidence
+ (entries stay — the tombstone keeps the keys in the walked shape, the
+ `rls.priority` precedent)
+- pin tests (`permission.test.ts` — refusal pins asserting the prescription;
+ `security-plugin.test.ts` — fail-closed pins incl. the legacy-stored-grant
+ and modifyAllRecords directions; `audience-anchors.test.ts` — the predicate
+ no longer reads the retired bit)
+- generated baselines/docs follow the schema (`authorable-surface/`,
+ `authorable-defaults/`, spec-changes, upgrade guide, reference docs)
+
+## FROM → TO
+
+```ts
+// before — parsed green; nothing ever read the bits, no operation existed
+definePermissionSet({
+ name: 'support_agent',
+ objects: {
+ crm_ticket: {
+ allowRead: true, allowEdit: true,
+ allowRestore: true, // claimed: can undelete — nothing enforced it
+ allowPurge: false, // claimed: GDPR erase locked — no lock existed
+ },
+ },
+});
+
+// after — delete the keys; restore/purge dispatches are denied fail-closed
+// until the M2 lifecycle batch ships the operations WITH their RBAC bits
+definePermissionSet({
+ name: 'support_agent',
+ objects: {
+ crm_ticket: { allowRead: true, allowEdit: true },
+ },
+});
+```
+
+
diff --git a/content/docs/permissions/permission-metadata.mdx b/content/docs/permissions/permission-metadata.mdx
index 33d49dc59d..689e8c5f5e 100644
--- a/content/docs/permissions/permission-metadata.mdx
+++ b/content/docs/permissions/permission-metadata.mdx
@@ -79,8 +79,6 @@ objects: {
allowEdit: true, // Can update records
allowDelete: false, // Can delete records
allowTransfer: false, // Can change record ownership
- allowRestore: false, // Can restore deleted records
- allowPurge: false, // Can permanently delete (GDPR)
viewAllRecords: false, // Bypass sharing rules for read
modifyAllRecords: false, // Bypass sharing rules for write
},
@@ -94,11 +92,16 @@ objects: {
| `allowEdit` | Update records (subject to sharing rules) |
| `allowDelete` | Soft-delete records |
| `allowTransfer` | Transfer record ownership |
-| `allowRestore` | Restore records from trash |
-| `allowPurge` | Permanently delete records (GDPR compliance) |
| `viewAllRecords` | View all records regardless of sharing rules |
| `modifyAllRecords` | Edit all records regardless of sharing rules |
+> **Retired:** the former `allowRestore` / `allowPurge` keys were removed
+> (#12497, ADR-0049) — the `restore` / `purge` operations they claimed to gate
+> do not exist yet, so authoring them granted nothing. Authoring either key is
+> now a loud publish-time error carrying this prescription. The keys return
+> with the M2 lifecycle initiative (feature + RBAC in one batch, #1883);
+> until then a dispatched `restore` / `purge` is denied unconditionally.
+
## Field Permissions
Control visibility and editability of individual fields:
@@ -229,8 +232,6 @@ const salesManagerPermission = {
allowEdit: true,
allowDelete: true,
allowTransfer: true,
- allowRestore: true,
- allowPurge: false,
viewAllRecords: true,
modifyAllRecords: false,
},
@@ -240,8 +241,6 @@ const salesManagerPermission = {
allowEdit: true,
allowDelete: true,
allowTransfer: true,
- allowRestore: true,
- allowPurge: false,
viewAllRecords: true,
modifyAllRecords: true,
},
diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx
index 05081b0250..f541025220 100644
--- a/content/docs/permissions/permission-sets.mdx
+++ b/content/docs/permissions/permission-sets.mdx
@@ -50,7 +50,7 @@ export const SalesUser = definePermissionSet({
|------------|-------------|
| `allowCreate` / `allowRead` / `allowEdit` / `allowDelete` | CRUD on records the user can see |
| `allowExport` | Bulk data egress — an opt-in grant on top of read, see below |
-| `allowTransfer` / `allowRestore` / `allowPurge` | Lifecycle class (RBAC-gated ahead of the M2 operations) |
+| `allowTransfer` | Lifecycle class: change record ownership — enforced today via the `owner_id` guard (#3004). The former `allowRestore` / `allowPurge` keys were retired (#12497, ADR-0049 — the operations they claimed to gate do not exist yet) and return with the M2 lifecycle batch (#1883) |
| `viewAllRecords` | Read ALL records regardless of ownership (super-user read) |
| `modifyAllRecords` | Edit ALL records regardless of ownership (super-user write) |
diff --git a/content/docs/permissions/permissions-matrix.mdx b/content/docs/permissions/permissions-matrix.mdx
index 4b77ab6ba8..5b0291a3c3 100644
--- a/content/docs/permissions/permissions-matrix.mdx
+++ b/content/docs/permissions/permissions-matrix.mdx
@@ -23,13 +23,11 @@ ObjectStack's `ObjectPermission` schema defines these boolean flags for object a
| **Delete** | `allowDelete` | Remove records owned by the user or shared with them | Delete own records |
| **Export** | `allowExport` | Take a bulk machine-readable copy of the records the user can read | Export / bulk egress ([details](/docs/permissions/permission-sets#allowexport--the-export-axis)) |
| **Transfer** | `allowTransfer` | Change record ownership | Reassign owner |
-| **Restore** | `allowRestore` | Undelete from trash | Recover soft-deleted records |
-| **Purge** | `allowPurge` | Permanently (hard) delete | GDPR / compliance erase |
| **View All** | `viewAllRecords` | View all records regardless of ownership or sharing | Read all records (bypass sharing) |
| **Modify All** | `modifyAllRecords` | Edit/delete all records regardless of ownership | Full object access (bypass sharing) |
-**Super-user bypass:** When `modifyAllRecords` is set it satisfies write checks (`allowEdit`/`allowDelete`, and the lifecycle class `allowTransfer`/`allowRestore`/`allowPurge`) on any record; `viewAllRecords` (or `modifyAllRecords`) satisfies `allowRead` on any record — both bypass ownership and sharing. See `packages/plugins/plugin-security/src/permission-evaluator.ts`.
+**Super-user bypass:** When `modifyAllRecords` is set it satisfies write checks (`allowEdit`/`allowDelete`, and the lifecycle bit `allowTransfer`) on any record; `viewAllRecords` (or `modifyAllRecords`) satisfies `allowRead` on any record — both bypass ownership and sharing. See `packages/plugins/plugin-security/src/permission-evaluator.ts`.
**The one exception is `allowExport`.** Neither super-user bit confers it: a
principal with View/Modify All Data may read every record and still be refused a
@@ -52,7 +50,7 @@ and `plugin-security/src/security-plugin.ts` (`computeLayeredRlsFilter`).
-**Lifecycle operations are partly pending:** the dedicated `transfer` / `restore` / `purge` ObjectQL operations do not exist yet (roadmap M2); their RBAC gate is already mapped in the permission evaluator, so the moment they ship they are denied unless the matching flag (or `modifyAllRecords`) is granted. One exception: `allowTransfer` is **already enforced today** through the ordinary `insert` / `update` door — `owner_id` is system-managed, so planting a record under another user or reassigning / disowning one is denied unless the caller holds `allowTransfer` (or `modifyAllRecords`, which implies it) (#3004). Authoring `allowRestore` / `allowPurge` today still grants nothing (#1883).
+**Lifecycle operations are partly pending:** the dedicated `transfer` / `restore` / `purge` ObjectQL operations do not exist yet (roadmap M2). `allowTransfer` is **already enforced today** through the ordinary `insert` / `update` door — `owner_id` is system-managed, so planting a record under another user or reassigning / disowning one is denied unless the caller holds `allowTransfer` (or `modifyAllRecords`, which implies it) (#3004), and the future `transfer` op is pre-mapped to the same bit. The former `allowRestore` / `allowPurge` flags were **retired** (#12497, ADR-0049 — they claimed to gate operations that do not exist, so authoring them granted nothing); a dispatched `restore` / `purge` is denied unconditionally by the fail-closed destructive-operation backstop, and the flags return with the M2 lifecycle batch (#1883).
---
diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx
index db0f8d3393..0984480418 100644
--- a/content/docs/protocol/objectql/security.mdx
+++ b/content/docs/protocol/objectql/security.mdx
@@ -100,11 +100,16 @@ Beyond the four CRUD flags, the schema also exposes lifecycle and super-user gra
| --- | --- |
| `allowCreate` / `allowRead` / `allowEdit` / `allowDelete` | Standard CRUD |
| `allowTransfer` | Change record ownership (assign/reassign/disown `owner_id`) — *enforced now via the insert/update `owner_id` guard (#3004); the dedicated `transfer` op is still M2* |
-| `allowRestore` | Restore from trash (undelete) — *operation pending (M2); RBAC gate pre-mapped (#1883)* |
-| `allowPurge` | Permanently delete (hard delete / GDPR) — *operation pending (M2); RBAC gate pre-mapped (#1883)* |
| `viewAllRecords` | Read every record, bypassing sharing & ownership |
| `modifyAllRecords` | Write every record, bypassing sharing & ownership |
+> The former `allowRestore` / `allowPurge` flags were **retired** (#12497,
+> ADR-0049): the `restore` / `purge` operations they claimed to gate have never
+> existed, so authoring them granted nothing — the schema now refuses them with
+> a migration prescription. A dispatched `restore` / `purge` is denied
+> unconditionally (fail-closed destructive-operation backstop). The flags
+> return with the M2 lifecycle initiative (feature + RBAC in one batch, #1883).
+
> Permission sets are **additive-only**: a user's effective capability is the union of every set they hold — directly, via positions, or via the built-in `everyone` baseline (ADR-0090 D5). A `true` anywhere wins; there are no subtraction sets — to withhold, don't grant.
### Permission Check Flow
diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx
index ed35372299..8cfcfb8ab2 100644
--- a/content/docs/references/api/protocol.mdx
+++ b/content/docs/references/api/protocol.mdx
@@ -986,8 +986,8 @@ Enable package response
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission |
| **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. |
| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) |
-| **allowRestore** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Restore from trash (Undelete) |
-| **allowPurge** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) |
+| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
+| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
| **viewAllRecords** | `boolean` | optional (default: `false`) | View All Data (Bypass Sharing) |
| **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies (#6698). |
| **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org |
@@ -1337,8 +1337,8 @@ Enable package response
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission |
| **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. |
| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) |
-| **allowRestore** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Restore from trash (Undelete) |
-| **allowPurge** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) |
+| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
+| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
| **viewAllRecords** | `boolean` | optional (default: `false`) | View All Data (Bypass Sharing) |
| **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies (#6698). |
| **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org |
diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx
index bba81b716d..4d0a2081c0 100644
--- a/content/docs/references/security/permission.mdx
+++ b/content/docs/references/security/permission.mdx
@@ -57,8 +57,8 @@ const result = AdminScopeSchema.parse(data);
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission |
| **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. |
| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) |
-| **allowRestore** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Restore from trash (Undelete) |
-| **allowPurge** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) |
+| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
+| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
| **viewAllRecords** | `boolean` | optional (default: `false`) | View All Data (Bypass Sharing) |
| **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies (#6698). |
| **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org |
@@ -105,8 +105,8 @@ const result = AdminScopeSchema.parse(data);
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission |
| **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. |
| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) |
-| **allowRestore** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Restore from trash (Undelete) |
-| **allowPurge** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) |
+| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
+| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
| **viewAllRecords** | `boolean` | optional (default: `false`) | View All Data (Bypass Sharing) |
| **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies (#6698). |
| **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org |
@@ -152,8 +152,8 @@ const result = AdminScopeSchema.parse(data);
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission |
| **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. |
| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) |
-| **allowRestore** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Restore from trash (Undelete) |
-| **allowPurge** | `boolean` | optional (default: `false`) | [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) |
+| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
+| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
| **viewAllRecords** | `boolean` | optional (default: `false`) | View All Data (Bypass Sharing) |
| **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies (#6698). |
| **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org |
diff --git a/packages/plugins/plugin-security/src/audience-anchors.test.ts b/packages/plugins/plugin-security/src/audience-anchors.test.ts
index 822d270696..94d41db7b0 100644
--- a/packages/plugins/plugin-security/src/audience-anchors.test.ts
+++ b/packages/plugins/plugin-security/src/audience-anchors.test.ts
@@ -57,8 +57,12 @@ describe('describeHighPrivilegeBits (anchor-binding predicate)', () => {
expect(describeHighPrivilegeBits({ objects: { a: { viewAllRecords: true } } })).toMatch(/View\/Modify All/);
expect(describeHighPrivilegeBits({ objects: { a: { modifyAllRecords: true } } })).toMatch(/View\/Modify All/);
expect(describeHighPrivilegeBits({ objects: { a: { allowDelete: true } } })).toMatch(/delete\/purge\/transfer/);
- expect(describeHighPrivilegeBits({ objects: { a: { allowPurge: true } } })).toMatch(/delete\/purge\/transfer/);
expect(describeHighPrivilegeBits({ objects: { a: { allowTransfer: true } } })).toMatch(/delete\/purge\/transfer/);
+ // `allowPurge` RETIRED (#12497): the bit is a retiredKey tombstone — no
+ // parsed set can carry it, and a legacy stored row that still does grants
+ // nothing (no `purge` op, evaluator row retired), so the predicate stopped
+ // reading it. The M2 batch restores the bit, its gate row, and this read.
+ expect(describeHighPrivilegeBits({ objects: { a: { allowPurge: true } } })).toBeNull();
expect(describeHighPrivilegeBits({ systemPermissions: ['manage_users'], objects: {} })).toMatch(/system permissions/);
});
diff --git a/packages/plugins/plugin-security/src/controlled-by-parent-detail-write-authority.test.ts b/packages/plugins/plugin-security/src/controlled-by-parent-detail-write-authority.test.ts
index 9d40b3e341..6f05de3b9d 100644
--- a/packages/plugins/plugin-security/src/controlled-by-parent-detail-write-authority.test.ts
+++ b/packages/plugins/plugin-security/src/controlled-by-parent-detail-write-authority.test.ts
@@ -206,14 +206,20 @@ const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_defa
/**
* Object grants wide enough that NO case below is decided by the CRUD check.
*
- * The lifecycle three are here for a measured reason. Step 2.7 and step 2.8 are
- * both pre-wired for `transfer` / `restore` / `purge` (#1883), but each maps to
- * its own grant (`allowTransfer` / `allowRestore` / `allowPurge`,
- * `permission.zod.ts`), so a principal holding only CRUD is refused by the
+ * `allowTransfer` is here for a measured reason: step 2.7 and step 2.8 are
+ * pre-wired for `transfer` (#1883/#3004) but it maps to its own grant
+ * (`permission.zod.ts`), so a principal holding only CRUD is refused by the
* OBJECT-level check long before either row gate runs — with
- * `insufficient_permission`, not a row verdict. Granting them is what makes §1's
+ * `insufficient_permission`, not a row verdict. Granting it is what makes §1's
* per-operation coverage claim about the row gates rather than about the CRUD
* bit that never let the operation through.
+ *
+ * `allowRestore` / `allowPurge` left this fixture with #12497 (ADR-0049 —
+ * bits tombstoned, evaluator mapping rows retired; the keys return with M2,
+ * #1883): they can no longer be authored, and a `restore`/`purge` dispatch is
+ * refused fail-closed at the object gate, so no grant could carry those two
+ * verbs to the row gates this suite measures. Their loop cases went with them
+ * — see BY_ID_WRITE_OPERATIONS below.
*/
const CRUD = {
allowRead: true,
@@ -221,8 +227,6 @@ const CRUD = {
allowEdit: true,
allowDelete: true,
allowTransfer: true,
- allowRestore: true,
- allowPurge: true,
};
/**
@@ -549,8 +553,16 @@ async function boot(seed: { shares?: Row[] } = {}) {
const MASTER_GATE_SENTENCE = /requires edit access to its master record/;
const PRE_IMAGE_GATE_SENTENCE = /row-level security\)$/;
-/** Step 2.7's set — the operations whose floor this card removes on a cbp detail. */
-const BY_ID_WRITE_OPERATIONS = ['update', 'delete', 'transfer', 'restore', 'purge'] as const;
+/**
+ * Step 2.7's set — the operations whose floor this card removes on a cbp
+ * detail — MINUS `restore`/`purge` since #12497: their bits are tombstoned and
+ * their evaluator mapping rows retired, so those two verbs are refused
+ * fail-closed at the OBJECT gate (`insufficient_permission`) and can no longer
+ * reach the row gates this loop measures. Step 2.7 still lists them, as
+ * dormant defense-in-depth; re-add them here when the M2 batch makes them
+ * grantable again.
+ */
+const BY_ID_WRITE_OPERATIONS = ['update', 'delete', 'transfer'] as const;
describe('[#8757] §1 PRECONDITION — the master gate runs on every by-id write path the floor comes off', () => {
// The ruling's licence condition, stated once per operation in step 2.7's
diff --git a/packages/plugins/plugin-security/src/permission-evaluator.ts b/packages/plugins/plugin-security/src/permission-evaluator.ts
index eb1a1ec11e..70e3b5f73e 100644
--- a/packages/plugins/plugin-security/src/permission-evaluator.ts
+++ b/packages/plugins/plugin-security/src/permission-evaluator.ts
@@ -5,11 +5,20 @@ import type { PermissionSet, ObjectPermission, FieldPermissionParsed } from '@ob
/**
* Operation type mapping to permission checks.
*
- * `transfer`/`restore`/`purge` are pre-mapped to their RBAC bits (#1883) even
- * though the ObjectQL operations do not exist yet (roadmap M2): the moment such
- * an operation is dispatched through the security middleware it is gated by the
- * corresponding `allow*` bit — deny unless a resolved permission set grants it.
- * There is no window where the ops could ship ungated.
+ * `transfer` is pre-mapped to its RBAC bit (#1883) even though the dedicated
+ * ObjectQL operation does not exist yet (roadmap M2): the moment it is
+ * dispatched through the security middleware it is gated by `allowTransfer` —
+ * deny unless a resolved permission set grants it. (`allowTransfer` is also
+ * ENFORCED today through the ordinary insert/update `owner_id` door, #3004.)
+ *
+ * The former `restore`/`purge` rows RETIRED with their bits (#12497, ADR-0049
+ * — maintainer ruling 2026-08-26 accepting #1883's recommendation B):
+ * `allowRestore`/`allowPurge` are `retiredKey()` tombstones in the spec, so a
+ * mapping onto them was a claim about a surface that rejects authoring. A
+ * dispatched `restore`/`purge` is now denied unconditionally by the
+ * DESTRUCTIVE_OPERATIONS backstop below — there is still no window where the
+ * ops could ship ungated. The rows return with the M2 lifecycle initiative
+ * (feature + RBAC in one batch), together with the bits they read.
*/
const OPERATION_TO_PERMISSION: Record = {
find: 'allowRead',
@@ -20,36 +29,41 @@ const OPERATION_TO_PERMISSION: Record = {
update: 'allowEdit',
delete: 'allowDelete',
transfer: 'allowTransfer',
- restore: 'allowRestore',
- purge: 'allowPurge',
};
/**
* Destructive operation class — operations that must FAIL CLOSED when they are
* not mapped to a concrete permission key. See ADR-0049: an unrecognised
* destructive operation must be DENIED rather than silently allowed by the
- * default-allow fallthrough. `transfer`/`restore`/`purge` are now mapped above
- * (#1883), so this set acts as a backstop: it keeps them (and any future
- * destructive op prefixed here before its mapping lands) fail-closed if the
- * mapping is ever removed. Non-destructive unknown operations retain
- * default-allow so custom read-side operations are not broken.
+ * default-allow fallthrough. Since #12497 this set is the ACTIVE gate for
+ * `restore`/`purge` (their mapping rows retired with their tombstoned bits —
+ * denial is unconditional, not even `modifyAllRecords` reaches them until the
+ * M2 ops ship with re-added rows) and the backstop for `transfer` (mapped
+ * above; this keeps it fail-closed if the mapping is ever removed).
+ * Non-destructive unknown operations retain default-allow so custom read-side
+ * operations are not broken.
*/
const DESTRUCTIVE_OPERATIONS = new Set(['transfer', 'restore', 'purge']);
/**
* Permission keys covered by the `modifyAllRecords` super-user WRITE bypass:
- * edit/delete plus the destructive lifecycle class, DERIVED from the two
- * constants above so a future destructive op added to the map+set is covered
- * automatically (hand-listing it inline is how bypass gaps happen — #1883).
+ * edit/delete plus the MAPPED members of the destructive lifecycle class,
+ * DERIVED from the two constants above so a future destructive op added to the
+ * map+set is covered automatically (hand-listing it inline is how bypass gaps
+ * happen — #1883). Unmapped destructive ops (`restore`/`purge` since #12497)
+ * contribute nothing here — they are denied before the bypass is consulted.
* NOTE this means "Modify All Data" grants (incl. the wildcard on
- * organization_admin / admin_full_access defaults) will cover
- * transfer/restore/purge the moment the M2 ops ship — Salesforce semantics,
- * confirmed in the #1883 disposition; revisit per-op when M2 lands.
+ * organization_admin / admin_full_access defaults) cover `transfer` (and will
+ * cover restore/purge again when the M2 batch re-adds their rows — Salesforce
+ * semantics, confirmed in the #1883 disposition; revisit per-op when M2 lands).
*/
const MODIFY_ALL_WRITE_KEYS = new Set([
'allowEdit',
'allowDelete',
- ...[...DESTRUCTIVE_OPERATIONS].map((op) => OPERATION_TO_PERMISSION[op]),
+ ...[...DESTRUCTIVE_OPERATIONS].flatMap((op) => {
+ const key = OPERATION_TO_PERMISSION[op];
+ return key ? [key] : [];
+ }),
]);
/** CRUD operation class an object-level `requiredPermissions` map keys on. */
@@ -86,20 +100,21 @@ export function superuserBypassBitForOperation(operation: string): SuperuserBypa
* [ADR-0066 ⑤] Map a raw ObjectQL operation to the CRUD class a per-operation
* `requiredPermissions` map is keyed on, DERIVED from `OPERATION_TO_PERMISSION`
* so it stays in lockstep with the CRUD permission bits (and any future
- * destructive op added there). `transfer`/`restore` fold into `update`,
- * `purge` into `delete`. Returns `null` for an operation with no CRUD mapping
- * (e.g. a custom read-side op) — such an op is never matched by a per-operation
- * map, but the flat `string[]` form still gates it via its `all` bucket.
+ * destructive op added there). `transfer` folds into `update`. Returns `null`
+ * for an operation with no CRUD mapping (e.g. a custom read-side op) — such an
+ * op is never matched by a per-operation map, but the flat `string[]` form
+ * still gates it via its `all` bucket. (`restore`/`purge` fell out of the map
+ * with #12497 — they resolve `null` here, and their dispatch is denied at the
+ * object gate before any per-operation map is consulted; the M2 batch re-adds
+ * the rows, restore→update / purge→delete, with the bits.)
*/
export function crudBucketForOperation(operation: string): CrudBucket | null {
switch (OPERATION_TO_PERMISSION[operation]) {
case 'allowRead': return 'read';
case 'allowCreate': return 'create';
case 'allowEdit':
- case 'allowTransfer':
- case 'allowRestore': return 'update';
- case 'allowDelete':
- case 'allowPurge': return 'delete';
+ case 'allowTransfer': return 'update';
+ case 'allowDelete': return 'delete';
default: return null;
}
}
diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts
index d29f21de47..ab39130b16 100644
--- a/packages/plugins/plugin-security/src/security-plugin.test.ts
+++ b/packages/plugins/plugin-security/src/security-plugin.test.ts
@@ -859,12 +859,16 @@ describe('SecurityPlugin', () => {
expect(harness.findOne).toHaveBeenCalledTimes(1);
});
- it('DENIES a purge of a not-owned row via the pre-image check (#1883 — destructive ops inherit row-level gating)', async () => {
- // The destructive lifecycle class (transfer/restore/purge) is pre-wired
- // into OPERATION_TO_PERMISSION, so it clears the object-level RBAC gate.
- // The record-level pre-image RLS check must therefore ALSO cover it —
- // otherwise a grant-holder could destroy out-of-scope rows by id. purge
- // maps onto the `delete` RLS class.
+ it('DENIES a purge AT the object gate, before any row is read (#12497 — bits tombstoned, rows retired)', async () => {
+ // Until #12497 this case pinned the pre-image RLS check on a purge whose
+ // `allowPurge` grant had cleared the object gate (the #1883 pre-wiring).
+ // The bits retired with their mapping rows — `allowPurge` on a legacy
+ // stored set (`as any`: the spec type now spells it `never`) grants
+ // nothing, and the dispatch is refused fail-closed by the object-level
+ // gate. The row-level machinery must never even be consulted: no
+ // pre-image read, no sharing probe. (The pre-image coverage claim for
+ // by-id destructive writes lives on with `delete` above, and returns for
+ // purge with the M2 batch.)
const purgerSet: PermissionSet = {
name: 'purger', label: 'Purger',
objects: { '*': { allowRead: true, allowPurge: true } },
@@ -876,7 +880,7 @@ describe('SecurityPlugin', () => {
const harness = makeMiddlewareCtx({
permissionSets: [purgerSet],
objectFields: ownerFields,
- findOneImpl: () => null, // row exists but not owned → filtered out → deny
+ findOneImpl: () => null,
});
await plugin.init(harness.ctx);
await plugin.start(harness.ctx);
@@ -886,7 +890,7 @@ describe('SecurityPlugin', () => {
context: memberCtx,
};
await expect(harness.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' });
- expect(harness.findOne).toHaveBeenCalledTimes(1);
+ expect(harness.findOne).toHaveBeenCalledTimes(0);
});
it('SKIPS the check when no RLS policy applies (e.g. modifyAllRecords / admin) — no extra read', async () => {
@@ -2711,9 +2715,10 @@ describe('PermissionEvaluator', () => {
it('denies transfer/restore/purge without the matching RBAC bit (#1883)', () => {
const evaluator = new PermissionEvaluator();
- // Full CRUD does NOT imply the destructive lifecycle class: each op is
- // gated by its own bit (allowTransfer/allowRestore/allowPurge) and must
- // be denied when the bit is absent — never default-allow (ADR-0049).
+ // Full CRUD does NOT imply the destructive lifecycle class: `transfer` is
+ // gated by its own bit (`allowTransfer`) and must be denied when the bit
+ // is absent — never default-allow (ADR-0049). `restore`/`purge` are denied
+ // unconditionally since #12497 (bits tombstoned, mapping rows retired).
const ps = makePermSet('member', {
contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
});
@@ -2724,25 +2729,39 @@ describe('PermissionEvaluator', () => {
expect(evaluator.checkObjectPermission('purge', 'contact', [])).toBe(false);
});
- it('allows transfer/restore/purge via their specific RBAC bits (#1883)', () => {
+ it('allows transfer via its specific RBAC bit (#1883/#3004)', () => {
const evaluator = new PermissionEvaluator();
const transferOnly = makePermSet('t', { contact: { allowTransfer: true } });
- const restoreOnly = makePermSet('r', { contact: { allowRestore: true } });
- const purgeOnly = makePermSet('p', { contact: { allowPurge: true } });
expect(evaluator.checkObjectPermission('transfer', 'contact', [transferOnly])).toBe(true);
- expect(evaluator.checkObjectPermission('restore', 'contact', [restoreOnly])).toBe(true);
- expect(evaluator.checkObjectPermission('purge', 'contact', [purgeOnly])).toBe(true);
- // A bit on one op never leaks to another.
+ // The bit never leaks to another destructive op.
expect(evaluator.checkObjectPermission('purge', 'contact', [transferOnly])).toBe(false);
- expect(evaluator.checkObjectPermission('transfer', 'contact', [purgeOnly])).toBe(false);
+ expect(evaluator.checkObjectPermission('restore', 'contact', [transferOnly])).toBe(false);
+ });
+
+ it('restore/purge are denied FAIL-CLOSED even for a legacy stored grant (#12497)', () => {
+ // `allowRestore`/`allowPurge` retired with their mapping rows (#12497,
+ // ADR-0049 — the ops never existed; the keys return with M2, #1883). A
+ // 17-era STORED permission-set row can still carry the bits (`as any` —
+ // the spec type now spells them `never`, and a fresh parse rejects them),
+ // and it must grant nothing: denial is unconditional via the
+ // DESTRUCTIVE_OPERATIONS backstop, before any bit or bypass is consulted.
+ const evaluator = new PermissionEvaluator();
+ const legacy = makePermSet('legacy', {
+ contact: { allowRestore: true, allowPurge: true },
+ } as any);
+ expect(evaluator.checkObjectPermission('restore', 'contact', [legacy])).toBe(false);
+ expect(evaluator.checkObjectPermission('purge', 'contact', [legacy])).toBe(false);
});
- it('modifyAllRecords super-user bypass covers transfer/restore/purge (#1883)', () => {
+ it('modifyAllRecords super-user bypass covers transfer; restore/purge stay fail-closed (#1883/#12497)', () => {
const evaluator = new PermissionEvaluator();
const admin = makePermSet('admin', { contact: { modifyAllRecords: true } });
expect(evaluator.checkObjectPermission('transfer', 'contact', [admin])).toBe(true);
- expect(evaluator.checkObjectPermission('restore', 'contact', [admin])).toBe(true);
- expect(evaluator.checkObjectPermission('purge', 'contact', [admin])).toBe(true);
+ // Unmapped destructive ops are denied before the bypass is consulted —
+ // "Modify All Data" re-covers restore/purge only when the M2 batch re-adds
+ // their mapping rows (Salesforce semantics, #1883 disposition).
+ expect(evaluator.checkObjectPermission('restore', 'contact', [admin])).toBe(false);
+ expect(evaluator.checkObjectPermission('purge', 'contact', [admin])).toBe(false);
});
it('should allow via viewAllRecords', () => {
@@ -2941,15 +2960,20 @@ describe('crudBucketForOperation (ADR-0066 ⑤)', () => {
it('maps insert to `create`', () => {
expect(crudBucketForOperation('insert')).toBe('create');
});
- it('folds update/transfer/restore into `update`', () => {
- for (const op of ['update', 'transfer', 'restore']) {
+ it('folds update/transfer into `update`', () => {
+ for (const op of ['update', 'transfer']) {
expect(crudBucketForOperation(op)).toBe('update');
}
});
- it('folds delete/purge into `delete`', () => {
- for (const op of ['delete', 'purge']) {
- expect(crudBucketForOperation(op)).toBe('delete');
- }
+ it('folds delete into `delete`', () => {
+ expect(crudBucketForOperation('delete')).toBe('delete');
+ });
+ it('restore/purge resolve null since #12497 — out of the map with their retired bits', () => {
+ // Their dispatch is denied at the object gate before any per-operation map
+ // is consulted; the M2 batch re-adds the rows (restore→update,
+ // purge→delete) together with the bits.
+ expect(crudBucketForOperation('restore')).toBeNull();
+ expect(crudBucketForOperation('purge')).toBeNull();
});
it('returns null for an operation with no CRUD mapping', () => {
expect(crudBucketForOperation('customReadSideOp')).toBeNull();
diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts
index 27a2dcb311..184e4f96e2 100644
--- a/packages/plugins/plugin-security/src/security-plugin.ts
+++ b/packages/plugins/plugin-security/src/security-plugin.ts
@@ -2025,9 +2025,13 @@ export class SecurityPlugin implements Plugin {
// `checkDelete` for the delete class, so an `edit` share widens update and
// still leaves delete denied without this file knowing why.
if (
- // update/delete today; transfer/restore/purge are pre-wired (#1883) so
- // the M2 ops inherit the pre-image check the moment they dispatch —
- // the CRUD bit alone must never be the only row-level defense.
+ // update/delete today; the lifecycle verbs stay listed so the M2 ops
+ // inherit the pre-image check the moment they dispatch — the CRUD bit
+ // alone must never be the only row-level defense. `transfer` clears
+ // the object gate via its mapped bit (#1883/#3004); `restore`/`purge`
+ // are denied AT the object gate since #12497 (bits tombstoned, rows
+ // retired, DESTRUCTIVE_OPERATIONS fail-closed), so this branch is
+ // dormant defense-in-depth for them until the M2 batch re-adds both.
['update', 'delete', 'transfer', 'restore', 'purge'].includes(opCtx.operation) &&
permissionSets.length > 0 &&
!!opCtx.context?.userId &&
diff --git a/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts b/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts
index 000c4590f2..11d9be88cd 100644
--- a/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts
+++ b/packages/plugins/plugin-security/src/store-fault-fail-closed.test.ts
@@ -233,17 +233,23 @@ describe('[#7505] assertPackageManagedWriteGate — the two-doors boundary under
],
});
// `update` / `delete` defer to the ADR-0094 write-through, so the gate's own
- // row protection is reachable on the lifecycle verbs only.
- const purge = (where: Row) => ({ object: 'sys_permission_set', operation: 'purge', options: { where } });
+ // row protection is reachable on the lifecycle verbs only. Of those,
+ // `transfer` is the one a caller can still be GRANTED since #12497
+ // (`restore`/`purge` lost their bits and their evaluator rows, so the
+ // object-level gate downstream refuses them unconditionally and an
+ // "admitted" leg could never resolve) — the two-doors boundary this suite
+ // measures is verb-agnostic, so the probe verb moved from `purge` to
+ // `transfer` with nothing else changing.
+ const lifecycleWrite = (where: Row) => ({ object: 'sys_permission_set', operation: 'transfer', options: { where } });
// See TENANT_ADMIN_SET: this table's writes pass a second, later gate.
const bootPkg = (o: BootOptions = {}) => boot({ sets: [TENANT_ADMIN_SET], ...o });
it('STEADY STATE: a package-managed row is refused, an admin-authored row is admitted', async () => {
const h = await bootPkg({ rows: rows() });
- const denied = await refusalOf(h.write(purge({ id: 'ps_pkg' })));
+ const denied = await refusalOf(h.write(lifecycleWrite({ id: 'ps_pkg' })));
expect(denied.code).toBe('PERMISSION_DENIED');
expect(denied.message).toContain('package-managed permission set');
- await expect(h.write(purge({ id: 'ps_env' }))).resolves.toBe('admitted');
+ await expect(h.write(lifecycleWrite({ id: 'ps_env' }))).resolves.toBe('admitted');
});
it('FAULT (by id): the outage propagates — it does NOT read as "no package row here"', async () => {
@@ -252,7 +258,7 @@ describe('[#7505] assertPackageManagedWriteGate — the two-doors boundary under
// package-managed, and the purge went through — against the very row the
// steady-state case above proves is protected.
const h = await bootPkg({ rows: rows(), faultOn: 'sys_permission_set' });
- expectPropagatedOutage(await refusalOf(h.write(purge({ id: 'ps_pkg' }))));
+ expectPropagatedOutage(await refusalOf(h.write(lifecycleWrite({ id: 'ps_pkg' }))));
});
it('FAULT (bulk filter): the same answer — one gate cannot answer two ways for one outage', async () => {
@@ -260,14 +266,14 @@ describe('[#7505] assertPackageManagedWriteGate — the two-doors boundary under
// the same question of the same store with `.findOne`, and used to swallow
// the same fault with `.catch(() => null)`.
const h = await bootPkg({ rows: rows(), faultOn: 'sys_permission_set' });
- expectPropagatedOutage(await refusalOf(h.write(purge({ name: 'Package Set' }))));
+ expectPropagatedOutage(await refusalOf(h.write(lifecycleWrite({ name: 'Package Set' }))));
});
it('STEADY STATE (bulk filter): a filter that hits no package row is still admitted', async () => {
// Fail-closed must not become "refuse everything": the bulk branch exists
// precisely so a bulk edit that touches only env-authored rows succeeds.
const h = await bootPkg({ rows: rows() });
- await expect(h.write(purge({ managed_by: 'admin' }))).resolves.toBe('admitted');
+ await expect(h.write(lifecycleWrite({ managed_by: 'admin' }))).resolves.toBe('admitted');
});
});
diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts
index 7e7c5986e4..dbe91fde12 100644
--- a/packages/qa/dogfood/test/authz-conformance.matrix.ts
+++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts
@@ -325,6 +325,6 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
note: 'ADR-0056 D2 → #3963: the `requireAuth: false` opt-out is RETIRED, not merely defaulted-on. Legitimate session-less surfaces survive by DECLARATION, not by posture: public-form submission (publicFormGrant), share-links (token → SYSTEM read), and public-book reads (audience:public, §6.7). A stack that mounts no auth now FAILS AT BOOT (cli/serve.ts, plugin-dev) instead of getting an explicit fail-open. [#7976] The `showcase-anonymous-deny.dogfood.test.ts` CITATION WAS DROPPED under mutual attribution — that file drives the platform default and observes 401, which is precisely what the `anonymous-deny` row (same file) already claims; it never authors `requireAuth: false`, never reads the spec tombstone and never boots an auth-less stack, so it cannot prove the distinguishing half of THIS row (that there is no opt-out). The retirement is pinned elsewhere and unit-side: the spec tombstone + the ADR-0087 conversion entry `stack.api.requireAuth` (conversions/registry.ts, which strips a surviving key) and rest/rest-auth-gate.test.ts. Not high-risk, so the row is sound without a dogfood proof; writing a real one (author `api: { requireAuth: false }` → expect the boot/authoring rejection) is the honest upgrade path, not re-citing the posture proof.' },
// ── Removed — by ADR-0049 (roadmap M2) ─────────────────────────────────
- { id: 'allow-transfer-restore-purge', summary: 'transfer/restore/purge ops (RBAC gate pre-mapped)', state: 'removed',
- note: 'ADR-0049 → roadmap M2. #1883: the ops still do not exist in ObjectQL, but the evaluator PRE-MAPS them (OPERATION_TO_PERMISSION transfer/restore/purge → allowTransfer/allowRestore/allowPurge, modifyAllRecords bypass, unmapped destructive ops fail closed) — there is no ungated window when the ops ship. Unit-proven in plugin-security/security-plugin.test.ts.' },
+ { id: 'allow-transfer-restore-purge', summary: 'transfer/restore/purge ops (transfer bit enforced; restore/purge bits retired)', state: 'removed',
+ note: 'ADR-0049 → roadmap M2. #1883: the ops still do not exist in ObjectQL. `transfer` stays pre-mapped (OPERATION_TO_PERMISSION transfer→allowTransfer, modifyAllRecords bypass) and `allowTransfer` is ENFORCED today through the insert/update owner_id door (#3004). `allowRestore`/`allowPurge` RETIRED 2026-08-26 (#12497, maintainer ruling accepting #1883 rec B): the bits are retiredKey tombstones and their pre-mapping rows retired with them — a dispatched restore/purge is denied unconditionally via the DESTRUCTIVE_OPERATIONS fail-closed backstop (not even modifyAllRecords reaches it), so there is still no ungated window; the keys + rows + ops return in one M2 batch. Unit-proven in plugin-security/security-plugin.test.ts.' },
];
diff --git a/packages/spec/authorable-defaults/security.json b/packages/spec/authorable-defaults/security.json
index c21bf08692..5ede0be6cd 100644
--- a/packages/spec/authorable-defaults/security.json
+++ b/packages/spec/authorable-defaults/security.json
@@ -15,9 +15,7 @@
"security/EffectiveObjectPermission:allowCreate = false",
"security/EffectiveObjectPermission:allowDelete = false",
"security/EffectiveObjectPermission:allowEdit = false",
- "security/EffectiveObjectPermission:allowPurge = false",
"security/EffectiveObjectPermission:allowRead = false",
- "security/EffectiveObjectPermission:allowRestore = false",
"security/EffectiveObjectPermission:allowTransfer = false",
"security/EffectiveObjectPermission:modifyAllRecords = false",
"security/EffectiveObjectPermission:viewAllRecords = false",
@@ -28,9 +26,7 @@
"security/ObjectPermission:allowCreate = false",
"security/ObjectPermission:allowDelete = false",
"security/ObjectPermission:allowEdit = false",
- "security/ObjectPermission:allowPurge = false",
"security/ObjectPermission:allowRead = false",
- "security/ObjectPermission:allowRestore = false",
"security/ObjectPermission:allowTransfer = false",
"security/ObjectPermission:modifyAllRecords = false",
"security/ObjectPermission:viewAllRecords = false",
diff --git a/packages/spec/authorable-surface/security.json b/packages/spec/authorable-surface/security.json
index 407767cb13..4a9e3187f7 100644
--- a/packages/spec/authorable-surface/security.json
+++ b/packages/spec/authorable-surface/security.json
@@ -53,9 +53,9 @@
"security/EffectiveObjectPermission:allowDelete",
"security/EffectiveObjectPermission:allowEdit",
"security/EffectiveObjectPermission:allowExport",
- "security/EffectiveObjectPermission:allowPurge",
+ "security/EffectiveObjectPermission:allowPurge [RETIRED]",
"security/EffectiveObjectPermission:allowRead",
- "security/EffectiveObjectPermission:allowRestore",
+ "security/EffectiveObjectPermission:allowRestore [RETIRED]",
"security/EffectiveObjectPermission:allowTransfer",
"security/EffectiveObjectPermission:apiOperations",
"security/EffectiveObjectPermission:modifyAllRecords",
@@ -98,9 +98,9 @@
"security/ObjectPermission:allowDelete",
"security/ObjectPermission:allowEdit",
"security/ObjectPermission:allowExport",
- "security/ObjectPermission:allowPurge",
+ "security/ObjectPermission:allowPurge [RETIRED]",
"security/ObjectPermission:allowRead",
- "security/ObjectPermission:allowRestore",
+ "security/ObjectPermission:allowRestore [RETIRED]",
"security/ObjectPermission:allowTransfer",
"security/ObjectPermission:modifyAllRecords",
"security/ObjectPermission:readScope",
diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md
index a30f5886f0..046ec01632 100644
--- a/packages/spec/liveness/README.md
+++ b/packages/spec/liveness/README.md
@@ -791,7 +791,7 @@ marker where the Notes cell goes, never a guess at what belongs there.
| flow | dead count = **5 tombstone entries** + the kept docs field: `active`/`template`/nodes.`outputSchema`/errorHandling.`fallbackNodeId` REMOVED 2026-07-30 (#3896 close-out sweep — `active: false` never stopped a flow, `status` is the enforced lifecycle; faults route via per-node fault edges), plus errorHandling.`retryDelayMs` RENAMED to `backoffMs` 2026-08-04 (#4964). The rename is why the dead column moved while live did not: a rename is a removal on this ledger, so the old spelling is tombstoned (`retiredKey` keeps it in the walked shape) and the new spelling enters as its own `live` row. Read it beside the four above as the one entry here that cost an author nothing — the block was a THIRD encoding of the retry policy #4661 converged, invisible to that pass because it is an anonymous inline block with no exported name, and #4964 spelled its base delay `backoffMs` to match `job.retryPolicy` and a `try_catch` node's `retry`. Remaining dead = `description`, KEPT deliberately: docs-shaped, exempt from enforce-or-remove |
| action | `type:'form'` CORRECTED to live (objectui ActionRunner.executeForm, #2377); dead `timeout` REMOVED (#2377); `disabled` live since objectui#2863; `undoable` CORRECTED to live (#3714); `shortcut` + `bulkEnabled` REMOVED 2026-07-30 (#3896 close-out sweep — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions), and they are still the whole dead set. **#7367** (PR #7430) adds `description` as an authorable key, `live` on arrival — the only row this type has gained since that sweep |
| hook | model-healthy; label/description dead but KEPT deliberately (2026-07-30 sweep) — docs-shaped annotation fields, exempt from enforce-or-remove |
-| permission | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection) |
+| permission | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection). `objects.allowRestore`/`allowPurge` REMOVED 2026-08-26 (#12497, ADR-0049 — the `restore`/`purge` ops never existed; the 2026-07-30 'live' verdict cited only the evaluator pre-mapping, retired in the same batch; `retiredKey` tombstones, keys return with M2 per the #1883 ruling) |
| position | (role's ADR-0090 successor) fully live; all 4 `verifiedAt`-stamped 2026-07-30 |
| agent | dead `tenantId` + `planning.strategy`/`allowReplan` REMOVED (#2377); autonomy tier experimental; `knowledge` REMOVED 2026-07-30 (#3896 close-out sweep — declaring sources never scoped retrieval; AIKnowledgeSchema removed with it, the topics→sources rename absorbed pre-release) |
| tool | the inert authoring surface is now REMOVED, not merely marked: `category`/`permissions`/`active`/`builtIn` retired 2026-07-30 (#3896 close-out) after `requiresConfirmation` set the precedent (#3715, ADR-0033 §2). `permissions` promised an invocation gate nothing enforced and `active:false` withdrew nothing — false compliance, same shape as rls.enabled. The `.strict()` ToolSchema rejects each retired key with its prescription; the `tool-inert-authoring-keys-removed` conversion strips them from authored sources |
diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json
index a4367c6f36..212ea7d419 100644
--- a/packages/spec/liveness/permission.json
+++ b/packages/spec/liveness/permission.json
@@ -87,16 +87,16 @@
"note": "#1883 — RBAC gate pre-mapped, deny unless granted; the `transfer` ObjectQL operation is pending M2, so granting delivers nothing until it ships. Re-verified 2026-07-30: M2 still unshipped (no transfer/restore/purge operations in packages/objectql), the gate mapping stands."
},
"allowRestore": {
- "status": "live",
- "verifiedAt": "2026-07-30",
- "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:15 (OPERATION_TO_PERMISSION restore→allowRestore; DESTRUCTIVE_OPERATIONS fail-closed backstop)",
- "note": "#1883 — RBAC gate pre-mapped, deny unless granted; the `restore` ObjectQL operation is pending M2. Re-verified 2026-07-30: M2 still unshipped."
+ "status": "dead",
+ "verifiedAt": "2026-08-26",
+ "evidence": "packages/spec/src/security/permission.zod.ts (retiredKey tombstone — authored values REJECT with the prescription; z.input types the key never)",
+ "note": "REMOVED 2026-08-26 (#12497, ADR-0049 enforce-or-remove — maintainer ruling accepting #1883's recommendation B) — the `restore` ObjectQL operation the bit claimed to gate has never existed (no destructive lifecycle verb in the engine's dispatch vocabulary, #8106 pin), so granting it delivered nothing. The former 'live' verdict cited only the evaluator's pre-mapping row, which retired in the same batch: a mapping onto an unwritable bit is a claim about a surface that rejects authoring. A dispatched `restore` stays denied fail-closed via DESTRUCTIVE_OPERATIONS. Tombstoned at the schema and stripped from sources by the protocol-18 conversion `permission-allow-restore-purge-removed`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); the key RETURNS with the M2 lifecycle initiative (#1883, feature + RBAC in one batch) — drop the tombstone (and this entry) only if M2 respells it."
},
"allowPurge": {
- "status": "live",
- "verifiedAt": "2026-07-30",
- "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:15 (OPERATION_TO_PERMISSION purge→allowPurge; DESTRUCTIVE_OPERATIONS fail-closed backstop)",
- "note": "#1883 — RBAC gate pre-mapped, deny unless granted; the `purge` ObjectQL operation is pending M2. Re-verified 2026-07-30: M2 still unshipped."
+ "status": "dead",
+ "verifiedAt": "2026-08-26",
+ "evidence": "packages/spec/src/security/permission.zod.ts (retiredKey tombstone — authored values REJECT with the prescription; z.input types the key never)",
+ "note": "REMOVED 2026-08-26 (#12497, ADR-0049 enforce-or-remove — maintainer ruling accepting #1883's recommendation B) — the `purge` ObjectQL operation the bit claimed to gate has never existed (no destructive lifecycle verb in the engine's dispatch vocabulary, #8106 pin), so granting it delivered nothing; worse, an admin who set `allowPurge: false` believed a lock on GDPR hard-deletion existed. The former 'live' verdict cited only the evaluator's pre-mapping row, which retired in the same batch. A dispatched `purge` stays denied fail-closed via DESTRUCTIVE_OPERATIONS. Tombstoned at the schema and stripped from sources by the protocol-18 conversion `permission-allow-restore-purge-removed`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); the key RETURNS with the M2 lifecycle initiative (#1883, feature + RBAC in one batch) — drop the tombstone (and this entry) only if M2 respells it."
},
"viewAllRecords": {
"status": "live",
diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md
index 80ce8b863d..d9b6ad9279 100644
--- a/packages/spec/liveness/state-counts.md
+++ b/packages/spec/liveness/state-counts.md
@@ -32,7 +32,7 @@ for both corollaries.
| `flow` | 34 | 0 | 6 | 0 | 40 |
| `action` | 42 | 0 | 2 | 2 | 46 |
| `hook` | 18 | 0 | 2 | 0 | 20 |
-| `permission` | 38 | 0 | 4 | 0 | 42 |
+| `permission` | 36 | 0 | 6 | 0 | 42 |
| `position` | 12 | 0 | 0 | 0 | 12 |
| `agent` | 21 | 4 | 1 | 0 | 26 |
| `tool` | 13 | 1 | 0 | 0 | 14 |
@@ -58,4 +58,4 @@ for both corollaries.
| `capability` | 12 | 0 | 0 | 0 | 12 |
| `qa` | 4 | 0 | 5 | 0 | 9 |
| `manifest` | 22 | 0 | 21 | 0 | 43 |
-| **total** | **823** | **5** | **76** | **10** | **914** |
+| **total** | **821** | **5** | **78** | **10** | **914** |
diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts
index c06ecd3a6e..cd8dc13244 100644
--- a/packages/spec/src/conversions/registry.ts
+++ b/packages/spec/src/conversions/registry.ts
@@ -8093,6 +8093,99 @@ const objectGridDefaultSortRemoved: MetadataConversion = {
},
};
+/**
+ * Object-permission lifecycle bits `allowRestore` / `allowPurge` removed
+ * (protocol 18, #12497 — ADR-0049 enforce-or-remove, maintainer ruling
+ * 2026-08-26 accepting #1883's recommendation B).
+ *
+ * The `restore` / `purge` ObjectQL operations the bits claimed to gate have
+ * never existed: no destructive lifecycle verb is in the engine's dispatch
+ * vocabulary (pinned by objectql's
+ * `engine-middleware-operation-vocabulary.test.ts`, #8106). Authoring the bits
+ * therefore granted nothing — an advertised switch with nothing behind it, on
+ * the most destructive operations (undelete, GDPR hard-delete). A pure
+ * lossless delete: a dispatched `restore`/`purge` was denied before (deny
+ * unless a bit nothing could ever exercise was granted) and stays denied after
+ * (the evaluator's `DESTRUCTIVE_OPERATIONS` fail-closed backstop — the
+ * pre-mapping rows retired in the same batch, #12497). The keys RETURN with
+ * the M2 lifecycle initiative (feature + RBAC in one batch); #1883 stays open.
+ *
+ * `allowTransfer` — the third lifecycle bit — is ENFORCED (#3004) and stays.
+ *
+ * `retiredFromLoadPath`: ObjectPermissionSchema tombstones both keys
+ * (`retiredKey`, tsc `never` + the parse-time prescription), the
+ * `permission-rls-priority-removed` posture one block over.
+ */
+const permissionAllowRestorePurgeRemoved: MetadataConversion = {
+ id: 'permission-allow-restore-purge-removed',
+ toMajor: 18,
+ retiredFromLoadPath: true,
+ surface: 'permission.objects..allowRestore / permission.objects..allowPurge',
+ summary:
+ "object-permission keys 'allowRestore' and 'allowPurge' removed (#12497, ADR-0049 — the "
+ + '`restore`/`purge` operations they claimed to gate have never existed, so granting the '
+ + 'bits delivered nothing; dispatched destructive lifecycle verbs stay denied fail-closed. '
+ + 'The keys return with the M2 lifecycle initiative, #1883)',
+ apply(stack, emit) {
+ return mapCollection(stack, 'permissions', (ps, path) => {
+ const objects = (ps as { objects?: unknown }).objects;
+ if (!isDict(objects)) return ps;
+ let touched = false;
+ const nextObjects: Record = { ...objects };
+ for (const [objName, perm] of Object.entries(objects)) {
+ if (!isDict(perm)) continue;
+ const stripped = stripKeys(perm, ['allowRestore', 'allowPurge'], emit, `${path}.objects.${objName}`);
+ if (stripped === perm) continue;
+ nextObjects[objName] = stripped;
+ touched = true;
+ }
+ if (!touched) return ps;
+ return { ...ps, objects: nextObjects };
+ });
+ },
+ fixture: {
+ before: {
+ permissions: [{
+ name: 'support_agent',
+ label: 'Support Agent',
+ objects: {
+ // The measured shape: full CRUD plus the two inert lifecycle bits.
+ crm_ticket: {
+ allowRead: true,
+ allowCreate: true,
+ allowEdit: true,
+ allowDelete: true,
+ allowRestore: true,
+ allowPurge: false,
+ },
+ // An object WITHOUT the keys rides through untouched — the strip
+ // dispatches on key presence, and the copy-on-write contract keeps
+ // the reference.
+ crm_note: { allowRead: true },
+ },
+ }],
+ },
+ after: {
+ permissions: [{
+ name: 'support_agent',
+ label: 'Support Agent',
+ objects: {
+ crm_ticket: {
+ allowRead: true,
+ allowCreate: true,
+ allowEdit: true,
+ allowDelete: true,
+ },
+ crm_note: { allowRead: true },
+ },
+ }],
+ },
+ // Two notices: both keys on the one object carrying them (presence-based —
+ // the authored `false` is as dead as the authored `true`).
+ expectedNotices: 2,
+ },
+};
+
export const CONVERSIONS_BY_MAJOR: Readonly> = {
11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename],
13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition],
@@ -8178,6 +8271,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly>
// registration-time refusal in `PluginHealthMonitor.registerPlugin` is the door
// for the audience that exists.
'kernel/PluginHealthCheck:restartBackoff',
+ // #12497 — the RESPONSE-side face of `security/ObjectPermission:allowPurge`
+ // (see that entry for the full rationale: ADR-0049 enforce-or-remove,
+ // maintainer ruling 2026-08-26 accepting #1883's recommendation B; the key
+ // returns with the M2 lifecycle initiative). `EffectiveObjectPermissionSchema`
+ // is `ObjectPermissionSchema.extend({ apiOperations }).strip()` — the clone
+ // shares the authoring shape's per-property schema instances, so the
+ // `retiredKey()` tombstone rides into the effective surface and this def's
+ // walked shape carries the same `[RETIRED]` row. Registered so the aging clock
+ // (#5898) has an exact-key entry for BOTH rows the tombstone produces. The
+ // effective surface is server-resolved, never authored, so no D2 conversion
+ // clause targets it — the authoring-side strip in
+ // `permission-allow-restore-purge-removed` is the only source rewrite that
+ // exists to do.
+ 'security/EffectiveObjectPermission:allowPurge',
+ // #12497 — the RESPONSE-side face of `security/ObjectPermission:allowRestore`
+ // (see that entry for the full rationale: ADR-0049 enforce-or-remove,
+ // maintainer ruling 2026-08-26 accepting #1883's recommendation B; the key
+ // returns with the M2 lifecycle initiative). `EffectiveObjectPermissionSchema`
+ // is `ObjectPermissionSchema.extend({ apiOperations }).strip()` — the clone
+ // shares the authoring shape's per-property schema instances, so the
+ // `retiredKey()` tombstone rides into the effective surface and this def's
+ // walked shape carries the same `[RETIRED]` row. Registered so the aging clock
+ // (#5898) has an exact-key entry for BOTH rows the tombstone produces. The
+ // effective surface is server-resolved, never authored, so no D2 conversion
+ // clause targets it — the authoring-side strip in
+ // `permission-allow-restore-purge-removed` is the only source rewrite that
+ // exists to do.
+ 'security/EffectiveObjectPermission:allowRestore',
+ // #12497 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-26, decision-
+ // inbox batch 5, accepting #1883's recommendation B). `allowPurge` claimed to
+ // gate a `purge` (hard-delete / GDPR erase) ObjectQL operation that has never
+ // existed: no destructive lifecycle verb is in the engine's dispatch
+ // vocabulary (pinned by objectql's
+ // `engine-middleware-operation-vocabulary.test.ts`, #8106). This was ADR-0049's
+ // worst false-compliance shape — an admin who set `allowPurge: false` believed
+ // a lock on permanent deletion existed, when the operation itself did not.
+ // The permission evaluator's pre-mapping row (`OPERATION_TO_PERMISSION`
+ // purge→allowPurge) retired in the same batch: with the bit unwritable, a
+ // mapping onto it was a claim about a surface that rejects authoring. A
+ // dispatched `purge` stays denied fail-closed via the evaluator's
+ // `DESTRUCTIVE_OPERATIONS` backstop — there is no ungated window in either
+ // direction. THE KEY RETURNS with the M2 lifecycle initiative (feature + RBAC
+ // in one batch, maintainer 2026-08-03); anchor card #1883 stays open.
+ //
+ // Registered under 18, not 17: v17.0.0 was cut before this landed, so the
+ // removal ships on the 17.x line (launch-window convention: accept-set
+ // narrowings ride minor releases) and the prescription lives at the major
+ // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent).
+ // ObjectPermissionSchema is `strictObject` but the def is reachable from the
+ // `permission` metadata root, so the route is the `retiredKey()` tombstone
+ // (the `rls.priority` posture) — the key stays in the walked shape as
+ // `[RETIRED]`, and authoring it is a tsc error and a parse error carrying the
+ // prescription. Sources are rewritten by the D2 conversion
+ // `permission-allow-restore-purge-removed`, which strips the key from every
+ // object grant in `permissions[].objects`.
+ 'security/ObjectPermission:allowPurge',
+ // #12497 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-26, decision-
+ // inbox batch 5, accepting #1883's recommendation B). `allowRestore` claimed to
+ // gate a `restore` (undelete) ObjectQL operation that has never existed: no
+ // destructive lifecycle verb is in the engine's dispatch vocabulary (pinned by
+ // objectql's `engine-middleware-operation-vocabulary.test.ts`, #8106), so
+ // authoring the bit granted nothing — an AI author who declared it believed a
+ // recycle-bin capability boundary existed, and the failure was silent. The
+ // permission evaluator's pre-mapping row (`OPERATION_TO_PERMISSION`
+ // restore→allowRestore) retired in the same batch: with the bit unwritable, a
+ // mapping onto it was a claim about a surface that rejects authoring. A
+ // dispatched `restore` stays denied fail-closed via the evaluator's
+ // `DESTRUCTIVE_OPERATIONS` backstop — there is no ungated window in either
+ // direction. THE KEY RETURNS with the M2 lifecycle initiative (feature + RBAC
+ // in one batch, maintainer 2026-08-03); anchor card #1883 stays open.
+ //
+ // Registered under 18, not 17: v17.0.0 was cut before this landed, so the
+ // removal ships on the 17.x line (launch-window convention: accept-set
+ // narrowings ride minor releases) and the prescription lives at the major
+ // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent).
+ // ObjectPermissionSchema is `strictObject` but the def is reachable from the
+ // `permission` metadata root, so the route is the `retiredKey()` tombstone
+ // (the `rls.priority` posture) — the key stays in the walked shape as
+ // `[RETIRED]`, and authoring it is a tsc error and a parse error carrying the
+ // prescription. Sources are rewritten by the D2 conversion
+ // `permission-allow-restore-purge-removed`, which strips the key from every
+ // object grant in `permissions[].objects`.
+ 'security/ObjectPermission:allowRestore',
// #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never
// had a renderer or reader anywhere: objectui registers none (its
// renderers/basic/elements.tsx header deferred the element to "owning plugins"
diff --git a/packages/spec/src/security/high-privilege.ts b/packages/spec/src/security/high-privilege.ts
index 9d29e16b5a..a9ecf2eab1 100644
--- a/packages/spec/src/security/high-privilege.ts
+++ b/packages/spec/src/security/high-privilege.ts
@@ -62,7 +62,15 @@ export function describeHighPrivilegeBits(def: any): string | null {
for (const [objName, rawPerm] of Object.entries(objects)) {
const p: any = rawPerm ?? {};
if (p.viewAllRecords || p.modifyAllRecords) return `View/Modify All Data on '${objName}'`;
- if (p.allowDelete || p.allowPurge || p.allowTransfer) return `delete/purge/transfer on '${objName}'`;
+ // The class message keeps the D5 name "delete/purge/transfer", but the
+ // `allowPurge` READ is gone (#12497): the bit is a retiredKey tombstone —
+ // no authored or freshly-parsed set can carry it, and a legacy stored row
+ // that still does grants nothing (no `purge` operation exists, and the
+ // evaluator's mapping row retired with the bit), so flagging it guarded
+ // nothing real. When the M2 batch restores the bit and its gate row,
+ // restore the read here in the same PR — anchor bindings are re-checked
+ // at boot, so a legacy value regains no privilege silently.
+ if (p.allowDelete || p.allowTransfer) return `delete/purge/transfer on '${objName}'`;
if (p.allowExport) return `bulk export on '${objName}'`;
}
}
diff --git a/packages/spec/src/security/permission.test.ts b/packages/spec/src/security/permission.test.ts
index dd8dd24c06..14b6b30ddd 100644
--- a/packages/spec/src/security/permission.test.ts
+++ b/packages/spec/src/security/permission.test.ts
@@ -108,6 +108,62 @@ describe('ObjectPermissionSchema', () => {
});
});
+describe('allowRestore / allowPurge are RETIRED (#12497, ADR-0049)', () => {
+ // Removed by the 2026-08-26 maintainer ruling accepting #1883's
+ // recommendation B: the `restore`/`purge` ObjectQL operations the bits
+ // claimed to gate have never existed (no destructive lifecycle verb in the
+ // engine's dispatch vocabulary — the #8106 pin), so granting them delivered
+ // nothing. The tombstone keeps the removal audible instead of silently
+ // stripping an authored value; the keys return with the M2 lifecycle
+ // initiative (#1883 stays open).
+
+ it('absent parses clean — no defaults materialize for the retired keys', () => {
+ const parsed = ObjectPermissionSchema.parse({ allowRead: true });
+ expect('allowRestore' in parsed, 'retired key contributes nothing to the parsed output').toBe(false);
+ expect('allowPurge' in parsed, 'retired key contributes nothing to the parsed output').toBe(false);
+ });
+
+ it('authored values reject with the prescription (not a bare strict error)', () => {
+ for (const key of ['allowRestore', 'allowPurge'] as const) {
+ // Both directions are dead: the authored `false` claimed a lock that
+ // never existed just as loudly as the authored `true` claimed a grant.
+ for (const value of [true, false]) {
+ const r = ObjectPermissionSchema.safeParse({ [key]: value } as never);
+ expect(r.success).toBe(false);
+ const messages = r.error!.issues.map((i) => i.message).join('\n');
+ expect(messages).toContain('#12497');
+ expect(messages).toContain('removed in @objectstack/spec 17');
+ expect(messages).toContain('Delete the key');
+ expect(messages).toContain('M2');
+ }
+ }
+ });
+
+ it('the bare verbs carry the prescription too, never a rename onto a tombstone', () => {
+ // `restore`/`purge` were ALIASES of the retired bits; an alias may only
+ // prescribe a key the shape accepts (#5013), so both verbs moved to
+ // `guidance` and answer with the retirement instead of a dead-end rename.
+ for (const key of ['restore', 'purge'] as const) {
+ const r = ObjectPermissionSchema.safeParse({ [key]: true } as never);
+ expect(r.success).toBe(false);
+ const messages = r.error!.issues.map((i) => i.message).join('\n');
+ expect(messages).toContain('#12497');
+ expect(messages).not.toContain(`\`${key}\` → \``);
+ }
+ });
+
+ it('the tombstone rides into the EffectiveObjectPermission clone', () => {
+ // `.extend()` shares the authoring shape's per-property instances, so the
+ // response-side def carries the same `[RETIRED]` row in the authorable
+ // surface — and a DECLARED-never key is refused there even though the
+ // schema `.strip()`s unknown keys (declared ≠ unknown). No server can emit
+ // the bit any more (the parsed authoring output omits it), so this refusal
+ // has no wire-compat cost inside the launch window.
+ const r = EffectiveObjectPermissionSchema.safeParse({ allowRead: true, allowRestore: false } as never);
+ expect(r.success).toBe(false);
+ });
+});
+
describe('EffectiveObjectPermissionSchema (#3391 response-side)', () => {
it('carries every ObjectPermission field plus optional apiOperations', () => {
const parsed = EffectiveObjectPermissionSchema.parse({
diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts
index fd887b7c8d..abb833d1b2 100644
--- a/packages/spec/src/security/permission.zod.ts
+++ b/packages/spec/src/security/permission.zod.ts
@@ -17,6 +17,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
* - Purge (Hard delete / Compliance)
*/
import { lazySchema } from '../shared/lazy-schema';
+import { retiredKey } from '../shared/retired-key';
import { strictObject } from '../shared/strict-object';
/**
* [ADR-0057 D1] Object access DEPTH — the Dataverse "access level" axis,
@@ -58,8 +59,10 @@ const OBJECT_PERMISSION_KEY_ALIASES: Readonly> = {
remove: 'allowDelete',
export: 'allowExport',
transfer: 'allowTransfer',
- restore: 'allowRestore',
- purge: 'allowPurge',
+ // `restore` / `purge` left this table with the #12497 retirement: an alias
+ // may only prescribe a key the shape ACCEPTS (#5013), and `allowRestore` /
+ // `allowPurge` are tombstones now. Both verbs moved to `guidance` below so
+ // an author reaching for them still gets the retirement prescription.
canread: 'allowRead',
cancreate: 'allowCreate',
canedit: 'allowEdit',
@@ -80,6 +83,25 @@ export const ObjectPermissionSchema = lazySchema(() => strictObject(
'only on the RESPONSE surface (`/me/permissions`) and is never authored. Grant ' +
'capability with the `allow*` bits here; tighten an object\'s exposure with ' +
'`apiMethods` on the object schema.',
+ // ── Former aliases of the #12497 tombstones. The bare verbs were never
+ // accepted keys, but until the retirement they were aliased to
+ // `allowRestore` / `allowPurge`; pointing them at a tombstone would
+ // send the author into a second rejection (#5013), so they carry the
+ // prescription directly.
+ restore:
+ '`restore` was the alias of `objects..allowRestore`, which was removed in ' +
+ '@objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed ' +
+ 'to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete ' +
+ 'the key — a dispatched `restore` stays denied fail-closed by the permission ' +
+ 'evaluator\'s destructive-operation backstop, and the bit returns with the M2 ' +
+ 'lifecycle initiative (#1883) alongside the operation it gates.',
+ purge:
+ '`purge` was the alias of `objects..allowPurge`, which was removed in ' +
+ '@objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed ' +
+ 'to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete ' +
+ 'the key — a dispatched `purge` stays denied fail-closed by the permission ' +
+ 'evaluator\'s destructive-operation backstop, and the bit returns with the M2 ' +
+ 'lifecycle initiative (#1883) alongside the operation it gates.',
},
history:
'Until #4001 these were dropped silently — the permission set still parsed, so the ' +
@@ -134,15 +156,8 @@ export const ObjectPermissionSchema = lazySchema(() => strictObject(
/**
* Lifecycle Operations.
*
- * RBAC-gated, operations pending (#1883 / roadmap M2). The dedicated
- * `transfer`/`restore`/`purge` ObjectQL operations do not exist yet, but the
- * permission evaluator PRE-MAPS them to these bits
- * (`permission-evaluator.ts` OPERATION_TO_PERMISSION): the moment such an
- * operation is dispatched it is denied unless a resolved permission set
- * grants the bit (or `modifyAllRecords`). Until the operations ship,
- * authoring `restore`/`purge` grants nothing — there is no ungated window
- * either way (unmapped destructive ops additionally fail CLOSED via
- * DESTRUCTIVE_OPERATIONS, per ADR-0049).
+ * `allowTransfer` is the one lifecycle bit that stays: ENFORCED (#3004). Its
+ * former siblings `allowRestore` / `allowPurge` are tombstones below.
*
* EXCEPTION (#3004): `allowTransfer` is ALREADY ENFORCED today through the
* ordinary `insert`/`update` door, not only the future `transfer` op. The
@@ -154,8 +169,44 @@ export const ObjectPermissionSchema = lazySchema(() => strictObject(
* operation will reuse the same bit.
*/
allowTransfer: z.boolean().default(false).describe('[RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id)'),
- allowRestore: z.boolean().default(false).describe('[RBAC-gated; operation pending M2] Restore from trash (Undelete)'),
- allowPurge: z.boolean().default(false).describe('[RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR)'),
+
+ /**
+ * REMOVED — `allowRestore` / `allowPurge` claimed to gate `restore` /
+ * `purge` ObjectQL operations that have never existed (no destructive
+ * lifecycle verb is in the engine's dispatch vocabulary — pinned by
+ * objectql's `engine-middleware-operation-vocabulary.test.ts`, #8106).
+ *
+ * ADR-0049 enforce-or-remove, maintainer ruling 2026-08-26 accepting
+ * #1883's recommendation B (#12497): an AI author who declared
+ * `allowPurge: false` believed a lock existed; the failure was silent.
+ * Tombstoned so the removal is audible (tsc `never` + the parse-time
+ * prescription) instead of a silent strip. The former evaluator pre-mapping
+ * (`OPERATION_TO_PERMISSION` restore/purge rows) retired in the same batch —
+ * with the bits unwritable, a mapping onto them was a claim about a surface
+ * that rejects authoring — and a dispatched `restore`/`purge` stays denied
+ * fail-closed via the evaluator's `DESTRUCTIVE_OPERATIONS` backstop, so
+ * there is still no ungated window. THE KEYS RETURN with the M2 lifecycle
+ * initiative (feature + RBAC in one batch, maintainer 2026-08-03); #1883
+ * stays open as the anchor.
+ */
+ allowRestore: retiredKey(
+ '`objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — ' +
+ 'the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so ' +
+ 'granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied ' +
+ 'fail-closed by the permission evaluator\'s destructive-operation backstop, and the bit ' +
+ 'returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. ' +
+ 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.',
+ ),
+ allowPurge: retiredKey(
+ '`objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — ' +
+ 'the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so ' +
+ 'granting the bit delivered nothing (a compliance/GDPR erase the author believed was ' +
+ 'permission-locked was not — the operation itself does not exist). Delete the key — a ' +
+ 'dispatched `purge` stays denied fail-closed by the permission evaluator\'s ' +
+ 'destructive-operation backstop, and the bit returns with the M2 lifecycle initiative ' +
+ '(#1883) alongside the operation it gates. ' +
+ 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.',
+ ),
/**
* View All Records: Super-user read access.