1use crate::context::{
10 ContextItem, ContextProvider, ContextProviderFailureMode, ContextQuery, ContextResult,
11 ContextType,
12};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use std::collections::HashSet;
16use std::sync::Arc;
17use thiserror::Error;
18
19pub const COGNITIVE_PACKAGE_BINDING_SCHEMA: &str = "a3s.code.cognitive-package-session-binding.v1";
20pub const COGNITIVE_KNOWLEDGE_BINDING_SCHEMA: &str =
21 "agentic.ontology.r0-knowledge-lease-binding.v1";
22pub const COGNITIVE_CONTEXT_REQUEST_SCHEMA: &str = "a3s.code.cognitive-context-request.v1";
23pub const COGNITIVE_CONTEXT_RESPONSE_SCHEMA: &str = "a3s.code.cognitive-context-response.v1";
24pub const COGNITIVE_CONTEXT_REQUEST_DIGEST_DOMAIN: &str = "a3s.code.cognitive-context-request.v1";
25pub const OKF_KNOWLEDGE_SEARCH_REQUEST_SCHEMA: &str = "a3s.use.okf-knowledge-search-request.v1";
26pub const OKF_KNOWLEDGE_READ_REQUEST_SCHEMA: &str = "a3s.use.okf-knowledge-read-request.v1";
27pub const OKF_KNOWLEDGE_CITATION_SCHEMA: &str = "a3s.use.okf-knowledge-citation.v1";
28pub const COGNITIVE_CITATION_METADATA: &str = "a3s.cognitive.citation";
29pub const COGNITIVE_PACKAGE_BINDING_METADATA: &str = "a3s.cognitive.package_binding";
30
31const CAPABILITY_SNAPSHOT_DIGEST_DOMAIN: &str = "a3s.use.capability-snapshot.v1";
32const CANONICAL_DIGEST_PREFIX: &[u8] = b"agentic-ontology-canonical-v1\0";
33const MAX_QUERY_BYTES: usize = 4 * 1024;
34const MAX_PROVIDER_NAME_BYTES: usize = 128;
35const MAX_PACKAGE_VERSION_BYTES: usize = 128;
36const MAX_SURFACE_ID_BYTES: usize = 256;
37const MAX_FORMAT_VERSION_BYTES: usize = 64;
38const MAX_DOCUMENT_PATH_BYTES: usize = 512;
39const MAX_HEADING_BYTES: usize = 128;
40const MAX_EVIDENCE_IDS: usize = 256;
41const MAX_CONTEXT_RESULTS: usize = 4;
42const MAX_CONTEXT_DOCUMENT_BYTES: usize = 6 * 1024;
43const MAX_CONTEXT_TOTAL_BYTES: usize = 6 * 1024;
44
45#[derive(Debug, Clone, PartialEq, Eq, Error)]
47pub enum CognitiveContextError {
48 #[error("invalid cognitive package binding: {0}")]
49 InvalidBinding(String),
50 #[error("invalid cognitive context limits: {0}")]
51 InvalidLimits(String),
52 #[error("invalid cognitive context request: {0}")]
53 InvalidRequest(String),
54 #[error("cognitive context provider failed: {0}")]
55 Provider(String),
56 #[error("cognitive context response drifted: {0}")]
57 ResponseDrift(String),
58}
59
60pub type CognitiveContextResult<T> = std::result::Result<T, CognitiveContextError>;
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase", deny_unknown_fields)]
65pub struct CognitiveKnowledgeBindingV1 {
66 pub schema: String,
67 pub surface_id: String,
68 pub format_version: String,
69 pub content_digest: String,
70 pub search_schema: String,
71 pub read_schema: String,
72 pub citation_schema: String,
73 pub lifecycle_generation: u64,
74 pub generation_digest: String,
75}
76
77impl CognitiveKnowledgeBindingV1 {
78 pub fn new(
79 surface_id: impl Into<String>,
80 format_version: impl Into<String>,
81 content_digest: impl Into<String>,
82 lifecycle_generation: u64,
83 generation_digest: impl Into<String>,
84 ) -> CognitiveContextResult<Self> {
85 let binding = Self {
86 schema: COGNITIVE_KNOWLEDGE_BINDING_SCHEMA.to_string(),
87 surface_id: surface_id.into(),
88 format_version: format_version.into(),
89 content_digest: content_digest.into(),
90 search_schema: OKF_KNOWLEDGE_SEARCH_REQUEST_SCHEMA.to_string(),
91 read_schema: OKF_KNOWLEDGE_READ_REQUEST_SCHEMA.to_string(),
92 citation_schema: OKF_KNOWLEDGE_CITATION_SCHEMA.to_string(),
93 lifecycle_generation,
94 generation_digest: generation_digest.into(),
95 };
96 binding.validate()?;
97 Ok(binding)
98 }
99
100 pub fn validate(&self) -> CognitiveContextResult<()> {
101 if self.schema != COGNITIVE_KNOWLEDGE_BINDING_SCHEMA
102 || self.search_schema != OKF_KNOWLEDGE_SEARCH_REQUEST_SCHEMA
103 || self.read_schema != OKF_KNOWLEDGE_READ_REQUEST_SCHEMA
104 || self.citation_schema != OKF_KNOWLEDGE_CITATION_SCHEMA
105 {
106 return Err(invalid_binding(
107 "Knowledge schema negotiation is not the exact R0 typed search/read/citation contract",
108 ));
109 }
110 if !valid_machine_id(&self.surface_id, MAX_SURFACE_ID_BYTES) {
111 return Err(invalid_binding("Knowledge surface id is invalid"));
112 }
113 if !valid_plain_value(&self.format_version, MAX_FORMAT_VERSION_BYTES) {
114 return Err(invalid_binding("Knowledge format version is invalid"));
115 }
116 if self.lifecycle_generation == 0 {
117 return Err(invalid_binding(
118 "lifecycle generation must be an exact non-zero generation",
119 ));
120 }
121 if !valid_sha256(&self.content_digest) || !valid_sha256(&self.generation_digest) {
122 return Err(invalid_binding(
123 "Knowledge content and generation identities must be SHA-256 digests",
124 ));
125 }
126 Ok(())
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase", deny_unknown_fields)]
133pub struct CognitiveContextLimits {
134 pub max_results: usize,
135 pub max_document_bytes: usize,
136 pub max_total_bytes: usize,
137}
138
139impl CognitiveContextLimits {
140 pub fn new(
141 max_results: usize,
142 max_document_bytes: usize,
143 max_total_bytes: usize,
144 ) -> CognitiveContextResult<Self> {
145 let limits = Self {
146 max_results,
147 max_document_bytes,
148 max_total_bytes,
149 };
150 limits.validate()?;
151 Ok(limits)
152 }
153
154 pub fn validate(&self) -> CognitiveContextResult<()> {
155 if self.max_results == 0 || self.max_results > MAX_CONTEXT_RESULTS {
156 return Err(CognitiveContextError::InvalidLimits(format!(
157 "max_results must be between 1 and {MAX_CONTEXT_RESULTS}"
158 )));
159 }
160 if self.max_document_bytes == 0 || self.max_document_bytes > MAX_CONTEXT_DOCUMENT_BYTES {
161 return Err(CognitiveContextError::InvalidLimits(format!(
162 "max_document_bytes must be between 1 and {MAX_CONTEXT_DOCUMENT_BYTES}"
163 )));
164 }
165 if self.max_total_bytes == 0 || self.max_total_bytes > MAX_CONTEXT_TOTAL_BYTES {
166 return Err(CognitiveContextError::InvalidLimits(format!(
167 "max_total_bytes must be between 1 and {MAX_CONTEXT_TOTAL_BYTES}"
168 )));
169 }
170 Ok(())
171 }
172}
173
174impl Default for CognitiveContextLimits {
175 fn default() -> Self {
176 Self {
177 max_results: MAX_CONTEXT_RESULTS,
178 max_document_bytes: MAX_CONTEXT_DOCUMENT_BYTES,
179 max_total_bytes: MAX_CONTEXT_TOTAL_BYTES,
180 }
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase", deny_unknown_fields)]
187pub struct CognitivePackageBindingV1 {
188 pub schema: String,
189 pub package_id: String,
190 pub package_version: String,
191 pub lifecycle_generation: u64,
192 pub generation_digest: String,
193 pub capability_snapshot_digest: String,
194 pub knowledge: CognitiveKnowledgeBindingV1,
195 pub limits: CognitiveContextLimits,
196}
197
198impl CognitivePackageBindingV1 {
199 #[allow(clippy::too_many_arguments)]
200 pub fn new(
201 package_id: impl Into<String>,
202 package_version: impl Into<String>,
203 lifecycle_generation: u64,
204 generation_digest: impl Into<String>,
205 capability_snapshot_digest: impl Into<String>,
206 knowledge: CognitiveKnowledgeBindingV1,
207 limits: CognitiveContextLimits,
208 ) -> CognitiveContextResult<Self> {
209 let binding = Self {
210 schema: COGNITIVE_PACKAGE_BINDING_SCHEMA.to_string(),
211 package_id: package_id.into(),
212 package_version: package_version.into(),
213 lifecycle_generation,
214 generation_digest: generation_digest.into(),
215 capability_snapshot_digest: capability_snapshot_digest.into(),
216 knowledge,
217 limits,
218 };
219 binding.validate()?;
220 Ok(binding)
221 }
222
223 pub fn validate(&self) -> CognitiveContextResult<()> {
224 if self.schema != COGNITIVE_PACKAGE_BINDING_SCHEMA {
225 return Err(invalid_binding("session binding schema is unsupported"));
226 }
227 if !valid_package_id(&self.package_id) {
228 return Err(invalid_binding(
229 "package id must be a canonical publisher/name identity",
230 ));
231 }
232 if !valid_plain_value(&self.package_version, MAX_PACKAGE_VERSION_BYTES) {
233 return Err(invalid_binding("package version is invalid"));
234 }
235 if self.lifecycle_generation == 0 {
236 return Err(invalid_binding(
237 "lifecycle generation must be an exact non-zero generation",
238 ));
239 }
240 if !valid_sha256(&self.generation_digest) || !valid_sha256(&self.capability_snapshot_digest)
241 {
242 return Err(invalid_binding(
243 "generation and capability snapshot identities must be SHA-256 digests",
244 ));
245 }
246 self.knowledge.validate()?;
247 self.limits.validate()?;
248 if self.knowledge.lifecycle_generation != self.lifecycle_generation
249 || self.knowledge.generation_digest != self.generation_digest
250 {
251 return Err(invalid_binding(
252 "Knowledge surface belongs to a different lifecycle generation",
253 ));
254 }
255 let expected = capability_snapshot_digest(self)?;
256 if self.capability_snapshot_digest != expected {
257 return Err(invalid_binding(
258 "capability snapshot digest does not bind this package, generation, and Knowledge surface",
259 ));
260 }
261 Ok(())
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase", deny_unknown_fields)]
268pub struct CognitiveContextRequestV1 {
269 pub schema: String,
270 pub session_id: String,
271 pub query: String,
272 pub binding: CognitivePackageBindingV1,
273 pub request_digest: String,
274}
275
276impl CognitiveContextRequestV1 {
277 pub fn new(
278 session_id: impl Into<String>,
279 query: impl Into<String>,
280 binding: CognitivePackageBindingV1,
281 ) -> CognitiveContextResult<Self> {
282 let mut request = Self {
283 schema: COGNITIVE_CONTEXT_REQUEST_SCHEMA.to_string(),
284 session_id: session_id.into(),
285 query: query.into(),
286 binding,
287 request_digest: String::new(),
288 };
289 request.request_digest = request.digest()?;
290 request.validate()?;
291 Ok(request)
292 }
293
294 pub fn validate(&self) -> CognitiveContextResult<()> {
295 self.binding.validate()?;
296 if self.schema != COGNITIVE_CONTEXT_REQUEST_SCHEMA
297 || !valid_machine_id(&self.session_id, 256)
298 || self.query.trim() != self.query
299 || self.query.is_empty()
300 || self.query.len() > MAX_QUERY_BYTES
301 || self
302 .query
303 .chars()
304 .any(|character| character.is_control() && !matches!(character, '\n' | '\t'))
305 {
306 return Err(CognitiveContextError::InvalidRequest(
307 "request schema, session, or query is invalid or unbounded".to_string(),
308 ));
309 }
310 if self.request_digest != self.digest()? {
311 return Err(CognitiveContextError::InvalidRequest(
312 "request digest does not bind the exact query and session generation".to_string(),
313 ));
314 }
315 Ok(())
316 }
317
318 fn digest(&self) -> CognitiveContextResult<String> {
319 canonical_digest(
320 COGNITIVE_CONTEXT_REQUEST_DIGEST_DOMAIN,
321 &(
322 self.schema.as_str(),
323 self.session_id.as_str(),
324 self.query.as_str(),
325 &self.binding,
326 ),
327 )
328 }
329}
330
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333#[serde(rename_all = "camelCase", deny_unknown_fields)]
334pub struct CognitiveKnowledgeCitationV1 {
335 pub schema: String,
336 pub package_id: String,
337 pub package_version: String,
338 pub lifecycle_generation: u64,
339 pub generation_digest: String,
340 pub surface_id: String,
341 pub content_digest: String,
342 pub document_path: String,
343 pub heading: String,
344 pub evidence_ids: Vec<String>,
345 pub citation_digest: String,
346}
347
348impl CognitiveKnowledgeCitationV1 {
349 #[allow(clippy::too_many_arguments)]
350 pub fn new(
351 binding: &CognitivePackageBindingV1,
352 document_path: impl Into<String>,
353 heading: impl Into<String>,
354 evidence_ids: Vec<String>,
355 ) -> CognitiveContextResult<Self> {
356 let mut citation = Self {
357 schema: OKF_KNOWLEDGE_CITATION_SCHEMA.to_string(),
358 package_id: binding.package_id.clone(),
359 package_version: binding.package_version.clone(),
360 lifecycle_generation: binding.lifecycle_generation,
361 generation_digest: binding.generation_digest.clone(),
362 surface_id: binding.knowledge.surface_id.clone(),
363 content_digest: binding.knowledge.content_digest.clone(),
364 document_path: document_path.into(),
365 heading: heading.into(),
366 evidence_ids,
367 citation_digest: String::new(),
368 };
369 citation.citation_digest = citation.digest()?;
370 citation.validate_for(binding)?;
371 Ok(citation)
372 }
373
374 pub fn validate_for(&self, binding: &CognitivePackageBindingV1) -> CognitiveContextResult<()> {
375 binding.validate()?;
376 if self.schema != OKF_KNOWLEDGE_CITATION_SCHEMA
377 || self.package_id != binding.package_id
378 || self.package_version != binding.package_version
379 || self.lifecycle_generation != binding.lifecycle_generation
380 || self.generation_digest != binding.generation_digest
381 || self.surface_id != binding.knowledge.surface_id
382 || self.content_digest != binding.knowledge.content_digest
383 {
384 return Err(response_drift(
385 "citation does not belong to the session's exact package generation and Knowledge surface",
386 ));
387 }
388 if !valid_markdown_path(&self.document_path)
389 || self.heading.trim() != self.heading
390 || self.heading.is_empty()
391 || self.heading.len() > MAX_HEADING_BYTES
392 || self.heading.chars().any(char::is_control)
393 || self.evidence_ids.is_empty()
394 || self.evidence_ids.len() > MAX_EVIDENCE_IDS
395 || !self.evidence_ids.iter().all(|value| valid_sha256(value))
396 || !self.evidence_ids.windows(2).all(|pair| pair[0] < pair[1])
397 {
398 return Err(response_drift(
399 "citation path, heading, or canonical evidence identity set is invalid",
400 ));
401 }
402 if self.citation_digest != self.digest()? {
403 return Err(response_drift("citation digest was substituted"));
404 }
405 Ok(())
406 }
407
408 fn digest(&self) -> CognitiveContextResult<String> {
409 canonical_digest(
410 OKF_KNOWLEDGE_CITATION_SCHEMA,
411 &(
412 self.package_id.as_str(),
413 self.package_version.as_str(),
414 self.lifecycle_generation,
415 &self.generation_digest,
416 self.surface_id.as_str(),
417 self.content_digest.as_str(),
418 self.document_path.as_str(),
419 self.heading.as_str(),
420 &self.evidence_ids,
421 ),
422 )
423 }
424}
425
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428#[serde(rename_all = "camelCase", deny_unknown_fields)]
429pub struct CognitiveContextDocumentV1 {
430 pub citation: CognitiveKnowledgeCitationV1,
431 pub source_digest: String,
432 pub content: String,
433 pub byte_count: usize,
434}
435
436impl CognitiveContextDocumentV1 {
437 pub fn new(
438 citation: CognitiveKnowledgeCitationV1,
439 content: impl Into<String>,
440 ) -> CognitiveContextResult<Self> {
441 let content = content.into();
442 let document = Self {
443 citation,
444 source_digest: sha256(content.as_bytes()),
445 byte_count: content.len(),
446 content,
447 };
448 if document.content.is_empty() {
449 return Err(response_drift("cognitive document is empty"));
450 }
451 Ok(document)
452 }
453
454 fn validate_for(&self, request: &CognitiveContextRequestV1) -> CognitiveContextResult<()> {
455 self.citation.validate_for(&request.binding)?;
456 if self.content.is_empty()
457 || self.byte_count != self.content.len()
458 || self.byte_count > request.binding.limits.max_document_bytes
459 || !valid_sha256(&self.source_digest)
460 || self.source_digest != sha256(self.content.as_bytes())
461 {
462 return Err(response_drift(
463 "document bytes do not match the cited bounded source read",
464 ));
465 }
466 Ok(())
467 }
468}
469
470#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
472#[serde(rename_all = "camelCase", deny_unknown_fields)]
473pub struct CognitiveContextResponseV1 {
474 pub schema: String,
475 pub request_digest: String,
476 pub binding: CognitivePackageBindingV1,
477 pub documents: Vec<CognitiveContextDocumentV1>,
478 pub truncated: bool,
479}
480
481impl CognitiveContextResponseV1 {
482 pub fn new(
483 request: &CognitiveContextRequestV1,
484 documents: Vec<CognitiveContextDocumentV1>,
485 truncated: bool,
486 ) -> CognitiveContextResult<Self> {
487 let response = Self {
488 schema: COGNITIVE_CONTEXT_RESPONSE_SCHEMA.to_string(),
489 request_digest: request.request_digest.clone(),
490 binding: request.binding.clone(),
491 documents,
492 truncated,
493 };
494 response.validate_for(request)?;
495 Ok(response)
496 }
497
498 pub fn validate_for(&self, request: &CognitiveContextRequestV1) -> CognitiveContextResult<()> {
499 request.validate()?;
500 self.binding.validate()?;
501 if self.schema != COGNITIVE_CONTEXT_RESPONSE_SCHEMA
502 || self.request_digest != request.request_digest
503 || self.binding != request.binding
504 || self.documents.is_empty()
505 || self.documents.len() > request.binding.limits.max_results
506 {
507 return Err(response_drift(
508 "response schema, request, generation, or result count drifted",
509 ));
510 }
511
512 let mut total_bytes = 0usize;
513 let mut citations = HashSet::with_capacity(self.documents.len());
514 for document in &self.documents {
515 document.validate_for(request)?;
516 total_bytes = total_bytes
517 .checked_add(document.byte_count)
518 .ok_or_else(|| {
519 response_drift("response byte accounting overflowed its bounded integer")
520 })?;
521 if !citations.insert(document.citation.citation_digest.as_str()) {
522 return Err(response_drift("response repeats a cited document"));
523 }
524 }
525 if total_bytes > request.binding.limits.max_total_bytes {
526 return Err(response_drift(
527 "response exceeds the session's total cognitive context byte bound",
528 ));
529 }
530 Ok(())
531 }
532}
533
534#[async_trait::async_trait]
538pub trait CognitiveContextProvider: Send + Sync {
539 fn name(&self) -> &str;
540
541 async fn query(
542 &self,
543 request: &CognitiveContextRequestV1,
544 ) -> CognitiveContextResult<CognitiveContextResponseV1>;
545}
546
547#[derive(Clone)]
549pub struct CognitiveContextSession {
550 binding: CognitivePackageBindingV1,
551 provider: Arc<dyn CognitiveContextProvider>,
552}
553
554impl std::fmt::Debug for CognitiveContextSession {
555 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
556 formatter
557 .debug_struct("CognitiveContextSession")
558 .field("binding", &self.binding)
559 .field("provider", &self.provider.name())
560 .finish()
561 }
562}
563
564impl CognitiveContextSession {
565 pub fn new(
566 binding: CognitivePackageBindingV1,
567 provider: Arc<dyn CognitiveContextProvider>,
568 ) -> CognitiveContextResult<Self> {
569 binding.validate()?;
570 if !valid_plain_value(provider.name(), MAX_PROVIDER_NAME_BYTES) {
571 return Err(CognitiveContextError::Provider(
572 "provider name is empty, unbounded, or contains control characters".to_string(),
573 ));
574 }
575 Ok(Self { binding, provider })
576 }
577
578 pub fn binding(&self) -> &CognitivePackageBindingV1 {
579 &self.binding
580 }
581
582 pub fn provider_name(&self) -> &str {
583 self.provider.name()
584 }
585}
586
587#[async_trait::async_trait]
588impl ContextProvider for CognitiveContextSession {
589 fn name(&self) -> &str {
590 self.provider.name()
591 }
592
593 fn failure_mode(&self) -> ContextProviderFailureMode {
594 ContextProviderFailureMode::FailClosed
595 }
596
597 fn cognitive_package_binding(&self) -> Option<&CognitivePackageBindingV1> {
598 Some(&self.binding)
599 }
600
601 async fn query(&self, query: &ContextQuery) -> anyhow::Result<ContextResult> {
602 if !query.context_types.is_empty() && !query.context_types.contains(&ContextType::Resource)
603 {
604 return Err(CognitiveContextError::InvalidRequest(
605 "cognitive packages expose cited resources only".to_string(),
606 )
607 .into());
608 }
609 let session_id = query.session_id.as_deref().unwrap_or_default();
610 let request =
611 CognitiveContextRequestV1::new(session_id, query.query.clone(), self.binding.clone())?;
612 let response = self.provider.query(&request).await?;
613 response.validate_for(&request)?;
614
615 let mut result = ContextResult::new(self.provider.name());
616 result.truncated = response.truncated;
617 for (position, document) in response.documents.into_iter().enumerate() {
618 let citation_json = serde_json::to_value(&document.citation)?;
619 let binding_json = serde_json::to_value(&self.binding)?;
620 let source_digest = document.source_digest.clone();
621 let rendered = format!(
622 "[cognitive citation={} document={} heading={}]\n\n{}",
623 document.citation.citation_digest,
624 document.citation.document_path,
625 document.citation.heading,
626 document.content
627 );
628 let token_count = rendered.len().div_ceil(4).max(1);
629 let relevance = (1.0_f32 - (position as f32 * 0.02)).max(0.9);
630 result.add_item(
631 ContextItem::new(
632 document.citation.citation_digest.clone(),
633 ContextType::Resource,
634 rendered,
635 )
636 .with_source(format!(
637 "a3s-use://citation/{}",
638 document
639 .citation
640 .citation_digest
641 .strip_prefix("sha256:")
642 .unwrap_or(&document.citation.citation_digest)
643 ))
644 .with_metadata(COGNITIVE_CITATION_METADATA, citation_json)
645 .with_metadata(COGNITIVE_PACKAGE_BINDING_METADATA, binding_json)
646 .with_metadata("a3s.cognitive.source_digest", source_digest.into())
647 .with_provenance("a3s-use-cognitive-package")
648 .with_priority(1.0)
649 .with_trust(1.0)
650 .with_freshness(1.0)
651 .with_relevance(relevance)
652 .with_token_count(token_count),
653 );
654 }
655 Ok(result)
656 }
657}
658
659fn capability_snapshot_digest(
660 binding: &CognitivePackageBindingV1,
661) -> CognitiveContextResult<String> {
662 canonical_digest(
663 CAPABILITY_SNAPSHOT_DIGEST_DOMAIN,
664 &(
665 binding.package_id.as_str(),
666 binding.package_version.as_str(),
667 binding.lifecycle_generation,
668 &binding.generation_digest,
669 binding.knowledge.surface_id.as_str(),
670 binding.knowledge.format_version.as_str(),
671 binding.knowledge.content_digest.as_str(),
672 ),
673 )
674}
675
676fn canonical_digest<T: Serialize + ?Sized>(
677 domain: &str,
678 value: &T,
679) -> CognitiveContextResult<String> {
680 let encoded = serde_json::to_vec(value).map_err(|error| {
681 CognitiveContextError::InvalidBinding(format!(
682 "canonical identity could not be serialized: {error}"
683 ))
684 })?;
685 let mut hasher = Sha256::new();
686 hasher.update(CANONICAL_DIGEST_PREFIX);
687 hasher.update((domain.len() as u64).to_be_bytes());
688 hasher.update(domain.as_bytes());
689 hasher.update((encoded.len() as u64).to_be_bytes());
690 hasher.update(encoded);
691 Ok(format!("sha256:{:x}", hasher.finalize()))
692}
693
694fn sha256(bytes: &[u8]) -> String {
695 format!("sha256:{:x}", Sha256::digest(bytes))
696}
697
698fn invalid_binding(message: impl Into<String>) -> CognitiveContextError {
699 CognitiveContextError::InvalidBinding(message.into())
700}
701
702fn response_drift(message: impl Into<String>) -> CognitiveContextError {
703 CognitiveContextError::ResponseDrift(message.into())
704}
705
706fn valid_sha256(value: &str) -> bool {
707 value.strip_prefix("sha256:").is_some_and(|digest| {
708 digest.len() == 64
709 && digest
710 .bytes()
711 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
712 })
713}
714
715fn valid_machine_id(value: &str, max_bytes: usize) -> bool {
716 !value.is_empty()
717 && value.len() <= max_bytes
718 && value.is_ascii()
719 && value
720 .as_bytes()
721 .first()
722 .is_some_and(u8::is_ascii_alphanumeric)
723 && value.bytes().all(|byte| {
724 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b':' | b'/' | b'@')
725 })
726}
727
728fn valid_plain_value(value: &str, max_bytes: usize) -> bool {
729 !value.is_empty()
730 && value.len() <= max_bytes
731 && value.trim() == value
732 && !value.chars().any(char::is_control)
733}
734
735fn valid_package_id(value: &str) -> bool {
736 value.split_once('/').is_some_and(|(publisher, name)| {
737 !publisher.is_empty()
738 && !name.is_empty()
739 && [publisher, name].into_iter().all(|segment| {
740 segment.bytes().all(|byte| {
741 byte.is_ascii_lowercase()
742 || byte.is_ascii_digit()
743 || matches!(byte, b'-' | b'_')
744 })
745 })
746 })
747}
748
749fn valid_markdown_path(value: &str) -> bool {
750 !value.is_empty()
751 && value.len() <= MAX_DOCUMENT_PATH_BYTES
752 && value.ends_with(".md")
753 && !value.starts_with('/')
754 && !value.contains('\\')
755 && !value.chars().any(char::is_control)
756 && value
757 .split('/')
758 .all(|component| !component.is_empty() && !matches!(component, "." | ".."))
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764
765 fn digest(byte: u8) -> String {
766 format!("sha256:{}", format!("{byte:02x}").repeat(32))
767 }
768
769 fn binding() -> CognitivePackageBindingV1 {
770 let knowledge =
771 CognitiveKnowledgeBindingV1::new("domain-knowledge", "0.2", digest(1), 7, digest(2))
772 .unwrap();
773 let mut binding = CognitivePackageBindingV1 {
774 schema: COGNITIVE_PACKAGE_BINDING_SCHEMA.to_string(),
775 package_id: "contra-sense/handbook".to_string(),
776 package_version: "0.1.0".to_string(),
777 lifecycle_generation: 7,
778 generation_digest: digest(2),
779 capability_snapshot_digest: String::new(),
780 knowledge,
781 limits: CognitiveContextLimits::default(),
782 };
783 binding.capability_snapshot_digest = capability_snapshot_digest(&binding).unwrap();
784 binding.validate().unwrap();
785 binding
786 }
787
788 fn response(request: &CognitiveContextRequestV1) -> CognitiveContextResponseV1 {
789 let citation = CognitiveKnowledgeCitationV1::new(
790 &request.binding,
791 "concepts/retry-policy.md",
792 "Retry policy",
793 vec![digest(3)],
794 )
795 .unwrap();
796 let document = CognitiveContextDocumentV1::new(
797 citation,
798 "Retry only before an observable side effect.",
799 )
800 .unwrap();
801 CognitiveContextResponseV1::new(request, vec![document], false).unwrap()
802 }
803
804 #[test]
805 fn exact_binding_rejects_latest_and_capability_drift() {
806 let mut unpinned = binding();
807 unpinned.lifecycle_generation = 0;
808 unpinned.knowledge.lifecycle_generation = 0;
809 assert!(matches!(
810 unpinned.validate(),
811 Err(CognitiveContextError::InvalidBinding(_))
812 ));
813
814 let mut drifted = binding();
815 drifted.knowledge.content_digest = digest(9);
816 assert!(matches!(
817 drifted.validate(),
818 Err(CognitiveContextError::InvalidBinding(_))
819 ));
820 }
821
822 #[test]
823 fn response_rejects_generation_citation_and_source_drift() {
824 let request = CognitiveContextRequestV1::new("session-1", "retry", binding()).unwrap();
825
826 let mut generation = response(&request);
827 generation.binding.lifecycle_generation += 1;
828 assert!(matches!(
829 generation.validate_for(&request),
830 Err(CognitiveContextError::ResponseDrift(_))
831 | Err(CognitiveContextError::InvalidBinding(_))
832 ));
833
834 let mut citation = response(&request);
835 citation.documents[0].citation.heading = "Substituted".to_string();
836 assert!(matches!(
837 citation.validate_for(&request),
838 Err(CognitiveContextError::ResponseDrift(_))
839 ));
840
841 let mut source = response(&request);
842 source.documents[0].content.push_str(" changed");
843 source.documents[0].byte_count = source.documents[0].content.len();
844 assert!(matches!(
845 source.validate_for(&request),
846 Err(CognitiveContextError::ResponseDrift(_))
847 ));
848 }
849
850 #[test]
851 fn response_rejects_empty_duplicate_and_unbounded_documents() {
852 let request = CognitiveContextRequestV1::new("session-1", "retry", binding()).unwrap();
853 assert!(CognitiveContextResponseV1::new(&request, Vec::new(), false).is_err());
854
855 let valid = response(&request);
856 let duplicate = vec![valid.documents[0].clone(), valid.documents[0].clone()];
857 assert!(CognitiveContextResponseV1::new(&request, duplicate, false).is_err());
858
859 let citation = CognitiveKnowledgeCitationV1::new(
860 &request.binding,
861 "concepts/large.md",
862 "Large",
863 vec![digest(4)],
864 )
865 .unwrap();
866 let large = CognitiveContextDocumentV1::new(
867 citation,
868 "x".repeat(request.binding.limits.max_document_bytes + 1),
869 )
870 .unwrap();
871 assert!(CognitiveContextResponseV1::new(&request, vec![large], false).is_err());
872 }
873}