|
| 1 | +# ADR-0099: Posture-Adjudicated Tiering — one axis for tier decisions, the EXTERNAL rung's enforcement path, no explicit deny |
| 2 | + |
| 3 | +**Status**: Proposed (2026-07-17) |
| 4 | +**Deciders**: ObjectStack Protocol Architects |
| 5 | +**Builds on**: [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) (the posture ladder + Layer 0 — Accepted, implemented; this ADR is its adjudication follow-through), [ADR-0066](./0066-unified-authorization-model.md) (superuser bypass ①; precedence), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) (D10 principal taxonomy / `audience`, D11 external OWD), [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md) (membership lifecycle — where an external principal type would come from), [ADR-0094](./0094-sys-permission-set-pure-projection.md) (one-authority precedent) |
| 6 | +**Composes with**: [ADR-0096](./0096-execution-surface-identity-admission.md) — 0096 governs **admission** (may this call reach the engine, as whom); this ADR governs **tiering** (given an admitted principal, which tier of rows each layer grants). Orthogonal axes, deliberately separate ADRs. |
| 7 | +**Tracking**: framework#2920 (B-track follow-through) · #2947→#2956 (posture carried on `ExecutionContext` — the unblocking prerequisite) · #2946 Finding 2 (the divergence class this ADR closes) |
| 8 | +**Consumers**: `@objectstack/plugin-security` (Layer 0 exemption gate, superuser bypass, explain), `@objectstack/core` (`resolve-authz-context`, `posture-ladder`), `@objectstack/plugin-sharing` (EXTERNAL rung, when it activates), portal/external-identity work (ADR-0090 follow-up #6) |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | +## TL;DR |
| 13 | + |
| 14 | +ADR-0095 built the posture ladder (`PLATFORM_ADMIN > TENANT_ADMIN > MEMBER > |
| 15 | +EXTERNAL`) and #2956 made the derived rung ride the `ExecutionContext` into |
| 16 | +every enforcement entry point. But no enforcement decision *reads* it: each |
| 17 | +tier decision still re-derives "what kind of principal is this" from raw |
| 18 | +capability bits at its own site. That re-derivation is a bug class we have now |
| 19 | +hit twice — #2949 (explain re-derived posture and diverged from enforcement) |
| 20 | +and #2946 Finding 2 (the Layer 0 exemption keyed on `viewAllRecords`, which an |
| 21 | +org admin also holds, so org admins crossed the tenant wall). Two sites, two |
| 22 | +independent derivations, two divergences. |
| 23 | + |
| 24 | +Three decisions: |
| 25 | + |
| 26 | +1. **D1 — Posture is the single tier-adjudication input.** Every tier decision |
| 27 | + (Layer 0 exemption, Layer 1 short-circuit tier, explain's reported rung) |
| 28 | + reads the **carried** `ctx.posture`. Capabilities *derive* posture (ADR-0095 |
| 29 | + D3) — enforcement never re-derives tier from capability bits at the point of |
| 30 | + decision. The per-side superuser bits keep gating the per-side bypass |
| 31 | + (posture selects the *tier*; the read/write bit still gates the *side*). |
| 32 | + Behavior-preserving behind an equivalence gate in the authz matrix. |
| 33 | +2. **D2 — The posture → layer action map is normative.** One table (below) |
| 34 | + states what each rung means at each layer, with six invariants — most |
| 35 | + load-bearing: **TENANT_ADMIN never crosses Layer 0** (the #2946 Finding 2 |
| 36 | + lesson, promoted from a fix to an invariant), and **enforcement and explain |
| 37 | + read the same carried value** (the #2949 lesson). |
| 38 | +3. **D3 — The EXTERNAL rung gets its enforcement path, demand-gated.** |
| 39 | + Semantics were locked by ADR-0095 D2 (explicit shares only, never OWD). |
| 40 | + This ADR specifies the activation chain — `audience:'external'` (ADR-0090 |
| 41 | + D10/D11) → `derivePosture` returns `EXTERNAL` → the rung's injection rule — |
| 42 | + and lands the matrix cells *now* so the rung cannot drift before a portal |
| 43 | + feature exists. Implementation is gated on an external principal type |
| 44 | + (ADR-0093 membership), not on a date. |
| 45 | + |
| 46 | +**Rejected (D4)**: explicit deny / muting as an adjudication primitive. The |
| 47 | +ladder's monotonicity — visibility strictly nested down the rungs, misconfig |
| 48 | +can only narrow — is what makes the tier model provable and explain tractable; |
| 49 | +deny-composition reintroduces the #2836 conflict class. Recorded with the |
| 50 | +needs it would have served and where each is served instead. |
| 51 | + |
| 52 | +--- |
| 53 | + |
| 54 | +## Context |
| 55 | + |
| 56 | +### The carried-but-unread rung |
| 57 | + |
| 58 | +The chain today, end to end: |
| 59 | + |
| 60 | +- `resolve-authz-context.ts` derives the rung **once**, from capability |
| 61 | + grants (`derivePosture`, ADR-0095 D3) — landed with #2920 B2/B4. |
| 62 | +- #2956 (closing #2947) added `posture` to `ExecutionContextSchema` and made |
| 63 | + both transports (REST `rest-server.ts`, runtime |
| 64 | + `resolve-execution-context.ts`) carry it — so enforcement middleware now |
| 65 | + *receives* the derived rung. |
| 66 | +- **Nothing consumes it.** The Layer 0 exemption gate re-derives platform-ness |
| 67 | + from a capability probe (`hasPlatformAdminPosture`, |
| 68 | + `security-plugin.ts:2390` — checking `manage_metadata` / |
| 69 | + `manage_platform_settings` / `studio.access` / `manage_users`); the |
| 70 | + Layer 1 superuser bypass reads the raw super-bits; the explain engine reads |
| 71 | + `ctx.posture` when present but replicates the derivation when absent |
| 72 | + (#2949's patch). The comment above the probe |
| 73 | + (`security-plugin.ts:71-77`) still gives "the field is NOT plumbed" as the |
| 74 | + reason for probing — **that reason expired with #2956**; the probe is now a |
| 75 | + choice, not a necessity. |
| 76 | + |
| 77 | +### Why per-site re-derivation is a bug class, not a style issue |
| 78 | + |
| 79 | +Each site that re-derives "what tier is this principal" from raw bits is an |
| 80 | +independent chance to get the mapping wrong, and the two mappings drift |
| 81 | +independently: |
| 82 | + |
| 83 | +- **#2946 Finding 2** — the Layer 0 exemption keyed on |
| 84 | + `viewAllRecords`/`modifyAllRecords`. Both a platform admin *and* an |
| 85 | + `organization_admin` hold those via wildcard grants, so the bits cannot |
| 86 | + distinguish the two top rungs — and org admins crossed the tenant wall on |
| 87 | + `access.default:'private'` objects. The fix (the platform-exclusive |
| 88 | + capability probe) is correct but is itself a *third* derivation of the same |
| 89 | + fact. |
| 90 | +- **#2949** — the explain engine derived posture its own way and reported a |
| 91 | + rung enforcement did not use. Patched by preferring the carried value — a |
| 92 | + seam that only exists because enforcement doesn't read the same field. |
| 93 | + |
| 94 | +One derivation site (the resolver), one carried value, N readers — the |
| 95 | +divergence class closes by construction. This is the ADR-0094 one-authority |
| 96 | +rule applied to a computed fact at its point of use. |
| 97 | + |
| 98 | +### What this ADR is *not* |
| 99 | + |
| 100 | +- Not a new ladder — ADR-0095 D2's enum, nesting invariant, and derivation |
| 101 | + (D3) are unchanged. |
| 102 | +- Not admission — a context-less call is ADR-0096's problem (its D5 strict |
| 103 | + mode). This ADR assumes an admitted principal whose context the resolver |
| 104 | + built. |
| 105 | +- Not a change to *what* any rung can see today — D1/D2 are |
| 106 | + behavior-preserving by contract; only D3 (EXTERNAL) adds behavior, for a |
| 107 | + principal type that does not exist yet. |
| 108 | + |
| 109 | +## Decisions |
| 110 | + |
| 111 | +### D1 — Posture is the single tier-adjudication input |
| 112 | + |
| 113 | +Every enforcement-time **tier** decision reads `ctx.posture`: |
| 114 | + |
| 115 | +- **Layer 0 exemption** (`security-plugin.ts` tenant-wall gate): crossing |
| 116 | + requires `ctx.posture === 'PLATFORM_ADMIN'` **and** the object-posture |
| 117 | + conditions of ADR-0095 D1 (platform-global / `private` / better-auth-managed |
| 118 | + — unchanged). The capability probe (`hasPlatformAdminPosture`) is retained |
| 119 | + as **fallback only** for contexts that did not pass through the shared |
| 120 | + resolver (hand-built contexts at direct engine call sites — the population |
| 121 | + ADR-0096 D3 is progressively eliminating). When both signals are present |
| 122 | + the carried rung wins; a disagreement is logged as a defect signal, and the |
| 123 | + **narrower** verdict is enforced (fail-safe). |
| 124 | +- **Layer 1 short-circuit**: the *tier* question ("does business RLS apply at |
| 125 | + all inside the org") reads posture (`>= TENANT_ADMIN` short-circuits, per |
| 126 | + today's semantics); the *side* question keeps its per-side capability bit — |
| 127 | + `viewAllRecords` gates the read bypass, `modifyAllRecords` the write bypass, |
| 128 | + exactly as today. Posture never grants a side the bits don't. |
| 129 | +- **Explain** reports the carried rung it read — the same field, not a |
| 130 | + replica. The #2949 fallback derivation retires once the resolver-built |
| 131 | + context is guaranteed on the explain path. |
| 132 | + |
| 133 | +**Behavior contract.** D1 is behavior-preserving. The proof obligation is an |
| 134 | +**equivalence gate**: the authz matrix (`authz-matrix-gate.test.ts`) gains |
| 135 | +cells asserting, for every seeded principal shape, that the carried rung and |
| 136 | +the site-local probe agree — *before* any reader flips. Any cell where they |
| 137 | +disagree is a bug in one of the two derivations and must be resolved before |
| 138 | +the flip (that is the gate doing its job; #2946 Finding 2 is exactly the class |
| 139 | +it would have caught). |
| 140 | + |
| 141 | +### D2 — The posture → layer action map (normative) |
| 142 | + |
| 143 | +| Layer | `PLATFORM_ADMIN` | `TENANT_ADMIN` | `MEMBER` | `EXTERNAL` | |
| 144 | +|:---|:---|:---|:---|:---| |
| 145 | +| **Layer 0** (tenant wall) | exempt, where object posture permits (ADR-0095 D1) | **walled** — never exempt | walled | walled | |
| 146 | +| **Layer 1** (business RLS) | short-circuit (side-gated by super-bits) | short-circuit within org (side-gated) | business RLS applies (ownership / depth / sharing) | **not evaluated** — the rung's rule replaces it | |
| 147 | +| **OWD / sharing** | n/a (already all-rows in tier) | n/a within org | OWD baseline + shares + hierarchy widening | **explicit shares only — OWD and sharing *rules* never apply** | |
| 148 | +| **Write path** | tier does not bypass write guards: readonly strip (#2957), identity write guard (ADR-0092), Layer 0 write wall (#2946) all still apply | same | same | shares with edit access only | |
| 149 | +| **Explain** | reports the carried rung — identical input as enforcement, by construction | same | same | same | |
| 150 | + |
| 151 | +Invariants (each carries a matrix cell): |
| 152 | + |
| 153 | +- **I1** — `TENANT_ADMIN` never crosses Layer 0. No capability composition may |
| 154 | + re-open it; only the `PLATFORM_ADMIN` rung reaches the exemption gate. |
| 155 | +- **I2** — Visibility is strictly nested down the ladder within a tenant |
| 156 | + (ADR-0095 D2's invariant, now asserted at the adjudication site, not only at |
| 157 | + derivation). |
| 158 | +- **I3** — Posture is derived only by the resolver, only from capability |
| 159 | + grants (ADR-0095 D3); no enforcement site re-derives tier at the point of |
| 160 | + decision (fallback probe excepted, per D1, and it may only narrow). |
| 161 | +- **I4** — Enforcement and explain read the same carried value; a divergence |
| 162 | + is a defect, not a display issue. |
| 163 | +- **I5** — Posture selects tier, never side: no rung grants a read or write |
| 164 | + bypass that the per-side capability bit does not. |
| 165 | +- **I6** — Posture never bypasses system-context-only guards: `isSystem` / |
| 166 | + `SystemGrant` (ADR-0096 D2) remains the only write-path elevation; a |
| 167 | + `PLATFORM_ADMIN` human is not system. |
| 168 | + |
| 169 | +### D3 — The EXTERNAL rung's enforcement path (demand-gated) |
| 170 | + |
| 171 | +Semantics are already locked (ADR-0095 D2): an EXTERNAL principal sees only |
| 172 | +rows explicitly shared to it; OWD baselines and sharing *rules* never widen |
| 173 | +its visibility; misconfiguration can only narrow. What this ADR adds is the |
| 174 | +**activation chain** so the rung turns on as one seam, not a scatter: |
| 175 | + |
| 176 | +1. **Derivation**: when the resolved principal carries |
| 177 | + `audience: 'external'` (ADR-0090 D10; populated by the portal/external |
| 178 | + membership model when ADR-0093-track work delivers it), `derivePosture` |
| 179 | + returns `EXTERNAL`. Until such a principal exists the branch is dead code |
| 180 | + with live tests — identical staging to ADR-0095 D2's "defined and locked |
| 181 | + now, enforced later". |
| 182 | +2. **Injection rule**: at the sharing/RLS seam the `EXTERNAL` rung compiles to |
| 183 | + the explicit-share filter *instead of* the member pipeline (OWD → RLS → |
| 184 | + sharing-rule widening). Absence of a share row yields the deny sentinel — |
| 185 | + fail-closed, never fall-through to the member rule. |
| 186 | +3. **Write side**: edit requires an explicit share granting edit; the |
| 187 | + `owner`-type sharing rules currently marked |
| 188 | + `[experimental — not enforced]` (`sharing.zod.ts:104`, ADR-0049 marker) |
| 189 | + stay outside the EXTERNAL rung until separately enforced — an external |
| 190 | + principal's visibility never depends on an unenforced construct. |
| 191 | +4. **Matrix first**: the `EXTERNAL × layer` cells land with this ADR |
| 192 | + (asserting the dead branch's rule against fixture shares), so the first |
| 193 | + portal PR inherits a green gate it must keep green, rather than authoring |
| 194 | + the rung's semantics under feature pressure. |
| 195 | + |
| 196 | +Implementation is **gated on the external principal type existing** — the |
| 197 | +ADR-0073/0096 idiom: specify now, enforce when the first consumer arrives. |
| 198 | +The portal/licensing follow-up (ADR-0090 named follow-up #6) is the expected |
| 199 | +consumer. |
| 200 | + |
| 201 | +### D4 — No explicit deny / muting (Rejected as an adjudication primitive) |
| 202 | + |
| 203 | +ADR-0066 deferred "explicit deny"; this ADR closes the question with a **No** |
| 204 | +for the enforced model: |
| 205 | + |
| 206 | +- **Why**: the ladder's value is monotonicity — rung ⊇ rung, additive grants, |
| 207 | + misconfig can only narrow (I2). A deny primitive makes effective access |
| 208 | + order-dependent across sources, reintroduces the #2836 conflict class |
| 209 | + (deny from one track out-composing allow from another) that ADR-0095 D3 |
| 210 | + removed, and turns explain from "which grants admitted this row" into |
| 211 | + "which denials might have removed it" — the property that makes |
| 212 | + Salesforce-class models notoriously un-explainable. |
| 213 | +- **Where the real needs live instead**: "this tier must not touch X" → |
| 214 | + object/field `requiredPermissions` capability gates (ADR-0066 D3) and FLS |
| 215 | + `editable:false`; "this surface must not act" → ADR-0096 admission; "narrow |
| 216 | + a grant's rows" → narrowing filters within the grant itself; "retire a |
| 217 | + grant" → ADR-0091 validity windows. |
| 218 | +- **The one future door**: if permission-set *groups* ever land (deferred by |
| 219 | + ADR-0094), a group-scoped *muting* construct — subtractive **assembly** of a |
| 220 | + grant bundle before it enters adjudication — can be considered in that |
| 221 | + ADR. Assembly-time subtraction preserves adjudication monotonicity; |
| 222 | + adjudication-time deny does not. This ADR constrains that future: nothing |
| 223 | + subtractive enters the ladder itself. |
| 224 | + |
| 225 | +## Sequencing and gates |
| 226 | + |
| 227 | +Strictly serial, each step behind the matrix: |
| 228 | + |
| 229 | +- **P0 (with this ADR)** — equivalence + invariant cells in |
| 230 | + `authz-matrix-gate.test.ts` (D1's probe-vs-rung agreement; I1–I6; the |
| 231 | + EXTERNAL dead-branch cells). Pure test additions; no behavior change. |
| 232 | +- **P1** — flip the Layer 0 exemption gate and explain to read the carried |
| 233 | + rung (probe demoted to fallback; stale comment at |
| 234 | + `security-plugin.ts:71-77` retired). Behavior-preserving under P0's gate. |
| 235 | + Unblocked today (#2956); small. |
| 236 | +- **P2** — Layer 1 short-circuit reads posture for tier (side bits unchanged). |
| 237 | + Behavior-preserving under the gate. |
| 238 | +- **P3 (demand-gated)** — EXTERNAL derivation + injection when the external |
| 239 | + principal type ships (portal track). New behavior for a new principal type; |
| 240 | + no existing principal's visibility changes. If any breaking surface |
| 241 | + emerges in P1–P3 it rides the same major as ADR-0096's D5 flip — one |
| 242 | + breaking window for both authz-model majors. |
| 243 | + |
| 244 | +## Consequences |
| 245 | + |
| 246 | +**Positive** |
| 247 | +- The #2946-Finding-2 / #2949 divergence class ends by construction: one |
| 248 | + derivation, one carried value, N readers. |
| 249 | +- The posture → action map becomes a normative, matrix-locked artifact — the |
| 250 | + kernel-side property the explainable-authorization track (record-grained |
| 251 | + explain, Studio access panel) needs to be *true* rather than best-effort. |
| 252 | +- The portal/external product line inherits a fail-closed, pre-tested rung |
| 253 | + instead of authoring isolation semantics under deadline. |
| 254 | +- The deny question is closed, not deferred — future grant-model work has a |
| 255 | + recorded constraint instead of an open door. |
| 256 | + |
| 257 | +**Negative / accepted** |
| 258 | +- A transition period with two live signals (carried rung + fallback probe) |
| 259 | + and the discipline that the narrower wins; retired as ADR-0096 D3 shrinks |
| 260 | + the hand-built-context population. |
| 261 | +- More matrix cells to maintain (equivalence, invariants, EXTERNAL fixtures) — |
| 262 | + accepted; the matrix is the mechanism. |
| 263 | +- `EXTERNAL` stays dead code until a consumer ships — carrying spec'd-but-idle |
| 264 | + branches is a cost ADR-0049 staging accepts deliberately. |
| 265 | + |
| 266 | +**Explicitly unchanged** |
| 267 | +- Rung derivation (ADR-0095 D3), the enum, and nesting semantics. |
| 268 | +- Per-side superuser bits, system-context write guards (#2946/#2957, |
| 269 | + ADR-0092), and ADR-0096's admission machinery. |
| 270 | +- Physical isolation (ADR-0002) and `single`-mode inertness of Layer 0. |
| 271 | + |
| 272 | +## Alternatives considered |
| 273 | + |
| 274 | +- **Keep per-site capability probes** (status quo). Rejected: two shipped |
| 275 | + divergences in one release cycle is the empirical case; every new |
| 276 | + enforcement site re-answers "what tier is this principal" and drifts |
| 277 | + independently. |
| 278 | +- **Adjudicate directly on capability bits everywhere, drop the ladder.** |
| 279 | + Rejected: the bits cannot distinguish the two top rungs (#2946 Finding 2 — |
| 280 | + both hold `viewAllRecords`); the enum exists precisely because tier is not |
| 281 | + bit-expressible. |
| 282 | +- **Store posture on the principal record.** Already rejected by ADR-0095 |
| 283 | + (a second writable authority that drifts from grants); restated here |
| 284 | + because D1 makes the temptation stronger. |
| 285 | +- **Model EXTERNAL as a maximally-restricted MEMBER.** Already rejected by |
| 286 | + ADR-0095 D2 (fail-closed demands the *absence* of OWD/sharing sources, not |
| 287 | + a data-driven restriction of them); restated as it becomes load-bearing at |
| 288 | + activation. |
| 289 | +- **Explicit deny as a first-class grant type.** Rejected — see D4. |
| 290 | + |
| 291 | +## References |
| 292 | + |
| 293 | +- ADR-0095 (ladder, Layer 0, EXTERNAL semantics — Accepted) · ADR-0096 |
| 294 | + (admission; the composing axis) · ADR-0090 D10/D11 (audience, external OWD) |
| 295 | + · ADR-0093 (membership → external principal source) · ADR-0094 |
| 296 | + (one-authority precedent) · ADR-0066 (superuser bypass; deny deferred → D4) |
| 297 | + · ADR-0091 (validity windows) · ADR-0049 (specify-then-enforce staging). |
| 298 | +- Incidents: #2946 Finding 2 (org admin crossed the tenant wall — the probe's |
| 299 | + origin), #2949 (explain/enforcement posture divergence), #2836 (dual-track |
| 300 | + conflict class). |
| 301 | +- Landed substrate: #2920 B2/B4 (`posture-ladder.ts`, derivation), #2956 |
| 302 | + (posture carried on `ExecutionContext` — closes #2947), #2957 (readonly |
| 303 | + write guard, I6), cloud#835/#836 (multi-org / position-anchor e2e gates). |
| 304 | +- Code (current state): `packages/plugins/plugin-security/src/security-plugin.ts:2390` |
| 305 | + (`hasPlatformAdminPosture` probe; stale rationale at `:71-77`), |
| 306 | + `packages/core/src/security/posture-ladder.ts`, |
| 307 | + `packages/core/src/security/resolve-authz-context.ts` (derivation), |
| 308 | + `packages/plugins/plugin-security/src/explain-engine.ts` (carried-value |
| 309 | + preference), `packages/spec/src/security/sharing.zod.ts:104` (`owner`-rule |
| 310 | + experimental marker), `packages/spec/src/kernel/execution-context.zod.ts` |
| 311 | + (`posture` field). |
0 commit comments