diff --git a/.changeset/permission-set-projection-d5r-jsdoc.md b/.changeset/permission-set-projection-d5r-jsdoc.md new file mode 100644 index 0000000000..f744251eaa --- /dev/null +++ b/.changeset/permission-set-projection-d5r-jsdoc.md @@ -0,0 +1,27 @@ +--- +"@objectstack/plugin-security": patch +--- + +Docs: bring the second ADR-0094 copy in `permission-set-projection.ts` up to D5-R. + +PR #6962 retired the 2026-07-14 "customize packaged permission sets through an ADR-0005 +env overlay" direction and corrected this file's **header**. A second copy survived in +the function-level JSDoc of `upsertEnvPermissionSet` — an exported symbol, so the stale +text ships in the published `.d.ts` and reads as fact to the next author. It stated both +halves D5-R retired: that an env-scope overlay is the platform's standard customization +of a packaged definition, and that deleting the overlay resets the row to the shipped +declaration. + +Both are now stated as current: `#6483` (PR #6608) rolled `permission` back to +`allowOrgOverride: false`, so a metadata write against a code-declared (artifact-backed) +set is refused by the producer with 403 `NOT_OVERRIDABLE` and the supported channel is +ADR-0086's (edit the package, re-publish); and `#6960` measures the ordinary delete path +refusing to lift even a legacy pre-rollback overlay, leaving `OS_METADATA_WRITABLE` as +the only documented removal — so "delete = reset" is recorded as retired rather than +restated. The retirement itself is kept in the text, not deleted, so a reader arriving +at this function does not have to reconstruct the history. + +Prose only — no behaviour change. The same retired direction was also corrected in three +neighbouring comments in this package (the `readDeclaredBody` JSDoc, and the two +`security-plugin.ts` package-managed write-gate comments) plus their two test rationales, +so the package no longer states the direction in two voices. 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 18f5f56deb..655545d298 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.test.ts @@ -333,9 +333,12 @@ describe('upsertEnvPermissionSet (ADR-0094 — record is a pure projection)', () }); it('projects onto a PACKAGE-OWNED row (overlay customization) while preserving its provenance', async () => { - // Direction confirmed 2026-07-14: an env overlay of a packaged set is the - // platform's standard ADR-0005 customization — the record follows the - // effective body; the package still owns the row. + // Unit-level shape, unchanged by ADR-0094 D5-R: handed a body for a + // package-owned row, this function projects the facets and preserves the + // provenance. What D5-R retired is the CLAIM about where that body comes + // from — an env overlay of a packaged set is no longer a supported + // customization channel (#6483 / PR #6608); see the D5-R lifecycle block + // below for the refusal this projector now sits behind. const ql = makeQl(); ql.permRows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg"]' }); const r = await upsertEnvPermissionSet(ql, envBody()); diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index 448b9fd799..1cf282840a 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -287,8 +287,11 @@ const isProjectionEcho = (v: any): boolean => * Read the DECLARED (artifact) body for a permission set from the engine's * SchemaRegistry — the same source `bootstrapDeclaredPermissions` seeds from, * and the one store the env projection never writes, so it can't be poisoned - * by our own registry sync. Used as the reset target when an env overlay is - * deleted off a declared set. + * by our own registry sync. It is the body for a declared set with no overlay + * at all, and the reset target when an overlay IS lifted off one — which since + * ADR-0094 D5-R means a LEGACY (pre-#6483) row removed through the operator + * hatch, not a data-door delete: #6960 measures that delete refusing with 403 + * `NOT_OVERRIDABLE`. * * Items tagged `_packageId: 'sys_metadata'` are RUNTIME SHADOWS — hydrated * into the registry from overlay rows (loadMetaFromDb / getMetaItems), not @@ -328,13 +331,49 @@ function hasSchemaRegistry(ql: any): boolean { * (`managed_by:'admin'` — A4 #2920 unified vocab, formerly 'user'; a * Studio-authored set appears in Setup, where the * #2867 band-aid declined to create). A PACKAGE-OWNED row is also projected — - * an env-scope overlay is the platform's standard customization of a packaged - * definition (ADR-0005; direction confirmed 2026-07-14, reversing the earlier - * refuse-the-env-door rule): the facets update to the EFFECTIVE (overlay-wins) - * body while the `managed_by:'package'` + `package_id` provenance is - * PRESERVED — the row still belongs to the package; the overlay is a - * customization of it, and deleting the overlay resets the row to the shipped - * declaration (the layered read reveals the baseline again). + * its facets follow the body this pass is handed, while the + * `managed_by:'package'` + `package_id` provenance is PRESERVED: the row + * still belongs to the package. + * + * WHICH body that is, is the part this comment used to get wrong. The + * 2026-07-14 direction confirmation that used to sit here — "an env-scope + * overlay is the platform's standard ADR-0005 customization of a packaged + * definition, and deleting the overlay resets the row to the shipped + * declaration" — is **RETIRED**, in BOTH halves (ADR-0094 D5-R, 2026-08-09; + * #6609 ruling A executed by #6858; the file header above records the same + * retirement). Since #6483 / PR #6608 rolled `permission` back to + * `allowOrgOverride: false`: + * + * - **no new overlay of a packaged set can be minted.** A metadata write + * against a CODE-DECLARED (artifact-backed) set — `*.permission.ts`, a + * stack's `permissionSets` — is refused by the producer with 403 + * `NOT_OVERRIDABLE`, so for those names the body reaching here is the + * DECLARED one and the projected facets ARE the shipped declaration. The + * supported way to change them is the one ADR-0086 always named: edit the + * package and re-publish. What survives is the neighbouring + * `allowRuntimeCreate` tier (still `true`) — a set whose definition lives + * only in `sys_metadata`, created through the data door or authored and + * published through the METADATA door (ADR-0070). That tier edits the + * single stored definition IN PLACE: no code-vs-overlay layering, and + * nothing to reset to (ADR-0094 D5-R calls it the surviving neighbour, not + * D5's successor); + * - **`customized` therefore badges LEGACY state, not a supported channel.** + * `supportsOverlay` is unchanged, so an overlay row authored BEFORE the + * rollback still merges overlay-wins at read time + * ({@link projectPermissionMutation} hands us `overlay ?? declared`) and + * this pass still stamps the flag for it. The in-repo corpus had zero such + * rows when PR #6608 measured it; + * - **"delete = reset" must NOT be read back into that.** #6960 measures the + * ordinary delete path refusing to lift exactly such a legacy overlay: on + * an environment-scoped kernel `deleteMetaItem` throws `NOT_OVERRIDABLE` / + * 403 for an artifact-backed target of a non-overridable type BEFORE it + * probes for the row, and a kernel with no `environmentId` refuses the + * same write as `override-artifact` intent — leaving the operator hatch + * (`OS_METADATA_WRITABLE=permission`) as the only documented removal. With + * no overlay to lift — the normal case — the delete is a no-op success and + * the row keeps projecting the declaration. {@link readDeclaredBody} stays + * the correct reset target IF an overlay is ever lifted; it is no longer a + * path the data door can walk. */ export async function upsertEnvPermissionSet( ql: any, diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index bc8392c066..a8d53a255f 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -1986,11 +1986,14 @@ describe('SecurityPlugin', () => { return harness.run(opCtx); }; - it('PASSES an admin update of a package-managed set (ADR-0094: the write-through turns it into an env overlay)', async () => { - // Direction confirmed 2026-07-14: update/delete on a package row are no - // longer refused at this gate — the ADR-0094 write-through downstream - // translates them into env-scope overlay operations (customize / reset). - // The single-store refusal lives in the write-through itself, covered in + it('PASSES an admin update of a package-managed set at THIS gate (ADR-0094: the refusal is the write-through producer\'s, not this gate\'s)', async () => { + // update/delete on a package row are not refused at this gate — the + // ADR-0094 write-through downstream translates them into a metadata + // write, and that producer decides. Since ADR-0094 D5-R (#6483 / + // PR #6608) the answer for a CODE-DECLARED set is 403 NOT_OVERRIDABLE, + // so "the write-through turns it into an env overlay" is no longer why + // this passes; it passes because the gate's job is forging provenance, + // not overridability. The refusal is covered in // permission-set-projection.test.ts. const opCtx: any = { object: 'sys_permission_set', operation: 'update', diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index ca83fc562b..b15c0ea552 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -887,10 +887,12 @@ export class SecurityPlugin implements Plugin { // update), and the lifecycle ops with no overlay translation // (transfer/restore/purge) stay refused on package rows. Ordinary // `update`/`delete` on a package row are handled downstream by the - // ADR-0094 write-through, which translates them into env-scope OVERLAY - // operations (customize / reset via the standard ADR-0005 layering) — - // the boot re-seed can no longer revert an admin's change, because the - // change lives in the overlay and the record projects overlay-wins. + // ADR-0094 write-through, which TRANSLATES them into a metadata write. + // Whether that write is ACCEPTED is ADR-0005's call, not this gate's, + // and since ADR-0094 D5-R (#6483 / PR #6608 rolled `permission` back to + // `allowOrgOverride: false`) a CODE-DECLARED set is refused there with + // 403 `NOT_OVERRIDABLE` — the 2026-07-14 "customize / reset via an env + // overlay" direction this comment used to state is RETIRED. // Placed BEFORE the empty-principal fall-open and the CRUD check so the // forging boundary holds even for a principal-less context and a // superuser with modifyAllRecords. System/boot writes carry `isSystem` @@ -3189,14 +3191,17 @@ export class SecurityPlugin implements Plugin { } if (op === 'insert') return; // no existing row to protect - // [ADR-0094, direction confirmed 2026-07-14] `update`/`delete` on a - // package-managed row are no longer refused here: the write-through - // middleware (which runs after this gate + the delegated-admin gate + - // the CRUD checks) translates them into env-scope OVERLAY operations — - // customize / reset via the standard ADR-0005 layering — and itself - // re-asserts the legacy refusal when the kernel has no metadata overlay - // layer to carry the customization. The lifecycle ops below have no - // overlay translation, so the package-row protection stays for them. + // [ADR-0094 D5-R] `update`/`delete` on a package-managed row are not + // refused HERE: the write-through middleware (which runs after this gate + // + the delegated-admin gate + the CRUD checks) translates them into a + // metadata write, and the refusal is LEFT TO THAT PRODUCER. Since #6483 / + // PR #6608 rolled `permission` back to `allowOrgOverride: false`, a + // CODE-DECLARED (artifact-backed) set is refused there with 403 + // `NOT_OVERRIDABLE`; a `sys_metadata`-backed set rides + // `allowRuntimeCreate` and still lands. The 2026-07-14 "customize / reset + // via the standard ADR-0005 layering" direction this comment used to cite + // is RETIRED. The lifecycle ops below have no metadata translation, so the + // package-row protection stays for them. if (op === 'update' || op === 'delete') return; if (!this.ql) return;