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:
- Slice 1A —
user { ... }+currentUsermagic identifier + per-deployableauth: requiredmiddleware + verifier hook. - Slice 1B — per-subdomain
permissions { ... }block +permissions.<name>magic identifier resolving to a stable<subdomain>.<name>runtime string + the.contains(x)collection op for ergonomic claim membership checks. - Slice 1C —
currentUseradmissible inside repository findwherefilters; the renderer threads the resolved user through as a closure-captured parameter on the generated method. - Slice 2 —
requires <expr>statement: a declarative authorization gate that maps to HTTP 403, distinct fromprecondition(which maps to 422 — RS-15).
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:
public aggregate operations, creates, and destroys (each carries
requiresin its body);workflows — every command-triggered
create … {}starter and namedhandle …(){}continuation (event-triggered creates andon(...)reactors are not client-reachable, so they are excluded);every read of an aggregate — the list read
GET /api/<plural>, the single-record readGET /api/<plural>/{id}, each repositoryfind's route, and the audit history read — gated by ONE line in the context'spolicyblock (see Read gates):repository Secrets for Secret {} policy { read Secret requires currentUser.role == "admin" }Every backend evaluates the gate before the load, so a refused caller gets 403 (on
/{id}, for an existing and a missing id alike — no existence oracle); the routes' OpenAPI responses gain 403. All five backends resolve the list and by-id reads through one shared derivation (src/ir/util/read-gates.ts).requires trueis the explicit public escape; the tenancy filter andmask unlessstill apply on top of the gate. Spell the list return type the way the warning does: the injected read isT paged(thePaged<T>envelope) on every aggregate whose rows page, and a declaredallanswers exactly the type it names —T[]is a bare JSON array, so writing it to add a gate changes the response shape every client parses. Role separation within a tenant can also be written as a rowfilterthat tests the principal (filter currentUser.role == "agent" || this.customer == currentUser.customerId); a filtered-out row reads 404 on the by-id route, like a foreign tenant's. See capabilities.md § Principal terms. See Migration: the synthesised reads fail closed for what changes for an existing model.an event-sourced
createis the one remaining exception — on the four backends that cannot gate it. ApersistedAs: eventLogaggregate's create body renders into the domain_init, which has no principal in scope, so arequiresthere is refused (loom.lifecycle-guard-event-sourced, below) onnode/dotnet/python/java.elixiris not affected — Phoenix hoists the gate to its context function and binds a principal, so there the gate is accepted and the missing-gate case stays a hard error. Demanding one underdenyByDefaultwas therefore an instruction with no satisfying answer — gate present, one error; gate absent, one error — which madeenforcement: denyByDefaultandpersistedAs: eventLogmutually exclusive for any event-sourced aggregate with a creation endpoint. It now raisesloom.default-deny-es-create-ungateable(a warning: there is nothing the author can write to satisfy it), and the model builds. To close the hole today, issue the create from a gatedoperation/workflowand keep the canonicalcreateoff the client, or host the aggregate on a deployable whose whole api is restricted. Gating the event-sourced create route in place means hoisting the gate out of_initto each backend's own chokepoint — mission M-T3.16.projections — both kinds — the same optionalrequiresgate, declared on the projection HEADER (projection X keyed by k requires <expr> { … }, afterkeyed by, like every other gate in the language), evaluated againstcurrentUserbefore the read; failure → 403. It is the projection twin of the policy read gate, currentUser-only (a source-row reference is rejected byloom.projection-gate-not-current-user), and it enforces on all five backends:- a query-time projection gates its comprehension route
(
GET /projections/<p>), 403 before the query runs; - a folded (materialized) projection gates BOTH read-model routes
(
GET /projections/<p>andGET /projections/<p>/{key}), 403 before the lookup — so a denied caller cannot distinguish "forbidden" from "no such key".
The projections
scaffoldDashboardemits carry the source aggregate'spolicy { read X requires … }gate on their header, so a dashboard total is guarded exactly like the rows it summarises. Without that rule, underdenyByDefault, the scaffolded projection is refused (loom.default-deny-ungated) with a hint to write it.A folded projection used to be unable to carry a gate at all: the keyword lived inside the query-clause fragment, which a folded projection has none of, and
loom.projection-gate-without-sourcerejected the combination. Both halves are fixed and that diagnostic is gone.- a query-time projection gates its comprehension route
(
workflow INSTANCE reads —
GET /workflows/<wf>/instancesand.../instances/{id}publish every instance's correlation id and state, so an observable workflow (one with a correlation field) carries the same optional gate on its declaration header:workflow Fulfilment requires currentUser.role == "supervisor" { orderId: Order id stage: string create start(order: Order id) { requires currentUser.role == "clerk" … } }The header gate guards the reads only — command entries keep their own body gates, because "who may start this workflow" and "who may read its state" are different questions (this example authorizes them to different roles deliberately). It is
currentUser-only for the same reason the find and projection gates are: it runs before any instance is loaded, so there is no instance to reference (loom.workflow-gate-not-current-user). Enforced on all five backends, on both routes, ahead of the store read — so a denied caller cannot tell an existing correlation id from an absent one.Like the folded projection before it, this read was previously ungateable rather than merely ungated: the routes are compiler-derived and a workflow had no surface to declare a gate on.
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:
- Workflow bodies calling currentUser-bound finds — the validator
currently rejects this with a pointer at
getByIdor moving the call out to the route layer.
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:
- The aggregate method drops its
currentUser: Userparameter when the gate was its only use, so the entity stays callable from a saga, a seed, or a timer without fabricating a principal. - A gate that references operation parameters resolves them to whatever the
call site passes (
body.<name>on a route, the caller's own expression at an inline op-call) — the parameters are not locals outside the method. requiresis evaluated beforewhen, so an unauthorized caller gets 403 and never learns whether the operation would have been allowed in the row's current state.- A
requiresthat is not in the leading run (one that appears after a mutation or aletit depends on) stays in the body and still throws from the domain — hoisting it would change when it evaluates, not just where it lives.
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));
- A
createguard may readcurrentUserand nothing else. There is no instance until the factory runs, and the emittedPOST /<aggs>takes the field-derived create input rather than the declared parameter list, so a parameter has no wire slot either. Both are refused byloom.lifecycle-guard-unreadable, which names the offending refs. - A
destroyguard may also readthis: the route already loads the row for its 404 probe, and the gate runs against that load. So an unreachable id answers 404, not 403 — same order as the operation routes. A parameter is still out (a DELETE carries no body). - The gate precedes the write and any audit staging, so a denied create constructs nothing and a denied destroy records nothing.
errorStatuses("create" | "destroy", guarded)declares the 403, so a generated client types the denial instead of treating it as an unexpected throw.
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:
- The refusal covers every create on an event-sourced aggregate, not just
the canonical one. The create the backends render is
creates[0]by index, so a namedcreate open(...)on an event stream is the emitted one — and while the refusal readcanonicalCreatealone, its guard reached_inituntouched:ddd parsesaid0 error(s)and the generated project then failed to compile on a freecurrentUser. - Under
denyByDefaultthe absence of a gate is an error onelixir(the gate is both required and accepted there) and a warning on a host that cannot enforce one (loom.default-deny-es-create-ungateable— see the exception list above). The rule is recourse, not persistence shape. Beside its gate, a state-based canonicalcreatebody renders itspreconditions (the domain-floor 422) andemits; the rest — a computedassign, a destroy'sprecondition/emit— is aloom.lifecycle-body-droppederror, not a silent drop.
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 on Xdenies read:Xbecomes invisible —findAllreturns[]andfindById404s. Because every backend's write command-load reuses the read filter, writes fail too.deny write on Xdenies write only: reads still work, but every instance mutation (update-style ops,destroy, applier dispatch) 404s.
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 anauth { 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
};
}
- Optional claim, absent →
null/None/nilon all five backends. A!= nullgate denies (403). The token still verifies. - Identifier claim, malformed → treated as absent. A
guidorX idclaim that is present but not a UUID ("demo-agent") would otherwise be non-null and pass a!= nullgate. A list claim drops its malformed elements. - Required scalar claim, absent or malformed → the token is rejected (401)
on node, Python and Elixir, and for typed claims on .NET and Java. A required
string claim that is absent still maps to
""on .NET and Java. - Required array claim, absent → the empty list. An IdP routinely omits an
empty
permissions, so this is not a reason to reject.
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 nox-loom-dev-claimspath and no env switch that brings it back, so every request needs a real bearer token. Local development with anoidcblock therefore needs an IdP:
- Self-hosted provider (
provider: keycloak,provider: custom, or a bareoidc { issuer … }) —docker compose upstarts the bundled dev Keycloak (realm import from the emittedkeycloak/realm.json, demo user included), and the backends are wired to its issuer. Running a single deployable outside compose still needs that Keycloak: start it withdocker compose up -d keycloakand pointOIDC_ISSUER/OIDC_CLIENT_IDat it.- Hosted preset (
auth0, …) — no bundled service; set theOIDC_*variables to your tenant.To exercise gates with hand-written claims instead, generate a copy of the model without the
oidcblock (keepauth: requiredand theuser { … }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 arequires 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 mapstring[]claims (arealm_access.roles-shaped list) from the verified token. This asymmetry is dev-stub-only, it is not surfaced by anyloom.*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_AUDIENCEunset, 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 underaudience: 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_rejectedatdebugwith areasonfield (unexpected "aud" claim value,Audience doesn't match, a discovery failure, …). SetLOG_LEVEL=debugto see why a token was refused.The bundled dev realm mints the audience. A literal
audience: "x"gets an audience mapper forx; anenv(…)audience gets one for the realm's own client id (<realm>-app), soOIDC_AUDIENCE=<realm>-appturns 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/...):
/api/auth/me— the session probe theauth: uifrontend guard reads; always present underauth: required, and not bypassed (the middleware verifies the principal or returns 401 first). The body is the declareduser { … }shape, by declared name — and nothing else: the per-request derived tenancy members (currentUser.orgPath/rootOrg) are server-side scoping state, so they stay off the wire. All five backends answer byte-identically here (frozen by the/api/auth/meentry in the behavioural wire goldens)./api/auth/login,/api/auth/callback,/api/auth/logout— the OIDC authorization-code redirect handshake, emitted only under anauth { oidc { … } }block. The block's fields are documented inlanguage-reference/17-auth.md; the one worth reading before you ship isaudience:, which is optional and whose absence turns theaudcheck off — the verifier then accepts any token from the configured issuer, including one minted for a different client in the same realm.POST /api/auth/refresh— silent renewal: exchanges the stored refresh token for a fresh access token (no IdP round-trip) and rotates it, so a SPA can extend a session on a 401 without bouncing the user back to login. Emitted with the handshake; bypassed by the middleware (the caller has no valid access token yet). On a spent/revoked token it clears both cookies (→ full/login).
Session depth (PKCE + refresh rotation)
The handshake is hardened by default (no knobs):
- PKCE (RFC 7636), unconditional —
/loginmints a per-login code verifier, sends only itsS256challenge to the IdP, and stashes the verifier in an HttpOnlyoidc_verifiercookie the/callbackexchange proves possession with. OAuth 2.1 makes PKCE mandatory; it costs nothing for confidential clients and closes the code-interception hole for public ones. - Refresh rotation — the handshake requests the
offline_accessscope, stores the granted refresh token in its own HttpOnlyrefreshcookie, andPOST /api/auth/refreshrotates it (the cookie is overwritten with the new token the IdP hands back — single-use). All access/refresh tokens ride HttpOnly cookies; the SPA never sees the raw tokens.
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:
/health/ready/openapi.json/swagger(and any/swagger/...subpath)
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.