use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const ROUTING_ANCHOR: &str = "arXiv:2605.27466";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RoutingCandidate {
pub role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RoutingChoice {
pub role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
pub model: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RoutingReasonCode {
PolicyMatch,
BudgetWithinLimits,
ApprovalGate,
Skip,
FallbackDefault,
}
impl RoutingReasonCode {
pub fn as_str(self) -> &'static str {
match self {
RoutingReasonCode::PolicyMatch => "policy_match",
RoutingReasonCode::BudgetWithinLimits => "budget_within_limits",
RoutingReasonCode::ApprovalGate => "approval_gate",
RoutingReasonCode::Skip => "skip",
RoutingReasonCode::FallbackDefault => "fallback_default",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RoutingReason {
pub code: RoutingReasonCode,
pub detail: String,
}
impl RoutingReason {
pub fn new(code: RoutingReasonCode, detail: impl Into<String>) -> Self {
Self {
code,
detail: detail.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RoutingDecision {
pub id: String,
pub run_id: String,
pub subtask_id: String,
pub candidates: Vec<RoutingCandidate>,
pub chosen: RoutingChoice,
pub reason: RoutingReason,
pub content_hash: String,
pub decided_at: DateTime<Utc>,
#[serde(default = "default_anchor")]
pub anchor: String,
}
fn default_anchor() -> String {
ROUTING_ANCHOR.to_string()
}
impl RoutingDecision {
pub fn new(
id: impl Into<String>,
run_id: impl Into<String>,
subtask_id: impl Into<String>,
candidates: Vec<RoutingCandidate>,
chosen: RoutingChoice,
reason: RoutingReason,
decided_at: DateTime<Utc>,
) -> Self {
let id = id.into();
let run_id = run_id.into();
let subtask_id = subtask_id.into();
let content_hash =
compute_content_hash(&run_id, &subtask_id, &candidates, &chosen, reason.code);
Self {
id,
run_id,
subtask_id,
candidates,
chosen,
reason,
content_hash,
decided_at,
anchor: ROUTING_ANCHOR.into(),
}
}
pub fn to_audit_details(&self) -> serde_json::Value {
serde_json::to_value(self).expect("RoutingDecision must always serialise")
}
pub fn from_audit_details(value: &serde_json::Value) -> Result<Self, serde_json::Error> {
serde_json::from_value(value.clone())
}
pub fn verify_hash(&self) -> bool {
let expected = compute_content_hash(
&self.run_id,
&self.subtask_id,
&self.candidates,
&self.chosen,
self.reason.code,
);
expected == self.content_hash
}
}
fn compute_content_hash(
run_id: &str,
subtask_id: &str,
candidates: &[RoutingCandidate],
chosen: &RoutingChoice,
reason_code: RoutingReasonCode,
) -> String {
#[derive(Serialize)]
struct HashableProjection<'a> {
run_id: &'a str,
subtask_id: &'a str,
candidates: &'a [RoutingCandidate],
chosen: &'a RoutingChoice,
reason_code: &'a str,
}
let projection = HashableProjection {
run_id,
subtask_id,
candidates,
chosen,
reason_code: reason_code.as_str(),
};
let bytes = serde_json::to_vec(&projection).expect("hashable projection must serialise");
let digest = Sha256::digest(&bytes);
hex::encode(digest)
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(secs: i64) -> DateTime<Utc> {
DateTime::<Utc>::from_timestamp(secs, 0).expect("valid timestamp")
}
fn fixture_decision() -> RoutingDecision {
RoutingDecision::new(
"rtg_fixture_001",
"run_fixture_001",
"stp_planner_001",
vec![
RoutingCandidate {
role: "planner".into(),
agent_id: Some("agt_plan_alpha".into()),
model: "claude-opus-4-7".into(),
score: Some(0.91),
},
RoutingCandidate {
role: "planner".into(),
agent_id: Some("agt_plan_beta".into()),
model: "gpt-4o".into(),
score: Some(0.74),
},
],
RoutingChoice {
role: "planner".into(),
agent_id: Some("agt_plan_alpha".into()),
model: "claude-opus-4-7".into(),
},
RoutingReason::new(
RoutingReasonCode::PolicyMatch,
"policy planner.default fired",
),
ts(1_700_000_000),
)
}
#[test]
fn new_populates_content_hash_and_anchor() {
let d = fixture_decision();
assert!(!d.content_hash.is_empty());
assert_eq!(d.content_hash.len(), 64, "sha-256 hex digest is 64 chars");
assert_eq!(d.anchor, ROUTING_ANCHOR);
}
#[test]
fn verify_hash_passes_for_unchanged_decision() {
let d = fixture_decision();
assert!(d.verify_hash());
}
#[test]
fn verify_hash_fails_when_structural_field_changes() {
let mut d = fixture_decision();
d.chosen.model = "gpt-4o".into();
assert!(!d.verify_hash());
}
#[test]
fn audit_details_round_trip_preserves_decision() {
let d = fixture_decision();
let details = d.to_audit_details();
let parsed = RoutingDecision::from_audit_details(&details).expect("parse");
assert_eq!(parsed, d);
}
#[test]
fn hash_is_stable_across_repeated_computation() {
let a = fixture_decision();
let b = fixture_decision();
assert_eq!(a.content_hash, b.content_hash);
}
#[test]
fn hash_changes_with_reason_code() {
let baseline = fixture_decision();
let with_other_reason = RoutingDecision::new(
baseline.id.clone(),
baseline.run_id.clone(),
baseline.subtask_id.clone(),
baseline.candidates.clone(),
baseline.chosen.clone(),
RoutingReason::new(RoutingReasonCode::BudgetWithinLimits, "budget ok"),
baseline.decided_at,
);
assert_ne!(baseline.content_hash, with_other_reason.content_hash);
}
#[test]
fn anchor_round_trips_through_serde() {
let d = fixture_decision();
let json = serde_json::to_string(&d).expect("serialise");
assert!(json.contains("\"anchor\":\"arXiv:2605.27466\""));
let parsed: RoutingDecision = serde_json::from_str(&json).expect("deserialise");
assert_eq!(parsed.anchor, ROUTING_ANCHOR);
}
#[test]
fn reason_codes_serialise_snake_case() {
for (code, expected) in [
(RoutingReasonCode::PolicyMatch, "\"policy_match\""),
(
RoutingReasonCode::BudgetWithinLimits,
"\"budget_within_limits\"",
),
(RoutingReasonCode::ApprovalGate, "\"approval_gate\""),
(RoutingReasonCode::Skip, "\"skip\""),
(RoutingReasonCode::FallbackDefault, "\"fallback_default\""),
] {
let s = serde_json::to_string(&code).expect("serialise");
assert_eq!(s, expected);
}
}
}