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, 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}
254
255impl RoutingCandidate {
256    /// Constructs a candidate, material-digests its fields, and validates the result.
257    pub fn new(
258        descriptor_id: impl Into<String>,
259        preference_rank: u32,
260        profile: impl Into<String>,
261        authority_hash: impl Into<String>,
262        credential_available: bool,
263        enabled: bool,
264    ) -> Result<Self, RoutingError> {
265        let mut candidate = Self {
266            id: String::new(),
267            descriptor_id: descriptor_id.into(),
268            preference_rank,
269            profile: profile.into(),
270            authority_hash: authority_hash.into(),
271            credential_available,
272            enabled,
273        };
274        candidate.validate_material()?;
275        candidate.id = candidate.material_digest()?;
276        Ok(candidate)
277    }
278
279    /// Material-digests the candidate's profile-bound fields without the ID.
280    pub fn material_digest(&self) -> Result<String, RoutingError> {
281        Ok(hash_json(&self.material_value()))
282    }
283
284    /// Validates material fields and ensures the ID matches the material digest.
285    pub fn validate(&self) -> Result<(), RoutingError> {
286        self.validate_material()?;
287        if !valid_digest(&self.id) || self.id != self.material_digest()? {
288            return Err(RoutingError::CandidateDigest(self.id.clone()));
289        }
290        Ok(())
291    }
292
293    fn material_value(&self) -> serde_json::Value {
294        serde_json::json!({
295            "descriptorId": self.descriptor_id,
296            "preferenceRank": self.preference_rank,
297            "profile": self.profile,
298            "authorityHash": self.authority_hash,
299            "credentialAvailable": self.credential_available,
300            "enabled": self.enabled,
301        })
302    }
303
304    fn validate_material(&self) -> Result<(), RoutingError> {
305        if self.profile.trim().is_empty()
306            || !valid_digest(&self.descriptor_id)
307            || !valid_digest(&self.authority_hash)
308        {
309            return Err(RoutingError::InvalidCandidate(self.profile.clone()));
310        }
311        Ok(())
312    }
313}
314
315/// Runtime-owned requirements and hard budgets for one model decision.
316#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
317#[serde(rename_all = "camelCase", deny_unknown_fields)]
318pub struct RoutingRequirements {
319    /// Capabilities the route must support (e.g. streaming, tools).
320    #[serde(default)]
321    pub required_capabilities: BTreeSet<RoutingCapability>,
322    /// Minimum context window any eligible candidate must offer.
323    pub min_context_tokens: u64,
324    /// Max input token budget for the route decision.
325    pub max_input_tokens: u64,
326    /// Reserved output token budget reserved for responses.
327    pub reserved_output_tokens: u64,
328    /// Optional maximum estimated cost in micro-USD.
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub max_estimated_cost_microusd: Option<u64>,
331    /// Optional maximum accepted latency in milliseconds.
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub max_latency_ms: Option<u64>,
334    /// When true, pricing-unknown candidates are excluded.
335    #[serde(default)]
336    pub require_known_pricing: bool,
337    /// When true, latency-unknown candidates are excluded.
338    #[serde(default)]
339    pub require_known_latency: bool,
340}
341
342impl RoutingRequirements {
343    /// Rejects zero or negative budgets and missing required values.
344    pub fn validate(&self) -> Result<(), RoutingError> {
345        if self.min_context_tokens == 0
346            || self.max_input_tokens == 0
347            || self.reserved_output_tokens == 0
348            || self.reserved_output_tokens == u64::MAX
349            || self.max_estimated_cost_microusd == Some(0)
350            || self.max_latency_ms == Some(0)
351        {
352            return Err(RoutingError::InvalidRequirements);
353        }
354        Ok(())
355    }
356}
357
358/// State and authority bindings that make a routing decision non-replayable.
359#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
360#[serde(rename_all = "camelCase", deny_unknown_fields)]
361pub struct RoutingBindings {
362    /// Hash of the full routing contract payload.
363    pub contract_hash: String,
364    /// Hash of the policy applied during routing.
365    pub policy_hash: String,
366    /// Optional hash of the tool-calling authority when present.
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub tool_authority_hash: Option<String>,
369    /// Content-addressed digest of the model catalog.
370    pub catalog_hash: String,
371    /// Hash of the run-controls configuration.
372    pub run_controls_hash: String,
373    /// Monotonic step counter for sequential provider invocations.
374    pub step: u64,
375    /// Monotonic workspace generation for replay protection.
376    pub workspace_generation: u64,
377    /// Opaque state binding that pins this decision to prior outputs.
378    pub state_binding: String,
379    /// Monotonic watermark tracking side effects to prevent replay.
380    pub side_effect_watermark: u64,
381    /// Optional candidate ID locked for the entire step.
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub locked_candidate_id: Option<String>,
384}
385
386impl RoutingBindings {
387    fn validate(&self) -> Result<(), RoutingError> {
388        for digest in [
389            Some(self.contract_hash.as_str()),
390            Some(self.policy_hash.as_str()),
391            self.tool_authority_hash.as_deref(),
392            Some(self.catalog_hash.as_str()),
393            Some(self.run_controls_hash.as_str()),
394            Some(self.state_binding.as_str()),
395            self.locked_candidate_id.as_deref(),
396        ]
397        .into_iter()
398        .flatten()
399        {
400            if !valid_digest(digest) {
401                return Err(RoutingError::InvalidBinding(digest.to_owned()));
402            }
403        }
404        Ok(())
405    }
406}
407
408/// Stable reason why a candidate cannot execute.
409#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
410#[serde(rename_all = "snake_case")]
411pub enum RoutingExclusionReason {
412    /// Candidate `enabled` is `false`.
413    Disabled,
414    /// A required credential could not be resolved.
415    CredentialUnavailable,
416    /// A required `RoutingCapability` is not advertised.
417    MissingCapability,
418    /// The candidate's context window is smaller than `min_context_tokens`.
419    ContextWindowTooSmall,
420    /// The candidate has no pricing data and `require_known_pricing` is true.
421    PricingUnknown,
422    /// The candidate's estimated cost exceeds `max_estimated_cost_microusd`.
423    CostBudgetExceeded,
424    /// The candidate has no latency estimate and `require_known_latency` is true.
425    LatencyUnknown,
426    /// The candidate's estimated latency exceeds `max_latency_ms`.
427    LatencyBudgetExceeded,
428    /// The step has a locked candidate that differs from this candidate.
429    SessionRouteLocked,
430    /// A fallback is blocked because a side effect was already recorded.
431    FallbackBlockedAfterSideEffect,
432}
433
434/// Deterministic reducer output for one candidate.
435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
436#[serde(rename_all = "camelCase", deny_unknown_fields)]
437pub struct RoutingSelection {
438    /// The candidate evaluated by the reducer.
439    pub candidate: RoutingCandidate,
440    /// Whether this candidate is eligible for execution.
441    pub eligible: bool,
442    /// Reasons for exclusion when `eligible` is `false`.
443    #[serde(default)]
444    pub reasons: BTreeSet<RoutingExclusionReason>,
445    /// Required capabilities this candidate lacks.
446    #[serde(default)]
447    pub missing_capabilities: BTreeSet<RoutingCapability>,
448    /// Pre-computed cost estimate for this candidate.
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub estimated_max_cost_microusd: Option<u64>,
451}
452
453/// Proof-carrying deterministic route plan for exactly one provider step.
454#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
455#[serde(rename_all = "camelCase", deny_unknown_fields)]
456pub struct RoutingPlan {
457    /// Schema version enforced against `SCHEMA_VERSION`.
458    pub schema_version: String,
459    /// Algorithm version enforced against `ROUTING_ALGORITHM_VERSION`.
460    pub algorithm_version: String,
461    /// Embedded model catalog governing candidate descriptors.
462    pub catalog: ModelCatalog,
463    /// Hard budgets and requirements for the route decision.
464    pub requirements: RoutingRequirements,
465    /// Non-replayability bindings including state and side-effect watermarks.
466    pub bindings: RoutingBindings,
467    /// Deterministic reduction results, one per candidate, in preference order.
468    pub selections: Vec<RoutingSelection>,
469}
470
471impl RoutingPlan {
472    /// Validates all inputs, builds candidate selections, and constructs the plan.
473    pub fn build(
474        catalog: ModelCatalog,
475        requirements: RoutingRequirements,
476        bindings: RoutingBindings,
477        mut candidates: Vec<RoutingCandidate>,
478    ) -> Result<Self, RoutingError> {
479        catalog.validate()?;
480        requirements.validate()?;
481        bindings.validate()?;
482        if catalog.digest()? != bindings.catalog_hash {
483            return Err(RoutingError::CatalogBinding);
484        }
485        candidates.sort_by(|left, right| {
486            (left.preference_rank, &left.id).cmp(&(right.preference_rank, &right.id))
487        });
488        validate_candidate_set(
489            &catalog,
490            &candidates,
491            bindings.locked_candidate_id.as_deref(),
492        )?;
493        let selections = compute_selections(&catalog, &requirements, &bindings, candidates)?;
494        let plan = Self {
495            schema_version: SCHEMA_VERSION.to_owned(),
496            algorithm_version: ROUTING_ALGORITHM_VERSION.to_owned(),
497            catalog,
498            requirements,
499            bindings,
500            selections,
501        };
502        plan.validate()?;
503        Ok(plan)
504    }
505
506    /// Validates schema, algorithm, catalog, requirements, bindings, and determinism.
507    pub fn validate(&self) -> Result<(), RoutingError> {
508        if self.schema_version != SCHEMA_VERSION {
509            return Err(RoutingError::UnsupportedSchema(self.schema_version.clone()));
510        }
511        if self.algorithm_version != ROUTING_ALGORITHM_VERSION {
512            return Err(RoutingError::UnsupportedAlgorithm(
513                self.algorithm_version.clone(),
514            ));
515        }
516        self.catalog.validate()?;
517        self.requirements.validate()?;
518        self.bindings.validate()?;
519        if self.catalog.digest()? != self.bindings.catalog_hash {
520            return Err(RoutingError::CatalogBinding);
521        }
522        let candidates = self
523            .selections
524            .iter()
525            .map(|selection| selection.candidate.clone())
526            .collect::<Vec<_>>();
527        validate_candidate_set(
528            &self.catalog,
529            &candidates,
530            self.bindings.locked_candidate_id.as_deref(),
531        )?;
532        let expected = compute_selections(
533            &self.catalog,
534            &self.requirements,
535            &self.bindings,
536            candidates,
537        )?;
538        if expected != self.selections {
539            return Err(RoutingError::SelectionMismatch);
540        }
541        Ok(())
542    }
543
544    /// Computes a canonical BLAKE3 digest over the validated plan JSON.
545    pub fn digest(&self) -> Result<String, RoutingError> {
546        self.validate()?;
547        let value = serde_json::to_value(self).map_err(|_| RoutingError::Serialization)?;
548        Ok(hash_json(&value))
549    }
550
551    /// Returns candidate IDs in preference order where `eligible` is `true`.
552    pub fn eligible_candidate_ids(&self) -> Vec<String> {
553        self.selections
554            .iter()
555            .filter(|selection| selection.eligible)
556            .map(|selection| selection.candidate.id.clone())
557            .collect()
558    }
559
560    /// Returns the ID of the single eligible candidate, or `None` if none.
561    pub fn selected_candidate_id(&self) -> Option<&str> {
562        self.selections
563            .iter()
564            .find(|selection| selection.eligible)
565            .map(|selection| selection.candidate.id.as_str())
566    }
567
568    /// Returns the smallest context boundary among candidates that may execute.
569    pub fn minimum_eligible_context_tokens(&self) -> Option<u64> {
570        let descriptors = self
571            .catalog
572            .descriptors
573            .iter()
574            .map(|descriptor| (descriptor.id.as_str(), descriptor.context_tokens))
575            .collect::<BTreeMap<_, _>>();
576        self.selections
577            .iter()
578            .filter(|selection| selection.eligible)
579            .filter_map(|selection| {
580                descriptors
581                    .get(selection.candidate.descriptor_id.as_str())
582                    .copied()
583            })
584            .min()
585    }
586}
587
588fn validate_candidate_set(
589    catalog: &ModelCatalog,
590    candidates: &[RoutingCandidate],
591    locked_candidate_id: Option<&str>,
592) -> Result<(), RoutingError> {
593    if candidates.is_empty() {
594        return Err(RoutingError::EmptyCandidates);
595    }
596    let descriptors = catalog
597        .descriptors
598        .iter()
599        .map(|descriptor| descriptor.id.as_str())
600        .collect::<BTreeSet<_>>();
601    let mut ids = BTreeSet::new();
602    let mut previous = None;
603    for candidate in candidates {
604        candidate.validate()?;
605        if !descriptors.contains(candidate.descriptor_id.as_str()) {
606            return Err(RoutingError::UnknownDescriptor(
607                candidate.descriptor_id.clone(),
608            ));
609        }
610        if !ids.insert(candidate.id.as_str()) {
611            return Err(RoutingError::DuplicateCandidate(candidate.id.clone()));
612        }
613        let key = (candidate.preference_rank, candidate.id.as_str());
614        if previous.is_some_and(|prior| prior >= key) {
615            return Err(RoutingError::CandidateOrder);
616        }
617        previous = Some(key);
618    }
619    if let Some(locked) = locked_candidate_id
620        && !ids.contains(locked)
621    {
622        return Err(RoutingError::UnknownLockedCandidate(locked.to_owned()));
623    }
624    Ok(())
625}
626
627fn compute_selections(
628    catalog: &ModelCatalog,
629    requirements: &RoutingRequirements,
630    bindings: &RoutingBindings,
631    candidates: Vec<RoutingCandidate>,
632) -> Result<Vec<RoutingSelection>, RoutingError> {
633    let descriptors = catalog
634        .descriptors
635        .iter()
636        .map(|descriptor| (descriptor.id.as_str(), descriptor))
637        .collect::<BTreeMap<_, _>>();
638    let minimum_context_tokens = requirements
639        .min_context_tokens
640        .max(requirements.reserved_output_tokens + 1);
641    let mut selections = Vec::with_capacity(candidates.len());
642    for candidate in candidates {
643        let descriptor = descriptors
644            .get(candidate.descriptor_id.as_str())
645            .copied()
646            .ok_or_else(|| RoutingError::UnknownDescriptor(candidate.descriptor_id.clone()))?;
647        let missing_capabilities = requirements
648            .required_capabilities
649            .iter()
650            .copied()
651            .filter(|capability| !capability.is_supported_by(&descriptor.capabilities))
652            .collect::<BTreeSet<_>>();
653        let estimated_max_cost_microusd = descriptor
654            .pricing
655            .as_ref()
656            .map(|pricing| {
657                pricing.estimate(
658                    requirements.max_input_tokens,
659                    requirements.reserved_output_tokens,
660                )
661            })
662            .transpose()?;
663        let mut reasons = BTreeSet::new();
664        if !candidate.enabled {
665            reasons.insert(RoutingExclusionReason::Disabled);
666        }
667        if !candidate.credential_available {
668            reasons.insert(RoutingExclusionReason::CredentialUnavailable);
669        }
670        if !missing_capabilities.is_empty() {
671            reasons.insert(RoutingExclusionReason::MissingCapability);
672        }
673        if descriptor.context_tokens < minimum_context_tokens {
674            reasons.insert(RoutingExclusionReason::ContextWindowTooSmall);
675        }
676        if requirements.require_known_pricing && descriptor.pricing.is_none() {
677            reasons.insert(RoutingExclusionReason::PricingUnknown);
678        }
679        if let Some(maximum) = requirements.max_estimated_cost_microusd {
680            match estimated_max_cost_microusd {
681                Some(estimate) if estimate > maximum => {
682                    reasons.insert(RoutingExclusionReason::CostBudgetExceeded);
683                }
684                None => {
685                    reasons.insert(RoutingExclusionReason::PricingUnknown);
686                }
687                Some(_) => {}
688            }
689        }
690        if requirements.require_known_latency && descriptor.expected_latency_ms.is_none() {
691            reasons.insert(RoutingExclusionReason::LatencyUnknown);
692        }
693        if let Some(maximum) = requirements.max_latency_ms {
694            match descriptor.expected_latency_ms {
695                Some(estimate) if estimate > maximum => {
696                    reasons.insert(RoutingExclusionReason::LatencyBudgetExceeded);
697                }
698                None => {
699                    reasons.insert(RoutingExclusionReason::LatencyUnknown);
700                }
701                Some(_) => {}
702            }
703        }
704        if let Some(locked) = bindings.locked_candidate_id.as_deref()
705            && candidate.id != locked
706        {
707            reasons.insert(RoutingExclusionReason::SessionRouteLocked);
708        }
709        selections.push(RoutingSelection {
710            candidate,
711            eligible: reasons.is_empty(),
712            reasons,
713            missing_capabilities,
714            estimated_max_cost_microusd,
715        });
716    }
717
718    if bindings.locked_candidate_id.is_none() && bindings.side_effect_watermark > 0 {
719        let mut retained_one = false;
720        for selection in &mut selections {
721            if selection.eligible && !retained_one {
722                retained_one = true;
723            } else if selection.eligible {
724                selection
725                    .reasons
726                    .insert(RoutingExclusionReason::FallbackBlockedAfterSideEffect);
727                selection.eligible = false;
728            }
729        }
730    }
731    Ok(selections)
732}
733
734/// Outcome of one provider attempt committed by a routing receipt.
735#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
736#[serde(rename_all = "snake_case")]
737pub enum RoutingAttemptOutcome {
738    /// The attempt failed before producing any visible output.
739    FailedBeforeVisibleOutput,
740    /// The attempt failed after producing visible output.
741    FailedAfterVisibleOutput,
742    /// The attempt completed successfully.
743    Succeeded,
744    /// Runtime cancellation interrupted this attempt.
745    Cancelled,
746}
747
748/// One secret-free attempt in exact execution order.
749#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
750#[serde(rename_all = "camelCase", deny_unknown_fields)]
751pub struct RoutingAttemptReceipt {
752    /// ID of the candidate this attempt targeted.
753    pub candidate_id: String,
754    /// Outcome of the provider attempt.
755    pub outcome: RoutingAttemptOutcome,
756    /// Hash of the provider error, when `outcome` is not `Succeeded`.
757    #[serde(skip_serializing_if = "Option::is_none")]
758    pub error_hash: Option<String>,
759}
760
761/// Terminal reducer state for one provider step.
762#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
763#[serde(rename_all = "snake_case")]
764pub enum RoutingReceiptOutcome {
765    /// An eligible candidate was selected and succeeded.
766    Selected,
767    /// All eligible candidates failed before producing visible output.
768    Exhausted,
769    /// No eligible candidate could execute (empty eligible set or blocked).
770    Blocked,
771    /// The runtime stopped before invoking an otherwise eligible candidate.
772    NotInvoked,
773    /// Cooperative cancellation interrupted route execution.
774    Cancelled,
775    /// Recovery found a provider attempt without a durable terminal receipt.
776    Ambiguous,
777}
778
779/// Deterministic execution receipt bound to exactly one routing plan.
780#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
781#[serde(rename_all = "camelCase", deny_unknown_fields)]
782pub struct RoutingReceipt {
783    /// Schema version enforced against `SCHEMA_VERSION`.
784    pub schema_version: String,
785    /// Algorithm version enforced against `ROUTING_ALGORITHM_VERSION`.
786    pub algorithm_version: String,
787    /// Content-addressed digest of the bound `RoutingPlan`.
788    pub plan_hash: String,
789    /// Monotonic step counter matching the plan bindings.
790    pub step: u64,
791    /// Monotonic workspace generation matching the plan bindings.
792    pub workspace_generation: u64,
793    /// State binding matching the plan bindings.
794    pub state_binding: String,
795    /// Side-effect watermark matching the plan bindings.
796    pub side_effect_watermark: u64,
797    /// Provider attempts in exact execution order.
798    pub attempts: Vec<RoutingAttemptReceipt>,
799    /// The ID of the candidate that succeeded, when `outcome` is `Selected`.
800    #[serde(skip_serializing_if = "Option::is_none")]
801    pub selected_candidate_id: Option<String>,
802    /// Terminal reducer state for the step.
803    pub outcome: RoutingReceiptOutcome,
804}
805
806impl RoutingReceipt {
807    /// Constructs a receipt, validates it against the plan, and verifies all invariants.
808    pub fn new(
809        plan: &RoutingPlan,
810        attempts: Vec<RoutingAttemptReceipt>,
811        selected_candidate_id: Option<String>,
812        outcome: RoutingReceiptOutcome,
813    ) -> Result<Self, RoutingError> {
814        let receipt = Self {
815            schema_version: SCHEMA_VERSION.to_owned(),
816            algorithm_version: ROUTING_ALGORITHM_VERSION.to_owned(),
817            plan_hash: plan.digest()?,
818            step: plan.bindings.step,
819            workspace_generation: plan.bindings.workspace_generation,
820            state_binding: plan.bindings.state_binding.clone(),
821            side_effect_watermark: plan.bindings.side_effect_watermark,
822            attempts,
823            selected_candidate_id,
824            outcome,
825        };
826        receipt.validate(plan)?;
827        Ok(receipt)
828    }
829
830    /// Validates schema, algorithm, plan binding, attempt sequence, and outcome consistency.
831    pub fn validate(&self, plan: &RoutingPlan) -> Result<(), RoutingError> {
832        plan.validate()?;
833        if self.schema_version != SCHEMA_VERSION {
834            return Err(RoutingError::UnsupportedSchema(self.schema_version.clone()));
835        }
836        if self.algorithm_version != ROUTING_ALGORITHM_VERSION {
837            return Err(RoutingError::UnsupportedAlgorithm(
838                self.algorithm_version.clone(),
839            ));
840        }
841        if self.plan_hash != plan.digest()?
842            || self.step != plan.bindings.step
843            || self.workspace_generation != plan.bindings.workspace_generation
844            || self.state_binding != plan.bindings.state_binding
845            || self.side_effect_watermark != plan.bindings.side_effect_watermark
846        {
847            return Err(RoutingError::ReceiptBinding);
848        }
849        let eligible = plan.eligible_candidate_ids();
850        if self.attempts.len() > eligible.len() {
851            return Err(RoutingError::ReceiptAttempts);
852        }
853        for (index, attempt) in self.attempts.iter().enumerate() {
854            if eligible.get(index) != Some(&attempt.candidate_id)
855                || !valid_digest(&attempt.candidate_id)
856            {
857                return Err(RoutingError::ReceiptAttempts);
858            }
859            match attempt.outcome {
860                RoutingAttemptOutcome::Succeeded => {
861                    if attempt.error_hash.is_some() || index + 1 != self.attempts.len() {
862                        return Err(RoutingError::ReceiptAttempts);
863                    }
864                }
865                RoutingAttemptOutcome::FailedBeforeVisibleOutput
866                | RoutingAttemptOutcome::FailedAfterVisibleOutput
867                | RoutingAttemptOutcome::Cancelled => {
868                    if !attempt.error_hash.as_deref().is_some_and(valid_digest) {
869                        return Err(RoutingError::ReceiptAttempts);
870                    }
871                }
872            }
873        }
874        let last = self.attempts.last().map(|attempt| attempt.outcome);
875        match self.outcome {
876            RoutingReceiptOutcome::Selected => {
877                let Some(selected) = self.selected_candidate_id.as_deref() else {
878                    return Err(RoutingError::ReceiptOutcome);
879                };
880                if last != Some(RoutingAttemptOutcome::Succeeded)
881                    || self
882                        .attempts
883                        .last()
884                        .map(|attempt| attempt.candidate_id.as_str())
885                        != Some(selected)
886                {
887                    return Err(RoutingError::ReceiptOutcome);
888                }
889            }
890            RoutingReceiptOutcome::Exhausted => {
891                if self.selected_candidate_id.is_some()
892                    || self.attempts.len() != eligible.len()
893                    || self.attempts.iter().any(|attempt| {
894                        attempt.outcome != RoutingAttemptOutcome::FailedBeforeVisibleOutput
895                    })
896                {
897                    return Err(RoutingError::ReceiptOutcome);
898                }
899            }
900            RoutingReceiptOutcome::Blocked => {
901                let blocked_without_attempt = eligible.is_empty() && self.attempts.is_empty();
902                let blocked_after_visible = last
903                    == Some(RoutingAttemptOutcome::FailedAfterVisibleOutput)
904                    && self.selected_candidate_id.is_none();
905                if !blocked_without_attempt && !blocked_after_visible {
906                    return Err(RoutingError::ReceiptOutcome);
907                }
908            }
909            RoutingReceiptOutcome::NotInvoked | RoutingReceiptOutcome::Ambiguous => {
910                if eligible.is_empty()
911                    || !self.attempts.is_empty()
912                    || self.selected_candidate_id.is_some()
913                {
914                    return Err(RoutingError::ReceiptOutcome);
915                }
916            }
917            RoutingReceiptOutcome::Cancelled => {
918                let cancelled_before_attempt = eligible.len() > self.attempts.len()
919                    && self.attempts.iter().all(|attempt| {
920                        attempt.outcome == RoutingAttemptOutcome::FailedBeforeVisibleOutput
921                    });
922                let cancelled_during_attempt = last == Some(RoutingAttemptOutcome::Cancelled);
923                if self.selected_candidate_id.is_some()
924                    || (!cancelled_before_attempt && !cancelled_during_attempt)
925                {
926                    return Err(RoutingError::ReceiptOutcome);
927                }
928            }
929        }
930        Ok(())
931    }
932
933    /// Computes a canonical BLAKE3 digest over the validated receipt JSON.
934    pub fn digest(&self, plan: &RoutingPlan) -> Result<String, RoutingError> {
935        self.validate(plan)?;
936        let value = serde_json::to_value(self).map_err(|_| RoutingError::Serialization)?;
937        Ok(hash_json(&value))
938    }
939}
940
941fn valid_digest(value: &str) -> bool {
942    value.len() == 64
943        && value
944            .bytes()
945            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
946}
947
948/// Routing contract or deterministic reduction failure.
949#[derive(Debug, Clone, Error, PartialEq, Eq)]
950pub enum RoutingError {
951    /// A routing plan, receipt, or catalog carries an unsupported `schema_version`.
952    #[error("unsupported routing schema: {0}")]
953    UnsupportedSchema(String),
954    /// A routing plan carries an unsupported `algorithm_version`.
955    #[error("unsupported routing algorithm: {0}")]
956    UnsupportedAlgorithm(String),
957    /// A model catalog carries an unsupported `catalog_version`.
958    #[error("unsupported model catalog: {0}")]
959    UnsupportedCatalog(String),
960    /// A routing identity field is empty when a non-empty value is required.
961    #[error("routing identity is empty")]
962    EmptyIdentity,
963    /// A model descriptor failed validation (e.g. invalid digest, empty provider).
964    #[error("invalid model descriptor: {0}")]
965    InvalidDescriptor(String),
966    /// The BLAKE3 digest in a model descriptor does not match its payload.
967    #[error("model descriptor digest is invalid: {0}")]
968    DescriptorDigest(String),
969    /// A model catalog was constructed with zero descriptors.
970    #[error("model catalog is empty")]
971    EmptyCatalog,
972    /// The model catalog descriptors are not strictly sorted by digest.
973    #[error("model catalog descriptors are not strictly digest-sorted")]
974    CatalogOrder,
975    /// A routing candidate failed validation (e.g. empty profile, invalid digests).
976    #[error("routing candidate is invalid: {0}")]
977    InvalidCandidate(String),
978    /// The routing candidate ID does not match the material digest.
979    #[error("routing candidate digest is invalid: {0}")]
980    CandidateDigest(String),
981    /// The routing candidate list is empty when at least one is required.
982    #[error("routing candidate list is empty")]
983    EmptyCandidates,
984    /// The routing candidates are not sorted in deterministic preference order.
985    #[error("routing candidates are not in deterministic preference order")]
986    CandidateOrder,
987    /// Two or more routing candidates share the same digest identity.
988    #[error("duplicate routing candidate: {0}")]
989    DuplicateCandidate(String),
990    /// A routing candidate references a descriptor ID absent from the catalog.
991    #[error("routing candidate references an unknown descriptor: {0}")]
992    UnknownDescriptor(String),
993    /// The locked candidate ID in bindings does not match any candidate.
994    #[error("locked routing candidate is absent: {0}")]
995    UnknownLockedCandidate(String),
996    /// Routing requirements failed validation (e.g. zero-valued budgets).
997    #[error("routing requirements are invalid")]
998    InvalidRequirements,
999    /// A binding field is not a valid BLAKE3 hex digest.
1000    #[error("routing binding is not a canonical digest: {0}")]
1001    InvalidBinding(String),
1002    /// The model catalog digest in bindings does not match the catalog's canonical digest.
1003    #[error("model catalog digest does not match the routing binding")]
1004    CatalogBinding,
1005    /// The selections computed during plan validation disagree with the stored selections.
1006    #[error("routing selections disagree with deterministic reduction")]
1007    SelectionMismatch,
1008    /// A routing cost estimate overflowed `u64`.
1009    #[error("routing cost estimate overflowed")]
1010    CostOverflow,
1011    /// Serde serialization of a routing structure failed.
1012    #[error("routing contract serialization failed")]
1013    Serialization,
1014    /// The receipt's binding fields do not match the plan's binding values.
1015    #[error("routing receipt does not match plan bindings")]
1016    ReceiptBinding,
1017    /// The receipt attempt count, order, or per-attempt outcomes violate invariants.
1018    #[error("routing receipt attempt sequence is invalid")]
1019    ReceiptAttempts,
1020    /// The receipt terminal outcome is inconsistent with the attempt sequence.
1021    #[error("routing receipt outcome is inconsistent")]
1022    ReceiptOutcome,
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027    use super::*;
1028    use crate::hash_bytes;
1029
1030    fn descriptor(model: &str, tools: bool, price: Option<u64>) -> ModelDescriptor {
1031        ModelDescriptor::new(
1032            "mock",
1033            model,
1034            ProviderCapabilities {
1035                streaming: false,
1036                tools,
1037                structured_output: true,
1038                multimodal_input: false,
1039                context_tokens: Some(32_768),
1040            },
1041            32_768,
1042            RoutingContextSource::Provider,
1043            price.map(|rate| ModelPricing {
1044                input_microusd_per_million_tokens: rate,
1045                output_microusd_per_million_tokens: rate,
1046            }),
1047            Some(1_000),
1048        )
1049        .unwrap()
1050    }
1051
1052    fn plan(lock: Option<String>, side_effects: u64) -> RoutingPlan {
1053        let first = descriptor("a", true, Some(1_000));
1054        let second = descriptor("b", true, Some(2_000));
1055        let catalog = ModelCatalog::new(vec![second.clone(), first.clone()]).unwrap();
1056        let candidates = vec![
1057            RoutingCandidate::new(
1058                first.id.clone(),
1059                0,
1060                "primary",
1061                hash_bytes(b"primary-authority"),
1062                true,
1063                true,
1064            )
1065            .unwrap(),
1066            RoutingCandidate::new(
1067                second.id.clone(),
1068                1,
1069                "fallback",
1070                hash_bytes(b"fallback-authority"),
1071                true,
1072                true,
1073            )
1074            .unwrap(),
1075        ];
1076        let bindings = RoutingBindings {
1077            contract_hash: hash_bytes(b"contract"),
1078            policy_hash: hash_bytes(b"policy"),
1079            tool_authority_hash: None,
1080            catalog_hash: catalog.digest().unwrap(),
1081            run_controls_hash: hash_bytes(b"resume"),
1082            step: 0,
1083            workspace_generation: 0,
1084            state_binding: hash_bytes(b"workspace"),
1085            side_effect_watermark: side_effects,
1086            locked_candidate_id: lock,
1087        };
1088        RoutingPlan::build(
1089            catalog,
1090            RoutingRequirements {
1091                required_capabilities: [RoutingCapability::Tools].into_iter().collect(),
1092                min_context_tokens: 8_192,
1093                max_input_tokens: 8_192,
1094                reserved_output_tokens: 1_024,
1095                max_estimated_cost_microusd: Some(20),
1096                max_latency_ms: Some(2_000),
1097                require_known_pricing: true,
1098                require_known_latency: true,
1099            },
1100            bindings,
1101            candidates,
1102        )
1103        .unwrap()
1104    }
1105
1106    #[test]
1107    fn plan_is_canonical_and_content_addressed() {
1108        let first = plan(None, 0);
1109        let second = plan(None, 0);
1110        assert_eq!(first, second);
1111        assert_eq!(first.digest().unwrap(), second.digest().unwrap());
1112        assert_eq!(first.eligible_candidate_ids().len(), 2);
1113    }
1114
1115    #[test]
1116    fn side_effects_and_route_lock_remove_fallbacks() {
1117        let side_effect_plan = plan(None, 1);
1118        assert_eq!(side_effect_plan.eligible_candidate_ids().len(), 1);
1119        assert!(
1120            side_effect_plan.selections[1]
1121                .reasons
1122                .contains(&RoutingExclusionReason::FallbackBlockedAfterSideEffect)
1123        );
1124        let lock = side_effect_plan.selections[1].candidate.id.clone();
1125        let locked = plan(Some(lock.clone()), 0);
1126        assert_eq!(locked.eligible_candidate_ids(), vec![lock]);
1127        assert!(
1128            locked.selections[0]
1129                .reasons
1130                .contains(&RoutingExclusionReason::SessionRouteLocked)
1131        );
1132    }
1133
1134    #[test]
1135    fn output_reserve_that_consumes_the_context_excludes_every_candidate() {
1136        let base = plan(None, 0);
1137        let candidates = base
1138            .selections
1139            .iter()
1140            .map(|selection| selection.candidate.clone())
1141            .collect();
1142        let routed = RoutingPlan::build(
1143            base.catalog,
1144            RoutingRequirements {
1145                min_context_tokens: 1,
1146                max_input_tokens: 1,
1147                reserved_output_tokens: 32_768,
1148                ..base.requirements
1149            },
1150            base.bindings,
1151            candidates,
1152        )
1153        .unwrap();
1154
1155        assert!(routed.eligible_candidate_ids().is_empty());
1156        assert!(routed.selections.iter().all(|selection| {
1157            selection
1158                .reasons
1159                .contains(&RoutingExclusionReason::ContextWindowTooSmall)
1160        }));
1161    }
1162
1163    #[test]
1164    fn priced_budget_excludes_unknown_and_expensive_models() {
1165        let cheap = descriptor("cheap", true, Some(1));
1166        let unknown = descriptor("unknown", true, None);
1167        let expensive = descriptor("expensive", true, Some(1_000_000));
1168        let catalog =
1169            ModelCatalog::new(vec![cheap.clone(), unknown.clone(), expensive.clone()]).unwrap();
1170        let candidates = [cheap, unknown, expensive]
1171            .into_iter()
1172            .enumerate()
1173            .map(|(rank, descriptor)| {
1174                RoutingCandidate::new(
1175                    descriptor.id,
1176                    u32::try_from(rank).unwrap(),
1177                    format!("p{rank}"),
1178                    hash_bytes(format!("authority-{rank}")),
1179                    true,
1180                    true,
1181                )
1182                .unwrap()
1183            })
1184            .collect();
1185        let bindings = RoutingBindings {
1186            contract_hash: hash_bytes(b"contract"),
1187            policy_hash: hash_bytes(b"policy"),
1188            tool_authority_hash: None,
1189            catalog_hash: catalog.digest().unwrap(),
1190            run_controls_hash: hash_bytes(b"resume"),
1191            step: 0,
1192            workspace_generation: 0,
1193            state_binding: hash_bytes(b"workspace"),
1194            side_effect_watermark: 0,
1195            locked_candidate_id: None,
1196        };
1197        let routed = RoutingPlan::build(
1198            catalog,
1199            RoutingRequirements {
1200                required_capabilities: BTreeSet::new(),
1201                min_context_tokens: 1,
1202                max_input_tokens: 10_000,
1203                reserved_output_tokens: 1_000,
1204                max_estimated_cost_microusd: Some(100),
1205                max_latency_ms: None,
1206                require_known_pricing: true,
1207                require_known_latency: false,
1208            },
1209            bindings,
1210            candidates,
1211        )
1212        .unwrap();
1213        assert_eq!(routed.eligible_candidate_ids().len(), 1);
1214        assert!(routed.selections.iter().any(|selection| {
1215            selection
1216                .reasons
1217                .contains(&RoutingExclusionReason::PricingUnknown)
1218        }));
1219        assert!(routed.selections.iter().any(|selection| {
1220            selection
1221                .reasons
1222                .contains(&RoutingExclusionReason::CostBudgetExceeded)
1223        }));
1224    }
1225
1226    #[test]
1227    fn receipt_commits_exact_attempt_prefix_and_selection() {
1228        let plan = plan(None, 0);
1229        let eligible = plan.eligible_candidate_ids();
1230        let receipt = RoutingReceipt::new(
1231            &plan,
1232            vec![
1233                RoutingAttemptReceipt {
1234                    candidate_id: eligible[0].clone(),
1235                    outcome: RoutingAttemptOutcome::FailedBeforeVisibleOutput,
1236                    error_hash: Some(hash_bytes(b"transport")),
1237                },
1238                RoutingAttemptReceipt {
1239                    candidate_id: eligible[1].clone(),
1240                    outcome: RoutingAttemptOutcome::Succeeded,
1241                    error_hash: None,
1242                },
1243            ],
1244            Some(eligible[1].clone()),
1245            RoutingReceiptOutcome::Selected,
1246        )
1247        .unwrap();
1248        receipt.validate(&plan).unwrap();
1249        assert!(valid_digest(&receipt.digest(&plan).unwrap()));
1250    }
1251
1252    #[test]
1253    fn plan_and_receipt_tampering_fail_closed() {
1254        let mut routed = plan(None, 0);
1255        routed.selections[0].eligible = false;
1256        assert_eq!(routed.validate(), Err(RoutingError::SelectionMismatch));
1257
1258        let routed = plan(None, 0);
1259        let error =
1260            RoutingReceipt::new(&routed, Vec::new(), None, RoutingReceiptOutcome::Exhausted)
1261                .unwrap_err();
1262        assert_eq!(error, RoutingError::ReceiptOutcome);
1263
1264        let mut unknown_field = serde_json::to_value(&routed).unwrap();
1265        unknown_field["uncommittedDiagnostic"] = serde_json::json!(true);
1266        assert!(serde_json::from_value::<RoutingPlan>(unknown_field).is_err());
1267    }
1268}