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(orautonomy ros2 runfor paid tier) with a policy bundle loaded via--policy. Confirm withcurl ${AUTONOMY_RUNTIME_URL}/health— should return 200.Governed bridge (for Channel 2): install per the governed-bridge runbook. Verify with
which governed_ros2_bridgeand by observing the “governed_ros2_bridge: ready” line in autonomy stderr after--governed-bridgeis set.Policy (for either channel): a Rego bundle that gates on
input.scene.*(Channel 1) orinput.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 reachesAUTONOMY_RUNTIME_URLover 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:8080or similar) and the adapter’sAUTONOMY_RUNTIME_URLto 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→ 200autonomy wal inspect --kind autonomy.scene_state --since 30s→ at least one entry per active adapter, timestamps recentAdapter stderr shows no repeated
POST failedlines (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/tooland observedecision == "deny")For typed-decode gates: post a MALFORMED payload deliberately (short bytes, wrong endianness) and confirm the fail-closed rule fires —
curl POST /v1/toolwith truncatedpayload_b64should returndecision == "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:
Adapter is not POSTing — check adapter logs
Adapter is POSTing but validation is rejecting — check runtime stderr for
[WARN]lines aboutscene-state:errorsAdapter is POSTing to the wrong URL — verify
AUTONOMY_RUNTIME_URLmatches 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:
The publish is going through the governed bridge (not a direct
POST /v1/toolbypass) — bridge should be logginggoverned_ros2_bridge: ALLOW topic=... (republished on real)per forwarded messageparams.typeon the tool call is EXACTLYgeometry_msgs/msg/Twistorgeometry_msgs/msg/PoseStamped— typos silently fall through to the “unknown type = no decode” branchThe payload isn’t malformed — if it is,
input.params._typed_decode_errorshould be present (see Pattern 1)