From 740de97bdfdc8a8b8e466c95a73598e0981b1c05 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 06:24:47 +0000 Subject: [PATCH] docs(adr-0094): retire D5's env-overlay customization direction (D5-R, #6858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6609 maintainer ruling A follow-through, written against the post-merge world PR #6608 created (merged 2026-08-09). Premise re-verified on origin/main @ 68feaadd6 before writing: the four production write points are still at :713/:752/:794/:928 verbatim, the 2026-07-14 direction confirmation is still in the projection file's header, and PR #6608 touched neither the ADR nor that file — the card's work was all still outstanding. Direction chosen: RETIRE, with the surviving layer named narrowly. The ruling permitted re-routing the intent to the allowRuntimeCreate materialized layer instead, and that reading was rejected from code: D5's own argument against "clone to customize" was that a clone FORKS, and for a code-declared set the allowRuntimeCreate tier has exactly that property — it can only author a separate definition, never customize the declared one. Calling it a re-route would re-introduce the fork D5 rejected under a new name. D5-R therefore retires the direction for artifact-backed sets and records the surviving tier as a neighbour, not a successor. Disposition of the four write points: LEFT TO 403 LOUDLY at the producer. plugin-security does not re-derive artifact-backing to pre-empt the refusal — isArtifactBacked is the protocol's rule (it excludes the 'sys_metadata' rehydration sentinel) and a second copy in a consumer is the parallel-allowlist failure PD #8 exists to prevent. Measured: only :794 (update) is closed by the rollback; :752 needs an unmaterialized declaration to reach the gate, and :713/:928 cannot target an artifact-backed name at all. Tests: the suite's protocol stub now models ADR-0005's tier gate. PR #6608's own body recorded that this suite stubs saveMetaItem and therefore could not see the gate; four cases here were pinning the RETIRED direction and staying green for exactly that reason. Triaged individually — two replaced with the 403 envelope (code AND status, not a bare throw), two re-shaped to seed the legacy overlay directly since the write that used to mint it is refused — and five cases added covering each write point's disposition plus the surviving tier as the control. Reverse verification, direction predicted before running: - remove the stub's tier gate => predicted 4 red / 45 green, measured 4 red / 45 green, the four named ones. The :928 backfill pin stays GREEN under the same mutation, as predicted and reported rather than tidied: it claims the branch is never entered, which is true independently of the gate. - strip ADR-0005 from the projection file => predicted check:adr-anchors red, measured exit 1 naming the file; restored exit 0. Gates: check:adr-anchors OK (42 anchored files, +1 new entry for permission-set-projection.ts), check:adr-links OK, check:nul-bytes OK, check:doc-authoring OK, check:docs-audit-scope OK, check:role-word OK, check:error-code-casing OK, check:durability-log-level OK, check:spec-parsed-alias OK, check:meta-type-normalized OK. plugin-security: 866/866 tests pass, tsc --noEmit clean, eslint clean. Deliberately NOT done: no behaviour change to the rollback (option C stays closed), no admission pair, no promote, no edit under content/docs/releases/, and the single-store branch's thrown message is left verbatim (#5240 — one condition, one wording); only its comment is corrected. Out of scope, filed as #6960: a pre-rollback overlay row on an artifact-backed item can no longer be REMOVED through the ordinary delete path (403 on every kernel), only via OS_METADATA_WRITABLE. Dormant (zero such rows in-repo). Fixes #6858 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ --- .../adr0094-d5r-overlay-direction-retired.md | 25 ++ ...0094-sys-permission-set-pure-projection.md | 136 +++++++++-- .../src/permission-set-projection.test.ts | 230 ++++++++++++++++-- .../src/permission-set-projection.ts | 54 +++- scripts/adr-anchors.json | 9 + 5 files changed, 414 insertions(+), 40 deletions(-) create mode 100644 .changeset/adr0094-d5r-overlay-direction-retired.md diff --git a/.changeset/adr0094-d5r-overlay-direction-retired.md b/.changeset/adr0094-d5r-overlay-direction-retired.md new file mode 100644 index 0000000000..350d4722bc --- /dev/null +++ b/.changeset/adr0094-d5r-overlay-direction-retired.md @@ -0,0 +1,25 @@ +--- +"@objectstack/plugin-security": patch +--- + +ADR-0094 D5-R: retire the "customize packaged permission sets through an ADR-0005 env +overlay" direction (2026-07-14), and make the ADR text and the +`permission-set-projection.ts` header agree with what is enforced. + +`#6483` (PR #6608) rolled `permission` back to `allowOrgOverride: false`, so a metadata +write against a **code-declared (artifact-backed)** permission set is refused with 403 +`NOT_OVERRIDABLE` — ADR-0005's security row ("overlays would create silent privilege +drift") is enforced again. The supported channel for those sets is the one ADR-0086 +always named: edit the package and re-publish. Environment authoring survives on the +`allowRuntimeCreate` tier, for sets whose definition lives only in `sys_metadata` +(data-door creations, and package sets authored + published through the metadata door); +that tier edits the single stored definition in place and is deliberately **not** +described as a re-route of the retired overlay channel. + +No behaviour change: the four production write points keep their current dispositions. +The refusal is left to the producer — `plugin-security` does not re-derive +artifact-backing to pre-empt it — and the two write points that catch a failed metadata +write (the `restore` leg and the boot backfill) keep reporting on the durability channel. +What changes is prose, plus test coverage that can now see the gate: the suite's protocol +stub models ADR-0005's tier gate, so the four cases that pinned the retired direction no +longer pass for want of a stub that could refuse. diff --git a/docs/adr/0094-sys-permission-set-pure-projection.md b/docs/adr/0094-sys-permission-set-pure-projection.md index 16622c2aa9..12425cbefa 100644 --- a/docs/adr/0094-sys-permission-set-pure-projection.md +++ b/docs/adr/0094-sys-permission-set-pure-projection.md @@ -1,9 +1,12 @@ # ADR-0094: Permission-Set Definitions Have One Authoritative Store — `sys_permission_set` Becomes a Pure Projection -**Status**: Accepted (2026-07-14) +**Status**: Accepted (2026-07-14); **D5 retired 2026-08-09** (see D5-R) **Deciders**: ObjectStack Protocol Architects **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) **Closes**: framework#2875 (root cause behind the #2857 display-freshness class) +**Revised**: 2026-08-09 — D5's env-overlay customization direction is retired for +artifact-backed permission sets (#6609 maintainer ruling A, executed by #6858, on the +ADR-0005 rollback landed by #6483 / PR #6608) **Consumers**: `@objectstack/plugin-security`, `@objectstack/metadata-protocol`, Setup/Studio surfaces --- @@ -33,11 +36,14 @@ not by a subscriber a new write path might forget to trigger: the data plane are migrated into the metadata store once. Package-owned records (`managed_by:'package'`) keep the shipped declaration as their -BASELINE (boot seeding / publish materialization), and — per the revised D5 — the -environment customizes them through the standard ADR-0005 overlay: the record projects -the effective (overlay-wins) body with its package provenance preserved, and removing -the overlay resets it to the declaration. Forging package provenance through the data -door stays impossible. +BASELINE (boot seeding / publish materialization). D5's 2026-07-14 direction — that the +environment customizes them through the standard ADR-0005 overlay — was **retired on +2026-08-09** (D5-R): `permission` is back to `allowOrgOverride: false`, so an overlay of +a CODE-DECLARED (artifact-backed) set is refused with 403 `NOT_OVERRIDABLE` at the moment +of the write. Environment authoring survives on a different tier — `allowRuntimeCreate`, +for sets whose definition lives only in `sys_metadata` — and that tier is a direct edit +of the one stored definition, **not** an overlay. Forging package provenance through the +data door stays impossible. --- @@ -124,9 +130,37 @@ CRUD/FLS checks — applies first) translates every **non-system** write: | `insert` (Setup "New" / clone) | `saveMetaItem('permission', name, body)` → projector creates the record | | `update` (facet/label/active edits) | merge patch into the layered effective body → `saveMetaItem` → projector updates the record | | `delete` of a **runtime-only** set | `deleteMetaItem` (hard delete) → projector retires the record (trash applies) | -| `delete` of an **artifact-backed** set | `deleteMetaItem` (overlay tombstone = reset, ADR-0005) → projector re-projects the **declared** body; the record resets instead of vanishing | +| `delete` of an **artifact-backed** set | `deleteMetaItem` (overlay tombstone = reset, ADR-0005) → projector re-projects the **declared** body; the record resets instead of vanishing. **Since D5-R this row describes a path that is normally empty**: with no overlay left to lift the delete is a no-op success, and on an environment-scoped kernel the `override-artifact` intent refuses it — see the tier gate below | | `restore` (un-trash) | record restore proceeds, then the definition is re-authored into metadata from the restored row | +**The tier gate (2026-08-09).** The table above is the *translation* map, and it is +unchanged: the middleware always redirects. Whether the redirected metadata write is +then ACCEPTED is ADR-0005's call, not this ADR's, and since the #6483 rollback the +answer depends on the target's provenance: + +| Target's definition | `saveMetaItem` / `deleteMetaItem` | Why | +| :-- | :-- | :-- | +| **artifact-backed** — ships in a code package (`*.permission.ts`, a stack's `permissionSets`) | **403 `NOT_OVERRIDABLE`** | `permission` is `allowOrgOverride: false`; the write is an `override-artifact` intent | +| **`sys_metadata`-only** — runtime-created through the data door, or a package set authored and published through the METADATA door (ADR-0070 package-first authoring) | accepted | `runtime-only` intent, and `permission` keeps `allowRuntimeCreate: true` | + +The gate reads **artifact provenance**, never the record's `managed_by` column, and the +two disagree in production. Measured on the real showcase stack +(`packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts`): `member_default`'s row +is `managed_by:'admin'` while its definition ships as a platform artifact, and its +data-door edit is **refused** — the record's provenance column was never what the gate +read. The mirror case is `twodoors_pkgset`, whose row is `managed_by:'package'` while its +definition lives in `sys_metadata` (that materialization is pinned in the same file); +that an edit of such a row still **lands** is pinned as a unit case in +`packages/plugins/plugin-security/src/permission-set-projection.test.ts`, whose protocol +stub models this gate. + +The refusal is left to the producer deliberately: `plugin-security` does not re-derive +artifact-backing to pre-empt it. That rule belongs to the metadata protocol +(`isArtifactBacked`, which excludes the `'sys_metadata'` rehydration sentinel), and a +second copy of it in a consumer is the parallel-allowlist failure Prime Directive #8 +exists to prevent — the `managed_by`-keyed heuristic the single-store branch uses is +already provably not the same fact. + The driver write for insert/update/delete never executes; `opCtx.result` is the projected record. Renaming a set through the data door is rejected (the name is the metadata identity; clone-then-delete is the supported flow). System-context writes @@ -158,7 +192,7 @@ convergence pass: The pass is idempotent and re-runs harmlessly on every boot. -### D5 — Env-scope overlays of package-owned sets are FIRST-CLASS customizations (revised 2026-07-14) +### D5 — Env-scope overlays of package-owned sets are FIRST-CLASS customizations (revised 2026-07-14; RETIRED 2026-08-09 — see D5-R) **History.** As first landed, the projector refused env-scope bodies for package-owned records (the #2867 rule), which left an authored overlay of a @@ -198,24 +232,89 @@ review; ADR-0091 recertification covers overlays like any other grant source. This is the same trade every overlayable type makes — permission sets no longer get a bespoke, stricter rule that the rest of the platform contradicts. +### D5-R — D5's direction is RETIRED for artifact-backed sets (2026-08-09) + +**What changed under this ADR.** D5 rested on one premise: that `permission` +declares `allowOrgOverride: true`, so refusing an env overlay would make it +"the one metadata type whose declared flag is a lie". #6483 removed the +premise. The 2026-08-08 maintainer ruling read ADR-0005's security row — +*"Authorization correctness; overlays would create silent privilege drift"* — +as excluding `permission` outright, ruled the unratified `true` back to +`false` without waiting for a row-count measurement, and PR #6608 landed it +(merged 2026-08-09). The maintainer then ruled **option A** on the conflict +this ADR raised (#6609): accept the tightening, and stop pointing at the path +that now refuses. This section is that follow-through (#6858). + +**The decision.** For a permission set whose definition ships as a code +artifact, the ADR-0005 overlay is **no longer a customization channel of any +kind**. The supported channel is the one ADR-0086 always named: edit the +package and re-publish. D5's "no more flat 403" sentence is withdrawn — the +403 is back, it is fail-closed, and it is issued by the producer at the moment +of the write with a `NOT_OVERRIDABLE` envelope naming ADR-0005. + +**What survives, stated narrowly so it is not mistaken for a re-route.** +Environment authoring of permission sets continues on the `allowRuntimeCreate` +tier — sets created through the data door, and package sets authored and +published through the METADATA door, whose definition lives in `sys_metadata` +rather than in an artifact. That tier was measured unaffected at PR #6608's +boundary probe and is deliberately left open. It is **not** the capability D5 +described: it edits the single stored definition in place, so there is no +code-vs-overlay layering, no `overlay`/`code` diff in the Studio layered view, +and no "delete = reset to the shipped declaration". Calling it a re-route +would re-introduce exactly the FORK D5 rejected under a new name; it is +recorded here as the surviving neighbour, not as D5's successor. + +**Why retire rather than restore the flag** (the three axes the ruling weighed): +*business* — the direction was confirmed 2026-07-14 and never exercised: zero +live org-scoped `permission` overlay rows in the in-repo corpus at rollback, +and no example app customizes a packaged set from the environment, so there is +no measured pull to preserve; *long-term* — ADR-0005 excludes the +authorization surface definitionally, and restoring the flag needs the +admission pair (an overlay schema AND a written render-only rationale) that +cannot be written for a surface whose whole effect is to rewrite authorization; +*authoring safety* — an ADR that points an author, human or agent, at a write +path that 403s is the most expensive kind of documentation error, because the +code it produces is confident and wrong. Restoring `allowOrgOverride: true` +for `permission` remains possible only through an ADR-0005 revision that +ratifies that pair — never by editing the registry entry, and never by adding +a tolerant fallback in a consumer. + +**The consequences D5 accepted, re-read under the retirement.** The +whole-document overlay risk D5 took on (an env overlay widening a vendor +baseline; a vendor tightening not reaching a pinned name) is gone with the +channel. Its mitigations are correspondingly moot for `permission`, with one +carry-over: `supportsOverlay: true` is unchanged, so an overlay row authored +BEFORE the rollback still merges overlay-wins at read time. The corpus has +none; an environment that has one should treat it as legacy state to be lifted +by an operator (`OS_METADATA_WRITABLE=permission` for the removal), because +the ordinary delete path is `override-artifact` intent and refuses like any +other write. + ## Consequences **Positive.** - One truth. No write path — present or future — can desync the record from metadata through the data plane: the choke point is the engine middleware every ObjectQL write traverses, not an opt-in subscriber. -- Setup edits of declared sets finally **enforce** (they become env overlays), and - Studio edits/creations appear in Setup **before the save returns** (awaited - projection — acceptance criterion "no projection race"). +- Setup edits of declared sets are no longer silently enforcement-inert — the + divergence that motivated this ADR is closed at the other end since D5-R: they + are **refused**, loudly, instead of updating a record nothing enforces. Studio + edits/creations of `sys_metadata`-backed sets appear in Setup **before the save + returns** (awaited projection — acceptance criterion "no projection race"). - Display and enforcement can no longer disagree: both derive from the layered effective body (projection + in-memory registry sync). - Legacy data is migrated, not stranded (D4 backfill). **Negative / behavior changes.** -- "Deleting" an artifact-backed set through Setup now **resets** it to the declared - body instead of deleting the row (the definition ships with the app and cannot be - deleted from the env — the honest semantic; previously the delete produced a ghost: - row gone, enforcement unchanged). +- "Deleting" an artifact-backed set through Setup never removes the row (the + definition ships with the app and cannot be deleted from the env — the honest + semantic; previously the delete produced a ghost: row gone, enforcement + unchanged). Since D5-R the delete no longer *resets* either, because there is + no overlay left to lift: with nothing to remove it is a no-op success, and on an + environment-scoped kernel the `override-artifact` intent refuses it outright. +- Since D5-R, a data-door edit of an artifact-backed set answers 403 + `NOT_OVERRIDABLE` (ADR-0005). The Setup surface must present that refusal, not + retry it or fall back to a local edit. - Record drift authored through the data door **before** this change and shadowed by metadata is discarded at first boot (loud warn). It was never enforced. - Renames through the data door are rejected. @@ -246,6 +345,13 @@ longer get a bespoke, stricter rule that the rest of the platform contradicts. - framework#2875 (this ADR), #2857 / #2867 (display-freshness gap and projection band-aid), ADR-0005, ADR-0086 (D3/D4/D5/P2), ADR-0090 (D12), ADR-0056. +- D5-R (2026-08-09): #6483 / PR #6608 (the ADR-0005 rollback and its boundary + probe), #6609 (the conflict ruling — option A), #6858 (this revision), + #5768 (the objectui consumer symptom). Enforcement lives in + `packages/spec/src/kernel/metadata-plugin.zod.ts` (the registry row), + `packages/metadata-protocol/src/protocol.ts` (`saveMetaItem` / + `deleteMetaItem` tier gates) and + `packages/metadata-protocol/src/sys-metadata-repository.ts` (`assertAllowed`). - Implementation: `packages/plugins/plugin-security/src/permission-set-projection.ts`, `packages/metadata-protocol/src/protocol.ts` (`registerMutationProjector`), `packages/plugins/plugin-security/src/security-plugin.ts` (wiring). diff --git a/packages/plugins/plugin-security/src/permission-set-projection.test.ts b/packages/plugins/plugin-security/src/permission-set-projection.test.ts index 59566c2ae0..18f5f56deb 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.test.ts @@ -6,6 +6,17 @@ * registry sync), the data-door write-through middleware, and the boot * reconciliation/backfill pass. The package door stays covered in * bootstrap-declared-permissions.test.ts. + * + * [#6858 / ADR-0094 D5-R] `makeProtocol` models ADR-0005's TIER GATE, which + * this suite used to be blind to: `permission` is `allowOrgOverride: false` + * since #6483 (PR #6608), so a metadata write whose (type, name) is backed by + * a code ARTIFACT is refused 403 `NOT_OVERRIDABLE`, while an artifact-free + * name rides `allowRuntimeCreate: true` and still lands. PR #6608 recorded the + * blind spot in its own body — "its own suite stubs `saveMetaItem`, so this + * file is where that behaviour is actually pinned against the real gate" — + * which is exactly why four cases here kept asserting the RETIRED overlay + * direction and stayed green: the stub could not refuse. They are triaged + * below, each one individually. */ import { describe, it, expect } from 'vitest'; @@ -84,6 +95,31 @@ function makeProtocol(ql: any, declared: Record = {}) { projector = fn; }, async saveMetaItem(req: { type: string; name: string; item: any; actor?: string }) { + // [#6858 / ADR-0094 D5-R] ADR-0005's tier gate, first — ahead of schema + // validation, exactly as `protocol.ts` orders it. `declared` IS this + // stub's artifact registry, so `declared[name] !== undefined` is the + // `isArtifactBacked` fact the production gate reads (never the record's + // `managed_by` column). The refusal is topology-independent: the + // protocol gate fires when `environmentId` is set, and + // `SysMetadataRepository.assertAllowed` refuses the `override-artifact` + // intent on every other kernel — pinned in + // metadata-protocol/src/protocol.adr0005-org-override-rollback.test.ts. + // + // `deleteMetaItem` below deliberately does NOT model a gate: its + // artifact-backed refusal sits inside `environmentId !== undefined` + // with no repository-level twin, so the answer is topology-dependent + // and a single modelled verdict here would be a fabrication. It stays + // pinned where the real gate can be reached (the protocol suite above, + // and packages/qa/dogfood/test/two-doors-permission.dogfood.test.ts). + if (declared[req.name] !== undefined) { + const err: any = new Error( + `[not_overridable] Metadata item 'permission/${req.name}' is provided by a code package ` + + 'and the type has not opted into per-org overlay writes (allowOrgOverride=false).', + ); + err.code = 'NOT_OVERRIDABLE'; + err.status = 403; + throw err; + } // [#4669] The REAL `PermissionSetSchema`, exactly as `saveMetaItem` runs // it (metadata-protocol/src/protocol.ts → `resolveOverlaySchema`), same // `[invalid_metadata]` 422 envelope. Without this the mock accepts any @@ -408,10 +444,29 @@ describe('registerPermissionSetProjection', () => { }); }); -// ── Package-set customization via overlay (ADR-0094, direction 2026-07-14) ── - -describe('package-owned set customization lifecycle (env overlay)', () => { - it('a Studio env-scope save on a PACKAGE name customizes the record and keeps provenance', async () => { +// ── Package-set customization (ADR-0094 D5-R — the 2026-07-14 direction is +// RETIRED; what survives is the `allowRuntimeCreate` tier) ─────────────── + +/** Seed an env-scope overlay row directly — a LEGACY overlay, authored before + * the #6483 rollback closed the write door. `saveMetaItem` can no longer mint + * one for an artifact-backed name, but `supportsOverlay: true` is unchanged, + * so rows that already exist still merge overlay-wins at read time. */ +const seedLegacyOverlay = (ql: any, name: string, body: any) => { + ql.metaRows.push({ + id: `meta_${name}`, type: 'permission', name, state: 'active', + organization_id: null, metadata: JSON.stringify(body), + }); +}; + +describe('package-owned set customization lifecycle (ADR-0094 D5-R)', () => { + it('an env-scope save on an ARTIFACT-BACKED package name is REFUSED (403 NOT_OVERRIDABLE) — no overlay, record untouched', async () => { + // Was: "a Studio env-scope save on a PACKAGE name customizes the record + // and keeps provenance" — the 2026-07-14 direction. #6483 rolled + // `permission` back to `allowOrgOverride: false` and #6609 ruling A + // accepted the tightening, so the write this case used to assert is the + // write production now refuses. Rejection-class: the ENVELOPE is the + // claim (`code` AND `status`), because a bare "it threw" would stay green + // on a stub that threw for any other reason. const ql = makeQl(); const declaredBody = envBody({ systemPermissions: ['pkg.baseline'] }); (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; @@ -419,25 +474,52 @@ describe('package-owned set customization lifecycle (env overlay)', () => { registerPermissionSetProjection(protocol, { ql }); ql.permRows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); + await expect( + protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody({ systemPermissions: ['customized'] }) }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + + expect(ql.metaRows.length, 'refused, not "refused after writing" — no phantom overlay row').toBe(0); + const row = ql.permRows[0]; + expect(JSON.parse(row.system_permissions), 'the record still projects the shipped declaration').toEqual(['pkg.baseline']); + expect(row.customized, 'nothing was customized').toBeFalsy(); + }); + + it('a package row MATERIALIZED through the metadata door is still customizable — the surviving allowRuntimeCreate tier', async () => { + // The boundary #6608 measured UNAFFECTED, and the reason D5-R names a + // surviving NEIGHBOUR rather than a re-route: this row is + // `managed_by:'package'` like the one above, but its DEFINITION lives in + // `sys_metadata` (authored + published through the metadata door, + // ADR-0070), so no artifact backs it and the write rides + // `allowRuntimeCreate`. It is a direct edit of the one stored + // definition — there is no code layer for it to be an overlay OF. + const ql = makeQl(); + const protocol = makeProtocol(ql, {}); // no artifact registry entry + registerPermissionSetProjection(protocol, { ql }); + ql.permRows.push({ id: 'ps_mat', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["materialized.baseline"]' }); + await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody({ systemPermissions: ['customized'] }) }); const row = ql.permRows[0]; expect(JSON.parse(row.system_permissions)).toEqual(['customized']); - expect(row.managed_by).toBe('package'); + expect(row.managed_by, 'the package still owns the row').toBe('package'); expect(row.package_id).toBe('com.example.crm'); - expect(row.customized, 'package row now carries an overlay → flagged customized').toBe(true); }); - it('deleting the overlay RESETS the package record to its declared baseline', async () => { + it('a LEGACY overlay (authored before the rollback) still projects, and deleting it still RESETS to the declaration', async () => { + // `supportsOverlay: true` was not touched by #6483 — only the WRITE flag + // was. A row that already exists keeps merging overlay-wins, so the + // reset invariant still has to hold for it. Seeded directly because the + // write door that used to mint it is closed. const ql = makeQl(); const declaredBody = envBody({ systemPermissions: ['pkg.baseline'] }); (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; const protocol = makeProtocol(ql, { organization_admin: declaredBody }); registerPermissionSetProjection(protocol, { ql }); ql.permRows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); + seedLegacyOverlay(ql, 'organization_admin', envBody({ systemPermissions: ['legacy.overlay'] })); - await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody({ systemPermissions: ['customized'] }) }); - expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['customized']); + await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'organization_admin', state: 'active', organizationId: null }); + expect(JSON.parse(ql.permRows[0].system_permissions), 'the legacy overlay still wins at read time').toEqual(['legacy.overlay']); await protocol.deleteMetaItem({ type: 'permission', name: 'organization_admin' }); const row = ql.permRows[0]; @@ -628,8 +710,12 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () => const declared = { organization_admin: envBody({ systemPermissions: ['declared.only'] }) }; const protocol = makeProtocol(ql, declared); registerPermissionSetProjection(protocol, { ql }); - // env overlay shadows the declaration; record projected from the overlay - await protocol.saveMetaItem({ type: 'permission', name: 'organization_admin', item: envBody({ systemPermissions: ['overlaid'] }) }); + // [#6858] A LEGACY env overlay shadows the declaration — seeded directly + // because `saveMetaItem` can no longer mint one for an artifact-backed + // name (ADR-0094 D5-R). The invariant under test is unchanged: the record + // is projected from the overlay, and the data-door delete resets it. + seedLegacyOverlay(ql, 'organization_admin', envBody({ systemPermissions: ['overlaid'] })); + await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'organization_admin', state: 'active', organizationId: null }); expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['overlaid']); const mw = makeMiddleware(ql, protocol); const nextCalled = await run(mw, { @@ -640,7 +726,15 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () => expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['declared.only']); // …reset to the declaration }); - it('UPDATE of a PACKAGE-OWNED set becomes an env overlay; the record keeps its provenance', async () => { + it('UPDATE of an ARTIFACT-BACKED set surfaces the producer\'s 403 to the caller (write point :794 — left to 403 loudly)', async () => { + // Was: "UPDATE of a PACKAGE-OWNED set becomes an env overlay" — the + // retired D5 direction. This is the ONE of the four production write + // points that the #6483 rollback actually closes, and the card's design + // question was what to do with it. Decision (ADR-0094 D5-R): leave it to + // the producer. The middleware still TRANSLATES the write; the protocol's + // ADR-0005 tier gate refuses it; the middleware neither pre-empts the + // refusal (it would need a second copy of `isArtifactBacked` — Prime + // Directive #8) nor swallows it. The caller hears the envelope. const ql = makeQl(); const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; @@ -651,21 +745,91 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () => system_permissions: '["pkg.baseline"]', }); const mw = makeMiddleware(ql, protocol); + await expect( + run(mw, { + object: 'sys_permission_set', operation: 'update', context: userCtx, + data: { id: 'ps_pkg', system_permissions: '["customized"]' }, + }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + + expect(ql.metaRows.length, 'no overlay was minted by the refused edit').toBe(0); + const row = ql.permRows[0]; + expect(JSON.parse(row.system_permissions), 'the record still shows the shipped declaration').toEqual(['pkg.baseline']); + expect(row.managed_by).toBe('package'); + }); + + it('UPDATE of a package row MATERIALIZED through the metadata door still lands (the surviving tier)', async () => { + // The other half of the boundary — same `managed_by:'package'` row shape, + // no artifact behind the name, so the write rides `allowRuntimeCreate` + // and the data door keeps working. Without this the case above would also + // pass on a harness that could not write ANYTHING through the middleware. + const ql = makeQl(); + const protocol = makeProtocol(ql, {}); // no artifact registry entry + registerPermissionSetProjection(protocol, { ql }); + ql.permRows.push({ + id: 'ps_mat', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', + system_permissions: '["materialized.baseline"]', + }); + const mw = makeMiddleware(ql, protocol); const opCtx: any = { object: 'sys_permission_set', operation: 'update', context: userCtx, - data: { id: 'ps_pkg', system_permissions: '["customized"]' }, + data: { id: 'ps_mat', system_permissions: '["customized"]' }, }; const nextCalled = await run(mw, opCtx); - expect(nextCalled).toBe(false); - // The customization lives in the metadata overlay… + expect(nextCalled, 'the driver write is still skipped — the record is projector-owned').toBe(false); expect(JSON.parse(ql.metaRows[0].metadata).systemPermissions).toEqual(['customized']); - // …the record projects it, and the package still owns the row. const row = ql.permRows[0]; expect(JSON.parse(row.system_permissions)).toEqual(['customized']); - expect(row.managed_by).toBe('package'); + expect(row.managed_by, 'the package still owns the row').toBe('package'); expect(row.package_id).toBe('com.example.crm'); }); + it('INSERT of a name that shadows a code-declared set surfaces the 403 too (write point :752)', async () => { + // Reachable when a declaration ships but its record was never + // materialized: the duplicate-name probe finds no row, so the insert + // proceeds to `saveMetaItem` on an artifact-backed name. Fail-closed and + // unswallowed, same disposition as the UPDATE above. + const ql = makeQl(); + const declaredBody = envBody({ name: 'crm_rep' }); + const protocol = makeProtocol(ql, { crm_rep: declaredBody }); + registerPermissionSetProjection(protocol, { ql }); + const mw = makeMiddleware(ql, protocol); + await expect( + run(mw, { + object: 'sys_permission_set', operation: 'insert', context: userCtx, + data: { name: 'crm_rep', label: 'Shadow', object_permissions: JSON.stringify({ crm_lead: { allowRead: true } }) }, + }), + ).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(ql.permRows.length, 'no record was created by the refused insert').toBe(0); + expect(ql.metaRows.length).toBe(0); + }); + + it('RESTORE reports a refused re-author on the durability channel instead of throwing (write point :713)', async () => { + // The deliberate asymmetry. `restore` runs AFTER the engine has already + // un-trashed the row, so throwing would leave the caller with a restored + // record and a failed request; the write point catches and reports on the + // durability channel (#4632) instead. Pinned with an artifact-backed name + // so the refusal is the ADR-0005 one — the scenario is narrow (a packaged + // definition cannot be trashed through the data door at all), which is + // why the DISPOSITION, not the frequency, is what this case fixes. + const ql = makeQl(); + const declaredBody = envBody({ name: 'crm_rep' }); + const protocol = makeProtocol(ql, { crm_rep: declaredBody }); + const errors: any[] = []; + const mw = createPermissionSetWriteThrough({ + ql, getProtocol: () => protocol, + logger: { error: (m: string, e?: Error) => errors.push({ m, e }), info: () => {}, warn: () => {} }, + }); + ql.permRows.push({ id: 'ps_r', name: 'crm_rep', managed_by: 'user' }); + const nextCalled = await run(mw, { + object: 'sys_permission_set', operation: 'restore', options: { where: { id: 'ps_r' } }, context: userCtx, + }); + expect(nextCalled, 'the engine un-trash still runs').toBe(true); + expect(errors.length, 'the failure is reported, not swallowed').toBe(1); + expect(errors[0].e).toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(errors[0].m).toContain('NOT re-authored into metadata'); + }); + it('DELETE of a customized PACKAGE set removes the overlay and resets to the declared baseline', async () => { const ql = makeQl(); const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); @@ -674,8 +838,12 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () => registerPermissionSetProjection(protocol, { ql }); ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); const mw = makeMiddleware(ql, protocol); - // customize first - await run(mw, { object: 'sys_permission_set', operation: 'update', context: userCtx, data: { id: 'ps_pkg', system_permissions: '["customized"]' } }); + // [#6858] The overlay is LEGACY (pre-rollback) and seeded directly — the + // data-door edit that used to mint it now answers 403 (ADR-0094 D5-R). + // The invariant under test is untouched: "delete" lifts the overlay and + // the record resets to the shipped declaration. + seedLegacyOverlay(ql, 'crm_rep', envBody({ name: 'crm_rep', systemPermissions: ['customized'] })); + await projectPermissionMutation(protocol, { ql }, { type: 'permission', name: 'crm_rep', state: 'active', organizationId: null }); expect(JSON.parse(ql.permRows[0].system_permissions)).toEqual(['customized']); // "delete" = reset const nextCalled = await run(mw, { object: 'sys_permission_set', operation: 'delete', options: { where: { id: 'ps_pkg' } }, context: userCtx }); @@ -741,6 +909,30 @@ describe('reconcilePermissionSetProjection', () => { expect(out2.backfilledIntoMetadata).toBe(0); }); + it('[#6858] the backfill never targets an ARTIFACT-BACKED name — write point :928 cannot reach the tier gate', async () => { + // The measured half of ADR-0094 D5-R's "3 of the 4 write points were + // already on the surviving tier". The backfill runs only for records + // whose name has NO metadata presence at all; a code-declared name has a + // declared body, so the branch is not entered and no `saveMetaItem` is + // issued. Asserted on the write LEDGER (`protocol.saves`) rather than on + // the absence of a throw: "it did not fail" would also be true if the + // gate had simply accepted the write. + const ql = makeQl(); + const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); + (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; + const protocol = makeProtocol(ql, { crm_rep: declaredBody }); + ql.permRows.push({ + id: 'ps_pkg_env', name: 'crm_rep', managed_by: 'admin', active: true, + label: 'CRM Rep', system_permissions: '["pkg.baseline"]', + }); + + const out = await reconcilePermissionSetProjection(protocol, { ql }); + + expect(protocol.saves.length, 'no metadata write was attempted for a declared name').toBe(0); + expect(out.backfilledIntoMetadata).toBe(0); + expect(out.backfillFailed, 'and therefore nothing could be refused').toBe(0); + }); + it('[#4669] a row carrying the `active` STORAGE COLUMN backfills instead of failing spec validation', async () => { // The reported symptom: every `sys_permission_set` row has an `active` // column, `permissionSetBodyFromRow` handed it to `saveMetaItem`, and diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index b715fce3e2..448b9fd799 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -26,12 +26,42 @@ * records into the metadata store (one-time backfill). * * Package-owned records (`managed_by:'package'`) keep their shipped - * declaration as the BASELINE (boot seeding / publish materialization), and — - * direction confirmed 2026-07-14 — the environment customizes them through - * the platform's standard ADR-0005 metadata overlay: a data-door edit of a - * package set becomes an env-scope overlay, the record projects the EFFECTIVE - * (overlay-wins) body with its package provenance preserved, and deleting the - * overlay (the data-door "delete") resets the record to the declaration. + * declaration as the BASELINE (boot seeding / publish materialization). The + * 2026-07-14 direction confirmation that used to sit here — "the environment + * customizes them through the platform's standard ADR-0005 metadata overlay" + * — is **RETIRED** (ADR-0094 D5-R, 2026-08-09; #6609 ruling A executed by + * #6858). #6483 rolled `permission` back to `allowOrgOverride: false` + * (PR #6608), so ADR-0005's security row is enforced again: an overlay of the + * authorization surface IS the "silent privilege drift" it excludes. + * + * What that means for THIS file, per write point: + * + * - the middleware still TRANSLATES every data-door write into a metadata + * write — that half is unchanged. Whether the translated write is accepted + * is ADR-0005's tier gate, decided by the target's ARTIFACT provenance: + * a CODE-DECLARED set (`*.permission.ts`, a stack's `permissionSets`) is + * refused with 403 `NOT_OVERRIDABLE`; a set whose definition lives only in + * `sys_metadata` — created through the data door, or authored and + * published through the METADATA door (ADR-0070) — rides + * `allowRuntimeCreate`, still `true`, and keeps working; + * - that refusal is deliberately LEFT TO THE PRODUCER. This file does not + * pre-empt it by re-deriving artifact-backing: `isArtifactBacked` is the + * protocol's rule (it excludes the `'sys_metadata'` rehydration sentinel), + * and a second copy here would be the parallel-allowlist failure Prime + * Directive #8 exists to prevent. The `managed_by` column is measurably + * NOT that fact — `member_default`'s row is `managed_by:'admin'` and its + * edit is refused, `twodoors_pkgset`'s row is `managed_by:'package'` and + * its edit lands; + * - the two write points that CATCH a failed metadata write + * ({@link createPermissionSetWriteThrough}'s `restore` leg and + * {@link reconcilePermissionSetProjection}'s backfill) keep catching: both + * run after the record already exists, so a throw would strand the caller + * with a healthy-looking row and no way to hear about it. They log on the + * durability channel (#4632) and the backfill counts the failure — that is + * the degradation report, not a swallow. Neither targets an artifact-backed + * name in the first place (a packaged definition cannot be trashed, and the + * backfill only runs for names with NO metadata presence at all). + * * Cross-package composition stays a POSITION concern (bind several packages' * sets to one position); package-first authoring (ADR-0070) gives * runtime-created sets a home package. @@ -682,6 +712,18 @@ export function createPermissionSetWriteThrough( // protection applies HERE (the outer security gate delegates the // update/delete package-row check to this middleware): a // package-managed row stays read-only through the data door. + // + // [ADR-0094 D5-R] This is no longer the ONLY reason a packaged set is + // read-only through the data door — since #6483 a CAPABLE kernel refuses + // an artifact-backed set too, at the protocol's ADR-0005 tier gate. The + // remedy this message names ("edit the package and re-publish") is + // therefore right on every kernel; only the stated cause is specific to + // this branch. The two conditions keep separate wordings deliberately + // (#5240 — one condition, one wording): they are distinguishable and an + // operator needs to know which one they hit. Note also that this branch + // keys on `managed_by:'package'` — a record-provenance heuristic that is + // the best this file can do with no protocol to ask, and measurably NOT + // the artifact-provenance fact the capable path's gate reads. if (op === 'update' || op === 'delete') { const targets = await resolveTargetRows(ql, opCtx); const pkg = targets.find((t: any) => t?.managed_by === 'package'); diff --git a/scripts/adr-anchors.json b/scripts/adr-anchors.json index 5cdbef9fa7..880b082042 100644 --- a/scripts/adr-anchors.json +++ b/scripts/adr-anchors.json @@ -301,6 +301,15 @@ "ADR-0106" ], "invariant": "ADR-0106 D7 — getMetadataReadableFields differs from getReadableFields in exactly one place, and the asymmetry is the decision. On the DATA plane a caller resolving to zero permission sets falls OPEN, mirroring the engine middleware, because reporting a narrowing the data path would not enforce is its own drift. On the METADATA plane the same caller resolves the configured fallback permission set (the two-step /auth/me/permissions performs), so a guest-facing deployment's schema exposure is a deliberate permission-set decision rather than an accidental everything-default. Converging the two methods in either direction reverses this." + }, + { + "file": "packages/plugins/plugin-security/src/permission-set-projection.ts", + "adrs": [ + "ADR-0094", + "ADR-0005", + "ADR-0086" + ], + "invariant": "This file IS ADR-0094's machinery (write-through + awaited projector + boot reconciliation), and since 2026-08-09 it is also the site of ADR-0094 D5-R. The 2026-07-14 direction confirmation that once sat in its header — package sets are customized through an ADR-0005 env overlay — is RETIRED: #6483 rolled `permission` back to `allowOrgOverride: false`, so a metadata write against a CODE-DECLARED (artifact-backed) set is refused 403 NOT_OVERRIDABLE, while a set whose definition lives only in `sys_metadata` rides `allowRuntimeCreate` and still works. Two rules follow and must not be quietly undone: (1) the refusal is LEFT TO THE PRODUCER — never re-derive artifact-backing here to pre-empt it, and never add a tolerant fallback (`??`, catch-and-continue) around the refused `saveMetaItem`; `isArtifactBacked` is the protocol's rule and the `managed_by` column is measurably not that fact; (2) the two write points that DO catch (the `restore` leg and the reconcile backfill) catch because the record already exists by then — they report on the durability channel (#4632) and must keep doing so rather than being 'fixed' into throwing. Restoring the overlay channel needs an ADR-0005 revision ratifying an admission pair, not an edit here or in the registry." } ] }