Writing flows and suites
Flows are YAML files with kind: flow describing a finite-state machine for one protocol procedure. The engine drives the FSM per UE, dispatching transitions on inbound events (RX messages) and the synthetic Start event. The first half of this guide walks through the pieces of a flow YAML and what each one does; the second half covers writing suites that chain flows into one cycle. For exhaustive field tables, see the flow schema and suite schema references.
Flow anatomy
Every shipped flow and suite this guide cites lives in the public templates repository — open it to copy-edit a canonical example rather than starting from scratch. For the conceptual model behind the pieces below, see states and transitions and actions and checks.
Every flow has the same top-level shape:
The shipped templates/gnb/registration.yaml is the canonical NGAP example. templates/amf/registration_amf.yaml is the canonical server-mode example.
Defining states
A state is a node in the FSM. The engine sits in one state per UE and waits for an event that matches a transition. Each state can also declare on_timeout — a fallback transition that fires if no matching event arrives within the duration.
A final_state has no transitions — reaching one terminates the flow for that UE.
Transitions and events
A transition fires when an inbound event matches its event: clause. Three forms:
Event names match the wire-message form the protocol resolver emits. NGAP uses dotted nested forms (NASDownlinkTransport.AuthenticationRequest) for inner-NAS messages. Authors can also prefix with the protocol name (ngap.X, diameter.X) for visual disambiguation in multi-protocol flows — the matcher strips one leading prefix on either side before comparing.
Synthetic events:
Start— fired by the engine for every client-mode UE to drive the first transition out ofinitial_state.Error— emitted when an inbound message fails decoding or an enricher returns an error.StateTimeout— fired when anon_timeoutblock elapses (you don't reference this inevent:—on_timeoutitself is the wiring).UplaneComplete— fired after auplane_startaction finishes its run.
any_state_transitions
Top-level transitions that fire from any state when no state-specific transition matched. Use for global error handling:
Action types
Every transition can carry an actions: list, executed in order when the transition fires. Types: send, parallel_send, check, extract, uplane_start, ngap_realloc, ngap_handover_swap, cmd.
send
Emit a message on the wire. message: names a registered enricher; the enricher fills protocol-specific fields from UE state. message_body: is optional pre-populated JSON merged into the typed message struct before the enricher runs. params: overlays runtime values. protocol: and peer: override the default routing — used in multi-protocol flows.
check
Assert a field on the most recent inbound message (or ue.<path> against UE context). Operators include equals, not_equals, not_empty, greater_than, less_than, greater_or_equal, less_or_equal, contains, not_contains, exists, regex, in / oneof, length, and ie_present. A failed check aborts the transition; the FSM stays in the current state and either receives a later matching event or hits on_timeout.
A check or extract that appears after a send in the same list is rejected at flow-load. Put reads first. Sends in a transition still dispatch together as one burst once the non-send actions have run.
extract
Read a field off the most recent inbound message and store it in ue.Params[<store key>] for later use. Two forms:
uplane_start
Arm the user-plane traffic generator after the NGAP send that surfaced the UPF tunnel parameters. The parameters come from the gNB's uplane: config block and the inbound PduSessionResourceSetupRequest.
See user-plane testing and the shipped templates/gnb/uplane_traffic.yaml.
ngap_realloc
NGAP-only. Reallocate the RAN-UE-NGAP-ID on the same gNB before the next send. Used in stress flows to verify AMF tracking when the gNB renumbers a UE.
ngap_handover_swap
NGAP-only. Move the UE from the source gNB to the target gNB after HandoverCommand. Used by templates/gnb/handover_source.yaml to switch the UE binding mid-flow.
parallel_send
Fire count copies of one send, with at most concurrency in flight. It must be the only send-type action in that transition.
cmd
Run a local program from argv (a list, no shell). Off unless you pass -allow-cmd-actions. See the flow schema for cwd, env, timeout, and ignore_error.
Check ops
| Op | Passes when |
|---|---|
equals | Field equals expected: |
not_empty | Field resolves and is non-zero |
greater_than | Field > expected: |
less_than | Field < expected: |
greater_or_equal | Field ≥ expected: |
less_or_equal | Field ≤ expected: |
contains | Field's string form contains expected: substring |
not_contains | Field's string form does NOT contain expected: substring (passes vacuously when the field is absent) |
exists | Field resolves (regardless of value) |
not_equals | Field's string form differs from expected: |
regex | Field's string form matches the RE2 pattern in expected: |
in / oneof | Field's string form is a member of the expected set |
length | Length of the resolved value equals expected: |
ie_present | Field resolves and is non-nil/non-empty |
Template expressions
message_body:, params: values, and expected: accept {{...}} templating. Two forms:
A whole-value template ("{{x}}") preserves the resolved value's native type. String interpolation ("prefix-{{x}}-suffix") returns a string. Templates are compiled at flow-load time; unknown functions and arity errors fail before the first send.
Enrichers
Enrichers fill protocol fields on outbound messages. You don't write them — you reference one by name in send.message. Dump the live catalog with d3x-run vocab (enricher names, accepted params, check ops, metric keys). A representative subset is in the flow schema reference.
Client vs server flows
type: client flows have a Start event transition out of initial_state. The engine fires Start per UE; the first send action emits the procedure's first message.
type: server flows have no Start — they auto-spawn when an inbound demux matches a registered server-mode message at initial_state. There must be at least one transition at initial_state whose event matches an inbound RX message. The shipped templates/amf/registration_amf.yaml, templates/sbi/nudm_sdm_get_server.yaml, and templates/rest/edge_admin_server.yaml are the canonical examples.
Troubleshooting flows
unknown enricher "X" at flow load — the message: value doesn't match any registered enricher name. Run d3x-run vocab for the exact spelling; REST flows may also declare a message label inline at flow-load.
event must be a string or {and: [...]} or {or: [...]} — your event: field is malformed. Use one of the three documented forms.
state X has no transitions and is not a final state — every non-terminal state needs at least one transition (or on_timeout).
client FSM must have a transition matching "Start" — client flows must dispatch on the synthetic Start event from initial_state.
server FSM must not have a transition matching "Start" — server flows are reactive. Drop the Start transition.
Check fails but trace shows the field is set — field: paths are case-sensitive. NGAP fields use lowercase-with-underscores (amf_ue_ngap_id); UE-context fields are PascalCase prefixed with ue. (ue.AmfUeNgapId).
Writing suites
A suite is a YAML file with kind: suite that orders a list of flow steps into one cycle. Each step runs its referenced flow with its own workload before the next step starts; subscribers are not shared across steps. Suites are strictly serial in v1 — no rate: at the suite level. The rest of this guide covers the YAML shape and the runner semantics. For the full field tables, see the suite schema.
Suite anatomy
The shipped templates/suites/gnb_register_deregister.yaml is the canonical example.
Ordered steps
Steps execute in YAML order. Each step independently acquires its own subscribers from the pool — repetitions: 5 on step 1 takes 5 subscribers, releases them at the end of the step, then step 2 takes 5 fresh ones. There is no subscriber sharing across steps in v1.
Per-step workload
Each step has its own repetitions, rate, duration, timeout — the same knobs run-flow exposes. CLI overrides (-trace, -gen-subscriber) OR with the per-step settings, so a CLI -trace opts every step in.
| Field | Purpose | Default |
|---|---|---|
repetitions | UE count for this step | 1 |
rate | UEs/s within this step (0 = burst) | 0 |
duration | Stop spawning UEs after this elapses | 0 (unlimited) |
timeout | Per-UE flow timeout | 30s |
params | Overlay merged into each UE's per-flow params | nil |
trace | TX/RX hex dump + JSON trace for this step | false |
gen_subscriber | Synthesize subscribers per UE; skip the YAML set | false |
stop_on_failure
Default true. If a step's result is not a pass (AllPassed is false, after any success_when override), the cycle aborts before the next non-always_run step. Override with stop_on_failure: false to keep going regardless.
always_run
A step marked always_run: true executes even when the cycle has aborted earlier. Use for cleanup steps that must run regardless of upstream failures — the equivalent of a finally block.
params
A map merged into each UE's per-flow params before any {{params.X}} template resolves. Useful when one suite reuses a flow but with different inputs per step:
trace
Per-step trace flag. Setting it on one step doesn't affect other steps. The CLI -trace flag OR's with this — one of them being true enables trace for that step.
Suite cycle semantics
The runner repeats the full step list -repetitions times (or until -duration elapses, whichever applies). Each iteration is one cycle. The CLI exposes only the outer-loop knobs:
-repetitions <n>— number of full cycles (default 1)-duration <duration>— keep cycling back-to-back until this elapses-timeout <duration>— per-cycle deadline; each step inherits if the step doesn't override
Suite-level -rate is rejected — suites are strictly serial in v1.
Reports
Each cycle produces one suite-level result with one child result per step. Capture it from the CLI with -output json / -output-file. Historical suite reports live in the control plane when agents are attached — see Agent mode. The suite result's AllPassed is true only when every non-aborted step passed. Aborted is set when stop_on_failure cut the cycle short.
Validation
Invalid suite YAML fails when the templates dir is loaded for a run. Common failures:
step %q: flow is required— every step needs aflow:field.duplicate step name %q— step names must be unique within a suite.step %q: rate must be >= 0— rates and durations cannot be negative.
Step-level flow names are resolved from the -templates directory at run time. A suite that references a missing flow fails with flow %q not found.
Troubleshooting suites
Step skipped after a prior failure — that's stop_on_failure: true (the default) doing its job. Either set it to false on the upstream step or mark the cleanup step always_run: true.
Subscribers exhausted between steps — every step takes its own batch from the pool. Provision enough subscribers for the largest step (or use gen_subscriber: true to synthesize per-UE subscribers in memory).
-rate is not supported for run-suite — suites are serial. Move the rate into a per-step rate: field.
Where to go next
- Running flows and suites — execute what you just wrote.
- Flow schema and suite schema — exhaustive field tables.
- Templates repository — copy-edit the canonical shipped flows and suites.