Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .changeset/permission-restore-purge-retired.md
Original file line numberDiff line numberDiff line change
@@ -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 },
},
});
```

<!-- adr-0087: registered permission-allow-restore-purge-removed -->
15 changes: 7 additions & 8 deletions content/docs/permissions/permission-metadata.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
},
Expand All@@ -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:
Expand DownExpand Up@@ -229,8 +232,6 @@ const salesManagerPermission = {
allowEdit: true,
allowDelete: true,
allowTransfer: true,
allowRestore: true,
allowPurge: false,
viewAllRecords: true,
modifyAllRecords: false,
},
Expand All@@ -240,8 +241,6 @@ const salesManagerPermission = {
allowEdit: true,
allowDelete: true,
allowTransfer: true,
allowRestore: true,
allowPurge: false,
viewAllRecords: true,
modifyAllRecords: true,
},
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/permission-sets.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |

Expand Down
6 changes: 2 additions & 4 deletions content/docs/permissions/permissions-matrix.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |

<Callout type="tip">
**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
Expand All@@ -52,7 +50,7 @@ and `plugin-security/src/security-plugin.ts` (`computeLayeredRlsFilter`).
</Callout>

<Callout type="warn">
**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).
</Callout>

---
Expand Down
9 changes: 7 additions & 2 deletions content/docs/protocol/objectql/security.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
8 changes: 4 additions & 4 deletions content/docs/references/api/protocol.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.<object>.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.<object>.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 |
Expand DownExpand Up@@ -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.<object>.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.<object>.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 |
Expand Down
Loading
Loading