Skip to main content

lean_ctx_protocol/
edge_via.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! LeanCTX Edge↔Via protocol V1 core contracts.
4//!
5//! These metadata-first types intentionally contain no provider request,
6//! header, cookie, credential, or arbitrary JSON-patch field. Edge validates
7//! and executes typed plans; Via never receives authority to rewrite an opaque
8//! provider request.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::fmt::{self, Write as _};
12
13use chrono::DateTime;
14use serde::{
15    Deserialize, Deserializer, Serialize,
16    de::{Error as DeError, MapAccess, SeqAccess, Visitor},
17};
18use serde_json::{Map, Value};
19use sha2::{Digest, Sha256};
20
21use crate::{
22    AgentId, CapabilityId, EventId, PlanId, ProjectId, ProtocolReference, SemanticVersion,
23    SessionId, Sha256Digest, UtcTimestamp, ValidationError, WorkspaceId,
24};
25
26pub const VIA_PROTOCOL_V1: u16 = 1;
27pub const MAX_VIA_CAUSALITY: usize = 32;
28pub const MAX_VIA_CAPABILITIES: usize = 64;
29pub const MAX_VIA_CONTENT_ITEMS: usize = 4_096;
30pub const MAX_VIA_PLAN_SOURCES: usize = 64;
31pub const MAX_VIA_FRAME_BYTES: usize = 65_536;
32pub const MAX_VIA_MANIFEST_BYTES: usize = 262_144;
33pub const MAX_VIA_PLAN_BYTES: usize = 65_536;
34pub const MAX_VIA_WIRE_BYTES: usize = 262_656;
35pub const MAX_VIA_OUTPUT_TOKENS: u64 = 1_000_000;
36pub const MAX_VIA_PLAN_TTL_SECONDS: u64 = 300;
37pub const MAX_VIA_ACK_EVENTS: u64 = 1_024;
38pub const MAX_VIA_ACK_GRAPH_NODES: u64 = 4_096;
39pub const MAX_VIA_ACK_GRAPH_EDGES: u64 = 8_192;
40pub const MAX_VIA_STREAM_WINDOW: u16 = 32;
41pub const MAX_VIA_BACKPRESSURE_RETRY_MS: u32 = 30_000;
42pub const MAX_VIA_CONTENT_CHUNKS_PER_REQUEST: usize = 4;
43pub const MAX_VIA_CONTENT_CHUNK_BYTES: usize = 16 * 1_024;
44pub const MAX_VIA_CONTENT_REQUEST_BYTES: u64 = 64 * 1_024;
45pub const MAX_VIA_CONTENT_REQUEST_TOKENS: u64 = 16_384;
46pub const MAX_VIA_CONTENT_REQUEST_TTL_SECONDS: u64 = 30;
47pub const MAX_VIA_INFERENCE_SEGMENTS: usize = 128;
48pub const MAX_VIA_INFERENCE_LINEAGE: usize = 8;
49pub const MAX_VIA_INFERENCE_MANIFEST_BYTES: usize = 49_152;
50pub const MAX_VIA_INFERENCE_CANDIDATES: usize = 64;
51pub const MAX_VIA_INFERENCE_CANDIDATE_SET_BYTES: usize = 65_536;
52pub const MAX_VIA_PLAN_REQUEST_BYTES: usize = 65_536;
53
54fn deserialize_via_protocol_version<'de, D>(deserializer: D) -> Result<u16, D::Error>
55where
56    D: Deserializer<'de>,
57{
58    let version = u16::deserialize(deserializer)?;
59    if version == VIA_PROTOCOL_V1 {
60        Ok(version)
61    } else {
62        Err(DeError::custom(format!(
63            "unsupported Via protocol_version {version}; expected {VIA_PROTOCOL_V1}"
64        )))
65    }
66}
67
68fn validate_protocol_version(version: u16) -> Result<(), ValidationError> {
69    if version == VIA_PROTOCOL_V1 {
70        Ok(())
71    } else {
72        Err(ValidationError::new(format!(
73            "unsupported Via protocol_version {version}; expected {VIA_PROTOCOL_V1}"
74        )))
75    }
76}
77
78fn sha256(bytes: &[u8]) -> Result<Sha256Digest, ValidationError> {
79    let mut value = String::with_capacity(71);
80    value.push_str("sha256:");
81    for byte in Sha256::digest(bytes) {
82        write!(&mut value, "{byte:02x}").expect("writing to String cannot fail");
83    }
84    Sha256Digest::new(value)
85}
86
87fn canonical_json_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, ValidationError> {
88    let value = serde_json::to_value(value)
89        .map_err(|error| ValidationError::new(format!("serialize canonical JSON: {error}")))?;
90    serde_json::to_vec(&canonical_json_value(value))
91        .map_err(|error| ValidationError::new(format!("encode canonical JSON: {error}")))
92}
93
94fn canonical_json_value(value: serde_json::Value) -> serde_json::Value {
95    match value {
96        serde_json::Value::Object(fields) => {
97            let sorted = fields
98                .into_iter()
99                .map(|(key, value)| (key, canonical_json_value(value)))
100                .collect::<BTreeMap<_, _>>();
101            serde_json::Value::Object(sorted.into_iter().collect())
102        }
103        serde_json::Value::Array(values) => {
104            serde_json::Value::Array(values.into_iter().map(canonical_json_value).collect())
105        }
106        scalar => scalar,
107    }
108}
109
110fn validate_content_ttl(
111    now: &UtcTimestamp,
112    expires_at: &UtcTimestamp,
113) -> Result<(), ValidationError> {
114    let now = DateTime::parse_from_rfc3339(now.as_str())
115        .map_err(|_| ValidationError::new("content request time is invalid"))?;
116    let expires_at = DateTime::parse_from_rfc3339(expires_at.as_str())
117        .map_err(|_| ValidationError::new("content request expiry is invalid"))?;
118    let seconds = expires_at.signed_duration_since(now).num_seconds();
119    if !(1..=MAX_VIA_CONTENT_REQUEST_TTL_SECONDS as i64).contains(&seconds) {
120        return Err(ValidationError::new(
121            "content request TTL must be active and at most 30 seconds",
122        ));
123    }
124    Ok(())
125}
126
127fn validate_plan_ttl(now: &UtcTimestamp, expires_at: &UtcTimestamp) -> Result<(), ValidationError> {
128    let now = DateTime::parse_from_rfc3339(now.as_str())
129        .map_err(|_| ValidationError::new("plan time is invalid"))?;
130    let expires_at = DateTime::parse_from_rfc3339(expires_at.as_str())
131        .map_err(|_| ValidationError::new("plan expiry is invalid"))?;
132    let seconds = expires_at.signed_duration_since(now).num_seconds();
133    if !(1..=MAX_VIA_PLAN_TTL_SECONDS as i64).contains(&seconds) {
134        return Err(ValidationError::new(
135            "plan TTL must be active and at most 300 seconds",
136        ));
137    }
138    Ok(())
139}
140
141fn validate_unique<T: Ord + Clone>(
142    values: &[T],
143    maximum: usize,
144    field: &str,
145) -> Result<(), ValidationError> {
146    if values.len() > maximum {
147        return Err(ValidationError::new(format!(
148            "{field} exceeds the {maximum} item limit"
149        )));
150    }
151    let unique = values.iter().cloned().collect::<BTreeSet<_>>();
152    if unique.len() != values.len() {
153        return Err(ValidationError::new(format!(
154            "{field} must not contain duplicates"
155        )));
156    }
157    Ok(())
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum ViaClassificationV1 {
163    Public,
164    Normal,
165    Sensitive,
166    Secret,
167    LocalOnly,
168    EnterprisePrivate,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(deny_unknown_fields)]
173pub struct ViaScopeV1 {
174    #[serde(deserialize_with = "deserialize_via_protocol_version")]
175    pub protocol_version: u16,
176    pub event_or_request_id: ProtocolReference,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub causality_id: Option<ProtocolReference>,
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub user_id: Option<ProtocolReference>,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub device_id: Option<ProtocolReference>,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub project_id: Option<ProjectId>,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub workspace_id: Option<WorkspaceId>,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub session_id: Option<SessionId>,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub conversation_id: Option<ProtocolReference>,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub agent_id: Option<AgentId>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub team_id: Option<ProtocolReference>,
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub organization_id: Option<ProtocolReference>,
197    pub classification: ViaClassificationV1,
198}
199
200impl ViaScopeV1 {
201    pub fn validate(&self) -> Result<(), ValidationError> {
202        validate_protocol_version(self.protocol_version)
203    }
204
205    pub fn digest(&self) -> Result<Sha256Digest, ValidationError> {
206        self.validate()?;
207        let bytes = serde_json::to_vec(self)
208            .map_err(|error| ValidationError::new(format!("serialize Via scope: {error}")))?;
209        sha256(&bytes)
210    }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
214#[serde(rename_all = "snake_case")]
215pub enum ViaCapabilityV1 {
216    EventStream,
217    ContentManifest,
218    ContentOnDemand,
219    TypedPlans,
220    MultiAgentIdentity,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(deny_unknown_fields)]
225pub struct ViaProtocolHelloV1 {
226    pub minimum_version: u16,
227    pub maximum_version: u16,
228    pub capabilities: Vec<ViaCapabilityV1>,
229}
230
231impl ViaProtocolHelloV1 {
232    pub fn validate(&self) -> Result<(), ValidationError> {
233        if self.minimum_version == 0 || self.minimum_version > self.maximum_version {
234            return Err(ValidationError::new(
235                "ViaProtocolHelloV1 version range must be nonzero and ordered",
236            ));
237        }
238        validate_unique(
239            &self.capabilities,
240            MAX_VIA_CAPABILITIES,
241            "ViaProtocolHelloV1 capabilities",
242        )
243    }
244
245    pub fn negotiate(&self, peer: &Self) -> Result<u16, ValidationError> {
246        self.validate()?;
247        peer.validate()?;
248        let minimum = self.minimum_version.max(peer.minimum_version);
249        let maximum = self.maximum_version.min(peer.maximum_version);
250        if minimum > maximum || !(minimum..=maximum).contains(&VIA_PROTOCOL_V1) {
251            return Err(ValidationError::new(
252                "Via protocol version ranges have no locally supported overlap",
253            ));
254        }
255        Ok(VIA_PROTOCOL_V1)
256    }
257
258    pub fn shared_capabilities(
259        &self,
260        peer: &Self,
261    ) -> Result<Vec<ViaCapabilityV1>, ValidationError> {
262        self.negotiate(peer)?;
263        let peer_capabilities = peer.capabilities.iter().copied().collect::<BTreeSet<_>>();
264        let mut shared = self
265            .capabilities
266            .iter()
267            .copied()
268            .filter(|capability| peer_capabilities.contains(capability))
269            .collect::<Vec<_>>();
270        shared.sort_unstable();
271        Ok(shared)
272    }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
276#[serde(deny_unknown_fields)]
277pub struct ScopedContentIdV1 {
278    pub scope_digest: Sha256Digest,
279    pub content_digest: Sha256Digest,
280}
281
282impl ScopedContentIdV1 {
283    /// Derive path-independent identity from exact bytes and canonical scope.
284    pub fn from_scope_and_bytes(
285        scope: &ViaScopeV1,
286        content: &[u8],
287    ) -> Result<Self, ValidationError> {
288        Ok(Self {
289            scope_digest: scope.digest()?,
290            content_digest: sha256(content)?,
291        })
292    }
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
296#[serde(deny_unknown_fields)]
297pub struct ScopedReferenceV1 {
298    pub scope_digest: Sha256Digest,
299    pub reference: ProtocolReference,
300}
301
302impl ScopedReferenceV1 {
303    pub fn new(scope: &ViaScopeV1, reference: ProtocolReference) -> Result<Self, ValidationError> {
304        Ok(Self {
305            scope_digest: scope.digest()?,
306            reference,
307        })
308    }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(rename_all = "snake_case")]
313pub enum ContentKindV1 {
314    SourceFile,
315    Message,
316    SearchResult,
317    ShellResult,
318    McpResult,
319    InferenceManifest,
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "snake_case")]
324pub enum ContentAvailabilityV1 {
325    EdgeOnly,
326    TransientRemoteAllowed,
327    DeniedRemote,
328}
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(deny_unknown_fields)]
332pub struct TokenCountV1 {
333    pub family: CapabilityId,
334    pub tokens: u64,
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
338#[serde(rename_all = "snake_case")]
339pub enum InferenceSegmentRoleV1 {
340    System,
341    Developer,
342    User,
343    Assistant,
344    ToolCall,
345    ToolResult,
346    ToolSchema,
347    RequestControl,
348}
349
350impl InferenceSegmentRoleV1 {
351    const fn discriminant(self) -> u8 {
352        match self {
353            Self::System => 0,
354            Self::Developer => 1,
355            Self::User => 2,
356            Self::Assistant => 3,
357            Self::ToolCall => 4,
358            Self::ToolResult => 5,
359            Self::ToolSchema => 6,
360            Self::RequestControl => 7,
361        }
362    }
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
366#[serde(rename_all = "snake_case")]
367pub enum InferenceMutabilityV1 {
368    Immutable,
369    Conditional,
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "snake_case")]
374pub enum InferenceDeliveryStateV1 {
375    CacheMarked,
376    Uncached,
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(rename_all = "snake_case")]
381pub enum InferenceTokenizerFamilyV1 {
382    Cl100kBaseApproxV1,
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
386#[serde(rename_all = "snake_case")]
387pub enum InferenceCachePolicyV1 {
388    AnthropicExplicitCacheControlV1,
389}
390
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(deny_unknown_fields)]
393pub struct InferenceSegmentV1 {
394    pub segment_id: Sha256Digest,
395    pub position: u16,
396    pub content_or_representation_id: ScopedContentIdV1,
397    pub role: InferenceSegmentRoleV1,
398    pub mutability: InferenceMutabilityV1,
399    pub guard_hash: Sha256Digest,
400    pub token_count: u64,
401    pub delivery_state: InferenceDeliveryStateV1,
402    #[serde(default, skip_serializing_if = "Vec::is_empty")]
403    pub source_lineage: Vec<ScopedContentIdV1>,
404}
405
406impl InferenceSegmentV1 {
407    #[allow(clippy::too_many_arguments)] // Mirrors the closed wire fields; no options bag or arbitrary JSON.
408    pub fn from_canonical_bytes(
409        scope: &ViaScopeV1,
410        position: u16,
411        role: InferenceSegmentRoleV1,
412        mutability: InferenceMutabilityV1,
413        canonical_bytes: &[u8],
414        token_count: u64,
415        delivery_state: InferenceDeliveryStateV1,
416        source_lineage: Vec<ScopedContentIdV1>,
417    ) -> Result<Self, ValidationError> {
418        let content_or_representation_id =
419            ScopedContentIdV1::from_scope_and_bytes(scope, canonical_bytes)?;
420        let guard_hash = content_or_representation_id.content_digest.clone();
421        let segment_id = inference_segment_id(scope, position, role, &guard_hash)?;
422        let segment = Self {
423            segment_id,
424            position,
425            content_or_representation_id,
426            role,
427            mutability,
428            guard_hash,
429            token_count,
430            delivery_state,
431            source_lineage,
432        };
433        segment.validate(scope, usize::from(position))?;
434        Ok(segment)
435    }
436
437    fn validate(
438        &self,
439        scope: &ViaScopeV1,
440        expected_position: usize,
441    ) -> Result<(), ValidationError> {
442        if usize::from(self.position) != expected_position {
443            return Err(ValidationError::new(
444                "InferenceSegmentV1 positions must be contiguous and ordered",
445            ));
446        }
447        let scope_digest = scope.digest()?;
448        if self.content_or_representation_id.scope_digest != scope_digest
449            || self
450                .source_lineage
451                .iter()
452                .any(|source| source.scope_digest != scope_digest)
453        {
454            return Err(ValidationError::new(
455                "InferenceSegmentV1 content and lineage IDs must match manifest scope",
456            ));
457        }
458        if self.guard_hash != self.content_or_representation_id.content_digest {
459            return Err(ValidationError::new(
460                "InferenceSegmentV1 guard_hash must match content identity",
461            ));
462        }
463        if self.source_lineage.len() > MAX_VIA_INFERENCE_LINEAGE {
464            return Err(ValidationError::new(format!(
465                "InferenceSegmentV1 source_lineage exceeds the {MAX_VIA_INFERENCE_LINEAGE} item limit"
466            )));
467        }
468        validate_unique(
469            &self.source_lineage,
470            MAX_VIA_INFERENCE_LINEAGE,
471            "InferenceSegmentV1 source_lineage",
472        )?;
473        if self.segment_id
474            != inference_segment_id(scope, self.position, self.role, &self.guard_hash)?
475        {
476            return Err(ValidationError::new(
477                "InferenceSegmentV1 segment_id does not match its canonical binding",
478            ));
479        }
480        Ok(())
481    }
482}
483
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
485#[serde(deny_unknown_fields)]
486pub struct InferenceAccountingV1 {
487    pub total_tokens: u64,
488    pub immutable_tokens: u64,
489    pub conditional_tokens: u64,
490    pub cache_marked_tokens: u64,
491    pub uncached_tokens: u64,
492}
493
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
495#[serde(deny_unknown_fields)]
496pub struct InferenceManifestV1 {
497    pub scope: ViaScopeV1,
498    pub manifest_id: ScopedContentIdV1,
499    pub tokenizer_family: InferenceTokenizerFamilyV1,
500    pub cache_policy: InferenceCachePolicyV1,
501    pub segments: Vec<InferenceSegmentV1>,
502    pub accounting: InferenceAccountingV1,
503    #[serde(default, skip_serializing_if = "Option::is_none")]
504    pub stable_prefix_digest: Option<Sha256Digest>,
505}
506
507#[derive(Serialize)]
508struct InferenceManifestIdentityV1<'a> {
509    accounting: &'a InferenceAccountingV1,
510    cache_policy: InferenceCachePolicyV1,
511    scope: &'a ViaScopeV1,
512    segments: &'a [InferenceSegmentV1],
513    stable_prefix_digest: &'a Option<Sha256Digest>,
514    tokenizer_family: InferenceTokenizerFamilyV1,
515}
516
517impl InferenceManifestV1 {
518    pub fn new(
519        scope: ViaScopeV1,
520        segments: Vec<InferenceSegmentV1>,
521        stable_prefix_digest: Option<Sha256Digest>,
522    ) -> Result<Self, ValidationError> {
523        let accounting = inference_accounting(&segments)?;
524        let placeholder = ScopedContentIdV1::from_scope_and_bytes(&scope, b"")?;
525        let mut manifest = Self {
526            scope,
527            manifest_id: placeholder,
528            tokenizer_family: InferenceTokenizerFamilyV1::Cl100kBaseApproxV1,
529            cache_policy: InferenceCachePolicyV1::AnthropicExplicitCacheControlV1,
530            segments,
531            accounting,
532            stable_prefix_digest,
533        };
534        manifest.manifest_id = manifest.expected_manifest_id()?;
535        manifest.validate()?;
536        Ok(manifest)
537    }
538
539    pub fn validate(&self) -> Result<(), ValidationError> {
540        self.scope.validate()?;
541        if self.segments.is_empty() || self.segments.len() > MAX_VIA_INFERENCE_SEGMENTS {
542            return Err(ValidationError::new(format!(
543                "InferenceManifestV1 segments must contain 1..={MAX_VIA_INFERENCE_SEGMENTS} items"
544            )));
545        }
546        let mut segment_ids = BTreeSet::new();
547        for (position, segment) in self.segments.iter().enumerate() {
548            segment.validate(&self.scope, position)?;
549            if !segment_ids.insert(segment.segment_id.clone()) {
550                return Err(ValidationError::new(
551                    "InferenceManifestV1 segment IDs must be unique",
552                ));
553            }
554        }
555        if self.accounting != inference_accounting(&self.segments)? {
556            return Err(ValidationError::new(
557                "InferenceManifestV1 accounting does not match segment tokens",
558            ));
559        }
560        if self.manifest_id != self.expected_manifest_id()? {
561            return Err(ValidationError::new(
562                "InferenceManifestV1 manifest_id does not match canonical fields",
563            ));
564        }
565        let bytes = serde_json::to_vec(self).map_err(|error| {
566            ValidationError::new(format!("serialize inference manifest: {error}"))
567        })?;
568        if bytes.len() > MAX_VIA_INFERENCE_MANIFEST_BYTES {
569            return Err(ValidationError::new(format!(
570                "InferenceManifestV1 exceeds the {MAX_VIA_INFERENCE_MANIFEST_BYTES} byte limit"
571            )));
572        }
573        Ok(())
574    }
575
576    fn expected_manifest_id(&self) -> Result<ScopedContentIdV1, ValidationError> {
577        let body = InferenceManifestIdentityV1 {
578            accounting: &self.accounting,
579            cache_policy: self.cache_policy,
580            scope: &self.scope,
581            segments: &self.segments,
582            stable_prefix_digest: &self.stable_prefix_digest,
583            tokenizer_family: self.tokenizer_family,
584        };
585        let encoded = canonical_json_bytes(&body)?;
586        let mut domain = b"leanctx.inference.manifest.v1\0".to_vec();
587        domain.extend_from_slice(&encoded);
588        ScopedContentIdV1::from_scope_and_bytes(&self.scope, &domain)
589    }
590}
591
592fn inference_accounting(
593    segments: &[InferenceSegmentV1],
594) -> Result<InferenceAccountingV1, ValidationError> {
595    let mut accounting = InferenceAccountingV1 {
596        total_tokens: 0,
597        immutable_tokens: 0,
598        conditional_tokens: 0,
599        cache_marked_tokens: 0,
600        uncached_tokens: 0,
601    };
602    for segment in segments {
603        accounting.total_tokens = accounting
604            .total_tokens
605            .checked_add(segment.token_count)
606            .ok_or_else(|| ValidationError::new("InferenceManifestV1 total token overflow"))?;
607        let mutability = match segment.mutability {
608            InferenceMutabilityV1::Immutable => &mut accounting.immutable_tokens,
609            InferenceMutabilityV1::Conditional => &mut accounting.conditional_tokens,
610        };
611        *mutability = mutability
612            .checked_add(segment.token_count)
613            .ok_or_else(|| ValidationError::new("InferenceManifestV1 mutability token overflow"))?;
614        let delivery = match segment.delivery_state {
615            InferenceDeliveryStateV1::CacheMarked => &mut accounting.cache_marked_tokens,
616            InferenceDeliveryStateV1::Uncached => &mut accounting.uncached_tokens,
617        };
618        *delivery = delivery
619            .checked_add(segment.token_count)
620            .ok_or_else(|| ValidationError::new("InferenceManifestV1 delivery token overflow"))?;
621    }
622    Ok(accounting)
623}
624
625fn inference_segment_id(
626    scope: &ViaScopeV1,
627    position: u16,
628    role: InferenceSegmentRoleV1,
629    guard_hash: &Sha256Digest,
630) -> Result<Sha256Digest, ValidationError> {
631    let mut bytes = b"leanctx.inference.segment.v1\0".to_vec();
632    bytes.extend_from_slice(scope.digest()?.as_str().as_bytes());
633    bytes.extend_from_slice(&position.to_be_bytes());
634    bytes.push(role.discriminant());
635    bytes.extend_from_slice(guard_hash.as_str().as_bytes());
636    sha256(&bytes)
637}
638
639#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
640#[serde(deny_unknown_fields)]
641pub struct ChunkDescriptorV1 {
642    pub chunk_id: Sha256Digest,
643    pub start_byte: u64,
644    pub end_byte: u64,
645    pub byte_count: u64,
646    #[serde(default, skip_serializing_if = "Option::is_none")]
647    pub symbol: Option<String>,
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub token_count: Option<u64>,
650    pub operator_id: CapabilityId,
651    pub operator_version: SemanticVersion,
652}
653
654impl ChunkDescriptorV1 {
655    fn validate(&self, content_bytes: u64) -> Result<(), ValidationError> {
656        if self.start_byte >= self.end_byte
657            || self.end_byte > content_bytes
658            || self.byte_count != self.end_byte - self.start_byte
659        {
660            return Err(ValidationError::new(
661                "ChunkDescriptorV1 byte range and byte_count are inconsistent",
662            ));
663        }
664        if self.symbol.as_ref().is_some_and(|value| {
665            value.trim().is_empty() || value.len() > 1_024 || value.chars().any(char::is_control)
666        }) {
667            return Err(ValidationError::new(
668                "ChunkDescriptorV1 symbol must be bounded printable text",
669            ));
670        }
671        Ok(())
672    }
673}
674
675#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
676#[serde(deny_unknown_fields)]
677pub struct ContentManifestV1 {
678    pub scope: ViaScopeV1,
679    pub content_id: ScopedContentIdV1,
680    pub kind: ContentKindV1,
681    pub byte_count: u64,
682    #[serde(default, skip_serializing_if = "Vec::is_empty")]
683    pub token_counts: Vec<TokenCountV1>,
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub source_version: Option<Sha256Digest>,
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub language: Option<String>,
688    #[serde(default, skip_serializing_if = "Option::is_none")]
689    pub structural_manifest: Option<Sha256Digest>,
690    pub chunks: Vec<ChunkDescriptorV1>,
691    pub availability: ContentAvailabilityV1,
692}
693
694impl ContentManifestV1 {
695    pub fn validate(&self) -> Result<(), ValidationError> {
696        self.scope.validate()?;
697        if self.content_id.scope_digest != self.scope.digest()? {
698            return Err(ValidationError::new(
699                "ContentManifestV1 content_id scope does not match manifest scope",
700            ));
701        }
702        if self.chunks.len() > MAX_VIA_CONTENT_ITEMS {
703            return Err(ValidationError::new(format!(
704                "ContentManifestV1 chunks exceeds the {MAX_VIA_CONTENT_ITEMS} item limit"
705            )));
706        }
707        if self.byte_count == 0 && !self.chunks.is_empty()
708            || self.byte_count > 0 && self.chunks.is_empty()
709        {
710            return Err(ValidationError::new(
711                "ContentManifestV1 empty state is inconsistent with chunks",
712            ));
713        }
714        if matches!(
715            self.scope.classification,
716            ViaClassificationV1::Secret | ViaClassificationV1::LocalOnly
717        ) && self.availability == ContentAvailabilityV1::TransientRemoteAllowed
718        {
719            return Err(ValidationError::new(
720                "secret or local-only content cannot allow remote availability",
721            ));
722        }
723        if self.language.as_ref().is_some_and(|value| {
724            value.trim().is_empty() || value.len() > 128 || value.chars().any(char::is_control)
725        }) {
726            return Err(ValidationError::new(
727                "ContentManifestV1 language must be bounded printable text",
728            ));
729        }
730        let families = self
731            .token_counts
732            .iter()
733            .map(|entry| entry.family.as_str().to_owned())
734            .collect::<Vec<_>>();
735        validate_unique(
736            &families,
737            MAX_VIA_CAPABILITIES,
738            "ContentManifestV1 token families",
739        )?;
740        let mut previous_end = 0;
741        let mut chunk_ids = BTreeSet::new();
742        for chunk in &self.chunks {
743            chunk.validate(self.byte_count)?;
744            if chunk.start_byte != previous_end || !chunk_ids.insert(chunk.chunk_id.clone()) {
745                return Err(ValidationError::new(
746                    "ContentManifestV1 chunks must be contiguous, ordered, and unique",
747                ));
748            }
749            previous_end = chunk.end_byte;
750        }
751        if previous_end != self.byte_count {
752            return Err(ValidationError::new(
753                "ContentManifestV1 chunks must cover the declared byte_count",
754            ));
755        }
756        let bytes = serde_json::to_vec(self).map_err(|error| {
757            ValidationError::new(format!("serialize content manifest: {error}"))
758        })?;
759        if bytes.len() > MAX_VIA_MANIFEST_BYTES {
760            return Err(ValidationError::new(format!(
761                "ContentManifestV1 exceeds the {MAX_VIA_MANIFEST_BYTES} byte limit"
762            )));
763        }
764        Ok(())
765    }
766}
767
768#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
769#[serde(rename_all = "snake_case")]
770pub enum NeedContentPurposeV1 {
771    ContextPlanning,
772}
773
774#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
775#[serde(deny_unknown_fields)]
776pub struct NeedContentV1 {
777    #[serde(deserialize_with = "deserialize_via_protocol_version")]
778    pub protocol_version: u16,
779    pub request_id: ScopedReferenceV1,
780    pub source_content_id: ScopedContentIdV1,
781    pub chunk_ids: Vec<Sha256Digest>,
782    pub purpose: NeedContentPurposeV1,
783    pub max_bytes: u64,
784    pub max_tokens: u64,
785    pub expires_at: UtcTimestamp,
786    pub policy_version: SemanticVersion,
787}
788
789impl NeedContentV1 {
790    pub fn validate_at(&self, now: &UtcTimestamp) -> Result<(), ValidationError> {
791        validate_protocol_version(self.protocol_version)?;
792        if self.request_id.scope_digest != self.source_content_id.scope_digest {
793            return Err(ValidationError::new(
794                "NeedContentV1 request and source scopes must match",
795            ));
796        }
797        if self.chunk_ids.is_empty() {
798            return Err(ValidationError::new(
799                "NeedContentV1 must request at least one chunk",
800            ));
801        }
802        validate_unique(
803            &self.chunk_ids,
804            MAX_VIA_CONTENT_CHUNKS_PER_REQUEST,
805            "NeedContentV1 chunk IDs",
806        )?;
807        if self.max_bytes == 0
808            || self.max_bytes > MAX_VIA_CONTENT_REQUEST_BYTES
809            || self.max_tokens == 0
810            || self.max_tokens > MAX_VIA_CONTENT_REQUEST_TOKENS
811        {
812            return Err(ValidationError::new(
813                "NeedContentV1 budgets and expiry must be bounded and active",
814            ));
815        }
816        validate_content_ttl(now, &self.expires_at)?;
817        Ok(())
818    }
819}
820
821#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
822#[serde(deny_unknown_fields)]
823pub struct ContentChunkV1 {
824    #[serde(deserialize_with = "deserialize_via_protocol_version")]
825    pub protocol_version: u16,
826    pub request_id: ScopedReferenceV1,
827    pub source_content_id: ScopedContentIdV1,
828    pub chunk_id: Sha256Digest,
829    pub start_byte: u64,
830    pub end_byte: u64,
831    pub byte_count: u64,
832    pub source_version: Sha256Digest,
833    pub content: String,
834    pub payload_digest: Sha256Digest,
835    pub expires_at: UtcTimestamp,
836}
837
838impl fmt::Debug for ContentChunkV1 {
839    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
840        formatter
841            .debug_struct("ContentChunkV1")
842            .field("request_id", &self.request_id)
843            .field("source_content_id", &self.source_content_id)
844            .field("chunk_id", &self.chunk_id)
845            .field("byte_count", &self.byte_count)
846            .field("content", &"[REDACTED]")
847            .field("payload_digest", &self.payload_digest)
848            .finish()
849    }
850}
851
852impl ContentChunkV1 {
853    pub fn validate_at(&self, now: &UtcTimestamp) -> Result<(), ValidationError> {
854        validate_protocol_version(self.protocol_version)?;
855        if self.request_id.scope_digest != self.source_content_id.scope_digest {
856            return Err(ValidationError::new(
857                "ContentChunkV1 request and source scopes must match",
858            ));
859        }
860        let content = self.content.as_bytes();
861        if self.start_byte >= self.end_byte
862            || self.end_byte.checked_sub(self.start_byte) != Some(self.byte_count)
863            || self.byte_count as usize != content.len()
864            || content.len() > MAX_VIA_CONTENT_CHUNK_BYTES
865        {
866            return Err(ValidationError::new(
867                "ContentChunkV1 range, size, or expiry is invalid",
868            ));
869        }
870        validate_content_ttl(now, &self.expires_at)?;
871        let digest = sha256(content)?;
872        if digest != self.chunk_id || digest != self.payload_digest {
873            return Err(ValidationError::new(
874                "ContentChunkV1 payload digest does not bind exact content bytes",
875            ));
876        }
877        Ok(())
878    }
879}
880
881#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
882#[serde(deny_unknown_fields)]
883pub struct ContentChunkAckV1 {
884    #[serde(deserialize_with = "deserialize_via_protocol_version")]
885    pub protocol_version: u16,
886    pub request_id: ScopedReferenceV1,
887    pub source_content_id: ScopedContentIdV1,
888    pub chunk_id: Sha256Digest,
889}
890
891impl ContentChunkAckV1 {
892    pub fn validate(&self) -> Result<(), ValidationError> {
893        validate_protocol_version(self.protocol_version)?;
894        if self.request_id.scope_digest != self.source_content_id.scope_digest {
895            return Err(ValidationError::new(
896                "ContentChunkAckV1 request and source scopes must match",
897            ));
898        }
899        Ok(())
900    }
901}
902
903#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
904#[serde(rename_all = "snake_case")]
905pub enum ViaObservedEventKindV1 {
906    SessionStarted,
907    SessionEnded,
908    AgentObserved,
909    UserMessageObserved,
910    AssistantMessageObserved,
911    FileObserved,
912    FileReadObserved,
913    FileChanged,
914    SearchExecuted,
915    SearchResultObserved,
916    ShellExecuted,
917    ShellResultObserved,
918    McpToolCalled,
919    McpResultObserved,
920    ProviderRequestPending,
921    ContextDelivered,
922    ContextReused,
923    ContextInvalidated,
924}
925
926#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
927#[serde(deny_unknown_fields)]
928pub struct ObservationPayloadV1 {
929    pub kind: ViaObservedEventKindV1,
930    #[serde(default, skip_serializing_if = "Vec::is_empty")]
931    pub input_ids: Vec<ScopedContentIdV1>,
932    #[serde(default, skip_serializing_if = "Vec::is_empty")]
933    pub output_ids: Vec<ScopedContentIdV1>,
934    #[serde(default, skip_serializing_if = "Option::is_none")]
935    pub capability_id: Option<CapabilityId>,
936}
937
938#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
939#[serde(rename_all = "snake_case")]
940pub enum PlanEventStatusV1 {
941    Applied,
942    Rejected,
943}
944
945#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
946#[serde(deny_unknown_fields)]
947pub struct PlanEventPayloadV1 {
948    pub plan_id: PlanId,
949    pub status: PlanEventStatusV1,
950}
951
952#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
953#[serde(tag = "event_type", content = "payload", rename_all = "snake_case")]
954pub enum ViaEventPayloadV1 {
955    Observation(ObservationPayloadV1),
956    ContentManifest(Box<ContentManifestV1>),
957    InferenceManifest(Box<InferenceManifestV1>),
958    Plan(PlanEventPayloadV1),
959}
960
961impl ViaEventPayloadV1 {
962    fn validate(&self, scope: &ViaScopeV1) -> Result<(), ValidationError> {
963        match self {
964            Self::Observation(payload) => {
965                validate_unique(
966                    &payload.input_ids,
967                    MAX_VIA_CONTENT_ITEMS,
968                    "ObservationPayloadV1 input_ids",
969                )?;
970                validate_unique(
971                    &payload.output_ids,
972                    MAX_VIA_CONTENT_ITEMS,
973                    "ObservationPayloadV1 output_ids",
974                )?;
975                let scope_digest = scope.digest()?;
976                if payload
977                    .input_ids
978                    .iter()
979                    .chain(&payload.output_ids)
980                    .any(|id| id.scope_digest != scope_digest)
981                {
982                    return Err(ValidationError::new(
983                        "ObservationPayloadV1 content IDs must match frame scope",
984                    ));
985                }
986                Ok(())
987            }
988            Self::ContentManifest(manifest) => {
989                if manifest.scope != *scope {
990                    return Err(ValidationError::new(
991                        "ContentManifestV1 scope must match frame scope",
992                    ));
993                }
994                manifest.validate()
995            }
996            Self::InferenceManifest(manifest) => {
997                if manifest.scope != *scope {
998                    return Err(ValidationError::new(
999                        "InferenceManifestV1 scope must match frame scope",
1000                    ));
1001                }
1002                manifest.validate()
1003            }
1004            Self::Plan(_) => Ok(()),
1005        }
1006    }
1007
1008    pub fn digest(&self) -> Result<Sha256Digest, ValidationError> {
1009        let bytes = serde_json::to_vec(self).map_err(|error| {
1010            ValidationError::new(format!("serialize Via event payload: {error}"))
1011        })?;
1012        sha256(&bytes)
1013    }
1014}
1015
1016#[derive(Deserialize)]
1017struct ViaEventFrameV1Wire {
1018    #[serde(deserialize_with = "deserialize_via_protocol_version")]
1019    protocol_version: u16,
1020    stream_id: ScopedReferenceV1,
1021    seq: u64,
1022    event_id: EventId,
1023    #[serde(default)]
1024    causality: Vec<EventId>,
1025    payload_hash: Sha256Digest,
1026    scope: ViaScopeV1,
1027    #[serde(flatten)]
1028    payload: ViaEventPayloadV1,
1029    #[serde(flatten)]
1030    unknown_fields: BTreeMap<String, serde_json::Value>,
1031}
1032
1033#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1034pub struct ViaEventFrameV1 {
1035    #[serde(deserialize_with = "deserialize_via_protocol_version")]
1036    pub protocol_version: u16,
1037    pub stream_id: ScopedReferenceV1,
1038    pub seq: u64,
1039    pub event_id: EventId,
1040    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1041    pub causality: Vec<EventId>,
1042    pub payload_hash: Sha256Digest,
1043    pub scope: ViaScopeV1,
1044    #[serde(flatten)]
1045    pub payload: ViaEventPayloadV1,
1046}
1047
1048impl<'de> Deserialize<'de> for ViaEventFrameV1 {
1049    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1050    where
1051        D: Deserializer<'de>,
1052    {
1053        let wire = ViaEventFrameV1Wire::deserialize(deserializer)?;
1054        if !wire.unknown_fields.is_empty() {
1055            let fields = wire
1056                .unknown_fields
1057                .keys()
1058                .map(|field| field.as_str())
1059                .collect::<Vec<_>>()
1060                .join(", ");
1061            return Err(DeError::custom(format!(
1062                "ViaEventFrameV1 contains unknown top-level fields: {fields}"
1063            )));
1064        }
1065        Ok(Self {
1066            protocol_version: wire.protocol_version,
1067            stream_id: wire.stream_id,
1068            seq: wire.seq,
1069            event_id: wire.event_id,
1070            causality: wire.causality,
1071            payload_hash: wire.payload_hash,
1072            scope: wire.scope,
1073            payload: wire.payload,
1074        })
1075    }
1076}
1077
1078impl ViaEventFrameV1 {
1079    pub fn new(
1080        stream_id: ScopedReferenceV1,
1081        seq: u64,
1082        event_id: EventId,
1083        causality: Vec<EventId>,
1084        scope: ViaScopeV1,
1085        payload: ViaEventPayloadV1,
1086    ) -> Result<Self, ValidationError> {
1087        let payload_hash = payload.digest()?;
1088        let frame = Self {
1089            protocol_version: VIA_PROTOCOL_V1,
1090            stream_id,
1091            seq,
1092            event_id,
1093            causality,
1094            payload_hash,
1095            scope,
1096            payload,
1097        };
1098        frame.validate()?;
1099        Ok(frame)
1100    }
1101
1102    pub fn validate(&self) -> Result<(), ValidationError> {
1103        validate_protocol_version(self.protocol_version)?;
1104        self.scope.validate()?;
1105        if self.stream_id.scope_digest != self.scope.digest()? {
1106            return Err(ValidationError::new(
1107                "ViaEventFrameV1 stream_id scope does not match frame scope",
1108            ));
1109        }
1110        validate_unique(
1111            &self.causality,
1112            MAX_VIA_CAUSALITY,
1113            "ViaEventFrameV1 causality",
1114        )?;
1115        if self.causality.contains(&self.event_id) {
1116            return Err(ValidationError::new(
1117                "ViaEventFrameV1 event_id cannot be its own cause",
1118            ));
1119        }
1120        self.payload.validate(&self.scope)?;
1121        if self.payload.digest()? != self.payload_hash {
1122            return Err(ValidationError::new(
1123                "ViaEventFrameV1 payload_hash does not match payload",
1124            ));
1125        }
1126        let bytes = serde_json::to_vec(self)
1127            .map_err(|error| ValidationError::new(format!("serialize Via event frame: {error}")))?;
1128        if bytes.len() > MAX_VIA_FRAME_BYTES {
1129            return Err(ValidationError::new(format!(
1130                "ViaEventFrameV1 exceeds the {MAX_VIA_FRAME_BYTES} byte limit"
1131            )));
1132        }
1133        Ok(())
1134    }
1135
1136    /// Validate deterministic stream ordering against the immediately prior frame.
1137    pub fn validate_successor(&self, previous: &Self) -> Result<(), ValidationError> {
1138        self.validate()?;
1139        previous.validate()?;
1140        let expected_seq = previous.seq.checked_add(1).ok_or_else(|| {
1141            ValidationError::new("ViaEventFrameV1 predecessor seq cannot advance")
1142        })?;
1143        if self.stream_id != previous.stream_id || self.seq != expected_seq {
1144            return Err(ValidationError::new(
1145                "ViaEventFrameV1 successor must share stream_id and increment seq by one",
1146            ));
1147        }
1148        Ok(())
1149    }
1150}
1151
1152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1153#[serde(rename_all = "snake_case")]
1154pub enum ViaEventAckStatusV1 {
1155    Stored,
1156    Duplicate,
1157}
1158
1159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1160#[serde(deny_unknown_fields)]
1161pub struct ViaEventAckV1 {
1162    #[serde(deserialize_with = "deserialize_via_protocol_version")]
1163    pub protocol_version: u16,
1164    pub stream_id: ScopedReferenceV1,
1165    pub acknowledged_seq: u64,
1166    pub acknowledged_event_id: EventId,
1167    pub next_seq: u64,
1168    pub status: ViaEventAckStatusV1,
1169    pub accepted_event_count: u64,
1170    pub graph_nodes: u64,
1171    pub graph_edges: u64,
1172}
1173
1174impl ViaEventAckV1 {
1175    pub fn validate(&self) -> Result<(), ValidationError> {
1176        validate_protocol_version(self.protocol_version)?;
1177        if self.acknowledged_seq.checked_add(1) != Some(self.next_seq) {
1178            return Err(ValidationError::new(
1179                "ViaEventAckV1 next_seq must immediately follow acknowledged_seq",
1180            ));
1181        }
1182        if self.accepted_event_count != self.next_seq
1183            || self.accepted_event_count > MAX_VIA_ACK_EVENTS
1184            || self.graph_nodes > MAX_VIA_ACK_GRAPH_NODES
1185            || self.graph_edges > MAX_VIA_ACK_GRAPH_EDGES
1186        {
1187            return Err(ValidationError::new(
1188                "ViaEventAckV1 accepted_event_count must equal the stream frontier and counters must remain bounded",
1189            ));
1190        }
1191        Ok(())
1192    }
1193}
1194
1195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1196#[serde(deny_unknown_fields)]
1197pub struct ViaBackpressureV1 {
1198    #[serde(deserialize_with = "deserialize_via_protocol_version")]
1199    pub protocol_version: u16,
1200    pub stream_id: ScopedReferenceV1,
1201    pub accepted_window: u16,
1202    pub retry_after_ms: u32,
1203}
1204
1205impl ViaBackpressureV1 {
1206    pub fn validate(&self) -> Result<(), ValidationError> {
1207        validate_protocol_version(self.protocol_version)?;
1208        if self.accepted_window > MAX_VIA_STREAM_WINDOW
1209            || self.retry_after_ms == 0
1210            || self.retry_after_ms > MAX_VIA_BACKPRESSURE_RETRY_MS
1211        {
1212            return Err(ValidationError::new(
1213                "ViaBackpressureV1 window or retry interval exceeds its bound",
1214            ));
1215        }
1216        Ok(())
1217    }
1218}
1219
1220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1221#[serde(deny_unknown_fields)]
1222pub struct ViaStreamResumeV1 {
1223    #[serde(deserialize_with = "deserialize_via_protocol_version")]
1224    pub protocol_version: u16,
1225    pub stream_id: ScopedReferenceV1,
1226}
1227
1228impl ViaStreamResumeV1 {
1229    pub fn validate(&self) -> Result<(), ValidationError> {
1230        validate_protocol_version(self.protocol_version)
1231    }
1232}
1233
1234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1235#[serde(deny_unknown_fields)]
1236pub struct ViaStreamPositionV1 {
1237    pub seq: u64,
1238    pub event_id: EventId,
1239}
1240
1241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1242#[serde(deny_unknown_fields)]
1243pub struct ViaStreamCursorV1 {
1244    #[serde(deserialize_with = "deserialize_via_protocol_version")]
1245    pub protocol_version: u16,
1246    pub stream_id: ScopedReferenceV1,
1247    pub next_seq: u64,
1248    #[serde(default, skip_serializing_if = "Option::is_none")]
1249    pub last_accepted: Option<ViaStreamPositionV1>,
1250}
1251
1252impl ViaStreamCursorV1 {
1253    pub fn validate(&self) -> Result<(), ValidationError> {
1254        validate_protocol_version(self.protocol_version)?;
1255        match &self.last_accepted {
1256            None if self.next_seq == 0 => Ok(()),
1257            Some(position) if position.seq.checked_add(1) == Some(self.next_seq) => Ok(()),
1258            _ => Err(ValidationError::new(
1259                "ViaStreamCursorV1 next_seq must bind the last accepted position",
1260            )),
1261        }
1262    }
1263}
1264
1265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1266#[serde(deny_unknown_fields)]
1267pub struct ViaPlanHeaderV1 {
1268    pub plan_id: PlanId,
1269    pub caused_by: EventId,
1270    pub policy_id: CapabilityId,
1271    pub policy_version: SemanticVersion,
1272    pub expected_source_ids: Vec<ScopedContentIdV1>,
1273    pub immutable_guard_digest: Sha256Digest,
1274    pub operator_id: CapabilityId,
1275    pub operator_version: SemanticVersion,
1276    pub expires_at: UtcTimestamp,
1277    pub output_budget_tokens: u64,
1278}
1279
1280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1281#[serde(deny_unknown_fields)]
1282pub struct ViaPlanRequestV1 {
1283    #[serde(deserialize_with = "deserialize_via_protocol_version")]
1284    pub protocol_version: u16,
1285    pub stream_id: ScopedReferenceV1,
1286    pub event_id: EventId,
1287    pub immutable_guard_digest: Sha256Digest,
1288    pub now: UtcTimestamp,
1289    pub expires_at: UtcTimestamp,
1290    #[serde(default, skip_serializing_if = "Option::is_none")]
1291    pub inference_candidates: Option<InferenceCandidateSetV1>,
1292}
1293
1294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1295#[serde(deny_unknown_fields)]
1296pub struct InferenceSegmentTransitionV1 {
1297    pub position: u16,
1298    pub old_segment_id: Sha256Digest,
1299    pub old_representation_id: ScopedContentIdV1,
1300    pub old_guard_hash: Sha256Digest,
1301    pub new_segment_id: Sha256Digest,
1302    pub new_representation_id: ScopedContentIdV1,
1303    pub new_guard_hash: Sha256Digest,
1304    pub old_token_count: u64,
1305    pub new_token_count: u64,
1306}
1307
1308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1309#[serde(deny_unknown_fields)]
1310pub struct InferenceCandidateSetV1 {
1311    pub candidate_set_id: ScopedContentIdV1,
1312    pub manifest_id: ScopedContentIdV1,
1313    pub operator_id: CapabilityId,
1314    pub operator_version: SemanticVersion,
1315    pub transitions: Vec<InferenceSegmentTransitionV1>,
1316}
1317
1318#[derive(Serialize)]
1319struct InferenceCandidateSetIdentityV1<'a> {
1320    manifest_id: &'a ScopedContentIdV1,
1321    operator_id: &'a CapabilityId,
1322    operator_version: &'a SemanticVersion,
1323    transitions: &'a [InferenceSegmentTransitionV1],
1324}
1325
1326#[derive(Serialize)]
1327struct InferencePlanIdIdentityV1<'a> {
1328    scope_digest: &'a Sha256Digest,
1329    caused_by: &'a EventId,
1330    manifest_id: &'a ScopedContentIdV1,
1331    candidate_set_id: &'a ScopedContentIdV1,
1332    selected_transitions: &'a [InferenceSegmentTransitionV1],
1333    output_budget_tokens: u64,
1334}
1335
1336impl InferenceCandidateSetV1 {
1337    pub fn validate_for_scope(&self, scope_digest: &Sha256Digest) -> Result<(), ValidationError> {
1338        if self.manifest_id.scope_digest != *scope_digest
1339            || self.candidate_set_id.scope_digest != *scope_digest
1340        {
1341            return Err(ValidationError::new(
1342                "InferenceCandidateSetV1 scope does not match request",
1343            ));
1344        }
1345        if self.operator_id.as_str() != "leanctx.inference.history.compact"
1346            || self.operator_version.as_str() != "1.0.0"
1347        {
1348            return Err(ValidationError::new(
1349                "InferenceCandidateSetV1 operator is unsupported",
1350            ));
1351        }
1352        if self.transitions.is_empty() || self.transitions.len() > MAX_VIA_INFERENCE_CANDIDATES {
1353            return Err(ValidationError::new(format!(
1354                "InferenceCandidateSetV1 transitions must be within 1..={MAX_VIA_INFERENCE_CANDIDATES}"
1355            )));
1356        }
1357        let mut prior_position = None;
1358        let mut old_segments = BTreeSet::new();
1359        let mut new_segments = BTreeSet::new();
1360        for transition in &self.transitions {
1361            if prior_position.is_some_and(|position| transition.position <= position) {
1362                return Err(ValidationError::new(
1363                    "InferenceCandidateSetV1 transitions must be position-sorted",
1364                ));
1365            }
1366            prior_position = Some(transition.position);
1367            if !old_segments.insert(&transition.old_segment_id)
1368                || !new_segments.insert(&transition.new_segment_id)
1369                || transition.old_representation_id.scope_digest != *scope_digest
1370                || transition.new_representation_id.scope_digest != *scope_digest
1371                || transition.old_guard_hash != transition.old_representation_id.content_digest
1372                || transition.new_guard_hash != transition.new_representation_id.content_digest
1373                || transition.old_segment_id == transition.new_segment_id
1374                || transition.old_representation_id == transition.new_representation_id
1375                || transition.new_token_count >= transition.old_token_count
1376            {
1377                return Err(ValidationError::new(
1378                    "InferenceCandidateSetV1 transition identity or reduction is invalid",
1379                ));
1380            }
1381        }
1382        let encoded = serde_json::to_vec(self).map_err(|error| {
1383            ValidationError::new(format!("serialize inference candidate set: {error}"))
1384        })?;
1385        if encoded.len() > MAX_VIA_INFERENCE_CANDIDATE_SET_BYTES {
1386            return Err(ValidationError::new(format!(
1387                "InferenceCandidateSetV1 exceeds the {MAX_VIA_INFERENCE_CANDIDATE_SET_BYTES} byte limit"
1388            )));
1389        }
1390        if self.candidate_set_id != self.expected_id(scope_digest)? {
1391            return Err(ValidationError::new(
1392                "InferenceCandidateSetV1 ID does not match canonical metadata",
1393            ));
1394        }
1395        Ok(())
1396    }
1397
1398    pub fn expected_id(
1399        &self,
1400        scope_digest: &Sha256Digest,
1401    ) -> Result<ScopedContentIdV1, ValidationError> {
1402        let identity = InferenceCandidateSetIdentityV1 {
1403            manifest_id: &self.manifest_id,
1404            operator_id: &self.operator_id,
1405            operator_version: &self.operator_version,
1406            transitions: &self.transitions,
1407        };
1408        let mut bytes = b"leanctx.inference.candidate-set.v1\0".to_vec();
1409        bytes.extend_from_slice(&serde_json::to_vec(&identity).map_err(|error| {
1410            ValidationError::new(format!("serialize inference candidate identity: {error}"))
1411        })?);
1412        Ok(ScopedContentIdV1 {
1413            scope_digest: scope_digest.clone(),
1414            content_digest: sha256(&bytes)?,
1415        })
1416    }
1417}
1418
1419pub fn inference_plan_id(
1420    scope_digest: &Sha256Digest,
1421    caused_by: &EventId,
1422    manifest_id: &ScopedContentIdV1,
1423    candidate_set_id: &ScopedContentIdV1,
1424    selected_transitions: &[InferenceSegmentTransitionV1],
1425    output_budget_tokens: u64,
1426) -> Result<PlanId, ValidationError> {
1427    let identity = InferencePlanIdIdentityV1 {
1428        scope_digest,
1429        caused_by,
1430        manifest_id,
1431        candidate_set_id,
1432        selected_transitions,
1433        output_budget_tokens,
1434    };
1435    let mut bytes = b"leanctx.inference.plan-id.v1\0".to_vec();
1436    bytes.extend_from_slice(&serde_json::to_vec(&identity).map_err(|error| {
1437        ValidationError::new(format!("serialize inference plan identity: {error}"))
1438    })?);
1439    let digest = sha256(&bytes)?;
1440    let hex = digest
1441        .as_str()
1442        .strip_prefix("sha256:")
1443        .ok_or_else(|| ValidationError::new("inference plan digest prefix is invalid"))?;
1444    PlanId::new(format!("inference-{hex}"))
1445}
1446
1447/// Deterministically binds every immutable inference-manifest field that an
1448/// optimizer is forbidden to change.
1449pub fn inference_immutable_guard(
1450    manifest: &InferenceManifestV1,
1451) -> Result<Sha256Digest, ValidationError> {
1452    manifest.validate()?;
1453    let mut bytes = b"leanctx.inference.immutable-guard.v1\0".to_vec();
1454    push_guard_text(&mut bytes, manifest.scope.digest()?.as_str())?;
1455    bytes.push(match manifest.tokenizer_family {
1456        InferenceTokenizerFamilyV1::Cl100kBaseApproxV1 => 0,
1457    });
1458    bytes.push(match manifest.cache_policy {
1459        InferenceCachePolicyV1::AnthropicExplicitCacheControlV1 => 0,
1460    });
1461    let immutable = manifest
1462        .segments
1463        .iter()
1464        .filter(|segment| segment.mutability == InferenceMutabilityV1::Immutable)
1465        .collect::<Vec<_>>();
1466    bytes.extend_from_slice(
1467        &u16::try_from(immutable.len())
1468            .map_err(|_| ValidationError::new("too many immutable inference segments"))?
1469            .to_be_bytes(),
1470    );
1471    for segment in immutable {
1472        bytes.extend_from_slice(&segment.position.to_be_bytes());
1473        bytes.push(match segment.role {
1474            InferenceSegmentRoleV1::System => 0,
1475            InferenceSegmentRoleV1::Developer => 1,
1476            InferenceSegmentRoleV1::User => 2,
1477            InferenceSegmentRoleV1::Assistant => 3,
1478            InferenceSegmentRoleV1::ToolCall => 4,
1479            InferenceSegmentRoleV1::ToolResult => 5,
1480            InferenceSegmentRoleV1::ToolSchema => 6,
1481            InferenceSegmentRoleV1::RequestControl => 7,
1482        });
1483        push_guard_text(&mut bytes, segment.segment_id.as_str())?;
1484        push_guard_text(
1485            &mut bytes,
1486            segment.content_or_representation_id.scope_digest.as_str(),
1487        )?;
1488        push_guard_text(
1489            &mut bytes,
1490            segment.content_or_representation_id.content_digest.as_str(),
1491        )?;
1492        push_guard_text(&mut bytes, segment.guard_hash.as_str())?;
1493        bytes.extend_from_slice(&segment.token_count.to_be_bytes());
1494        bytes.push(match segment.delivery_state {
1495            InferenceDeliveryStateV1::CacheMarked => 0,
1496            InferenceDeliveryStateV1::Uncached => 1,
1497        });
1498        bytes.push(
1499            u8::try_from(segment.source_lineage.len())
1500                .map_err(|_| ValidationError::new("too much inference lineage"))?,
1501        );
1502        for source in &segment.source_lineage {
1503            push_guard_text(&mut bytes, source.scope_digest.as_str())?;
1504            push_guard_text(&mut bytes, source.content_digest.as_str())?;
1505        }
1506    }
1507    sha256(&bytes)
1508}
1509
1510fn push_guard_text(bytes: &mut Vec<u8>, value: &str) -> Result<(), ValidationError> {
1511    bytes.extend_from_slice(
1512        &u32::try_from(value.len())
1513            .map_err(|_| ValidationError::new("inference guard field is too large"))?
1514            .to_be_bytes(),
1515    );
1516    bytes.extend_from_slice(value.as_bytes());
1517    Ok(())
1518}
1519
1520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1521#[serde(deny_unknown_fields)]
1522pub struct InferencePlanV1 {
1523    pub manifest_id: ScopedContentIdV1,
1524    pub transitions: Vec<InferenceSegmentTransitionV1>,
1525}
1526
1527impl InferencePlanV1 {
1528    fn validate(&self, scope: &ViaScopeV1) -> Result<(), ValidationError> {
1529        if self.transitions.is_empty() || self.transitions.len() > MAX_VIA_INFERENCE_SEGMENTS {
1530            return Err(ValidationError::new(format!(
1531                "InferencePlanV1 transitions must be within 1..={MAX_VIA_INFERENCE_SEGMENTS}"
1532            )));
1533        }
1534        let scope_digest = scope.digest()?;
1535        if self.manifest_id.scope_digest != scope_digest {
1536            return Err(ValidationError::new(
1537                "InferencePlanV1 manifest_id must match plan scope",
1538            ));
1539        }
1540        let mut prior_position = None;
1541        let mut old_segments = BTreeSet::new();
1542        let mut new_segments = BTreeSet::new();
1543        for transition in &self.transitions {
1544            if prior_position.is_some_and(|position| transition.position <= position) {
1545                return Err(ValidationError::new(
1546                    "InferencePlanV1 transitions must be strictly position-sorted",
1547                ));
1548            }
1549            prior_position = Some(transition.position);
1550            if !old_segments.insert(&transition.old_segment_id)
1551                || !new_segments.insert(&transition.new_segment_id)
1552            {
1553                return Err(ValidationError::new(
1554                    "InferencePlanV1 transition segment IDs must be unique",
1555                ));
1556            }
1557            if transition.old_representation_id.scope_digest != scope_digest
1558                || transition.new_representation_id.scope_digest != scope_digest
1559                || transition.old_guard_hash != transition.old_representation_id.content_digest
1560                || transition.new_guard_hash != transition.new_representation_id.content_digest
1561                || transition.old_segment_id == transition.new_segment_id
1562                || transition.old_representation_id == transition.new_representation_id
1563                || transition.new_token_count >= transition.old_token_count
1564            {
1565                return Err(ValidationError::new(
1566                    "InferencePlanV1 transition identity or token reduction is invalid",
1567                ));
1568            }
1569        }
1570        Ok(())
1571    }
1572}
1573
1574impl ViaPlanRequestV1 {
1575    pub fn validate(&self) -> Result<(), ValidationError> {
1576        validate_protocol_version(self.protocol_version)?;
1577        validate_plan_ttl(&self.now, &self.expires_at)?;
1578        if let Some(candidates) = &self.inference_candidates {
1579            candidates.validate_for_scope(&self.stream_id.scope_digest)?;
1580        }
1581        Ok(())
1582    }
1583}
1584
1585#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1586#[serde(tag = "plan_type", rename_all = "snake_case")]
1587pub enum ViaPlanBodyV1 {
1588    Read { source_id: ScopedContentIdV1 },
1589    Search { index_id: ScopedContentIdV1 },
1590    Shell { output_id: ScopedContentIdV1 },
1591    Mcp { output_id: ScopedContentIdV1 },
1592    Reuse { source_ids: Vec<ScopedContentIdV1> },
1593    Inference { inference_plan: InferencePlanV1 },
1594}
1595
1596impl ViaPlanBodyV1 {
1597    fn source_ids(&self) -> Vec<&ScopedContentIdV1> {
1598        match self {
1599            Self::Read { source_id } => vec![source_id],
1600            Self::Search { index_id } => vec![index_id],
1601            Self::Shell { output_id } | Self::Mcp { output_id } => vec![output_id],
1602            Self::Reuse { source_ids } => source_ids.iter().collect(),
1603            Self::Inference { inference_plan } => vec![&inference_plan.manifest_id],
1604        }
1605    }
1606}
1607
1608#[derive(Deserialize)]
1609struct ViaOptimizationPlanV1Wire {
1610    #[serde(deserialize_with = "deserialize_via_protocol_version")]
1611    protocol_version: u16,
1612    scope: ViaScopeV1,
1613    header: ViaPlanHeaderV1,
1614    #[serde(flatten)]
1615    body: ViaPlanBodyV1,
1616}
1617
1618struct StrictPlanJsonValue(Value);
1619
1620impl<'de> Deserialize<'de> for StrictPlanJsonValue {
1621    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1622    where
1623        D: Deserializer<'de>,
1624    {
1625        deserializer.deserialize_any(StrictPlanJsonVisitor)
1626    }
1627}
1628
1629struct StrictPlanJsonVisitor;
1630
1631impl<'de> Visitor<'de> for StrictPlanJsonVisitor {
1632    type Value = StrictPlanJsonValue;
1633
1634    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1635        formatter.write_str("a JSON plan value with unique object keys")
1636    }
1637
1638    fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
1639        Ok(StrictPlanJsonValue(Value::Bool(value)))
1640    }
1641
1642    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
1643        Ok(StrictPlanJsonValue(Value::Number(value.into())))
1644    }
1645
1646    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
1647        Ok(StrictPlanJsonValue(Value::Number(value.into())))
1648    }
1649
1650    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
1651    where
1652        E: serde::de::Error,
1653    {
1654        serde_json::Number::from_f64(value)
1655            .map(Value::Number)
1656            .map(StrictPlanJsonValue)
1657            .ok_or_else(|| E::custom("non-finite JSON number in Via plan"))
1658    }
1659
1660    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
1661        Ok(StrictPlanJsonValue(Value::String(value.to_owned())))
1662    }
1663
1664    fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
1665        Ok(StrictPlanJsonValue(Value::String(value)))
1666    }
1667
1668    fn visit_none<E>(self) -> Result<Self::Value, E> {
1669        Ok(StrictPlanJsonValue(Value::Null))
1670    }
1671
1672    fn visit_unit<E>(self) -> Result<Self::Value, E> {
1673        Ok(StrictPlanJsonValue(Value::Null))
1674    }
1675
1676    fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
1677    where
1678        A: SeqAccess<'de>,
1679    {
1680        let mut values = Vec::new();
1681        while let Some(value) = seq.next_element::<StrictPlanJsonValue>()? {
1682            values.push(value.0);
1683        }
1684        Ok(StrictPlanJsonValue(Value::Array(values)))
1685    }
1686
1687    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1688    where
1689        A: MapAccess<'de>,
1690    {
1691        let mut object = Map::new();
1692        while let Some(key) = map.next_key::<String>()? {
1693            if object.contains_key(&key) {
1694                return Err(serde::de::Error::custom(format!(
1695                    "duplicate JSON object key {key:?}"
1696                )));
1697            }
1698            let value = map.next_value::<StrictPlanJsonValue>()?;
1699            object.insert(key, value.0);
1700        }
1701        Ok(StrictPlanJsonValue(Value::Object(object)))
1702    }
1703}
1704
1705#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1706pub struct ViaOptimizationPlanV1 {
1707    #[serde(deserialize_with = "deserialize_via_protocol_version")]
1708    pub protocol_version: u16,
1709    pub scope: ViaScopeV1,
1710    pub header: ViaPlanHeaderV1,
1711    #[serde(flatten)]
1712    pub body: ViaPlanBodyV1,
1713}
1714
1715impl<'de> Deserialize<'de> for ViaOptimizationPlanV1 {
1716    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1717    where
1718        D: Deserializer<'de>,
1719    {
1720        let value = StrictPlanJsonValue::deserialize(deserializer)?.0;
1721        let object = value
1722            .as_object()
1723            .ok_or_else(|| DeError::custom("ViaOptimizationPlanV1 must be an object"))?;
1724        let plan_type = object
1725            .get("plan_type")
1726            .and_then(serde_json::Value::as_str)
1727            .ok_or_else(|| DeError::custom("ViaOptimizationPlanV1 requires plan_type"))?;
1728        let variant_field = match plan_type {
1729            "read" => "source_id",
1730            "search" => "index_id",
1731            "shell" | "mcp" => "output_id",
1732            "reuse" => "source_ids",
1733            "inference" => "inference_plan",
1734            other => {
1735                return Err(DeError::custom(format!(
1736                    "ViaOptimizationPlanV1 has unsupported plan_type {other}"
1737                )));
1738            }
1739        };
1740        let unknown = object
1741            .keys()
1742            .filter(|field| {
1743                !matches!(
1744                    field.as_str(),
1745                    "protocol_version" | "scope" | "header" | "plan_type"
1746                ) && field.as_str() != variant_field
1747            })
1748            .map(String::as_str)
1749            .collect::<Vec<_>>();
1750        if !unknown.is_empty() {
1751            return Err(DeError::custom(format!(
1752                "ViaOptimizationPlanV1 contains unknown top-level fields: {}",
1753                unknown.join(", ")
1754            )));
1755        }
1756        let wire =
1757            serde_json::from_value::<ViaOptimizationPlanV1Wire>(value).map_err(DeError::custom)?;
1758        Ok(Self {
1759            protocol_version: wire.protocol_version,
1760            scope: wire.scope,
1761            header: wire.header,
1762            body: wire.body,
1763        })
1764    }
1765}
1766
1767impl ViaOptimizationPlanV1 {
1768    pub fn validate_at(&self, now: &UtcTimestamp) -> Result<(), ValidationError> {
1769        validate_protocol_version(self.protocol_version)?;
1770        self.scope.validate()?;
1771        let scope_digest = self.scope.digest()?;
1772        if self.header.output_budget_tokens == 0
1773            || self.header.output_budget_tokens > MAX_VIA_OUTPUT_TOKENS
1774        {
1775            return Err(ValidationError::new(format!(
1776                "ViaPlanHeaderV1 output_budget_tokens must be within 1..={MAX_VIA_OUTPUT_TOKENS}"
1777            )));
1778        }
1779        validate_unique(
1780            &self.header.expected_source_ids,
1781            MAX_VIA_PLAN_SOURCES,
1782            "ViaPlanHeaderV1 expected_source_ids",
1783        )?;
1784        if self.header.expected_source_ids.is_empty() {
1785            return Err(ValidationError::new(
1786                "ViaPlanHeaderV1 expected_source_ids must not be empty",
1787            ));
1788        }
1789        if self
1790            .header
1791            .expected_source_ids
1792            .iter()
1793            .any(|source| source.scope_digest != scope_digest)
1794        {
1795            return Err(ValidationError::new(
1796                "ViaPlanHeaderV1 expected_source_ids must match plan scope",
1797            ));
1798        }
1799        validate_plan_ttl(now, &self.header.expires_at)?;
1800        let expected = self
1801            .header
1802            .expected_source_ids
1803            .iter()
1804            .collect::<BTreeSet<_>>();
1805        let body_sources = self
1806            .body
1807            .source_ids()
1808            .into_iter()
1809            .cloned()
1810            .collect::<Vec<_>>();
1811        validate_unique(
1812            &body_sources,
1813            MAX_VIA_PLAN_SOURCES,
1814            "Via plan body source IDs",
1815        )?;
1816        if body_sources.is_empty() || body_sources.iter().any(|source| !expected.contains(source)) {
1817            return Err(ValidationError::new(
1818                "Via plan body sources must be covered by expected_source_ids",
1819            ));
1820        }
1821        if let ViaPlanBodyV1::Inference { inference_plan } = &self.body {
1822            inference_plan.validate(&self.scope)?;
1823        }
1824        let bytes = serde_json::to_vec(self)
1825            .map_err(|error| ValidationError::new(format!("serialize Via plan: {error}")))?;
1826        if bytes.len() > MAX_VIA_PLAN_BYTES {
1827            return Err(ValidationError::new(format!(
1828                "ViaOptimizationPlanV1 exceeds the {MAX_VIA_PLAN_BYTES} byte limit"
1829            )));
1830        }
1831        Ok(())
1832    }
1833
1834    /// Validate a plan against Edge-owned state immediately before execution.
1835    pub fn validate_for_execution(
1836        &self,
1837        now: &UtcTimestamp,
1838        current_event_id: &EventId,
1839        current_source_ids: &[ScopedContentIdV1],
1840        current_guard_digest: &Sha256Digest,
1841    ) -> Result<(), ValidationError> {
1842        self.validate_at(now)?;
1843        if &self.header.caused_by != current_event_id {
1844            return Err(ValidationError::new(
1845                "Via optimization plan causal event is stale",
1846            ));
1847        }
1848        validate_unique(
1849            current_source_ids,
1850            MAX_VIA_PLAN_SOURCES,
1851            "current source IDs",
1852        )?;
1853        let expected = self
1854            .header
1855            .expected_source_ids
1856            .iter()
1857            .collect::<BTreeSet<_>>();
1858        let current = current_source_ids.iter().collect::<BTreeSet<_>>();
1859        if current != expected {
1860            return Err(ValidationError::new(
1861                "Via optimization plan source state is stale",
1862            ));
1863        }
1864        if current_guard_digest != &self.header.immutable_guard_digest {
1865            return Err(ValidationError::new(
1866                "Via optimization plan immutable guard is stale",
1867            ));
1868        }
1869        Ok(())
1870    }
1871}
1872
1873/// Closed V1 serialization boundary between Edge and Via.
1874///
1875/// Only these protocol-owned variants can enter the canonical wire encoder.
1876/// Provider requests, headers, credentials, and cookie stores have no variant
1877/// and cannot acquire one outside this crate.
1878#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1879#[serde(
1880    tag = "message_type",
1881    content = "message",
1882    rename_all = "snake_case",
1883    deny_unknown_fields
1884)]
1885pub enum ViaWireMessageV1 {
1886    Hello(ViaProtocolHelloV1),
1887    Event(Box<ViaEventFrameV1>),
1888    EventAck(ViaEventAckV1),
1889    Backpressure(ViaBackpressureV1),
1890    StreamResume(ViaStreamResumeV1),
1891    StreamCursor(ViaStreamCursorV1),
1892    ContentManifest(Box<ContentManifestV1>),
1893    NeedContent(NeedContentV1),
1894    ContentChunk(Box<ContentChunkV1>),
1895    ContentChunkAck(ContentChunkAckV1),
1896    PlanRequest(ViaPlanRequestV1),
1897    OptimizationPlan(Box<ViaOptimizationPlanV1>),
1898}
1899
1900impl ViaWireMessageV1 {
1901    pub fn validate_at(&self, now: &UtcTimestamp) -> Result<(), ValidationError> {
1902        match self {
1903            Self::Hello(hello) => hello.validate(),
1904            Self::Event(event) => event.validate(),
1905            Self::EventAck(ack) => ack.validate(),
1906            Self::Backpressure(backpressure) => backpressure.validate(),
1907            Self::StreamResume(resume) => resume.validate(),
1908            Self::StreamCursor(cursor) => cursor.validate(),
1909            Self::ContentManifest(manifest) => manifest.validate(),
1910            Self::NeedContent(request) => request.validate_at(now),
1911            Self::ContentChunk(chunk) => chunk.validate_at(now),
1912            Self::ContentChunkAck(ack) => ack.validate(),
1913            Self::PlanRequest(request) => request.validate(),
1914            Self::OptimizationPlan(plan) => plan.validate_at(now),
1915        }
1916    }
1917
1918    pub fn encode_at(&self, now: &UtcTimestamp) -> Result<Vec<u8>, ValidationError> {
1919        self.validate_at(now)?;
1920        let bytes = serde_json::to_vec(self)
1921            .map_err(|error| ValidationError::new(format!("serialize Via V1 message: {error}")))?;
1922        if bytes.len() > MAX_VIA_WIRE_BYTES {
1923            return Err(ValidationError::new(format!(
1924                "ViaWireMessageV1 exceeds the {MAX_VIA_WIRE_BYTES} byte limit"
1925            )));
1926        }
1927        if matches!(self, Self::PlanRequest(_)) && bytes.len() > MAX_VIA_PLAN_REQUEST_BYTES {
1928            return Err(ValidationError::new(format!(
1929                "Via plan request exceeds the {MAX_VIA_PLAN_REQUEST_BYTES} byte limit"
1930            )));
1931        }
1932        Ok(bytes)
1933    }
1934
1935    pub fn decode_at(bytes: &[u8], now: &UtcTimestamp) -> Result<Self, ValidationError> {
1936        if bytes.len() > MAX_VIA_WIRE_BYTES {
1937            return Err(ValidationError::new(format!(
1938                "ViaWireMessageV1 exceeds the {MAX_VIA_WIRE_BYTES} byte limit"
1939            )));
1940        }
1941        let message = serde_json::from_slice::<Self>(bytes)
1942            .map_err(|error| ValidationError::new(format!("decode Via V1 message: {error}")))?;
1943        if matches!(message, Self::PlanRequest(_)) && bytes.len() > MAX_VIA_PLAN_REQUEST_BYTES {
1944            return Err(ValidationError::new(format!(
1945                "Via plan request exceeds the {MAX_VIA_PLAN_REQUEST_BYTES} byte limit"
1946            )));
1947        }
1948        message.validate_at(now)?;
1949        Ok(message)
1950    }
1951}
1952
1953#[cfg(test)]
1954mod tests {
1955    use super::*;
1956
1957    const A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1958    const B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
1959    const C: &str = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
1960    const FORBIDDEN_AUTH_FIELDS: &[&str] = &[
1961        "authorization",
1962        "cookie",
1963        "api_key",
1964        "api-key",
1965        "x-api-key",
1966        "x-goog-api-key",
1967        "x-oauth-token",
1968        "x-xai-token-auth",
1969        "oauth_token",
1970        "access_token",
1971        "refresh_token",
1972        "session_token",
1973        "client_secret",
1974        "credentials",
1975        "provider_headers",
1976        "raw_provider_request",
1977    ];
1978
1979    fn id<T>(value: &str) -> T
1980    where
1981        T: TryFrom<String>,
1982        <T as TryFrom<String>>::Error: std::fmt::Debug,
1983    {
1984        T::try_from(value.to_owned()).unwrap()
1985    }
1986
1987    fn digest(value: &str) -> Sha256Digest {
1988        Sha256Digest::new(value).unwrap()
1989    }
1990
1991    fn scope(classification: ViaClassificationV1) -> ViaScopeV1 {
1992        ViaScopeV1 {
1993            protocol_version: VIA_PROTOCOL_V1,
1994            event_or_request_id: id("request-1"),
1995            causality_id: Some(id("cause-1")),
1996            user_id: Some(id("user-1")),
1997            device_id: Some(id("device-1")),
1998            project_id: Some(id("project-1")),
1999            workspace_id: Some(id("workspace-1")),
2000            session_id: Some(id("session-1")),
2001            conversation_id: Some(id("conversation-1")),
2002            agent_id: Some(id("agent-1")),
2003            team_id: None,
2004            organization_id: None,
2005            classification,
2006        }
2007    }
2008
2009    fn content_id(scope: &ViaScopeV1) -> ScopedContentIdV1 {
2010        ScopedContentIdV1 {
2011            scope_digest: scope.digest().unwrap(),
2012            content_digest: digest(B),
2013        }
2014    }
2015
2016    fn stream_id(scope: &ViaScopeV1) -> ScopedReferenceV1 {
2017        ScopedReferenceV1::new(scope, id("stream-1")).unwrap()
2018    }
2019
2020    fn valid_manifest(
2021        scope: ViaScopeV1,
2022        kind: ContentKindV1,
2023        availability: ContentAvailabilityV1,
2024    ) -> ContentManifestV1 {
2025        let content_id = content_id(&scope);
2026        ContentManifestV1 {
2027            scope,
2028            content_id,
2029            kind,
2030            byte_count: 4,
2031            token_counts: vec![],
2032            source_version: None,
2033            language: None,
2034            structural_manifest: None,
2035            chunks: vec![ChunkDescriptorV1 {
2036                chunk_id: digest(A),
2037                start_byte: 0,
2038                end_byte: 4,
2039                byte_count: 4,
2040                symbol: None,
2041                token_count: Some(1),
2042                operator_id: id("operator:chunk"),
2043                operator_version: SemanticVersion::new("1.0.0").unwrap(),
2044            }],
2045            availability,
2046        }
2047    }
2048
2049    fn valid_plan(scope: ViaScopeV1, body: ViaPlanBodyV1) -> ViaOptimizationPlanV1 {
2050        ViaOptimizationPlanV1 {
2051            protocol_version: VIA_PROTOCOL_V1,
2052            header: ViaPlanHeaderV1 {
2053                plan_id: id("plan-wire-1"),
2054                caused_by: id("event-wire-1"),
2055                policy_id: id("policy:wire"),
2056                policy_version: SemanticVersion::new("1.0.0").unwrap(),
2057                expected_source_ids: vec![content_id(&scope)],
2058                immutable_guard_digest: digest(A),
2059                operator_id: id("operator:wire"),
2060                operator_version: SemanticVersion::new("1.0.0").unwrap(),
2061                expires_at: UtcTimestamp::new("2026-08-26T17:05:00Z").unwrap(),
2062                output_budget_tokens: 1_000,
2063            },
2064            scope,
2065            body,
2066        }
2067    }
2068
2069    fn assert_observed_kind_is_covered(kind: ViaObservedEventKindV1) {
2070        match kind {
2071            ViaObservedEventKindV1::SessionStarted
2072            | ViaObservedEventKindV1::SessionEnded
2073            | ViaObservedEventKindV1::AgentObserved
2074            | ViaObservedEventKindV1::UserMessageObserved
2075            | ViaObservedEventKindV1::AssistantMessageObserved
2076            | ViaObservedEventKindV1::FileObserved
2077            | ViaObservedEventKindV1::FileReadObserved
2078            | ViaObservedEventKindV1::FileChanged
2079            | ViaObservedEventKindV1::SearchExecuted
2080            | ViaObservedEventKindV1::SearchResultObserved
2081            | ViaObservedEventKindV1::ShellExecuted
2082            | ViaObservedEventKindV1::ShellResultObserved
2083            | ViaObservedEventKindV1::McpToolCalled
2084            | ViaObservedEventKindV1::McpResultObserved
2085            | ViaObservedEventKindV1::ProviderRequestPending
2086            | ViaObservedEventKindV1::ContextDelivered
2087            | ViaObservedEventKindV1::ContextReused
2088            | ViaObservedEventKindV1::ContextInvalidated => {}
2089        }
2090    }
2091
2092    fn assert_manifest_enums_are_covered(
2093        classification: ViaClassificationV1,
2094        kind: ContentKindV1,
2095        availability: ContentAvailabilityV1,
2096    ) {
2097        match classification {
2098            ViaClassificationV1::Public
2099            | ViaClassificationV1::Normal
2100            | ViaClassificationV1::Sensitive
2101            | ViaClassificationV1::Secret
2102            | ViaClassificationV1::LocalOnly
2103            | ViaClassificationV1::EnterprisePrivate => {}
2104        }
2105        match kind {
2106            ContentKindV1::SourceFile
2107            | ContentKindV1::Message
2108            | ContentKindV1::SearchResult
2109            | ContentKindV1::ShellResult
2110            | ContentKindV1::McpResult
2111            | ContentKindV1::InferenceManifest => {}
2112        }
2113        match availability {
2114            ContentAvailabilityV1::EdgeOnly
2115            | ContentAvailabilityV1::TransientRemoteAllowed
2116            | ContentAvailabilityV1::DeniedRemote => {}
2117        }
2118    }
2119
2120    #[test]
2121    fn identity_scope_round_trips_with_reserved_agent_fields() {
2122        let value = scope(ViaClassificationV1::Normal);
2123        let json = serde_json::to_string(&value).unwrap();
2124        assert_eq!(serde_json::from_str::<ViaScopeV1>(&json).unwrap(), value);
2125        assert!(json.contains("conversation_id"));
2126        assert!(json.contains("agent_id"));
2127    }
2128
2129    #[test]
2130    fn negotiation_selects_v1_and_rejects_no_overlap() {
2131        let hello = ViaProtocolHelloV1 {
2132            minimum_version: 1,
2133            maximum_version: 2,
2134            capabilities: vec![ViaCapabilityV1::EventStream],
2135        };
2136        assert_eq!(hello.negotiate(&hello).unwrap(), VIA_PROTOCOL_V1);
2137        let future = ViaProtocolHelloV1 {
2138            minimum_version: 2,
2139            maximum_version: 3,
2140            capabilities: vec![],
2141        };
2142        assert!(hello.negotiate(&future).is_err());
2143        let peer = ViaProtocolHelloV1 {
2144            minimum_version: 1,
2145            maximum_version: 1,
2146            capabilities: vec![ViaCapabilityV1::TypedPlans, ViaCapabilityV1::EventStream],
2147        };
2148        assert_eq!(
2149            hello.shared_capabilities(&peer).unwrap(),
2150            vec![ViaCapabilityV1::EventStream]
2151        );
2152    }
2153
2154    #[test]
2155    fn manifest_is_metadata_only_bounded_and_classification_safe() {
2156        let scope = scope(ViaClassificationV1::Secret);
2157        let mut manifest = ContentManifestV1 {
2158            content_id: content_id(&scope),
2159            scope,
2160            kind: ContentKindV1::SourceFile,
2161            byte_count: 4,
2162            token_counts: vec![],
2163            source_version: None,
2164            language: Some("rust".into()),
2165            structural_manifest: None,
2166            chunks: vec![ChunkDescriptorV1 {
2167                chunk_id: digest(A),
2168                start_byte: 0,
2169                end_byte: 4,
2170                byte_count: 4,
2171                symbol: None,
2172                token_count: Some(1),
2173                operator_id: id("operator:chunk"),
2174                operator_version: SemanticVersion::new("1.0.0").unwrap(),
2175            }],
2176            availability: ContentAvailabilityV1::TransientRemoteAllowed,
2177        };
2178        assert!(manifest.validate().is_err());
2179        manifest.availability = ContentAvailabilityV1::EdgeOnly;
2180        manifest.validate().unwrap();
2181        manifest.content_id.scope_digest = digest(A);
2182        assert!(manifest.validate().is_err());
2183        manifest.content_id.scope_digest = manifest.scope.digest().unwrap();
2184        let json = serde_json::to_string(&manifest).unwrap();
2185        assert!(!json.contains("bytes"));
2186    }
2187
2188    #[test]
2189    fn event_hash_is_deterministic_and_detects_mutation() {
2190        let scope = scope(ViaClassificationV1::Normal);
2191        let payload = ViaEventPayloadV1::Observation(ObservationPayloadV1 {
2192            kind: ViaObservedEventKindV1::FileObserved,
2193            input_ids: vec![],
2194            output_ids: vec![content_id(&scope)],
2195            capability_id: None,
2196        });
2197        let mut frame = ViaEventFrameV1::new(
2198            stream_id(&scope),
2199            1,
2200            id("event-1"),
2201            vec![],
2202            scope,
2203            payload.clone(),
2204        )
2205        .unwrap();
2206        assert_eq!(frame.payload_hash, payload.digest().unwrap());
2207        assert_eq!(
2208            serde_json::to_vec(&frame).unwrap(),
2209            serde_json::to_vec(&frame).unwrap()
2210        );
2211        frame.payload = ViaEventPayloadV1::Plan(PlanEventPayloadV1 {
2212            plan_id: id("plan-1"),
2213            status: PlanEventStatusV1::Applied,
2214        });
2215        assert!(frame.validate().is_err());
2216    }
2217
2218    #[test]
2219    fn event_decoder_rejects_unsupported_version() {
2220        let scope = scope(ViaClassificationV1::Normal);
2221        let payload = ViaEventPayloadV1::Observation(ObservationPayloadV1 {
2222            kind: ViaObservedEventKindV1::SessionStarted,
2223            input_ids: vec![],
2224            output_ids: vec![],
2225            capability_id: None,
2226        });
2227        let frame =
2228            ViaEventFrameV1::new(stream_id(&scope), 0, id("event-1"), vec![], scope, payload)
2229                .unwrap();
2230        let mut value = serde_json::to_value(frame).unwrap();
2231        value["protocol_version"] = serde_json::json!(2);
2232        assert!(serde_json::from_value::<ViaEventFrameV1>(value).is_err());
2233    }
2234
2235    #[test]
2236    fn typed_plan_rejects_stale_or_unexpected_sources() {
2237        let scope = scope(ViaClassificationV1::Normal);
2238        let expected = content_id(&scope);
2239        let mut plan = ViaOptimizationPlanV1 {
2240            protocol_version: VIA_PROTOCOL_V1,
2241            scope,
2242            header: ViaPlanHeaderV1 {
2243                plan_id: id("plan-1"),
2244                caused_by: id("event-1"),
2245                policy_id: id("policy:read"),
2246                policy_version: SemanticVersion::new("1.0.0").unwrap(),
2247                expected_source_ids: vec![expected.clone()],
2248                immutable_guard_digest: digest(A),
2249                operator_id: id("operator:read"),
2250                operator_version: SemanticVersion::new("1.0.0").unwrap(),
2251                expires_at: UtcTimestamp::new("2026-08-26T17:05:00Z").unwrap(),
2252                output_budget_tokens: 1_000,
2253            },
2254            body: ViaPlanBodyV1::Read {
2255                source_id: expected.clone(),
2256            },
2257        };
2258        let now = UtcTimestamp::new("2026-08-26T17:00:00Z").unwrap();
2259        plan.validate_at(&now).unwrap();
2260        plan.validate_for_execution(
2261            &now,
2262            &id("event-1"),
2263            std::slice::from_ref(&expected),
2264            &digest(A),
2265        )
2266        .unwrap();
2267        assert!(
2268            plan.validate_for_execution(
2269                &now,
2270                &id("event-1"),
2271                std::slice::from_ref(&expected),
2272                &digest(B),
2273            )
2274            .is_err()
2275        );
2276        assert!(
2277            plan.validate_for_execution(&now, &id("event-1"), &[], &digest(A))
2278                .is_err()
2279        );
2280        assert!(
2281            plan.validate_for_execution(
2282                &now,
2283                &id("event-other"),
2284                std::slice::from_ref(&expected),
2285                &digest(A),
2286            )
2287            .is_err()
2288        );
2289        let expired = UtcTimestamp::new("2026-08-26T17:05:00Z").unwrap();
2290        assert!(plan.validate_at(&expired).is_err());
2291        plan.body = ViaPlanBodyV1::Reuse {
2292            source_ids: vec![expected.clone(), expected],
2293        };
2294        assert!(plan.validate_at(&now).is_err());
2295        plan.body = ViaPlanBodyV1::Read {
2296            source_id: ScopedContentIdV1 {
2297                scope_digest: digest(B),
2298                content_digest: digest(A),
2299            },
2300        };
2301        assert!(plan.validate_at(&now).is_err());
2302    }
2303
2304    #[test]
2305    fn scoped_content_identity_depends_on_scope_and_bytes_not_path() {
2306        let primary_scope = scope(ViaClassificationV1::Normal);
2307        let other = scope(ViaClassificationV1::Sensitive);
2308        let first = ScopedContentIdV1::from_scope_and_bytes(&primary_scope, b"same bytes").unwrap();
2309        let second =
2310            ScopedContentIdV1::from_scope_and_bytes(&primary_scope, b"same bytes").unwrap();
2311        let other_scope = ScopedContentIdV1::from_scope_and_bytes(&other, b"same bytes").unwrap();
2312        assert_eq!(first, second);
2313        assert_ne!(first, other_scope);
2314    }
2315
2316    #[test]
2317    fn manifest_rejects_gaps_and_incomplete_coverage() {
2318        let chunk = |chunk_id, start_byte, end_byte| ChunkDescriptorV1 {
2319            chunk_id: digest(chunk_id),
2320            start_byte,
2321            end_byte,
2322            byte_count: end_byte - start_byte,
2323            symbol: None,
2324            token_count: None,
2325            operator_id: id("operator:chunk"),
2326            operator_version: SemanticVersion::new("1.0.0").unwrap(),
2327        };
2328        let scope = scope(ViaClassificationV1::Normal);
2329        let mut manifest = ContentManifestV1 {
2330            content_id: content_id(&scope),
2331            scope,
2332            kind: ContentKindV1::SourceFile,
2333            byte_count: 8,
2334            token_counts: vec![],
2335            source_version: None,
2336            language: None,
2337            structural_manifest: None,
2338            chunks: vec![chunk(A, 0, 4), chunk(B, 5, 8)],
2339            availability: ContentAvailabilityV1::EdgeOnly,
2340        };
2341        assert!(manifest.validate().is_err());
2342        manifest.chunks = vec![chunk(A, 0, 4)];
2343        assert!(manifest.validate().is_err());
2344        manifest.chunks = vec![chunk(A, 0, 4), chunk(B, 4, 8)];
2345        manifest.validate().unwrap();
2346
2347        manifest.byte_count = 300;
2348        manifest.chunks = (0_u64..300)
2349            .map(|offset| ChunkDescriptorV1 {
2350                chunk_id: sha256(&offset.to_be_bytes()).unwrap(),
2351                start_byte: offset,
2352                end_byte: offset + 1,
2353                byte_count: 1,
2354                symbol: Some("x".repeat(1_024)),
2355                token_count: None,
2356                operator_id: id("operator:chunk"),
2357                operator_version: SemanticVersion::new("1.0.0").unwrap(),
2358            })
2359            .collect();
2360        assert!(manifest.validate().is_err());
2361    }
2362
2363    #[test]
2364    fn event_rejects_duplicate_self_causality_and_invalid_successor() {
2365        let scope = scope(ViaClassificationV1::Normal);
2366        let payload = || {
2367            ViaEventPayloadV1::Observation(ObservationPayloadV1 {
2368                kind: ViaObservedEventKindV1::SessionStarted,
2369                input_ids: vec![],
2370                output_ids: vec![],
2371                capability_id: None,
2372            })
2373        };
2374        let first = ViaEventFrameV1::new(
2375            stream_id(&scope),
2376            7,
2377            id("event-1"),
2378            vec![],
2379            scope.clone(),
2380            payload(),
2381        )
2382        .unwrap();
2383        let next = ViaEventFrameV1::new(
2384            stream_id(&scope),
2385            8,
2386            id("event-2"),
2387            vec![id("event-1")],
2388            scope.clone(),
2389            payload(),
2390        )
2391        .unwrap();
2392        next.validate_successor(&first).unwrap();
2393
2394        let terminal = ViaEventFrameV1::new(
2395            stream_id(&scope),
2396            u64::MAX,
2397            id("event-terminal"),
2398            vec![],
2399            scope.clone(),
2400            payload(),
2401        )
2402        .unwrap();
2403        let terminal_replay = ViaEventFrameV1::new(
2404            stream_id(&scope),
2405            u64::MAX,
2406            id("event-replay"),
2407            vec![],
2408            scope,
2409            payload(),
2410        )
2411        .unwrap();
2412        assert!(terminal_replay.validate_successor(&terminal).is_err());
2413
2414        let mut invalid = next.clone();
2415        invalid.stream_id.scope_digest = digest(A);
2416        assert!(invalid.validate().is_err());
2417        invalid = next.clone();
2418        invalid.seq = 9;
2419        assert!(invalid.validate_successor(&first).is_err());
2420        invalid = next.clone();
2421        invalid.causality = vec![id("event-1"), id("event-1")];
2422        assert!(invalid.validate().is_err());
2423        invalid.causality = vec![id("event-2")];
2424        assert!(invalid.validate().is_err());
2425    }
2426
2427    #[test]
2428    fn stream_ack_and_resume_cursor_are_exactly_bound() {
2429        let scope = scope(ViaClassificationV1::Normal);
2430        let stream_id = stream_id(&scope);
2431        let event_id: EventId = id("event-ack-7");
2432        let ack = ViaEventAckV1 {
2433            protocol_version: VIA_PROTOCOL_V1,
2434            stream_id: stream_id.clone(),
2435            acknowledged_seq: 7,
2436            acknowledged_event_id: event_id.clone(),
2437            next_seq: 8,
2438            status: ViaEventAckStatusV1::Stored,
2439            accepted_event_count: 8,
2440            graph_nodes: 2,
2441            graph_edges: 1,
2442        };
2443        ack.validate().unwrap();
2444        let mut invalid_ack = ack.clone();
2445        invalid_ack.next_seq = 9;
2446        assert!(invalid_ack.validate().is_err());
2447        invalid_ack = ack.clone();
2448        invalid_ack.accepted_event_count = 0;
2449        assert!(invalid_ack.validate().is_err());
2450        invalid_ack = ack.clone();
2451        invalid_ack.accepted_event_count = 9;
2452        assert!(invalid_ack.validate().is_err());
2453        invalid_ack = ack.clone();
2454        invalid_ack.graph_edges = MAX_VIA_ACK_GRAPH_EDGES + 1;
2455        assert!(invalid_ack.validate().is_err());
2456
2457        let backpressure = ViaBackpressureV1 {
2458            protocol_version: VIA_PROTOCOL_V1,
2459            stream_id: stream_id.clone(),
2460            accepted_window: MAX_VIA_STREAM_WINDOW,
2461            retry_after_ms: MAX_VIA_BACKPRESSURE_RETRY_MS,
2462        };
2463        backpressure.validate().unwrap();
2464        let mut invalid_backpressure = backpressure.clone();
2465        invalid_backpressure.accepted_window = MAX_VIA_STREAM_WINDOW + 1;
2466        assert!(invalid_backpressure.validate().is_err());
2467        invalid_backpressure = backpressure;
2468        invalid_backpressure.retry_after_ms = 0;
2469        assert!(invalid_backpressure.validate().is_err());
2470
2471        ViaStreamResumeV1 {
2472            protocol_version: VIA_PROTOCOL_V1,
2473            stream_id: stream_id.clone(),
2474        }
2475        .validate()
2476        .unwrap();
2477        ViaStreamCursorV1 {
2478            protocol_version: VIA_PROTOCOL_V1,
2479            stream_id: stream_id.clone(),
2480            next_seq: 0,
2481            last_accepted: None,
2482        }
2483        .validate()
2484        .unwrap();
2485        let cursor = ViaStreamCursorV1 {
2486            protocol_version: VIA_PROTOCOL_V1,
2487            stream_id,
2488            next_seq: 8,
2489            last_accepted: Some(ViaStreamPositionV1 { seq: 7, event_id }),
2490        };
2491        cursor.validate().unwrap();
2492        let mut invalid_cursor = cursor;
2493        invalid_cursor.next_seq = 7;
2494        assert!(invalid_cursor.validate().is_err());
2495    }
2496
2497    #[test]
2498    fn content_on_demand_is_request_bound_bounded_and_debug_redacted() {
2499        let now = UtcTimestamp::new("2026-08-26T17:00:00Z").unwrap();
2500        let expires_at = UtcTimestamp::new("2026-08-26T17:00:30Z").unwrap();
2501        let scope = scope(ViaClassificationV1::Normal);
2502        let source_content_id = ScopedContentIdV1::from_scope_and_bytes(&scope, b"hello").unwrap();
2503        let request_id = ScopedReferenceV1::new(&scope, id("content-request-1")).unwrap();
2504        let chunk_id = sha256(b"hello").unwrap();
2505        let request = NeedContentV1 {
2506            protocol_version: VIA_PROTOCOL_V1,
2507            request_id: request_id.clone(),
2508            source_content_id: source_content_id.clone(),
2509            chunk_ids: vec![chunk_id.clone()],
2510            purpose: NeedContentPurposeV1::ContextPlanning,
2511            max_bytes: 5,
2512            max_tokens: 5,
2513            expires_at: expires_at.clone(),
2514            policy_version: SemanticVersion::new("1.0.0").unwrap(),
2515        };
2516        request.validate_at(&now).unwrap();
2517        let mut invalid_request = request.clone();
2518        invalid_request.chunk_ids = vec![chunk_id.clone(); 2];
2519        assert!(invalid_request.validate_at(&now).is_err());
2520        invalid_request = request.clone();
2521        invalid_request.max_bytes = MAX_VIA_CONTENT_REQUEST_BYTES + 1;
2522        assert!(invalid_request.validate_at(&now).is_err());
2523        invalid_request = request.clone();
2524        invalid_request.expires_at = UtcTimestamp::new("2026-08-26T17:00:31Z").unwrap();
2525        assert!(invalid_request.validate_at(&now).is_err());
2526        assert!(request.validate_at(&expires_at).is_err());
2527
2528        let chunk = ContentChunkV1 {
2529            protocol_version: VIA_PROTOCOL_V1,
2530            request_id: request_id.clone(),
2531            source_content_id: source_content_id.clone(),
2532            chunk_id: chunk_id.clone(),
2533            start_byte: 0,
2534            end_byte: 5,
2535            byte_count: 5,
2536            source_version: sha256(b"hello").unwrap(),
2537            content: "hello".to_owned(),
2538            payload_digest: chunk_id.clone(),
2539            expires_at,
2540        };
2541        chunk.validate_at(&now).unwrap();
2542        assert!(!format!("{chunk:?}").contains("hello"));
2543        let mut over_ttl = chunk.clone();
2544        over_ttl.expires_at = UtcTimestamp::new("2026-08-26T17:00:31Z").unwrap();
2545        assert!(over_ttl.validate_at(&now).is_err());
2546        let mut altered = chunk.clone();
2547        altered.content = "jello".to_owned();
2548        assert!(altered.validate_at(&now).is_err());
2549
2550        let ack = ContentChunkAckV1 {
2551            protocol_version: VIA_PROTOCOL_V1,
2552            request_id,
2553            source_content_id,
2554            chunk_id,
2555        };
2556        ack.validate().unwrap();
2557        for message in [
2558            ViaWireMessageV1::NeedContent(request),
2559            ViaWireMessageV1::ContentChunk(Box::new(chunk)),
2560            ViaWireMessageV1::ContentChunkAck(ack),
2561        ] {
2562            let encoded = message.encode_at(&now).unwrap();
2563            assert_eq!(
2564                ViaWireMessageV1::decode_at(&encoded, &now).unwrap(),
2565                message
2566            );
2567        }
2568    }
2569
2570    #[test]
2571    fn negotiation_and_plan_bounds_fail_closed() {
2572        let hello = ViaProtocolHelloV1 {
2573            minimum_version: 1,
2574            maximum_version: 1,
2575            capabilities: vec![ViaCapabilityV1::EventStream, ViaCapabilityV1::EventStream],
2576        };
2577        assert!(hello.validate().is_err());
2578
2579        let scope = scope(ViaClassificationV1::Normal);
2580        let expected = content_id(&scope);
2581        let mut plan = ViaOptimizationPlanV1 {
2582            protocol_version: VIA_PROTOCOL_V1,
2583            scope,
2584            header: ViaPlanHeaderV1 {
2585                plan_id: id("plan-1"),
2586                caused_by: id("event-1"),
2587                policy_id: id("policy:read"),
2588                policy_version: SemanticVersion::new("1.0.0").unwrap(),
2589                expected_source_ids: vec![expected.clone()],
2590                immutable_guard_digest: digest(A),
2591                operator_id: id("operator:read"),
2592                operator_version: SemanticVersion::new("1.0.0").unwrap(),
2593                expires_at: UtcTimestamp::new("2026-08-26T17:05:00Z").unwrap(),
2594                output_budget_tokens: 0,
2595            },
2596            body: ViaPlanBodyV1::Read {
2597                source_id: expected,
2598            },
2599        };
2600        let now = UtcTimestamp::new("2026-08-26T17:00:00Z").unwrap();
2601        assert!(plan.validate_at(&now).is_err());
2602        plan.header.output_budget_tokens = MAX_VIA_OUTPUT_TOKENS + 1;
2603        assert!(plan.validate_at(&now).is_err());
2604        plan.header.output_budget_tokens = 1;
2605        plan.header.expires_at = UtcTimestamp::new("2026-08-26T17:05:01Z").unwrap();
2606        assert!(plan.validate_at(&now).is_err());
2607    }
2608
2609    #[test]
2610    fn wire_gate_covers_all_v1_variants_and_drops_or_rejects_auth_injection() {
2611        const CANARIES: &[&str] = &[
2612            "via-auth-canary-never-wire",
2613            "via-cookie-canary-never-wire",
2614            "via-oauth-canary-never-wire",
2615            "via-refresh-canary-never-wire",
2616            "via-client-secret-canary-never-wire",
2617        ];
2618        let now = UtcTimestamp::new("2026-08-26T17:00:00Z").unwrap();
2619        let normal_scope = scope(ViaClassificationV1::Normal);
2620        let source = content_id(&normal_scope);
2621        let mut messages = vec![ViaWireMessageV1::Hello(ViaProtocolHelloV1 {
2622            minimum_version: VIA_PROTOCOL_V1,
2623            maximum_version: VIA_PROTOCOL_V1,
2624            capabilities: vec![
2625                ViaCapabilityV1::EventStream,
2626                ViaCapabilityV1::ContentManifest,
2627                ViaCapabilityV1::ContentOnDemand,
2628                ViaCapabilityV1::TypedPlans,
2629                ViaCapabilityV1::MultiAgentIdentity,
2630            ],
2631        })];
2632        let stream = stream_id(&normal_scope);
2633        messages.extend([
2634            ViaWireMessageV1::EventAck(ViaEventAckV1 {
2635                protocol_version: VIA_PROTOCOL_V1,
2636                stream_id: stream.clone(),
2637                acknowledged_seq: 0,
2638                acknowledged_event_id: id("event-wire-ack"),
2639                next_seq: 1,
2640                status: ViaEventAckStatusV1::Stored,
2641                accepted_event_count: 1,
2642                graph_nodes: 0,
2643                graph_edges: 0,
2644            }),
2645            ViaWireMessageV1::Backpressure(ViaBackpressureV1 {
2646                protocol_version: VIA_PROTOCOL_V1,
2647                stream_id: stream.clone(),
2648                accepted_window: 0,
2649                retry_after_ms: 100,
2650            }),
2651            ViaWireMessageV1::StreamResume(ViaStreamResumeV1 {
2652                protocol_version: VIA_PROTOCOL_V1,
2653                stream_id: stream.clone(),
2654            }),
2655            ViaWireMessageV1::StreamCursor(ViaStreamCursorV1 {
2656                protocol_version: VIA_PROTOCOL_V1,
2657                stream_id: stream,
2658                next_seq: 0,
2659                last_accepted: None,
2660            }),
2661        ]);
2662
2663        for kind in [
2664            ViaObservedEventKindV1::SessionStarted,
2665            ViaObservedEventKindV1::SessionEnded,
2666            ViaObservedEventKindV1::AgentObserved,
2667            ViaObservedEventKindV1::UserMessageObserved,
2668            ViaObservedEventKindV1::AssistantMessageObserved,
2669            ViaObservedEventKindV1::FileObserved,
2670            ViaObservedEventKindV1::FileReadObserved,
2671            ViaObservedEventKindV1::FileChanged,
2672            ViaObservedEventKindV1::SearchExecuted,
2673            ViaObservedEventKindV1::SearchResultObserved,
2674            ViaObservedEventKindV1::ShellExecuted,
2675            ViaObservedEventKindV1::ShellResultObserved,
2676            ViaObservedEventKindV1::McpToolCalled,
2677            ViaObservedEventKindV1::McpResultObserved,
2678            ViaObservedEventKindV1::ProviderRequestPending,
2679            ViaObservedEventKindV1::ContextDelivered,
2680            ViaObservedEventKindV1::ContextReused,
2681            ViaObservedEventKindV1::ContextInvalidated,
2682        ] {
2683            assert_observed_kind_is_covered(kind);
2684            let payload = ViaEventPayloadV1::Observation(ObservationPayloadV1 {
2685                kind,
2686                input_ids: vec![source.clone()],
2687                output_ids: vec![],
2688                capability_id: None,
2689            });
2690            messages.push(ViaWireMessageV1::Event(Box::new(
2691                ViaEventFrameV1::new(
2692                    stream_id(&normal_scope),
2693                    1,
2694                    id("event-wire-1"),
2695                    vec![],
2696                    normal_scope.clone(),
2697                    payload,
2698                )
2699                .unwrap(),
2700            )));
2701        }
2702
2703        for status in [PlanEventStatusV1::Applied, PlanEventStatusV1::Rejected] {
2704            let payload = ViaEventPayloadV1::Plan(PlanEventPayloadV1 {
2705                plan_id: id("plan-wire-1"),
2706                status,
2707            });
2708            messages.push(ViaWireMessageV1::Event(Box::new(
2709                ViaEventFrameV1::new(
2710                    stream_id(&normal_scope),
2711                    1,
2712                    id("event-wire-1"),
2713                    vec![],
2714                    normal_scope.clone(),
2715                    payload,
2716                )
2717                .unwrap(),
2718            )));
2719        }
2720
2721        for (classification, kind) in [
2722            (ViaClassificationV1::Public, ContentKindV1::SourceFile),
2723            (ViaClassificationV1::Normal, ContentKindV1::Message),
2724            (ViaClassificationV1::Sensitive, ContentKindV1::SearchResult),
2725            (ViaClassificationV1::Secret, ContentKindV1::ShellResult),
2726            (ViaClassificationV1::LocalOnly, ContentKindV1::McpResult),
2727            (
2728                ViaClassificationV1::EnterprisePrivate,
2729                ContentKindV1::InferenceManifest,
2730            ),
2731        ] {
2732            assert_manifest_enums_are_covered(
2733                classification,
2734                kind,
2735                ContentAvailabilityV1::EdgeOnly,
2736            );
2737            messages.push(ViaWireMessageV1::ContentManifest(Box::new(valid_manifest(
2738                scope(classification),
2739                kind,
2740                ContentAvailabilityV1::EdgeOnly,
2741            ))));
2742        }
2743        messages.push(ViaWireMessageV1::ContentManifest(Box::new(valid_manifest(
2744            normal_scope.clone(),
2745            ContentKindV1::SourceFile,
2746            ContentAvailabilityV1::TransientRemoteAllowed,
2747        ))));
2748        messages.push(ViaWireMessageV1::ContentManifest(Box::new(valid_manifest(
2749            normal_scope.clone(),
2750            ContentKindV1::SourceFile,
2751            ContentAvailabilityV1::DeniedRemote,
2752        ))));
2753
2754        for body in [
2755            ViaPlanBodyV1::Read {
2756                source_id: source.clone(),
2757            },
2758            ViaPlanBodyV1::Search {
2759                index_id: source.clone(),
2760            },
2761            ViaPlanBodyV1::Shell {
2762                output_id: source.clone(),
2763            },
2764            ViaPlanBodyV1::Mcp {
2765                output_id: source.clone(),
2766            },
2767            ViaPlanBodyV1::Reuse {
2768                source_ids: vec![source.clone()],
2769            },
2770            ViaPlanBodyV1::Inference {
2771                inference_plan: InferencePlanV1 {
2772                    manifest_id: source.clone(),
2773                    transitions: vec![InferenceSegmentTransitionV1 {
2774                        position: 0,
2775                        old_segment_id: digest(A),
2776                        old_representation_id: source.clone(),
2777                        old_guard_hash: source.content_digest.clone(),
2778                        new_segment_id: digest(
2779                            "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
2780                        ),
2781                        new_representation_id: ScopedContentIdV1::from_scope_and_bytes(
2782                            &normal_scope,
2783                            b"new",
2784                        )
2785                        .unwrap(),
2786                        new_guard_hash: ScopedContentIdV1::from_scope_and_bytes(
2787                            &normal_scope,
2788                            b"new",
2789                        )
2790                        .unwrap()
2791                        .content_digest,
2792                        old_token_count: 2,
2793                        new_token_count: 1,
2794                    }],
2795                },
2796            },
2797        ] {
2798            messages.push(ViaWireMessageV1::OptimizationPlan(Box::new(valid_plan(
2799                normal_scope.clone(),
2800                body,
2801            ))));
2802        }
2803        messages.push(ViaWireMessageV1::PlanRequest(ViaPlanRequestV1 {
2804            protocol_version: VIA_PROTOCOL_V1,
2805            stream_id: stream_id(&normal_scope),
2806            event_id: id("event-wire-1"),
2807            immutable_guard_digest: digest(A),
2808            now: now.clone(),
2809            expires_at: UtcTimestamp::new("2026-08-26T17:00:30Z").unwrap(),
2810            inference_candidates: None,
2811        }));
2812
2813        for message in &messages {
2814            let encoded = message.encode_at(&now).unwrap();
2815            assert_eq!(
2816                ViaWireMessageV1::decode_at(&encoded, &now).unwrap(),
2817                *message
2818            );
2819            let wire = String::from_utf8(encoded).unwrap();
2820            for canary in CANARIES {
2821                assert!(!wire.contains(canary));
2822            }
2823            for field in FORBIDDEN_AUTH_FIELDS {
2824                assert!(!wire.contains(&format!("\"{field}\"")));
2825            }
2826        }
2827
2828        for (index, field) in FORBIDDEN_AUTH_FIELDS.iter().enumerate() {
2829            let canary = format!("via-{index}-credential-canary-never-wire");
2830            let mut injected = serde_json::to_value(&messages[0]).unwrap();
2831            injected[*field] = serde_json::json!(&canary);
2832            let injected = serde_json::to_vec(&injected).unwrap();
2833            assert!(ViaWireMessageV1::decode_at(&injected, &now).is_err());
2834
2835            let mut nested = serde_json::to_value(&messages[0]).unwrap();
2836            nested["message"][*field] = serde_json::json!(&canary);
2837            let nested = serde_json::to_vec(&nested).unwrap();
2838            assert!(ViaWireMessageV1::decode_at(&nested, &now).is_err());
2839        }
2840    }
2841
2842    #[test]
2843    fn representative_wire_values_have_no_provider_authority_fields() {
2844        fn assert_safe_keys(value: &serde_json::Value) {
2845            match value {
2846                serde_json::Value::Object(fields) => {
2847                    for (key, value) in fields {
2848                        assert!(!FORBIDDEN_AUTH_FIELDS.contains(&key.as_str()));
2849                        assert_safe_keys(value);
2850                    }
2851                }
2852                serde_json::Value::Array(values) => {
2853                    values.iter().for_each(assert_safe_keys);
2854                }
2855                _ => {}
2856            }
2857        }
2858
2859        let scope = scope(ViaClassificationV1::Sensitive);
2860        let payload = ViaEventPayloadV1::Observation(ObservationPayloadV1 {
2861            kind: ViaObservedEventKindV1::ProviderRequestPending,
2862            input_ids: vec![content_id(&scope)],
2863            output_ids: vec![],
2864            capability_id: Some(id("provider-call")),
2865        });
2866        let frame =
2867            ViaEventFrameV1::new(stream_id(&scope), 1, id("event-1"), vec![], scope, payload)
2868                .unwrap();
2869        assert_safe_keys(&serde_json::to_value(frame).unwrap());
2870    }
2871
2872    #[test]
2873    fn event_and_plan_decoders_reject_unknown_authority_fields() {
2874        let scope = scope(ViaClassificationV1::Normal);
2875        let payload = ViaEventPayloadV1::Observation(ObservationPayloadV1 {
2876            kind: ViaObservedEventKindV1::SessionStarted,
2877            input_ids: vec![],
2878            output_ids: vec![],
2879            capability_id: None,
2880        });
2881        let frame = ViaEventFrameV1::new(
2882            stream_id(&scope),
2883            1,
2884            id("event-1"),
2885            vec![],
2886            scope.clone(),
2887            payload,
2888        )
2889        .unwrap();
2890        let frame_value = serde_json::to_value(&frame).unwrap();
2891        assert!(serde_json::from_value::<ViaEventFrameV1>(frame_value.clone()).is_ok());
2892
2893        let expected = content_id(&scope);
2894        let plan = ViaOptimizationPlanV1 {
2895            protocol_version: VIA_PROTOCOL_V1,
2896            scope,
2897            header: ViaPlanHeaderV1 {
2898                plan_id: id("plan-1"),
2899                caused_by: id("event-1"),
2900                policy_id: id("policy:read"),
2901                policy_version: SemanticVersion::new("1.0.0").unwrap(),
2902                expected_source_ids: vec![expected.clone()],
2903                immutable_guard_digest: digest(A),
2904                operator_id: id("operator:read"),
2905                operator_version: SemanticVersion::new("1.0.0").unwrap(),
2906                expires_at: UtcTimestamp::new("2026-08-26T17:05:00Z").unwrap(),
2907                output_budget_tokens: 1_000,
2908            },
2909            body: ViaPlanBodyV1::Read {
2910                source_id: expected,
2911            },
2912        };
2913        let plan_value = serde_json::to_value(&plan).unwrap();
2914        let decoded_plan = serde_json::from_value::<ViaOptimizationPlanV1>(plan_value.clone());
2915        assert!(decoded_plan.is_ok(), "{decoded_plan:?}");
2916
2917        for field in FORBIDDEN_AUTH_FIELDS {
2918            let mut frame_with_unknown = frame_value.clone();
2919            frame_with_unknown[*field] = serde_json::json!("sensitive");
2920            assert!(
2921                serde_json::from_value::<ViaEventFrameV1>(frame_with_unknown).is_err(),
2922                "ViaEventFrameV1 must reject top-level {field}"
2923            );
2924
2925            let mut plan_with_unknown = plan_value.clone();
2926            plan_with_unknown[*field] = serde_json::json!("sensitive");
2927            assert!(
2928                serde_json::from_value::<ViaOptimizationPlanV1>(plan_with_unknown).is_err(),
2929                "ViaOptimizationPlanV1 must reject top-level {field}"
2930            );
2931        }
2932    }
2933
2934    #[test]
2935    fn inference_manifest_is_deterministic_metadata_only_and_strictly_bound() {
2936        const PROMPT_CANARY: &[u8] = b"provider-key-and-raw-prompt-canary";
2937        let scope = scope(ViaClassificationV1::Sensitive);
2938        let segment = |position, role, tokens| {
2939            InferenceSegmentV1::from_canonical_bytes(
2940                &scope,
2941                position,
2942                role,
2943                InferenceMutabilityV1::Immutable,
2944                PROMPT_CANARY,
2945                tokens,
2946                InferenceDeliveryStateV1::Uncached,
2947                Vec::new(),
2948            )
2949            .unwrap()
2950        };
2951        let first = InferenceManifestV1::new(
2952            scope.clone(),
2953            vec![
2954                segment(0, InferenceSegmentRoleV1::System, 4),
2955                segment(1, InferenceSegmentRoleV1::User, 7),
2956            ],
2957            None,
2958        )
2959        .unwrap();
2960        let second = InferenceManifestV1::new(
2961            scope.clone(),
2962            vec![
2963                segment(0, InferenceSegmentRoleV1::System, 4),
2964                segment(1, InferenceSegmentRoleV1::User, 7),
2965            ],
2966            None,
2967        )
2968        .unwrap();
2969        assert_eq!(first, second);
2970        assert_eq!(first.accounting.total_tokens, 11);
2971        let encoded = serde_json::to_vec(&first).unwrap();
2972        assert!(
2973            !encoded
2974                .windows(PROMPT_CANARY.len())
2975                .any(|window| window == PROMPT_CANARY)
2976        );
2977
2978        let mut tampered = first.clone();
2979        tampered.segments[1].position = 0;
2980        assert!(tampered.validate().is_err());
2981        let mut tampered = first.clone();
2982        tampered.segments[0].guard_hash = digest(A);
2983        assert!(tampered.validate().is_err());
2984        let mut tampered = first.clone();
2985        tampered.accounting.total_tokens += 1;
2986        assert!(tampered.validate().is_err());
2987        let mut unknown = serde_json::to_value(first).unwrap();
2988        unknown["provider_api_key"] = serde_json::json!("credential");
2989        assert!(serde_json::from_value::<InferenceManifestV1>(unknown).is_err());
2990    }
2991
2992    #[test]
2993    fn inference_manifest_bounds_and_overflow_fail_closed() {
2994        let scope = scope(ViaClassificationV1::Normal);
2995        assert!(InferenceManifestV1::new(scope.clone(), Vec::new(), None).is_err());
2996        let lineage = (0..=MAX_VIA_INFERENCE_LINEAGE)
2997            .map(|index| {
2998                ScopedContentIdV1::from_scope_and_bytes(&scope, index.to_string().as_bytes())
2999                    .unwrap()
3000            })
3001            .collect();
3002        assert!(
3003            InferenceSegmentV1::from_canonical_bytes(
3004                &scope,
3005                0,
3006                InferenceSegmentRoleV1::User,
3007                InferenceMutabilityV1::Immutable,
3008                b"bounded",
3009                1,
3010                InferenceDeliveryStateV1::Uncached,
3011                lineage,
3012            )
3013            .is_err()
3014        );
3015        let maximum = InferenceSegmentV1::from_canonical_bytes(
3016            &scope,
3017            0,
3018            InferenceSegmentRoleV1::System,
3019            InferenceMutabilityV1::Immutable,
3020            b"maximum",
3021            u64::MAX,
3022            InferenceDeliveryStateV1::Uncached,
3023            Vec::new(),
3024        )
3025        .unwrap();
3026        let overflow = InferenceSegmentV1::from_canonical_bytes(
3027            &scope,
3028            1,
3029            InferenceSegmentRoleV1::User,
3030            InferenceMutabilityV1::Immutable,
3031            b"overflow",
3032            1,
3033            InferenceDeliveryStateV1::Uncached,
3034            Vec::new(),
3035        )
3036        .unwrap();
3037        assert!(InferenceManifestV1::new(scope, vec![maximum, overflow], None).is_err());
3038    }
3039
3040    fn inference_plan_fixture() -> ViaOptimizationPlanV1 {
3041        let scope = ViaScopeV1 {
3042            protocol_version: VIA_PROTOCOL_V1,
3043            event_or_request_id: id("request-inference-plan"),
3044            causality_id: None,
3045            user_id: None,
3046            device_id: None,
3047            project_id: None,
3048            workspace_id: None,
3049            session_id: None,
3050            conversation_id: None,
3051            agent_id: None,
3052            team_id: None,
3053            organization_id: None,
3054            classification: ViaClassificationV1::Normal,
3055        };
3056        let scope_digest = scope.digest().unwrap();
3057        let scoped = |content_digest| ScopedContentIdV1 {
3058            scope_digest: scope_digest.clone(),
3059            content_digest,
3060        };
3061        let manifest_id = scoped(digest(B));
3062        ViaOptimizationPlanV1 {
3063            protocol_version: VIA_PROTOCOL_V1,
3064            scope,
3065            header: ViaPlanHeaderV1 {
3066                plan_id: id("plan-inference-1"),
3067                caused_by: id("event-inference-pending"),
3068                policy_id: id("via.inference-planning"),
3069                policy_version: SemanticVersion::new("1.0.0").unwrap(),
3070                expected_source_ids: vec![manifest_id.clone()],
3071                immutable_guard_digest: digest(A),
3072                operator_id: id("leanctx.inference.history.compact"),
3073                operator_version: SemanticVersion::new("1.0.0").unwrap(),
3074                expires_at: UtcTimestamp::new("2026-08-26T17:00:30Z").unwrap(),
3075                output_budget_tokens: 8,
3076            },
3077            body: ViaPlanBodyV1::Inference {
3078                inference_plan: InferencePlanV1 {
3079                    manifest_id: manifest_id.clone(),
3080                    transitions: vec![InferenceSegmentTransitionV1 {
3081                        position: 3,
3082                        old_segment_id: digest(A),
3083                        old_representation_id: manifest_id,
3084                        old_guard_hash: digest(B),
3085                        new_segment_id: digest(
3086                            "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
3087                        ),
3088                        new_representation_id: scoped(digest(
3089                            "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
3090                        )),
3091                        new_guard_hash: digest(
3092                            "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
3093                        ),
3094                        old_token_count: 10,
3095                        new_token_count: 5,
3096                    }],
3097                },
3098            },
3099        }
3100    }
3101
3102    #[test]
3103    #[allow(clippy::useless_concat)]
3104    fn inference_plan_wire_is_canonical_metadata_only_and_strict() {
3105        const FIXTURE: &str = concat!(
3106            r#"{"protocol_version":1,"scope":{"protocol_version":1,"event_or_request_id":"request-inference-plan","classification":"normal"},"header":{"plan_id":"plan-inference-1","caused_by":"event-inference-pending","policy_id":"via.inference-planning","policy_version":"1.0.0","expected_source_ids":[{"scope_digest":"sha256:4c6e93595932ddbe4418c020f30fe5fd321c4567ac17f5703644125647ccdcd0","content_digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}],"immutable_guard_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","operator_id":"leanctx.inference.history.compact","operator_version":"1.0.0","expires_at":"2026-08-26T17:00:30Z","output_budget_tokens":8},"plan_type":"inference","inference_plan":{"manifest_id":{"scope_digest":"sha256:4c6e93595932ddbe4418c020f30fe5fd321c4567ac17f5703644125647ccdcd0","content_digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"transitions":[{"position":3,"old_segment_id":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","old_representation_id":{"scope_digest":"sha256:4c6e93595932ddbe4418c020f30fe5fd321c4567ac17f5703644125647ccdcd0","content_digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"old_guard_hash":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","new_segment_id":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","new_representation_id":{"scope_digest":"sha256:4c6e93595932ddbe4418c020f30fe5fd321c4567ac17f5703644125647ccdcd0","content_digest":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"},"new_guard_hash":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd","old_token_count":10,"new_token_count":5}]}}"#,
3107        );
3108        let now = UtcTimestamp::new("2026-08-26T17:00:00Z").unwrap();
3109        let plan = inference_plan_fixture();
3110        plan.validate_at(&now).unwrap();
3111        assert_eq!(serde_json::to_string(&plan).unwrap(), FIXTURE);
3112        assert_eq!(
3113            serde_json::from_str::<ViaOptimizationPlanV1>(FIXTURE).unwrap(),
3114            plan
3115        );
3116        for canary in ["provider-key", "raw-prompt", "authorization", "cookie"] {
3117            assert!(!FIXTURE.contains(canary));
3118        }
3119    }
3120
3121    #[test]
3122    fn inference_plan_bounds_and_hostile_shapes_fail_closed() {
3123        let now = UtcTimestamp::new("2026-08-26T17:00:00Z").unwrap();
3124        let valid = inference_plan_fixture();
3125
3126        let mutate = |mut plan: ViaOptimizationPlanV1, change: fn(&mut InferencePlanV1)| {
3127            let ViaPlanBodyV1::Inference { inference_plan } = &mut plan.body else {
3128                unreachable!()
3129            };
3130            change(inference_plan);
3131            assert!(plan.validate_at(&now).is_err());
3132        };
3133        mutate(valid.clone(), |body| body.transitions.clear());
3134        mutate(valid.clone(), |body| {
3135            body.transitions[0].new_token_count = body.transitions[0].old_token_count;
3136        });
3137        mutate(valid.clone(), |body| {
3138            body.transitions.push(body.transitions[0].clone());
3139        });
3140        mutate(valid.clone(), |body| {
3141            body.transitions[0].new_guard_hash = digest(A);
3142        });
3143
3144        let mut unknown = serde_json::to_value(valid).unwrap();
3145        unknown["inference_plan"]["raw_provider_request"] =
3146            serde_json::json!("provider-key-and-prompt-canary");
3147        assert!(serde_json::from_value::<ViaOptimizationPlanV1>(unknown).is_err());
3148        let encoded = serde_json::to_string(&inference_plan_fixture()).unwrap();
3149        let duplicate = encoded.replacen(
3150            "\"plan_type\":\"inference\"",
3151            "\"plan_type\":\"inference\",\"plan_type\":\"inference\"",
3152            1,
3153        );
3154        assert!(serde_json::from_str::<ViaOptimizationPlanV1>(&duplicate).is_err());
3155    }
3156
3157    fn inference_candidate_set_fixture(scope: &ViaScopeV1) -> InferenceCandidateSetV1 {
3158        let scope_digest = scope.digest().unwrap();
3159        let scoped = |content_digest| ScopedContentIdV1 {
3160            scope_digest: scope_digest.clone(),
3161            content_digest,
3162        };
3163        let mut candidates = InferenceCandidateSetV1 {
3164            candidate_set_id: scoped(digest(A)),
3165            manifest_id: scoped(digest(B)),
3166            operator_id: id("leanctx.inference.history.compact"),
3167            operator_version: SemanticVersion::new("1.0.0").unwrap(),
3168            transitions: vec![InferenceSegmentTransitionV1 {
3169                position: 4,
3170                old_segment_id: digest(A),
3171                old_representation_id: scoped(digest(B)),
3172                old_guard_hash: digest(B),
3173                new_segment_id: digest(C),
3174                new_representation_id: scoped(digest(C)),
3175                new_guard_hash: digest(C),
3176                old_token_count: 10,
3177                new_token_count: 5,
3178            }],
3179        };
3180        candidates.candidate_set_id = candidates.expected_id(&scope_digest).unwrap();
3181        candidates
3182    }
3183
3184    #[test]
3185    fn inference_candidate_set_wire_is_exact_metadata_only_and_strict() {
3186        let scope = scope(ViaClassificationV1::Normal);
3187        let candidates = inference_candidate_set_fixture(&scope);
3188        candidates
3189            .validate_for_scope(&scope.digest().unwrap())
3190            .unwrap();
3191        assert_eq!(
3192            candidates.candidate_set_id.content_digest.as_str(),
3193            "sha256:06a8f76a1d6118e7508364a6b5f72bebcde7a851d07a23c49e2ef0108f35fda9"
3194        );
3195        let value = serde_json::to_value(&candidates).unwrap();
3196        assert_eq!(
3197            value.as_object().unwrap().keys().collect::<Vec<_>>(),
3198            vec![
3199                "candidate_set_id",
3200                "manifest_id",
3201                "operator_id",
3202                "operator_version",
3203                "transitions"
3204            ]
3205        );
3206        let request = ViaPlanRequestV1 {
3207            protocol_version: VIA_PROTOCOL_V1,
3208            stream_id: stream_id(&scope),
3209            event_id: id("event-candidates"),
3210            immutable_guard_digest: digest(A),
3211            now: UtcTimestamp::new("2026-08-26T17:00:00Z").unwrap(),
3212            expires_at: UtcTimestamp::new("2026-08-26T17:00:30Z").unwrap(),
3213            inference_candidates: Some(candidates.clone()),
3214        };
3215        request.validate().unwrap();
3216        let encoded = serde_json::to_string(&request).unwrap();
3217        const REQUEST_FIXTURE: &str = r#"{"protocol_version":1,"stream_id":{"scope_digest":"sha256:261c81d042ed5b69e3ff4d24e632714d700d39ca9cd6e1295565c12a15d7124d","reference":"stream-1"},"event_id":"event-candidates","immutable_guard_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","now":"2026-08-26T17:00:00Z","expires_at":"2026-08-26T17:00:30Z","inference_candidates":{"candidate_set_id":{"scope_digest":"sha256:261c81d042ed5b69e3ff4d24e632714d700d39ca9cd6e1295565c12a15d7124d","content_digest":"sha256:06a8f76a1d6118e7508364a6b5f72bebcde7a851d07a23c49e2ef0108f35fda9"},"manifest_id":{"scope_digest":"sha256:261c81d042ed5b69e3ff4d24e632714d700d39ca9cd6e1295565c12a15d7124d","content_digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"operator_id":"leanctx.inference.history.compact","operator_version":"1.0.0","transitions":[{"position":4,"old_segment_id":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","old_representation_id":{"scope_digest":"sha256:261c81d042ed5b69e3ff4d24e632714d700d39ca9cd6e1295565c12a15d7124d","content_digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"old_guard_hash":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","new_segment_id":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","new_representation_id":{"scope_digest":"sha256:261c81d042ed5b69e3ff4d24e632714d700d39ca9cd6e1295565c12a15d7124d","content_digest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"},"new_guard_hash":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","old_token_count":10,"new_token_count":5}]}}"#;
3218        assert_eq!(encoded, REQUEST_FIXTURE);
3219        assert_eq!(
3220            serde_json::from_str::<ViaPlanRequestV1>(&encoded).unwrap(),
3221            request
3222        );
3223        for canary in ["provider-key", "raw-prompt", "authorization", "cookie"] {
3224            assert!(!encoded.contains(canary));
3225        }
3226        let mut unknown = serde_json::to_value(&candidates).unwrap();
3227        unknown["raw_prompt"] = serde_json::json!("prompt-canary");
3228        assert!(serde_json::from_value::<InferenceCandidateSetV1>(unknown).is_err());
3229        let duplicate = serde_json::to_string(&candidates).unwrap().replacen(
3230            "\"operator_id\":\"leanctx.inference.history.compact\"",
3231            "\"operator_id\":\"leanctx.inference.history.compact\",\"operator_id\":\"leanctx.inference.history.compact\"",
3232            1,
3233        );
3234        assert!(serde_json::from_str::<InferenceCandidateSetV1>(&duplicate).is_err());
3235    }
3236
3237    #[test]
3238    fn inference_candidate_set_limits_hash_and_scope_reject_hostile() {
3239        let scope = scope(ViaClassificationV1::Normal);
3240        let valid = inference_candidate_set_fixture(&scope);
3241        let scope_digest = scope.digest().unwrap();
3242        let mut wrong_scope = valid.clone();
3243        wrong_scope.manifest_id.scope_digest = digest(A);
3244        assert!(wrong_scope.validate_for_scope(&scope_digest).is_err());
3245        let mut wrong_request_scope = ViaPlanRequestV1 {
3246            protocol_version: VIA_PROTOCOL_V1,
3247            stream_id: stream_id(&scope),
3248            event_id: id("event-candidates"),
3249            immutable_guard_digest: digest(A),
3250            now: UtcTimestamp::new("2026-08-26T17:00:00Z").unwrap(),
3251            expires_at: UtcTimestamp::new("2026-08-26T17:00:30Z").unwrap(),
3252            inference_candidates: Some(valid.clone()),
3253        };
3254        wrong_request_scope.stream_id.scope_digest = digest(A);
3255        assert!(wrong_request_scope.validate().is_err());
3256
3257        let mut wrong_operator = valid.clone();
3258        wrong_operator.operator_id = id("operator:unknown");
3259        assert!(wrong_operator.validate_for_scope(&scope_digest).is_err());
3260
3261        let mut wrong_hash = valid.clone();
3262        wrong_hash.transitions[0].new_guard_hash = digest(A);
3263        assert!(wrong_hash.validate_for_scope(&scope_digest).is_err());
3264
3265        let mut unsorted = valid.clone();
3266        unsorted.transitions.push(valid.transitions[0].clone());
3267        assert!(unsorted.validate_for_scope(&scope_digest).is_err());
3268
3269        let mut maximum = valid.clone();
3270        maximum.transitions = (0..MAX_VIA_INFERENCE_CANDIDATES)
3271            .map(|position| {
3272                let mut transition = maximum.transitions[0].clone();
3273                transition.position = position as u16;
3274                transition.old_segment_id = digest(&format!("sha256:{position:064x}"));
3275                transition.new_segment_id = digest(&format!("sha256:{:064x}", position + 1));
3276                transition
3277            })
3278            .collect();
3279        maximum.candidate_set_id = maximum.expected_id(&scope_digest).unwrap();
3280        maximum.validate_for_scope(&scope_digest).unwrap();
3281        let now = UtcTimestamp::new("2026-08-26T17:00:00Z").unwrap();
3282        let request = ViaPlanRequestV1 {
3283            protocol_version: VIA_PROTOCOL_V1,
3284            stream_id: stream_id(&scope),
3285            event_id: id("event-candidates-maximum"),
3286            immutable_guard_digest: digest(A),
3287            now: now.clone(),
3288            expires_at: UtcTimestamp::new("2026-08-26T17:00:30Z").unwrap(),
3289            inference_candidates: Some(maximum),
3290        };
3291        let encoded = ViaWireMessageV1::PlanRequest(request)
3292            .encode_at(&now)
3293            .unwrap();
3294        assert!(encoded.len() <= MAX_VIA_PLAN_REQUEST_BYTES);
3295        let mut over_byte_cap = encoded;
3296        over_byte_cap.resize(MAX_VIA_PLAN_REQUEST_BYTES + 1, b' ');
3297        assert!(ViaWireMessageV1::decode_at(&over_byte_cap, &now).is_err());
3298
3299        let mut oversized = valid;
3300        oversized.transitions = (0..MAX_VIA_INFERENCE_CANDIDATES + 1)
3301            .map(|position| {
3302                let mut transition = oversized.transitions[0].clone();
3303                transition.position = position as u16;
3304                transition.old_segment_id = digest(&format!("sha256:{position:064x}"));
3305                transition.new_segment_id = digest(&format!("sha256:{:064x}", position + 1));
3306                transition
3307            })
3308            .collect();
3309        assert!(oversized.validate_for_scope(&scope_digest).is_err());
3310    }
3311}