Skip to content
Menu

Documentation

ADR-0009: Route policy follows authority and effect — deny-by-default, live sessions, no route classes

  • Status: Accepted (2026-10-25; owner decisions in "Decisions recorded")
  • Date: 2026-10-25 (revised the same day: author simplicity as the first criterion; risk moved from route tags to its source)
  • Deciders: Sandloom maintainers and the owner
  • Implementation brief: docs/design/route-policy-implementation-brief-2026-10-25.md (internal research, retained in the original private repository)
  • Amends: ADR-0003. It supersedes ADR-0003's "Operation classes (normative)" section, its route tables ("Platform routes → classes", "Platform owner console routes") and decision 9. Everything else in ADR-0003 stands:
  • membership caching options;
  • failure rules;
  • the TOCTOU remedies;
  • operation intents;
  • "Guarantees, precisely".
  • Related: ADR-0005 (effect model), ADR-0006 (accepted risks), DX study R2/R4.
  • Research: docs/design/operation-classes-research-2026-10-25.md (internal research, retained in the original private repository): the inventory, the survey, candidates A–E, the simplicity scorecard, the mistake catalog, the trade-offs and the sources.

Context

ADR-0003 has every route declare an operation class:

Class Meaning
P public
R ordinary read
RS sensitive read
W ordinary write
F financial
A admin or security

Next to the class sit the strict_revocation() / revocation_freshness(N) / revocation_at_commit() markers and AUTH_REVOCATION_STRICT_PATH_PREFIXES. The inventory on b231a5ca found the following.

  1. The class has no runtime effect. Of its three startup checks, two only demand a redundant marker: the live check is already the default, and budgets are refused on writes. The one real check is that F refuses a weak idempotency guard. An absent guard passes.
  2. The prose promises are unchecked:
  3. that non-P routes are authenticated;
  4. the F/A commit-time re-check;
  5. A audit;
  6. F idempotency;
  7. step-up outside identity.
  8. The letter mixes independent facts: the principal, the effect (already given by the method), risk, freshness, and the commit-time and idempotency needs. A typical admin route carries 4–6 policy lines; a payment route carries 3–4.
  9. Bounded staleness is unused and saves no round trip, because the block mark is read live anyway. The strict prefixes exist only to override it.
  10. There are two subject marks (tokens_valid_after, blocked) only because of that budget path.

The owner's overriding goal is that a module author or AI agent writes nothing beyond business logic in the typical case, with every mistake caught deterministically and a fix-naming error, and no guarantee weakened.

Best practice (research §2) points the same way. The strongest systems put each requirement in the layer that every path crosses:

  • a global auth default with an explicit public opt-out: Rails 8 allow_unauthenticated_access, NestJS @Public(), ASP.NET FallbackPolicy, Django LoginRequiredMiddleware;
  • revocation in the token-validation layer (CAE, introspection, Keycloak not-before);
  • recency on the credential flow: Ory privileged sessions, Phoenix and GitHub sudo mode;
  • policy on the entity (Ent / Meta privacy);
  • audit at the API platform (CloudTrail);
  • idempotency at the HTTP layer (Stripe, the IETF Idempotency-Key draft).

Decision

Defaults every route gets (nothing to declare)

  1. Deny by default.
  2. Every API route resolves an authenticator, @authenticates(kind="user" | "service"), or declares public(reason=...).
  3. Token issuers declare public(issues_tokens=True, reason=...); their live issuance check is unchanged.
  4. PSP webhooks are public(reason=...). The verified signature is their authorization, checked inside the endpoint, as today. They are not service principals.
  5. create_app refuses a route that is neither, or both.
  6. Live session check. Every user authenticator reads the subject-state record live: one GET, memoised per request. Fail-closed / fail-open follows AUTH_REVOCATION_FAIL_CLOSED, unchanged. There is no route-level freshness setting.
  7. At-commit re-check on every authenticated write (any method other than GET, HEAD or OPTIONS).
  8. Today's subject_commit_check runs before COMMIT of every UoW the request opens.
  9. Self-revocation is excluded by "check, then revoke" (detailed below), so a password change, self-deactivation, leaving an org or an ownership transfer works with no declaration.
  10. It travels in the MAC-covered RPC context to remote commands, whose provider registers the same check on its own UoW against the shared CACHE_URL.
  11. Local and remote behave the same. The guarantee is unchanged: serializable, not linearizable (ADR-0003).

Self-revocation, exactly. Let S be the request's authenticated subject (from the bearer) and U a UoW of that request with the at-commit check armed.

  1. When code in U calls revoke_user_tokens_on_commit(S, ...) or block_user(S, ...) (the only two writers of S's subject record), the kernel FIRST runs the at-commit check for S right there. That is a live read of the record, decided against the bearer's iat_us with the usual rules: 401 revoked / blocked, 503 on a fail-closed outage. It runs BEFORE this call writes anything, including the require_immediate=True early write.
  2. If the check passes, the kernel records S in U's self_revoked set. Only then does the revocation proceed: early write, outbox event, post-commit write.
  3. At COMMIT, the armed check skips every subject in U.self_revoked. Any other subject is checked as usual.
  4. A command contract that revokes S in its provider's UoW does the same in the provider, because the propagated context names S. Its response carries the revoked subjects (MAC-covered, as part of the result envelope; a local call shares the context variable). The calling UoW adds them to its own self_revoked, so its COMMIT is not refused by a mark the request itself caused.

Effect: a withdrawal by someone else that lands before the self-revoking call is still seen (401, rollback). The window becomes "self-revoking call → COMMIT" instead of "final read → COMMIT". That is the same serializable-not-linearizable guarantee: the revocation must already be the last fallible step of the UoW (AGENTS.md), so the window is the same length. The rule is deterministic: it depends only on calls made in this UoW, never on timing or mark values. A self-revoking route needs no opt-out. 4. Cache-Control: no-store on authenticated responses, unless the route sets its own. 5. Automatic durable idempotency. - Any write carrying Idempotency-Key is deduped by the durable guard (ADR-0005), scoped by route template and subject. - A route without a request DbSession gets the fail-closed in-flight form. - idempotency=False opts a route out (streaming). - An explicit idempotency_guard(...) remains for custom scopes.

Risk is declared at its source, once, by its owner

  1. Admin. The framework's admin gates carry risk="admin": require_org_role(≥ admin), require_platform_admin and require(Role("admin")). This is visible at startup.
  2. Money. The effect owner declares it once:
  3. @contract_method(command=True, risk="money");
  4. the MoneyLedger model mixin, which names its unique operation-key column;
  5. @sensitive_effect("money") on effect services (operation intents).

It is detected when the effect runs in a request, before COMMIT. 8. Security. The credential owner (identity) uses @sensitive_effect("security", step_up=300) on credential-changing services. mark_reauthenticated() after a successful password check satisfies the step-up in that request. Step-up is derived for security only (300 s). Admin gates and money effects carry no step-up default; an owner may still pass step_up= on a specific effect. 9. The escape hatch is sensitive("admin" | "money" | "security") in a route's dependencies when its authority check is hand-rolled. The marker already returns a dependency; do not wrap it in Depends.

These static tags (gates, risk= on contracts, MoneyLedger, @sensitive_effect, sensitive()) are what gates behaviour. Effects observed by tests are informational only (item 14).

What risk triggers (applied by the framework)

  1. Audit. One automatic audit row per sensitive write, in the request's UoW, required=True. It records the actor, route template, method, path parameters, risk and status. It never records bodies.
  2. Money dedupe is mandatory. The effect must sit under a dedupe record: the command's own key, the ledger row's operation key, or the request's Idempotency-Key (request_operation_key()).
    • A developer omission raises MoneyEffectWithoutDedupeError before COMMIT.
    • A client that sent no key gets 428 Idempotency-Key required.
  3. Step-up. Where an effect declares step_up=N, the auth_time claim (never iat) must be ≤ N s old, or the request must have called mark_reauthenticated(). Otherwise: 401 WWW-Authenticate: Bearer error="insufficient_user_authentication", max_age=N (RFC 9470). It is checked before the effect.

One subject-state record replaces the two marks

  1. The record is sandloom:auth:subject:{id}.
    • Values:
    • v1:after:<us>: tokens issued at or before <us> are revoked. It is written at now + skew, with TTL, monotonic, as today.
    • v1:blocked:<since_us>: no TTL. It orders above every after:, so a revocation never unblocks.
    • Unblock replaces blocked with after:<now+skew> by compare-and-set, after commit. It never deletes.
    • Unchanged: a corrupt value fails closed and the issuance check repairs it; block audit and event order; the durable revocation consumers; require_immediate=True.
    • Recovery: sandloom backup reconcile-security re-derives the single record.

The route snapshot

  1. Each module's routes.snapshot.json records the static resolved policy per route:

    • auth;
    • effect;
    • session;
    • static risk, with its source (gate, sensitive());
    • idempotency;
    • audit;
    • step_up_max_age_s.

    Drift fails the module's tests (sandloom routes snapshot --check). An observed_effects field (money / security effects that the module's own tests saw on that route) is informational only: it is written by --write, ignored by --check, and never gates a build or a request.

Removed from the internal API before the initial public alpha

The following are removed, and AUTH_REVOCATION_STRICT_PATH_PREFIXES moves to config_tiers.REMOVED with inert value []:

  • operation_class, the P/R/RS/W/F/A letters, OPERATION_CLASSES, STRICT_CLASSES, OperationClassError and assert_operation_classes;
  • strict_revocation(), revocation_freshness() and sandloom.core.auth.local_marks with its invalidation channel;
  • AUTH_REVOCATION_STRICT_PATH_PREFIXES;
  • identity's session_not_revoked;
  • revocation_at_commit(). Depends(at_commit()) stays only for a rare non-write route that commits;
  • FinancialGuardError, replaced by item 11;
  • the ADR-0003 route tables and test_operation_classes.py's EXPECTED, replaced by the per-module routes.snapshot.json holding the resolved policy (DX R2).

Checks (deterministic; each error names its fix)

Check Where Error
authenticated xor public() create_app RoutePolicyError ("add CurrentUser or a gate, or public(reason=)")
money effect under a dedupe record before COMMIT MoneyEffectWithoutDedupeError ("pass operation_key=request_operation_key(), or set the ledger operation key")
the client sent a key for a money effect per request 428
step-up before the effect 401 per RFC 9470
live session / at commit start / before COMMIT 401 invalid_token (revoked / blocked), or 503 on a fail-closed outage
audit store present before COMMIT AuditUnavailableError (unchanged)
remaining hand-placed guards after auth create_app GuardOrderError (unchanged)
hand-rolled role checks, or ledger-like models without a money tag sandloom check untagged-sensitive warning, waivable by id with a reason
snapshot equals resolved policy module tests "run sandloom routes snapshot --write"

Consequences

Author effort. These are policy lines beyond business logic. Auth dependencies and authorization gates count as business logic.

Route type Today After
ordinary read 1–2 0
ordinary write 2 (+ guard) 0
payment (calls a money command, or writes a money ledger) 3–4 0
admin (behind a framework gate) 4–6 0
public 1 1
policy concepts a route author must know ≈ 10 1 (public())
concepts for an owner of a sensitive effect — +2 (risk= on the effect, and its dedupe)

Mistakes:

  • Now impossible: an accidental unauthenticated route (it is refused at startup), a weakened session check, a forgotten audit, a missing cross-service commit re-check, the self-revocation at-commit trap, a cached sensitive response, and split/monolith divergence.
  • Caught at runtime before COMMIT, deterministically: a money effect without dedupe.
  • Residual (heuristic warning plus snapshot review): a hand-rolled admin check, or an owner forgetting to tag a new money effect. This is once per effect, not per route.

Hot path:

Request Today After
authenticated read one MGET of two keys one GET of one key
authenticated write one MGET (+1 if opted in) one GET + one GET before COMMIT
keyed write the guard's statements, if declared two statements in the UoW
sensitive write as a write as a write + one audit INSERT
step-up — none (a claim check)

Guarantees.

  • Stronger:
  • authentication presence;
  • mandatory money dedupe on every path to the effect, where today a route without a guard passed;
  • audit;
  • the cross-service commit re-check on all writes;
  • split equality;
  • a single subject record.
  • Unchanged: everything in ADR-0003 "Guarantees, precisely", operation intents, row-lock strict serializability, the fail-closed policy, and durable revocation delivery.
  • Given up: bounded revocation staleness. It is unused and saved no round trip; research §4.7 has the bar for reintroducing it.

Trade-offs taken (research §3.8):

  • T1: one more Redis GET per authenticated write, for zero concepts.
  • T2: money-dedupe omissions surface when the effect first runs (in a test), not at boot. The guarantee holds on every execution.
  • T4: keyed writes pay two statements.

Phased plan (input to DX R4)

  1. Subject record, and delete the freshness knobs.
  2. Deny-by-default: @authenticates(kind=) and public().
  3. Write and response defaults: at-commit on writes (self-skip, RPC propagation), no-store, automatic keyed idempotency.
  4. Sourced risk:
  5. gates, risk= on contracts (and in the contract snapshot), MoneyLedger and @sensitive_effect;
  6. automatic audit, money dedupe and step-up;
  7. sandloom check untagged-sensitive;
  8. delete operation_class;
  9. tag the platform's effects (billing credits, ledger and intents; identity credential services).
  10. Snapshot (with DX R2): the static resolved policy (the gate), plus informational observed effects. Delete the ADR-0003 tables and EXPECTED.
  11. Performance measurement.
  12. cachegrind: the authenticated read rung (L6S5) and a new authenticated write rung, before and after.
  13. The Oracle qualification (benchmarks/qualification/oracle_perf.py): /s5/, /auth/me and a write route.

This quantifies the session-record change on reads (MGET → GET, the budget branch removed) and the added before-COMMIT GET on writes. The numbers are recorded in this ADR's consequences.

Each phase ships with failing-first tests, keeps the split-conformance matrix and the effect-model suites green, and shows that the derived policy of every platform route is at least as strict as today's explicit markers.

Alternatives considered

Alternative Why not
(A) keep the letters, derived (DX R4 "class implies strictness") Cleans up the redundant marker only. Admin routes still need letters, an F left at W skips its check, and nothing checks auth presence, the commit-time re-check or audit.
(B / B′) orthogonal declarations with a per-router risk tag This was the first draft of this ADR. It is sound, but the risk tag is a per-router declaration whose omission is silent, and audit and idempotency become rules the author can break. E keeps B′'s foundations and removes those declarations.
(C) a route → policy file Drifts from the code and breaks module isolation; the generated snapshot gives the same review surface.
(D) a policy engine (OPA / Cedar) for route requirements The wrong layer. Such engines decide resource access, which the authz algebra already does; these requirements are endpoint and effect properties.
Macaroons / Biscuit (freshness in token caveats) More concepts for every client and service; the single live GET already gives next-request revocation.
Keep bounded staleness with the merged record Deferred until a measured case exists.

Decisions recorded (owner, 2026-10-25)

  1. At-commit re-check on every authenticated write: yes. Self-revoking routes are excluded automatically by the "check, then revoke" rule (Decision 3).
  2. Automatic durable idempotency on every write carrying Idempotency-Key: yes. Money dedupe stays mandatory (Decision 11).
  3. Step-up: 300 s for security only, for now.
  4. Observed effects in the route snapshot: informational only, never a gate. Static risk tags are the gate (Decision 14).
  5. Bounded staleness budgets are deleted. A performance measurement step is added (phase 6).
  6. PSP webhooks: public(reason=...) plus signature verification (Decision 1).
  7. A data risk for personal-data exports: a follow-up, not in this ADR.
  8. An OpenID Shared Signals / CAEP receiver: roadmap, not now.

Follow-ups (not part of this decision)

  • data risk (personal-data exports: read-audit, step-up).
  • An inbound OpenID SSF/CAEP receiver (session-revoked, credential-change) that writes the subject record (identity edge adapter).
  • Reintroducing bounded staleness, only under research §4.7's three conditions.