Scene-State + Typed-Field Policy Input

Audience: operators turning on the #1130 + #1131 policy-input channels epic — feeding live perception signals into policy via POST /v1/scene-state, decoding ROS 2 message payloads into typed Rego input, and reconstructing decision context from the WAL after an incident.

This page covers deployment, verification, and recovery. For authoring the Rego policies that consume this input, see ROS 2 Scene State + Typed Field Policy Authoring. For the demo that ties it all together, see demo/policies/nvblox/.

Installed: the demo/policies/nvblox/ path above is an in-repo source reference. The installed binary loads demo/production policies via autonomy run --policy <path-or-oci-ref>; for local rehearsal against the shipped demo, point --policy at the path directly.

The two channels

Two input surfaces reach your Rego policy:

Channel 1 — Scene state (out-of-band, streamed by adapters)

Perception source ─▶ adapter script ─▶ POST /v1/scene-state ─▶ runtime store
                                                                    │
                                                                    ├─▶ input.scene.<source>.fields.*  (visible to Rego)
                                                                    │
                                                                    └─▶ autonomy.scene_state WAL entry

Adapter runs alongside the runtime, subscribes to whatever perception topic makes sense (nvblox costmap, lidar watchdog, safety monitor, custom sensor fusion), and POSTs the derived signals — dynamic-obstacle booleans, occupied-zone lists, risk scores, whatever your policy needs to gate on.

Channel 2 — Typed message fields (in-band, decoded at policy eval)

Agent ─▶ ROS 2 publish ─▶ governed_ros2_bridge ─▶ POST /v1/tool ─▶ runtime evaluates policy
                                                                    │
                                                                    ├─▶ input.params.linear/pose/etc  (typed decode)
                                                                    │
                                                                    └─▶ input.params._typed_decode_error on decode fail

The bridge already forwards payload_b64; the runtime decodes registered message types (geometry_msgs/msg/Twist, geometry_msgs/msg/PoseStamped today; more are a Go-side extension) into typed fields the policy sees.

Prerequisites

  • Runtime: autonomy run (or autonomy ros2 run for paid tier) with a policy bundle loaded via --policy. Confirm with curl ${AUTONOMY_RUNTIME_URL}/health — should return 200.

  • Governed bridge (for Channel 2): install per the governed-bridge runbook. Verify with which governed_ros2_bridge and by observing the “governed_ros2_bridge: ready” line in autonomy stderr after --governed-bridge is set.

  • Policy (for either channel): a Rego bundle that gates on input.scene.* (Channel 1) or input.params.<field> (Channel 2), or both. See the authoring guide for the input reference + fail-closed patterns.

Installed: autonomy run --policy <oci-ref> loads a bundle from the managed cache; autonomy run --policy demo/policies/nvblox/nvblox.rego loads the shipped demo policy directly from a source path (dev mode).

Deployment

Step 1: choose your adapter

For Channel 1 you need an adapter that translates your perception source into POSTs. The epic ships one template: scripts/adapters/nvblox_scene_state.py. Fork it for your integration; the wire contract is documented at the top of that file.

Rehearse locally without hardware first:

# Terminal 1: start the runtime with the demo policy
export AUTONOMY_RUNTIME_URL=http://127.0.0.1:8080
autonomy run --policy demo/policies/nvblox/nvblox.rego -- sleep infinity

# Terminal 2: adapter in fake-data mode (20s scripted cycle)
python3 scripts/adapters/nvblox_scene_state.py \
    --runtime-url "$AUTONOMY_RUNTIME_URL" \
    --fake-data \
    --interval 1

Watch the runtime stderr — you should see one POST /v1/scene-state per second from the adapter, all returning 202 Accepted.

Installed: the scripts/adapters/nvblox_scene_state.py and demo/policies/nvblox/nvblox.rego paths above are in-repo source references (fork the adapter for your integration, use the demo policy as the --policy target for local rehearsal). Both ship alongside the installed binary in the release tarball; production policies live in the managed cache and are loaded via autonomy run --policy <oci-ref>.

Step 2: verify the scene-state store sees the intake

Scene state is queryable in-process; the simplest external check is the WAL:

# One autonomy.scene_state entry per accepted POST.
autonomy wal inspect --kind autonomy.scene_state --since 30s

Each entry carries the source name, the state’s own timestamp (RFC3339Nano, the observation time — distinct from the WAL write time), and the fields payload verbatim.

Step 3: verify Rego sees the input

The policy layer sees the scene state at input.scene.<source>.*. The canonical proof-of-life: publish something the policy gates on and observe the decision.

For the shipped nvblox demo, during the fake-data cycle’s “human in aisle” window (t=5–15s), any tool.ros2.topic.publish on /cmd_vel should be denied:

# Terminal 3: during t=5-15s of the fake-data cycle
curl -X POST "$AUTONOMY_RUNTIME_URL/v1/tool" \
    -H 'Content-Type: application/json' \
    -d '{"kind":"tool.ros2.topic.publish","params":{"topic":"/cmd_vel","type":"geometry_msgs/msg/Twist"}}' | jq .decision
# → "deny"

Outside the window (t=0–5s or t=15–20s): "allow".

Step 4: production adapter deployment

Once the fake-data rehearsal is green, deploy the real adapter alongside the runtime. Two operator choices depending on your isolation model:

  • Same host as autonomy run — adapter reaches AUTONOMY_RUNTIME_URL over localhost. Simplest; runtime + adapter share a process failure domain.

  • Separate host / container — adapter posts over the network to the runtime’s bound URL. Requires the runtime to bind on a routable address (AUTONOMY_RUNTIME_ADDR=0.0.0.0:8080 or similar) and the adapter’s AUTONOMY_RUNTIME_URL to point at that address. Fleet- scale deployment (managing many adapter+runtime pairs from a control plane) is a Controlled-Deployment topic and out of scope for this runbook.

Adapter restart policy is up to you (systemd unit, k8s deployment, supervisord — the adapter itself just loops and doesn’t manage lifecycle).

Step 5: enable typed-field decoding

Channel 2 (typed decode of Twist / PoseStamped) is automatic on any tool.ros2.topic.publish whose params.type is in KnownTypes. No config knob — if the bridge is forwarding the type + payload_b64, the runtime decodes it before policy eval. Nothing operator-actionable beyond loading a policy that gates on the decoded fields.

The fail-closed contract matters for Channel 2 operators: any policy that reaches into input.params.linear / input.params.pose / etc. MUST ship the _typed_decode_error fail-closed rule (see the authoring guide’s Pattern 1). Without it, a malformed payload silently admits under a default-allow policy because input.params.linear is undefined and comparisons don’t fire.

Operator verification checklist

After deployment, walk this list to confirm end-to-end health:

  • curl ${AUTONOMY_RUNTIME_URL}/health → 200

  • autonomy wal inspect --kind autonomy.scene_state --since 30s → at least one entry per active adapter, timestamps recent

  • Adapter stderr shows no repeated POST failed lines (transient is OK; sustained means the runtime is unreachable or your policy cap is being hit — see Failure Modes below)

  • Policy denies scene-driven cases as expected (drive a known-should-deny scenario via curl POST /v1/tool and observe decision == "deny")

  • For typed-decode gates: post a MALFORMED payload deliberately (short bytes, wrong endianness) and confirm the fail-closed rule fires — curl POST /v1/tool with truncated payload_b64 should return decision == "deny" with the reason mentioning _typed_decode_error

WAL forensics: reconstructing a deny

After a decision the operator wants to understand, walk the WAL in chronological order:

# 1. The decision itself — get the audit_id.
autonomy wal inspect --kind autonomy.decision --since 5m | \
    jq 'select(.event.attrs.outcome == "deny")'

# 2. The scene-state stream around that time — what did the world look
#    like when the decision was made?
autonomy wal inspect --kind autonomy.scene_state --since 5m

# 3. Correlate by timestamp. Scene-state entries have their own
#    `attrs.timestamp` (STATE observation time) distinct from the WAL
#    write time. Rego saw the state observation time.

The scene-state stream is many-to-one with decisions (adapters push independently of tool calls), so read them as parallel streams — the question is “what was the LATEST scene state per source at the moment policy evaluated?”

Failure modes + recovery

Adapter POSTs get 429 (Too Many Requests)

The scene-state store caps the number of DISTINCT sources at ServerOptions.MaxSceneSources (default 64). Once full, NEW sources are rejected; existing-source UPDATES always succeed.

Diagnosis: autonomy wal inspect --kind autonomy.scene_state --since 1h | jq -r .event.attrs.source | sort -u | wc -l — if you see close to 64 distinct sources, you’re at cap.

Recovery: either raise the cap when starting the runtime (see the Go API surface at ServerOptions.MaxSceneSources — CLI flag pending), or fix the noisy adapter that’s inventing source names.

Adapter POSTs get 413 (Payload Too Large)

Body-size cap is MaxSceneStateBodyBytes (default 64 KiB). Adapters posting large costmap dumps or high-dimensional feature vectors hit this.

Recovery: either shrink the payload (typical adapter should send summarized signals, not raw data — the runtime’s job is governance, not data storage), or raise the cap via ServerOptions.MaxSceneStateBodyBytes.

Policy denies when scene-state looks correct

Check the WAL for a autonomy.scene_state entry matching what you expected around the decision’s timestamp. If it’s absent:

  1. Adapter is not POSTing — check adapter logs

  2. Adapter is POSTing but validation is rejecting — check runtime stderr for [WARN] lines about scene-state: errors

  3. Adapter is POSTing to the wrong URL — verify AUTONOMY_RUNTIME_URL matches the runtime’s bind address

If the WAL entry IS there but policy still denies unexpectedly, the policy has a bug — reduce your Rego to the minimal case using the authoring guide’s patterns and re-test.

Typed decode of Twist / PoseStamped silently missing

If input.params.linear / input.params.pose is absent when you expected it, check:

  1. The publish is going through the governed bridge (not a direct POST /v1/tool bypass) — bridge should be logging governed_ros2_bridge: ALLOW topic=... (republished on real) per forwarded message

  2. params.type on the tool call is EXACTLY geometry_msgs/msg/Twist or geometry_msgs/msg/PoseStamped — typos silently fall through to the “unknown type = no decode” branch

  3. The payload isn’t malformed — if it is, input.params._typed_decode_error should be present (see Pattern 1)