Skip to content

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

Stateful inspection stack used by multi-state industrial rules

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:

protocol:machine:device-or-session:subject

Examples:

dnp3:sbo:3:100
dnp3:recovery:100
industrial:maintenance:session-42:device-100

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.

action: delete_state
state_key_expr: '"dnp3:pending:" + string(dnp3.Destination())'

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:

state_key_expr
state_value_expr
state_from_expr
state_delta_expr

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

StateExists(key)   bool
GetState(key)      string
GetStateInt(key)   int
StateAge(key)      int, whole seconds

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_timeout without 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:

MaxEntries:    65536
MaxKeyBytes:   512
MaxValueBytes: 4096
CleanupBudget: 16

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.yaml
  • rules/muli_state_rules/dnp3_restart_recovery.yaml
  • rules/muli_state_rules/maintenance_authorized_control.yaml
  • rules/muli_state_rules/dnp3_file_transfer_lifecycle.yaml
  • rules/muli_state_rules/industrial_alarm_chattering.yaml

All example YAML files are automatically loaded and compiled by the Rule Engine tests.