ROS 2 Governed Bridge Quickstart

This tutorial walks through autonomy demo ros2-bridge — the end-to-end demo of the long-lived governed_ros2_bridge C++ process — and shows the operator-visible evidence it produces: a per-publish allow/deny narrative on stdout plus one autonomy.decision WAL frame per bridged message, each tagged with bridge_origin=governed_ros2_bridge.

By the end you’ll have run the bridge against both an allowed topic (/cmd_vel) and a denied topic (/disable_safety) and inspected the WAL to confirm the policy enforcement happened at the bridge layer, not just at launch time.

Operator-facing runbook for enabling the bridge on a real workload: ROS 2 Governed Bridge runbook.

What this demo proves

  1. Per-message policy interception. Every DDS publish on the agent domain (99) round-trips through POST /v1/tool before the bridge republishes it on the real domain (42). The runtime evaluates each message against the embedded ros2-bridge-demo policy.

  2. Fail-closed deny path. A publish on a denied topic (/disable_safety) is recorded as outcome=deny and never republished on the real domain.

  3. Spoof-resistant origin tagging (#939 4-E.a). Bridge-routed publishes carry bridge_origin=governed_ros2_bridge in the decision frame; direct node POSTs do not. The marker is gated by BridgeRoutableKinds so a non-tool.ros2.topic.publish request cannot forge it (#941 fix).

  4. Readiness-wait correctness (#937). The launch waits for the bridge to print governed_ros2_bridge: ready on stdout before running the scripted publishes; the readiness gap that pre-#937 silently dropped early publishes is closed.

Prerequisites

  • docker on PATH. The bridge runs in a container; the scripted publishers spawn per-message containers via docker run.

  • ghcr.io/autonomyops/adk-ros2-runtime:latest present locally. Pull once:

    docker pull ghcr.io/autonomyops/adk-ros2-runtime:latest
    

    Or build from source (docker build -t ghcr.io/autonomyops/adk-ros2-runtime:local demo/ros2-runtime/) and pass --image ghcr.io/autonomyops/adk-ros2-runtime:local to the demo.

  • The autonomy binary on PATH. CE works (autonomy demo ros2-bridge is registered under both tiers).

  • jq is helpful for the WAL inspection step but not required.

Step 1 — Run the demo (allow + deny in one run)

Default flags drive the multi-topic flow: the bridge subscribes to /cmd_vel (policy allow) AND /disable_safety (policy deny) in one run; the scripted publisher interleaves both so the transcript visibly cycles between accepted and rejected publishes.

autonomy demo ros2-bridge

Expected output (annotated):

──────────────────────────────────────────────────────
  AutonomyOps  /  ROS 2  /  governed-bridge per-message governance demo
──────────────────────────────────────────────────────
  policy        : ros2-bridge-demo v1.0.0  (embedded:ros2-bridge-demo)
  bridge image  : ghcr.io/autonomyops/adk-ros2-runtime:latest
  agent domain  : 99
  real domain   : 42
  bridge topics : /cmd_vel:std_msgs/msg/String, /disable_safety:std_msgs/msg/String
  runtime URL   : http://127.0.0.1:<random>  (in-process, --network host on bridge)
  mode          : multi-topic — allow + deny in one run (#939 4-A generic-type subscription)
──────────────────────────────────────────────────────

[1/4] spawning governed_ros2_bridge container...
      bridge ready (subscribed on agent domain, listening for POSTs to /v1/tool).
[2/4] running scripted agent publishes:
      (DDS discovery warmup: 3s)
      → publish /cmd_vel "go-forward"           (bridge will POST + republish on allow)
      → publish /disable_safety "shut-it-down"  (bridge will POST + republish on allow)
      → publish /cmd_vel "go-left"              (bridge will POST + republish on allow)
      → publish /disable_safety "trip-the-estop" (bridge will POST + republish on allow)
      → publish /cmd_vel "go-right"             (bridge will POST + republish on allow)
[3/4] reading WAL for autonomy.decision frames written by this run...
[4/4] tearing down bridge...

──────────────────────────────────────────────────────
  WAL summary (this run):
    allow         : 3  (publishes that reached the real DDS domain)
    deny          : 2  (publishes the bridge dropped on policy)
    bridge_origin : 5 / 5 decision frames carried the governed_ros2_bridge marker (#939 4-E.a)
    inspect       : autonomy wal inspect --dir <wal-dir> --kind autonomy.decision
──────────────────────────────────────────────────────

The per-publish line says “bridge will POST + republish on allow” for every entry; whether the bridge actually republishes (vs drops) is decided by the policy AFTER the POST and is summarised in the WAL counts. The 3 + 2 split is the load-bearing evidence: the bridge intercepts every publish, the policy approves the /cmd_vel traffic, the policy rejects the /disable_safety traffic, and only the approved publishes reach the real DDS domain.

If the summary shows anything other than allow=3, deny=2, bridge_origin=5/5, see Troubleshooting below.

Step 2 — Read the WAL evidence

The demo writes the run’s WAL to a temp directory and tears it down on exit. Two ways to preserve and inspect it:

Option A — pin the WAL directory up front. Set AUTONOMY_DEMO_WAL_DIR before running the demo, then point autonomy wal inspect --dir at the same path:

AUTONOMY_DEMO_WAL_DIR=/tmp/ros2-bridge-wal autonomy demo ros2-bridge
# Preferred: first-class filter, no jq needed (#939 4-E.a).
autonomy wal inspect --dir /tmp/ros2-bridge-wal \
  --kind autonomy.decision --bridge-only

Option B — preserve the ephemeral path with --keep. The teardown line prints the preserved WAL dir; pass it to autonomy wal inspect --dir:

autonomy demo ros2-bridge --keep
# (note the "preserved at" path the teardown prints, e.g. /tmp/autonomyops-demo-wal-XXXXXXXX)
autonomy wal inspect --dir <preserved-path> \
  --kind autonomy.decision --bridge-only

--bridge-only filters to entries whose event.attrs.bridge_origin names a recognized bridge (today: governed_ros2_bridge). It’s the first-class equivalent of the older --json | jq 'select(.event.attrs.bridge_origin == "governed_ros2_bridge")' incantation — keep using jq if you need richer field projection, but for “show me the bridge-routed decisions” the flag is enough. Composes with --kind, --since, and --json. Note the .event. hop: wal inspect --json emits the full telemetry.Entry shape ({seq, written_at, event: {kind, attrs}}), so attrs live one level down from the top-level object.

The env var AUTONOMY_DEMO_WAL_DIR is honoured only by the demo commands (it pins where the demo writes); autonomy wal inspect reads the directory it is given via --dir. Mixing the two is the common gotcha — without --dir, wal inspect falls back to XDG_CACHE_HOME/autonomyops/telemetry, not the demo’s preserved path.

Expected: five JSON objects, each with event.attrs.tool=tool.ros2.topic.publish, event.attrs.bridge_origin=governed_ros2_bridge, and event.attrs.policy_ref=embedded:ros2-bridge-demo. Three have event.attrs.outcome=allow (the /cmd_vel publishes), two have event.attrs.outcome=deny (the /disable_safety publishes).

Direct node POSTs (had there been any) would show bridge_origin absent. The demo’s bridge runs as the sole publisher so all five frames carry the marker.

Step 3 — Pin to a single topic (back-compat override)

--topic <name> overrides the multi-topic default and drives the legacy single-topic flow against the named topic — three std_msgs/msg/String publishes onto it, no interleaving. Useful for testing one topic in isolation or matching pre-#939-4-A demo recordings.

# All allow — bridge subscribes only to /cmd_vel:
autonomy demo ros2-bridge --topic /cmd_vel

# All deny — bridge subscribes only to /disable_safety:
autonomy demo ros2-bridge --topic /disable_safety

Expected single-topic summaries: allow=3, deny=0, bridge_origin=3/3 for /cmd_vel; allow=0, deny=3, bridge_origin=3/3 for /disable_safety. Each scripted publish reached the bridge, was POSTed to /v1/tool, was decided by the policy, and (only on allow) was republished on the real domain. Denies are first-class governance evidence — they appear as decision frames in the WAL with outcome=deny, not silent drops.

Subscribe-side proof (optional, in two separate terminals before the demo runs):

# Terminal A — sees go-forward / go-left / go-right echoes (allowed):
ROS_DOMAIN_ID=42 ros2 topic echo /cmd_vel

# Terminal B — sees NO echoes; the two /disable_safety publishes
# never reach the real domain because the policy denied them:
ROS_DOMAIN_ID=42 ros2 topic echo /disable_safety

Step 4 — Confirm the spoof gate

The bridge_origin marker is gated on the request kind so a node calling tool.echo cannot forge it (#941 fix). The demo does not itself spoof, but the contract is exercised by the test suite — to verify on your branch:

go test ./runtime/... -run BridgeOrigin -v

Expected: tests including TestBridgeOriginFromRequest_NonRoutableKindIsIgnored pass. The gate’s source is bridgeOriginFromRequest in runtime/server.go; the closed set lives in ros2bridge.BridgeRoutableKinds.

Troubleshooting

Symptom

Likely cause

Fix

bridge did not signal ready within 30s

Cold image pull, missing binary, or stale image.

Pre-pull (docker pull ghcr.io/autonomyops/adk-ros2-runtime:latest). If still failing, run the binary probe in runbook Step 5.

bridge exited before signaling ready

Hard exit during startup — broken image or ErrSameDomain.

Check --agent-domain--real-domain; check the wrapped error in the abort message.

allow : <3 or deny : <2 on the default run

DDS discovery race — a publisher container exited before the bridge’s subscription matched.

The demo paces publishes with ros2BridgeDemoPublishPace=1s and a 3s discovery warmup; if you customised either down, raise them.

bridge_origin : 0 / 5

The bridge is not in fact mediating — the publishes reached the runtime via a non-bridge path.

Confirm the bridge container is alive (docker ps); confirm --ipc=host is set (the runtime sets this; a tampered runtime/exec would drop it).

docker: image ... not found locally

The image isn’t on the host.

Pull it (docker pull ghcr.io/autonomyops/adk-ros2-runtime:latest).

Bridge container leaked after Ctrl-C

Docker didn’t get the SIGTERM cascade in time.

See runbook Step 6.

Share snippet

A compact, copy-pasteable summary suitable for an email, issue, sales note, or proof artifact.

Prerequisites

  • docker on PATH; ghcr.io/autonomyops/adk-ros2-runtime:latest pulled.

  • autonomy binary on PATH.

Run it

# Default (multi-topic): allow + deny in one run.
autonomy demo ros2-bridge

# Override to a single topic (back-compat):
autonomy demo ros2-bridge --topic /cmd_vel          # all allow
autonomy demo ros2-bridge --topic /disable_safety   # all deny

Expected proof markers (default run)

  • WAL summary: allow=3, deny=2, bridge_origin=5/5 — five autonomy.decision frames, all carrying bridge_origin=governed_ros2_bridge.

  • Subscribe-side ros2 topic echo on ROS_DOMAIN_ID=42 /cmd_vel sees the three allowed messages; on /disable_safety it sees zero (the two denies never reach the real domain).

What this proves

The governed bridge enforces policy per DDS message — not just at launch time — and emits one spoof-resistant decision frame per publish, with allow vs. deny outcomes visible both on the wire (real domain receives or doesn’t) and in the WAL (outcome + spoof-gated bridge_origin marker).

What’s next

  • Real-workload bring-up procedure: ROS 2 Governed Bridge runbook.

  • Launch-level governance (the “did this launch start at all?” question): ROS 2 Governance reference.

  • Markers + observability shape: ROS 2 Markers and Observability.

  • Generic-type subscription, multi-topic in one bridge: #939 4-A (landed — see the “Multi-topic + generic-type interception” section of the runbook for the GOVERNED_BRIDGE_TOPICS env contract).

  • DDS-Security / SROS 2 (defense-in-depth on top of the bridge): SROS 2 quickstart + SROS 2 runbook. Provision the keystore + per-identity enclaves, wire to the bridge via --bridge-keystore + --bridge-enclave + --workload-enclave, verify bypass-resistance end-to-end.