← Back to home

6. Behavior & statements

How an aggregate changes state: the four action members — operation (a mutating method), create / destroy (lifecycle factory / terminator), and the event-sourcing apply fold — plus the statement vocabulary their bodies share (precondition, requires, let, emit, return, the assignment family := / += / -=, and the effect-form match). The same statements also fill a page action and an application-layer commandHandler / queryHandler. Reach for it when you need a domain method that validates, mutates, raises an event, or returns a typed outcome.

Grammar: Operation, Create, Destroy, Apply, CommandHandler / QueryHandler, Statement (PreconditionStmt, RequiresStmt, LetStmt, EmitStmt / EmitField, ReturnStmt, AssignOrCallStmt / LValue, MatchStmt / VariantStmtArm; ForStmt / IfLetStmt are workflow-body only — see Workflows) · Validators: loom.this-id-in-create, loom.extern-body-not-precondition, loom.lifecycle-body-dropped / loom.create-param-not-a-field / loom.create-param-required-omitted / loom.named-lifecycle-dropped / loom.lifecycle-guard-unreadable / loom.lifecycle-guard-event-sourced, loom.applier-* / loom.emitted-event-*, loom.audited-backend-unsupported, loom.repository-access-outside-workflow, loom.when-references-op-param / loom.unmapped-error-status, loom.missing-effect-marker / loom.effect-in-lambda / loom.match-await-* / loom.instance-effect-needs-route-id, loom.handler-* / loom.query-handler-saves / loom.command-handler-multi-aggregate (src/language/validators/statements.ts, handlers.ts; src/ir/validate/checks/structural-checks.ts, system-checks.ts, api-checks.ts, ui-checks.ts) · Lowering: src/ir/lower/lower-stmt.ts, lower-members.ts, lower-workflow.ts (handler bodies); shared dispatch in _stmt/target.ts · Docs: ../language.md, ../actions.md, ../workflow.md, ../auth.md

Every aggregate body lowers through one shared lowerStatement, so an operation, a create, a destroy, and an apply all draw from the same statement set; the kind tag (not the body syntax) carries the lifecycle asymmetry. The lowered StmtIR has 11 kinds — precondition, requires, let, assign, add, remove, emit, call, expression, return, variant-match — and src/generator/_stmt/target.ts owns the dispatch once; each backend's render-stmt.ts is a leaf table. Every tab below was generated from one scratch system with one deployable per backend (node bin/cli.js generate system … -o out) and excerpted; statements are separated by newlines (there is no ; statement separator). The C# tabs are excerpted one step further than the rest: the .NET emitter weaves a C#10 #line (a,b)-(c,d) "…ddd" directive ahead of each mapped statement of a named operation (weaveLineDirectives, src/generator/dotnet/emit/entity.ts), so a debugger steps the .ddd source; those directives are elided here.

operation — a mutating method

[private] operation name(params) [extern] [audited] [: ReturnType] [requires Gate] [when Guard] { body }. A public method on the aggregate root that mutates this; private operation keeps it off the HTTP surface (callable only from another op via a bare name(args) call). The body runs, then the framework asserts invariants on the way out.

context Orders {
  enum Status { Draft, Placed, Done }
  event OrderConfirmed { order: Order id, at: datetime }

  aggregate Order {
    qty: int
    status: Status
    contains lines: Line[]

    function isMutable(): bool = status == Status.Draft

    private operation recompute() {
      qty := qty + 0
    }

    operation confirm() when status == Status.Draft {
      precondition isMutable()
      recompute()
      precondition lines.count > 0
      status := Status.Placed
      emit OrderConfirmed { order: id, at: now() }
    }

    entity Line { amount: money }
  }
}

The body becomes a method whose statements emit in order, opened by the when gate and capped by the invariant assertion.

// domain/order.ts
private recompute(): void {
  this._qty = this._qty + 0;
  this._assertInvariants();
}
public confirm(): void {
  if (!(this._status === Status.Draft)) throw new DisallowedError("operation 'confirm' is not allowed in the current state of Order (when status == Status.Draft).");
  if (!(this.isMutable())) throw new DomainError("Precondition failed: isMutable()");
  this.recompute();
  if (!(this._lines.length > 0)) throw new DomainError("Precondition failed: lines.count > 0");
  this._status = Status.Placed;
  this._events.push({ type: "OrderConfirmed", order: this._id, at: new Date() });
  this._assertInvariants();
}
// Domain/Orders/Order.cs
public void Confirm()
{
    if (!(this.Status == Status.Draft)) throw new DisallowedException("operation 'confirm' is not allowed in the current state of Order (when status == Status.Draft).");
    if (!(this.IsMutable())) throw new DomainException("Precondition failed: isMutable()");
    this.Recompute();
    if (!(this.Lines.Count > 0)) throw new DomainException("Precondition failed: lines.count > 0");
    Status = Status.Placed;
    _domainEvents.Add(new OrderConfirmed(Order: this.Id, At: DateTime.UtcNow));
    AssertInvariants();
}
// features/orders/Order.java
public void confirm() {
    if (!(this.status == Status.Draft)) throw new DisallowedException("operation 'confirm' is not allowed in the current state of Order (when status == Status.Draft).");
    if (!(this.isMutable())) throw new DomainException("Precondition failed: isMutable()");
    this.recompute();
    if (!(this.lines.size() > 0)) throw new DomainException("Precondition failed: lines.count > 0");
    this.status = Status.Placed;
    this._domainEvents.add(new OrderConfirmed(this.id, Instant.now()));
    this._assertInvariants();
}
# app/domain/order.py
def confirm(self) -> None:
    if not (self._status == Status.Draft):
        raise DisallowedError("operation 'confirm' is not allowed in the current state of Order (when status == Status.Draft).")
    if not (self._is_mutable()):
        raise DomainError("Precondition failed: isMutable()")
    self._recompute()
    if not (len(self._lines) > 0):
        raise DomainError("Precondition failed: lines.count > 0")
    self._status = Status.Placed
    self._events.append(OrderConfirmed(order=self._id, at=datetime.now(UTC)))
    self._assert_invariants()
# lib/ex_api/orders.ex — a context function: guards are a `with` chain, the
# assignments rebuild the struct, the persist goes through a forced changeset,
# and the event is broadcast over Phoenix.PubSub after the save.
def confirm_order(%ExApi.Orders.Order{} = record, params) when is_map(params) do
  with :ok <- ensure(record.status == :Draft, {:disallowed, "operation 'confirm' is not allowed in the current state of Order (when status == Status.Draft)."}),
       :ok <- ensure(is_mutable(record), {:precondition_failed, "Precondition failed: isMutable()"}),
       :ok <- ensure(Enum.count(record.lines) > 0, {:precondition_failed, "Precondition failed: lines.count > 0"}) do
    record = __op_recompute(record)  # the private op as a module-local pure transform
    record = %{record | status: :Placed}
    changeset =
      record
      |> Ecto.Changeset.change(%{})
      |> Ecto.Changeset.force_change(:status, record.status)
      |> Ecto.Changeset.optimistic_lock(:version)
      # |> ExApi.Orders.OrderChangeset.validate_invariants()  ← only when the
      #    aggregate carries a RESIDUAL invariant (cross-field, or messaged);
      #    a single-field rule rides a native Ecto `validate_*` instead.

    case ExApi.Orders.OrderRepository.persist_change(changeset) do
      {:ok, saved} ->
        loom_event_0 = %ExApi.Orders.Events.OrderConfirmed{order: record.id, at: DateTime.utc_now()}
        Phoenix.PubSub.broadcast(ExApi.PubSub, "events", loom_event_0)
        {:ok, saved}
      {:error, reason} -> {:error, reason}
    end
  end
end

A private operation is invoked from another op as a bare call — recompute() lowers to this.recompute() (TS/.NET/Java), self._recompute() (Python) and, on Elixir, to record = __op_recompute(record) against a module-local defp __op_recompute/1 carrying the callee's body as a PURE struct transform (the caller's persist tail writes the columns it assigned). The public twin recompute_order/2 is not used for this: it persists, and calling it mid-operation would commit a partial write inside the caller's optimistic-lock window. One narrow refusal on Elixir: a private operation whose body reads currentUser is rejected with loom.vanilla-op-call-actor — the helper takes no actor and the caller binds current_user only when its own body reads the principal, so the generated project would not compile. Move the currentUser read up into the routed operation.

No repository is in scope. An operation is a single-aggregate state transition over state that is already loaded, so a body may not name a repository — precondition Technicians.getById(assignTo).skills.contains(requiredSkill) is loom.repository-access-outside-workflow. The rule covers every body that renders into domain code (operation, create / destroy and their when guards, invariant, derived, and function in both body forms), and it covers a repository of any context, not just this one. Loading another aggregate belongs to a workflow, which owns the transaction and the loads — read there and pass the value in as a parameter:

workflow assignJob {
  create(jobId: Job id, techId: Technician id) {
    let tech = Technicians.getById(techId)
    let job = Jobs.getById(jobId)
    job.assign(techId, tech.skills.contains(job.requiredSkill))
  }
}

A domainService may run read-only queries against its OWN context's repositories (see Domain services); a commandHandler / queryHandler body may read them too.

Modifiers: extern emits only the gates and hands the business decision to a user-registered handler — its body may contain nothing but precondition statements (loom.extern-body-not-precondition), and it can't be private (loom.extern-on-private-operation); see Externs. audited records an audit row around the call on all five backends (a context hosted elsewhere is loom.audited-backend-unsupported), including an operation that also declares a return type: the audit / provenance rows and the aggregate save share one transaction and the tagged result is carried out of it onto the 200. See Capabilities for auditable.

Guards — requires (403) vs when (409) vs precondition (422)

Three distinct gates, three distinct HTTP failures. They type identically (each is a bool expression) but lower to different throws so the route layer maps them to different statuses — the same three codes on every backend.

Clause Means Throws HTTP
requires Expr (header or body) the caller is authorized ForbiddenError / ForbiddenException / {:error, {:forbidden, _}} 403 Forbidden
when Expr (header) the aggregate is in a state that admits this op DisallowedError / DisallowedException / {:disallowed, _} 409 Disallowed
precondition Expr (body) domain validity of the arguments/state DomainError / DomainException / {:precondition_failed, _} 422 Unprocessable Entity
aggregate Order {
  subtotal: money
  status: Status
  operation addLine(price: money, isStaff: bool) requires isStaff {
    precondition price > money("0.00")
    subtotal := subtotal + price
  }
}

requires may be written on the header (operation addLine(...) requires isStaff { … }) or as the first statement(s) of the body — the header form lowers to a synthetic leading requires statement, so the two are indistinguishable after phase ⑤. A precondition may carry message "…" to replace the derived Precondition failed: <source> text. A refused when names its predicate the same way — operation 'confirm' is not allowed in the current state of Order (when status == Status.Draft). — identically on all five backends. Evaluation order at the call site is requires → when → body, so a 403 wins over a 409 wins over a 422.

Where each gate runs. when is a property of the domain method: every backend emits the predicate as the first line of the generated operation, so every caller evaluates it (the route, a workflow step, a handler). The leading run of requires is deliberately hoisted out of the entity to the calling handler (src/ir/util/op-gates.ts), because authorization needs a principal the domain layer does not carry; a requires further down the body stays where it is. The hoisted gate, one level up on each backend:

// http/order.routes.ts — inside the POST /{id}/add_line handler
const aggregate = await repo.getById(Ids.OrderId(id));
if (!(body.isStaff)) throw new ForbiddenError("Forbidden: isStaff");
aggregate.addLine(body.price, body.isStaff);
// Application/Orders/Commands/AddLineHandler.cs
var aggregate = await _repo.GetByIdAsync(command.Id, cancellationToken)
    ?? throw new AggregateNotFoundException($"Order {command.Id} not found");
if (!(command.IsStaff)) throw new ForbiddenException("Forbidden: isStaff");
aggregate.AddLine(command.Price, command.IsStaff);
await _repo.SaveAsync(aggregate, cancellationToken);
// features/orders/OrderService.java
var isStaff = request.isStaff();
if (!(isStaff)) throw new ForbiddenException("Forbidden: isStaff");
aggregate.addLine(price, isStaff);
# app/http/order_routes.py — inside add_line_order
if not (body.isStaff):
    raise ForbiddenError("Forbidden: isStaff")
# lib/ex_api/orders.ex — the context function's `with` chain
with :ok <- ensure(is_staff, {:forbidden, "Forbidden: isStaff"}),
     :ok <- ensure(Decimal.compare(price, Decimal.new("0.00")) == :gt, {:precondition_failed, "Precondition failed: price > money(\"0.00\")"}) do

The route layer turns each failure into RFC-7807 application/problem+json. On the Hono backend the app.onError handler checks ForbiddenError before DomainError; the same ladder is DomainExceptionFilter (.NET), ApiExceptionAdvice (Java), app/http/problem.py (Python), and the controller's {:error, {:forbidden | :disallowed | :precondition_failed, detail}} clauses (Elixir):

// http/ledgerApi-routes.ts — the generated `app.onError` ladder (node).
// Every routed sub-app carries the same ladder; the root `http/index.ts` copy
// adds structured logging + a `recordDomainFault(...)` metric per arm.
if (err instanceof ForbiddenError)         return problem(403, "Forbidden", err.message);
if (err instanceof DisallowedError)        return problem(409, "Disallowed", err.message);
if (err instanceof DomainError)            return problem(422, "Unprocessable Entity", err.message);
if (err instanceof AggregateNotFoundError) return problem(404, "Not Found", err.message);
if (err instanceof ConcurrencyError)       return problem(409, "Conflict", err.message);

A requires that reads currentUser needs a deployable that binds a verified principal (loom.guard-principal-without-auth); see Auth.

Null narrowing in a body

A null test narrows the path it tests for the region of the body it decides: an if's block (!= null the then block, == null the else block), and the rest of the body after a precondition <path> != null or after an if <path> == null { … return … } with no else. A member of a T? is otherwise refused (loom.nullable-receiver).

operation rename(o: string?) {
  if o != null { name := o.trim() }                  // o is string here
}
operation stampOrg() {
  precondition currentUser.org != null
  name := currentUser.org.toLower()                  // narrowed for the rest of the body
}
public rename(o: string | null): void {
  if (o !== null) {
    this._name = o.trim();
  }
}
public stampOrg(): void {
  if (!(currentUser.org !== null)) throw new DomainError("Precondition failed: currentUser.org != null", "precondition");
  this._name = currentUser.org.toLowerCase();
}

The region must not change the path. A write to it, or to a prefix of it (addr := … changes addr.city), blocks narrowing. So does an operation call when the path is aggregate state, because the operation can assign the field; a param, a let and a currentUser claim cannot change under a call. A requires does not narrow: it is checked at the route, before the aggregate method runs.

A parameterless operation needs no request body

operation confirm() routes to POST /api/<plural>/{id}/confirm, and a caller with nothing to send may send nothing: on all five backends an empty body — no Content-Type, or a JSON one with zero bytes — is answered exactly as {} is (RS-39). The same holds for a workflow started with no input. The published requestBody is optional; a foreign media type (text/plain, a form post) is still 415.

operation confirm() when status == Requested { status := Confirmed }
// http/appointment.routes.ts
request: {
  params: z.object({ id: UuidString }),
  body: { content: { "application/json": { schema: ConfirmAppointmentRequest } }, required: false },
},
# app/http/appointment_routes.py
async def confirm_appointment(id: …, body: Annotated[ConfirmAppointmentRequest, Body(default_factory=ConfirmAppointmentRequest)], …)

curl -X POST …/appointments/$ID/confirm → 204. On an operation with parameters an empty body is the {} that fails validation: 422 with an errors[] entry per missing field — {"pointer": "/reason", "message": "Required"} on all five backends. A create with no input fields publishes an optional body too.

when also auto-exposes GET /{id}/can_<op>

A when-gated operation gets a free, side-effect-free companion route returning { allowed } so a UI can enable/disable the action without invoking it (the canCommand pattern), on all five backends (can_confirmOrder / CanConfirmQuery / canConfirm / can_confirm_order / can_confirm_order/1). That companion route takes no arguments, so a when predicate may only read the aggregate's own state — referencing an operation parameter is loom.when-references-op-param. Move an argument-aware check into a precondition in the body, or express it as from <Criterion>(args).

For operation confirm() when status == Status.Draft:

// http/order.routes.ts — auto-emitted alongside the confirm route
app.openapi(
  createRoute({
    method: "get",
    path: "/{id}/can_confirm",
    tags: ["orders"],
    operationId: "can_confirmOrder",
    request: { params: z.object({ id: z.string().uuid() }) },
    responses: {
      200: { description: "OK", content: { "application/json": { schema: z.object({ allowed: z.boolean() }) } } },
      404: { description: "Not Found", content: { "application/problem+json": { schema: ProblemDetails } } },
    },
  }),
  async (c) => {
    const { id } = c.req.valid("param");
    const aggregate = await repo.getById(Ids.OrderId(id));
    return c.json({ allowed: aggregate.status === Status.Draft }, 200);
  },
);

The mutating route evaluates the same predicate post-load before the body runs; false there throws DisallowedError → 409. See ../criterion.md for when's relationship to reusable criteria.

let & emit

let name = Expr binds a local; emit Event { field: value, … } records a domain event (EmitFields are name: value pairs; a missing or ill-typed field is reported at the construction site, and the event must be declared in the same context). Field names re-quote and re-case per backend; emit-field order is normalized to the event's declared field order on positional-constructor backends (Java/.NET).

operation addLine(price: money) {
  let next = subtotal + price
  subtotal := next
  emit LinePriced { order: id, total: next }
}
const next = this._subtotal.plus(price);
this._subtotal = next;
this._events.push({ type: "LinePriced", order: this._id, total: next });
var next = this.Subtotal + price;
Subtotal = next;
_domainEvents.Add(new LinePriced(Order: this.Id, Total: next));
var next = this.subtotal.add(price);
this.subtotal = next;
this._domainEvents.add(new LinePriced(this.id, next));
next = self._subtotal + price
self._subtotal = next
self._events.append(LinePriced(order=self._id, total=next))
next = Decimal.add(record.subtotal, price)
record = %{record | subtotal: next}
# … force_change(:subtotal, record.subtotal) + persist, then:
loom_event_0 = %ExApi.Orders.Events.LinePriced{order: record.id, total: next}
Phoenix.PubSub.broadcast(ExApi.PubSub, "events", loom_event_0)

let name: Type = Expr declares the local's type instead of taking the expression's. The value must fit it (loom.let-type-mismatch): a bare numeric literal promotes (let fee: money = 5), and a narrower numeric value widens (int → long / decimal, long → decimal) through the same explicit conversion long(n) lowers to, so a backend that infers a local from its initializer still gets the declared type.

operation addLines(n: int) {
  let fee: money = 5
  let count: long = n
  subtotal := subtotal + fee
  lines := lines + count
}
const fee = new Decimal("5");
const count = n;
var fee = 5m;
var count = (long)n;
var fee = new BigDecimal("5");
var count = (long) n;
fee = Decimal("5")
count = int(n)
fee = Decimal.new("5")
count = n

On an event-sourced aggregate (persistedAs: eventLog), emit does double duty — it records the event and folds it immediately via _apply, so the in-memory aggregate reflects the transition before the command returns. There, command bodies may only emit; the state change lives in the apply block (below). The event-sourced emit becomes { const __ev: Events.DomainEvent = { type: "Deposited", … }; this._events.push(__ev); this._apply(__ev); } (node), { var __ev = new Deposited(…); _domainEvents.Add(__ev); _Apply(__ev); } (.NET), the same three-step block on Java, and __ev = Deposited(…) / self._events.append(__ev) / self._apply(__ev) on Python.

Assignment — :=, +=, -=

target := Expr is scalar assignment; target += Expr / target -= Expr are collection append / remove (add / remove in the IR). The target is an LValue — a bare field name or a dotted path (draft.zip), optionally rooted with an explicit this. (this.name, this.draft.zip). Assigning to a derived member is an error (Cannot assign to derived property …). A numeric/decimal literal flowing into a money target is elaborated to the precise money constructor at lowering (subtotal := 0.50 → money("0.50")).

An assignment always writes the member. Every backend emits name := … as a write to the aggregate's own field (this._name = …), so a bare head that names a member is type-checked against that member — even when a parameter or let of the same name shadows it on the right-hand side:

operation rename(name: int) {
  this.name := name   // error: Cannot assign 'int' to 'string'
  name := name        // error: Cannot assign 'int' to 'string' — the head is the field
}

(Before M-T5.42 the bare form resolved its head to the int parameter, validated clean, and emitted an assignment its own target language rejected.) Prefer the explicit prefix whenever a parameter shares a field's name. The same applies to a this-rooted call: this.files.put(k, v) is a member call on the aggregate even when a resource files is in scope, which the bare spelling would otherwise resolve to the resource.

aggregate Order {
  status: Status
  notes: string[]

  operation addNote() {
    status := Status.Placed     // scalar
    notes  += "line"            // collection append
    notes  -= "draft"           // collection remove
  }
}
this._status = Status.Placed;
this._notes.push("line");
{ const idx = this._notes.findIndex((e) => e === ("draft")); if (idx >= 0) this._notes.splice(idx, 1); }
Status = Status.Placed;
Notes.Add("line");
Notes.Remove("draft");
this.status = Status.Placed;
this.notes.add("line");
this.notes.remove("draft");
self._status = Status.Placed
self._notes.append("line")
__rm = "draft"
if __rm in self._notes:
    self._notes.remove(__rm)
# the struct is rebuilt, then every touched field is force_change'd onto the changeset
record = %{record | status: :Placed}
record = %{record | notes: (record.notes || []) ++ ["line"]}
record = %{record | notes: List.delete(record.notes || [], "draft")}
changeset =
  record
  |> Ecto.Changeset.change(%{})
  |> Ecto.Changeset.force_change(:status, record.status)
  |> Ecto.Changeset.force_change(:notes, record.notes)
  |> Ecto.Changeset.optimistic_lock(:version)

+= / -= on a contains collection append/remove an entity part; on an X id[] reference collection they attach/detach a target id (the join-table row is written on save).

create / destroy — lifecycle actions

create [name](params) [audited] { body } is the factory marker: an unnamed create(...) makes the aggregate constructible over HTTP (POST /<aggs> + a static factory whose input is the parameter list — or, for the empty create(), the whole client-suppliable field set; see below); destroy [name][(params)] [audited] { body } is the terminator (DELETE /{id}). Neither is ever private or extern. The crudish capability injects the canonical pair plus an update. Two gates are distinct: the domain factory Agg.create(...) is emitted for every constructible aggregate — one that declares a create, or whose every invariant is satisfiable from the create input (isConstructible, src/ir/enrich/wire-projection.ts) — and is parameterized over the create-input field set, while the REST POST /<aggs> appears only when a canonical create is actually declared (by hand or by crudish). Field defaults do not decide either: a default only makes that field optional input.

On a state-based aggregate a create body runs against the constructed instance, exactly like an operation body (createBodyStatements, src/ir/util/op-gates.ts): its requires gates the route (403, below), a precondition refuses with the domain-floor 422 an operation's does, an emit records the event and the create's save dispatches it, and a let binds a value either reads. The params are locals and every field the params do not supply already holds its declared default, so a guard may read both. Everything else would silently vanish and is refused instead (loom.lifecycle-body-dropped, an error): a +=/-=, a call, an if / match, or an assignment whose value the emitted factory does not already reproduce — and in a destroy body anything beyond its requires. Two assignments are reproduced and therefore admitted — field := <same-named param> (the create input supplies it) and field := <literal> when the field declares that same literal default.

event AppointmentRequested { appointment: Appointment id, patient: string }
aggregate Appointment {
  patient: string
  status: AppointmentStatus = Requested
  create(patient: string) {
    precondition patient.length >= 2 message "A patient name needs two characters"
    emit AppointmentRequested { appointment: id, patient: patient }
  }
}
// domain/appointment.ts
static create(input: { patient: string }): Appointment {
  const inst = new Appointment({ id: Ids.newAppointmentId(), patient: input.patient,
    status: AppointmentStatus.Requested, version: 1 });
  inst._createBody(input.patient);
  return inst;
}

private _createBody(patient: string): void {
  if (!([...patient].length >= 2)) throw new DomainError("A patient name needs two characters", "msg.ojnyph", "/patient");
  this._events.push({ type: "AppointmentRequested", appointment: this._id, patient: patient });
}
# lib/app/scheduling.ex — guards over the validated draft, insert, then dispatch
def create_appointment(attrs) do
  with {:ok, record} <- Ecto.Changeset.apply_action(AppointmentChangeset.base_changeset(AppointmentChangeset.take_create_attrs(attrs)), :insert),
       patient = record.patient,
       :ok <- ensure(length(String.to_charlist(patient)) >= 2, {:precondition_failed, %{detail: "A patient name needs two characters", code: "msg.ojnyph", pointer: "/patient"}}) do
    case AppointmentRepository.insert(attrs) do
      {:ok, record} ->
        loom_event_0 = %Scheduling.Events.AppointmentRequested{appointment: record.id, patient: patient}
        Phoenix.PubSub.broadcast(App.PubSub, "events", loom_event_0)
        {:ok, record}
      error -> error
    end
  end
end

A named create open(...) / destroy close(...) on a state-based aggregate reaches no emitter at all and is refused whole (loom.named-lifecycle-dropped) — with one exception: on an event-sourced aggregate the single create is canonical whether or not it is named, so create open(owner: string) { emit Opened { … } } is emitted and ungated. Reading the identity as the explicit this.id inside a create body is loom.this-id-in-create; a bare id is not gated, and is what the event-sourced create above emits.

The parameter list IS the request contract. On a state-based aggregate, a non-empty unnamed create(p1, p2, …) narrows the create input to exactly the fields it lists — the POST /<plural> wire schema, the route binding, the OpenAPI document, the generated clients, the domain factory and the scaffolded CreateForm all derive from it (declaredCreateParamNames / buildCreateInput, src/ir/enrich/wire-projection.ts). Every field the list leaves out is initialised by the factory: its declared constant default, null for a T?, false for a bare bool, [] for a collection. A request body that names such a field anyway is ignored for it:

aggregate Appointment {
  doctor: Doctor id
  minutes: int
  status: AppointmentStatus = Requested
  notes: string?
  create(doctor: Doctor id, minutes: int) { }
  operation complete(summary: string) when status == Confirmed { status := Completed  notes := summary }
}
// http/appointment.routes.ts + domain/appointment.ts
const CreateAppointmentRequest = z.object({
  doctor: UuidString,
  minutes: z.number().int()…,
})   // `status` / `notes` are not create input — a POSTed "status":"Completed" is dropped

static create(input: { doctor: Ids.DoctorId; minutes: number }): Appointment {
  return new Appointment({ id: Ids.newAppointmentId(), doctor: input.doctor, minutes: input.minutes,
    status: AppointmentStatus.Requested, notes: null, version: 1 });
}

So a when-gated state field cannot be set at creation, and the create form shows only the listed fields. Two shapes are refused, because neither has an honest emission: a parameter that is not the wire slot of a client-suppliable field — no such field, a managed / internal / token one, or a different type (loom.create-param-not-a-field) — and a field outside the list the factory cannot initialise on its own: required with no default, or defaulted to something that is not a compile-time constant (now(), currentUser.*, a value-object constructor) (loom.create-param-required-omitted). The empty list create() { … } keeps the full field-derived input, as does the create with crudish synthesizes, and an event-sourced create's parameters are its command payload. The parens stay: create() { … }, not create { … }. Unlike destroy, whose parens are optional, Create requires them (Expecting token of type '(' but found \{`` otherwise) — a deliberate asymmetry recorded in the grammar, and the reason this sentence spells the form.

Call sites are checked against the same contract. A Agg.create({ … }) written in a test, a workflow body or a handler is validated against the factory the emitters actually emit, using those same two derivations: calling it on an aggregate that is not constructible is loom.create-call-not-constructible (the message names the invariant that removed the factory — e.g. invariant lines.all(l => l.currency == currency), which reaches a containment and so cannot be satisfied from the create input), omitting a required create-input field is loom.create-call-missing-field (loom.workflow-create-missing-field inside a workflow body), and passing a field a narrowed create(…) does not list is loom.create-call-field-not-in-create. Both used to pass validation and then fail the generated project's own compiler — TS2551: Property 'create' does not exist on type 'typeof Order' and TS2345: Property 'binCode' is missing. Unknown and server-owned keys at the same call site stay loom.create-unknown-field / loom.create-server-field.

A requires is rendered for the canonical pair (every backend evaluates it at its own chokepoint and denies with 403). Note the spelling: Create/Destroy have no header requires clause in the grammar — create(name: string) requires … { } is a parse error — so a lifecycle guard is written as the first statement of the body. What it may read is narrow: a create guard may read currentUser only; a destroy guard may read currentUser and this — never a parameter (loom.lifecycle-guard-unreadable). On an event-sourced aggregate a create guard is enforceable only on elixir, which hoists it to the context function and binds a principal there; the other four render the create body into a domain _init with no principal in scope, so loom.lifecycle-guard-event-sourced refuses it — naming the hosting backends that cannot enforce it, and for every create on the aggregate (the rendered one is creates[0] by index, so a named create open(...) is covered too). Gate the operation that issues the create instead. Where that refusal applies the create has no usable gate surface, so denyByDefault reports a missing gate as a warning (loom.default-deny-es-create-ungateable) rather than an unsatisfiable error; on an elixir host it stays a hard error, because there the gate is accepted.

aggregate Wallet {
  name: string
  balance: int
  create(name: string) {
    name := name          // admitted — a no-op restatement of the wire contract
  }
  destroy { }
}
// domain/wallet.ts — the factory is synthesised from the field set
static create(input: { name: string; balance: number }): Wallet {
  return new Wallet({ id: Ids.newWalletId(), name: input.name, balance: input.balance, version: 0 });
}
// http/wallet.routes.ts — POST / (operationId createWallet) …
const created = Wallet.create({ name: body.name, balance: body.balance });
await repo.save(created);
return c.json({ id: created.id as string }, 201);
// … and DELETE /{id} (operationId destroyWallet) — the delete is wrapped in a
// try/catch that turns a PG 23503 FK violation into a 409 problem+json
await repo.getById(Ids.WalletId(id));
try {
  await repo.delete(Ids.WalletId(id));
} catch (err) { /* … 23503 → 409 "Wallet is still referenced and cannot be deleted." */ }
return c.body(null, 204);
// Domain/Wallets/Wallet.cs
public static Wallet Create(string name, int balance) { var e = new Wallet(); /* … */ e.AssertInvariants(); return e; }
// Application/Wallets/Commands/DestroyWalletHandler.cs
public async ValueTask<Unit> Handle(DestroyWalletCommand command, CancellationToken cancellationToken)
{
    var aggregate = await _repo.GetByIdAsync(command.Id, cancellationToken)
        ?? throw new AggregateNotFoundException($"Wallet {command.Id} not found");
    await _repo.DeleteAsync(aggregate, cancellationToken);
    return Unit.Value;
}

Java (Wallet.create(...) behind WalletService.createWallet + a @DeleteMapping route), Python (a Wallet.create(...) classmethod + DELETE /{id}) and Elixir (defdelegate create_wallet(attrs), to: WalletRepository, as: :insert, plus destroy_wallet!/1) follow the same shape. The event-sourced create is the exception: its body is rendered — see apply next.

apply(e: Event) — the event-sourcing fold

On a persistedAs: eventLog aggregate, apply(e: SomeEvent) { body } is the fold that turns an emitted event into state. Applier bodies are pure folds — := / += / -=, let and if only: an emit is loom.applier-emits, a guard is loom.applier-guard, a call is loom.applier-impure-call, and a return or a bare call statement is loom.applier-stmt-invalid (an applier yields nothing and calls nothing for its effect). Phoenix renders no if in a fold (loom.elixir-if-stmt-unsupported). Every emitted event needs an applier (loom.emitted-event-no-applier / loom.emitted-event-unhandled). The command bodies decide and emit; the appliers own the actual state transition. (apply lowers to its own ApplyIR and never joins agg.operations.)

aggregate Account persistedAs: eventLog {
  owner: string
  balance: int

  create open(owner: string) {
    emit Opened { account: id, owner: owner }
  }

  operation deposit(amount: int) {
    precondition amount > 0
    emit Deposited { account: id, amount: amount }
  }

  apply(e: Opened) {
    owner := e.owner
    balance := 0
  }
  apply(e: Deposited) { balance := balance + e.amount }
}

Each applier becomes a private _apply<Event> method; a _apply dispatcher switches on the event; a _fromEvents rehydrator folds a stream from an empty shell on load.

// domain/account.ts
private _applyOpened(e: Events.Opened): void {
  this._owner = e.owner;
  this._balance = 0;
}
private _applyDeposited(e: Events.Deposited): void {
  this._balance = this._balance + e.amount;
}
private _apply(ev: Events.DomainEvent): void {
  switch (ev.type) {
    case "Opened":
      this._applyOpened(ev as Events.Opened);
      break;
    case "Deposited":
      this._applyDeposited(ev as Events.Deposited);
      break;
  }
}
static _fromEvents(id: Ids.AccountId, events: Events.DomainEvent[]): Account {
  const inst = Account._rehydrate({ id } as unknown as { id: Ids.AccountId; owner: string; balance: number });
  for (const ev of events) inst._apply(ev);
  return inst;
}
// Domain/Accounts/Account.cs
private void _ApplyOpened(Opened e)
{
    Owner = e.Owner;
    Balance = 0;
}
private void _ApplyDeposited(Deposited e)
{
    Balance = this.Balance + e.Amount;
}
private void _Apply(IDomainEvent ev)
{
    switch (ev)
    {
        case Opened e: _ApplyOpened(e); break;
        case Deposited e: _ApplyDeposited(e); break;
    }
}
public static Account _FromEvents(AccountId id, IReadOnlyList<IDomainEvent> events) { /* fold from an empty shell */ }
// features/accounts/Account.java — a sealed-interface switch
private void _applyOpened(Opened e) {
    this.owner = e.owner();
    this.balance = 0;
}
private void _applyDeposited(Deposited e) {
    this.balance = this.balance + e.amount();
}
void _apply(DomainEvent ev) {
    switch (ev) {
        case Opened e -> _applyOpened(e);
        case Deposited e -> _applyDeposited(e);
        default -> { }
    }
}
public static Account _fromEvents(AccountId id, List<DomainEvent> events) {
    var e = new Account();
    e.id = id;
    for (var ev : events) e._apply(ev);
    e._assertInvariants();
    return e;
}
# app/domain/account.py — isinstance ladder
def _apply_opened(self, e: Opened) -> None:
    self._owner = e.owner
    self._balance = 0

def _apply_deposited(self, e: Deposited) -> None:
    self._balance = self._balance + e.amount

def _apply(self, ev: DomainEvent) -> None:
    if isinstance(ev, Opened):
        self._apply_opened(ev)
    elif isinstance(ev, Deposited):
        self._apply_deposited(ev)
# lib/ex_api/accounts/account_fold.ex — one apply_event/2 clause per applier
def apply_event(state, %ExApi.Accounts.Events.Opened{} = e) do
  state = %{state | owner: e.owner}
  state = %{state | balance: 0}
  state
end

def apply_event(state, %ExApi.Accounts.Events.Deposited{} = e) do
  state = %{state | balance: state.balance + e.amount}
  state
end

def apply_event(_state, ev) do
  raise ArgumentError, "no applier for event #{inspect(ev.__struct__)}"
end

The full runnable example is examples/event-sourcing.ddd. See ../workflow.md §"Member forms" for the applier discipline and how the event log persists.

return — the exception-less outcome

operation foo(): X or Error { … return … } declares an or-union return; instead of throwing, the body returns a designed-in outcome that the route translates — an error variant to an RFC-7807 status, a success variant to 200. Each return is tagged at lowering with the variant whose structural shape matches the returned value. An error with no stdlib status and no httpStatus <Error> -> <code> line on the serving api defaults to 500 and warns (loom.unmapped-error-status).

context Orders {
  error NotAllowed { reason: string }
  aggregate Order {
    status: Status
    operation place(): Order or NotAllowed {
      precondition status == Status.Draft
      return NotAllowed { reason: "already placed" }
    }
  }
}
// domain/order.ts — record variant flattens beside the `type` tag on the wire
public place(): ({ type: "Order"; id: string; /* …the Order wire shape… */ } | { type: "NotAllowed"; reason: string }) {
  if (!(this._status === Status.Draft)) throw new DomainError("Precondition failed: status == Status.Draft");
  return { type: "NotAllowed", ...(({ reason: "already placed" })) };
}
// Domain/Orders/Order.cs — constructs the union variant record <Union>_<Tag>(…)
public OrderOrNotAllowed Place()
{
    if (!(this.Status == Status.Draft)) throw new DomainException("Precondition failed: status == Status.Draft");
    return new OrderOrNotAllowed_NotAllowed("already placed");
}
// features/orders/Order.java — variant record, args ordered by the variant's declared fields
public OrderOrNotAllowed place() {
    if (!(this.status == Status.Draft)) throw new DomainException("Precondition failed: status == Status.Draft");
    return new OrderOrNotAllowed_NotAllowed("already placed");
}
# app/domain/order.py — a dict carrying the wire keys
def place(self) -> dict[str, object]:
    if not (self._status == Status.Draft):
        raise DomainError("Precondition failed: status == Status.Draft")
    return {"type": "NotAllowed", **{"reason": "already placed"}}
# lib/ex_api/orders.ex — the value is the last expression; an error variant is an {:error, tag, fields} tuple
def place_order(%ExApi.Orders.Order{} = record, params) when is_map(params) do
  with :ok <- ensure(record.status == :Draft, {:precondition_failed, "Precondition failed: status == Status.Draft"}) do
    {:error, "NotAllowed", %{reason: "already placed"}}
  end
end

A record variant flattens its fields beside type on the wire; a scalar variant wraps a value; a none variant is the bare { type: … }. See Payloads & unions for the union wire shape and httpStatus mapping.

A caller must handle the error case (loom.unhandled-result, D-FAILURE-MODEL). An or E result whose E is an error is matched, returned through a caller whose own declared result admits it, or bound with let and read. A call statement and an unread let drop it, which in a workflow would answer 204 on the failure. There is no implicit propagation.

operation withdraw(amt: money): Account or InsufficientCredit { … }
operation drain(amt: money) { withdraw(amt) }                                   // loom.unhandled-result
operation pay(amt: money): Account or InsufficientCredit { return withdraw(amt) } // OK — returned
public pay(amt: Decimal): ({ type: "Account"; id: string; balance: Decimal; version: number } | { type: "InsufficientCredit"; available: Decimal }) {
  return this.withdraw(amt);
}

match — the effect-form variant match

match SUBJECT { Variant [b] => stmt | { stmts }, …, else => … } (MatchStmt / VariantStmtArm) is the statement twin of the variant-match expression: its arms run statements rather than yield a value, and each arm may bind the narrowed variant. It lowers to the variant-match StmtIR kind, which is frontend-only: it renders in a page / component / store action body and has no backend form. Written anywhere else — an operation, a create / destroy / apply body, a function, a domain-service operation, a projection on fold, or a workflow / handler body — it is refused at phase ④ by loom.variant-match-placement, a permanent placement rule. (Until M-T5.28 nothing gated it: a domain-body match validated clean and then threw variant-match statement is frontend-only … out of the shared statement dispatcher on all five backends. That throw survives as an internal-invariant assertion for a caller that generates without validating.) Every arm of an or-union must be covered or an else supplied.

ui Console {
  api Orders: OrdersApi
  page OrderPage(id: Order id) {
    route: "/orders/:id"
    state { message: string = "" }
    action place() {
      match await Orders.Order.place() {
        Order o      => { message := "placed" }
        NotAllowed e => { message := e.reason }
      }
    }
    body: Stack { Text { message }, Button { "Place", onClick: place } }
  }
}

The await is the effect marker. A bare Orders.Order.place() in an action body is loom.missing-effect-marker (an error — every remote mutating command is awaited and its result matched); the same call inside a render-tree lambda is loom.effect-in-lambda; an instance op needs a :id route param in scope (loom.instance-effect-needs-route-id); the call's arguments must match the operation signature (loom.match-await-arg-mismatch / loom.match-await-arg-type). A context-local error resolves in an arm only when the page's api X: Y handle binds the context that owns it.

// src/pages/order_page.tsx — the awaited mutation, the ProblemDetails → variant
// re-tagging, and one switch case per arm
const orderPlace = usePlaceOrder(id ?? "");
const place = async () => { {
  let result: PlaceOrderResponse;
  try {
    result = await orderPlace.mutateAsync({});
  } catch (e) {
    if (e instanceof ApiError) {
      result = { ...(e.body as Record<string, unknown>), type: "NotAllowed" } as PlaceOrderResponse;
    } else {
      throw e;
    }
  }
  switch (result.type) {
    case "Order": {
      const o = result;
      setMessage("placed");
      break;
    }
    case "NotAllowed": {
      const e = result;
      setMessage(e.reason);
      break;
    }
  }
} };

Vue / Svelte / Angular / Feliz / Flutter emit the same try-match shape through their own reactivity primitives; Phoenix LiveView renders each variant as a case clause on the context function's {:ok, _} / {:error, "Tag", _} result. See ../actions.md for the full contract and UI pages for action itself.

Handler bodies — commandHandler / queryHandler

[extern] commandHandler Name(params) [: Return] { … } and [extern] queryHandler Name(params): Return { … } are context-level, application-layer handlers — the named entry points of a context, routable from an api { route POST "/…" -> Ctx.Name } body. Their body is the workflow vocabulary (lower-workflow.ts, see Workflows): let x = Repo.getById(id) / a single non-nullable find, an optional read bound as T? (let c = Repo.find(<Criterion>), findById, findBy, a T? find), if let x = <optional read or binding> { … } else { … }, an operation call on the binding, Repo.delete(x), precondition / requires, emit, return. A loaded aggregate is saved automatically when an operation was invoked on it — you don't write the save. Gates: a non-extern handler needs a body (loom.handler-missing-body) and an extern one must be bodyless ; (loom.extern-handler-has-body); a parameter may not be named id (loom.handler-param-reserved-id); an optional binding is not dereferenced until a null test or an if let narrows it (loom.nullable-receiver); a queryHandler may not mutate (loom.query-handler-saves) and a commandHandler touches one aggregate (loom.command-handler-multi-aggregate).

context Accounts {
  aggregate Wallet {
    name: string
    operation rename(label: string) { name := label }
  }
  repository Wallets for Wallet { }
  commandHandler RenameWallet(walletId: Wallet id, name: string): Wallet {
    let w = Wallets.getById(walletId)
    w.rename(name)
    return w
  }
}
api LedgerApi from Core {
  route POST "/wallets/{walletId}/rename" -> Accounts.RenameWallet
}
// http/ledgerApi-routes.ts — the routed handler body, save implied by the op-call
const w = await wallets.getById(walletId);
w.rename(name);
await wallets.save(w);
return httpCtx.json(wallets.toWire(w) as z.infer<typeof WalletResponse>, 200);
// Application/Wallets/Commands/RenameWalletHandler.cs
public async ValueTask<Wallet> Handle(RenameWalletCommand command, CancellationToken cancellationToken)
{
    var w = await _wallets.GetByIdAsync(command.WalletId, cancellationToken)
        ?? throw new AggregateNotFoundException($"Wallet {command.WalletId} not found");
    w.Rename(command.Name);
    await _wallets.SaveAsync(w, cancellationToken);
    return w;
}
// application/workflows/RenameWalletHandler.java
public Wallet handle(WalletId walletId, String name) {
    var w = walletsRepository.getById(walletId);
    w.rename(name);
    walletsRepository.save(w);
    return w;
}
# app/application/rename_wallet.py
async def rename_wallet(session: AsyncSession, wallet_id: WalletId, name: str) -> dict[str, object]:
    wallets = WalletRepository(session, NoopDomainEventDispatcher())
    w = await wallets.get_by_id(wallet_id)
    w.rename(name)
    await wallets.save(w)
    return wallets.to_wire(w)
# lib/ex_api/accounts/handlers/rename_wallet.ex — the context's own operation
# function persists, so the handler only chains load → op
def run(params) when is_map(params) do
  %{"wallet_id" => wallet_id, "name" => name} = params
  with {:ok, w} <- Context.get_wallet(wallet_id),
       {:ok, _} <- Context.rename_wallet(w, %{"label" => name}) do
    {:ok, w}
  end
end

An extern handler keeps the routed dispatch but calls a scaffold-once, user-owned implementation file — see Externs and ../extern.md. The scaffold macros scaffoldApi / scaffoldHandlers unfold this layer for you (Macros).

for & if let — workflow bodies only

for x in xs { … } and if let x = Repo.find(C) { … } else { … } parse via the same Statement rule but are meaningful only inside workflow (and commandHandler / queryHandler) bodies — there they lower (lower-workflow.ts) to the for-each / if-let WorkflowStmtIR kinds with per-iteration / per-branch repository saves. The iterable is a ForIterable (a name plus optional postfix suffixes), so for n in notes parses but for n in [1, 2] does not.

The aggregate-body lowerer (lower-stmt.ts) has no arm for either, so anywhere else both are refused at phase ④ (src/language/validators/stmt-placement.ts) rather than lowered into the <unknown> call sentinel they used to produce:

aggregate Order {
  notes: string[]
  operation touch() {
    for n in notes { code := n }   // loom.for-placement
    if let c = code { code := c }  // loom.if-let-placement
  }
}

The two codes say different things on purpose. loom.if-let-placement is a permanent placement rule: if let exists to bind the optional result of a repository read, and a domain body reaches its own state through this (a cross-aggregate read from inside an aggregate is already refused by loom.infra-call-from-aggregate). loom.for-placement is an honest gap — nothing about a loop is workflow-specific, only the per-iteration save the workflow lowering owns is — and its message names the successor mission (M-T5.30) that tracks lowering for into domain bodies.

Before the gates landed, operation touch() { for n in notes { owner := n } } validated clean (0 error(s), 0 warning(s)) and every backend emitted a call to a method that does not exist — this.<unknown>() (node/.NET/Java), self._<unknown>() (Python), _ = <unknown>(record) (Elixir).

notFound — the not-found answer

notFound / notFound "detail" ends a workflow or handler body with the standard ProblemDetails 404 — the same response a getById miss produces, because each backend raises its by-id read's own error (AggregateNotFoundError on node / python, AggregateNotFoundException on .NET / Java, the tagged {:not_found, detail} on Elixir). Its home is the else of an if let over a criterion read, which has no uniqueness guarantee and so no must-exist get:

commandHandler EscalateOpen(customer: string) {
  if let t = Tickets.find(OpenFor(customer)) { t.escalate() } else { notFound "no open ticket" }
}
// node (Hono)
} else {
  throw new AggregateNotFoundError("no open ticket");
}

A bare notFound in an if let else names the lookup's aggregate (Ticket not found). Two placement rules: it is refused outside a workflow / handler body (loom.not-found-placement — an operation is called by a handler and refuses with precondition / requires / a declared error variant), and it must be the last statement of its block (loom.not-found-unreachable — anything after it can never run). See workflow.md.

Choosing a callable kind

Pick by what the body must be allowed to touch, not by what feels natural to write. Each row is a different capability set; the validators below enforce the "no" cells.

Kind Purpose May mutate? Transaction scope Routed (HTTP)? May call resources? Emits events? Minimal example
aggregate operation A state change on one aggregate this only One aggregate save (its own request) POST /api/<aggs>/{id}/<op>; none when private No (loom.resource-op-outside-workflow) Yes (emit) operation place() { status := Status.Placed }
aggregate create / destroy Factory / terminator of the root create: the new instance; destroy: nothing One aggregate save POST /api/<aggs> / DELETE /api/<aggs>/{id} No create: yes; destroy: no (body is requires only) create(total: int) { emit OrderPlaced { order: id } }
aggregate apply (persistedAs: eventLog only) Pure fold of one emitted event into state this; assignments and let only None — runs when events are appended and replayed No No No apply(e: OrderPlaced) { status := Status.Placed }
function (aggregate / entity / workflow / value object) Reusable pure calculation No None No No No function isDraft(): bool = status == Status.Draft
domainService operation Cross-aggregate calculation or decision Only the aggregates passed in, and only in the mutating tier None — the calling orchestrator owns the commit No No No operation withTax(base: int): int { return base + base.divTrunc(5) }
workflow create Use-case that spans aggregates Via aggregate operations (:= is rejected in the body) Per save; one DB transaction with transactional POST /api/workflows/<wf>… Yes — not inside a transactional span Yes (orchestration-level emit) create(target: Order id) by target { … }
workflow on(e: Event) React to an event (saga step) As create As create No — driven by the event As create Yes on(paid: PaymentReceived) by paid.order { … }
commandHandler One-aggregate use-case without a workflow One aggregate (loom.command-handler-multi-aggregate); save is implied The one aggregate save Only through an explicit api { route … -> Ctx.Name } Yes Yes commandHandler PlaceOrder(orderId: Order id): Order { … }
queryHandler One-aggregate read with a typed result No (loom.query-handler-saves) None Only through an explicit route Yes No queryHandler GetOrder(orderId: Order id): Order { … }
page / component action Client-side event handler Page state / store only None — it runs in the browser No (it calls routes, via match await) No No (UI effects such as navigate / toast) action bump() { count += 1 }

Rules of thumb: a rule about one aggregate's own state belongs in its operation; a calculation that reads two aggregates but writes none is a domainService; the moment a use-case needs a second aggregate, a resource, an inbound event or an explicit transaction it is a workflow; a single-aggregate endpoint that needs a custom route or a resource is a commandHandler / queryHandler. Everything the table calls pure can be called from anywhere; everything that mutates is reachable only from the kinds that may mutate. All the kinds below parse and validate together:

system Shop {
  subdomain Sales {
    context Orders {
      enum Status { Draft, Placed }
      event OrderPlaced { order: Order id }

      aggregate Order {
        total: int
        status: Status = Draft

        function isDraft(): bool = status == Status.Draft
        create(total: int) { emit OrderPlaced { order: id } }
        operation place() when status == Status.Draft {
          status := Status.Placed
          emit OrderPlaced { order: id }
        }
        destroy() { }
      }
      repository Orders for Order { }

      domainService Pricing {
        operation withTax(base: int): int { return base + base.divTrunc(5) }
      }

      workflow checkout transactional {
        orderId: Order id
        create(target: Order id) by target {
          let o = Orders.getById(target)
          o.place()
        }
      }

      commandHandler PlaceOrder(orderId: Order id): Order {
        let o = Orders.getById(orderId)
        o.place()
        return o
      }
      queryHandler GetOrder(orderId: Order id): Order {
        let o = Orders.getById(orderId)
        return o
      }
    }
  }
  api ShopApi from Sales {
    route POST "/orders/{orderId}/place" -> Orders.PlaceOrder
  }
  storage pg { type: postgres }
  resource ordersState { for: Orders, kind: state, use: pg }
  ui Web {
    api Shop: ShopApi
    page Home {
      route: "/"
      state { count: int = 0 }
      action bump() { count += 1 }
      body: Button { "+", onClick: bump }
    }
  }
  deployable api { platform: node contexts: [Orders] dataSources: [ordersState] serves: ShopApi port: 3000 }
  deployable web { platform: react targets: api ui: Web { Shop: api } port: 3001 }
}

The modifiers each kind accepts (private, extern, audited, requires, when) are in the callable modifier surface below; the per-kind detail lives in Workflows, Domain services & seeds, Handler bodies and Actions.

The callable modifier surface

Fifteen grammar rules mean "a named body of statements runs here" — operation, create, destroy, apply, function, commandHandler, queryHandler, a domainService operation, a workflow's create / handle / on, a page action — and each one used to hard-code its own subset of the modifier and clause surface. Writing audited on a domainService operation was a bare parse error with nothing to say why.

Since M-T5.21 the grammar accepts the whole surface at every one of those sites, through three shared fragments (CallableLeadModifiers / CallableSigModifiers / CallableGates in src/language/ddd.langium), and what is legal where is declared data — CALLABLE_SITES in src/language/callable-sites.ts, read by one validator. Header order is the aggregate operation spelling every other site is a fork of:

[private] <keyword> <name>(<params>) [extern] [audited] [: <ReturnType>] [requires <expr>] [when <expr>] { … }
site private extern audited requires when
aggregate operation yes yes yes yes yes
create / destroy — — yes — —
apply — — — — —
function — — — — —
commandHandler / queryHandler — yes — — —
domainService operation — — — — —
workflow create — — — yes —
workflow on — — — — —
page / component action — — — — —

Anything outside a row is loom.callable-modifier-not-allowed-here, and the message says what the site is rather than only that the modifier is wrong:

domainService Pricing {
  operation quote(base: int) audited : int { return base }
}
error: 'audited' is not allowed on a domain-service operation. A domain service is a
stateless, context-internal calculator: no instance to gate, no route to authorize, no
persistence to audit — and its body already refuses repository / extern / emit /
this-write. Move the rule to the aggregate operation that owns the state.

The table states today's surface exactly: no capability was added or removed when it landed, and emission is byte-identical across all eleven targets. A row that gains a modifier is an emitter obligation on every target, so widening one is a change to make deliberately, not a table edit.

The two callable-shaped declarations that are deliberately not rows: criterion (it carries of <T> / as <alias> and is inlined at its call sites) and component (it returns markup, not a value). The ui-level function is a separate rule that is extern by construction — the extern from "<path>" clause is its body — so it has no modifier surface to police.

Parameter defaults

param: T = <expr> parses at every callable site (one shared Parameter rule), but only an aggregate operation / create / destroy and a workflow create lower the default so the generated signature carries it. A domainService operation, a commandHandler / queryHandler and a function drop it — so a caller omitting the argument would reach a signature that still requires it. A default there is loom.param-default-unsupported:

domainService Pricing {
  operation quote(base: int = 3): int { return base }
}
error: Parameter 'base' declares a default, but a domain-service operation does not
support parameter defaults: the default would be dropped and every generated signature
would still require the argument. Remove '= …' and pass the value at each call site.