Skip to main content

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}