|
1 | 1 | # ADR-0086: Authorization metadata↔config boundary + cross-package composition — DEFINITION travels, ASSIGNMENT stays, packages ship their own sets |
2 | 2 |
|
3 | | -**Status**: Proposed (2026-07-04) |
| 3 | +**Status**: Accepted (2026-07-04) — **substantially implemented**; parts refined/superseded by [ADR-0090](./0090-permission-model-v2-concept-convergence.md) and [ADR-0094](./0094-sys-permission-set-pure-projection.md). See the **Re-evaluation (2026-07-16)** addendum for the current, code-grounded status of every decision. |
4 | 4 | **Deciders**: ObjectStack Protocol Architects |
5 | 5 | **Builds on**: [ADR-0066](./0066-unified-authorization-model.md) (three-way separation: Capability / Assignment / Requirement — this ADR operationalizes its **D5** for the packaging axis), [ADR-0056](./0056-permission-model-landing-verification.md) (whole-model enforce/prove; **D7** app-declared default profile), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (`readScope`/`writeScope` depth), [ADR-0005](./0005-metadata-customization-overlay.md) (per-org overlay = the subtract/adjust layer), [ADR-0028](./0028-metadata-naming-and-namespace-isolation.md) / [ADR-0048](./0048-cross-package-metadata-collision.md) (namespaced api names = collision-proof keys), [ADR-0078](./0078-no-silently-inert-metadata.md) (no declarable-but-never-seeded metadata), [ADR-0016](./0016-studio-package-authoring-and-publish.md) / [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (draft/publish), [ADR-0084](./0084-application-builder-information-architecture.md) (Access pillar = matrix; defers composition), [ADR-0025](./0025-plugin-package-distribution.md) (manifest `permissions` = install-consent scopes, **not** RBAC grants), [ADR-0003](./0003-package-as-first-class-citizen.md) / [ADR-0070](./0070-package-first-authoring.md) (package-first), [ADR-0006](./0006-project-environment-split.v4.md) (environment split) |
6 | 6 | **Consumers**: `@objectstack/spec` (`security/permission.zod.ts`, `stack.zod.ts`, `system/metadata-persistence.zod.ts`), `@objectstack/plugin-security` (`bootstrap-declared-*`, `permission-evaluator`), `@objectstack/core` (`resolve-authz-context`), `@objectstack/metadata` (overlay), `../objectui` (`AccessPillar`, `PermissionMatrixEditor`, `metadata-admin`) |
@@ -352,3 +352,87 @@ each door only writes what it owns. |
352 | 352 | - **Lifecycle context**: this ADR covers the package-authoring/composition slice of the authz |
353 | 353 | surface; the whole-lifecycle gap map (P0–P3, package dev → production) is tracked in |
354 | 354 | umbrella issue #2561. |
| 355 | + |
| 356 | +--- |
| 357 | + |
| 358 | +## Re-evaluation (2026-07-16) — code-grounded status; what landed, what later ADRs refined |
| 359 | + |
| 360 | +This ADR was written the day issue #2557 opened (2026-07-04). In the two weeks since, its |
| 361 | +whole plan **shipped**, and three later ADRs (0090, 0094, and — transitively — 0095) built on |
| 362 | +top of it and refined two of its parts. This addendum re-grounds every decision against the |
| 363 | +current code (HEAD 2026-07-16) so the ADR stops reading as an unimplemented proposal. **The |
| 364 | +central thesis holds unchanged**: authorization *definitions* travel with the package |
| 365 | +(metadata, on the `managedBy` provenance axis + `packageId`); *subject bindings* stay |
| 366 | +environment config; cross-package grants compose conflict-free by the namespaced-key UNION. |
| 367 | +Two mechanisms named in the original text have been overtaken by a better realization and are |
| 368 | +flagged inline below. |
| 369 | + |
| 370 | +### Status of each decision / phase |
| 371 | + |
| 372 | +| Item | Status | Where it landed / how it changed | |
| 373 | +|---|---|---| |
| 374 | +| **D1** classification on the provenance axis | **Adopted**; table vocabulary now stale — see "revised table" below | Principle intact. Rows renamed/removed by ADR-0090 (Profile gone, role→position). | |
| 375 | +| **D2** DEFINITION↔ASSIGNMENT principle | **Adopted, reinforced** | ADR-0090 D2 leans on it to justify removing Profile; ADR-0091 dates the *assignment* rows only (`valid_from`/`valid_until`), confirming they are env data. | |
| 376 | +| **D3** `packageId` + provenance `managedBy` on the set | **Implemented** | `spec/security/permission.zod.ts:167` (`packageId`, tagged `[ADR-0086 D3]`) + `:178` (`managedBy` enum); record fields `package_id`/`managed_by`/`customized` + index on `sys-permission-set.object.ts:217-266`. | |
| 377 | +| **D4** Shape B (packages ship own sets; union; shared slices env-only) | **Adopted, reinforced; subtract layer upgraded** | ADR-0090 D9 / Alt #6 reject package-written shared/builtin sets for the same reasons. Composition is now anchored at the **position** ("bind several packages' sets to one position"), ADR-0094 D5. **Subtract layer changed** — see ⚠️ below. | |
| 378 | +| **D5** `bootstrapDeclaredPermissions` | **Implemented; `isDefault` semantics refined by ADR-0090 D5** | `plugin-security/src/bootstrap-declared-permissions.ts` (invoked at `kernel:ready`, `security-plugin.ts`); seeds `managed_by:'package'` + `package_id`, idempotent/upgrade-aware; closes the ADR-0078 inert-metadata smell the D5 header calls out. `isDefault` no longer means "profile fallback": app-level default sets **auto-bind to the `everyone` position**; package-level default sets materialize a `sys_audience_binding_suggestion` an admin confirms (new object). | |
| 379 | +| **D6** package Access door under draft/publish | **Implemented** | objectui writes the package door as `mode:'draft', packageId` (`PermissionMatrixEditor.tsx`); env door stays live and, per ADR-0094 D3, that "live" save is itself redirected into a metadata overlay. | |
| 380 | +| **D7** two doors | **Implemented; gate narrowed by ADR-0094 D5** | Package door edits the package slice (scoped, draft); env-admin door keeps the cross-package matrix + assignment (live). ADR-0094 narrows the two-doors *gate* to what stays structurally true: the admin door can never **forge** package provenance, and package-row lifecycle ops with no overlay translation stay refused — but an env edit of a package set is now a first-class overlay, not a flat 403. | |
| 381 | +| **P0** objectui scope + slice-merge | **Done** (objectui #2505/#2508) | `permission-slice.ts` + `mergePermissionSlice`; matrix loads `client.list('object', { packageId })`, fields lazy per-object; save re-reads a fresh layered record and merges only the package slice, byte-for-byte preserving other packages. ⚠️ realized against the ADR-0094 store — see below. | |
| 382 | +| **P1** framework `packageId` + seeder | **Done** | = D3 + D5 above. | |
| 383 | +| **P2** two doors + overlay/subtract | **Largely done** | Two doors shipped (D6/D7). Subtract layer landed as **ADR-0005 first-class overlay** (ADR-0094 D5), not the Salesforce-muting/explicit-deny path this ADR sketched. The ADR-0066 precedence-step-4 explicit-deny layer remains deferred and **evidence-gated** — the whole-document overlay-wins customization covers today's need. | |
| 384 | + |
| 385 | +### ⚠️ Two mechanisms in the original text are superseded by a better realization |
| 386 | + |
| 387 | +1. **P0's "slice-merge the shared *data record*" premise → ADR-0094 projection + overlay.** |
| 388 | + The original Context/P0 assumed grants live inline on **one writable `sys_permission_set` |
| 389 | + record** and the fix was to slice-merge that record on save. **ADR-0094 relocated the |
| 390 | + authoritative store to the metadata layer**: the `sys_permission_set` row is now a **pure |
| 391 | + projection** written only by an awaited mutation projector, and every data-door write is |
| 392 | + redirected (write-through) into a metadata save/overlay. The data-loss trap this ADR |
| 393 | + worried about is now closed **structurally** (there is no second writable authority to |
| 394 | + clobber), and the shipped P0 correctly slice-merges the **layered effective** body, not a |
| 395 | + raw shared row. *Note the inline `objects`/`fields` dict shape survived — ADR-0094 moved |
| 396 | + the store, not the body shape — so this ADR's load-bearing claim that namespaced keys make |
| 397 | + a package's slice unambiguous still holds and underpins both slice-merge and overlay.* |
| 398 | + |
| 399 | +2. **D4's "Salesforce muting / clone-to-customize" subtract layer → ADR-0005 whole-document |
| 400 | + overlay.** ADR-0094 D5 (revised 2026-07-14) makes an environment overlay of a |
| 401 | + package-owned set a **first-class ADR-0005 customization** (overlay-wins, `managed_by: |
| 402 | + 'package'` + `package_id` preserved; a data-door "delete" is an overlay reset to the shipped |
| 403 | + declaration). This is strictly better than muting/forking: the customization keeps receiving |
| 404 | + the vendor's baseline security tightenings and retains the code-vs-overlay diff. The trade |
| 405 | + it accepts (an overlay pins a name against a later vendor tightening until reset) is the same |
| 406 | + trade every overlayable type makes and is covered by ADR-0091 recertification. |
| 407 | + |
| 408 | +### D1 classification table — restated in current (ADR-0090) vocabulary |
| 409 | + |
| 410 | +Superseding the D1 table only where vocabulary changed; the **class** column is unchanged |
| 411 | +except where noted: |
| 412 | + |
| 413 | +| Primitive (current name) | Class | What changed since 2026-07-04 | |
| 414 | +|---|---|---| |
| 415 | +| **Position** (was "Role") definition | **metadata** | Renamed `role`→`position` (ADR-0090 D3); "role" is now a reserved-forbidden word. | |
| 416 | +| ~~Role hierarchy `parent`~~ | **removed** | Positions are **flat, no `parent`** (ADR-0090 D3); the visibility hierarchy is the **Business Unit tree** (ADR-0057), which carries its own metadata/config split. The old "hybrid parent" row no longer exists. | |
| 417 | +| **PermissionSet** definition | **metadata** | **Profile removed** (ADR-0090 D2 — `isProfile` deleted); `permission_set` is the sole capability container. | |
| 418 | +| ObjectPermission / FieldPermission / `systemPermissions` / `tabPermissions` | **metadata** | Unchanged — ride the set definition, own objects/fields only. | |
| 419 | +| RLS policy / Sharing rule / OWD `sharingModel` | metadata / hybrid / metadata | Unchanged in class. `sharingModel` default flipped to `private` for custom objects (ADR-0090 D1) and gained `externalSharingModel` (D11). `sys_sharing_rule` is **record-authoritative** (seed-not-clobber), per the ADR-0094 addendum. | |
| 420 | +| **`everyone` / `guest`** built-in positions | **config** (admin-confirmed) | NEW default-grant mechanism (ADR-0090 D5/D9) that replaces the `member_default` fallback + ADR-0056 D7 default-profile. Packages *suggest* bindings; admins confirm. High-privilege bits on `everyone`/`guest` are lint-blocked. | |
| 421 | +| **Assignment** `sys_user_position` / `sys_position_permission_set` (was `sys_user_permission_set` / `sys_role_permission_set`) | **config / data** | Tables renamed (ADR-0090 D3); still the universal DEFINITION↔ASSIGNMENT line. ADR-0091 adds `valid_from`/`valid_until` — assignment is now *time-boxed* config. | |
| 422 | +| Delegated-admin **`adminScope`** on a set | **metadata (definition) + config (grant)** | NEW (ADR-0090 D12): admin capability itself is scoped (BU subtree + assignable-set allowlist + no self-escalation); `authorEnvironmentSets` keeps package-managed rows publish-only per this ADR. | |
| 423 | +| Organization / tenancy / actual users & teams | **config / data** | Unchanged. Tenancy is now an explicit kernel service (ADR-0093) and tenant isolation an always-first Layer 0 (ADR-0095). Teams *receive* sharing, never carry capability (ADR-0090 D8). | |
| 424 | + |
| 425 | +### Net assessment |
| 426 | + |
| 427 | +ADR-0086 is **done and correct**, not pending. Its provenance-axis classification and Shape-B |
| 428 | +composition became the load-bearing assumption of the whole permission-model-v2 wave: ADR-0090 |
| 429 | +cites "ADR-0086 D3 made machine-checkable" as the reason Profile could be removed and |
| 430 | +uninstall-by-`packageId` works; the companion `docs/design/permission-model.md` documents D3/D5 |
| 431 | +as the operative package/platform isolation boundary. The only corrections a reader needs are |
| 432 | +(1) the two superseded *mechanisms* above (store = ADR-0094 projection/overlay, not a raw shared |
| 433 | +record; subtract = ADR-0005 overlay, not muting) and (2) the ADR-0090 vocabulary (Position not |
| 434 | +Role, no Profile, `everyone`/`guest`). No decision here was reversed. Remaining genuinely-open |
| 435 | +items are the ones this ADR already marked deferred and evidence-gated: the ADR-0066 explicit-deny |
| 436 | +precedence layer, permission-set **groups** as a bundling primitive over Shape-B sets, and |
| 437 | +OWD/`sharingModel` env-tightening riding the overlay path (ADR-0005 still marks `security` types |
| 438 | +non-org-overridable by default; ADR-0094's overlay is the explicit, gated exception). |
0 commit comments