ROS 2 Scene State + Typed Field Policy Authoring

How to write Rego policies against the two policy-input channels the #1130 + #1131 epic shipped:

  • Scene state — live perception/environment signals pushed by adapters (nvblox costmap, lidar watchdog, safety monitor, etc.) via POST /v1/scene-state; visible to Rego at input.scene.<source>.fields.*.

  • Typed message fields — ROS 2 message payloads decoded from CDR wire bytes; visible to Rego at input.params.<field> (in addition to the existing input.params.{topic,type,payload_b64} metadata).

These channels compose with each other and with the existing tool.ros2.topic.publish gate — one policy can gate on message content AND scene context together.

Input reference

Policy evaluation input for tool.ros2.topic.publish calls, with the new surfaces highlighted:

{
  "kind": "tool.ros2.topic.publish",
  "params": {
    "topic":       "/cmd_vel",
    "type":        "geometry_msgs/msg/Twist",
    "payload_b64": "<base64 CDR bytes>",
    "_bridge_origin": "governed_ros2_bridge",

    // ── Typed field decoding (PR-C: Twist, PR-E: PoseStamped) ──
    // Present only when input.params.type is in the KnownTypes set
    // (runtime/ros2bridge.KnownTypes). Missing on unmapped types
    // (existing non-typed topics keep working via payload_b64 alone).
    "linear":  { "x": 1.5, "y": 0.0, "z": 0.0 },
    "angular": { "x": 0.0, "y": 0.0, "z": 0.7 },
    "pose":    { "position": {...}, "orientation": {...} },
    "header":  { "frame_id": "map", "stamp": {"sec": ..., "nanosec": ...} },

    // ── Fail-closed decode signal (PR-C review fix1) ──
    // Runtime SETS this when a KnownTypes payload fails to decode.
    // Callers cannot set it (runtime deletes any caller value first).
    "_typed_decode_error": {
      "type":   "geometry_msgs/msg/Twist",
      "reason": "ros2bridge: typed decode failed: ..."
    }
  },

  // ── Scene state (PR-B: exposes PR-A ingest to Rego) ──
  // Per-source latest-wins snapshot. Empty map when no adapter has
  // posted. Number values inside fields are json.Number — OPA compares
  // them at integer precision (PR-A fix2 preserves through the pipeline).
  "scene": {
    "nvblox": {
      "source":    "nvblox",
      "timestamp": "2026-07-01T03:15:27.5Z",
      "fields": {
        "dynamic_obstacle_present": true,
        "occupied_zones":           ["human_shared_aisle"],
        "risk_score":               0.87
      }
    },
    "lidar-watchdog": {
      "source":    "lidar-watchdog",
      "timestamp": "2026-07-01T03:15:26.1Z",
      "fields": { "obstacle_count": 3 }
    }
  }
}

KnownTypes: what gets typed-decoded

Runtime today ships decoders for:

ROS 2 type

Decoded fields

Use cases

geometry_msgs/msg/Twist

input.params.linear.{x,y,z}, input.params.angular.{x,y,z}

per-zone speed limits, direction gating

geometry_msgs/msg/PoseStamped

input.params.pose.position.{x,y,z}, input.params.pose.orientation.{x,y,z,w}, input.params.header.frame_id, input.params.header.stamp.{sec,nanosec}

nav-goal zone gating, provenance-aware rules

Extension is a runtime-side Go change (runtime/ros2bridge/typed_decode.go) — add the type constant, KnownTypes registration, and a decodeXxx function that handles the CDR wire layout. See PR-C (#1140) and PR-E (#1142) for worked examples.

Fail-closed patterns

Pattern 1: typed-decode failure must deny

Any policy that reaches into decoded typed fields (input.params.linear, input.params.pose, etc.) MUST ship this rule. The runtime surfaces _typed_decode_error when a KnownTypes payload fails to decode; without this rule, a malformed payload evaluates against input.params.linear = undefined and rules like linear.x > 1.0 don’t fire, silently admitting the request.

package autonomy
import rego.v1
default allow := true

# Fail-closed on any typed decode failure.
allow := false if {
    input.params._typed_decode_error
}

This is a runtime-controlled signal — attackers can’t spoof it in either direction (PR-C review fix1).

Pattern 2: scene-state staleness

Scene-state entries carry the observation timestamp (RFC3339Nano UTC) so policies can refuse to gate on stale signals:

default_max_scene_age_ns := 5000000000  # 5 seconds

# Deny cmd_vel if nvblox scene-state is older than max_scene_age_ns.
allow := false if {
    input.kind == "tool.ros2.topic.publish"
    input.params.topic == "/cmd_vel"
    scene_ns := time.parse_rfc3339_ns(input.scene.nvblox.timestamp)
    now_ns   := time.now_ns()
    now_ns - scene_ns > default_max_scene_age_ns
}

The now_ns() - scene_ns > MAX shape denies when the last scene update is too old. Complement with an alerting adapter that pushes updates at a higher rate than MAX so a healthy system never trips this rule.

Pattern 3: absent-source treated as unsafe

If your safety case requires a specific perception source to be reporting (e.g. nvblox for a human-detection story), assert its presence — an adapter crash shouldn’t silently admit motion:

# Deny cmd_vel unless nvblox has ever posted a scene-state.
allow := false if {
    input.kind == "tool.ros2.topic.publish"
    input.params.topic == "/cmd_vel"
    not input.scene.nvblox
}

The not input.scene.nvblox check fires when the key is absent (no POST from that source since runtime start OR since a filter cleared it).

Worked examples

Example 1: per-zone speed limits

Cap linear velocity at 0.5 m/s inside slow_zone, 2.0 m/s elsewhere:

package autonomy
import rego.v1
default allow := true

# Fail-closed on decode failure.
allow := false if input.params._typed_decode_error

# Slow-zone: linear.x above 0.5 m/s denied.
allow := false if {
    input.kind == "tool.ros2.topic.publish"
    input.params.topic == "/cmd_vel"
    some zone in input.scene.perception.fields.occupied_zones
    zone == "slow_zone"
    input.params.linear.x > 0.5
}

# General ceiling: linear.x above 2.0 m/s always denied.
allow := false if {
    input.kind == "tool.ros2.topic.publish"
    input.params.topic == "/cmd_vel"
    input.params.linear.x > 2.0
}

Example 2: nav goal zone denial

Deny goals into any zone the perception adapter marks as restricted_zones:

allow := false if {
    input.kind == "tool.ros2.topic.publish"
    input.params.topic == "/goal_pose"
    input.params.header.frame_id == "map"
    some zone_name in input.scene.perception.fields.restricted_zones
    zone_polygon := data.zones[zone_name]  # from a static data doc
    point_in_polygon(input.params.pose.position, zone_polygon)
}

(point_in_polygon and the data.zones lookup are policy-author-provided helpers; the framework doesn’t ship geometric primitives out of the box — the intent is that the adapter tells you WHICH zone is occupied and the policy decides WHERE that zone is.)

Example 3: multi-source consensus

Deny motion only when TWO independent perception sources agree an obstacle is present (guards against a single false-positive from a flaky sensor):

allow := false if {
    input.kind == "tool.ros2.topic.publish"
    input.params.topic == "/cmd_vel"
    input.scene.nvblox.fields.dynamic_obstacle_present == true
    input.scene["lidar-watchdog"].fields.obstacle_count > 0
}

Sources with hyphens or other punctuation need bracket-lookup syntax (input.scene["lidar-watchdog"]), not dot-access.

Example 4: allow-only-when-scene-fresh

Combine staleness + presence + content: only allow motion in a shared aisle when the aisle-camera adapter reports “clear” AND its report is fresh:

default allow := true

allow := false if {
    input.kind == "tool.ros2.topic.publish"
    input.params.topic == "/cmd_vel"

    # Fresh aisle-camera report is required.
    not fresh_and_clear
}

fresh_and_clear if {
    scene_ns := time.parse_rfc3339_ns(input.scene["aisle-camera"].timestamp)
    time.now_ns() - scene_ns < 2000000000   # 2s max age
    input.scene["aisle-camera"].fields.aisle_clear == true
}

Common pitfalls

Pitfall 1: forgetting the _typed_decode_error rule

A policy that gates on input.params.linear.x > 1.0 but doesn’t ship the fail-closed decode-error rule will silently admit malformed Twist payloads. The malformed payload evaluates with input.params.linear = undefined, the comparison rule doesn’t fire, and default-allow lets it through. Always include Pattern 1 above when you gate on typed fields.

Pitfall 2: assuming input.scene is always present

input.scene is always present as at least an empty map (never nil / undefined), so count(input.scene) == 0 works cleanly. But input.scene.nvblox IS absent when no adapter with that source name has posted yet — guard with not input.scene.nvblox or input.scene.nvblox.fields.X (rules against missing paths evaluate to undefined, which is not the same as false).

Pitfall 3: comparing numbers across two type-origination paths

Numbers reach Rego from two different code paths in this epic — with subtly different Go types — but OPA handles both identically, so the practical guidance is the same either way: compare them directly. The distinction matters only if you’re debugging what the store or handler actually holds.

  • Scene state (from POST /v1/scene-state): numbers arrive as json.Number because the ingress handler uses decoder.UseNumber() to preserve integer precision above 2^53 through the JSON path. That’s PR-A fix2’s contract. Example: input.scene.lidar.fields.count.

  • Typed-decoded ROS 2 fields (from PR-C/PR-E CDR decoders): numbers arrive as native Go types (int32, uint32, float64) from binary wire decode — no JSON round-trip. Example: input.params.header.stamp.sec is int32, input.params.header.stamp.nanosec is uint32, input.params.pose.position.x is float64.

OPA compares both natively. input.scene.lidar.fields.count > 1000 works whether count is json.Number or int64; same for input.params.header.stamp.sec > 1700000000. No conversion helpers needed in Rego either way.

Pitfall 4: source names with punctuation

Rego dot-access can’t reach input.scene.lidar-watchdog (parser treats - as subtraction). Use bracket-lookup: input.scene["lidar-watchdog"].

Pitfall 5: default allow := true vs allow := false

For composable governance rules that layer on top of an application policy, default to true and let the specific rules deny (see demo/policies/nvblox/nvblox.rego). For standalone allowlists (like demo/policies/mavlink/mavlink.rego), default to false and let specific rules allow. Pick per-policy based on whether “no rule matched” should mean allow or deny.

Installed: the demo/policies/nvblox/ and demo/policies/mavlink/ paths above are in-repo source references for reading + forking; the installed binary ships them embedded as default demo policies (the first is loaded via autonomy run --policy demo/policies/nvblox/nvblox.rego -- <agent>, the second via autonomy demo mavlink-sitl). Production policies land in the managed cache and are loaded via autonomy run --policy <oci-ref>.

Wire flow reference

For end-to-end context on how the input surfaces get populated:

Sensor / perception ─▶ adapter ─▶ POST /v1/scene-state ─▶ runtime store (PR-A)
                                                                  │
                                                                  ├─▶ input.scene.<source>.*  (PR-B, seen by this policy)
                                                                  │
                                                                  └─▶ autonomy.scene_state WAL entry (PR-D, seen by `wal inspect`)

Agent ─▶ ROS 2 publish ─▶ bridge intercepts ─▶ POST /v1/tool ─▶ runtime evaluates THIS policy
                                                                  │
                                                                  ├─▶ input.params.linear/pose/etc  (PR-C/PR-E typed decode)
                                                                  │
                                                                  └─▶ input.params._typed_decode_error on decode fail (PR-C fix1)

Governing a high-rate topic on live external perception

When the governed bridge (autonomy ros2 run --governed-bridge) intercepts a high-rate topic such as /cmd_vel, it POSTs /v1/tool once per message — at the topic’s publish rate.

Start the external runtime decision-only (#1225). With --governed-bridge --runtime-url <external>, the bridge is itself the DDS republisher — it asks the external runtime only for the verdict and republishes allowed messages on the real domain itself. Start that runtime with autonomy runtime start --decision-only so it returns 200 {"decision":"allow"|"deny"} without attempting to deliver the message. A plain (executing) autonomy runtime start would try to deliver tool.ros2.topic.publish via a DDS mediator it doesn’t have and return HTTP 400 “no DDS bridge configured”, which the bridge conflates with failure and drops every allowed message.

If your policy gates that topic on live scene-state (Pattern 2 / Pattern 4 above), also keep the bridge and adapter rates aligned so the runtime does not evaluate against momentarily-stale scene-state and deny a steady stream:

  • Post scene-state at least as fast as the governed topic publishes. A 5 Hz /cmd_vel governed on a 5 Hz adapter leaves almost no slack — prefer the adapter running faster than the topic, or widen the freshness ceiling.

  • Size the freshness ceiling (max_scene_age_ns) generously relative to the adapter interval — several adapter periods, not one. A 200 ms adapter with a 200 ms ceiling denies roughly half the time purely on jitter; a 1–2 s ceiling over a 200 ms adapter tolerates normal lag while still failing closed on a genuinely dead perception feed.

  • The scene-state store is latest-wins with no runtime TTL: freshness is entirely policy-controlled via the time.now_ns() - scene_ns check. There is no AUTONOMY_SCENE_STATE_TTL knob — tune the ceiling in Rego.

Deny vs. unreachable (#1225). A policy deny is a successful evaluation: the runtime returns HTTP 200 with {"decision":"deny"} (or defer), reserving HTTP ≥400 for genuine transport/request errors. So a governed-bridge log line attributing a drop to “denied” is a governance outcome (tune your policy / freshness), whereas a curl-level transport failure means the runtime is actually unreachable. If a robot governed on live perception is not getting a steady command stream, check the WAL: a run of decision=deny frames points at the policy/freshness sizing above, not at the bridge or the runtime.

See also

Installed: all demo/policies/* paths above are in-repo source references for reading + forking. The installed binary ships them embedded (loaded via autonomy run --policy demo/policies/nvblox/nvblox.rego -- <agent> for the nvblox demo, via autonomy demo mavlink-sitl for the MAVLink demo). Production policies land in the managed cache and are loaded via autonomy run --policy <oci-ref>.