Writing Multi-State Rules¶
This reference explains the current customer-facing contract for log, accept,
drop, and generic state-machine actions. It is intended for integrators and customer
engineers who review or customize the executable examples.
Architecture boundary¶
l2proxy-dissector
↓
protocol model and semantic helpers
↓
generic Rule Engine and bounded StateStore
↓
customer YAML policy
↓
log / accept / drop

Figure — Multi-state rules operate against Raymon’s stateful inspection and context stack.
Helpers extract protocol facts. The engine provides generic state primitives. Customer point numbers, device identities, timing, process transitions, and verdicts remain in YAML.
Base file¶
version: 1
rules:
- name: unique-rule-name
order: 10
description: Human-readable purpose
enabled: true
condition: 'dnp3.IsDNP3() && StateExists("dnp3:machine:100")'
action: log
log: true
meta:
phase: '"observed"'
device: 'dnp3.Outstation()'
YAML field reference¶
| Field | Required | Default | Meaning |
|---|---|---|---|
version |
yes | — | Rule schema version; currently 1 |
rules |
yes | — | Ordered rule list |
name |
yes | — | Unique stable identifier within the file |
condition |
yes | — | Boolean expr expression |
description |
no | empty | Human documentation |
order |
no | 0 |
Ascending evaluation order; equal order retains file order |
enabled |
no | true |
Disable without deleting |
action |
no | accept |
Verdict or state action |
log |
no | false |
Emit structured event; implicit for action: log |
stop |
no | true |
Stop after verdict; ignored for state actions |
meta |
no | empty | Up to 32 expressions for rule_match evidence |
state_key |
action-specific | — | Literal or legacy templated key |
state_key_expr |
no | — | Explicit key expression; takes precedence |
state_value |
action-specific | — | Literal or legacy value |
state_value_expr |
no | — | Explicit value expression; takes precedence |
state_from |
transition | — | Expected value or @absent |
state_from_expr |
no | — | Dynamic expected value; takes precedence |
state_delta |
no | 1 |
Integer increment; configured zero also resolves to one |
state_delta_expr |
no | — | Dynamic integer delta; takes precedence |
state_limit |
no | none | Optional upper result ceiling |
state_ttl |
no | 0 |
Seconds; zero means no expiry, negative is rejected |
No-match behavior is accept. A fail-closed policy therefore requires a final explicit drop for the protected operation or scope.
State key design¶
Recommended form:
Examples:
Include every dimension that must not share state: machine type, initiator/session, target device, operation, object/point, and customer scope as required.
State actions¶
Set¶
Record an observation or begin a phase. Set overwrites the value and restarts its TTL.
action: set_state
state_key_expr: '"dnp3:recovery:" + string(dnp3.Source())'
state_value: restarted
state_ttl: 30
Delete¶
Cancel, invalidate, or finish a lifecycle.
Delete reports an applied action even if the key was absent. Do not use it as proof that state previously existed.
Atomic transition¶
Compare the exact current value and apply a replacement atomically. Use @absent to
create only if no live state exists.
action: transition_state
state_key_expr: '"machine:" + src_ip'
state_from: selected
state_value: consumed
state_ttl: 1
Concurrent or replayed operations have one winner when the ready state is consumed.
Atomic increment¶
action: increment_state
state_key_expr: '"rate:" + src_ip'
state_delta: 1
state_limit: 100
state_ttl: 10
Missing state begins at zero. Negative delta is permitted. state_limit is an upper
ceiling only. Overflow, a non-numeric current value, capacity/size rejection, or crossing
the ceiling results in state_action_applied=false.
Dynamic expressions¶
Prefer explicit fields:
They compile once at load. During value/from evaluation the resolved key is available as
state_key. Legacy {{ expression }} templates remain supported, but explicit expression
fields remove ambiguity.
There are no global YAML variables, includes, or macros in rule schema version 1. Customer parameters are maintained in the deployment copy and its tests.
Reading state¶
Missing state returns empty string or zero from the getters. Always use StateExists
when absence must be distinguished from an empty or zero value.
Safe accept/drop branching¶
- name: consume-operation
order: 20
condition: 'operation_event'
action: transition_state
state_key_expr: 'machine_key'
state_from_expr: 'expected_fingerprint'
state_value: consumed
state_ttl: 1
- name: accept-valid-operation
order: 30
condition: >
operation_event &&
state_action_rule == "consume-operation" &&
state_action_applied
action: accept
- name: drop-invalid-operation
order: 40
condition: 'operation_event'
action: drop
log: true
Both packet-local fields must be checked. A later state action overwrites the outcome. An expression error occurs before a new outcome is recorded and follows the configured runtime error policy.
TTL and scheduler¶
- TTL uses the engine clock, not packet timestamp.
- Default expiration is lazy: state disappears when accessed after its deadline.
- The optional scheduler emits
state_timeoutwithout waiting for another packet. - Scheduler overwrite replaces the previous deadline; delete cancels it.
ConfigurableEngine.Close()must be called when scheduler use ends.- A timeout event does not evaluate another rule without a packet.
Metadata and logs¶
meta expressions compile at load and evaluate only for a logged verdict match. If one
meta expression fails, that key is omitted and the decision event still logs. Use stable
scalar string, number, or boolean values for customer integrations.
State actions emit a fixed state_action event and do not currently evaluate custom
metadata. Follow the state action with a guarded logged verdict rule when dynamic evidence
is required.
Expression inputs¶
Packet-level values include:
src_mac, dst_mac, src_ip, dst_ip, src_port, dst_port,
pkt_num, timestamp, layers,
state_key, state_action_rule, state_action_applied
General helpers include HasLayer, HasField, FieldCount, typed Field* getters,
indexed getters, AnyFieldInt*, and AnyFieldFloat*. Network helpers under net.*
evaluate CIDR, port ranges, and MAC prefixes. Optional L2Proxy Connect identity is under
se.* and must be guarded with se.Present().
Do not pair two global repeated-field arrays for an industrial object unless parser layout guarantees that association. When several fields describe one object, use a protocol helper built on the object's scoped subtree.
DNP3 correlation helpers¶
dnp3.Master(), dnp3.Outstation(), dnp3.HasCanonicalEndpoints()
dnp3.ControlSessionKey()
dnp3.HasApplicationSequence(), dnp3.ApplicationSequence()
dnp3.HasFinalFragmentFlag(), dnp3.IsFinalFragment()
dnp3.HasControlFingerprint(), dnp3.ControlFingerprint()
dnp3.ControlTargetCount(), dnp3.ControlResponseOK()
The control fingerprint canonicalizes the complete supported CROB/G41 target set and
returns empty on incomplete identity. A security rule must test HasControlFingerprint().
Resource controls¶
Default StateStore limits:
Zero-valued options select defaults; negative limits are invalid. Key/value limits are
bytes. StateStats() reports live entries and monotonic write, delete, expiration,
transition, capacity, oversize, and increment counters. Avoid calling a full live-size
snapshot on every packet.
Error policy¶
| Go option | Runtime behavior |
|---|---|
EvaluationErrorContinue |
Log error and continue; version-1 default |
EvaluationErrorAccept |
Immediately return accept |
EvaluationErrorDrop |
Immediately return drop |
CAS mismatch and resource rejection are not expression errors; they produce an unapplied state action. The error policy is configured through the Go API, not YAML.
Required test paths¶
- normal happy path;
- missing state;
- value or identity mismatch;
- expiration and timeout;
- replay and duplicate;
- out-of-order event;
- failed response;
- concurrent transition for enforcement;
- parser field missing or incomplete;
- customer-approved retry, failover, maintenance, and recovery exceptions.
From the repository root:
GOCACHE=/tmp/l2proxy-go-cache go test ./internal/rule_engine/...
GOCACHE=/tmp/l2proxy-go-cache go test -race \
./internal/rule_engine \
./internal/rule_engine/config \
./internal/rule_engine/expr \
./internal/rule_engine/dnp3m
GOCACHE=/tmp/l2proxy-go-cache go vet ./internal/rule_engine/...
git diff --check -- internal/rule_engine rules/muli_state_rules
Troubleshooting¶
| Symptom | Check |
|---|---|
| Rule does not load | schema version, action, required state fields, compile error |
| Rule does not match | direction, layer, exact abbreviation, display versus canonical value |
| Numeric getter is zero | field presence/type; zero can also be a valid value |
| Transition is not applied | resolved key, exact expected value, expiry, capacity/size statistics |
| Wrong branch accepts | check both state_action_rule and state_action_applied |
| Timeout log is missing | scheduler enabled, positive TTL, engine not already closed |
| Metadata key is missing | inspect the per-key metadata evaluation diagnostic |
| State capacity grows | key cardinality, TTL, cleanup, and MaxEntries |
| Replay is accepted | replace check-then-set with atomic one-time transition |
Complete examples¶
- DNP3 multi-object SBO
rules/muli_state_rules/dnp3_command_status_verification.yamlrules/muli_state_rules/dnp3_restart_recovery.yamlrules/muli_state_rules/maintenance_authorized_control.yamlrules/muli_state_rules/dnp3_file_transfer_lifecycle.yamlrules/muli_state_rules/industrial_alarm_chattering.yaml
All example YAML files are automatically loaded and compiled by the Rule Engine tests.