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 atinput.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 existinginput.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 |
|---|---|---|
|
|
per-zone speed limits, direction gating |
|
|
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 asjson.Numberbecause the ingress handler usesdecoder.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.secisint32,input.params.header.stamp.nanosecisuint32,input.params.pose.position.xisfloat64.
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_velgoverned 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_nscheck. There is noAUTONOMY_SCENE_STATE_TTLknob — 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¶
demo/policies/nvblox/nvblox.regoand its README — the #1130 wow-target demo policy (4 fail-closed rules covering the patterns above)scripts/adapters/nvblox_scene_state.py— sample perception → scene-state adapter with--fake-datamode for local rehearsalMAVLink Policy Authoring — companion authoring guide for the
tool.mavlink.*command surface
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>.