ADR-0003: The rosbridge connection is an optional dependency that degrades (P8)¶
Status¶
Accepted
Context¶
M3's acceptance criterion is that POST /missions produces real swarm movement and
GET /swarm/state returns current positions with sub-second latency. Proving that end
to end requires a running Gazebo + PX4 SITL + ROS 2 + rosbridge stack, which needs a
GPU-capable machine (or headless mode) and is exactly the piece this sandbox/CI cannot
run (ADR-0004). Building SwarmApi.Api so it can only be exercised against a live
simulation would mean the M3 acceptance criteria are untestable anywhere except a
developer's own machine — the opposite of P13.
Constitution P8 already states the general pattern for exactly this situation: "every
optional dependency degrades; it does not fail startup," with the test "git clone &&
dotnet run with zero cloud credentials must produce a working system with reduced
features."
Decision¶
ISwarmStateProvider / IMissionDispatcher (P10: interface + DI, no base class) has two
implementations:
RosBridgeSwarmStateProvider— connects toRosBridge:Urlover a WebSocket (rosbridge_suite's JSON protocol), normalizing its topic/message dialect into the internalSwarmStatemodel once, at this boundary (P11).SimulatedSwarmStateProvider— a deterministic in-memory swarm engine: onPOST /missionsit spawns the requested drone count and advances each drone toward its waypoints by elapsed time on every read, with no external process required.
Registration tries the rosbridge connection at startup with a short timeout; on failure
(or when RosBridge:Url is unset) it registers the simulated provider instead and logs
which mode is active — the same visible-degradation pattern P8 asks for (the worked
example: CopilotScope's /api/health naming its degraded integrations). GET /health
here reports "swarmBridge": "Connected" | "Simulated" for the same reason.
Consequences¶
- The full mission lifecycle (
POST /missions→GET /swarm/state→ waypoints advance → mission completes) is unit- and integration-testable in CI against the simulated provider with no container, no GPU, and no flakiness budget spent on infrastructure — this is what actually makes M3's acceptance criteria checkable in this repository's CI. - The simulated engine's movement math (
AdvanceTowardWaypoint) is deliberately the same shape as the "real" trajectory the ROS 2 side computes (swarm_coordination/trajectory.py), so a reviewer comparing the two is comparing two independent, intentional implementations of one idea — not one canonical implementation and one stub that happens to look plausible. - Risk accepted: the simulated provider is not a substitute for verifying the real rosbridge integration against a running Gazebo/PX4 stack. That verification is necessarily manual, on a GPU-capable machine, per the root README's M0/M3 manual verification steps — recorded, not silently assumed away.
Worked example: backend/src/SwarmApi.Infrastructure/, backend/tests/SwarmApi.Api.Tests/.
Amendment — 2026-09-22: the names in this record, and what it did not cover¶
The decision above names types that were renamed before the code shipped:
ISwarmStateProvider / IMissionDispatcher became one interface, ISwarmBridge
(backend/src/SwarmApi.Application/ISwarmBridge.cs), and the two implementations are
RosBridgeSwarmBridge and SimulatedSwarmBridge (backend/src/SwarmApi.Infrastructure/).
AdvanceTowardWaypoint is Trajectory.StepTowards (SwarmApi.Domain). The decision itself
is unchanged; only the vocabulary in the text above is stale, and it is left as written so
the record shows what was decided at the time.
Two gaps the decision did not address are recorded here rather than silently absorbed:
- Degradation is visible only at startup. The bridge is chosen once; if the rosbridge
connection drops afterwards there is no reconnection, and
GET /healthkeeps reportingConnectedwhile the swarm state silently goes stale. Tracked indocs/architecture/DEVIATIONS.md(P8 row) until the bridge reconnects and reports its live state. - The compose stack raced this decision.
apiused to start as soon as thesimcontainer existed, well before rosbridge listened, so the one-time probe almost always chose Simulated.docker/docker-compose.ymlnow gatesapion thesimhealthcheck (rosbridge accepting connections).
Amendment — 2026-09-22 (later the same day): the decision itself, revised¶
The original decision — probe once at startup, fall back to the simulated swarm if the probe fails — is replaced, because both of its failure modes were worse than the degradation it was avoiding:
- A configured swarm is never replaced by a simulated one. If
RosBridge:Urlis set, the real bridge is registered, full stop. While rosbridge is unreachable it reportsDisconnected(a newSwarmBridgeMode), refuses to dispatch (HTTP 503, so no caller mistakes a mission for flying), and reconnects in the background with a doubling back-off. The simulated swarm is reserved for "no URL configured" — thegit clone && dotnet runcase P8 names. A URL that is set but is notws://orwss://stops startup rather than falling back, for the same reason. Showing an operator stand-in drones because the real ones were briefly unreachable at boot was the more dangerous degradation. - Degradation is visible for the process's whole life, not only at startup.
/healthreportsswarmBridgeas Connected/Disconnected at the moment it is read, withlastStateAgeSeconds, and isDegraded(still HTTP 200) while disconnected. The swarm state is stamped with the live mode, so the dashboard says "disconnected — positions are stale" instead of showing old positions as live. - The connection's lifecycle belongs to the host (
RosBridgeConnectionService, aBackgroundService), not to a probe inProgram.cs. One malformed or oversized message is counted and dropped; it no longer ends the receive loop. - Nothing the swarm did not report is invented. Battery, armed state and flight mode
come from MAVROS through
/swarm/stateor are null; the old parser stamped every droneInFlightat 100 % battery.
The messages crossing rosbridge are now written down once, in contracts/rosbridge/, and
both sides are tested against the same example files. Worked example:
backend/tests/SwarmApi.Infrastructure.Tests/ (an in-process fake rosbridge that drops,
refuses and reconnects).