A policy gates who may run a command or query. Where a spec shapes a value and an invariant keeps an aggregate consistent, a policy answers a different question: is this caller allowed to perform this operation at all? It is authorization, evaluated over the resolved caller at the operation boundary — before any input is validated or any row is touched.
A reusable named policy
Authorization rules repeat — "only an operator may do this" guards more than one operation. So you declare the rule once, as a named top-level policy, and reference it wherever it applies:
policy OperatorOnly scope:operation allow {
doc "The caller must be a Chowk operator."
expr "caller.role == 'OPERATOR'"
error "operator role required"
}scope:operation makes it an operation-entry gate; the expr is a boolean over the caller — a pure authorization check, with no this (row) to look at. On its own the policy does nothing; it becomes active when an operation references it.
Referencing a policy from a command
A command applies policies through its policy [...] belt. A ref leaf pulls in a named policy by name instead of re-authoring the condition:
command CreateProduct {
aggregate Product
operation create
policy [
{ leaf {
ref "OperatorOnly"
message "Only a Chowk operator can add products to the catalog."
} }
]
set status = expr "'DRAFT'"
sku string required specs [Sku]
name string required
price Money required
}The leaf refs OperatorOnly and supplies its own message — a per-site denial reason. A caller whose role is not OPERATOR is refused before the command's inputs are even validated. That role is a caller axis, resolved at the edge from the authenticated request and never sent by the client — commands and queries read the caller through the same uniform identity.
The payoff is reuse. Chowk's ArchiveProduct gates on the very same policy:
command ArchiveProduct {
aggregate Product
operation update
policy [
{ leaf {
ref "OperatorOnly"
message "Only a Chowk operator can archive a product."
} }
]
set status = expr "'ARCHIVED'"
id uuid required
}Two commands, one rule. Broaden OperatorOnly — say, to also admit an ADMIN role — and both gates move together. Copy-pasting expr "caller.role == 'OPERATOR'" into each command instead would drift the moment one copy is updated and the other is forgotten. Queries carry the same policy [...] belt, so a read can be gated exactly the same way — a reusable policy reaches every operation that references it.
Inline conditions, and composing them
When a rule is genuinely one-off — not worth naming — a leaf can carry its own expr inline instead of a ref (a name, the expr, and a message). And either way, a policy is really a boolean tree: group leaves with all_of (every child must hold) or any_of (at least one must), nesting as needed. A composed policy freely mixes shared ref leaves with local inline ones:
policy [
{ any_of {
message "An operator, or a caller with an active session."
{ leaf { ref "OperatorOnly" } }
{ leaf { name "session" expr "caller.session_id != ''" message "no active session" } }
} }
]Each leaf stays a small, individually-messaged condition, so a denial points at exactly the clause that failed — while all_of/any_of compose them into richer rules.
Policy versus guard: two questions at the operation boundary
A command carries two kinds of precondition, and keeping them distinct matters:
| Construct | Question | Evaluated over |
|---|---|---|
policy | Is this caller permitted to run this operation at all? | the caller (caller.*) |
guard | Is this write allowed right now, given the row's state? | the row being written (this) |
A guard is a per-row precondition — an order must be PENDING before it can be paid. A policy is an authorization check over the caller — an operator before a product can be created or archived. Both could be written as a condition, but conflating them (dropping caller.role == 'OPERATOR' into a guard) would bury an access-control decision inside business logic. Keeping it a policy — better still, a named, reusable one — states the intent plainly and puts authorization where a reviewer looks for it.