Skip to content

Commit 1dede32

Browse files
os-zhuangclaude
andauthored
refactor(plugin-security): make sys_permission_set a pure projection of the metadata layer (ADR-0094) (#2901)
* refactor(plugin-security): make sys_permission_set a pure projection of the metadata layer (ADR-0094) Retire the two-store split-brain behind the #2857 display-freshness class (#2875): the metadata layer (packaged declarations + sys_metadata overlay, merged overlay-wins) is now the ONE authoritative store for permission-set definitions, and the queryable sys_permission_set record is a derived read-model — enforced structurally, not by a subscriber a new write path might forget. - metadata-protocol: new registerMutationProjector(type, fn) — an awaited, best-effort per-type hook run after persistence inside saveMetaItem / publishMetaItem / deleteMetaItem, surfaced as `projectionApplied` on the response. A derived read-model is consistent before the write returns (the #2867 onMetadataMutation subscriber was fire-and-forget). - plugin-security: new permission-set-projection module: * data-door write-through — every non-system CRUD write on sys_permission_set is redirected into the metadata store at the engine middleware choke point (after the two-doors + delegated-admin + CRUD/FLS gates); the record is written only by the projector; * the projector re-reads the layered effective body, upserts/creates the env record (Studio-authored sets finally appear in Setup), retires it when the definition is gone, resets it to the declared body when an overlay tombstone reveals the artifact baseline, and syncs the metadata manager's in-memory `permission` entry so evaluator resolution (registry-first list) can no longer disagree with the display; * boot reconciliation — env overlays project onto records, legacy data-door-only records are backfilled into metadata once, and records that drifted from an existing definition are re-projected (metadata wins; the drift was never enforced); * declared (artifact) baselines are read from the engine SchemaRegistry, skipping runtime shadows and projection echoes, so a deleted runtime-only definition cannot zombie back as "declared". Behavior changes: deleting an artifact-backed set through the data door now RESETS it to its declared body instead of removing the row; renames through the data door are rejected (400); pre-existing record drift shadowed by a metadata definition is discarded loudly at first boot. Follow-up: rejecting env-scope overlays of package-owned sets at authoring time is tracked in #2898. Closes #2875 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q * docs(permissions): document the ADR-0094 pure-projection env door in authorization.mdx Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 04ce2db commit 1dede32

12 files changed

Lines changed: 1770 additions & 298 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
"@objectstack/plugin-security": minor
4+
---
5+
6+
Make the `sys_permission_set` data record a pure projection of the metadata layer (ADR-0094; framework#2875) — one authoritative store for permission-set definitions, retiring the two-store split-brain behind the #2857 display-freshness class.
7+
8+
- **`@objectstack/metadata-protocol`**: new `registerMutationProjector(type, fn)` — an awaited, best-effort per-type hook invoked after persistence inside `saveMetaItem` / `publishMetaItem` / `deleteMetaItem`, so a derived data-plane read-model is already consistent when the write returns (outcome surfaced as `projectionApplied` on the response). Complements the fire-and-forget `onMetadataMutation` listeners.
9+
- **`@objectstack/plugin-security`**: every non-system data-door write on `sys_permission_set` (Setup CRUD, bulk imports, any ObjectQL path) is redirected into the metadata store by an engine middleware; the record is written only by the projector. Boot reconciliation projects env overlays onto records (Studio-created sets now appear in Setup), backfills legacy data-door-only records into metadata once, and re-projects drifted records from the effective body (metadata wins). The projector also syncs the metadata manager's in-memory `permission` entry, so evaluator resolution and the Setup display can no longer disagree.
10+
11+
Behavior changes: "deleting" an artifact-backed permission set through the data door now resets it to its declared body instead of removing the row; renaming a set through the data door is rejected (`400`) — clone to a new name instead; record edits that predate this change and are shadowed by a metadata definition are discarded (loud warning) at first boot, since they were never enforced.
12+
13+
Moved exports (from `@objectstack/plugin-security`): `upsertEnvPermissionSet` now lives in `permission-set-projection.js` (still re-exported from the package root) and **creates** missing records; `projectEnvPermissionOnMutation` / `subscribeEnvPermissionProjection` are replaced by `projectPermissionMutation` / `registerPermissionSetProjection`.

content/docs/permissions/authorization.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,15 @@ one of two doors, each writing only what it owns:
133133
matrix plus subject **assignment** (`sys_user_permission_set`,
134134
`sys_position_permission_set`), edited **live** (config). It owns env-authored
135135
sets (`managedBy` `platform`/`user`) and assignments — not package sets.
136+
Since ADR-0094 the set **definition** itself has one authoritative store —
137+
the metadata layer: an env-door write to `sys_permission_set` (the Setup
138+
CRUD) is transparently redirected into an env-scope metadata save
139+
(`saveMetaItem`), and the data record is a **pure projection** the platform
140+
re-derives on every metadata mutation (awaited — no staleness window) and at
141+
boot. Deleting an artifact-backed set through this door **resets** it to its
142+
declared body rather than removing it; renaming through the data door is
143+
rejected (the name is the metadata identity — clone instead). Subject
144+
assignments remain plain config rows, unchanged.
136145
- **Data-layer write gate** — the security middleware **refuses** any admin-door
137146
write to a `managedBy:'package'` `sys_permission_set` row, and refuses a
138147
payload that forges that provenance (insert or update, single or array). It
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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).

packages/metadata-protocol/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeViewMetadata } from './protocol.js';
44
export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js';
5+
export type { MetadataMutationEvent, MetadataMutationProjector, MutationProjectionOutcome } from './protocol.js';
56

67
export { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js';
78
export type {

packages/metadata-protocol/src/mutation-listeners.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,55 @@ describe('ObjectStackProtocolImplementation.onMetadataMutation', () => {
6464
expect(after).toHaveBeenCalledTimes(1);
6565
});
6666
});
67+
68+
// ADR-0094 — the AWAITED per-type projector seam. Unlike the listeners above
69+
// (fire-and-forget), a registered projector runs inside the metadata write and
70+
// its outcome is surfaced as `projectionApplied`. These tests pin the seam's
71+
// contract (dispatch, plural normalization, replace-on-reregister, failure
72+
// isolation); the save/publish/delete invocation points are exercised by the
73+
// plugin-security projection suite against a mock protocol.
74+
describe('ObjectStackProtocolImplementation.registerMutationProjector (ADR-0094)', () => {
75+
it('runs the registered projector for its type and reports success', async () => {
76+
const p = makeProtocol();
77+
const seen: any[] = [];
78+
p.registerMutationProjector('permission', async (e) => { seen.push(e); });
79+
80+
const out = await (p as any).runMutationProjector(evt({ type: 'permission', body: { name: 'x' } }));
81+
expect(out).toEqual({ success: true });
82+
expect(seen).toHaveLength(1);
83+
expect(seen[0].type).toBe('permission');
84+
expect(seen[0].body).toEqual({ name: 'x' });
85+
});
86+
87+
it('returns undefined when no projector is registered for the type', async () => {
88+
const p = makeProtocol();
89+
p.registerMutationProjector('permission', async () => {});
90+
expect(await (p as any).runMutationProjector(evt({ type: 'view' }))).toBeUndefined();
91+
});
92+
93+
it('normalizes plural type names on registration', async () => {
94+
const p = makeProtocol();
95+
const projector = vi.fn(async () => {});
96+
p.registerMutationProjector('permissions', projector);
97+
await (p as any).runMutationProjector(evt({ type: 'permission' }));
98+
expect(projector).toHaveBeenCalledTimes(1);
99+
});
100+
101+
it('a second registration replaces the first (idempotent re-init)', async () => {
102+
const p = makeProtocol();
103+
const first = vi.fn(async () => {});
104+
const second = vi.fn(async () => {});
105+
p.registerMutationProjector('permission', first);
106+
p.registerMutationProjector('permission', second);
107+
await (p as any).runMutationProjector(evt({ type: 'permission' }));
108+
expect(first).not.toHaveBeenCalled();
109+
expect(second).toHaveBeenCalledTimes(1);
110+
});
111+
112+
it('a throwing projector is surfaced as { success:false, error }, never thrown', async () => {
113+
const p = makeProtocol();
114+
p.registerMutationProjector('permission', async () => { throw new Error('projection boom'); });
115+
const out = await (p as any).runMutationProjector(evt({ type: 'permission' }));
116+
expect(out).toEqual({ success: false, error: 'projection boom' });
117+
});
118+
});

0 commit comments

Comments
 (0)