Policies

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:

sub-domains/catalog/policies/operator_only.vishwakarma
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:

sub-domains/catalog/commands/product.vishwakarma
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:

sub-domains/catalog/commands/product.vishwakarma
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:

ConstructQuestionEvaluated over
policyIs this caller permitted to run this operation at all?the caller (caller.*)
guardIs 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.