Skip to content

Industrial Rule Library

These are complete executable policies, not pseudocode. Each listing is included from rules/muli_state_rules, the same source loaded by the automated Rule Engine tests.

Customer deployment still requires asset, point, identity, timing, severity, and enforcement review. Start in monitor mode unless the use case has completed site acceptance.

DNP3 multi-object Select-Before-Operate

# Production-style DNP3 SBO policy for complete multi-point CROB/G41 commands.
# Customize TTL and the final default policy for the deployment. The rule is
# fail-closed for malformed control requests and consumes a Select exactly once.
version: 1

rules:
  - name: dnp3-sbo-drop-incomplete-control
    order: 5
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() &&
      (!dnp3.HasCanonicalEndpoints() || !dnp3.HasControlFingerprint())
    action: drop
    log: true
    meta:
      policy: '"sbo_required"'
      violation: '"incomplete_control_identity"'
      function: 'dnp3.meta.FunctionCodeText()'

  - name: dnp3-sbo-drop-direct-operate
    order: 10
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() &&
      dnp3.IsDirectOperate() && dnp3.HasControlFingerprint()
    action: drop
    log: true
    meta:
      policy: '"sbo_required"'
      violation: '"direct_operate"'
      master: 'dnp3.Master()'
      outstation: 'dnp3.Outstation()'
      targets: 'dnp3.ControlTargetCount()'
      fingerprint: 'dnp3.ControlFingerprint()'

  - name: dnp3-sbo-record-select-request
    order: 20
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsSelect() &&
      dnp3.HasCanonicalEndpoints() && dnp3.HasApplicationSequence() &&
      dnp3.HasControlFingerprint()
    action: set_state
    state_key_expr: '"dnp3:sbo:" + dnp3.ControlSessionKey()'
    state_value_expr: >
      "pending|" + string(dnp3.ApplicationSequence()) + "|" +
      dnp3.ControlFingerprint()
    state_ttl: 5
    log: true
    meta:
      phase: '"select_request"'
      master: 'dnp3.Master()'
      outstation: 'dnp3.Outstation()'
      sequence: 'dnp3.ApplicationSequence()'
      targets: 'dnp3.ControlTargetCount()'
      fingerprint: 'dnp3.ControlFingerprint()'

  - name: dnp3-sbo-confirm-select-response
    order: 30
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() &&
      dnp3.HasCanonicalEndpoints() && dnp3.HasApplicationSequence() &&
      dnp3.ControlResponseOK()
    action: transition_state
    state_key_expr: '"dnp3:sbo:" + dnp3.ControlSessionKey()'
    state_from_expr: >
      "pending|" + string(dnp3.ApplicationSequence()) + "|" +
      dnp3.ControlFingerprint()
    state_value_expr: '"selected|" + dnp3.ControlFingerprint()'
    state_ttl: 5
    log: true
    meta:
      phase: '"select_response"'
      result: '"success"'
      master: 'dnp3.Master()'
      outstation: 'dnp3.Outstation()'
      sequence: 'dnp3.ApplicationSequence()'
      targets: 'dnp3.ControlTargetCount()'
      fingerprint: 'dnp3.ControlFingerprint()'

  - name: dnp3-sbo-clear-mismatched-control-response
    order: 34
    description: Invalidate pending state when a successful response cannot confirm it.
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.ControlResponseOK() &&
      state_action_rule == "dnp3-sbo-confirm-select-response" &&
      !state_action_applied
    action: delete_state
    state_key_expr: '"dnp3:sbo:" + dnp3.ControlSessionKey()'
    log: true
    meta:
      phase: '"select_response"'
      violation: '"select_response_mismatch"'
      sequence: 'dnp3.ApplicationSequence()'
      targets: 'dnp3.ControlTargetCount()'
      fingerprint: 'dnp3.ControlFingerprint()'

  - name: dnp3-sbo-clear-failed-control-response
    order: 35
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() &&
      dnp3.HasCanonicalEndpoints() && dnp3.HasControlFingerprint() &&
      !dnp3.ControlResponseOK()
    action: delete_state
    state_key_expr: '"dnp3:sbo:" + dnp3.ControlSessionKey()'
    log: true
    meta:
      phase: '"control_response"'
      result: '"failed"'
      master: 'dnp3.Master()'
      outstation: 'dnp3.Outstation()'
      targets: 'dnp3.ControlTargetCount()'
      fingerprint: 'dnp3.ControlFingerprint()'
      iin: 'dnp3.meta.IIN()'

  - name: dnp3-sbo-consume-operate
    order: 40
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
      dnp3.HasCanonicalEndpoints() && dnp3.HasControlFingerprint()
    action: transition_state
    state_key_expr: '"dnp3:sbo:" + dnp3.ControlSessionKey()'
    state_from_expr: '"selected|" + dnp3.ControlFingerprint()'
    state_value: consumed
    state_ttl: 1
    log: true
    meta:
      phase: '"operate"'
      master: 'dnp3.Master()'
      outstation: 'dnp3.Outstation()'
      targets: 'dnp3.ControlTargetCount()'
      fingerprint: 'dnp3.ControlFingerprint()'

  - name: dnp3-sbo-accept-valid-operate
    order: 50
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
      state_action_rule == "dnp3-sbo-consume-operate" &&
      state_action_applied
    action: accept
    log: true
    meta:
      policy: '"sbo_required"'
      result: '"valid_operate"'
      master: 'dnp3.Master()'
      outstation: 'dnp3.Outstation()'
      targets: 'dnp3.ControlTargetCount()'
      fingerprint: 'dnp3.ControlFingerprint()'

  - name: dnp3-sbo-drop-invalid-operate
    order: 60
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate()'
    action: drop
    log: true
    meta:
      policy: '"sbo_required"'
      violation: '"operate_without_matching_select"'
      master: 'dnp3.Master()'
      outstation: 'dnp3.Outstation()'
      targets: 'dnp3.ControlTargetCount()'
      fingerprint: 'dnp3.ControlFingerprint()'

  - name: dnp3-sbo-default-accept
    order: 1000
    condition: 'true'
    action: accept

DNP3 command-to-status verification

# Customer demo: verify CROB:7 Latch On/Off against binary feedback BI:20.
# Customize command point, feedback point, outstation scope and timeout.
# The state lives for 30 seconds; policy timeout is checked at age >= 5 seconds
# on the next observed packet (scheduler-independent version).
version: 1

rules:
  - name: dnp3-csv-drop-conflicting-command
    order: 10
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
      dnp3.CrobOpcodePresent(7) &&
      StateExists("dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20")
    action: drop
    log: true
    meta:
      policy: '"command_status_verification"'
      violation: '"command_while_feedback_pending"'
      outstation: 'dnp3.Outstation()'
      command_point: '7'

  - name: dnp3-csv-record-latch-on
    order: 20
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
      dnp3.HasCanonicalEndpoints() && dnp3.IsLatchOn(7)
    action: set_state
    state_key_expr: >
      "dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20"
    state_value: awaiting:on
    state_ttl: 30
    log: true
    meta:
      phase: '"command_sent"'
      expected_feedback: '"on"'
      master: 'dnp3.Master()'
      outstation: 'dnp3.Outstation()'

  - name: dnp3-csv-record-latch-off
    order: 21
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
      dnp3.HasCanonicalEndpoints() && dnp3.IsLatchOff(7)
    action: set_state
    state_key_expr: >
      "dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20"
    state_value: awaiting:off
    state_ttl: 30
    log: true
    meta:
      phase: '"command_sent"'
      expected_feedback: '"off"'
      master: 'dnp3.Master()'
      outstation: 'dnp3.Outstation()'

  - name: dnp3-csv-verify-feedback-on
    order: 30
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() &&
      dnp3.HasCanonicalEndpoints() && dnp3.HasPointRef("BI:20") &&
      dnp3.PointRefOn("BI:20")
    action: transition_state
    state_key_expr: >
      "dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20"
    state_from: awaiting:on
    state_value: verified
    state_ttl: 1
    log: true
    meta:
      phase: '"feedback"'
      result: '"verified"'
      feedback: '"on"'
      outstation: 'dnp3.Outstation()'

  - name: dnp3-csv-verify-feedback-off
    order: 31
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() &&
      dnp3.HasCanonicalEndpoints() && dnp3.HasPointRef("BI:20") &&
      dnp3.PointRefOff("BI:20")
    action: transition_state
    state_key_expr: >
      "dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20"
    state_from: awaiting:off
    state_value: verified
    state_ttl: 1
    log: true
    meta:
      phase: '"feedback"'
      result: '"verified"'
      feedback: '"off"'
      outstation: 'dnp3.Outstation()'

  - name: dnp3-csv-fail-opposite-on
    order: 40
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() &&
      dnp3.HasCanonicalEndpoints() && dnp3.PointRefOn("BI:20")
    action: transition_state
    state_key_expr: >
      "dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20"
    state_from: awaiting:off
    state_value: failed:opposite_feedback
    state_ttl: 30
    log: true
    meta:
      phase: '"feedback"'
      result: '"opposite_feedback"'
      expected: '"off"'
      actual: '"on"'

  - name: dnp3-csv-fail-opposite-off
    order: 41
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() &&
      dnp3.HasCanonicalEndpoints() && dnp3.PointRefOff("BI:20")
    action: transition_state
    state_key_expr: >
      "dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20"
    state_from: awaiting:on
    state_value: failed:opposite_feedback
    state_ttl: 30
    log: true
    meta:
      phase: '"feedback"'
      result: '"opposite_feedback"'
      expected: '"on"'
      actual: '"off"'

  - name: dnp3-csv-timeout-awaiting-on
    order: 50
    condition: >
      dnp3.IsDNP3() && dnp3.HasCanonicalEndpoints() &&
      GetState("dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20") == "awaiting:on" &&
      StateAge("dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20") >= 5
    action: transition_state
    state_key_expr: >
      "dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20"
    state_from: awaiting:on
    state_value: failed:timeout
    state_ttl: 30
    log: true
    meta:
      phase: '"feedback_timeout"'
      expected: '"on"'
      timeout_seconds: '5'

  - name: dnp3-csv-timeout-awaiting-off
    order: 51
    condition: >
      dnp3.IsDNP3() && dnp3.HasCanonicalEndpoints() &&
      GetState("dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20") == "awaiting:off" &&
      StateAge("dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20") >= 5
    action: transition_state
    state_key_expr: >
      "dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20"
    state_from: awaiting:off
    state_value: failed:timeout
    state_ttl: 30
    log: true
    meta:
      phase: '"feedback_timeout"'
      expected: '"off"'
      timeout_seconds: '5'

  - name: dnp3-csv-log-uncommanded-feedback
    order: 60
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() &&
      dnp3.HasCanonicalEndpoints() && dnp3.HasPointRef("BI:20") &&
      !StateExists("dnp3:cmd_status:" + string(dnp3.Outstation()) + ":crob7:bi20")
    action: log
    meta:
      policy: '"command_status_verification"'
      observation: '"feedback_without_observed_command"'
      outstation: 'dnp3.Outstation()'
      feedback: 'dnp3.meta.PointRefStateText("BI:20")'

  - name: dnp3-csv-default-accept
    order: 1000
    condition: 'true'
    action: accept

DNP3 restart recovery

# Customer demo: gate DNP3 controls after device restart until time sync (when
# requested) and a successful final Class-0 integrity response complete.
# Timeout alerts are evaluated on the next packet until scheduler support lands.
version: 1

rules:
  - name: dnp3-recovery-log-restart-outside-maintenance
    order: 5
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.DeviceRestarted() &&
      !StateExists("industrial:maintenance:device:" + string(dnp3.Outstation()))
    action: log
    stop: false
    meta:
      policy: '"restart_recovery"'
      observation: '"restart_outside_maintenance"'
      outstation: 'dnp3.Outstation()'

  - name: dnp3-recovery-log-repeated-restart
    order: 6
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.DeviceRestarted() &&
      StateExists("dnp3:recovery:" + string(dnp3.Outstation()))
    action: log
    stop: false
    meta:
      policy: '"restart_recovery"'
      observation: '"repeated_restart_during_recovery"'
      recovery_state: 'GetState("dnp3:recovery:" + string(dnp3.Outstation()))'
      outstation: 'dnp3.Outstation()'

  - name: dnp3-recovery-drop-control-while-recovering
    order: 10
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() &&
      StateExists("dnp3:recovery:" + string(dnp3.Outstation())) &&
      GetState("dnp3:recovery:" + string(dnp3.Outstation())) != "operational"
    action: drop
    log: true
    meta:
      policy: '"restart_recovery"'
      violation: '"control_during_recovery"'
      master: 'dnp3.Master()'
      outstation: 'dnp3.Outstation()'
      recovery_state: 'GetState("dnp3:recovery:" + string(dnp3.Outstation()))'

  - name: dnp3-recovery-observe-restart
    order: 20
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.HasCanonicalEndpoints() &&
      dnp3.DeviceRestarted() &&
      !StateExists("dnp3:recovery:" + string(dnp3.Outstation()))
    action: set_state
    state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
    state_value: restarted
    state_ttl: 120
    log: true
    meta:
      phase: '"restarted"'
      master: 'dnp3.Master()'
      outstation: 'dnp3.Outstation()'
      iin: 'dnp3.meta.IINHexText()'

  - name: dnp3-recovery-require-time-sync
    order: 25
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.NeedTime() &&
      GetState("dnp3:recovery:" + string(dnp3.Outstation())) == "restarted"
    action: transition_state
    state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
    state_from: restarted
    state_value: time_sync_required
    state_ttl: 120
    log: true
    meta:
      phase: '"time_sync_required"'
      outstation: 'dnp3.Outstation()'

  - name: dnp3-recovery-skip-time-sync-when-not-required
    order: 26
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() && !dnp3.NeedTime() &&
      GetState("dnp3:recovery:" + string(dnp3.Outstation())) == "restarted"
    action: transition_state
    state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
    state_from: restarted
    state_value: integrity_scan_required
    state_ttl: 120
    log: true
    meta:
      phase: '"integrity_scan_required"'
      time_sync_skipped: 'true'

  - name: dnp3-recovery-record-time-write
    order: 30
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsWrite() &&
      dnp3.HasObject(50, 1) && dnp3.HasApplicationSequence() &&
      GetState("dnp3:recovery:" + string(dnp3.Outstation())) == "time_sync_required"
    action: transition_state
    state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
    state_from: time_sync_required
    state_value_expr: '"time_sync_pending|" + string(dnp3.ApplicationSequence())'
    state_ttl: 30
    log: true
    meta:
      phase: '"time_sync_pending"'
      sequence: 'dnp3.ApplicationSequence()'

  - name: dnp3-recovery-confirm-time-write
    order: 35
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.HasApplicationSequence() &&
      !dnp3.HasIINError()
    action: transition_state
    state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
    state_from_expr: '"time_sync_pending|" + string(dnp3.ApplicationSequence())'
    state_value: integrity_scan_required
    state_ttl: 120
    log: true
    meta:
      phase: '"integrity_scan_required"'
      sequence: 'dnp3.ApplicationSequence()'

  - name: dnp3-recovery-record-integrity-read
    order: 40
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsClassRead(0) &&
      dnp3.HasApplicationSequence() &&
      GetState("dnp3:recovery:" + string(dnp3.Outstation())) == "integrity_scan_required"
    action: transition_state
    state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
    state_from: integrity_scan_required
    state_value_expr: '"integrity_scan_pending|" + string(dnp3.ApplicationSequence())'
    state_ttl: 60
    log: true
    meta:
      phase: '"integrity_scan_pending"'
      sequence: 'dnp3.ApplicationSequence()'

  - name: dnp3-recovery-complete-integrity-response
    order: 45
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.HasApplicationSequence() &&
      dnp3.IsFinalFragment() && !dnp3.HasIINError()
    action: transition_state
    state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
    state_from_expr: '"integrity_scan_pending|" + string(dnp3.ApplicationSequence())'
    state_value: operational
    state_ttl: 5
    log: true
    meta:
      phase: '"operational"'
      sequence: 'dnp3.ApplicationSequence()'
      outstation: 'dnp3.Outstation()'

  - name: dnp3-recovery-fail-response
    order: 50
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.HasIINError() &&
      StateExists("dnp3:recovery:" + string(dnp3.Outstation()))
    action: set_state
    state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
    state_value: failed:iin_error
    state_ttl: 120
    log: true
    meta:
      phase: '"failed"'
      reason: '"iin_error"'
      iin: 'dnp3.meta.IINHexText()'

  - name: dnp3-recovery-timeout
    order: 60
    condition: >
      dnp3.IsDNP3() && dnp3.HasCanonicalEndpoints() &&
      StateExists("dnp3:recovery:" + string(dnp3.Outstation())) &&
      GetState("dnp3:recovery:" + string(dnp3.Outstation())) != "operational" &&
      StateAge("dnp3:recovery:" + string(dnp3.Outstation())) >= 60
    action: set_state
    state_key_expr: '"dnp3:recovery:" + string(dnp3.Outstation())'
    state_value: failed:timeout
    state_ttl: 120
    log: true
    meta:
      phase: '"failed"'
      reason: '"recovery_timeout"'
      timeout_seconds: '60'

  - name: dnp3-recovery-default-accept
    order: 1000
    condition: 'true'
    action: accept

Maintenance-authorized DNP3 control

# Customer demo: a designated DNP3 CROB:99 Latch-On acts as a maintenance
# key-switch. Only engineer@OT-ENGINEERING can authorize one DNP3 restart for
# the same SoftEther session and outstation within 300 seconds.
version: 1

rules:
  - name: maintenance-drop-unauthorized-key-switch
    order: 10
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
      dnp3.IsLatchOn(99) &&
      (!se.Present() || se.User() != "engineer" || se.Hub() != "OT-ENGINEERING")
    action: drop
    log: true
    meta:
      policy: '"maintenance_authorization"'
      violation: '"unauthorized_maintenance_key_switch"'
      user: 'se.User()'
      hub: 'se.Hub()'
      outstation: 'dnp3.Outstation()'

  - name: maintenance-authorize-session
    order: 20
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
      dnp3.HasCanonicalEndpoints() && dnp3.IsLatchOn(99) &&
      se.Present() && se.User() == "engineer" && se.Hub() == "OT-ENGINEERING"
    action: set_state
    state_key_expr: >
      "industrial:maintenance:session:" + se.Session() + ":device:" +
      string(dnp3.Outstation())
    state_value: authorized
    state_ttl: 300
    log: true
    meta:
      phase: '"authorized"'
      user: 'se.User()'
      session: 'se.Session()'
      hub: 'se.Hub()'
      outstation: 'dnp3.Outstation()'
      expires_seconds: '300'

  - name: maintenance-mark-device-active
    order: 21
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
      dnp3.HasCanonicalEndpoints() && dnp3.IsLatchOn(99) &&
      se.Present() && se.User() == "engineer" && se.Hub() == "OT-ENGINEERING"
    action: set_state
    state_key_expr: '"industrial:maintenance:device:" + string(dnp3.Outstation())'
    state_value: active
    state_ttl: 300
    log: true
    meta:
      phase: '"maintenance_active"'
      outstation: 'dnp3.Outstation()'

  - name: maintenance-consume-restart-authorization
    order: 30
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsRestart() &&
      dnp3.HasCanonicalEndpoints() && se.Present()
    action: transition_state
    state_key_expr: >
      "industrial:maintenance:session:" + se.Session() + ":device:" +
      string(dnp3.Outstation())
    state_from: authorized
    state_value: consumed
    state_ttl: 1
    log: true
    meta:
      phase: '"authorization_consumption"'
      operation: '"dnp3_restart"'
      user: 'se.User()'
      session: 'se.Session()'
      outstation: 'dnp3.Outstation()'

  - name: maintenance-accept-authorized-restart
    order: 40
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsRestart() &&
      state_action_rule == "maintenance-consume-restart-authorization" &&
      state_action_applied
    action: accept
    log: true
    meta:
      policy: '"maintenance_authorization"'
      result: '"authorized_restart"'
      user: 'se.User()'
      session: 'se.Session()'
      outstation: 'dnp3.Outstation()'

  - name: maintenance-drop-unauthorized-restart
    order: 50
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsRestart()'
    action: drop
    log: true
    meta:
      policy: '"maintenance_authorization"'
      violation: '"restart_without_valid_session_authorization"'
      user: 'se.User()'
      session: 'se.Session()'
      hub: 'se.Hub()'
      outstation: 'dnp3.Outstation()'

  - name: maintenance-default-accept
    order: 1000
    condition: 'true'
    action: accept

DNP3 active/standby master

# Customer demo: first control master becomes active per outstation. For this
# example master 3 may authorize failover to master 4 using CROB:98 Latch-On.
version: 1

rules:
  - name: dnp3-master-authorize-failover-3-to-4
    order: 10
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
      dnp3.Master() == 3 && dnp3.IsLatchOn(98)
    action: transition_state
    state_key_expr: '"dnp3:active_master:" + string(dnp3.Outstation())'
    state_from: '3'
    state_value: failover_pending:4
    state_ttl: 30
    log: true
    meta:
      phase: '"failover_pending"'
      from_master: '3'
      to_master: '4'
      outstation: 'dnp3.Outstation()'

  - name: dnp3-master-accept-failover-authorization
    order: 11
    condition: >
      state_action_rule == "dnp3-master-authorize-failover-3-to-4" &&
      state_action_applied
    action: accept
    log: true
    meta:
      policy: '"active_standby_master"'
      result: '"failover_authorized"'

  - name: dnp3-master-claim-first-controller
    order: 20
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() &&
      dnp3.HasCanonicalEndpoints()
    action: transition_state
    state_key_expr: '"dnp3:active_master:" + string(dnp3.Outstation())'
    state_from: '@absent'
    state_value_expr: 'string(dnp3.Master())'
    state_ttl: 0
    log: true
    meta:
      phase: '"active_master_claim"'
      master: 'dnp3.Master()'
      outstation: 'dnp3.Outstation()'

  - name: dnp3-master-activate-standby-4
    order: 25
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() &&
      dnp3.Master() == 4
    action: transition_state
    state_key_expr: '"dnp3:active_master:" + string(dnp3.Outstation())'
    state_from: failover_pending:4
    state_value: '4'
    state_ttl: 0
    log: true
    meta:
      phase: '"failover_complete"'
      active_master: '4'
      outstation: 'dnp3.Outstation()'

  - name: dnp3-master-accept-active
    order: 30
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() &&
      GetState("dnp3:active_master:" + string(dnp3.Outstation())) == string(dnp3.Master())
    action: accept
    log: true
    meta:
      policy: '"active_standby_master"'
      result: '"active_master_control"'
      master: 'dnp3.Master()'
      outstation: 'dnp3.Outstation()'

  - name: dnp3-master-drop-standby-or-split-brain
    order: 40
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl()'
    action: drop
    log: true
    meta:
      policy: '"active_standby_master"'
      violation: '"standby_or_split_brain_control"'
      attempted_master: 'dnp3.Master()'
      active_state: 'GetState("dnp3:active_master:" + string(dnp3.Outstation()))'
      outstation: 'dnp3.Outstation()'

  - name: dnp3-master-default-accept
    order: 1000
    condition: 'true'
    action: accept

Industrial session identity binding

# Customer demo: bind the first industrial control flow to SoftEther identity,
# network endpoint and DNP3 master/outstation. Later controls must match exactly.
version: 1

rules:
  - name: identity-drop-control-without-softether
    order: 5
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() && !se.Present()'
    action: drop
    log: true
    meta:
      policy: '"session_identity_binding"'
      violation: '"missing_identity_context"'

  - name: identity-claim-first-control
    order: 10
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() &&
      dnp3.HasCanonicalEndpoints() && se.Present()
    action: transition_state
    state_key_expr: '"industrial:identity:" + dnp3.ControlSessionKey()'
    state_from: '@absent'
    state_value_expr: >
      se.Hub() + "|" + se.Session() + "|" + se.User() + "|" +
      src_ip + "|" + src_mac
    state_ttl: 3600
    log: true
    meta:
      phase: '"identity_claim"'
      hub: 'se.Hub()'
      session: 'se.Session()'
      user: 'se.User()'
      master: 'dnp3.Master()'
      outstation: 'dnp3.Outstation()'

  - name: identity-validate-control
    order: 20
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() &&
      dnp3.HasCanonicalEndpoints() && se.Present()
    action: transition_state
    state_key_expr: '"industrial:identity:" + dnp3.ControlSessionKey()'
    state_from_expr: >
      se.Hub() + "|" + se.Session() + "|" + se.User() + "|" +
      src_ip + "|" + src_mac
    state_value_expr: >
      se.Hub() + "|" + se.Session() + "|" + se.User() + "|" +
      src_ip + "|" + src_mac
    state_ttl: 3600
    log: true
    meta:
      phase: '"identity_validation"'
      hub: 'se.Hub()'
      session: 'se.Session()'
      user: 'se.User()'

  - name: identity-accept-bound-control
    order: 30
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl() &&
      state_action_rule == "identity-validate-control" && state_action_applied
    action: accept
    log: true
    meta:
      policy: '"session_identity_binding"'
      result: '"bound_identity"'
      hub: 'se.Hub()'
      session: 'se.Session()'
      user: 'se.User()'

  - name: identity-drop-drift-or-hijack
    order: 40
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsControl()'
    action: drop
    log: true
    meta:
      policy: '"session_identity_binding"'
      violation: '"identity_drift_or_session_hijack"'
      hub: 'se.Hub()'
      session: 'se.Session()'
      user: 'se.User()'
      master: 'dnp3.Master()'
      outstation: 'dnp3.Outstation()'

  - name: identity-default-accept
    order: 1000
    condition: 'true'
    action: accept

DNP3 safety trip and reset

# Customer demo: BI:30 trip cause, CROB:31 acknowledge, BI:30 clear and CROB:32 reset.
# Only operator@OT-OPERATIONS may acknowledge/reset.
version: 1
rules:
  - name: safety-observe-trip
    order: 10
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOn("BI:30") &&
      (!StateExists("dnp3:safety:" + string(dnp3.Outstation())) ||
       GetState("dnp3:safety:" + string(dnp3.Outstation())) == "normal")
    action: set_state
    state_key_expr: '"dnp3:safety:" + string(dnp3.Outstation())'
    state_value: tripped
    state_ttl: 0
    log: true
    meta:
      phase: '"tripped"'
      cause_point: '30'
      outstation: 'dnp3.Outstation()'

  - name: safety-acknowledge-trip
    order: 20
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(31) &&
      se.Present() && se.User() == "operator" && se.Hub() == "OT-OPERATIONS"
    action: transition_state
    state_key_expr: '"dnp3:safety:" + string(dnp3.Outstation())'
    state_from: tripped
    state_value: acknowledged
    state_ttl: 0
    log: true
    meta:
      phase: '"acknowledged"'
      user: 'se.User()'
      session: 'se.Session()'

  - name: safety-cause-cleared
    order: 30
    condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOff("BI:30")'
    action: transition_state
    state_key_expr: '"dnp3:safety:" + string(dnp3.Outstation())'
    state_from: acknowledged
    state_value: reset_allowed
    state_ttl: 300
    log: true
    meta:
      phase: '"reset_allowed"'
      cause_point: '30'

  - name: safety-consume-valid-reset
    order: 40
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(32) &&
      se.Present() && se.User() == "operator" && se.Hub() == "OT-OPERATIONS" &&
      StateExists("industrial:maintenance:device:" + string(dnp3.Outstation()))
    action: transition_state
    state_key_expr: '"dnp3:safety:" + string(dnp3.Outstation())'
    state_from: reset_allowed
    state_value: normal
    state_ttl: 1
    log: true
    meta:
      phase: '"reset"'
      user: 'se.User()'
      outstation: 'dnp3.Outstation()'
      maintenance_active: 'StateExists("industrial:maintenance:device:" + string(dnp3.Outstation()))'

  - name: safety-accept-valid-reset
    order: 50
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(32) &&
      state_action_rule == "safety-consume-valid-reset" && state_action_applied
    action: accept
    log: true
    meta:
      policy: '"safety_trip_reset"'
      result: '"valid_reset"'

  - name: safety-drop-invalid-reset
    order: 60
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(32)'
    action: drop
    log: true
    meta:
      policy: '"safety_trip_reset"'
      violation: '"reset_without_completed_sequence"'
      current_state: 'GetState("dnp3:safety:" + string(dnp3.Outstation()))'
      user: 'se.User()'

  - name: safety-default-accept
    order: 1000
    condition: 'true'
    action: accept

DNP3 unauthorized state change

# Monitor-only customer demo: BO feedback BI:20 is expected only after CROB:7.
version: 1
rules:
  - name: state-change-expect-on
    order: 10
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(7)'
    action: set_state
    state_key_expr: '"dnp3:expected_change:" + string(dnp3.Outstation()) + ":bi20"'
    state_value: expected:on
    state_ttl: 10

  - name: state-change-expect-off
    order: 11
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOff(7)'
    action: set_state
    state_key_expr: '"dnp3:expected_change:" + string(dnp3.Outstation()) + ":bi20"'
    state_value: expected:off
    state_ttl: 10

  - name: state-change-consume-on
    order: 20
    condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOn("BI:20")'
    action: transition_state
    state_key_expr: '"dnp3:expected_change:" + string(dnp3.Outstation()) + ":bi20"'
    state_from: expected:on
    state_value: observed
    state_ttl: 1

  - name: state-change-consume-off
    order: 21
    condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOff("BI:20")'
    action: transition_state
    state_key_expr: '"dnp3:expected_change:" + string(dnp3.Outstation()) + ":bi20"'
    state_from: expected:off
    state_value: observed
    state_ttl: 1

  - name: state-change-log-unexpected
    order: 30
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.HasPointRef("BI:20") &&
      !state_action_applied &&
      (state_action_rule == "state-change-consume-on" ||
       state_action_rule == "state-change-consume-off")
    action: log
    meta:
      policy: '"unauthorized_state_change"'
      observation: '"feedback_without_matching_command"'
      severity: '"medium"'
      outstation: 'dnp3.Outstation()'
      point: '"BI:20"'
      state: 'dnp3.meta.PointRefStateText("BI:20")'

  - name: state-change-default-accept
    order: 1000
    condition: 'true'
    action: accept

DNP3 valve/pump process interlock

# Customer demo: BI:10 must report valve open before CROB:11 pump start.
version: 1
rules:
  - name: interlock-valve-open
    order: 10
    condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOn("BI:10")'
    action: set_state
    state_key_expr: '"dnp3:interlock:" + string(dnp3.Outstation()) + ":valve10:pump11"'
    state_value: prerequisite_met
    state_ttl: 10
    log: true
    meta:
      phase: '"prerequisite_met"'
      valve_point: '10'
      outstation: 'dnp3.Outstation()'

  - name: interlock-valve-closed
    order: 11
    condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOff("BI:10")'
    action: delete_state
    state_key_expr: '"dnp3:interlock:" + string(dnp3.Outstation()) + ":valve10:pump11"'
    log: true
    meta:
      phase: '"prerequisite_removed"'

  - name: interlock-consume-pump-start
    order: 20
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(11)'
    action: transition_state
    state_key_expr: '"dnp3:interlock:" + string(dnp3.Outstation()) + ":valve10:pump11"'
    state_from: prerequisite_met
    state_value: operation_allowed
    state_ttl: 1
    log: true
    meta:
      phase: '"pump_start"'
      valve_point: '10'
      pump_point: '11'

  - name: interlock-accept-pump-start
    order: 30
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(11) &&
      state_action_rule == "interlock-consume-pump-start" && state_action_applied
    action: accept
    log: true
    meta:
      policy: '"valve_before_pump"'
      result: '"interlock_satisfied"'

  - name: interlock-drop-unsafe-pump-start
    order: 40
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(11)'
    action: drop
    log: true
    meta:
      policy: '"valve_before_pump"'
      violation: '"pump_start_without_open_valve"'
      outstation: 'dnp3.Outstation()'

  - name: interlock-default-accept
    order: 1000
    condition: 'true'
    action: accept

DNP3 file-transfer lifecycle

# Customer demo: controlled write of config.bin (<=10 MiB), exact block order,
# same SoftEther owner session, successful close, and maintenance authorization.
# DNP3 functions: 25=open, 26=close, 30=abort; data blocks use Write (FC=2).
version: 1
rules:
  - name: file-drop-invalid-open
    order: 5
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.HasFunc(25) &&
      (!se.Present() || se.User() != "engineer" ||
       !StateExists("industrial:maintenance:device:" + string(dnp3.Outstation())) ||
       !dnp3m.file.NameEq("config.bin") || !dnp3m.file.ModeEq(2) ||
       dnp3m.file.Size() <= 0 || dnp3m.file.Size() > 10485760 ||
       !dnp3.HasApplicationSequence())
    action: drop
    log: true
    meta:
      policy: '"dnp3_file_transfer"'
      violation: '"invalid_open_policy"'
      file: 'dnp3m.file.Name()'
      size: 'dnp3m.file.Size()'
      user: 'se.User()'

  - name: file-begin-open
    order: 10
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.HasFunc(25) &&
      dnp3.HasCanonicalEndpoints() && dnp3.HasApplicationSequence() &&
      dnp3m.file.NameEq("config.bin") && dnp3m.file.ModeEq(2) &&
      dnp3m.file.Size() > 0 && dnp3m.file.Size() <= 10485760 &&
      se.Present() && se.User() == "engineer" &&
      StateExists("industrial:maintenance:device:" + string(dnp3.Outstation()))
    action: transition_state
    state_key_expr: '"dnp3:file:" + dnp3.ControlSessionKey()'
    state_from: '@absent'
    state_value_expr: '"open_pending|" + string(dnp3.ApplicationSequence())'
    state_ttl: 30
    log: true
    meta:
      phase: '"open_pending"'
      file: 'dnp3m.file.Name()'
      size: 'dnp3m.file.Size()'
      owner_session: 'se.Session()'

  - name: file-bind-owner
    order: 11
    condition: >
      state_action_rule == "file-begin-open" && state_action_applied
    action: set_state
    state_key_expr: '"dnp3:file_owner:" + dnp3.ControlSessionKey()'
    state_value_expr: 'se.Hub() + "|" + se.Session() + "|" + se.User()'
    state_ttl: 300

  - name: file-accept-valid-open
    order: 12
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.HasFunc(25) &&
      GetState("dnp3:file_owner:" + dnp3.ControlSessionKey()) ==
      se.Hub() + "|" + se.Session() + "|" + se.User()
    action: accept
    log: true
    meta:
      policy: '"dnp3_file_transfer"'
      result: '"open_allowed"'

  - name: file-drop-concurrent-open
    order: 13
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.HasFunc(25)'
    action: drop
    log: true
    meta:
      policy: '"dnp3_file_transfer"'
      violation: '"concurrent_or_duplicate_open"'

  - name: file-confirm-open-response
    order: 20
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.HasApplicationSequence() &&
      dnp3m.file.StatusEq(0) && dnp3m.file.Handle() > 0
    action: transition_state
    state_key_expr: '"dnp3:file:" + dnp3.ControlSessionKey()'
    state_from_expr: '"open_pending|" + string(dnp3.ApplicationSequence())'
    state_value_expr: '"opened|" + string(dnp3m.file.Handle()) + "|next:0"'
    state_ttl: 120
    log: true
    meta:
      phase: '"opened"'
      handle: 'dnp3m.file.Handle()'

  - name: file-record-last-block
    order: 30
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsWrite() &&
      dnp3m.file.Present() && dnp3m.file.LastBlock() && dnp3m.file.BlockNum() >= 0 &&
      GetState("dnp3:file_owner:" + dnp3.ControlSessionKey()) ==
      se.Hub() + "|" + se.Session() + "|" + se.User()
    action: transition_state
    state_key_expr: '"dnp3:file:" + dnp3.ControlSessionKey()'
    state_from_expr: >
      (dnp3m.file.BlockNum() == 0 ? "opened|" : "transferring|") +
      string(dnp3m.file.Handle()) + "|next:" + string(dnp3m.file.BlockNum())
    state_value_expr: '"last_block|" + string(dnp3m.file.Handle())'
    state_ttl: 60
    log: true
    meta:
      phase: '"last_block"'
      block: 'dnp3m.file.BlockNum()'
      handle: 'dnp3m.file.Handle()'

  - name: file-record-data-block
    order: 31
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsWrite() &&
      dnp3m.file.Present() && !dnp3m.file.LastBlock() && dnp3m.file.BlockNum() >= 0 &&
      GetState("dnp3:file_owner:" + dnp3.ControlSessionKey()) ==
      se.Hub() + "|" + se.Session() + "|" + se.User()
    action: transition_state
    state_key_expr: '"dnp3:file:" + dnp3.ControlSessionKey()'
    state_from_expr: >
      (dnp3m.file.BlockNum() == 0 ? "opened|" : "transferring|") +
      string(dnp3m.file.Handle()) + "|next:" + string(dnp3m.file.BlockNum())
    state_value_expr: >
      "transferring|" + string(dnp3m.file.Handle()) + "|next:" +
      string(dnp3m.file.BlockNum() + 1)
    state_ttl: 120
    log: true
    meta:
      phase: '"transferring"'
      block: 'dnp3m.file.BlockNum()'
      next_block: 'dnp3m.file.BlockNum() + 1'

  - name: file-accept-ordered-block
    order: 35
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsWrite() && dnp3m.file.Present() &&
      (state_action_rule == "file-record-last-block" ||
       state_action_rule == "file-record-data-block") && state_action_applied
    action: accept
    log: true
    meta:
      policy: '"dnp3_file_transfer"'
      result: '"ordered_block"'
      block: 'dnp3m.file.BlockNum()'

  - name: file-drop-invalid-block
    order: 40
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsWrite() && dnp3m.file.Present()'
    action: drop
    log: true
    meta:
      policy: '"dnp3_file_transfer"'
      violation: '"block_without_open_wrong_owner_handle_or_order"'
      block: 'dnp3m.file.BlockNum()'
      handle: 'dnp3m.file.Handle()'

  - name: file-record-close-request
    order: 50
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.HasFunc(26) &&
      dnp3.HasApplicationSequence() &&
      GetState("dnp3:file_owner:" + dnp3.ControlSessionKey()) ==
      se.Hub() + "|" + se.Session() + "|" + se.User()
    action: transition_state
    state_key_expr: '"dnp3:file:" + dnp3.ControlSessionKey()'
    state_from_expr: '"last_block|" + string(dnp3m.file.Handle())'
    state_value_expr: '"close_pending|" + string(dnp3.ApplicationSequence())'
    state_ttl: 30
    log: true
    meta:
      phase: '"close_pending"'
      handle: 'dnp3m.file.Handle()'

  - name: file-accept-valid-close
    order: 55
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.HasFunc(26) &&
      state_action_rule == "file-record-close-request" && state_action_applied
    action: accept

  - name: file-drop-invalid-close
    order: 56
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.HasFunc(26)'
    action: drop
    log: true
    meta:
      policy: '"dnp3_file_transfer"'
      violation: '"close_before_last_block_or_wrong_owner"'

  - name: file-confirm-close-response
    order: 60
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.HasApplicationSequence() &&
      dnp3m.file.StatusEq(0)
    action: transition_state
    state_key_expr: '"dnp3:file:" + dnp3.ControlSessionKey()'
    state_from_expr: '"close_pending|" + string(dnp3.ApplicationSequence())'
    state_value: completed
    state_ttl: 5
    log: true
    meta:
      phase: '"completed"'

  - name: file-abort-transfer
    order: 70
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.HasFunc(30)'
    action: delete_state
    state_key_expr: '"dnp3:file:" + dnp3.ControlSessionKey()'
    log: true
    meta:
      phase: '"aborted"'

  - name: file-default-accept
    order: 1000
    condition: 'true'
    action: accept

DNP3 configuration change and restart verification

# Customer demo: a protected configuration Write (G80V1) must be followed by a
# correlated restart and successful Class-0 health scan within policy windows.
version: 1
rules:
  - name: config-record-change
    order: 10
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsWrite() && dnp3.HasObject(80, 1) &&
      se.Present() && se.User() == "engineer" &&
      StateExists("industrial:maintenance:device:" + string(dnp3.Outstation()))
    action: set_state
    state_key_expr: '"dnp3:change_restart:" + string(dnp3.Outstation())'
    state_value: config_changed
    state_ttl: 300
    log: true
    meta:
      phase: '"config_changed"'
      user: 'se.User()'
      session: 'se.Session()'
      outstation: 'dnp3.Outstation()'

  - name: config-record-expected-restart
    order: 20
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsRestart() &&
      dnp3.HasApplicationSequence()
    action: transition_state
    state_key_expr: '"dnp3:change_restart:" + string(dnp3.Outstation())'
    state_from: config_changed
    state_value_expr: '"restart_pending|" + string(dnp3.ApplicationSequence())'
    state_ttl: 60
    log: true
    meta:
      phase: '"restart_pending"'
      sequence: 'dnp3.ApplicationSequence()'

  - name: config-accept-expected-restart
    order: 25
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsRestart() &&
      state_action_rule == "config-record-expected-restart" && state_action_applied
    action: accept
    log: true
    meta:
      policy: '"change_then_restart"'
      result: '"expected_restart"'

  - name: config-drop-unexpected-restart
    order: 26
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsRestart()'
    action: drop
    log: true
    meta:
      policy: '"change_then_restart"'
      violation: '"restart_without_configuration_change"'
      outstation: 'dnp3.Outstation()'

  - name: config-confirm-device-restarted
    order: 30
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.DeviceRestarted() &&
      dnp3.HasApplicationSequence()
    action: transition_state
    state_key_expr: '"dnp3:change_restart:" + string(dnp3.Outstation())'
    state_from_expr: '"restart_pending|" + string(dnp3.ApplicationSequence())'
    state_value: health_check_required
    state_ttl: 120
    log: true
    meta:
      phase: '"health_check_required"'
      sequence: 'dnp3.ApplicationSequence()'

  - name: config-record-health-scan
    order: 40
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsClassRead(0) &&
      dnp3.HasApplicationSequence()
    action: transition_state
    state_key_expr: '"dnp3:change_restart:" + string(dnp3.Outstation())'
    state_from: health_check_required
    state_value_expr: '"health_pending|" + string(dnp3.ApplicationSequence())'
    state_ttl: 60
    log: true
    meta:
      phase: '"health_check_pending"'
      sequence: 'dnp3.ApplicationSequence()'

  - name: config-verify-health-response
    order: 50
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.IsFinalFragment() &&
      dnp3.HasApplicationSequence() && !dnp3.HasIINError()
    action: transition_state
    state_key_expr: '"dnp3:change_restart:" + string(dnp3.Outstation())'
    state_from_expr: '"health_pending|" + string(dnp3.ApplicationSequence())'
    state_value: verified
    state_ttl: 5
    log: true
    meta:
      phase: '"verified"'
      outstation: 'dnp3.Outstation()'

  - name: config-log-change-without-restart
    order: 60
    condition: >
      dnp3.IsDNP3() && dnp3.HasCanonicalEndpoints() &&
      GetState("dnp3:change_restart:" + string(dnp3.Outstation())) == "config_changed" &&
      StateAge("dnp3:change_restart:" + string(dnp3.Outstation())) >= 120
    action: set_state
    state_key_expr: '"dnp3:change_restart:" + string(dnp3.Outstation())'
    state_value: failed:restart_timeout
    state_ttl: 300
    log: true
    meta:
      phase: '"failed"'
      reason: '"configuration_change_without_restart"'

  - name: config-default-accept
    order: 1000
    condition: 'true'
    action: accept

Industrial rate and burst detection

# Customer demo: rolling-window limits. TTL refreshes on each accepted increment;
# one threshold-crossing event is logged, while every over-limit event is dropped.
version: 1
rules:
  - name: rate-count-dnp3-operate
    order: 10
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate()'
    action: increment_state
    state_key_expr: '"rate:dnp3:operate:" + dnp3.ControlSessionKey()'
    state_delta: 1
    state_limit: 5
    state_ttl: 10

  - name: rate-mark-operate-threshold
    order: 11
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
      state_action_rule == "rate-count-dnp3-operate" && !state_action_applied
    action: transition_state
    state_key_expr: '"rate_alert:dnp3:operate:" + dnp3.ControlSessionKey()'
    state_from: '@absent'
    state_value: fired
    state_ttl: 10

  - name: rate-log-operate-threshold-once
    order: 12
    condition: >
      state_action_rule == "rate-mark-operate-threshold" && state_action_applied
    action: log
    stop: false
    meta:
      policy: '"rate_burst"'
      violation: '"dnp3_operate_rate_exceeded"'
      limit: '5'
      window_seconds: '10'
      master: 'dnp3.Master()'
      outstation: 'dnp3.Outstation()'

  - name: rate-drop-over-limit-operate
    order: 13
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() &&
      StateExists("rate_alert:dnp3:operate:" + dnp3.ControlSessionKey()) &&
      GetStateInt("rate:dnp3:operate:" + dnp3.ControlSessionKey()) >= 5
    action: drop

  - name: rate-count-dnp3-restart
    order: 20
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsRestart()'
    action: increment_state
    state_key_expr: '"rate:dnp3:restart:" + dnp3.ControlSessionKey()'
    state_delta: 1
    state_limit: 2
    state_ttl: 60

  - name: rate-drop-restart-burst
    order: 21
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsRestart() &&
      state_action_rule == "rate-count-dnp3-restart" && !state_action_applied
    action: drop
    log: true
    meta:
      policy: '"rate_burst"'
      violation: '"dnp3_restart_rate_exceeded"'
      limit: '2'
      window_seconds: '60'

  - name: rate-count-dnp3-write
    order: 30
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsWrite()'
    action: increment_state
    state_key_expr: '"rate:dnp3:write:" + dnp3.ControlSessionKey()'
    state_delta: 1
    state_limit: 20
    state_ttl: 10

  - name: rate-drop-write-storm
    order: 31
    condition: >
      dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsWrite() &&
      state_action_rule == "rate-count-dnp3-write" && !state_action_applied
    action: drop
    log: true
    meta:
      policy: '"rate_burst"'
      violation: '"dnp3_write_storm"'
      limit: '20'
      window_seconds: '10'

  - name: rate-default-accept
    order: 1000
    condition: 'true'
    action: accept

Industrial alarm lifecycle and chattering

# Customer demo: BI:40 alarm lifecycle and chattering detection. CROB:41 Latch-On
# acknowledges the alarm. Four real state toggles within a rolling 30s window
# generate one chattering event.
version: 1
rules:
  - name: alarm-activate
    order: 5
    condition: >
      dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOn("BI:40") &&
      (!StateExists("alarm:lifecycle:" + string(dnp3.Outstation()) + ":bi40") ||
       GetState("alarm:lifecycle:" + string(dnp3.Outstation()) + ":bi40") == "cleared" ||
       GetState("alarm:lifecycle:" + string(dnp3.Outstation()) + ":bi40") == "cleared_without_ack")
    action: set_state
    state_key_expr: '"alarm:lifecycle:" + string(dnp3.Outstation()) + ":bi40"'
    state_value: active
    state_ttl: 0
    log: true
    meta:
      phase: '"active"'
      alarm_point: '40'

  - name: alarm-acknowledge
    order: 6
    condition: 'dnp3.IsDNP3() && dnp3.IsRequest() && dnp3.IsOperate() && dnp3.IsLatchOn(41)'
    action: transition_state
    state_key_expr: '"alarm:lifecycle:" + string(dnp3.Outstation()) + ":bi40"'
    state_from: active
    state_value: acknowledged
    state_ttl: 0
    log: true
    meta:
      phase: '"acknowledged"'
      master: 'dnp3.Master()'

  - name: alarm-clear-after-ack
    order: 7
    condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOff("BI:40")'
    action: transition_state
    state_key_expr: '"alarm:lifecycle:" + string(dnp3.Outstation()) + ":bi40"'
    state_from: acknowledged
    state_value: cleared
    state_ttl: 5
    log: true
    meta:
      phase: '"cleared"'

  - name: alarm-clear-without-ack
    order: 8
    condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOff("BI:40")'
    action: transition_state
    state_key_expr: '"alarm:lifecycle:" + string(dnp3.Outstation()) + ":bi40"'
    state_from: active
    state_value: cleared_without_ack
    state_ttl: 30
    log: true
    meta:
      phase: '"cleared_without_ack"'
      observation: '"alarm_cleared_before_acknowledge"'

  - name: alarm-init-last-on
    order: 10
    condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOn("BI:40")'
    action: transition_state
    state_key_expr: '"alarm:last:" + string(dnp3.Outstation()) + ":bi40"'
    state_from: '@absent'
    state_value: on
    state_ttl: 300

  - name: alarm-init-last-off
    order: 11
    condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOff("BI:40")'
    action: transition_state
    state_key_expr: '"alarm:last:" + string(dnp3.Outstation()) + ":bi40"'
    state_from: '@absent'
    state_value: off
    state_ttl: 300

  - name: alarm-toggle-to-on
    order: 20
    condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOn("BI:40")'
    action: transition_state
    state_key_expr: '"alarm:last:" + string(dnp3.Outstation()) + ":bi40"'
    state_from: off
    state_value: on
    state_ttl: 300

  - name: alarm-toggle-to-off
    order: 21
    condition: 'dnp3.IsDNP3() && dnp3.IsResponse() && dnp3.PointRefOff("BI:40")'
    action: transition_state
    state_key_expr: '"alarm:last:" + string(dnp3.Outstation()) + ":bi40"'
    state_from: on
    state_value: off
    state_ttl: 300

  - name: alarm-count-real-toggle
    order: 30
    condition: >
      (state_action_rule == "alarm-toggle-to-on" ||
       state_action_rule == "alarm-toggle-to-off") && state_action_applied
    action: increment_state
    state_key_expr: '"alarm:toggles:" + string(dnp3.Outstation()) + ":bi40"'
    state_delta: 1
    state_ttl: 30

  - name: alarm-mark-chattering
    order: 40
    condition: >
      state_action_rule == "alarm-count-real-toggle" && state_action_applied &&
      GetStateInt("alarm:toggles:" + string(dnp3.Outstation()) + ":bi40") >= 4
    action: transition_state
    state_key_expr: '"alarm:chattering:" + string(dnp3.Outstation()) + ":bi40"'
    state_from: '@absent'
    state_value: detected
    state_ttl: 30

  - name: alarm-log-chattering-once
    order: 41
    condition: 'state_action_rule == "alarm-mark-chattering" && state_action_applied'
    action: log
    meta:
      policy: '"alarm_lifecycle"'
      observation: '"alarm_chattering"'
      point: '"BI:40"'
      toggles: 'GetStateInt("alarm:toggles:" + string(dnp3.Outstation()) + ":bi40")'
      window_seconds: '30'

  - name: alarm-default-accept
    order: 1000
    condition: 'true'
    action: accept

Modbus read-before-write and readback

Supported This existing example is preserved and tested. Further Modbus expansion is not currently active.

# Industrial Modbus read-before-write and readback policy.
# Customer parameters: holding register 10, allowed value 0..1000, TTLs, verdicts.
# The fixed target is intentional: Modbus read responses carry values but not the
# original address, so the transaction state supplies that correlation context.
version: 1

rules:
  - name: modbus-rbw-record-read-request
    order: 10
    condition: >
      modbus.IsModbus() && modbus.IsRequest() && modbus.Function() == 3 &&
      modbus.Address() == 10 && modbus.Quantity() == 1 &&
      modbus.TransactionKey() != ""
    action: set_state
    state_key_expr: '"modbus:rbw:read-tx:" + modbus.TransactionKey()'
    state_value: pending
    state_ttl: 5

  - name: modbus-rbw-confirm-read-response
    order: 20
    condition: >
      modbus.IsModbus() && modbus.IsResponse() && !modbus.IsException() &&
      modbus.Function() == 3 && modbus.RegisterValueCount() == 1 &&
      modbus.TransactionKey() != ""
    action: transition_state
    state_key_expr: '"modbus:rbw:read-tx:" + modbus.TransactionKey()'
    state_from: pending
    state_value: confirmed
    state_ttl: 2

  - name: modbus-rbw-authorize-write-after-read
    order: 30
    condition: >
      state_action_rule == "modbus-rbw-confirm-read-response" &&
      state_action_applied
    action: set_state
    state_key_expr: '"modbus:rbw:read-ok:" + modbus.SessionKey() + ":hr10"'
    state_value: authorized
    state_ttl: 30
    log: true
    meta:
      phase: '"read_confirmed"'
      register: '10'
      observed_value: 'modbus.RegisterValueAt(0)'
      transaction: 'modbus.TransactionID()'

  - name: modbus-rbw-record-authorized-write
    order: 40
    condition: >
      modbus.IsModbus() && modbus.IsRequest() && modbus.Function() == 6 &&
      modbus.Address() == 10 && modbus.RegisterValueCount() == 1 &&
      modbus.RegValue(10) >= 0 && modbus.RegValue(10) <= 1000 &&
      StateExists("modbus:rbw:read-ok:" + modbus.SessionKey() + ":hr10") &&
      modbus.TransactionKey() != ""
    action: set_state
    state_key_expr: '"modbus:rbw:write-tx:" + modbus.TransactionKey()'
    state_value_expr: 'string(modbus.RegValue(10))'
    state_ttl: 5
    log: true
    meta:
      phase: '"write_requested"'
      register: '10'
      requested_value: 'modbus.RegValue(10)'
      transaction: 'modbus.TransactionID()'

  - name: modbus-rbw-drop-write-without-read
    order: 50
    condition: >
      modbus.IsModbus() && modbus.IsRequest() && modbus.Function() == 6 &&
      modbus.Address() == 10 &&
      !StateExists("modbus:rbw:read-ok:" + modbus.SessionKey() + ":hr10")
    action: drop
    log: true
    meta:
      violation: '"write_without_confirmed_read"'
      register: '10'
      requested_value: 'modbus.RegValue(10)'

  - name: modbus-rbw-confirm-write-response
    order: 60
    condition: >
      modbus.IsModbus() && modbus.IsResponse() && !modbus.IsException() &&
      modbus.Function() == 6 && modbus.TransactionKey() != ""
    action: transition_state
    state_key_expr: '"modbus:rbw:write-tx:" + modbus.TransactionKey()'
    state_from_expr: 'string(modbus.RegValue(10))'
    state_value: acknowledged
    state_ttl: 2

  - name: modbus-rbw-await-readback
    order: 70
    condition: >
      state_action_rule == "modbus-rbw-confirm-write-response" &&
      state_action_applied
    action: set_state
    state_key_expr: '"modbus:rbw:expected:" + modbus.SessionKey() + ":hr10"'
    state_value_expr: 'string(modbus.RegValue(10))'
    state_ttl: 30

  - name: modbus-rbw-verify-readback
    order: 80
    condition: >
      modbus.IsModbus() && modbus.IsResponse() && modbus.Function() == 3 &&
      modbus.RegisterValueCount() == 1 &&
      StateExists("modbus:rbw:expected:" + modbus.SessionKey() + ":hr10") &&
      modbus.RegisterValueAt(0) == GetStateInt("modbus:rbw:expected:" + modbus.SessionKey() + ":hr10")
    action: delete_state
    state_key_expr: '"modbus:rbw:expected:" + modbus.SessionKey() + ":hr10"'
    log: true
    meta:
      phase: '"readback_verified"'
      register: '10'
      observed_value: 'modbus.RegisterValueAt(0)'

  - name: modbus-rbw-log-readback-mismatch
    order: 90
    condition: >
      modbus.IsModbus() && modbus.IsResponse() && modbus.Function() == 3 &&
      modbus.RegisterValueCount() == 1 &&
      StateExists("modbus:rbw:expected:" + modbus.SessionKey() + ":hr10") &&
      modbus.RegisterValueAt(0) != GetStateInt("modbus:rbw:expected:" + modbus.SessionKey() + ":hr10")
    action: log
    log: true
    meta:
      violation: '"readback_mismatch"'
      register: '10'
      expected: 'GetStateInt("modbus:rbw:expected:" + modbus.SessionKey() + ":hr10")'
      observed: 'modbus.RegisterValueAt(0)'

  - name: modbus-rbw-default-accept
    order: 1000
    condition: 'true'
    action: accept