Skip to main content

proofborne_core/
routing.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6use crate::{ProviderCapabilities, SCHEMA_VERSION, TokenStatus, hash_json};
7
8/// Deterministic routing reducer understood by this release.
9pub const ROUTING_ALGORITHM_VERSION: &str = "proofborne.routing.deterministic.v1";
10/// Version of the proof-carrying local model-catalog contract.
11pub const MODEL_CATALOG_VERSION: &str = "proofborne.model-catalog.v1";
12/// Pricing is expressed in micro-US-dollars per one million tokens.
13pub const ROUTING_PRICE_TOKEN_SCALE: u64 = 1_000_000;
14
15/// Portable capability that a route may require from a provider adapter.
16#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
17#[serde(rename_all = "snake_case")]
18pub enum RoutingCapability {
19    /// Requires streaming output (chunked transport).
20    Streaming,
21    /// Requires tool / function calling support.
22    Tools,
23    /// Requires JSON-structured output without free-form text.
24    StructuredOutput,
25    /// Requires image / video / audio inputs.
26    MultimodalInput,
27}
28
29impl RoutingCapability {
30    fn is_supported_by(self, capabilities: &ProviderCapabilities) -> bool {
31        match self {
32            Self::Streaming => capabilities.streaming,
33            Self::Tools => capabilities.tools,
34            Self::StructuredOutput => capabilities.structured_output,
35            Self::MultimodalInput => capabilities.multimodal_input,
36        }
37    }
38}
39
40/// Provenance of the context-window value used by routing.
41#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "snake_case")]
43pub enum RoutingContextSource {
44    /// Declared directly by the provider adapter.
45    Provider,
46    /// Pinned by a local, version-controlled profile.
47    Profile,
48    /// Conservative runtime fallback recorded explicitly in the plan.
49    RuntimeFallback,
50}
51
52/// Immutable local pricing used only for deterministic preflight estimates.
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "camelCase", deny_unknown_fields)]
55pub struct ModelPricing {
56    /// Maximum input charge in micro-USD per one million tokens.
57    pub input_microusd_per_million_tokens: u64,
58    /// Maximum output charge in micro-USD per one million tokens.
59    pub output_microusd_per_million_tokens: u64,
60}
61
62impl ModelPricing {
63    /// Computes a ceiling estimate without floating point arithmetic.
64    pub fn estimate(
65        &self,
66        max_input_tokens: u64,
67        reserved_output_tokens: u64,
68    ) -> Result<u64, RoutingError> {
69        let input = estimate_component(max_input_tokens, self.input_microusd_per_million_tokens)?;
70        let output = estimate_component(
71            reserved_output_tokens,
72            self.output_microusd_per_million_tokens,
73        )?;
74        input.checked_add(output).ok_or(RoutingError::CostOverflow)
75    }
76}
77
78fn estimate_component(tokens: u64, rate: u64) -> Result<u64, RoutingError> {
79    let numerator = u128::from(tokens)
80        .checked_mul(u128::from(rate))
81        .ok_or(RoutingError::CostOverflow)?;
82    let scale = u128::from(ROUTING_PRICE_TOKEN_SCALE);
83    let estimate = numerator
84        .checked_add(scale.saturating_sub(1))
85        .ok_or(RoutingError::CostOverflow)?
86        / scale;
87    u64::try_from(estimate).map_err(|_| RoutingError::CostOverflow)
88}
89
90/// One content-addressed model description in the local catalog.
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
92#[serde(rename_all = "camelCase", deny_unknown_fields)]
93pub struct ModelDescriptor {
94    /// BLAKE3 identity over every remaining descriptor field.
95    pub id: String,
96    /// Adapter identifier.
97    pub provider: String,
98    /// Exact provider model selector.
99    pub model: String,
100    /// Adapter capabilities observed before routing.
101    pub capabilities: ProviderCapabilities,
102    /// Effective context boundary used by the compaction budget.
103    pub context_tokens: u64,
104    /// Provenance of `context_tokens`.
105    pub context_source: RoutingContextSource,
106    /// Optional pinned pricing. Unknown pricing cannot satisfy a priced budget.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub pricing: Option<ModelPricing>,
109    /// Optional conservative latency ceiling in milliseconds.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub expected_latency_ms: Option<u64>,
112}
113
114impl ModelDescriptor {
115    /// Constructs a descriptor, validates material fields, and computes its content-addressed ID.
116    #[allow(clippy::too_many_arguments)]
117    pub fn new(
118        provider: impl Into<String>,
119        model: impl Into<String>,
120        capabilities: ProviderCapabilities,
121        context_tokens: u64,
122        context_source: RoutingContextSource,
123        pricing: Option<ModelPricing>,
124        expected_latency_ms: Option<u64>,
125    ) -> Result<Self, RoutingError> {
126        let mut descriptor = Self {
127            id: String::new(),
128            provider: provider.into(),
129            model: model.into(),
130            capabilities,
131            context_tokens,
132            context_source,
133            pricing,
134            expected_latency_ms,
135        };
136        descriptor.validate_material()?;
137        descriptor.id = descriptor.material_digest()?;
138        Ok(descriptor)
139    }
140
141    /// Material-digests the descriptor's non-ID fields.
142    pub fn material_digest(&self) -> Result<String, RoutingError> {
143        Ok(hash_json(&self.material_value()))
144    }
145
146    /// Validates material fields and ensures the ID matches the material digest.
147    pub fn validate(&self) -> Result<(), RoutingError> {
148        self.validate_material()?;
149        if !valid_digest(&self.id) || self.id != self.material_digest()? {
150            return Err(RoutingError::DescriptorDigest(self.id.clone()));
151        }
152        Ok(())
153    }
154
155    fn material_value(&self) -> serde_json::Value {
156        serde_json::json!({
157            "provider": self.provider,
158            "model": self.model,
159            "capabilities": self.capabilities,
160            "contextTokens": self.context_tokens,
161            "contextSource": self.context_source,
162            "pricing": self.pricing,
163            "expectedLatencyMs": self.expected_latency_ms,
164        })
165    }
166
167    fn validate_material(&self) -> Result<(), RoutingError> {
168        if self.provider.trim().is_empty() || self.model.trim().is_empty() {
169            return Err(RoutingError::EmptyIdentity);
170        }
171        if self.context_tokens == 0 || self.expected_latency_ms == Some(0) {
172            return Err(RoutingError::InvalidDescriptor(self.model.clone()));
173        }
174        Ok(())
175    }
176}
177
178/// Versioned, content-addressed catalog embedded in every routing plan.
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
180#[serde(rename_all = "camelCase", deny_unknown_fields)]
181pub struct ModelCatalog {
182    /// Schema version enforced against `SCHEMA_VERSION`.
183    pub schema_version: String,
184    /// Catalog contract version enforced against `MODEL_CATALOG_VERSION`.
185    pub catalog_version: String,
186    /// Content-addressed model descriptors, sorted by `id`.
187    pub descriptors: Vec<ModelDescriptor>,
188}
189
190impl ModelCatalog {
191    /// Creates a new catalog from descriptors, validates, and digest-sorts them by `id`.
192    pub fn new(mut descriptors: Vec<ModelDescriptor>) -> Result<Self, RoutingError> {
193        descriptors.sort_by(|left, right| left.id.cmp(&right.id));
194        let catalog = Self {
195            schema_version: SCHEMA_VERSION.to_owned(),
196            catalog_version: MODEL_CATALOG_VERSION.to_owned(),
197            descriptors,
198        };
199        catalog.validate()?;
200        Ok(catalog)
201    }
202
203    /// Validates schema version, catalog version, descriptor digests, sort order, and non-emptiness.
204    pub fn validate(&self) -> Result<(), RoutingError> {
205        if self.schema_version != SCHEMA_VERSION {
206            return Err(RoutingError::UnsupportedSchema(self.schema_version.clone()));
207        }
208        if self.catalog_version != MODEL_CATALOG_VERSION {
209            return Err(RoutingError::UnsupportedCatalog(
210                self.catalog_version.clone(),
211            ));
212        }
213        let mut previous = None;
214        for descriptor in &self.descriptors {
215            descriptor.validate()?;
216            if previous.is_some_and(|id: &str| id >= descriptor.id.as_str()) {
217                return Err(RoutingError::CatalogOrder);
218            }
219            previous = Some(descriptor.id.as_str());
220        }
221        if self.descriptors.is_empty() {
222            return Err(RoutingError::EmptyCatalog);
223        }
224        Ok(())
225    }
226
227    /// Computes a canonical BLAKE3 digest over the validated catalog JSON.
228    pub fn digest(&self) -> Result<String, RoutingError> {
229        self.validate()?;
230        let value = serde_json::to_value(self).map_err(|_| RoutingError::Serialization)?;
231        Ok(hash_json(&value))
232    }
233}
234
235/// Executable candidate bound to a catalog descriptor and runtime authority.
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237#[serde(rename_all = "camelCase", deny_unknown_fields)]
238pub struct RoutingCandidate {
239    /// Stable digest over profile, descriptor, authority, and credential identity.
240    pub id: String,
241    /// Content-addressed model descriptor this candidate references.
242    pub descriptor_id: String,
243    /// Lower values are preferred. Candidate ID breaks ties deterministically.
244    pub preference_rank: u32,
245    /// User-defined profile name used for deterministic preference and configuration.
246    pub profile: String,
247    /// Secret-free authority digest, including credential identity when present.
248    pub authority_hash: String,
249    /// False when an explicitly required credential could not be resolved.
250    pub credential_available: bool,
251    /// Explicit local kill switch.
252    pub enabled: bool,
253    /// Resolved token lifecycle, when the candidate is secret-free account-aware.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub token_status: Option<TokenStatus>,
256    /// Canonically ordered granted scopes, when account metadata is present.
257    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
258    pub scopes: BTreeSet<String>,
259}
260
261impl RoutingCandidate {
262    /// Constructs a credential-only candidate (legacy, no auth lifecycle).
263    pub fn new(
264        descriptor_id: impl Into<String>,
265        preference_rank: u32,
266        profile: impl Into<String>,
267        authority_hash: impl Into<String>,
268        credential_available: bool,
269        enabled: bool,
270    ) -> Result<Self, RoutingError> {
271        Self::with_auth(
272            descriptor_id,
273            preference_rank,
274            profile,
275            authority_hash,
276            credential_available,
277            enabled,
278            None,
279            BTreeSet::new(),
280        )
281    }
282
283    /// Constructs an account-aware candidate with a secret-free token lifecycle.
284    pub fn with_auth(
285        descriptor_id: impl Into<String>,
286        preference_rank: u32,
287        profile: impl Into<String>,
288        authority_hash: impl Into<String>,
289        credential_available: bool,
290        enabled: bool,
291        token_status: Option<TokenStatus>,
292        scopes: BTreeSet<String>,
293    ) -> Result<Self, RoutingError> {
294        let mut candidate = Self {
295            id: String::new(),
296            descriptor_id: descriptor_id.into(),
297            preference_rank,
298            profile: profile.into(),
299            authority_hash: authority_hash.into(),
300            credential_available,
301            enabled,
302            token_status,
303            scopes,
304        };
305        candidate.validate_material()?;
306        candidate.id = candidate.material_digest()?;
307        Ok(candidate)
308    }
309
310    /// Material-digests the candidate's profile-bound fields without the ID.
311    pub fn material_digest(&self) -> Result<String, RoutingError> {
312        Ok(hash_json(&self.material_value()))
313    }
314
315    /// Validates material fields and ensures the ID matches the material digest.
316    pub fn validate(&self) -> Result<(), RoutingError> {
317        self.validate_material()?;
318        if !valid_digest(&self.id) || self.id != self.material_digest()? {
319            return Err(RoutingError::CandidateDigest(self.id.clone()));
320        }
321        Ok(())
322    }
323
324    fn material_value(&self) -> serde_json::Value {
325        let mut map = serde_json::Map::new();
326        map.insert(
327            "descriptorId".to_owned(),
328            serde_json::json!(self.descriptor_id),
329        );
330        map.insert(
331            "preferenceRank".to_owned(),
332            serde_json::json!(self.preference_rank),
333        );
334        map.insert("profile".to_owned(), serde_json::json!(self.profile));
335        map.insert(
336            "authorityHash".to_owned(),
337            serde_json::json!(self.authority_hash),
338        );
339        map.insert(
340            "credentialAvailable".to_owned(),
341            serde_json::json!(self.credential_available),
342        );
343        map.insert("enabled".to_owned(), serde_json::json!(self.enabled));
344        // Auth lifecycle is hashed only when present, so legacy credential-only
345        // candidates keep their `proofborne.routing.deterministic.v1` digest.
346        if let Some(status) = self.token_status {
347            map.insert("tokenStatus".to_owned(), serde_json::json!(status));
348        }
349        if !self.scopes.is_empty() {
350            map.insert("scopes".to_owned(), serde_json::json!(self.scopes));
351        }
352        serde_json::Value::Object(map)
353    }
354
355    fn validate_material(&self) -> Result<(), RoutingError> {
356        if self.profile.trim().is_empty()
357            || !valid_digest(&self.descriptor_id)
358            || !valid_digest(&self.authority_hash)
359            || self.scopes.iter().any(|scope| !valid_scope(scope))
360        {
361            return Err(RoutingError::InvalidCandidate(self.profile.clone()));
362        }
363        Ok(())
364    }
365}
366
367/// Runtime-owned requirements and hard budgets for one model decision.
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
369#[serde(rename_all = "camelCase", deny_unknown_fields)]
370pub struct RoutingRequirements {
371    /// Capabilities the route must support (e.g. streaming, tools).
372    #[serde(default)]
373    pub required_capabilities: BTreeSet<RoutingCapability>,
374    /// Minimum context window any eligible candidate must offer.
375    pub min_context_tokens: u64,
376    /// Max input token budget for the route decision.
377    pub max_input_tokens: u64,
378    /// Reserved output token budget reserved for responses.
379    pub reserved_output_tokens: u64,
380    /// Optional maximum estimated cost in micro-USD.
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub max_estimated_cost_microusd: Option<u64>,
383    /// Optional maximum accepted latency in milliseconds.
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub max_latency_ms: Option<u64>,
386    /// When true, pricing-unknown candidates are excluded.
387    #[serde(default)]
388    pub require_known_pricing: bool,
389    /// When true, latency-unknown candidates are excluded.
390    #[serde(default)]
391    pub require_known_latency: bool,
392    /// Scopes a candidate must grant to be eligible (empty means no scope gate).
393    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
394    pub required_scopes: BTreeSet<String>,
395}
396
397impl RoutingRequirements {
398    /// Rejects zero or negative budgets and missing required values.
399    pub fn validate(&self) -> Result<(), RoutingError> {
400        if self.min_context_tokens == 0
401            || self.max_input_tokens == 0
402            || self.reserved_output_tokens == 0
403            || self.reserved_output_tokens == u64::MAX
404            || self.max_estimated_cost_microusd == Some(0)
405            || self.max_latency_ms == Some(0)
406            || self.required_scopes.iter().any(|scope| !valid_scope(scope))
407        {
408            return Err(RoutingError::InvalidRequirements);
409        }
410        Ok(())
411    }
412}
413
414/// State and authority bindings that make a routing decision non-replayable.
415#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
416#[serde(rename_all = "camelCase", deny_unknown_fields)]
417pub struct RoutingBindings {
418    /// Hash of the full routing contract payload.
419    pub contract_hash: String,
420    /// Hash of the policy applied during routing.
421    pub policy_hash: String,
422    /// Optional hash of the tool-calling authority when present.
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub tool_authority_hash: Option<String>,
425    /// Content-addressed digest of the model catalog.
426    pub catalog_hash: String,
427    /// Hash of the run-controls configuration.
428    pub run_controls_hash: String,
429    /// Monotonic step counter for sequential provider invocations.
430    pub step: u64,
431    /// Monotonic workspace generation for replay protection.
432    pub workspace_generation: u64,
433    /// Opaque state binding that pins this decision to prior outputs.
434    pub state_binding: String,
435    /// Monotonic watermark tracking side effects to prevent replay.
436    pub side_effect_watermark: u64,
437    /// Optional candidate ID locked for the entire step.
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub locked_candidate_id: Option<String>,
440}
441
442impl RoutingBindings {
443    fn validate(&self) -> Result<(), RoutingError> {
444        for digest in [
445            Some(self.contract_hash.as_str()),
446            Some(self.policy_hash.as_str()),
447            self.tool_authority_hash.as_deref(),
448            Some(self.catalog_hash.as_str()),
449            Some(self.run_controls_hash.as_str()),
450            Some(self.state_binding.as_str()),
451            self.locked_candidate_id.as_deref(),
452        ]
453        .into_iter()
454        .flatten()
455        {
456            if !valid_digest(digest) {
457                return Err(RoutingError::InvalidBinding(digest.to_owned()));
458            }
459        }
460        Ok(())
461    }
462}
463
464/// Stable reason why a candidate cannot execute.
465#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
466#[serde(rename_all = "snake_case")]
467pub enum RoutingExclusionReason {
468    /// Candidate `enabled` is `false`.
469    Disabled,
470    /// A required credential could not be resolved.
471    CredentialUnavailable,
472    /// A required `RoutingCapability` is not advertised.
473    MissingCapability,
474    /// The candidate's context window is smaller than `min_context_tokens`.
475    ContextWindowTooSmall,
476    /// The candidate has no pricing data and `require_known_pricing` is true.
477    PricingUnknown,
478    /// The candidate's estimated cost exceeds `max_estimated_cost_microusd`.
479    CostBudgetExceeded,
480    /// The candidate has no latency estimate and `require_known_latency` is true.
481    LatencyUnknown,
482    /// The candidate's estimated latency exceeds `max_latency_ms`.
483    LatencyBudgetExceeded,
484    /// The step has a locked candidate that differs from this candidate.
485    SessionRouteLocked,
486    /// A fallback is blocked because a side effect was already recorded.
487    FallbackBlockedAfterSideEffect,
488    /// The candidate's token is expired and cannot be refreshed.
489    TokenExpired,
490    /// The candidate's credential was revoked.
491    TokenRevoked,
492    /// The candidate's token needs a refresh that cannot safely proceed.
493    TokenNeedsRefresh,
494    /// The candidate lacks a required scope.
495    ScopeInsufficient,
496}
497
498/// Deterministic reducer output for one candidate.
499#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
500#[serde(rename_all = "camelCase", deny_unknown_fields)]
501pub struct RoutingSelection {
502    /// The candidate evaluated by the reducer.
503    pub candidate: RoutingCandidate,
504    /// Whether this candidate is eligible for execution.
505    pub eligible: bool,
506    /// Reasons for exclusion when `eligible` is `false`.
507    #[serde(default)]
508    pub reasons: BTreeSet<RoutingExclusionReason>,
509    /// Required capabilities this candidate lacks.
510    #[serde(default)]
511    pub missing_capabilities: BTreeSet<RoutingCapability>,
512    /// Pre-computed cost estimate for this candidate.
513    #[serde(skip_serializing_if = "Option::is_none")]
514    pub estimated_max_cost_microusd: Option<u64>,
515}
516
517/// Proof-carrying deterministic route plan for exactly one provider step.
518#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
519#[serde(rename_all = "camelCase", deny_unknown_fields)]
520pub struct RoutingPlan {
521    /// Schema version enforced against `SCHEMA_VERSION`.
522    pub schema_version: String,
523    /// Algorithm version enforced against `ROUTING_ALGORITHM_VERSION`.
524    pub algorithm_version: String,
525    /// Embedded model catalog governing candidate descriptors.
526    pub catalog: ModelCatalog,
527    /// Hard budgets and requirements for the route decision.
528    pub requirements: RoutingRequirements,
529    /// Non-replayability bindings including state and side-effect watermarks.
530    pub bindings: RoutingBindings,
531    /// Deterministic reduction results, one per candidate, in preference order.
532    pub selections: Vec<RoutingSelection>,
533}
534
535impl RoutingPlan {
536    /// Validates all inputs, builds candidate selections, and constructs the plan.
537    pub fn build(
538        catalog: ModelCatalog,
539        requirements: RoutingRequirements,
540        bindings: RoutingBindings,
541        mut candidates: Vec<RoutingCandidate>,
542    ) -> Result<Self, RoutingError> {
543        catalog.validate()?;
544        requirements.validate()?;
545        bindings.validate()?;
546        if catalog.digest()? != bindings.catalog_hash {
547            return Err(RoutingError::CatalogBinding);
548        }
549        candidates.sort_by(|left, right| {
550            (left.preference_rank, &left.id).cmp(&(right.preference_rank, &right.id))
551        });
552        validate_candidate_set(
553            &catalog,
554            &candidates,
555            bindings.locked_candidate_id.as_deref(),
556        )?;
557        let selections = compute_selections(&catalog, &requirements, &bindings, candidates)?;
558        let plan = Self {
559            schema_version: SCHEMA_VERSION.to_owned(),
560            algorithm_version: ROUTING_ALGORITHM_VERSION.to_owned(),
561            catalog,
562            requirements,
563            bindings,
564            selections,
565        };
566        plan.validate()?;
567        Ok(plan)
568    }
569
570    /// Validates schema, algorithm, catalog, requirements, bindings, and determinism.
571    pub fn validate(&self) -> Result<(), RoutingError> {
572        if self.schema_version != SCHEMA_VERSION {
573            return Err(RoutingError::UnsupportedSchema(self.schema_version.clone()));
574        }
575        if self.algorithm_version != ROUTING_ALGORITHM_VERSION {
576            return Err(RoutingError::UnsupportedAlgorithm(
577                self.algorithm_version.clone(),
578            ));
579        }
580        self.catalog.validate()?;
581        self.requirements.validate()?;
582        self.bindings.validate()?;
583        if self.catalog.digest()? != self.bindings.catalog_hash {
584            return Err(RoutingError::CatalogBinding);
585        }
586        let candidates = self
587            .selections
588            .iter()
589            .map(|selection| selection.candidate.clone())
590            .collect::<Vec<_>>();
591        validate_candidate_set(
592            &self.catalog,
593            &candidates,
594            self.bindings.locked_candidate_id.as_deref(),
595        )?;
596        let expected = compute_selections(
597            &self.catalog,
598            &self.requirements,
599            &self.bindings,
600            candidates,
601        )?;
602        if expected != self.selections {
603            return Err(RoutingError::SelectionMismatch);
604        }
605        Ok(())
606    }
607
608    /// Computes a canonical BLAKE3 digest over the validated plan JSON.
609    pub fn digest(&self) -> Result<String, RoutingError> {
610        self.validate()?;
611        let value = serde_json::to_value(self).map_err(|_| RoutingError::Serialization)?;
612        Ok(hash_json(&value))
613    }
614
615    /// Returns candidate IDs in preference order where `eligible` is `true`.
616    pub fn eligible_candidate_ids(&self) -> Vec<String> {
617        self.selections
618            .iter()
619            .filter(|selection| selection.eligible)
620            .map(|selection| selection.candidate.id.clone())
621            .collect()
622    }
623
624    /// Returns the ID of the single eligible candidate, or `None` if none.
625    pub fn selected_candidate_id(&self) -> Option<&str> {
626        self.selections
627            .iter()
628            .find(|selection| selection.eligible)
629            .map(|selection| selection.candidate.id.as_str())
630    }
631
632    /// Returns the smallest context boundary among candidates that may execute.
633    pub fn minimum_eligible_context_tokens(&self) -> Option<u64> {
634        let descriptors = self
635            .catalog
636            .descriptors
637            .iter()
638            .map(|descriptor| (descriptor.id.as_str(), descriptor.context_tokens))
639            .collect::<BTreeMap<_, _>>();
640        self.selections
641            .iter()
642            .filter(|selection| selection.eligible)
643            .filter_map(|selection| {
644                descriptors
645                    .get(selection.candidate.descriptor_id.as_str())
646                    .copied()
647            })
648            .min()
649    }
650}
651
652fn validate_candidate_set(
653    catalog: &ModelCatalog,
654    candidates: &[RoutingCandidate],
655    locked_candidate_id: Option<&str>,
656) -> Result<(), RoutingError> {
657    if candidates.is_empty() {
658        return Err(RoutingError::EmptyCandidates);
659    }
660    let descriptors = catalog
661        .descriptors
662        .iter()
663        .map(|descriptor| descriptor.id.as_str())
664        .collect::<BTreeSet<_>>();
665    let mut ids = BTreeSet::new();
666    let mut previous = None;
667    for candidate in candidates {
668        candidate.validate()?;
669        if !descriptors.contains(candidate.descriptor_id.as_str()) {
670            return Err(RoutingError::UnknownDescriptor(
671                candidate.descriptor_id.clone(),
672            ));
673        }
674        if !ids.insert(candidate.id.as_str()) {
675            return Err(RoutingError::DuplicateCandidate(candidate.id.clone()));
676        }
677        let key = (candidate.preference_rank, candidate.id.as_str());
678        if previous.is_some_and(|prior| prior >= key) {
679            return Err(RoutingError::CandidateOrder);
680        }
681        previous = Some(key);
682    }
683    if let Some(locked) = locked_candidate_id
684        && !ids.contains(locked)
685    {
686        return Err(RoutingError::UnknownLockedCandidate(locked.to_owned()));
687    }
688    Ok(())
689}
690
691fn compute_selections(
692    catalog: &ModelCatalog,
693    requirements: &RoutingRequirements,
694    bindings: &RoutingBindings,
695    candidates: Vec<RoutingCandidate>,
696) -> Result<Vec<RoutingSelection>, RoutingError> {
697    let descriptors = catalog
698        .descriptors
699        .iter()
700        .map(|descriptor| (descriptor.id.as_str(), descriptor))
701        .collect::<BTreeMap<_, _>>();
702    let minimum_context_tokens = requirements
703        .min_context_tokens
704        .max(requirements.reserved_output_tokens + 1);
705    let mut selections = Vec::with_capacity(candidates.len());
706    for candidate in candidates {
707        let descriptor = descriptors
708            .get(candidate.descriptor_id.as_str())
709            .copied()
710            .ok_or_else(|| RoutingError::UnknownDescriptor(candidate.descriptor_id.clone()))?;
711        let missing_capabilities = requirements
712            .required_capabilities
713            .iter()
714            .copied()
715            .filter(|capability| !capability.is_supported_by(&descriptor.capabilities))
716            .collect::<BTreeSet<_>>();
717        let estimated_max_cost_microusd = descriptor
718            .pricing
719            .as_ref()
720            .map(|pricing| {
721                pricing.estimate(
722                    requirements.max_input_tokens,
723                    requirements.reserved_output_tokens,
724                )
725            })
726            .transpose()?;
727        let mut reasons = BTreeSet::new();
728        if !candidate.enabled {
729            reasons.insert(RoutingExclusionReason::Disabled);
730        }
731        if !candidate.credential_available {
732            reasons.insert(RoutingExclusionReason::CredentialUnavailable);
733        }
734        match candidate.token_status {
735            Some(TokenStatus::Expired) => {
736                reasons.insert(RoutingExclusionReason::TokenExpired);
737            }
738            Some(TokenStatus::Revoked) => {
739                reasons.insert(RoutingExclusionReason::TokenRevoked);
740            }
741            Some(TokenStatus::NeedsRefresh) => {
742                reasons.insert(RoutingExclusionReason::TokenNeedsRefresh);
743            }
744            Some(TokenStatus::Active) | None => {}
745        }
746        if !requirements.required_scopes.is_subset(&candidate.scopes) {
747            reasons.insert(RoutingExclusionReason::ScopeInsufficient);
748        }
749        if !missing_capabilities.is_empty() {
750            reasons.insert(RoutingExclusionReason::MissingCapability);
751        }
752        if descriptor.context_tokens < minimum_context_tokens {
753            reasons.insert(RoutingExclusionReason::ContextWindowTooSmall);
754        }
755        if requirements.require_known_pricing && descriptor.pricing.is_none() {
756            reasons.insert(RoutingExclusionReason::PricingUnknown);
757        }
758        if let Some(maximum) = requirements.max_estimated_cost_microusd {
759            match estimated_max_cost_microusd {
760                Some(estimate) if estimate > maximum => {
761                    reasons.insert(RoutingExclusionReason::CostBudgetExceeded);
762                }
763                None => {
764                    reasons.insert(RoutingExclusionReason::PricingUnknown);
765                }
766                Some(_) => {}
767            }
768        }
769        if requirements.require_known_latency && descriptor.expected_latency_ms.is_none() {
770            reasons.insert(RoutingExclusionReason::LatencyUnknown);
771        }
772        if let Some(maximum) = requirements.max_latency_ms {
773            match descriptor.expected_latency_ms {
774                Some(estimate) if estimate > maximum => {
775                    reasons.insert(RoutingExclusionReason::LatencyBudgetExceeded);
776                }
777                None => {
778                    reasons.insert(RoutingExclusionReason::LatencyUnknown);
779                }
780                Some(_) => {}
781            }
782        }
783        if let Some(locked) = bindings.locked_candidate_id.as_deref()
784            && candidate.id != locked
785        {
786            reasons.insert(RoutingExclusionReason::SessionRouteLocked);
787        }
788        selections.push(RoutingSelection {
789            candidate,
790            eligible: reasons.is_empty(),
791            reasons,
792            missing_capabilities,
793            estimated_max_cost_microusd,
794        });
795    }
796
797    if bindings.locked_candidate_id.is_none() && bindings.side_effect_watermark > 0 {
798        let mut retained_one = false;
799        for selection in &mut selections {
800            if selection.eligible && !retained_one {
801                retained_one = true;
802            } else if selection.eligible {
803                selection
804                    .reasons
805                    .insert(RoutingExclusionReason::FallbackBlockedAfterSideEffect);
806                selection.eligible = false;
807            }
808        }
809    }
810    Ok(selections)
811}
812
813/// Outcome of one provider attempt committed by a routing receipt.
814#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
815#[serde(rename_all = "snake_case")]
816pub enum RoutingAttemptOutcome {
817    /// The attempt failed before producing any visible output.
818    FailedBeforeVisibleOutput,
819    /// The attempt failed after producing visible output.
820    FailedAfterVisibleOutput,
821    /// The attempt completed successfully.
822    Succeeded,
823    /// Runtime cancellation interrupted this attempt.
824    Cancelled,
825}
826
827/// One secret-free attempt in exact execution order.
828#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
829#[serde(rename_all = "camelCase", deny_unknown_fields)]
830pub struct RoutingAttemptReceipt {
831    /// ID of the candidate this attempt targeted.
832    pub candidate_id: String,
833    /// Outcome of the provider attempt.
834    pub outcome: RoutingAttemptOutcome,
835    /// Hash of the provider error, when `outcome` is not `Succeeded`.
836    #[serde(skip_serializing_if = "Option::is_none")]
837    pub error_hash: Option<String>,
838}
839
840/// Terminal reducer state for one provider step.
841#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
842#[serde(rename_all = "snake_case")]
843pub enum RoutingReceiptOutcome {
844    /// An eligible candidate was selected and succeeded.
845    Selected,
846    /// All eligible candidates failed before producing visible output.
847    Exhausted,
848    /// No eligible candidate could execute (empty eligible set or blocked).
849    Blocked,
850    /// The runtime stopped before invoking an otherwise eligible candidate.
851    NotInvoked,
852    /// Cooperative cancellation interrupted route execution.
853    Cancelled,
854    /// Recovery found a provider attempt without a durable terminal receipt.
855    Ambiguous,
856}
857
858/// Deterministic execution receipt bound to exactly one routing plan.
859#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
860#[serde(rename_all = "camelCase", deny_unknown_fields)]
861pub struct RoutingReceipt {
862    /// Schema version enforced against `SCHEMA_VERSION`.
863    pub schema_version: String,
864    /// Algorithm version enforced against `ROUTING_ALGORITHM_VERSION`.
865    pub algorithm_version: String,
866    /// Content-addressed digest of the bound `RoutingPlan`.
867    pub plan_hash: String,
868    /// Monotonic step counter matching the plan bindings.
869    pub step: u64,
870    /// Monotonic workspace generation matching the plan bindings.
871    pub workspace_generation: u64,
872    /// State binding matching the plan bindings.
873    pub state_binding: String,
874    /// Side-effect watermark matching the plan bindings.
875    pub side_effect_watermark: u64,
876    /// Provider attempts in exact execution order.
877    pub attempts: Vec<RoutingAttemptReceipt>,
878    /// The ID of the candidate that succeeded, when `outcome` is `Selected`.
879    #[serde(skip_serializing_if = "Option::is_none")]
880    pub selected_candidate_id: Option<String>,
881    /// Terminal reducer state for the step.
882    pub outcome: RoutingReceiptOutcome,
883}
884
885impl RoutingReceipt {
886    /// Constructs a receipt, validates it against the plan, and verifies all invariants.
887    pub fn new(
888        plan: &RoutingPlan,
889        attempts: Vec<RoutingAttemptReceipt>,
890        selected_candidate_id: Option<String>,
891        outcome: RoutingReceiptOutcome,
892    ) -> Result<Self, RoutingError> {
893        let receipt = Self {
894            schema_version: SCHEMA_VERSION.to_owned(),
895            algorithm_version: ROUTING_ALGORITHM_VERSION.to_owned(),
896            plan_hash: plan.digest()?,
897            step: plan.bindings.step,
898            workspace_generation: plan.bindings.workspace_generation,
899            state_binding: plan.bindings.state_binding.clone(),
900            side_effect_watermark: plan.bindings.side_effect_watermark,
901            attempts,
902            selected_candidate_id,
903            outcome,
904        };
905        receipt.validate(plan)?;
906        Ok(receipt)
907    }
908
909    /// Validates schema, algorithm, plan binding, attempt sequence, and outcome consistency.
910    pub fn validate(&self, plan: &RoutingPlan) -> Result<(), RoutingError> {
911        plan.validate()?;
912        if self.schema_version != SCHEMA_VERSION {
913            return Err(RoutingError::UnsupportedSchema(self.schema_version.clone()));
914        }
915        if self.algorithm_version != ROUTING_ALGORITHM_VERSION {
916            return Err(RoutingError::UnsupportedAlgorithm(
917                self.algorithm_version.clone(),
918            ));
919        }
920        if self.plan_hash != plan.digest()?
921            || self.step != plan.bindings.step
922            || self.workspace_generation != plan.bindings.workspace_generation
923            || self.state_binding != plan.bindings.state_binding
924            || self.side_effect_watermark != plan.bindings.side_effect_watermark
925        {
926            return Err(RoutingError::ReceiptBinding);
927        }
928        let eligible = plan.eligible_candidate_ids();
929        if self.attempts.len() > eligible.len() {
930            return Err(RoutingError::ReceiptAttempts);
931        }
932        for (index, attempt) in self.attempts.iter().enumerate() {
933            if eligible.get(index) != Some(&attempt.candidate_id)
934                || !valid_digest(&attempt.candidate_id)
935            {
936                return Err(RoutingError::ReceiptAttempts);
937            }
938            match attempt.outcome {
939                RoutingAttemptOutcome::Succeeded => {
940                    if attempt.error_hash.is_some() || index + 1 != self.attempts.len() {
941                        return Err(RoutingError::ReceiptAttempts);
942                    }
943                }
944                RoutingAttemptOutcome::FailedBeforeVisibleOutput
945                | RoutingAttemptOutcome::FailedAfterVisibleOutput
946                | RoutingAttemptOutcome::Cancelled => {
947                    if !attempt.error_hash.as_deref().is_some_and(valid_digest) {
948                        return Err(RoutingError::ReceiptAttempts);
949                    }
950                }
951            }
952        }
953        let last = self.attempts.last().map(|attempt| attempt.outcome);
954        match self.outcome {
955            RoutingReceiptOutcome::Selected => {
956                let Some(selected) = self.selected_candidate_id.as_deref() else {
957                    return Err(RoutingError::ReceiptOutcome);
958                };
959                if last != Some(RoutingAttemptOutcome::Succeeded)
960                    || self
961                        .attempts
962                        .last()
963                        .map(|attempt| attempt.candidate_id.as_str())
964                        != Some(selected)
965                {
966                    return Err(RoutingError::ReceiptOutcome);
967                }
968            }
969            RoutingReceiptOutcome::Exhausted => {
970                if self.selected_candidate_id.is_some()
971                    || self.attempts.len() != eligible.len()
972                    || self.attempts.iter().any(|attempt| {
973                        attempt.outcome != RoutingAttemptOutcome::FailedBeforeVisibleOutput
974                    })
975                {
976                    return Err(RoutingError::ReceiptOutcome);
977                }
978            }
979            RoutingReceiptOutcome::Blocked => {
980                let blocked_without_attempt = eligible.is_empty() && self.attempts.is_empty();
981                let blocked_after_visible = last
982                    == Some(RoutingAttemptOutcome::FailedAfterVisibleOutput)
983                    && self.selected_candidate_id.is_none();
984                if !blocked_without_attempt && !blocked_after_visible {
985                    return Err(RoutingError::ReceiptOutcome);
986                }
987            }
988            RoutingReceiptOutcome::NotInvoked | RoutingReceiptOutcome::Ambiguous => {
989                if eligible.is_empty()
990                    || !self.attempts.is_empty()
991                    || self.selected_candidate_id.is_some()
992                {
993                    return Err(RoutingError::ReceiptOutcome);
994                }
995            }
996            RoutingReceiptOutcome::Cancelled => {
997                let cancelled_before_attempt = eligible.len() > self.attempts.len()
998                    && self.attempts.iter().all(|attempt| {
999                        attempt.outcome == RoutingAttemptOutcome::FailedBeforeVisibleOutput
1000                    });
1001                let cancelled_during_attempt = last == Some(RoutingAttemptOutcome::Cancelled);
1002                if self.selected_candidate_id.is_some()
1003                    || (!cancelled_before_attempt && !cancelled_during_attempt)
1004                {
1005                    return Err(RoutingError::ReceiptOutcome);
1006                }
1007            }
1008        }
1009        Ok(())
1010    }
1011
1012    /// Computes a canonical BLAKE3 digest over the validated receipt JSON.
1013    pub fn digest(&self, plan: &RoutingPlan) -> Result<String, RoutingError> {
1014        self.validate(plan)?;
1015        let value = serde_json::to_value(self).map_err(|_| RoutingError::Serialization)?;
1016        Ok(hash_json(&value))
1017    }
1018}
1019
1020fn valid_digest(value: &str) -> bool {
1021    value.len() == 64
1022        && value
1023            .bytes()
1024            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1025}
1026
1027fn valid_scope(scope: &str) -> bool {
1028    !scope.is_empty()
1029        && scope.len() <= 256
1030        && !scope
1031            .chars()
1032            .any(|character| character.is_control() || character.is_whitespace())
1033}
1034
1035/// Routing contract or deterministic reduction failure.
1036#[derive(Debug, Clone, Error, PartialEq, Eq)]
1037pub enum RoutingError {
1038    /// A routing plan, receipt, or catalog carries an unsupported `schema_version`.
1039    #[error("unsupported routing schema: {0}")]
1040    UnsupportedSchema(String),
1041    /// A routing plan carries an unsupported `algorithm_version`.
1042    #[error("unsupported routing algorithm: {0}")]
1043    UnsupportedAlgorithm(String),
1044    /// A model catalog carries an unsupported `catalog_version`.
1045    #[error("unsupported model catalog: {0}")]
1046    UnsupportedCatalog(String),
1047    /// A routing identity field is empty when a non-empty value is required.
1048    #[error("routing identity is empty")]
1049    EmptyIdentity,
1050    /// A model descriptor failed validation (e.g. invalid digest, empty provider).
1051    #[error("invalid model descriptor: {0}")]
1052    InvalidDescriptor(String),
1053    /// The BLAKE3 digest in a model descriptor does not match its payload.
1054    #[error("model descriptor digest is invalid: {0}")]
1055    DescriptorDigest(String),
1056    /// A model catalog was constructed with zero descriptors.
1057    #[error("model catalog is empty")]
1058    EmptyCatalog,
1059    /// The model catalog descriptors are not strictly sorted by digest.
1060    #[error("model catalog descriptors are not strictly digest-sorted")]
1061    CatalogOrder,
1062    /// A routing candidate failed validation (e.g. empty profile, invalid digests).
1063    #[error("routing candidate is invalid: {0}")]
1064    InvalidCandidate(String),
1065    /// The routing candidate ID does not match the material digest.
1066    #[error("routing candidate digest is invalid: {0}")]
1067    CandidateDigest(String),
1068    /// The routing candidate list is empty when at least one is required.
1069    #[error("routing candidate list is empty")]
1070    EmptyCandidates,
1071    /// The routing candidates are not sorted in deterministic preference order.
1072    #[error("routing candidates are not in deterministic preference order")]
1073    CandidateOrder,
1074    /// Two or more routing candidates share the same digest identity.
1075    #[error("duplicate routing candidate: {0}")]
1076    DuplicateCandidate(String),
1077    /// A routing candidate references a descriptor ID absent from the catalog.
1078    #[error("routing candidate references an unknown descriptor: {0}")]
1079    UnknownDescriptor(String),
1080    /// The locked candidate ID in bindings does not match any candidate.
1081    #[error("locked routing candidate is absent: {0}")]
1082    UnknownLockedCandidate(String),
1083    /// Routing requirements failed validation (e.g. zero-valued budgets).
1084    #[error("routing requirements are invalid")]
1085    InvalidRequirements,
1086    /// A binding field is not a valid BLAKE3 hex digest.
1087    #[error("routing binding is not a canonical digest: {0}")]
1088    InvalidBinding(String),
1089    /// The model catalog digest in bindings does not match the catalog's canonical digest.
1090    #[error("model catalog digest does not match the routing binding")]
1091    CatalogBinding,
1092    /// The selections computed during plan validation disagree with the stored selections.
1093    #[error("routing selections disagree with deterministic reduction")]
1094    SelectionMismatch,
1095    /// A routing cost estimate overflowed `u64`.
1096    #[error("routing cost estimate overflowed")]
1097    CostOverflow,
1098    /// Serde serialization of a routing structure failed.
1099    #[error("routing contract serialization failed")]
1100    Serialization,
1101    /// The receipt's binding fields do not match the plan's binding values.
1102    #[error("routing receipt does not match plan bindings")]
1103    ReceiptBinding,
1104    /// The receipt attempt count, order, or per-attempt outcomes violate invariants.
1105    #[error("routing receipt attempt sequence is invalid")]
1106    ReceiptAttempts,
1107    /// The receipt terminal outcome is inconsistent with the attempt sequence.
1108    #[error("routing receipt outcome is inconsistent")]
1109    ReceiptOutcome,
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115    use crate::hash_bytes;
1116
1117    fn descriptor(model: &str, tools: bool, price: Option<u64>) -> ModelDescriptor {
1118        ModelDescriptor::new(
1119            "mock",
1120            model,
1121            ProviderCapabilities {
1122                streaming: false,
1123                tools,
1124                structured_output: true,
1125                multimodal_input: false,
1126                context_tokens: Some(32_768),
1127            },
1128            32_768,
1129            RoutingContextSource::Provider,
1130            price.map(|rate| ModelPricing {
1131                input_microusd_per_million_tokens: rate,
1132                output_microusd_per_million_tokens: rate,
1133            }),
1134            Some(1_000),
1135        )
1136        .unwrap()
1137    }
1138
1139    fn plan(lock: Option<String>, side_effects: u64) -> RoutingPlan {
1140        let first = descriptor("a", true, Some(1_000));
1141        let second = descriptor("b", true, Some(2_000));
1142        let catalog = ModelCatalog::new(vec![second.clone(), first.clone()]).unwrap();
1143        let candidates = vec![
1144            RoutingCandidate::new(
1145                first.id.clone(),
1146                0,
1147                "primary",
1148                hash_bytes(b"primary-authority"),
1149                true,
1150                true,
1151            )
1152            .unwrap(),
1153            RoutingCandidate::new(
1154                second.id.clone(),
1155                1,
1156                "fallback",
1157                hash_bytes(b"fallback-authority"),
1158                true,
1159                true,
1160            )
1161            .unwrap(),
1162        ];
1163        let bindings = RoutingBindings {
1164            contract_hash: hash_bytes(b"contract"),
1165            policy_hash: hash_bytes(b"policy"),
1166            tool_authority_hash: None,
1167            catalog_hash: catalog.digest().unwrap(),
1168            run_controls_hash: hash_bytes(b"resume"),
1169            step: 0,
1170            workspace_generation: 0,
1171            state_binding: hash_bytes(b"workspace"),
1172            side_effect_watermark: side_effects,
1173            locked_candidate_id: lock,
1174        };
1175        RoutingPlan::build(
1176            catalog,
1177            RoutingRequirements {
1178                required_capabilities: [RoutingCapability::Tools].into_iter().collect(),
1179                min_context_tokens: 8_192,
1180                max_input_tokens: 8_192,
1181                reserved_output_tokens: 1_024,
1182                max_estimated_cost_microusd: Some(20),
1183                max_latency_ms: Some(2_000),
1184                require_known_pricing: true,
1185                require_known_latency: true,
1186                required_scopes: BTreeSet::new(),
1187            },
1188            bindings,
1189            candidates,
1190        )
1191        .unwrap()
1192    }
1193
1194    #[test]
1195    fn plan_is_canonical_and_content_addressed() {
1196        let first = plan(None, 0);
1197        let second = plan(None, 0);
1198        assert_eq!(first, second);
1199        assert_eq!(first.digest().unwrap(), second.digest().unwrap());
1200        assert_eq!(first.eligible_candidate_ids().len(), 2);
1201    }
1202
1203    #[test]
1204    fn side_effects_and_route_lock_remove_fallbacks() {
1205        let side_effect_plan = plan(None, 1);
1206        assert_eq!(side_effect_plan.eligible_candidate_ids().len(), 1);
1207        assert!(
1208            side_effect_plan.selections[1]
1209                .reasons
1210                .contains(&RoutingExclusionReason::FallbackBlockedAfterSideEffect)
1211        );
1212        let lock = side_effect_plan.selections[1].candidate.id.clone();
1213        let locked = plan(Some(lock.clone()), 0);
1214        assert_eq!(locked.eligible_candidate_ids(), vec![lock]);
1215        assert!(
1216            locked.selections[0]
1217                .reasons
1218                .contains(&RoutingExclusionReason::SessionRouteLocked)
1219        );
1220    }
1221
1222    #[test]
1223    fn output_reserve_that_consumes_the_context_excludes_every_candidate() {
1224        let base = plan(None, 0);
1225        let candidates = base
1226            .selections
1227            .iter()
1228            .map(|selection| selection.candidate.clone())
1229            .collect();
1230        let routed = RoutingPlan::build(
1231            base.catalog,
1232            RoutingRequirements {
1233                min_context_tokens: 1,
1234                max_input_tokens: 1,
1235                reserved_output_tokens: 32_768,
1236                ..base.requirements
1237            },
1238            base.bindings,
1239            candidates,
1240        )
1241        .unwrap();
1242
1243        assert!(routed.eligible_candidate_ids().is_empty());
1244        assert!(routed.selections.iter().all(|selection| {
1245            selection
1246                .reasons
1247                .contains(&RoutingExclusionReason::ContextWindowTooSmall)
1248        }));
1249    }
1250
1251    #[test]
1252    fn priced_budget_excludes_unknown_and_expensive_models() {
1253        let cheap = descriptor("cheap", true, Some(1));
1254        let unknown = descriptor("unknown", true, None);
1255        let expensive = descriptor("expensive", true, Some(1_000_000));
1256        let catalog =
1257            ModelCatalog::new(vec![cheap.clone(), unknown.clone(), expensive.clone()]).unwrap();
1258        let candidates = [cheap, unknown, expensive]
1259            .into_iter()
1260            .enumerate()
1261            .map(|(rank, descriptor)| {
1262                RoutingCandidate::new(
1263                    descriptor.id,
1264                    u32::try_from(rank).unwrap(),
1265                    format!("p{rank}"),
1266                    hash_bytes(format!("authority-{rank}")),
1267                    true,
1268                    true,
1269                )
1270                .unwrap()
1271            })
1272            .collect();
1273        let bindings = RoutingBindings {
1274            contract_hash: hash_bytes(b"contract"),
1275            policy_hash: hash_bytes(b"policy"),
1276            tool_authority_hash: None,
1277            catalog_hash: catalog.digest().unwrap(),
1278            run_controls_hash: hash_bytes(b"resume"),
1279            step: 0,
1280            workspace_generation: 0,
1281            state_binding: hash_bytes(b"workspace"),
1282            side_effect_watermark: 0,
1283            locked_candidate_id: None,
1284        };
1285        let routed = RoutingPlan::build(
1286            catalog,
1287            RoutingRequirements {
1288                required_capabilities: BTreeSet::new(),
1289                min_context_tokens: 1,
1290                max_input_tokens: 10_000,
1291                reserved_output_tokens: 1_000,
1292                max_estimated_cost_microusd: Some(100),
1293                max_latency_ms: None,
1294                require_known_pricing: true,
1295                require_known_latency: false,
1296                required_scopes: BTreeSet::new(),
1297            },
1298            bindings,
1299            candidates,
1300        )
1301        .unwrap();
1302        assert_eq!(routed.eligible_candidate_ids().len(), 1);
1303        assert!(routed.selections.iter().any(|selection| {
1304            selection
1305                .reasons
1306                .contains(&RoutingExclusionReason::PricingUnknown)
1307        }));
1308        assert!(routed.selections.iter().any(|selection| {
1309            selection
1310                .reasons
1311                .contains(&RoutingExclusionReason::CostBudgetExceeded)
1312        }));
1313    }
1314
1315    #[test]
1316    fn receipt_commits_exact_attempt_prefix_and_selection() {
1317        let plan = plan(None, 0);
1318        let eligible = plan.eligible_candidate_ids();
1319        let receipt = RoutingReceipt::new(
1320            &plan,
1321            vec![
1322                RoutingAttemptReceipt {
1323                    candidate_id: eligible[0].clone(),
1324                    outcome: RoutingAttemptOutcome::FailedBeforeVisibleOutput,
1325                    error_hash: Some(hash_bytes(b"transport")),
1326                },
1327                RoutingAttemptReceipt {
1328                    candidate_id: eligible[1].clone(),
1329                    outcome: RoutingAttemptOutcome::Succeeded,
1330                    error_hash: None,
1331                },
1332            ],
1333            Some(eligible[1].clone()),
1334            RoutingReceiptOutcome::Selected,
1335        )
1336        .unwrap();
1337        receipt.validate(&plan).unwrap();
1338        assert!(valid_digest(&receipt.digest(&plan).unwrap()));
1339    }
1340
1341    #[test]
1342    fn plan_and_receipt_tampering_fail_closed() {
1343        let mut routed = plan(None, 0);
1344        routed.selections[0].eligible = false;
1345        assert_eq!(routed.validate(), Err(RoutingError::SelectionMismatch));
1346
1347        let routed = plan(None, 0);
1348        let error =
1349            RoutingReceipt::new(&routed, Vec::new(), None, RoutingReceiptOutcome::Exhausted)
1350                .unwrap_err();
1351        assert_eq!(error, RoutingError::ReceiptOutcome);
1352
1353        let mut unknown_field = serde_json::to_value(&routed).unwrap();
1354        unknown_field["uncommittedDiagnostic"] = serde_json::json!(true);
1355        assert!(serde_json::from_value::<RoutingPlan>(unknown_field).is_err());
1356    }
1357
1358    #[test]
1359    fn credential_only_candidate_digest_is_stable_without_auth() {
1360        // `with_auth(None, empty)` must hash identically to the legacy `new()`
1361        // so `proofborne.routing.deterministic.v1` digests stay stable for
1362        // credential-only candidates until account metadata is present.
1363        let legacy = RoutingCandidate::new(
1364            hash_bytes(b"descriptor"),
1365            0,
1366            "primary",
1367            hash_bytes(b"authority"),
1368            true,
1369            true,
1370        )
1371        .unwrap();
1372        let with_absent_auth = RoutingCandidate::with_auth(
1373            hash_bytes(b"descriptor"),
1374            0,
1375            "primary",
1376            hash_bytes(b"authority"),
1377            true,
1378            true,
1379            None,
1380            BTreeSet::new(),
1381        )
1382        .unwrap();
1383        assert_eq!(legacy.id, with_absent_auth.id);
1384        // Lock the exact legacy v1 material shape: six fields, no auth entries.
1385        let expected = hash_json(&serde_json::json!({
1386            "descriptorId": hash_bytes(b"descriptor"),
1387            "preferenceRank": 0,
1388            "profile": "primary",
1389            "authorityHash": hash_bytes(b"authority"),
1390            "credentialAvailable": true,
1391            "enabled": true,
1392        }));
1393        assert_eq!(legacy.id, expected);
1394    }
1395
1396    #[test]
1397    fn account_aware_candidate_digest_binds_auth_state() {
1398        let base = RoutingCandidate::new(
1399            hash_bytes(b"descriptor"),
1400            0,
1401            "primary",
1402            hash_bytes(b"auth"),
1403            true,
1404            true,
1405        )
1406        .unwrap();
1407        let expired = RoutingCandidate::with_auth(
1408            hash_bytes(b"descriptor"),
1409            0,
1410            "primary",
1411            hash_bytes(b"auth"),
1412            true,
1413            true,
1414            Some(TokenStatus::Expired),
1415            BTreeSet::new(),
1416        )
1417        .unwrap();
1418        assert_ne!(base.id, expired.id);
1419    }
1420
1421    fn auth_plan(requirements: RoutingRequirements) -> RoutingPlan {
1422        let descriptor = descriptor("model", true, Some(1));
1423        let catalog = ModelCatalog::new(vec![descriptor.clone()]).unwrap();
1424        let candidate = RoutingCandidate::with_auth(
1425            descriptor.id,
1426            0,
1427            "primary",
1428            hash_bytes(b"authority"),
1429            true,
1430            true,
1431            Some(TokenStatus::Active),
1432            BTreeSet::from(["chat.completions".to_owned()]),
1433        )
1434        .unwrap();
1435        let catalog_hash = catalog.digest().unwrap();
1436        RoutingPlan::build(
1437            catalog,
1438            requirements,
1439            RoutingBindings {
1440                contract_hash: hash_bytes(b"contract"),
1441                policy_hash: hash_bytes(b"policy"),
1442                tool_authority_hash: None,
1443                catalog_hash,
1444                run_controls_hash: hash_bytes(b"resume"),
1445                step: 0,
1446                workspace_generation: 0,
1447                state_binding: hash_bytes(b"workspace"),
1448                side_effect_watermark: 0,
1449                locked_candidate_id: None,
1450            },
1451            vec![candidate],
1452        )
1453        .unwrap()
1454    }
1455
1456    fn base_requirements() -> RoutingRequirements {
1457        RoutingRequirements {
1458            required_capabilities: BTreeSet::new(),
1459            min_context_tokens: 1,
1460            max_input_tokens: 10_000,
1461            reserved_output_tokens: 1_000,
1462            max_estimated_cost_microusd: None,
1463            max_latency_ms: None,
1464            require_known_pricing: false,
1465            require_known_latency: false,
1466            required_scopes: BTreeSet::new(),
1467        }
1468    }
1469
1470    #[test]
1471    fn token_lifecycle_excludes_candidate() {
1472        for (status, reason) in [
1473            (TokenStatus::Expired, RoutingExclusionReason::TokenExpired),
1474            (TokenStatus::Revoked, RoutingExclusionReason::TokenRevoked),
1475            (
1476                TokenStatus::NeedsRefresh,
1477                RoutingExclusionReason::TokenNeedsRefresh,
1478            ),
1479        ] {
1480            let descriptor = descriptor("model", true, Some(1));
1481            let catalog = ModelCatalog::new(vec![descriptor.clone()]).unwrap();
1482            let candidate = RoutingCandidate::with_auth(
1483                descriptor.id,
1484                0,
1485                "primary",
1486                hash_bytes(b"authority"),
1487                true,
1488                true,
1489                Some(status),
1490                BTreeSet::new(),
1491            )
1492            .unwrap();
1493            let cred = RoutingExclusionReason::CredentialUnavailable;
1494            let catalog_hash = catalog.digest().unwrap();
1495            let plan = RoutingPlan::build(
1496                catalog,
1497                base_requirements(),
1498                RoutingBindings {
1499                    contract_hash: hash_bytes(b"contract"),
1500                    policy_hash: hash_bytes(b"policy"),
1501                    tool_authority_hash: None,
1502                    catalog_hash,
1503                    run_controls_hash: hash_bytes(b"resume"),
1504                    step: 0,
1505                    workspace_generation: 0,
1506                    state_binding: hash_bytes(b"workspace"),
1507                    side_effect_watermark: 0,
1508                    locked_candidate_id: None,
1509                },
1510                vec![candidate],
1511            )
1512            .unwrap();
1513            assert!(plan.eligible_candidate_ids().is_empty());
1514            assert!(plan.selections[0].reasons.contains(&reason));
1515            assert!(!plan.selections[0].reasons.contains(&cred));
1516        }
1517    }
1518
1519    #[test]
1520    fn required_scope_excludes_candidate_without_scope() {
1521        let satisfied = auth_plan(base_requirements());
1522        assert_eq!(satisfied.eligible_candidate_ids().len(), 1);
1523
1524        let mut requirements = base_requirements();
1525        requirements.required_scopes =
1526            BTreeSet::from(["chat.completions".to_owned(), "models.read".to_owned()]);
1527        let denied = auth_plan(requirements);
1528        assert!(denied.eligible_candidate_ids().is_empty());
1529        assert!(
1530            denied.selections[0]
1531                .reasons
1532                .contains(&RoutingExclusionReason::ScopeInsufficient)
1533        );
1534    }
1535
1536    #[test]
1537    fn requirements_reject_invalid_scope_string() {
1538        let mut valid = base_requirements();
1539        assert!(valid.validate().is_ok());
1540        valid.required_scopes.insert("bad scope".to_owned());
1541        assert_eq!(valid.validate(), Err(RoutingError::InvalidRequirements));
1542    }
1543
1544    #[test]
1545    fn candidate_rejects_invalid_scope_string() {
1546        let descriptor = descriptor("model", true, Some(1));
1547        assert!(
1548            RoutingCandidate::with_auth(
1549                descriptor.id,
1550                0,
1551                "primary",
1552                hash_bytes(b"authority"),
1553                true,
1554                true,
1555                Some(TokenStatus::Active),
1556                BTreeSet::from(["bad	scope".to_owned()]),
1557            )
1558            .is_err()
1559        );
1560    }
1561}