---
title: Trigger DSL
description: Write deterministic Memrail conditions over state, bounded tags, and event history with reachability, scoping, and retention in mind.
eyebrow: LANGUAGE REFERENCE
keywords: Memrail trigger DSL, deterministic agent policy rules, event condition syntax
last_updated: 2026-09-10
---

# Trigger DSL

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

```dsl
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

```dsl
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

```dsl
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

```dsl
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.

```dsl
// 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

```dsl
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:

```dsl
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](/agent-control/#the-authority-boundary).

## 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:

```dsl
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

```dsl
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:

```dsl
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:

```dsl
// 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:

```dsl
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.

```json
{ "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.

```dsl
(state.user.tier == 'premium' OR state.user.tier == 'enterprise')
AND state.account.verified
```

## Reachability review

For every trigger, list the required inputs:

```dsl
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

```dsl
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.
