Intent Rules
Rules are evaluated in strict phase order: preflight → core → side_effects. Each rule has a name, optional when condition, and a then list of operations.
preflight:
rules:
- name: my_validation_rule
when: { expr: "intent.quantity <= 0" }
then:
- op: reject
code: INVALID_QUANTITY
core:
rules:
- name: my_mutation_rule
when: {}
then:
- op: set
path: /status
value: active
side_effects:
rules:
- name: my_fanout_rule
then:
- op: emit
event_type: ORDER_PLACED
payload:
order_id: intent.order_idRule structure
- name: reject_duplicate_checkin # snake_case; required
priority: 100 # lower runs first; default 100
when:
expr: "entity.last_checkin == event.show_id" # optional; omit or use when: {}
then:
- op: reject
code: DUPLICATE_CHECKIN| Field | Required | Description |
|---|---|---|
name | yes | Unique rule name within the phase (snake_case) |
when | no | { expr: "..." } — rule runs only when expression is truthy. when: {} always runs |
priority | no | Lower number runs first (default 100) |
then | yes | Ordered list of operations |
Warning: Rule
when:uses{ expr: "..." }. Array opwhere:fields (filter,remove,find) use plain strings:where: "it.active == true". Using{ expr: ... }underwhere:causes a compile-timeClassCastException.
Phase summary
| Phase | Purpose | Can mutate entity? | Can reject? |
|---|---|---|---|
preflight | Validate before any write | no | yes |
core | Mutate target entity + emit ledger events | yes (own stream) | no |
side_effects | Fan-out after ledger commit | no direct writes | no |
Execution order at runtime:
preflight → core → (ledger commit) → side_effectsIf preflight rejects, core and side_effects never run.
Preflight
Read-only validation. Abort the intent with reject when a business rule fails. No state mutations, no emits, no fan-out.
Allowed operations
| Op | Purpose |
|---|---|
reject | Abort intent with error code |
if | Conditional validation branches |
lookup | Load another entity for checks |
find | Bind an array element to a variable (read-only use) |
for_each | Iterate when each item needs validation |
stop / continue | Control for_each loops |
Use LOOKUP_FIELD(...) inside expr for inline cross-stream reads without lookup.
Forbidden in preflight
set, add, sub, push, merge, unset, remove, filter, map, sort, clone, emit, emit_each, submit, schedule, decision, lock, unlock, relationship_create, relationship_remove
The compiler rejects these with VERR_PREFLIGHT_SIDE_EFFECT.
reject
Abort the intent with a typed error code. Use in preflight to return structured errors to callers.
- op: reject
code: TICKET_LIMIT_EXCEEDED
message: "You can only purchase 4 tickets per show"| Field | Required | Description |
|---|---|---|
code | yes | SCREAMING_SNAKE_CASE rejection code returned to the client |
message | no | Human-readable message |
preflight:
rules:
- name: reject_self_follow
when: { expr: "intent.user_id == intent.artist_id" }
then:
- op: reject
code: CANNOT_FOLLOW_SELF
message: "A user cannot follow themselves"
- name: check_stock
when: {}
then:
- op: if
expr: "LOOKUP_FIELD('show_stream', intent.show_id, 'available_tickets') < intent.quantity"
then:
- op: reject
code: INSUFFICIENT_STOCKlookup (preflight)
Load another entity’s state into a scratch path for validation.
- op: lookup
into: /_tmp/show
stream: show_stream
entity_id_expr: intent.show_id
fields:
- title
- available_tickets
- op: if
expr: "_tmp.show == null"
then:
- op: reject
code: SHOW_NOT_FOUND| Field | Required | Description |
|---|---|---|
into | yes | Path to write result — use /_tmp/... for scratch |
stream | yes | Target stream name |
entity_id_expr | yes | Expression for entity ID |
fields | no | Optional field subset to load |
Expression alternative: LOOKUP_FIELD('show_stream', intent.show_id, 'title') inline in expr without binding a variable.
if (preflight)
Branch validation logic. Nested ops inside then/else must also be preflight-safe.
- op: if
expr: "intent.quantity <= 0"
then:
- op: reject
code: INVALID_QUANTITY
else:
- op: if
expr: "intent.quantity > 10"
then:
- op: reject
code: BULK_LIMIT_EXCEEDED| Field | Required | Description |
|---|---|---|
expr | yes | Condition string |
then | yes | Ops when truthy |
else | no | Ops when falsy |
find (preflight)
Bind the first matching array element to a variable — useful for duplicate detection.
- op: find
path: /following
where: "it == intent.artist_id"
as: existing_follow
- op: if
expr: "existing_follow != null"
then:
- op: reject
code: ALREADY_FOLLOWING| Field | Required | Description |
|---|---|---|
path | yes | Array field path |
where | yes | Plain string; it = current element |
as | yes | Variable name (null if not found) |
Core
Primary state mutations on the target entity. Relationship edges, array transforms, and event emission that participates in the ledger commit belong here.
Allowed operations
| Category | Ops |
|---|---|
| Mutation | set, add, sub, unset, merge |
| Array | push, remove, filter, find, map, sort, clone |
| Relationships | relationship_create, relationship_remove |
| Events | emit, emit_each |
| Concurrency | lock, unlock |
| Control flow | if, for_each, stop, continue |
emit in core is common — state change and domain event are committed together.
Forbidden in core
reject, submit, schedule, decision — use side_effects for fan-out and async work. Cross-stream set/add/sub on another entity’s stream is forbidden; use submit in side_effects instead.
set
Set a field to a value (overwrites existing).
- op: set
path: /status
value: purchased
- op: set
path: /owner_id
value: intent.user_id| Field | Required | Description |
|---|---|---|
path | yes | Field path (/cart/total) |
value | yes | Literal or expression string |
if_missing | no | create (default), fail, skip |
target_stream | no | Cross-stream write (avoid — prefer submit) |
target_entity | no | Entity ID for cross-stream write |
add / sub
Increment or decrement a numeric field.
- op: add
path: /view_count
value: 1
cap: { min: 0 }
- op: sub
path: /inventory/on_hand
value: intent.quantity
cap: { min: 0 }| Field | Required | Description |
|---|---|---|
path | yes | Numeric field path |
value | yes | Amount (literal or expression) |
cap | no | { max: N, min: N } — clamp result |
if_missing | no | create, fail, skip |
unset
Remove a field from entity state.
- op: unset
path: /tmp/scratchmerge
Shallow-merge an object into an existing field or bound variable.
- op: merge
path: /metadata
value:
last_seen: event.ts
source: intent.sourceNested objects are replaced, not deep-merged. Use multiple set ops for nested paths.
push
Append to an array.
- op: push
path: /following
value: intent.artist_id
- op: push
path: /cart/items
value:
product_id: intent.product_id
quantity: intent.quantityremove
Remove array elements matching where. where is a plain string.
- op: remove
path: /social/friends
where: "it == event.show_id"filter
Keep only array elements matching where.
- op: filter
path: /cart/items
where: "it.quantity > 0"find
Find first matching element and bind to variable — often paired with merge or if.
- op: find
path: /cart/items
where: "it.product_id == intent.product_id"
as: existing_item
- op: if
expr: "existing_item != null"
then:
- op: merge
path: existing_item
value:
quantity: "existing_item.quantity + intent.quantity"
else:
- op: push
path: /cart/items
value:
product_id: intent.product_id
quantity: intent.quantitymap
Transform each array element in place.
- op: map
path: /items
as: it
value:
sku: it.sku
qty: it.qty
subtotal: "it.qty * it.price"sort
Sort an array. Prefix - for descending.
- op: sort
path: /items
by: it.price
- op: sort
path: /items
by: "-it.price"clone
Deep-copy a value into a variable for use later in the same rule.
- op: clone
path: /items
as: original_itemsrelationship_create
Create a relationship edge from the current entity.
- op: relationship_create
relationship: artist_followers
to_id: intent.artist_id
# one_to_many — id required
- op: relationship_create
relationship: user_friends
to_id: intent.friend_id
id: "concat(intent.user_id, '_', intent.friend_id)"| Field | Required | Description |
|---|---|---|
relationship | yes | Declared relationship name |
to_id | yes | Target entity ID expression |
id | sometimes | Required for one_to_many edges |
relationship_remove
Remove a relationship edge.
- op: relationship_remove
relationship: artist_followers
to_id: intent.artist_idemit (core)
Emit a domain event as part of the ledger commit. Event type must be declared in events:.
- op: emit
event_type: ARTIST_FOLLOWED
payload:
user_id: intent.user_id
artist_id: intent.artist_id
ts: event.ts| Field | Required | Description |
|---|---|---|
event_type | yes | Declared event type |
payload | yes | Key-value map; values are expressions or literals |
target_stream | no | Emit on a different stream |
target_entity | no | Target entity ID expression |
emit_each (core)
Emit one event per collection item. Use item in payload expressions.
- op: emit_each
from: "entity.cart.items"
event_type: INVENTORY_RESERVE_REQUESTED
target_stream: inventory_stream
target_entity: "item.product_id"
payload:
order_id: intent.order_id
product_id: item.product_id
quantity: item.quantitylock / unlock (core)
Acquire and release a concurrency lock within a rule. Pair lock/unlock inside the same for_each scope.
- op: lock
key: item.sku
- op: unlock
key: item.skuNot allowed in preflight.
if / for_each / stop / continue (core)
- op: for_each
for_each: "entity.notifications ?: []"
then:
- op: emit
event_type: NOTIFICATION_SENT
payload:
notification_id: item.id
- op: if
expr: "entity.status == 'banned'"
then:
- op: stopstop halts the current rule. continue skips to the next for_each iteration (only valid inside for_each).
Side effects
Fan-out after the ledger commit. Failures here do not roll back core mutations.
Use side effects for: cross-entity work (submit), delayed work (schedule), external AI (decision), and emits that should not block the primary commit path.
Allowed operations
| Op | Purpose |
|---|---|
emit | Emit domain event |
emit_each | Emit per collection item |
submit | Submit follow-up intent on another entity |
schedule | Deferred event or intent |
decision | AI decision (external LLM) |
if, for_each, stop, continue | Control flow wrapping the above |
lock, unlock | Concurrency (rare in side effects) |
Forbidden in side effects
Direct state writes (set, add, sub, push, etc.), reject, relationship_create, relationship_remove
emit (side effects)
Same syntax as core. Use when the emit should run after commit or must not block validation/mutation.
side_effects:
rules:
- name: emit_purchase
then:
- op: emit
event_type: TICKET_PURCHASED
payload:
ticket_id: intent.ticket_id
user_id: intent.user_idemit_each (side effects)
- op: emit_each
from: "entity.line_items"
event_type: LINE_ITEM_FULFILLED
payload:
order_id: intent.order_id
sku: item.skusubmit
Submit a follow-up intent — invokes another action on a target entity. Side effects only.
- op: submit
intent_type: CREATE_NOTIFICATION
target_stream: user_stream
target_entity: intent.user_id
payload:
user_id: intent.user_id
message: "Your ticket has been confirmed"| Field | Required | Description |
|---|---|---|
intent_type | yes | Target action name |
payload | yes | Input map for the target intent |
target_stream | no | Stream override |
target_entity | no | Entity ID expression |
Submitted intents run asynchronously after the parent intent completes.
schedule
Schedule a deferred event or intent.
- op: schedule
event_type: TICKET_REMINDER_SENT
delay_seconds: 86400
payload:
ticket_id: intent.ticket_id
user_id: intent.user_id
- op: schedule
intent_type: SEND_POST_SHOW_RECAP
delay_seconds: 86400
payload:
user_id: intent.user_id
show_id: intent.show_id| Field | Required | Description |
|---|---|---|
delay_seconds | yes | Delay before execution |
event_type | one of | Schedule a deferred event |
intent_type | one of | Schedule a deferred intent |
Exactly one of event_type or intent_type is required.
decision
Invoke a compiled AI decision. External LLM call — not replayed from ledger. Side effects only.
- op: decision
ref: triage_ticket
input:
ticket_id: intent.ticket_id
customer_id: intent.customer_id
subject: intent.subject
body: intent.body| Field | Required | Description |
|---|---|---|
ref | yes | Decision name under decisions: in your DSL |
input | no | Map of decision input field → expression |
The runtime validates input, retrieves vector memories, calls the provider, and emits the event declared on the decision (emits:).
See AI Decisions and Support Copilot.
if (side effects)
Wrap fan-out ops conditionally.
side_effects:
rules:
- name: notify_on_large_order
when: { expr: "intent.total_cents > 10000" }
then:
- op: submit
intent_type: ALERT_FRAUD_TEAM
payload:
order_id: intent.order_idComplete example
actions:
PURCHASE_TICKET:
state: ticket
entity_id_expr: intent.ticket_id
input:
ticket_id: { type: string, required: true }
user_id: { type: string, required: true }
show_id: { type: string, required: true }
quantity: { type: integer, required: true }
preflight:
rules:
- name: validate_quantity
when: { expr: "intent.quantity <= 0" }
then:
- op: reject
code: INVALID_QUANTITY
- name: check_stock
when: {}
then:
- op: if
expr: "LOOKUP_FIELD('show_stream', intent.show_id, 'available_tickets') < intent.quantity"
then:
- op: reject
code: INSUFFICIENT_STOCK
core:
rules:
- name: mark_purchased
when: {}
then:
- op: set
path: /status
value: purchased
- op: emit
event_type: TICKET_PURCHASED
payload:
ticket_id: intent.ticket_id
user_id: intent.user_id
show_id: intent.show_id
quantity: intent.quantity
side_effects:
rules:
- name: notify_user
then:
- op: submit
intent_type: CREATE_NOTIFICATION
target_stream: user_stream
payload:
user_id: intent.user_id
message: "Your ticket has been confirmed"
- name: schedule_reminder
then:
- op: schedule
event_type: TICKET_REMINDER_SENT
delay_seconds: 86400
payload:
ticket_id: intent.ticket_idNext steps
- Expressions —
expr,value, andLOOKUP_FIELDsyntax - Operations — quick-reference tables
- DSL: Operations — surgical per-op reference
- DSL: actions — full platform reference
- Examples — concert-app patterns