1use 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 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)] 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 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
1447pub 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 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#[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}