Actions & checks
The action types a transition can run — send, check, extract, and the rest — and the check operators that gate a procedure.
Actions are what runs inside a transition. When a transition fires, its actions list executes top to bottom, then the flow advances to the transition's target state. A transition with no actions is legal — it just moves state.
This page is the conceptual tour. The exact field tables live in the flow schema reference.
A transition in three acts
The everyday transition reads the inbound message, then answers it. Order matters: check what arrived, extract anything later steps need, then send the reply.
That ordering is not a convention — the engine enforces it. A check or extract that appears after a send in the same list is rejected at flow-load, because once you have sent, the inbound message under inspection is no longer the latest frame. Read first, write last.
Action types
The first three are universal — nearly every flow uses them. The rest are specialised. cmd is off unless the process was started with -allow-cmd-actions.
| Type | What it does | Typical position |
|---|---|---|
check | Assert a field on the most recent inbound message (or on UE state) | Before any send |
extract | Read a field off the inbound message into UE state for later use | Before any send |
send | Emit a message on the wire | After checks and extracts |
parallel_send | Fire N copies of one send concurrently and tally responses in-flow | The only send-type action in that transition |
uplane_start | Arm the user-plane traffic generator once a PDU session is up | Between sends |
ngap_realloc | NGAP only — reallocate the RAN-UE-NGAP-ID before the next send | Between sends |
ngap_handover_swap | NGAP only — move the UE binding from source to target gNB | Between sends |
cmd | Run a local program named by argv (no shell). Off by default. | Between sends |
check — assert before you send
A check evaluates a field against an operator. The field resolves from the most recent inbound message, or — when the path is prefixed ue. — from the UE's own context. A failed check aborts the transition: the flow stays in the current state and waits for a later matching event or for on_timeout. This is how negative tests assert that a peer rejected a malformed request with the right cause.
Both field and op are required on every check. Field paths are case-sensitive: wire-message fields use lowercase-with-underscores (amf_ue_ngap_id), while UE-context fields are PascalCase under the ue. prefix (ue.AmfUeNgapId).
Check operators
Per-step check actions accept these operators. Most need an expected value; the presence tests do not. Dump the live list with d3x-run vocab.
| Operator | Needs expected | Passes when |
|---|---|---|
equals | yes | Field equals expected (compared as strings) |
not_equals | yes | Field's string form differs from expected |
not_empty | no | Field resolves and is non-nil, non-empty, non-zero |
greater_than | yes | Field > expected (numeric) |
less_than | yes | Field < expected (numeric) |
greater_or_equal | yes | Field ≥ expected (numeric) |
less_or_equal | yes | Field ≤ expected (numeric) |
contains | yes | Field's string form contains the expected substring |
not_contains | yes | Field's string form does not contain the expected substring |
exists | no | Field resolves at all, whatever its value |
regex | yes | Field's string form matches the RE2 pattern in expected |
in / oneof | yes | Field's string form is a member of the expected set (oneof is an alias of in) |
length | yes | Length of the resolved value equals expected (arrays, maps, strings) |
ie_present | no | Field resolves and its value is non-nil/non-empty (stricter than exists) |
exists and not_empty are not the same. exists only asks whether the AVP or field is present; not_empty additionally requires the value to be non-zero. Use exists when an empty or zero value is still a valid presence assertion.The expected value can itself be a {{...}} template, so a check's right-hand side can come from a field extracted earlier in the same flow — for example expected: "{{ue.Params.pre_count}}".
extract — bridge inbound to outbound
extract reads a field off the most recent inbound message and stores it under ue.Params[<key>]. Later sends reference it through {{ue.Params.<key>}} templates. This is how a procedure correlates a request with a later one — a session ID, a transaction ID, an authentication challenge — declaratively, without writing Go.
There are two forms. The shorthand handles a single field; the list form bundles several extracts into one action.
Each entry needs a store and exactly one source: a field path to resolve, or a value template expression to evaluate. The two are mutually exclusive. A value entry lets you store a derived result — for instance, decoding a token field and keeping the result:
Like check, extract must precede sends in the actions list.
send — emit a message
A send puts a message on the wire. The message field names a registered enricher — a built-in that fills protocol-specific fields from UE state. You reference enrichers by name; you never write them. message is the only required field on a send.
Optional fields refine the send:
| Field | Required | Purpose |
|---|---|---|
message | yes | Registered enricher name |
message_body | no | JSON pre-filled onto the typed message struct before the enricher fills the rest |
params | no | Per-action params overlay applied after the enricher runs |
protocol | no | Protocol override — used in multi-protocol flows |
peer | no | Target peer name within the protocol |
message_body is how you author negative tests: set one field to a malformed value and let the enricher fill the rest normally. Both message_body JSON values and params values accept {{...}} templates, compiled at flow-load so unknown function names and arity errors fail before the first send.
Specialised actions
These action types exist for procedures the read-check-send pattern does not cover. uplane_start, ngap_realloc, ngap_handover_swap, and cmd are allowed between sends. parallel_send cannot mix with a regular send in the same transition.
uplane_start — arm the traffic generator
uplane_start bridges signalling to user plane. It runs after a PDU session is established and arms the configured traffic generator using parameters surfaced by the inbound PduSessionResourceSetupRequest and the gNB's uplane: config block. When the run finishes, the engine fires a synthetic UplaneComplete event so the FSM can advance.
See User plane and the user-plane testing guide for the receiver setup.
ngap_realloc — renumber the UE on the same gNB
NGAP only. Reallocates the UE's RAN-UE-NGAP-ID before the next send, exercising AMF tracking when a gNB renumbers a UE on the same node.
ngap_handover_swap — move the UE between gNBs
NGAP only. After a HandoverCommand arrives, swaps the UE's gNB binding from source to target so the next send uses the target gNB's transport.
parallel_send — fire N copies of one send
Sends the same message count times, with at most concurrency in flight (0 = unbounded, capped at count). It is the only send-type action allowed in that transition — you cannot mix it with send.
cmd — run a local program
Runs argv as a list (no shell, no word-splitting) and waits for it. Off unless the process was started with -allow-cmd-actions. Use it to poke an admin CLI from inside a transition; do not turn the flag on for a daemon attached to a console you do not fully trust. Field tables live in the flow schema.
What you don't write
Notably absent: no wait, no sleep, no loop, no if/else. The state machine handles those structurally. Waiting is a state; conditional branching is multiple transitions on different events; looping is a self-targeting transition. If a flow needs an explicit retry counter, keep it as a UE param with extract and gate on it with a check.
Where to go next
- Suites — composing flows into a CI-gating run
- Writing flows — a how-to with full examples