Routing-decision audit (multi-agent coordination)¶
Anchor: AgensFlow — Auditable, Replayable Multi-Agent Coordination, arXiv:2605.27466.
What it records¶
Every time the workflow orchestrator binds a coordination subtask to a
concrete agent / role / model, the Rust governance plane writes a
RoutingDecision audit record to the existing immutable audit trail. Each
record captures:
| Field | Semantics |
|---|---|
id |
rtg_* ULID; unique per record. |
run_id |
Workflow run the decision belongs to. |
subtask_id |
DAG step id this decision bound. |
candidates[] |
Every candidate the orchestrator considered, in evaluation order — role + optional agent_id + model + optional score. |
chosen |
The candidate that won — role + optional agent_id + model. |
reason |
{code, detail} — the machine-readable reason code (policy_match / budget_within_limits / approval_gate / skip / fallback_default) plus an operator-readable explanation. |
content_hash |
SHA-256 over a stable JSON projection of the structural fields. Used by replays to detect coordination drift. |
decided_at |
Wall-clock UTC stamp. |
anchor |
arXiv:2605.27466. |
The record is written through the same path every other audit event uses —
Repos::spawn_audit(event) → AuditRepo::create(event) →
audit_events table — so there is no parallel store, no new exporter, no
behaviour change to runs that don't use multi-agent coordination.
Existing API (verified)¶
Audit writer (fd-storage::repos::AuditRepo +
fd-storage::models::audit::AuditEventBuilder):
let event = AuditEventBuilder::new(action, resource_type)
.actor(actor_type, Some(actor_id))
.resource_id(&id).tenant(tenant_id).project(&project_id).run(&run_id)
.details(serde_json::json!({...}))
.build();
repos.spawn_audit(event); // fire-and-forget into `audit_events`
// Queryable: AuditRepo::list_by_run(run_id) → Vec<AuditEvent>
// New projection: AuditRepo::list_routing_decisions(run_id)
Dispatch site (gateway::handlers::orchestrator::create_and_enqueue_step):
async fn create_and_enqueue_step(
&self, run_id: &str,
step: &StepDefinition, // step.config carries role/model
project_id: &str, tenant_id: &str,
_input: &serde_json::Value,
) -> Result<String, ApiError>
The orchestrator now calls self.record_routing_decision(...) immediately
after the step is created + enqueued, so every dispatched subtask emits
exactly one routing-decision audit row.
Action / resource keys¶
fd-storage::models::audit:
pub const ROUTING_DECIDED: &str = "routing.decided"; // action filter
pub const ROUTING_DECISION: &str = "routing_decision"; // resource type
Read endpoint¶
utoipa-documented; returns the chain ordered oldest → newest. Each entry
deserialises via RoutingDecision::from_audit_details(&event.details);
malformed rows are skipped with a warn log so a single bad row never
prevents the rest of the chain from rendering.
SSE event¶
The BFF mock generator emits routing.decision.recorded on the per-run
channel run:{run_id} so the dashboard's wire shape is locked in ahead of
the gateway → BFF push wiring (same lock-first pattern as
policy.decision.explained, run.forecast.updated, and the SchemaDriftGuard
surface). The payload matches RoutingDecisionResponse plus a run_id /
at envelope.
Replay¶
fd_evals.routing exposes the Python projection:
RoutingDecision.from_audit_details(details)parses a single decision.extract_chain_from_audit(audit_events)filters byaction == "routing.decided"and parses every match.verify_chain(decisions, expected_subtask_ids=...)returns aRoutingChainReportwithis_complete(every expected subtask present) andis_hash_consistent(every decision's stored hash matches the recomputed projection).
The Python RoutingDecision.expected_hash produces the SHA-256 of the same
JSON projection the Rust compute_content_hash writes — field order, field
selection, and JSON-encoding behaviour all match byte-for-byte. The
contract is pinned at both ends:
- Rust integration test:
rust/crates/fd-policy/tests/routing_hash_export.rs::fixture_hash_is_pinned - Python coverage test:
python/packages/fd-evals/tests/test_routing_decision_chain.py::TestCrossPlaneHash::test_python_hash_matches_rust_pin - Pinned digest:
b24284a106e28a41f408a96694ca410772719bc9d5dcc23f629707c75dfe4410
A hash mismatch on either side fails CI and the maintainer must renegotiate both sides + the runbook in a single commit.
Anti-pivot guarantees¶
- Dual-plane split preserved. The record originates in the Rust governance plane; the Python data plane and the Next.js dashboard consume it without ever owning it.
- MCP unchanged. Tool dispatch still goes through MCP — this audit layer sits one level up at the coordination boundary.
- OTel / Jaeger / GenAI semconv intact. No new span attribute keys are added; the routing record is an audit-table artefact, not a tracing primitive.
- No new store. The records live in the same
audit_eventstable every other audit row uses. The read path is a filter onaction. - Schema renegotiation is loud. Any change to the
RoutingDecisionshape changes the SHA-256 hash, which both the Rust unit test and the Python cross-plane test catch immediately.