Skip to content

Commit a34b11c

Browse files
os-zhuangclaude
andauthored
docs(adr): ADR-0099 — posture-adjudicated tiering, EXTERNAL enforcement path, no explicit deny (#3109)
Follow-through on ADR-0095: the derived posture rung is carried on the ExecutionContext since #2956 but no enforcement decision reads it — each tier decision re-derives principal tier from capability bits at its own site, the divergence class behind #2946 Finding 2 (org admin crossed the tenant wall) and #2949 (explain/enforcement posture split). - D1: posture becomes the single tier-adjudication input (Layer 0 exemption, Layer 1 short-circuit tier, explain); capability probe demoted to narrowing fallback; behavior-preserving behind an equivalence gate in the authz matrix. - D2: normative posture → layer action map with six invariants (TENANT_ADMIN never crosses Layer 0; enforcement and explain read the same carried value; posture selects tier, never side; never bypasses system-only write guards). - D3: EXTERNAL rung activation chain (audience:'external' → derivePosture → explicit-shares-only injection), matrix cells land now, implementation demand-gated on an external principal type (portal track). - D4: explicit deny/muting rejected as an adjudication primitive; needs mapped to capability gates / FLS / admission / validity windows; assembly-time muting reserved to a future permission-set-groups ADR. Composes with ADR-0096 (admission vs tiering — orthogonal axes). Number 0099 verified free of open-PR claims before push. Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs Co-authored-by: Claude <noreply@anthropic.com>
1 parent 06d4eed commit a34b11c

1 file changed

Lines changed: 311 additions & 0 deletions

File tree

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
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

Comments
 (0)