An EMU trigger is a boolean expression over typed context. The language is deliberately small so a policy is inspectable, reproducible, and statically analyzable.
Quick reference#
state.user.tier == 'premium'
state.user.tier IN ['gold', 'platinum']
state.account.verified == true AND state.account.suspended == false
tag.intent == 'refund_request'
event.agent.sent.email IN 'PT24H'
COUNT event.user.failed.login IN 'PT10M' >= 5
event.order.shipped.package WHERE order_id == 'ord-456' IN 'P7D'
state.user.email ENDS_WITH '@company.com'
state.document.code MATCHES '^[A-Z]{3}-\d{4}$'
Logical and special operators are uppercase. String literals and durations use single quotes. State keys are lowercase and namespaced, such as state.user.tier; a single key such as state.tier is invalid.
State conditions#
state.user.tier == 'premium'
state.ticket.priority != 'low'
state.order.total >= 1000
state.account.balance < 0
state.account.verified
NOT state.user.banned
Send numeric state for numeric comparisons. The evaluator also coerces numeric-looking strings, so validate input types before calling it rather than relying on coercion. Ordinary state string equality is case-sensitive.
Boolean shorthand checks truthiness, not a strict boolean contract. Nonzero numbers, including negative numbers, are true. Strings other than '', 'false', 'no', and '0' are true, ignoring case for those four exclusions. Thus 'unknown' is true. For permission-bearing fields, emit actual booleans and use explicit == true or == false.
Compare facts and do arithmetic#
state.refund.amount > state.customer.refund_limit
state.request.cost + state.budget.spent <= state.budget.limit
State comparisons can read another state or tag accessor. Numeric arithmetic supports +, -, *, and /; multiplication and division bind more tightly than addition and subtraction. Missing operands and division by zero make the arithmetic comparison false. Invalid nonnumeric arithmetic is an evaluation error, not a successful comparison.
Presence#
state.user.email EXISTS
state.refund.request_id EXISTS AND state.refund.amount > 0
EXISTS tests that a value is present and non-null. A positive comparison, including !=, returns false when either referenced value is missing. But the entire trigger does not necessarily become false: NOT flips that result, and an OR branch can independently match.
// With user.banned missing, the first expression is true; the second is false.
NOT state.user.banned
state.user.banned EXISTS AND state.user.banned == false
Guard required context before negation and before interpolating an action or event filter. Do not treat absence as an authorization decision.
Membership#
state.user.role IN ['admin', 'owner']
state.ticket.priority IN ['high', 'critical']
tag.intent IN ['refund_request', 'charge_dispute']
String membership uses Unicode NFKC normalization, case folding, and trimming. For example, a state value of 'ADMIN' matches IN ['admin'] but does not match == 'admin'. Numeric list membership does not use the numeric-string coercion of comparison operators.
String operations#
| Operator | Example |
|---|---|
STARTS_WITH |
state.case.reference STARTS_WITH 'EU-' |
ENDS_WITH |
state.user.email ENDS_WITH '@company.com' |
CONTAINS |
state.message.subject CONTAINS 'URGENT' |
MATCHES |
state.document.code MATCHES '^[A-Z]{3}-\d{4}$' |
String operations are case-sensitive. MATCHES uses Python regular-expression search semantics; anchor with ^ and $ when the whole value must match. Keep expressions bounded and test representative inputs. The literal \d above is valid in the DSL: the lexer preserves that escape. When putting the trigger inside JSON, escape the backslash as \\d; host-language string literals may require another escaping layer.
Tags#
Tags are classifications or metadata:
tag.intent == 'upgrade_request'
tag.sentiment == 'negative'
tag.channel == 'email'
Tag equality normalizes the kind and string value with Unicode NFKC, case folding, and trimming. Keep producer keys consistently lowercase even though normalized equality accepts other casing.
If a model produces a tag, constrain it to a fixed enum before building the ATOM. Define an explicit branch for unknown and label its model provenance; the SDK preserves source metadata on requests. See agent control patterns.
Events#
Modern event predicates require a subject.verb.object name followed by IN and a single-quoted ISO 8601 duration. A bare event name is not a complete predicate:
event.user.failed.login IN 'PT15M'
event.customer.completed.purchase IN 'P7D'
NOT event.agent.responded.ticket IN 'PT2H'
Event retention defaults to 90 days and is configurable at the organization level, inherited by workspaces. Events receive a per-document expiry based on their event timestamp and that effective policy. A longer query window can still match recent retained events, but cannot recover expired history; the validator can report WARN-WINDOW-OUT-OF-RETENTION. Confirm the effective retention before relying on a negative history check.
An existence query can name another project in the same workspace with event.project_slug.subject.verb.object IN 'PT1H'. Document this cross-project dependency in your topology. COUNT accepts only the three-token subject.verb.object form and does not support that project prefix.
Count events#
COUNT event.user.failed.login IN 'PT15M' >= 5
COUNT event.customer.placed.order IN 'P30D' >= 3
Both COUNT event.user.failed.login IN 'PT15M' >= 5 and COUNT(event.user.failed.login IN 'PT15M') >= 5 are supported. Use one house style consistently. A count requires a comparison against a number; with no matching events, the count is zero, so == 0 is true.
Scope events to the current entity#
Without a WHERE clause, an event query may match any event of that name in the project context. Scope multi-entity policy with event attributes:
event.customer.received.refund
WHERE customer_id == '{{customer.id}}'
IN 'P30D'
The template literal reads a current state value and converts it to a string. Emit entity identifiers as string attributes so the types match. An unresolved placeholder stays literal and normally matches nothing; under NOT, that produces true. Add an explicit state.customer.id EXISTS guard when the identifier is required. Do not write a state accessor directly as a WHERE value:
// Invalid
event.customer.received.refund WHERE customer_id == state.customer.id IN 'P30D'
// Valid
event.customer.received.refund WHERE customer_id == '{{customer.id}}' IN 'P30D'
The event producer must include customer_id in its attributes. Attribute filters support equality conditions joined by AND; values are single-quoted strings, numeric literals, or quoted templates. Numeric range operators and state accessors are not supported as WHERE values:
COUNT event.agent.called.tool
WHERE customer_id == '{{customer.id}}' AND tool_name == 'refund'
IN 'PT1H' >= 3
Durations#
| Literal | Window |
|---|---|
'PT5M' |
five minutes |
'PT1H' |
one hour |
'PT24H' |
twenty-four hours |
'P1D' |
one day |
'P7D' |
seven days |
'P30D' |
thirty days |
'P90D' |
ninety days |
Use durations only for event history. EMU cooldowns use an object with integer seconds, not an ISO string.
Fractional seconds are preserved: 'PT1.5S' means a 1.5-second window, including an event exactly on its boundary. Fractional hours such as 'PT1.5H' are invalid, so write 'PT1H30M'. Months mean 30 days and years mean 365 days, not calendar-aware intervals.
{ "cooldown": { "seconds": 3600, "gate": "ack" } }
Operator precedence#
From highest to lowest: NOT, AND, then OR. Use parentheses whenever mixed operators would force a reviewer to remember precedence.
(state.user.tier == 'premium' OR state.user.tier == 'enterprise')
AND state.account.verified
Reachability review#
For every trigger, list the required inputs:
state.customer.tier == 'enterprise'
AND state.customer.id EXISTS
AND tag.intent == 'refund_request'
AND NOT event.customer.received.refund
WHERE customer_id == '{{customer.id}}'
IN 'P30D'
| Dependency | Requirement |
|---|---|
state.customer.tier |
supplied at this decision point |
tag.intent |
classifier always returns a declared enum |
state.customer.id |
supplied because the event filter interpolates it |
event.customer.received.refund |
emitted after successful refund with customer_id attribute |
The negative event predicate evaluates true when there is no matching event, including when a producer is absent, a filter identifier is missing, or relevant history has expired. Reliable interpretation requires complete producer coverage, required context, and an appropriate retention window.
Common invalid expressions#
| Invalid | Correct | Reason |
|---|---|---|
state.tier == 'premium' |
state.user.tier == 'premium' |
state key is not namespaced |
state.User.Tier == 'premium' |
state.user.tier == 'premium' |
keys must be lowercase |
... and ... |
... AND ... |
logical operators are uppercase |
state.user.tier == "premium" |
state.user.tier == 'premium' |
strings use single quotes |
event.user.login IN PT1H |
event.user.failed.login IN 'PT1H' |
S.V.O name and quoted duration required |
WHERE user_id == state.user.id |
WHERE user_id == '{{user.id}}' |
state accessors are invalid as filter values |
event.user.created.account |
event.user.created.account IN 'P7D' |
modern event predicates require a quoted time window |
Complete control example#
state.refund.request_id EXISTS
AND state.customer.id EXISTS
AND state.refund.amount > 500
AND tag.refund_intent IN ['service_failure', 'fraud']
AND NOT event.agent.created.refund_review
WHERE request_id == '{{refund.request_id}}'
IN 'P7D'
This expression is syntactically valid regardless of which inputs arrive. For its positive conditions to match, the call must supply both IDs, a numeric amount above 500, and a matching tag. For the negative event check to be meaningful, the application must emit agent.created.refund_review with a matching string request_id after the action succeeds.