Skip to content

Commit 712328a

Browse files
os-zhuangclaude
andauthored
feat(plugin-security): ADR-0094 follow-ups — package sets customize via env overlay (D5 revised) + projection dogfood proof + Setup UI alignment (#2905)
* feat(plugin-security): authoring-time gate for package-owned permission sets + projection dogfood proof (ADR-0094 D5) Closes the last inert-metadata hole from the ADR-0094 pure-projection refactor (#2898), and binds the invariants into the liveness proof registry. - metadata-protocol: new registerAuthoringGate(type, fn) seam — a per-type gate run inside saveMetaItem before persistence; a returned rejection becomes a thrown Error (code/status). Fail-open on a gate error (this closes an inert-metadata hole, not a hard boundary — the data-plane two-doors gate + the projector's refusal already protect the record). - plugin-security: registers a `permission` gate that refuses an env-scope saveMetaItem targeting a managed_by:package sys_permission_set record — previously such an overlay persisted but neither projected nor enforced (ADR-0049 violation). The package door (save carries the owning packageId) and env-authored/new sets are unaffected. Error `package_owned` (403). - dogfood: showcase-permission-projection proves the ADR-0094 invariants on the real stack (write-through, awaited projection, declared-set edit → enforced overlay, delete-as-reset, and this authoring gate), registered in the ADR-0054 proof registry (unbound — a storage invariant has no authorable ledger property to ratchet, kept honest with a blockedReason). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q * feat(plugin-security): lock permission-set API name after creation + document projection UX (ADR-0094) Setup UI alignment for the ADR-0094 pure-projection behavior. The Setup surface is SDUI-driven by the object metadata, so both affordances are object-metadata changes objectui already honors (ObjectForm forwards `readonlyWhen` to fieldRules; the engine also strips such writes server-side) — no objectui code change needed: - sys_permission_set.name gains `readonlyWhen: record.id != null` — the name is the metadata identity the record projects from, so the create form accepts it but the edit form locks it, matching the data-door's 400 rename rejection. Verified the meta API surfaces the CEL predicate and the boot stack loads it cleanly. - permission-sets.mdx documents the one-authoritative-store model: Setup edits of declared sets now enforce, the API name is immutable (clone to rename), and deleting a packaged set resets it to the shipped definition while a self-created set is removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q * docs(adr-0094): addendum generalizing the one-authoritative-store rule to sibling two-store types Promote the permission-set-specific decision to a classification rule for every declared-metadata ↔ queryable-record two-store type (sys_position, sys_sharing_rule, sys_capability), keyed on which store enforcement reads at request time: - metadata-authoritative → record is a projection (this ADR's machinery, reusable via registerMutationProjector / registerAuthoringGate); - record-authoritative → the record is the authority and declared metadata is a boot SEED only (seed-not-clobber; do NOT project — that would invert the real authority). Records the per-type findings (sys_sharing_rule is record-authoritative — evaluation reads the row live; sys_position needs the seed-vs-authority audit), so the follow-up is scoped, not rediscovered. Tracked in framework#2909. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q * feat(plugin-security)!: package-owned permission sets customize via the standard env metadata overlay (ADR-0094 D5 revised) Direction confirmed by the maintainers 2026-07-14, reversing the #2898 authoring-time rejection: an env-scope overlay of a package-owned set is the platform's standard ADR-0005 customization, made FIRST-CLASS instead of refused — rejecting it would make `permission` the one type whose declared allowOrgOverride:true is a lie, and clone-to-customize forks away from vendor baseline updates (including security tightenings). - projector: a package-owned record's facets follow the EFFECTIVE (overlay-wins) body; managed_by:'package' + package_id provenance preserved. - write-through: a data-door edit of a packaged set translates into that overlay (no more flat 403); data-door "delete" removes the overlay and RESETS the record to the shipped declaration. Single-store kernels (no metadata overlay layer) keep the legacy two-doors refusal, now asserted by the write-through itself. - two-doors gate narrows to what stays structurally true: provenance forging refused (insert/update, single/array), and transfer/restore/purge on package rows refused (no overlay translation exists for them). - metadata-protocol: the registerAuthoringGate seam is removed again — with the rejection gone it had no consumer, and an unconsumed seam is exactly the inert surface ADR-0049 forbids. - docs: ADR-0094 TL;DR/D2/D5 revised (including the whole-document-overlay upgrade-pinning trade and its mitigations); authorization.mdx + permission-sets.mdx updated; dogfood proofs flipped to the new semantics (overlay customize/reset on showcase_contributor, live). Closes #2898 (resolved by support, not rejection). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXjD7g2JkEsdqhZFZgAo1Q * docs(permissions): fix role-word guard violation in permission-sets.mdx (ADR-0090 D3) 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 eaff014 commit 712328a

13 files changed

Lines changed: 649 additions & 112 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
Package-owned permission sets are now customizable through the standard environment metadata overlay (ADR-0094 D5, revised — closes framework#2898 by making the overlay FIRST-CLASS instead of rejecting it).
6+
7+
- An env-scope `saveMetaItem('permission', …)` on a package-owned set is a real customization: the awaited projector applies the effective (overlay-wins) body to the `sys_permission_set` record while preserving its `managed_by:'package'` + `package_id` provenance, and the evaluator enforces it.
8+
- A data-door edit of a packaged set (Setup PATCH) is translated into exactly that overlay — no more flat 403; a data-door "delete" removes the overlay and RESETS the record to the shipped declaration (the row survives).
9+
- The ADR-0086 two-doors data gate narrows to what stays structurally true: forging package provenance through the admin door remains refused, as do the lifecycle ops with no overlay translation (`transfer`/`restore`/`purge`) on package rows; kernels without a metadata overlay layer keep the legacy full refusal.
10+
- Cross-package roles compose via positions (bind several packages' sets); overlays narrow. Rationale: rejecting the overlay would make `permission` the one type whose declared `allowOrgOverride: true` is a lie, and clone-to-customize forks away from vendor baseline updates.
11+
12+
Note the standard overlay trade, now applicable to permission sets: while an overlay pins a set, later vendor baseline changes (including tightenings) don't take effect for that name until the overlay is reset or re-authored — surfaced by the Studio layered diff and covered by ADR-0091 recertification.
13+
14+
Also lands a dogfood proof (`showcase-permission-projection`) covering the full ADR-0094 invariant set — write-through, awaited projection, declared-set edit becomes an enforced overlay, package-set customize/reset lifecycle — registered in the liveness proof registry.

content/docs/permissions/authorization.mdx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -142,15 +142,17 @@ one of two doors, each writing only what it owns:
142142
declared body rather than removing it; renaming through the data door is
143143
rejected (the name is the metadata identity — clone instead). Subject
144144
assignments remain plain config rows, unchanged.
145-
- **Data-layer write gate** — the security middleware **refuses** any admin-door
146-
write to a `managedBy:'package'` `sys_permission_set` row, and refuses a
147-
payload that forges that provenance (insert or update, single or array). It
148-
fails closed ahead of the CRUD check — even a `modifyAllRecords` super-user is
149-
blocked — so the door separation is a real boundary, not a UI hint. System /
150-
boot writes carry `isSystem` and bypass it, so the seeder and materializer are
151-
never self-blocked. An environment adjusts a packaged set through the
152-
ADR-0005 overlay / muting subtract layer (below), never by editing the base
153-
record.
145+
- **Data-layer gate (evolved by ADR-0094)** — the security middleware still
146+
**refuses** any payload that forges package provenance (insert or update,
147+
single or array) and the lifecycle ops with no overlay translation
148+
(`transfer`/`restore`/`purge`) on package rows, failing closed ahead of the
149+
CRUD check — even a `modifyAllRecords` super-user is blocked. Ordinary
150+
admin-door **edits of a packaged set are no longer refused**: the ADR-0094
151+
write-through translates them into the standard ADR-0005 env-scope
152+
**overlay** — the record projects the effective body while the package keeps
153+
owning the row, and "delete" resets to the shipped declaration. System /
154+
boot writes carry `isSystem` and bypass it, so the seeder and materializer
155+
are never self-blocked.
154156

155157
## Lifecycle coverage (five stages)
156158

content/docs/permissions/permission-sets.mdx

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -192,13 +192,47 @@ tenant-level.
192192

193193
A package ships its own sets (`managedBy: 'package'` + `packageId`), seeded
194194
idempotently at boot and re-seeded on upgrade; environment-authored sets
195-
(`platform`/`user`) are never clobbered. The data layer refuses admin-door
196-
writes to package rows (two-doors separation), which is what makes package
197-
uninstall well-defined — and enforced: uninstalling a package
198-
(`DELETE /api/v1/packages/:id`) revokes its own sets, their position/user
199-
bindings, and its pending audience-binding suggestions in the same request
200-
(no ghost grants); the uninstall response reports the revocation under
201-
`cleanups`. Environment-authored sets and other packages' rows survive.
195+
(`platform`/`user`) are never clobbered. The data layer refuses forging package
196+
provenance through the admin door (two-doors separation, evolved by ADR-0094 —
197+
ordinary edits of a packaged set become environment overlays, see below), which
198+
is what makes package uninstall well-defined — and enforced: uninstalling a
199+
package (`DELETE /api/v1/packages/:id`) revokes its own sets, their
200+
position/user bindings, and its pending audience-binding suggestions in the
201+
same request (no ghost grants); the uninstall response reports the revocation
202+
under `cleanups`. Environment-authored sets and other packages' rows survive.
203+
204+
## One authoritative store — the record is a projection (ADR-0094)
205+
206+
A permission-set **definition** has a single authoritative store: the metadata
207+
layer (packaged declarations plus the `sys_metadata` overlay). The queryable
208+
`sys_permission_set` record — what Setup lists and user assignment reads — is a
209+
**pure projection** of that definition, never independently authoritative.
210+
Every non-system write on the record (Setup CRUD, a bulk import, any API that
211+
goes through the data engine) is redirected into a metadata write and the
212+
record is re-derived by an awaited projector, so the two can never drift. This
213+
has three author-visible consequences:
214+
215+
- **Editing any declared set through Setup — packaged sets included** becomes
216+
an environment overlay of that definition (the standard metadata
217+
customization): it genuinely takes effect, where a record-only edit
218+
previously displayed but never enforced. The row keeps its package
219+
provenance; the Studio layered view diffs the shipped baseline against your
220+
customization, and removing the overlay resets to the baseline. Note the
221+
trade every overlay makes: while an overlay pins a set, the vendor's later
222+
baseline changes don't take effect for it until you reset or re-author.
223+
- **The API name is immutable after creation.** It is the definition's metadata
224+
identity, so the create form accepts it but the edit form locks it, and a
225+
rename through the data door is rejected. Clone the set to a new name instead.
226+
- **Deleting through the data door depends on where the definition lives.** A
227+
set you created in this environment is removed. A set that ships with an
228+
installed package/app cannot be removed from the environment — "delete"
229+
**resets** it to the shipped definition (the customization overlay is
230+
dropped), and the row remains. Uninstall the owning package to remove a
231+
packaged set entirely.
232+
233+
A cross-package job function needs no hand-authored cross-package set: bind
234+
each package's own sets to one **position** (the union model adds), and use an
235+
overlay where a packaged set must be narrowed.
202236

203237
---
204238

docs/adr/0094-sys-permission-set-pure-projection.md

Lines changed: 117 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,12 @@ not by a subscriber a new write path might forget to trigger:
3232
re-derived from metadata (metadata wins), and legacy records that exist **only** in
3333
the data plane are migrated into the metadata store once.
3434

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.
35+
Package-owned records (`managed_by:'package'`) keep the shipped declaration as their
36+
BASELINE (boot seeding / publish materialization), and — per the revised D5 — the
37+
environment customizes them through the standard ADR-0005 overlay: the record projects
38+
the effective (overlay-wins) body with its package provenance preserved, and removing
39+
the overlay resets it to the declaration. Forging package provenance through the data
40+
door stays impossible.
3841

3942
---
4043

@@ -94,10 +97,10 @@ thrown, the metadata write itself succeeded and boot reconciliation heals on nex
9497
`plugin-security` registers the `permission` projector. It re-reads the **fresh layered
9598
effective body** and:
9699

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);
100+
- upserts the record (creates it if missing, `managed_by:'user'` — Studio-created
101+
sets now appear in Setup); a PACKAGE-OWNED record's facets follow the effective
102+
body too, with its `managed_by:'package'` + `package_id` provenance preserved
103+
(see D5 — an env overlay is the standard customization of a packaged set);
101104
- syncs the **metadata manager's in-memory `permission` entry**
102105
(`registerInMemory`) so the evaluator's registry-first `list('permission')`
103106
resolution sees the same effective body it projects — closing the
@@ -155,14 +158,45 @@ convergence pass:
155158

156159
The pass is idempotent and re-runs harmlessly on every boot.
157160

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.
161+
### D5 — Env-scope overlays of package-owned sets are FIRST-CLASS customizations (revised 2026-07-14)
162+
163+
**History.** As first landed, the projector refused env-scope bodies for
164+
package-owned records (the #2867 rule), which left an authored overlay of a
165+
packaged set inert — neither projecting nor enforcing. The initial follow-up
166+
(#2898) proposed rejecting such overlays at authoring time. The maintainers
167+
reversed that direction on 2026-07-14: rejection would have made permission
168+
sets the one metadata type whose declared `allowOrgOverride: true` is a lie,
169+
and "clone to customize" **forks** — a clone stops receiving the vendor's
170+
subsequent baseline changes (including security tightenings) and loses the
171+
layered code-vs-overlay diff.
172+
173+
**Decision.** An environment-scope overlay of a package-owned permission set
174+
is the platform's **standard ADR-0005 customization**, fully supported:
175+
176+
- the projector projects the EFFECTIVE (overlay-wins) body onto the record
177+
while **preserving** `managed_by:'package'` + `package_id` — the package
178+
still owns the row; the overlay customizes it;
179+
- a data-door edit of a package row is translated by the write-through into
180+
exactly this overlay (no more flat 403); a data-door "delete" removes the
181+
overlay — an ADR-0005 **reset** to the shipped declaration;
182+
- the ADR-0086 two-doors gate narrows to what is still structurally true:
183+
the admin door can never **forge** package provenance, and lifecycle ops
184+
with no overlay translation (`transfer`/`restore`/`purge`) stay refused on
185+
package rows. In a kernel without a metadata overlay layer the legacy full
186+
refusal applies (there is nothing to carry a customization);
187+
- cross-package composition remains a POSITION concern (bind several
188+
packages' sets to one position — the union model adds; an overlay narrows),
189+
and package-first authoring (ADR-0070) gives runtime-created sets a home
190+
package, so the loose `managed_by:'user'` category can retire over time.
191+
192+
**The risk this accepts, deliberately.** ADR-0005 overlays are whole-document:
193+
an env overlay can widen a vendor baseline, and a vendor's later baseline
194+
*tightening* does not reach a name that is pinned by an overlay until the
195+
overlay is reset or re-authored. Mitigations: the Studio layered view diffs
196+
code-vs-overlay; upgrade flows should surface "customized packaged sets" for
197+
review; ADR-0091 recertification covers overlays like any other grant source.
198+
This is the same trade every overlayable type makes — permission sets no
199+
longer get a bespoke, stricter rule that the rest of the platform contradicts.
166200

167201
## Consequences
168202

@@ -215,3 +249,71 @@ tracked as framework#2898 rather than silently expanding this change.
215249
- Implementation: `packages/plugins/plugin-security/src/permission-set-projection.ts`,
216250
`packages/metadata-protocol/src/protocol.ts` (`registerMutationProjector`),
217251
`packages/plugins/plugin-security/src/security-plugin.ts` (wiring).
252+
253+
---
254+
255+
## Addendum (2026-07-14): generalizing to the sibling declared-metadata ↔ queryable-record types
256+
257+
`sys_permission_set` is not the only object with **two stores** — a declared
258+
definition in the metadata layer AND a queryable `sys_*` record, historically
259+
synced only at boot and on publish. An audit found three siblings seeded the
260+
same way: `sys_position` (`bootstrapDeclaredPositions`), `sys_sharing_rule`
261+
(`bootstrapDeclaredSharingRules`), and `sys_capability`
262+
(`bootstrapSystemCapabilities`). This addendum promotes the decision from a
263+
permission-set-specific fix to a **classification rule** for all of them, so a
264+
future maintainer neither leaves a split-brain unaddressed nor naively applies
265+
the wrong cure.
266+
267+
### The general invariant
268+
269+
A declared definition and its queryable record must not be **two independently
270+
writable authorities** reconciled only at boot/publish. Exactly one is
271+
authoritative; the other is derived and documented as such — enforced
272+
structurally (a choke point every write traverses), not by a subscriber a new
273+
path can bypass.
274+
275+
### The classification criterion — *which store does enforcement read at request time?*
276+
277+
The cure follows the authority, and the authority is decided by one question:
278+
**at request time, does the runtime read the metadata definition or the data
279+
record?**
280+
281+
- **Metadata-authoritative** (enforcement resolves the definition from the
282+
metadata layer) → the record is a **pure projection** (this ADR's machinery:
283+
data-door write-through + awaited `registerMutationProjector` +
284+
`registerAuthoringGate` + boot reconciliation). The record must never be an
285+
independent authority, because it isn't the one enforcement trusts.
286+
- **Record-authoritative** (enforcement reads the `sys_*` record live) → the
287+
record is the authority and the declared metadata is a **boot SEED only**,
288+
not a competing overlay. The cure is the mirror image: the seeder must not
289+
clobber an environment-edited record, and no path may treat the declared
290+
body as a live override that silently loses to (or fights) the record. **Do
291+
not** apply the projection machinery here — projecting the record *from*
292+
metadata would invert the real authority.
293+
294+
The generic protocol seams added by this ADR (`registerMutationProjector`,
295+
`registerAuthoringGate`) serve the *metadata-authoritative* case and are
296+
reusable by any such type; the record-authoritative case needs no new seam,
297+
only a seed-not-clobber discipline.
298+
299+
### Per-type decisions
300+
301+
| Type | Enforcement reads | Class | Decision |
302+
| :-- | :-- | :-- | :-- |
303+
| `sys_permission_set` | metadata (`PermissionEvaluator.resolvePermissionSets``metadata.list('permission')`, DB row only as fallback) | metadata-authoritative | **Record is a projection — done** (this ADR). |
304+
| `sys_sharing_rule` | the record, live (`sharing-plugin.ts` "rule evaluation reads `sys_sharing_rule` live"; `sharing-rule-service` `engine.find`) | **record-authoritative** | Declared rules are a **boot seed**; the record is the authority. Do **not** project. Audit that `bootstrapDeclaredSharingRules` preserves env-edited rows (seed-not-clobber) and that the metadata overlay is not read as a live override. |
305+
| `sys_position` | mixed — position→permission-set resolution is metadata-first for the *sets*, but the `sys_position` **record** (incl. its `permissions` field, bindings, `delegatable`, `admin` gating) is read live by the anchor gate and `DelegatedAdminGate` | **needs the seed-vs-authority audit** against the criterion above; likely record-authoritative for bindings with metadata seeding identity | Classify precisely, then either project (if the position *definition* is enforced from metadata) or make declared positions seed-only. |
306+
| `sys_capability` | the record (curated registry read for capability existence) | record-authoritative (registry) | Seed-only; low authoring surface. Audit seed-not-clobber. |
307+
308+
Only `sys_permission_set` was both metadata-authoritative **and** carried a
309+
harmful, actively-drifting split-brain, which is why it was fixed first and in
310+
full. The others are recorded here with their class so the follow-up work is
311+
scoped, not rediscovered — tracked in framework#2909.
312+
313+
### Why an addendum, not a new ADR
314+
315+
The decision — *one authoritative store; the other derived; enforced
316+
structurally* — is identical; only the per-type **direction** differs. A
317+
separate ADR would duplicate the rationale and split the classification from
318+
the decision that motivates it. This addendum keeps the rule and its
319+
applications in one place.

0 commit comments

Comments
 (0)