fd_policy/routing.rs
1//! Routing-decision audit record for multi-agent coordination.
2//!
3//! Every time the orchestrator binds a subtask to a concrete agent / role /
4//! model, a [`RoutingDecision`] is produced and written to the existing
5//! immutable audit trail via the standard
6//! [`fd_storage::repos::AuditRepo`] writer — see
7//! [`crate::routing::RoutingDecision::to_audit_details`]. This crate is
8//! purely the **shape**: candidate enumeration, the chosen binding, the
9//! reason code, and a content-addressed hash that lets fd-evals replay a
10//! coordination chain deterministically.
11//!
12//! Anchor: *AgensFlow: Auditable, Replayable Multi-Agent Coordination*,
13//! arXiv:[2605.27466](https://arxiv.org/abs/2605.27466).
14//!
15//! No new store is introduced: the records flow through the existing
16//! `audit_events` table; the read API is `AuditRepo::list_routing_decisions`
17//! (filtered `list_by_run` projection). The dual-plane invariant is
18//! preserved — Rust governance produces the record; the Python data plane
19//! and the Next.js dashboard consume it without ever owning it.
20
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23use sha2::{Digest, Sha256};
24
25/// Stable arXiv anchor recorded on every decision so audit consumers can
26/// cite the source without re-fetching this docstring.
27pub const ROUTING_ANCHOR: &str = "arXiv:2605.27466";
28
29/// A single routing candidate the orchestrator considered for a subtask.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct RoutingCandidate {
32 /// Coordination role the candidate would fill (`planner`, `researcher`,
33 /// `coder`, etc.). Free-form so a project's workflow vocabulary stays
34 /// authoritative — the policy plane only stores it verbatim.
35 pub role: String,
36 /// Agent registry id of the candidate, when one exists. `None` for
37 /// candidates produced by an ad-hoc binding (e.g. a one-shot model
38 /// override on a workflow run).
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub agent_id: Option<String>,
41 /// Model id the candidate would run (`claude-opus-4-7`, `gpt-4o`, …).
42 /// Free-form string so a future model lands here without a schema
43 /// migration.
44 pub model: String,
45 /// Optional ranking score. `None` when the orchestrator did not perform
46 /// ordered scoring (e.g. policy short-circuit). Recorded so AgensFlow
47 /// replays can audit ranker drift.
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub score: Option<f64>,
50}
51
52/// The candidate that won the routing decision. Stored separately from the
53/// candidate list (rather than as an index) so the chosen binding survives
54/// a future change to the candidate-list ordering rules.
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub struct RoutingChoice {
57 pub role: String,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub agent_id: Option<String>,
60 pub model: String,
61}
62
63/// Stable, namespaced reason code for a routing decision. Matches the
64/// dispatch-time policy outcomes the orchestrator already enumerates, so
65/// each code aligns with an existing policy-engine return value.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum RoutingReasonCode {
69 /// A policy rule matched explicitly and selected the chosen candidate.
70 PolicyMatch,
71 /// The chosen candidate satisfies all budget axes; no policy rule was
72 /// needed.
73 BudgetWithinLimits,
74 /// The chosen candidate is allowed but requires approval before the
75 /// dispatched step can start. The audit record is written *now* so the
76 /// audit chain is complete even if approval is later denied.
77 ApprovalGate,
78 /// The subtask was deliberately not dispatched (e.g. conditional
79 /// skipped, scheduler-imposed truncation). `chosen` still records the
80 /// would-have-been binding so replays can attribute the skip.
81 Skip,
82 /// No policy match and no scoring signal — the orchestrator fell back
83 /// to the workflow's declared default. Surfaced as its own code so
84 /// "we routed because nothing else fired" is auditable.
85 FallbackDefault,
86}
87
88impl RoutingReasonCode {
89 pub fn as_str(self) -> &'static str {
90 match self {
91 RoutingReasonCode::PolicyMatch => "policy_match",
92 RoutingReasonCode::BudgetWithinLimits => "budget_within_limits",
93 RoutingReasonCode::ApprovalGate => "approval_gate",
94 RoutingReasonCode::Skip => "skip",
95 RoutingReasonCode::FallbackDefault => "fallback_default",
96 }
97 }
98}
99
100/// Reason for a routing decision. The `code` field is the stable
101/// machine-readable handle; `detail` is the operator-readable explanation
102/// that surfaces in the dashboard.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104pub struct RoutingReason {
105 pub code: RoutingReasonCode,
106 pub detail: String,
107}
108
109impl RoutingReason {
110 pub fn new(code: RoutingReasonCode, detail: impl Into<String>) -> Self {
111 Self {
112 code,
113 detail: detail.into(),
114 }
115 }
116}
117
118/// The full routing decision recorded per coordination step. Immutable once
119/// written; replays compare `content_hash` to detect drift.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct RoutingDecision {
122 /// `rtg_*` ULID; unique per record.
123 pub id: String,
124 /// Run this decision belongs to.
125 pub run_id: String,
126 /// Subtask / DAG step the decision is binding.
127 pub subtask_id: String,
128 /// Every candidate the orchestrator considered, in the order they were
129 /// evaluated. Order is preserved verbatim so a replay can attribute a
130 /// later ordering change.
131 pub candidates: Vec<RoutingCandidate>,
132 /// The candidate that won.
133 pub chosen: RoutingChoice,
134 /// Why this candidate won.
135 pub reason: RoutingReason,
136 /// SHA-256 over a stable JSON projection of the decision's structural
137 /// fields (everything *except* `decided_at` and `content_hash` itself).
138 /// Equal hashes mean equal decisions; unequal hashes mean a coordination
139 /// drift that the replay should surface.
140 pub content_hash: String,
141 /// Server-generated wall-clock UTC instant.
142 pub decided_at: DateTime<Utc>,
143 /// arXiv anchor of the audit methodology — defaults to
144 /// [`ROUTING_ANCHOR`].
145 #[serde(default = "default_anchor")]
146 pub anchor: String,
147}
148
149fn default_anchor() -> String {
150 ROUTING_ANCHOR.to_string()
151}
152
153impl RoutingDecision {
154 /// Build a decision from raw inputs. `id` and `decided_at` are taken
155 /// from the caller so the gateway can stamp them at dispatch time;
156 /// `content_hash` is computed here so the contract is single-source.
157 pub fn new(
158 id: impl Into<String>,
159 run_id: impl Into<String>,
160 subtask_id: impl Into<String>,
161 candidates: Vec<RoutingCandidate>,
162 chosen: RoutingChoice,
163 reason: RoutingReason,
164 decided_at: DateTime<Utc>,
165 ) -> Self {
166 let id = id.into();
167 let run_id = run_id.into();
168 let subtask_id = subtask_id.into();
169 let content_hash =
170 compute_content_hash(&run_id, &subtask_id, &candidates, &chosen, reason.code);
171 Self {
172 id,
173 run_id,
174 subtask_id,
175 candidates,
176 chosen,
177 reason,
178 content_hash,
179 decided_at,
180 anchor: ROUTING_ANCHOR.into(),
181 }
182 }
183
184 /// Serialize the decision into the JSON shape that goes into
185 /// `audit_events.details`. Mirrors `serde_json::to_value(&self)` but
186 /// kept as a named entry point so call sites read clearly.
187 pub fn to_audit_details(&self) -> serde_json::Value {
188 serde_json::to_value(self).expect("RoutingDecision must always serialise")
189 }
190
191 /// Parse a decision back out of an `audit_events.details` blob. Used by
192 /// the gateway `/v1/runs/{id}/routing` read endpoint and by the
193 /// fd-evals replay helper.
194 pub fn from_audit_details(value: &serde_json::Value) -> Result<Self, serde_json::Error> {
195 serde_json::from_value(value.clone())
196 }
197
198 /// Verify that the stored `content_hash` still matches a re-computation
199 /// from the structural fields. Used by replays to detect drift.
200 pub fn verify_hash(&self) -> bool {
201 let expected = compute_content_hash(
202 &self.run_id,
203 &self.subtask_id,
204 &self.candidates,
205 &self.chosen,
206 self.reason.code,
207 );
208 expected == self.content_hash
209 }
210}
211
212/// Compute the deterministic SHA-256 over a stable projection. Held in a
213/// free function so the hash contract is one place — change it here, the
214/// replay regression in fd-evals catches it.
215fn compute_content_hash(
216 run_id: &str,
217 subtask_id: &str,
218 candidates: &[RoutingCandidate],
219 chosen: &RoutingChoice,
220 reason_code: RoutingReasonCode,
221) -> String {
222 #[derive(Serialize)]
223 struct HashableProjection<'a> {
224 run_id: &'a str,
225 subtask_id: &'a str,
226 candidates: &'a [RoutingCandidate],
227 chosen: &'a RoutingChoice,
228 reason_code: &'a str,
229 }
230 let projection = HashableProjection {
231 run_id,
232 subtask_id,
233 candidates,
234 chosen,
235 reason_code: reason_code.as_str(),
236 };
237 // `serde_json::to_vec` is stable for the same input under serde's
238 // struct-field-order serialisation — that's the whole hash contract.
239 let bytes = serde_json::to_vec(&projection).expect("hashable projection must serialise");
240 let digest = Sha256::digest(&bytes);
241 hex::encode(digest)
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 fn ts(secs: i64) -> DateTime<Utc> {
249 DateTime::<Utc>::from_timestamp(secs, 0).expect("valid timestamp")
250 }
251
252 fn fixture_decision() -> RoutingDecision {
253 RoutingDecision::new(
254 "rtg_fixture_001",
255 "run_fixture_001",
256 "stp_planner_001",
257 vec![
258 RoutingCandidate {
259 role: "planner".into(),
260 agent_id: Some("agt_plan_alpha".into()),
261 model: "claude-opus-4-7".into(),
262 score: Some(0.91),
263 },
264 RoutingCandidate {
265 role: "planner".into(),
266 agent_id: Some("agt_plan_beta".into()),
267 model: "gpt-4o".into(),
268 score: Some(0.74),
269 },
270 ],
271 RoutingChoice {
272 role: "planner".into(),
273 agent_id: Some("agt_plan_alpha".into()),
274 model: "claude-opus-4-7".into(),
275 },
276 RoutingReason::new(
277 RoutingReasonCode::PolicyMatch,
278 "policy planner.default fired",
279 ),
280 ts(1_700_000_000),
281 )
282 }
283
284 #[test]
285 fn new_populates_content_hash_and_anchor() {
286 let d = fixture_decision();
287 assert!(!d.content_hash.is_empty());
288 assert_eq!(d.content_hash.len(), 64, "sha-256 hex digest is 64 chars");
289 assert_eq!(d.anchor, ROUTING_ANCHOR);
290 }
291
292 #[test]
293 fn verify_hash_passes_for_unchanged_decision() {
294 let d = fixture_decision();
295 assert!(d.verify_hash());
296 }
297
298 #[test]
299 fn verify_hash_fails_when_structural_field_changes() {
300 let mut d = fixture_decision();
301 // Mutate `chosen` directly; we deliberately do NOT recompute the
302 // hash so this simulates the on-disk record drifting.
303 d.chosen.model = "gpt-4o".into();
304 assert!(!d.verify_hash());
305 }
306
307 #[test]
308 fn audit_details_round_trip_preserves_decision() {
309 let d = fixture_decision();
310 let details = d.to_audit_details();
311 let parsed = RoutingDecision::from_audit_details(&details).expect("parse");
312 assert_eq!(parsed, d);
313 }
314
315 #[test]
316 fn hash_is_stable_across_repeated_computation() {
317 let a = fixture_decision();
318 let b = fixture_decision();
319 assert_eq!(a.content_hash, b.content_hash);
320 }
321
322 #[test]
323 fn hash_changes_with_reason_code() {
324 let baseline = fixture_decision();
325 let with_other_reason = RoutingDecision::new(
326 baseline.id.clone(),
327 baseline.run_id.clone(),
328 baseline.subtask_id.clone(),
329 baseline.candidates.clone(),
330 baseline.chosen.clone(),
331 RoutingReason::new(RoutingReasonCode::BudgetWithinLimits, "budget ok"),
332 baseline.decided_at,
333 );
334 assert_ne!(baseline.content_hash, with_other_reason.content_hash);
335 }
336
337 #[test]
338 fn anchor_round_trips_through_serde() {
339 let d = fixture_decision();
340 let json = serde_json::to_string(&d).expect("serialise");
341 assert!(json.contains("\"anchor\":\"arXiv:2605.27466\""));
342 let parsed: RoutingDecision = serde_json::from_str(&json).expect("deserialise");
343 assert_eq!(parsed.anchor, ROUTING_ANCHOR);
344 }
345
346 #[test]
347 fn reason_codes_serialise_snake_case() {
348 for (code, expected) in [
349 (RoutingReasonCode::PolicyMatch, "\"policy_match\""),
350 (
351 RoutingReasonCode::BudgetWithinLimits,
352 "\"budget_within_limits\"",
353 ),
354 (RoutingReasonCode::ApprovalGate, "\"approval_gate\""),
355 (RoutingReasonCode::Skip, "\"skip\""),
356 (RoutingReasonCode::FallbackDefault, "\"fallback_default\""),
357 ] {
358 let s = serde_json::to_string(&code).expect("serialise");
359 assert_eq!(s, expected);
360 }
361 }
362}