Skip to main content
A guard blocks the handler unless its CEL check passes. It is the first decision in the handler, after the entity is acquired.
The check reads entity, payload, and policy. on_fail says what happens when the check fails; the values are covered below. A guard reads the entity before this handler’s own writes, so it reflects what earlier handlers left, not what this one is about to change.

Multiple checks

When a guard has several conditions, write them as a list under checks instead of one long && expression. Each entry carries its own id:
The reason to split them is the failure report. When a guard fails, the trace and the dead-letter queue (where undeliverable events land) record the id of the check that failed, so you can see which condition blocked the handler instead of just learning that some combined expression came back false. In production this matters: a callback handler usually needs two separate checks — one that the callback belongs to this entity, and one that it has not already been processed — and naming them separately tells you which one fired. The list is evaluated top to bottom and stops at the first failure, so every check must pass for the guard to pass. There is one on_fail for the whole guard; an individual check cannot set its own. If you supply checks, the singular check is ignored, so use one form or the other, not both.

on_fail

on_fail chooses what happens when a check fails. It defaults to reject. The chosen value becomes the handler’s trace outcome, visible in swarm trace and recorded on the event delivery row, so picking the right one is what makes guard failures legible in operations.

Choosing between them

  • reject: the default. The event tried, the guard said no, it’s recorded as a failure. Operationally noisy on purpose; a rejection rate worth alerting on.
  • discard: the expected-failure value. A handler that subscribes to payment.received but only acts on currency == "USD" uses discard for everything else: not interesting, not an error.
  • kill: terminal cliff. The entity has hit a hard policy boundary (rate limit, fraud threshold, blocked region) and shouldn’t continue, ever. Advances to terminal; no recovery path from the guard alone.
  • escalate:{event}: routes the failure somewhere useful instead of just stopping. The escalation event is a normal event with a handler somewhere downstream.

Escalate emits with no implicit passthrough

Payload construction for an escalate event is covered in detail on Emitting events. When escalate:{event} fires, the emitted event follows the same payload-construction rules as a declarative system-node emit: block. Nothing is auto-copied from the triggering event: the payload stays empty unless the event declares fields and a matching escalate_payload: supplies them. The runtime-owned envelope (event_id, run_id, entity_id, etc.) is still attached; only business fields need authoring. Schema validation still runs fail-closed, so an escalate event that declares required fields without authored values fails at the emit step rather than silently shipping a half-filled payload. The practical consequence: if rate_limit.exceeded declares reason: text, you must author reason somewhere; the runtime won’t fill it from payload.reason on the triggering event.

What a failed guard leaves behind

When a guard fails, the handler stops: no state change, no data writes, no emit (other than the escalate one if configured). Guard failures are business logic, not transient errors, so they are not retried. See the execution model for where the guard sits in the pipeline.