← Back to home

Auth, currentUser, and permissions

Loom systems can declare a strongly-typed JWT claim shape at system scope and opt deployables in to JWT-decode middleware per request. Modules can declare a typed permission catalogue used to gate command entry from inside operation / workflow bodies. Repository finds can reference currentUser to scope query results to the requester.

Shipped over four slices:

Default-deny enforcement is the language default (M-T3.1): an auth { … } block that writes no enforcement: is in enforcement: denyByDefault. The pre-flip posture, enforcement: opt (only the members that declare a requires are gated; everything else serves any authenticated caller), is still available — write it explicitly. A project that relied on the old default keeps its behaviour by running the codemod, which writes enforcement: opt into every auth block that names none:

node scripts/codemod-enforcement-opt.mjs <dir-or-file>...   # --check / --list write nothing

See migrations.md § The enforcement: default flip. A system with no auth { … } block (a user { … } claim shape served by the stub verifier) has no enforcement posture before or after the flip — nothing is checked there. Under denyByDefault, every client-reachable command AND read on an auth: required deployable must declare a requires gate — requires true is the explicit "intentionally public" escape — else loom.default-deny-ungated fires. Covered:

These gates are proven to DENY, not just to compile. A guard emitted as a no-op passes every compile tier and every single-principal e2e identically — that is how each of the read gates above shipped open on some subset of backends. test/fixtures/corpus/read-gates.ddd closes that with two halves that only mean something together: its test e2e drives all three read surfaces with the principal the gate names (so an always-deny guard fails), and the authorization ladder (AUTHZ_LADDERS in test/behavioral/cases.mjs) drives the same surfaces with an authenticated-but-unauthorized principal and demands 403 (so a no-op guard fails). Both run per-PR on all five backend legs — which is the point, since the list-read drop was a java/python/elixir defect that a node-only probe could not have seen. See test/behavioral/README.md § "Principals — and the authorization ladder".

Migration: the synthesised reads fail closed (M-T3.19)

Under denyByDefault, the two compiler-derived reads of every aggregate — the list GET /api/<plural> and the by-id GET /api/<plural>/{id} — are refused at runtime with 403 on all five backends (evaluated before the load — no existence oracle) unless the aggregate has a read gate. To keep serving them, write the gate (requires true for intentionally public):

policy { read Order requires currentUser.role == "clerk" }

…or set enforcement: opt on the auth block to keep the pre-flip posture (nothing is denied by default, so the reads serve as before).

The build does not break for one release. Each ungated read raises a warning that names the policy line to write — loom.default-deny-list-ungated for the list read and loom.default-deny-by-id-ungated for the by-id read. Both warnings become errors in a later release (target ~2026-11-01), so treat them as a to-do list now. A declared find of an aggregate with no read gate is already an error (loom.default-deny-ungated).

Migration: find … requires moved to the policy block

A repository find no longer carries a requires clause — find … requires does not parse. Who may read an aggregate is an access fact, not a property of one collection query, so it lives beside the allow / deny ladder and covers every read at once (D-READ-GATE-IN-POLICY). The codemod moves each gate:

node scripts/codemod-find-gate-to-policy.mjs <dir-or-file>...   # --check / --list write nothing; --embedded also rewrites .ts/.md/.yml
// before
repository Orders for Order {
  find all(): Order paged requires currentUser.role == "clerk"
  find byId(id: Order id): Order? requires currentUser.role == "clerk"
}

// after
repository Orders for Order {
  find byId(id: Order id): Order?
}
policy { read Order requires currentUser.role == "clerk" }

It deletes a find left identical to the one the compiler injects (find all(): T paged) and keeps every other find, so the wire surface is unchanged. Where one repository's finds carried different gates, there is no one-to-one translation — the gate is per aggregate now; the codemod keeps the list read's gate, writes a review comment naming the dropped ones, and reports the file. Two reads of one aggregate that must answer to different principals belong on different aggregates (or behind an exposure gate: scaffoldPaged(requires:), a route / handler requires).

A natural-key read (Orders.getBy(code: c) / Orders.findBy(…), D-REPO-KEY-LOOKUP) has no route of its own, so the policy read gate never sits on it: it runs inside a body, and that body's exposure gate (a routed handler's requires, a workflow create's requires) decides who reaches it. A single-row find that only existed to be called from a body migrates with node scripts/codemod-find-to-findby.mjs <path> (to findBy / getBy, the by-id reads, or a criterion read with Repo.find(C)) — the gate it used to carry already lives in policy { read … }.

What's intentionally not here yet:

Surface

system Acme {
  user {
    id: string
    role: string
    permissions: string[]          // populated by the verifier hook from JWT claims
    customerId: Customer id?
    tenantId: string
  }

  subdomain Sales {
    permissions {
      ordersConfirm,
      ordersCancel,
      ordersRead
    }

    context Orders {
      enum OrderStatus { Draft, Confirmed, Cancelled }

      aggregate Customer { name: string }

      aggregate Order {
        customerId: Customer id
        status: OrderStatus

        // currentUser is in scope inside operation bodies.  The
        // precondition runs per request; failure throws
        // DomainException → 422 from the framework filter.
        // permissions.ordersCancel lowers to the literal
        // "sales.ordersCancel" so the runtime check reduces to
        // a plain string-array .includes(...) on the verified
        // claim payload.
        operation cancel() {
          precondition currentUser.role == "manager"
                    || (currentUser.customerId == this.customerId
                        && currentUser.permissions.contains(permissions.ordersCancel))
          status := Cancelled
        }
      }

      repository Orders for Order { }
    }
  }

  // Per-deployable opt-in.  Without `auth: required` the deployable
  // stays open (existing behaviour).
  storage primary { type: postgres }
  resource ordersState { for: Orders, kind: state, use: primary }

  deployable api {
    platform: dotnet
    contexts: [Orders]
    dataSources: [ordersState]
    port: 8080
    auth: required
  }
}

Computed principal members — orgPath, rootOrg

Beyond the declared user { … } claim fields, currentUser carries two computed members under multi-tenancy (Phase 2):

Member Type Meaning
currentUser.orgPath string The caller org's materialized path in the tenant registry tree (root.child.grandchild). Under flat tenancy it equals the tenant claim; under a hierarchy (implements tenantRegistry) each backend resolves it from the registry per request, memoized.
currentUser.rootOrg string The first segment of orgPath — a pure string derivation, no DB read. Anchors the global read level's subtree widening.

Both are fail-closed: referencing either without a tenancy by declaration is a validation error (loom.orgpath-without-tenancy). Full semantics, per-backend seams, and the policy {} read ladder they feed live in tenancy.md.

The system principal — currentUser.isSystem

An event reactor (a workflow's event-triggered create(e) by … starter or on(e) subscription — including one a timerSource tick or a broker consumer drives) has no request principal. It runs as the system principal (D-REACTOR-SYSTEM-PRINCIPAL): currentUser.isSystem is true (it is false on every request principal), every declared claim is EMPTY, the tenancy claim and orgPath are the triggering event's, and a runtime-only causedBy records the originating user's id for audit and logs. Gates are evaluated against it normally, so a gate that should admit a reactor says so — requires currentUser.isSystem || currentUser.permissions.contains(…) — and a reactor reaching a gate that reads currentUser without an isSystem disjunct is warned at compile time (loom.reactor-gate-unsatisfiable). isSystem / causedBy are reserved claim names (loom.user-reserved-field). The request principal's wire shape (/auth/me) is unchanged. Per-backend emission: language-reference/17-auth.md § Reactors.

Permissions surface

permissions { ... } lives at subdomain scope; each declared name becomes a typed identifier (permissions.<name>) usable in any expression body that resolves through the enclosing subdomain. The identifier lowers to a plain string literal of the form <lowercase-subdomain>.<name> — permissions.ordersCancel inside subdomain Sales becomes "sales.ordersCancel" everywhere it appears. Backends never see a separate Permission type; the runtime is string[].includes(string) either side of the wire.

Multiple permissions { ... } blocks in the same subdomain merge their declarations. Cross-subdomain references aren't supported in slice 1B — referencing a permission declared in another subdomain shows up as the same "no permission named 'X'" diagnostic as a typo.

implies — permission grant hierarchy (authorization.md §6)

A permission may declare that holding it transitively grants one or more others. Single target omits the brackets; multiple targets require them:

permissions {
  read,
  edit   implies read,        // holding `edit` grants `read`
  approve implies edit,       // … and `approve` grants `edit` (so also `read`)
  admin  implies [read, edit] // fan-out
}

The transitive closure is precomputed at lowering; the runtime check stays a flat membership test. A gate currentUser.permissions.contains(permissions.read) expands to an OR over read plus every permission that (transitively) implies it, so a caller holding approve or edit satisfies the read gate without the token carrying read:

// generated (Hono) — `read` gate on the `edit implies read`, `approve implies edit` catalogue
if (!((currentUser.permissions).includes("m.read")
   || (currentUser.permissions).includes("m.approve")
   || (currentUser.permissions).includes("m.edit"))) throw new ForbiddenError("Forbidden: …");
Diagnostic When
loom.permission-implies-unknown an implies target names no permission declared in the subdomain
loom.permission-implies-self a permission implies itself (a no-op)

A mutual-implication cycle (a implies b, b implies a) is allowed — the two simply grant each other; the closure computation is cycle-safe.

Policy decision-id (audit seam)

Every authorization gate has a stable, deterministic decision-id (policyDecisionId(qualifiedTarget, gateSource) → pd_<8-hex>), derived purely from the gate's identity so an audit entry can reference which decision authorised an action and still resolve after a regeneration. Gates are already IR-inspectable (each requires lowers to a requires StmtIR carrying its source), so the id is derived on demand, not stamped. Its consumer — the strict-tier AuditRecord.policyDecisionId — lands with the audit-promotion mission; the stable formula is the seam that mission plugs into.

.contains(x) on arrays

Slice 1B introduces .contains(x) as a collection op (joining count, sum, all, any, where, first, firstOrNull):

Backend Renders to
TypeScript array.includes(value)
C# / .NET array.Contains(value) (LINQ)
Python value in array
Java array.contains(value)
Elixir value in array

It's available on any array — not just currentUser.permissions — so the same vocabulary covers any membership check the domain needs.

Row-level visibility (slice 1C)

currentUser is admissible inside a criterion body (and a single-row find's where); the renderer reads the resolved User inside the generated repository method:

repository Orders for Order { }
criterion OrdersMine of Order = customerId == currentUser.customerId

What gets emitted:

A queryHandler runs it with Orders.run(OrdersMine()):

// db/repositories/order-repository.ts (Hono)
const ordersMineCriterion = () => eq(schema.orders.customerId, requireCurrentUser().customerId);

async runFindAllByOrdersMine(page?: { offset?: number; limit?: number }): Promise<Order[]> {
  let query = this.db.select().from(schema.orders).where(ordersMineCriterion()).orderBy(asc(schema.orders.id)).$dynamic();
  // …
}

The Drizzle / EF predicate translates currentUser.customerId into a closure-captured value — Drizzle parametrises it into the SQL bind, EF lifts it via Linq-to-Entities — so no string interpolation hits the SQL surface.

Slice 1C does not yet support workflow bodies calling such reads; the validator points users at getById (with an explicit id parameter) or asks them to call the user-aware read from the route layer.

requires clauses (slice 2)

requires <expr> is a declarative authorization gate at the top of an operation or workflow body. Failure surfaces as HTTP 403, distinct from precondition's 422 — the two are deliberately separate so domain validity (state) and authorization (caller) don't share an error class:

operation cancel() {
  requires currentUser.role == "manager"
        || currentUser.permissions.contains(permissions.ordersCancel)
  precondition status != "cancelled"
  status := Cancelled
}
Statement Maps to Failure means
precondition HTTP 422 (DomainException / DomainError) The request is well-formed but the aggregate state is invalid for this op (RFC 9110 §15.5.21; see RS-15 in conformance-semantics.md). 400 is reserved for a genuinely malformed body.
requires HTTP 403 (ForbiddenException / ForbiddenError) The caller isn't authorized to invoke this op.

Both type-check to bool and may reference currentUser, permissions.<name>, parameters, this.<field>, and any declared function. requires is admissible in workflow bodies too; the workflow handler / route handler maps it to 403 the same way as the operation route does.

Guarded state transitions — close the generic-update side door

A requires gate guards its own route. It does not make the field the operation writes unwritable through some other route — and on an aggregate carrying crudish, there is another route: the synthesised generic update(...), which assigns every writable update field.

aggregate Claim with crudish {
  status: ClaimStatus                    // ← editable by default
  operation approve() {
    requires currentUser.permissions.contains(permissions.claimsApprove)
    precondition status == UnderReview
    status := Approved
  }
}

POST /claims/{id}/approve is gated. POST /claims/{id}/update {"status":"Approved"} reaches the same column at whatever gate the update carries, skipping the requires and the precondition — a state-machine bypass, not only an authorization one. with crudish(requires: <Policy>) does not close it either: that gate is per-member and identical across create/update/destroy, while the update still writes every field.

The fix is on the field, not the gate — mark it immutable:

  status: ClaimStatus immutable

immutable is a wire constraint (read ✓ / create ✓ / update ✗), so the generic update loses the field while approve() keeps assigning it; see language.md → "Field access modifiers". Loom flags the shape for you: a field that a gated operation assigns AND crudish's update mass-assigns raises the warning loom.update-gate-suggestion (counted among ddd parse's warnings; never an error). "Gated" is any of three shapes, because the bypass is the same for each:

Operation carries Example Bypass via update
requires operation approve() { requires … ; status := Approved } skips the 403 gate (and any precondition)
when operation close() when status == Resolved { status := Closed } skips the 409 state gate — no auth involved
precondition reading the field (and no parameter) operation resolve() { precondition status == Open; status := Resolved } skips the 422 transition check

immutable closes the update half only: the field stays on the create input, so a client can still start an instance in any state. For a state field with a default, managed (status: TicketStatus managed = New) closes both — the warning says so for the when/precondition shapes.

Header requires clause (authorization.md §11.3)

The gate can also ride the declaration header — the write-side twin of the policy read X requires gate. It relocates the authorization decision out of the body onto the signature, and is exactly equivalent to a first-body requires statement (same 403, same pre-body evaluation, same scope):

// header form — reads as part of the signature
operation cancel() requires currentUser.role == "manager" {
  precondition status != "cancelled"
  status := Cancelled
}

// a workflow starter carries it too
workflow Fulfil {
  create open(ref: string) requires currentUser.role == "ops" { … }
}

On an operation it sits after the return type and before when; it is evaluated post-load (against the loaded this instance), so it may reference the resource, the operation params, and currentUser. A header gate and a first-body requires are the same thing by the time a backend sees them — lowering prepends the header form to the body as a synthetic requires statement — so both emit the identical guard.

Where the guard lands

The gate is not domain logic, and it is not emitted into the aggregate. A precondition (→ 422) says the aggregate is in an invalid state; a requires (→ 403) says the caller may not issue the command. The second is an application-layer decision, so the leading run of requires statements is hoisted out of the entity and evaluated by whatever calls it — the HTTP handler, or a workflow's / explicit handler's inline op-call:

operation cancel(reason: string) requires currentUser.role == "manager" && reason != "" {
  status := Cancelled
}
// generated (Hono) — http/order.routes.ts, post-load and pre-call
const aggregate = await repo.getById(Ids.OrderId(id));
if (!(currentUser.role === "manager" && body.reason !== "")) throw new ForbiddenError("Forbidden: currentUser.role == \"manager\" && reason != \"\"");
aggregate.cancel(body.reason);

// domain/order.ts — no gate, no principal parameter
public cancel(reason: string): void {
  this._status = "cancelled";
  this._assertInvariants();
}

What the 403 says (ruling D4, eval-closure #20). The thrown message names the failed gate, and every backend writes it to its forbidden log line. Whether the RFC 7807 body repeats it depends on the verifier the deployable ships:

Verifier 403 detail Server log forbidden line
dev stub (auth: required, a user { … } block, no auth { oidc … }) Forbidden: currentUser.role == "manager" && reason != "" same text
a real verifier (auth { oidc … }), or no verifier at all Forbidden Forbidden: currentUser.role == "manager" && reason != ""

So a production caller learns that it was forbidden, not which predicate it failed; the dev stub keeps the echo because naming the gate is the useful answer while you are wiring claims. One helper per backend decides it from the same two facts that pick the verifier (echoesDenialDetail, src/ir/util/denial-detail.ts): node's ForbiddenError.detail (read by every onError arm), .NET's DomainExceptionFilter, Java's ApiExceptionAdvice, Python's install_error_handlers, and Phoenix's ProblemDetails.problem_response/4 (plus the LiveView create form's flash).

Consequences worth knowing:

This is how all five backends behave, by two different routes. Node, .NET, Java, and Python needed the hoist: each emitted the 403 inside the aggregate method, so the gate moved out to the caller — the route handler / command handler / service, plus the inline op-call inside a workflow or explicit handler, because a gate that only the HTTP path evaluates is a gate a saga can walk around.

Phoenix/Elixir was already right, and by a better factoring. Its guards (requires, precondition, and the when gate alike) lift into a leading with :ok <- ensure(…) chain on the context function — Phoenix's application layer — leaving the Ecto schema a plain data struct. Because every caller goes through the context function, enforcement is inherited rather than re-emitted per call site:

# lib/<app>/<context>.ex — the context function owns the gate
def close_ticket(%App.C.Ticket{} = record, params, current_user \\ nil) do
  with :ok <- ensure(current_user.role == "agent", {:forbidden, "Forbidden: currentUser.role == \"agent\""}) do
    ...
  end
end

A workflow create gate scopes to currentUser + the starter's command params (a saga has no aggregate this) and renders identically.

currentUser types against the system's user { … } claim block, so a gate that is a bare boolean claim expression — the simplest permission check — type-checks on its own:

operation close() requires currentUser.permissions.contains(permissions.ticketsClose) { … }

No surrounding == … / && … is needed to satisfy the bool requirement: currentUser.permissions types as the claim's declared string[], so the .contains(…) membership types as bool.

Default-deny is the language default for an auth { … } block (see the note at the top). Under an explicit enforcement: opt a deployable on auth: required still serves any operation that doesn't declare a requires gate — Slice 2's original behaviour, and the language default until M-T3.1.

The canonical create / destroy gate

requires is legal inside the canonical lifecycle actions too, and it is enforced the same way — 403, at the caller. The two halves differ in one respect only, and it is the receiver:

aggregate Shipment {
  reference: string
  quantity: int = 0

  create(reference: string) {
    requires currentUser.permissions.contains(permissions.manage)
  }

  destroy {
    requires currentUser.permissions.contains(permissions.manage) && quantity == 0
  }
}
// generated (Hono) — the create route: gate, THEN the factory
const currentUser = (c as unknown as { get(k: "currentUser"): User }).get("currentUser");
if (!((currentUser.permissions).includes("ops.manage"))) throw new ForbiddenError("Forbidden: currentUser.permissions.contains(permissions.manage)");
const created = Shipment.create({ reference: body.reference, quantity: body.quantity });

// …and the destroy route: load (the 404 probe), gate, THEN delete
const __loaded = await repo.getById(Ids.ShipmentId(id));
if (!((currentUser.permissions).includes("ops.manage") && __loaded.quantity === 0)) throw new ForbiddenError("Forbidden: …");
await repo.delete(Ids.ShipmentId(id));

When the body comes from a macro, that gate statement is not yours to write — and neither create nor destroy has a header requires clause to fall back on. Hand the macro a named policy instead: with crudish(requires: <Policy>) splices requires <Policy>() first in each emitted create / update / destroy body, and softDelete / softDeleteByDefault take the same parameter. Nothing is inherited — the rule stays visible at the with call site. See scaffold-macros.md.

Placement per backend is each one's own chokepoint: the route (Hono, FastAPI), the Mediator command handler (.NET — its controller is a thin dispatch), the service (Java), and the context function (Phoenix). Phoenix's placement is load-bearing rather than stylistic: it is the only backend whose frontend runs in-process, and its scaffolded LiveView calls <Ctx>.create_<agg> and <Ctx>.destroy_<agg>! directly, so a controller-level gate would have a second front door. The principal is threaded as an explicit argument (a LiveView is a separate process from the HTTP request, so the plug's conn.assigns is not reachable from it), and a nil principal denies rather than raising:

# lib/<app>/<context>.ex — every REQUEST-side caller passes through this
def create_shipment(attrs, current_user \\ nil) do
  with :ok <- ensure(not is_nil(current_user) and (Enum.member?(current_user.permissions, "ops.manage")),
                     {:forbidden, "Forbidden: currentUser.permissions.contains(permissions.manage)"}) do
    create_shipment_unguarded(attrs)
  end
end

@doc "Create a Shipment with NO authorization gate — the in-process entry."
defdelegate create_shipment_unguarded(attrs), to: App.Warehouse.ShipmentRepository, as: :insert

The seam splits, because not every caller is a request. A workflow factory-let step, an event dispatcher, and the emitted integration tests all create aggregates IN-PROCESS, with no request and no principal — and on the other four backends a workflow body calls the domain factory directly, so the aggregate's create gate never applies there at all. Routing those through the guarded seam denied (nil principal) a workflow whose own caller did hold the permission: the same .ddd answering 200 on four backends and 403 on one.

So there are two entries, and the naming is the safety property:

function who calls it
create_<agg>/2 (the plain name) the request-side doors — controller, LiveView form, DestroyForm
create_<agg>_unguarded/1 the in-process callers — workflow step, event dispatch, emitted integration test

The guarded one keeps the obvious name and delegates through the unguarded one, so a caller that guesses create_<agg> gets the gate and there is exactly one write path; bypassing authorization is something a call site has to say in a word that shows up in review. A workflow's own authorization is its own requires gate, evaluated where the request is — re-checking the aggregate's create gate underneath it would make Phoenix enforce a rule the other four do not, and would fail closed for every principal-less internal caller (a timer, a seed, a saga). delete_<agg> splits the same way for a workflow destroy step.

An event-sourced lifecycle guard: supported on elixir, refused on the other four. Placement decides it, so this is a per-backend fact rather than a property of event sourcing. Phoenix hoists a lifecycle gate to the context function — create_<agg>(attrs, current_user \\ nil), with the controller passing the real principal — so an eventLog aggregate's create ... { requires ... } binds and enforces there like any other gate. The other four render the ES create body into the domain _init, which has no principal in scope: currentUser is a free identifier, so the guard does not deny, it does not compile. loom.lifecycle-guard-event-sourced refuses it naming the hosting backends that cannot enforce it, and points at the caller (the named operation / workflow that issues the create) instead.

Two details worth knowing:

Named policy functions (P3.2)

A named policy function names a reusable requires predicate once so a non-trivial gate isn't re-typed at every operation it guards. It is a context-level declaration —

policy <Name>(<params>): bool ( = <expr> | { <expr> } )

— an ambient boolean predicate (it sees currentUser, its own parameters, permissions.<name>, enum values, and sibling policy functions / criteria; it has no candidate row — pass row fields in as arguments). Parentheses are required (even for zero parameters) so the parser distinguishes the function form from the policy {} read-ladder block (tenancy).

subdomain Sales {
  // `permissions { … }` is a SUBDOMAIN member — the catalogue is the
  // permission namespace, and `sales.approve` below is its qualified name.
  permissions { approve, manage }

  context Orders {
    enum OrderStatus { Draft, Approved }

    policy CanApprove(cap: money): bool =
      currentUser.permissions.contains(permissions.approve) && cap <= 10000
    policy IsManager(): bool { currentUser.permissions.contains(permissions.manage) }

    aggregate Order {
      amount: money
      status: OrderStatus
      operation approve() {
        requires CanApprove(amount)   // ← argument bound to the parameter
        requires IsManager()
        status := OrderStatus.Approved
      }
    }
    repository Orders for Order { }
  }
}

A requires PolicyName(args) reference is inlined at the gate (the argument substituted for the parameter), exactly like a criterion … of bool reference (see docs/criterion.md). Because the result is an ordinary boolean gate expression, every backend enforces it through the same requires → 403 path — no new render code. The generated approve body:

// node / Hono
if (!((currentUser.permissions).includes("sales.approve") && this._amount.lte(new Decimal("10000"))))
  throw new ForbiddenError("Forbidden: CanApprove(amount)");
if (!((currentUser.permissions).includes("sales.manage")))
  throw new ForbiddenError("Forbidden: IsManager()");
// .NET / EF
if (!((currentUser.Permissions).Contains("sales.approve") && this.Amount <= 10000m))
    throw new ForbiddenException("Forbidden: CanApprove(amount)");

Composition falls out of the ordinary boolean operators (requires IsManager() && CanApprove(amount)), like criteria.

Diagnostic When
loom.policy-fn-return-type the return annotation is not bool
loom.policy-fn-arity a PolicyName(args) call supplies the wrong argument count
loom.policy-fn-cycle a policy function (transitively) references itself
loom.policy-out-of-scope a PolicyName(args) or Ctx.PolicyName(args) call from a context other than the policy's declaring one — policies are context-local; redeclare it there

Not yet shipped (P3.x follow-ups): the resource scope (referencing the gated row's fields directly instead of passing them as arguments), field masking, and hosting policy functions inside the policy {} block.

Deny carve-outs (Phase 4)

A deny rule is the negative twin of allow: a deny-wins carve-out that removes access to an aggregate. It sits in the same policy {} block as the allow read/write ladder, and — like the bare allow form — omits the read word (bare = read); the shipped write verb selects the write access:

policy {
  allow deep on Invoice   // widen the read scope …
  deny on Secret          // … but Secret is invisible (total READ carve-out)
  deny write on Invoice   // … and Invoice is read-only (WRITE carve-out)
}

Deny is all-or-nothing at the aggregate — there is no level word (a partial deny is field-masking / row-clause territory, a later slice). It composes as an always-false predicate through the existing filter seams — the read contextFilters (deny read) and the writeScopeFilter command load (deny write) — so no backend grows new render architecture; each just renders the deny sentinel to its native always-false fragment:

// node / Hono — deny read ANDs an always-false term into every Secret read
.where(and(eq(schema.secrets.id, id), and(isNull(schema.secrets.id), isNotNull(schema.secrets.id))))
// Java / Spring — deny read is a Hibernate @SQLRestriction on the entity;
// deny write is `and 1 = 0` in the for-write @Query
@SQLRestriction("1 = 0")
@Query("select e from Invoice e where e.id = :id and 1 = 0")
Optional<Invoice> findByIdForWrite(@Param("id") InvoiceId id);

Deny wins: it is applied after the allow read/write-level passes, so an always-false carve-out dominates any widened allow scope on the same target. Unlike the allow ladder, deny is not restricted to tenantOwned aggregates — contextFilters / writeScopeFilter exist on every aggregate.

Diagnostic When
loom.policy-deny-unknown-aggregate the deny target names no aggregate in the context
loom.policy-deny-duplicate the same (aggregate, access) is denied twice
loom.policy-deny-shadows-allow (warning) an allow on the same target+access is shadowed by a deny — the allow is dead (deny wins)

A lone deny with no matching allow is not flagged — aggregates are readable by default, so a carve-out with no prior grant is meaningful.

Not yet shipped (Phase 4.x follow-ups): the policy {}-block field rules (field f { mask unless … } / deny read nested in a read block), data {} row-attribute clauses, and per-operation / Workflow point gates — the larger slices the aggregate-level deny-wins primitive lays the plumbing for.

Field masking — mask unless (read redaction)

The aggregate-field baseline read mask (authorization.md §5) marks a field "sensitive everywhere, shown only to the authorised": it is REDACTED (null on the wire) UNLESS a currentUser-only predicate holds.

aggregate Person {
  name: string
  salary: money mask unless currentUser.permissions.contains(permissions.salaryUnmask)
}

The predicate is a bool and, like a requires gate, references only currentUser (+ constants) — it is evaluated at read projection as a param-free caller check, never against the row.

The redaction lands at the response boundary: every read route (GET /:id, each find shape) and explicit query-handler routes a masked aggregate through a masked serializer that redacts each masked field to null unless the caller satisfies its predicate — fail-closed (an unauthenticated request always redacts). The masked field is nullable in the response schema. Internal audit/provenance snapshots stay unmasked (they record the real value). A mask-free aggregate is byte-identical.

All five backends do this the same way. (.NET used to diverge — its audited command handlers reused the ordinary, masked wire projection, so a masked field was recorded as null whenever the acting principal failed the predicate, and the stored trail depended on who performed the write. Closed 2026-08-31: the four .NET audit projection sites pass unmasked (M-T3.9). The redaction did not vanish — reading the trail back through GET /<agg>/{id}/history still drops a masked field's FieldChange, which is where it belongs.)

// generated (Hono) — the aggregate's read serializer
toWireMasked(root: Person, currentUser: User | null): unknown {
  const wire = this.toWire(root) as Record<string, unknown>;
  if (!(currentUser !== null && ((currentUser.permissions).includes("hr.salaryUnmask")))) wire.salary = null;
  return wire;
}
# generated (FastAPI) — reads the ambient principal, no caller-passed arg
def to_wire_masked(self, root: Person) -> dict[str, object]:
    d = self.to_wire(root)
    _mask_user = current_user()
    if not (_mask_user is not None and ("hr.salaryUnmask" in _mask_user.permissions)):
        d["salary"] = None
    return d

On Java the aggregate's <Agg>Response record gains a second static mapper, fromMasked, that binds the ambient principal off the static CurrentUserAccessor.currentOrNull() and redacts each masked component; the read services + explicit handlers project through it, while audit before/after snapshots keep the unmasked from.

// generated (Spring) — audit keeps `from`; reads use `fromMasked`
public static PersonResponse fromMasked(Person value) {
    User __maskUser = CurrentUserAccessor.currentOrNull();
    return new PersonResponse(value.id().value(), value.name(),
        (__maskUser != null && (__maskUser.permissions().contains("hr.salaryUnmask"))) ? value.salary() : null);
}

On .NET the redaction is inlined into the wire projection itself, guarded by a C# pattern match on the ambient RequestContext.Current — no separate masked mapper.

// generated (.NET) — the read handler's projection, two masked fields
return found is null ? null : new PersonResponse(found.Id.Value, found.Name,
    (RequestContext.Current?.CurrentUser is { } __maskUser0 && ((__maskUser0.Permissions).Contains("hr.salaryUnmask"))) ? (decimal?)(found.Salary) : null,
    (RequestContext.Current?.CurrentUser is { } __maskUser1 && ((__maskUser1.Permissions).Contains("hr.salaryUnmask"))) ? (string?)(found.NationalId) : null);

The pattern variable is numbered (__maskUser0, __maskUser1, …) rather than fixed. x is { } n declares n in the enclosing BLOCK, not in the conditional expression that tests it, so a second wrap in the same C# scope would redeclare the same local — CS0128. Two masked fields on one aggregate (above) hit that inside a single projection; an audited command hits it across two, because its handler renders the projection once per before/after snapshot. Names are allocated per C# scope in field order (MaskNamer, generator/dotnet/dto-mapping.ts), so an emitter that renders more than one projection into one method body threads a single allocator through all of them.

On Elixir (vanilla Phoenix) the aggregate REST/ES controller's serialize/1 becomes the redacting serializer — it delegates to serialize_unmasked/1 (the raw map, which audit before/after snapshots project through) and nils each masked key unless the ambient principal satisfies the predicate. The controller has no conn in scope, so it reads the principal from the process dictionary the Auth plug stashes (Process.put(:loom_current_user, user)).

# generated (Phoenix) — reads the principal off the process dictionary
defp serialize(record) do
  current_user = Process.get(:loom_current_user)
  wire = serialize_unmasked(record)
  wire = if current_user != nil and (Enum.member?(current_user.permissions, "hr.salaryUnmask")), do: wire, else: Map.put(wire, "salary", nil)
  wire
end

Status (M-T3.2 item 6). Grammar + IR + printer + wire contract + validation, plus read redaction on all five backends (node, .NET, Python, Java, Elixir), have shipped — a mask unless field now redacts fail-closed on every backend. A write-side field gate (write(...) / readonly when) was tried and reverted (redundant with a post-load requires on the operation, which is also row-aware); gate field writes with operation update(…) requires <field> == this.<field> || <caller-check>.

Diagnostic When
loom.field-mask-not-current-user the predicate references the row / a param, not just currentUser
loom.field-mask-principal-without-auth the predicate reads currentUser but the hosting deployable has no auth (no auth: required + user {}) — there is no principal to evaluate it against, so the field could only ever read as null; the mask-side sibling of loom.guard-principal-without-auth
loom.field-mask-unsupported the hosting backend does not emit the read redaction (none today — every backend supports it)
loom.field-mask-projection-source a masked aggregate is a query-time projection source — projection responses aren't read-masked yet, so it would leak
(AST) 'mask unless' … must be of type 'bool' the predicate is not a bool

Read gates

Who may read an aggregate is one line in its context's policy block, beside the allow / deny ladder:

repository Tickets for Ticket { }
policy {
  read Ticket requires currentUser.role == "agent"
  read Product requires true          // intentionally public
}

The gate covers every read of the aggregate: the list GET /<plural>, the by-id GET /<plural>/{id}, each declared find's route, the audit history read (GET /<plural>/{id}/history), and the scaffolded List page (client-side, so its nav entry hides for a refused principal). An exposure-level gate — a scaffoldPaged(requires:), a route / handler requires — still ANDs on top.

It emits an in-handler 403 at the top of each of those routes, evaluated against the request's currentUser before the query runs. It is currentUser-only (plus constants) — no source row exists yet, so referencing an aggregate field is a compile error (loom.policy-read-gate-not-current-user). Like every requires clause it must type to bool (a non-bool gate — requires 42 — is rejected, so it can't lower to an always-truthy no-op). Use a where / capability filter to scope which rows come back, the read gate to decide who may read at all. requires true is the intentionally-public escape that also satisfies default-deny. One rule per aggregate (loom.policy-duplicate-target), and the aggregate must be declared in the same context (loom.policy-unknown-aggregate). read is not a keyword: it heads a rule only inside policy { } and stays an ordinary identifier (a permission, an operation, let read = …) everywhere else; any other word there is loom.policy-unknown-rule.

The generated handler throws ForbiddenError/ForbiddenException (→ RFC-7807 403) on all five backends before touching the repository:

// generated Hono route for `GET /orders/{id}` under
// `policy { read Order requires currentUser.role == "agent" }`
const currentUser = c.get("currentUser");
if (!(currentUser.role === "agent")) throw new ForbiddenError("Forbidden: find byId");

The detail names the read the gate refused (find all, find byId, find history, find <name>), identically on every backend.

UI gate — page { requires <expr> }

A page carries the same requires <expr> clause (page-metamodel §4). On a React / Vue / Svelte frontend with auth: ui (whose target backend is auth: required), the generated page evaluates the gate client-side against the verified session claims and renders a <Forbidden/> fallback instead of its body when it fails — the read-side mirror of the backend 403:

page Secret {
  route: "/secret"
  requires currentUser.role == "agent"
  body: Heading { "Top secret" }
}
const currentUser = useSession().user as Record<string, any>;
if (!(currentUser.role === "agent")) {
  return ( <div style={{ padding: 24 }}><h2>Forbidden</h2>…</div> );
}

It is currentUser-only (it has no row to scope), so it and the backend stay decidable from the same claims. The gate guard lands after every hook (keeping rules-of-hooks intact). A page without auth: ui, or without a gate, is byte-identical to before. The page requires gate ships on React, Vue, and Svelte; menu-link hiding ships on React + Svelte, and action-button gating on React / Vue / Svelte — only Angular still lacks an auth UI gate. The client guard is defence-in-depth — the authoritative check is always the backend 403.

Scaffolded pages inherit the gate their route is guarded by. A policy { read X requires … } puts its gate on the scaffolded List page, and a workflow … requires header gate puts its gate on both instance pages — so the no-code path guards itself instead of firing a read that 403s. Sound because both are currentUser-only by validation, which is exactly what a page gate can evaluate client-side.

Caveat — menu-link hiding is not automatic. A nav link is hidden from the page's OWN requires, and only for links derived from an explicit menu { … } block. The DEFAULT sidebar is built from the aggregate/workflow lists rather than from the pages, so it stays visible even when the page behind it is gated: a denied principal sees the link, clicks it, and gets <Forbidden/>. Write a menu { … } block if the link itself must disappear. Tracked as C3 in #3347.

currentUser is in scope wherever an expression evaluates per request:

Context currentUser allowed?
Operation body (preconditions, assignments, calls, emits) ✅
Workflow body ✅
Aggregate-level test body ✅
Repository find where clause ✅ (slice 1C)
policy { read X requires … } gate ✅ (currentUser + constants only)
Aggregate / part / value-object invariant ❌
Derived property ❌
function body ❌

The validator surfaces a friendly diagnostic for any disallowed use.

All five backends emit auth files. The two file layouts documented below (.NET and Hono) are representative — Python, Java, and Elixir/Phoenix emit the same surface (a strongly-typed User, a verifier hook, and request middleware that stashes the resolved principal), plus the OIDC authorization-code handshake when an auth { oidc { … } } block is present.

.NET (ASP.NET Core + Mediator)

When a deployable opts in via auth: required, the .NET generator emits five files under Auth/:

File Role
Auth/User.cs Strongly-typed User record matching the system's user block
Auth/IUserVerifier.cs Interface the user implements to decode a token into a User
Auth/ICurrentUserAccessor.cs Scoped accessor exposed to handlers
Auth/HttpContextCurrentUserAccessor.cs Default implementation backed by IHttpContextAccessor
Auth/UserMiddleware.cs Middleware that calls the verifier and stashes the resolved user

Program.cs is extended to mount UseMiddleware<UserMiddleware>() between UseSwagger() and MapControllers(), register the accessor + IHttpContextAccessor, and fail fast at startup if no IUserVerifier is registered.

You supply the verifier in your own project code:

using Acme.Auth;

public sealed class JwtUserVerifier : IUserVerifier
{
    public async Task<User?> VerifyAsync(HttpContext ctx, CancellationToken ct)
    {
        // Parse Authorization header, validate signature, project
        // claims into User.  Return null to reject with 401.
        // ...
    }
}

Register in Program.cs (or any DI extension):

builder.Services.AddScoped<IUserVerifier, JwtUserVerifier>();

When an aggregate operation references currentUser, the generated C# method picks up a trailing User currentUser parameter and the Mediator handler injects ICurrentUserAccessor, passing _currentUser.User into the call. Operations that don't reference currentUser stay untouched — no DI surface widening, no parameter noise.

Hono

When a Hono deployable opts in, the generator emits three files under auth/:

File Role
auth/user-types.ts User interface matching the user block
auth/verifier.ts registerUserVerifier(...) registry + verifyUserOrThrow(req) helper
auth/middleware.ts Hono middleware mounted in http/index.ts

http/index.ts mounts app.use("*", authMiddleware) after cors() and asserts at startup that the verifier is registered.

You register your verifier before serving:

import { registerUserVerifier } from "./auth/verifier.js";

registerUserVerifier(async (req) => {
  const auth = req.headers.get("authorization");
  // ... parse Bearer token, validate, project to User shape ...
  return { id: "u-1", role: "manager", customerId: "c-1", tenantId: "t-1" };
});

When an aggregate operation references currentUser, the route handler reads c.get("currentUser") as User at the top and passes it as the trailing argument to the aggregate method.

Absent claims (generated OIDC verifier)

A claim the verified token does not carry becomes the field's null, never undefined or a stub default. Every backend lowers currentUser.x != null to a plain null test, so the value an absent claim maps to decides the gate.

user { id: string  role: string  permissions: string[]  agentId: string? }
// …
operation take() { requires currentUser.agentId != null }
// node — auth/oidc.ts
const REQUIRED_CLAIMS: readonly (readonly [string, null])[] = [["sub", null], ["realm_access.roles", null]];
function toUser(payload: JWTPayload): UserClaims | null {
  if (REQUIRED_CLAIMS.some(([path, format]) => claim(payload, path) == null)) return null; // → 401
  return {
    id: claim(payload, "sub") as string,
    // …
    agentId: (claim(payload, "agentId") ?? null) as string | null, // absent → null → 403
  };
}
user { id: string  role: string  permissions: string[]  assignee: Ticket id? }
// …
operation assign() { requires currentUser.assignee != null }
// node — auth/oidc.ts: `assignee: "not-a-uuid"` → null → 403
assignee: (claimAs(claim(payload, "assignee"), "uuid") ?? null) as Ids.TicketId | null,
# python — app/auth/oidc.py
assignee=cast(TicketId | None, _claim_as(_claim(payload, "assignee"), "uuid")),

Local dev IdP (bundled Keycloak)

Once a system declares an auth { oidc { … } } block, the backends verify real JWTs and the dev-stub header below is not emitted. So generate system bundles a dev Keycloak into the compose stack for every provider — hosted presets (google, auth0, entra, …) included — and repoints the backends at it by env. The declared issuer is what a deployment uses; compose is the local stack.

auth {
  provider: auth0
  oidc { issuer: "https://shop.eu.auth0.com/"  clientId: env("OIDC_CLIENT_ID") }
}
# generated — docker-compose.yml
keycloak:
  image: quay.io/keycloak/keycloak:26.0
  command: ["start-dev", "--import-realm"]   # imports ./keycloak/realm.json
api:
  environment:
    OIDC_ISSUER: "http://host.docker.internal:8081/realms/shop"
    OIDC_CLIENT_ID: "shop-app"
// generated (node) — auth/oidc.ts: the env wins, the declared issuer is the fallback
const ISSUER = process.env.OIDC_ISSUER ?? "https://shop.eu.auth0.com/";

The realm seeds a demo / demo user carrying one value per declared user claim. To sign in to the real provider locally instead, set OIDC_ISSUER (and OIDC_CLIENT_ID) on the backend service.

The generated e2e suite mints its own token. When a backend is auth: required, every test first asks the bundled realm for a token by password grant (the demo user) and sends it as the bearer, so docker compose up -d then npx vitest run in e2e/ needs nothing exported:

// generated — e2e/Shop.e2e.test.ts
const __DEV_IDP_TOKEN_URL = process.env.E2E_OIDC_TOKEN_URL ?? "http://localhost:8081/realms/shop/protocol/openid-connect/token";
it("creates an order against api", async () => {
  const base = ENDPOINTS.api;
  await __ensureBearer();
  // …
});

It stands aside when you pick the principal yourself: E2E_BEARER_TOKEN=<jwt> wins, E2E_DEV_CLAIMS means a dev-stub target, and E2E_DEV_IDP=off disables it. E2E_OIDC_USERNAME / E2E_OIDC_PASSWORD select another realm user.

Dev-stub verifier (x-loom-dev-claims)

Until you register a real verifier, every backend ships an accept-all dev stub so the stack boots and the routes are reachable in local dev. The stub reads an optional x-loom-dev-claims request header — base64-encoded JSON — and overlays it on the User shape, so you can exercise currentUser/requires gates without wiring an identity provider:

curl -H "x-loom-dev-claims: $(echo -n '{"id":"u-1","role":"manager","tenantId":"t-1"}' | base64)" \
  http://localhost:8080/api/orders

The stub exists only without an auth { oidc … } block. Declaring one replaces the stub with the generated OIDC verifier on every backend (D-AUTH-OIDC): there is no x-loom-dev-claims path and no env switch that brings it back, so every request needs a real bearer token. Local development with an oidc block therefore needs an IdP:

To exercise gates with hand-written claims instead, generate a copy of the model without the oidc block (keep auth: required and the user { … } block) — that build gets the stub.

The encoding is load-bearing. A header that is present but does not decode to a JSON object — raw JSON, broken base64, a JSON array or string — is refused on all five backends with 400, before any route runs (ruling D6, eval-closure #23):

{ "type": "about:blank", "title": "Bad Request", "status": 400,
  "detail": "malformed x-loom-dev-claims header: expected a base64-encoded JSON object",
  "instance": "/api/orders" }

It used to be silently ignored, running the request as the built-in admin, so a permission test that sent a typo'd header could pass as the broad principal it never meant to be. An absent or empty header still means the built-in identity.

Under auth { oidc } the stub is preview-only. The Hono backend still emits auth/dev-stub.ts, but its index.ts registers only the OIDC verifier, so a running app ignores the header. The playground preview registers the stub instead, because no identity provider is reachable from its browser worker — that is the one place an OIDC system honours x-loom-dev-claims (a malformed header there answers 401, not 400, since the OIDC middleware does not know the stub).

// generated (node, auth { oidc }) — index.ts
registerOidcVerifier();          // auth/dev-stub.ts is never imported here

With no header the stub returns its built-in identity: one value per field the user { … } block declares — "admin" for a string, the all-zero uuid for a guid, 0 for a number, false for a bool, the epoch for a datetime, the EMPTY list for an array (so a permission-guarded surface denies by default), and null where the field is declared optional. The identity is derived from the declared shape, so a non-optional field is never null and the same .ddd yields the same principal on every backend. It is a dev convenience, not a production path: register a real verifier (above) before shipping.

A non-optional id-typed claim (customerId: Customer id) gets the same all-zero identity, but constructed through the emitted id type rather than written as a raw scalar — the id is a distinct nominal type on three of the five backends, so a bare literal does not compile:

// node — auth/dev-stub.ts   (`type CustomerId = string & { __brand }`)
customerId: Ids.CustomerId("00000000-0000-0000-0000-000000000000"),
// dotnet — Auth/DevStubUserVerifier.cs   (`readonly record struct CustomerId(Guid Value)`)
CustomerId: new CustomerId(System.Guid.Empty),

The seed widens with the aggregate's id value type (int/long → zero, string/guid → the all-zero uuid) and is decided once for all five backends in src/generator/_auth/dev-stub-id.ts. An optional id claim stays null / None / nil, as the declaration allows.

The header does NOT carry every claim type on every backend. Only string-typed user { … } fields are honoured on .NET, Python, Java and Elixir: each emits its claim mapper over the string fields alone, so a non-string claim in the header is silently discarded and the field keeps its built-in value. For an array that value is the EMPTY list — so a requires currentUser.permissions.contains(…) gate fails closed no matter what you send. Only Hono spreads the decoded JSON wholesale and therefore carries an array through. The OIDC path has no such limit: all five map string[] claims (a realm_access.roles-shaped list) from the verified token. This asymmetry is dev-stub-only, it is not surfaced by any loom.* diagnostic, and it is why a permission-gated fixture cannot be driven from the behavioural harness on four of the five backends.

Typed claims on the OIDC path. A verified token's non-string claims — X id, guid, int, long, bool, datetime, decimal, and lists of them — reach currentUser on every backend. .NET and Java decode each into the declared type (an X id claim is built through the generated XId type; a number may arrive as a JSON number or a numeric string; a datetime as ISO-8601 or epoch seconds). Node, Python and Elixir carry the JSON value, except that an identifier claim (guid, or an X id) must be a UUID. An optional claim that is absent or can't be decoded is null. A required one rejects the token with 401. Value-object, enum, money and json claims are not decoded on .NET/Java; they keep the type default.

Token audience (aud)

oidc { audience: … } is the token's intended recipient. When it resolves to a value, every backend's verifier requires the token's aud claim to carry it. When it resolves to nothing — no audience: declared and OIDC_AUDIENCE unset — the aud check is skipped and the verifier accepts any token the issuer signed, including one that issuer minted for a different client of the same realm.

That default is unsafe often enough to be stated, so an oidc { … } block with no audience: raises loom.auth-oidc-no-audience (warning, not error — a single-client deployment is a legitimate shape, and the check can still be turned on at deploy time without editing the .ddd).

The value is always env-overridable, declared or not. All five backends read OIDC_AUDIENCE, falling back to the declared value:

declared resolves to
audience: "loom-api" OIDC_AUDIENCE if set, else "loom-api"
audience: env("OIDC_AUDIENCE") OIDC_AUDIENCE
(undeclared) OIDC_AUDIENCE if set, else unset → aud not checked

So an operator can enforce audience isolation on a stack that never declared one by setting OIDC_AUDIENCE in compose — no .ddd edit, no rebuild.

Turning it back OFF is the same on every backend. An EMPTY audience — OIDC_AUDIENCE unset, or set to "" — skips the check on all five. (Until the claims-eval fix D7, dotnet, java and python treated "" as a declared audience no token carries and rejected every token; python and dotnet did so even with the variable merely unset under audience: env("OIDC_AUDIENCE").)

A rejected token is logged. Every verifier still answers a failed verification with 401 (never 500), and now also logs the catalog event auth_token_rejected at debug with a reason field (unexpected "aud" claim value, Audience doesn't match, a discovery failure, …). Set LOG_LEVEL=debug to see why a token was refused.

The bundled dev realm mints the audience. A literal audience: "x" gets an audience mapper for x; an env(…) audience gets one for the realm's own client id (<realm>-app), so OIDC_AUDIENCE=<realm>-app turns the check on against the bundled Keycloak with no realm edit.

auth {
  provider: keycloak
  oidc {
    issuer: env("OIDC_ISSUER")
    clientId: env("OIDC_CLIENT_ID")
    audience: env("OIDC_AUDIENCE")
  }
}
// generated (node) — auth/oidc.ts
const ISSUER = process.env.OIDC_ISSUER ?? "";
const AUDIENCE = process.env.OIDC_AUDIENCE ?? "";
const VERIFY_OPTIONS = AUDIENCE ? { issuer: ISSUER, audience: AUDIENCE } : { issuer: ISSUER };
// …
const { payload } = await jwtVerify(token, await getJwks(), VERIFY_OPTIONS);
# generated (FastAPI) — app/auth/oidc.py
_AUDIENCE = os.environ.get("OIDC_AUDIENCE")
jwt.decode(token, key, algorithms=_ALGS, issuer=_issuer(),
           audience=_AUDIENCE, options={"verify_aud": _AUDIENCE is not None})

Auth routes

Every backend mounts its auth routes under the shared API base, i.e. /api/auth, alongside the domain routes (/api/...):

Session depth (PKCE + refresh rotation)

The handshake is hardened by default (no knobs):

Emitted identically across all five backends (Hono, .NET, Java/Spring, Python/FastAPI, Phoenix).

Boundary (D-AUTH-OIDC): Loom owns "validate a token + run the redirect handshake". The IdP owns credentials, password reset, MFA, and consent — there is deliberately no Loom-hosted login form or password-reset flow. Point users at your IdP's account pages for those; Loom will not generate them.

Phoenix-LiveView auth: ui guard. A phoenixLiveView deployable that serves a HEEx ui: gates its LiveViews server-side: LiveAuth.on_mount runs first in the live_session, assigns @current_user, and redirects an unauthenticated connection to the login handshake. Under OIDC a BrowserAuth plug (in the :browser pipeline) seeds the Phoenix session's current_user from the verified session cookie so the on_mount hook sees a signed-in user; the dev stub falls back to the built-in admin so a fresh stack renders out of the box.

Auth is browser-facing traffic, the same class as the domain routes, so it lives under /api — one reverse-proxy / k8s-ingress rule (/api → backend) covers it, and the generated frontends (which fetch ${API_BASE_URL}/auth/… with API_BASE_URL already /api) line up. The infra probes (/health, /ready) stay at the root: they're hit directly by Docker/k8s, never through the public proxy. Set OIDC_REDIRECT_URI to a …/api/auth/callback URL when overriding the default.

Bypass list

Every backend bypasses auth on these paths so docker-compose health checks, OpenAPI clients, and Swagger UI work without tokens:

Pin the per-platform middleware file in .loomignore if you need to widen or tighten the list.

Errors

Situation Diagnostic
auth: required on a deployable but no user { ... } block Validation error: "deployable 'X' has 'auth: required' but system 'Y' declares no 'user { ... }' block."
Two user fields with the same name Validation error: "user block declares field 'X' more than once."
currentUser in an invariant / derived / function body Validation error: "currentUser is only available in per-request handlers."
Two permissions with the same name in one subdomain Validation error: "subdomain 'S': permission 'X' is declared more than once."
permissions.X referencing an undeclared name (or used outside any subdomain) Validation error: "permissions.X: no permission named 'X' is declared in this subdomain's 'permissions { ... }' block."
Workflow body calls a currentUser-bound repository find Validation error: "references a currentUser-bound find, which workflows don't yet pass the user into." Use getById or move the call to the route layer.

Missing IUserVerifier registration surfaces at runtime startup, not during generation — the project compiles, but boots with a clear InvalidOperationException pointing you at the AddScoped<IUserVerifier, ...> line that needs to exist.