|
| 1 | +# ADR-0094: Permission-Set Definitions Have One Authoritative Store — `sys_permission_set` Becomes a Pure Projection |
| 2 | + |
| 3 | +**Status**: Accepted (2026-07-14) |
| 4 | +**Deciders**: ObjectStack Protocol Architects |
| 5 | +**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (overlay store), [ADR-0056](./0056-permission-model-landing-verification.md) (landing verification), [ADR-0086](./0086-authz-metadata-config-boundary-and-cross-package-composition.md) (two doors / provenance) |
| 6 | +**Closes**: framework#2875 (root cause behind the #2857 display-freshness class) |
| 7 | +**Consumers**: `@objectstack/plugin-security`, `@objectstack/metadata-protocol`, Setup/Studio surfaces |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## TL;DR |
| 12 | + |
| 13 | +A permission-set **definition** (label, description, the six facet groups, `adminScope`, |
| 14 | +`active`) now has exactly **one authoritative store: the metadata layer** — packaged |
| 15 | +declarations plus the `sys_metadata` overlay, merged overlay-wins by the protocol's |
| 16 | +layered read. The queryable `sys_permission_set` data record is a **derived read-model |
| 17 | +(projection)**, never independently authoritative. This is enforced **structurally**, |
| 18 | +not by a subscriber a new write path might forget to trigger: |
| 19 | + |
| 20 | +1. **Write-through at the engine choke point.** Every non-system data-plane write to |
| 21 | + `sys_permission_set` (the Setup UI's generic CRUD, bulk imports, any future API that |
| 22 | + goes through ObjectQL) is intercepted by an engine middleware and **redirected into a |
| 23 | + metadata write** (`saveMetaItem` / `deleteMetaItem`). The driver write never executes, |
| 24 | + so no data-door path can produce a record the metadata doesn't back. |
| 25 | +2. **Awaited projection.** The metadata protocol gains a per-type **mutation projector** |
| 26 | + (`registerMutationProjector`) that is **awaited inside** `saveMetaItem` / |
| 27 | + `publishMetaItem` / `deleteMetaItem`, after persistence and before the write returns. |
| 28 | + The projector is the **only writer** of the record. A Studio save therefore returns |
| 29 | + only after the record already reflects it — no projection race (the #2867 subscriber |
| 30 | + was fire-and-forget). |
| 31 | +3. **Boot reconciliation + one-time backfill.** At `kernel:ready` the projection is |
| 32 | + re-derived from metadata (metadata wins), and legacy records that exist **only** in |
| 33 | + the data plane are migrated into the metadata store once. |
| 34 | + |
| 35 | +Package-owned records (`managed_by:'package'`) keep their ADR-0086 semantics: their |
| 36 | +baseline is the shipped declaration, projected by boot seeding / publish |
| 37 | +materialization; the environment door never touches them. |
| 38 | + |
| 39 | +--- |
| 40 | + |
| 41 | +## Context |
| 42 | + |
| 43 | +`sys_permission_set` had **two writable stores** that were only loosely synchronized: |
| 44 | + |
| 45 | +- **The metadata layer** — declarations registered by packages, plus env-scope edits |
| 46 | + written to the `sys_metadata` overlay by Studio (`saveMetaItem`). This is what the |
| 47 | + layered read shows and (mostly — see below) what enforcement resolves. |
| 48 | +- **The data record** — snake_case JSON-string columns that Setup reads for lists and |
| 49 | + user assignment, and *wrote* through the generic data CRUD endpoint. |
| 50 | + |
| 51 | +They were synced at boot and on publish (ADR-0086 D5/P2), and — after #2867 — by an |
| 52 | +`onMetadataMutation` subscriber projecting env-scope metadata saves onto the record. |
| 53 | +That subscriber is eventually-consistent glue: any write path that bypasses it (a bulk |
| 54 | +import, a migration, a future API) desyncs the two stores with no single winner. |
| 55 | + |
| 56 | +The audit for this ADR found the split-brain is worse than a stale display: |
| 57 | + |
| 58 | +- **Enforcement is metadata-first.** `PermissionEvaluator.resolvePermissionSets` |
| 59 | + resolves names from `metadata.list('permission')` first, the DB record last. A Setup |
| 60 | + edit of a *declared* set (e.g. `member_default`) therefore updated the record — and |
| 61 | + was **silently enforcement-inert**: the evaluator kept using the declared body. The |
| 62 | + record lied in *both* directions. |
| 63 | +- **The manager's `list()` is registry-first**, while the protocol's layered read is |
| 64 | + overlay-wins. An env-scope Studio edit of a declared set displayed (layered read, |
| 65 | + and — after #2867 — the record) but the evaluator still resolved the *declared* body |
| 66 | + from the in-memory registry. Display and enforcement disagreed with no error anywhere. |
| 67 | +- **Studio-created env sets never appeared in Setup** (the #2867 projection declined to |
| 68 | + create records), and Setup-created sets never existed in metadata at all — the record |
| 69 | + was their *only* store, resolvable solely through the evaluator's DB fallback loader. |
| 70 | + |
| 71 | +## Decision |
| 72 | + |
| 73 | +### D1 — The metadata layer is the only authoritative store for definitions |
| 74 | + |
| 75 | +The authoritative body of a permission set named `X` is the protocol's **layered |
| 76 | +effective read** for `permission/X` (env-scope overlay wins over packaged declaration). |
| 77 | +The `sys_permission_set` record for `X` is a projection of exactly that body, keyed by |
| 78 | +`name`. Row `id`s are stable (junction tables `sys_user_permission_set` / |
| 79 | +`sys_position_permission_set` reference them); the projector updates in place and never |
| 80 | +recreates ids. |
| 81 | + |
| 82 | +**Assignments and bindings stay data-plane.** Which users/positions hold a set is |
| 83 | +environment *state*, not part of the definition; those tables are unchanged. |
| 84 | + |
| 85 | +### D2 — The record is written only by the projector, awaited by the protocol |
| 86 | + |
| 87 | +`@objectstack/metadata-protocol` gains `registerMutationProjector(type, fn)`: an |
| 88 | +awaited, best-effort per-type hook invoked after persistence inside `saveMetaItem` |
| 89 | +(active saves), `publishMetaItem`, and `deleteMetaItem`, receiving |
| 90 | +`{ type, name, state, organizationId, body? }`. A projector failure is surfaced on the |
| 91 | +write's response (`projectionApplied: { success:false, error }`) and logged — never |
| 92 | +thrown, the metadata write itself succeeded and boot reconciliation heals on next start. |
| 93 | + |
| 94 | +`plugin-security` registers the `permission` projector. It re-reads the **fresh layered |
| 95 | +effective body** and: |
| 96 | + |
| 97 | +- upserts the env record (creates it if missing, `managed_by:'user'` — Studio-created |
| 98 | + sets now appear in Setup); |
| 99 | +- **refuses package-owned records** (`managed_by:'package'`) — the package door owns |
| 100 | + them (ADR-0086 D4); |
| 101 | +- syncs the **metadata manager's in-memory `permission` entry** |
| 102 | + (`registerInMemory`) so the evaluator's registry-first `list('permission')` |
| 103 | + resolution sees the same effective body it projects — closing the |
| 104 | + display-vs-enforcement divergence described above; |
| 105 | +- on a mutation whose layered read yields **no body at all** (a runtime-only definition |
| 106 | + was deleted), retires the record (engine delete; trash semantics apply) and drops the |
| 107 | + in-memory entry. |
| 108 | + |
| 109 | +The existing `onMetadataMutation` subscription remains only as a compatibility fallback |
| 110 | +when the protocol predates `registerMutationProjector`. |
| 111 | + |
| 112 | +### D3 — Data-door writes are redirected into metadata (write-through) |
| 113 | + |
| 114 | +An engine middleware (registered by `plugin-security`, object-filtered to |
| 115 | +`sys_permission_set`, running **inside** the security middleware so all existing |
| 116 | +authorization — the ADR-0086 two-doors gate, the ADR-0090 D12 delegated-admin gate, |
| 117 | +CRUD/FLS checks — applies first) translates every **non-system** write: |
| 118 | + |
| 119 | +| Data-door operation | Redirected to | |
| 120 | +| :-- | :-- | |
| 121 | +| `insert` (Setup "New" / clone) | `saveMetaItem('permission', name, body)` → projector creates the record | |
| 122 | +| `update` (facet/label/active edits) | merge patch into the layered effective body → `saveMetaItem` → projector updates the record | |
| 123 | +| `delete` of a **runtime-only** set | `deleteMetaItem` (hard delete) → projector retires the record (trash applies) | |
| 124 | +| `delete` of an **artifact-backed** set | `deleteMetaItem` (overlay tombstone = reset, ADR-0005) → projector re-projects the **declared** body; the record resets instead of vanishing | |
| 125 | +| `restore` (un-trash) | record restore proceeds, then the definition is re-authored into metadata from the restored row | |
| 126 | + |
| 127 | +The driver write for insert/update/delete never executes; `opCtx.result` is the |
| 128 | +projected record. Renaming a set through the data door is rejected (the name is the |
| 129 | +metadata identity; clone-then-delete is the supported flow). System-context writes |
| 130 | +(`isSystem`) pass through untouched — they *are* the projector/seeder channel. |
| 131 | + |
| 132 | +Kernels without a metadata protocol capable of `saveMetaItem` /`getMetaItemLayered` |
| 133 | +(minimal embeddings, unit-test stubs) fall back to the direct write: with a single |
| 134 | +store there is no split brain to prevent. |
| 135 | + |
| 136 | +### D4 — Boot reconciliation and the migration/backfill path |
| 137 | + |
| 138 | +At `kernel:ready`, after the ADR-0086 D5 package seeding, `plugin-security` runs a |
| 139 | +convergence pass: |
| 140 | + |
| 141 | +1. **Overlays → records.** Every active env-scope `permission` overlay is projected |
| 142 | + (creating missing records). Metadata wins. |
| 143 | +2. **Backfill (one-time migration).** An env-authored record (`managed_by` ≠ |
| 144 | + `'package'`) whose name has **no metadata presence** (no declaration, no overlay) is |
| 145 | + a legacy data-door creation — its body is written into the metadata store via |
| 146 | + `saveMetaItem`. Enforcement is unchanged by construction: the evaluator's DB |
| 147 | + fallback loader was already resolving exactly this body. After the backfill the |
| 148 | + record is derived like every other. |
| 149 | +3. **Drift healing.** An env-authored record whose name *has* metadata presence but |
| 150 | + whose columns differ from the effective body is re-projected from metadata, with a |
| 151 | + loud warning. Metadata wins deliberately: for such names the evaluator already |
| 152 | + resolved the metadata body, so the record drift was **display-only and never |
| 153 | + enforced** — promoting it into metadata would silently *change* effective |
| 154 | + permissions at upgrade, which is worse than discarding a lie. |
| 155 | + |
| 156 | +The pass is idempotent and re-runs harmlessly on every boot. |
| 157 | + |
| 158 | +### D5 — Env-scope overlays of package-owned sets remain inert (and should be rejected at authoring) |
| 159 | + |
| 160 | +For a name whose record is package-owned, the environment door is refused at |
| 161 | +projection (existing #2867 rule, kept). The metadata type registry currently allows |
| 162 | +authoring such an overlay (`allowOrgOverride: true`), which produces a layered overlay |
| 163 | +that neither projects nor enforces — an ADR-0049 violation surfaced but not fixed here. |
| 164 | +Rejecting it at `saveMetaItem` requires a per-type authoring gate in the protocol; |
| 165 | +tracked as framework#2898 rather than silently expanding this change. |
| 166 | + |
| 167 | +## Consequences |
| 168 | + |
| 169 | +**Positive.** |
| 170 | +- One truth. No write path — present or future — can desync the record from metadata |
| 171 | + through the data plane: the choke point is the engine middleware every ObjectQL write |
| 172 | + traverses, not an opt-in subscriber. |
| 173 | +- Setup edits of declared sets finally **enforce** (they become env overlays), and |
| 174 | + Studio edits/creations appear in Setup **before the save returns** (awaited |
| 175 | + projection — acceptance criterion "no projection race"). |
| 176 | +- Display and enforcement can no longer disagree: both derive from the layered |
| 177 | + effective body (projection + in-memory registry sync). |
| 178 | +- Legacy data is migrated, not stranded (D4 backfill). |
| 179 | + |
| 180 | +**Negative / behavior changes.** |
| 181 | +- "Deleting" an artifact-backed set through Setup now **resets** it to the declared |
| 182 | + body instead of deleting the row (the definition ships with the app and cannot be |
| 183 | + deleted from the env — the honest semantic; previously the delete produced a ghost: |
| 184 | + row gone, enforcement unchanged). |
| 185 | +- Record drift authored through the data door **before** this change and shadowed by |
| 186 | + metadata is discarded at first boot (loud warn). It was never enforced. |
| 187 | +- Renames through the data door are rejected. |
| 188 | +- Engine object hooks / realtime `data.record.*` events no longer fire for redirected |
| 189 | + `sys_permission_set` writes from the data door (the projector's system writes fire |
| 190 | + them instead). |
| 191 | + |
| 192 | +**Neutral / open.** |
| 193 | +- Multi-node: the in-memory registry sync is per-node; cross-node convergence rides on |
| 194 | + the existing metadata watch/boot mechanisms (pre-existing posture, unchanged). |
| 195 | +- Whether the *record* store can eventually be dropped entirely (queries served from |
| 196 | + metadata) stays open; junction FKs and Setup's list/query surface make the projection |
| 197 | + the pragmatic shape today. |
| 198 | + |
| 199 | +## Alternatives considered |
| 200 | + |
| 201 | +- **Keep hardening the #2867 subscriber** (more events, more call sites). Rejected — |
| 202 | + eventually-consistent glue between two writable stores can always be bypassed; the |
| 203 | + issue explicitly asks for a structural fix. |
| 204 | +- **Deny all data-door writes and move Setup to the metadata API.** Rejected for now — |
| 205 | + breaks the Setup surface (sibling repo) and every existing integration; write-through |
| 206 | + preserves the API while changing the store underneath. |
| 207 | +- **Make the record authoritative and project into metadata.** Rejected — the metadata |
| 208 | + layer is the platform-wide authoritative store for every other type (ADR-0005), is |
| 209 | + versioned/auditable, and is what enforcement already prefers. |
| 210 | + |
| 211 | +## References |
| 212 | + |
| 213 | +- framework#2875 (this ADR), #2857 / #2867 (display-freshness gap and projection |
| 214 | + band-aid), ADR-0005, ADR-0086 (D3/D4/D5/P2), ADR-0090 (D12), ADR-0056. |
| 215 | +- Implementation: `packages/plugins/plugin-security/src/permission-set-projection.ts`, |
| 216 | + `packages/metadata-protocol/src/protocol.ts` (`registerMutationProjector`), |
| 217 | + `packages/plugins/plugin-security/src/security-plugin.ts` (wiring). |
0 commit comments