1use std::cell::RefCell;
8use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13pub const OCLA_API_VERSION: &str = "ocla/v1";
14pub const CANONICAL_TOKEN_ENVELOPE_SCHEMA_VERSION: u16 = 1;
15pub const AGENT_ENVELOPE_SCHEMA_VERSION: u16 = 1;
16
17pub type OclaResult<T> = Result<T, OclaError>;
18
19#[must_use]
21pub fn redact_path(path: &str) -> String {
22 let mut components = path
23 .split('/')
24 .filter(|component| !component.is_empty())
25 .collect::<Vec<_>>();
26
27 if matches!(components.first(), Some(&"Users" | &"home")) {
28 components.remove(0);
29 }
30
31 components
32 .iter()
33 .enumerate()
34 .map(|(index, component)| {
35 if index < 2 {
36 "***".to_string()
37 } else {
38 (*component).to_string()
39 }
40 })
41 .collect::<Vec<_>>()
42 .join("/")
43}
44
45#[must_use]
47pub fn redact_id(id: &str) -> String {
48 format!("{}...", id.chars().take(8).collect::<String>())
49}
50
51#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
54pub struct OclaRequestContext {
55 pub request_id: String,
57 pub session_id: String,
59 pub agent_id: String,
61 pub content_ref: String,
63 pub tenant_id: Option<String>,
65 #[serde(default, skip_serializing_if = "String::is_empty")]
67 pub trace_id: String,
68}
69
70thread_local! {
71 static CURRENT_REQUEST_CONTEXT: RefCell<Option<OclaRequestContext>> = const {
72 RefCell::new(None)
73 };
74}
75
76fn generate_trace_id() -> String {
77 let mut bytes = [0_u8; 16];
78 getrandom::fill(&mut bytes).expect("CSPRNG unavailable");
79 bytes[6] = (bytes[6] & 0x0f) | 0x40;
80 bytes[8] = (bytes[8] & 0x3f) | 0x80;
81 let uuid = format!(
82 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
83 bytes[0],
84 bytes[1],
85 bytes[2],
86 bytes[3],
87 bytes[4],
88 bytes[5],
89 bytes[6],
90 bytes[7],
91 bytes[8],
92 bytes[9],
93 bytes[10],
94 bytes[11],
95 bytes[12],
96 bytes[13],
97 bytes[14],
98 bytes[15]
99 );
100 format!("tr-{uuid}")
101}
102
103#[derive(Deserialize)]
104#[serde(untagged)]
105enum RequiredNullableString {
106 Value(String),
107 Null(()),
108}
109
110impl RequiredNullableString {
111 fn into_option(self) -> Option<String> {
112 match self {
113 Self::Value(value) => Some(value),
114 Self::Null(()) => None,
115 }
116 }
117}
118
119#[derive(Deserialize)]
120#[serde(deny_unknown_fields)]
121struct WireContext {
122 request_id: String,
124 session_id: String,
126 agent_id: String,
128 content_ref: String,
130 tenant_id: RequiredNullableString,
132 #[serde(default)]
134 trace_id: Option<String>,
135}
136
137impl<'de> Deserialize<'de> for OclaRequestContext {
138 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
139 where
140 D: serde::Deserializer<'de>,
141 {
142 let wire = WireContext::deserialize(deserializer)?;
143 Ok(Self::new(
144 wire.request_id,
145 wire.session_id,
146 wire.agent_id,
147 wire.content_ref,
148 wire.tenant_id.into_option(),
149 wire.trace_id,
150 ))
151 }
152}
153
154#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum TokenEnvelopeSurface {
157 Mcp,
158 Proxy,
159 Shell,
160 Agent,
161}
162
163#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum TokenFlowDirection {
166 Input,
167 Output,
168}
169
170#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
173#[serde(deny_unknown_fields)]
174pub struct TokenBalanceV1 {
175 pub original_tokens: u64,
177 pub materialized_tokens: u64,
179 pub delivered_tokens: u64,
181 pub provider_billed_tokens: u64,
183}
184
185impl TokenBalanceV1 {
186 pub fn validate(&self) -> OclaResult<()> {
187 if self.materialized_tokens > self.original_tokens {
188 return Err(OclaError::InvalidRequest(
189 "materialized_tokens exceeds original_tokens".into(),
190 ));
191 }
192 if self.delivered_tokens > self.materialized_tokens {
193 return Err(OclaError::InvalidRequest(
194 "delivered_tokens exceeds materialized_tokens".into(),
195 ));
196 }
197 Ok(())
198 }
199}
200
201#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
205#[serde(deny_unknown_fields)]
206pub struct CanonicalTokenEnvelopeV1 {
207 pub schema_version: u16,
209 pub context: OclaRequestContext,
211 pub surface: TokenEnvelopeSurface,
213 pub direction: TokenFlowDirection,
215 pub provider: String,
217 pub model: String,
219 pub token_balance: TokenBalanceV1,
221 pub route_ref: Option<String>,
223 pub policy_ref: Option<String>,
225 pub idempotency_key: String,
227}
228
229impl CanonicalTokenEnvelopeV1 {
230 pub fn validate(&self) -> OclaResult<()> {
231 if self.schema_version != CANONICAL_TOKEN_ENVELOPE_SCHEMA_VERSION {
232 return Err(OclaError::UnsupportedVersion(
233 self.schema_version.to_string(),
234 ));
235 }
236 self.context.validate()?;
237 self.token_balance.validate()?;
238 for (label, value) in [
239 ("provider", &self.provider),
240 ("model", &self.model),
241 ("idempotency_key", &self.idempotency_key),
242 ] {
243 if value.trim().is_empty() {
244 return Err(OclaError::InvalidRequest(format!("{label} is required")));
245 }
246 }
247 Ok(())
248 }
249}
250
251impl OclaRequestContext {
252 #[must_use]
253 pub fn new(
254 request_id: String,
255 session_id: String,
256 agent_id: String,
257 content_ref: String,
258 tenant_id: Option<String>,
259 trace_id: Option<String>,
260 ) -> Self {
261 Self {
262 request_id,
263 session_id,
264 agent_id,
265 content_ref,
266 tenant_id,
267 trace_id: trace_id.unwrap_or_else(generate_trace_id),
268 }
269 }
270
271 pub fn scope<R>(&self, operation: impl FnOnce() -> R) -> R {
272 CURRENT_REQUEST_CONTEXT.with(|current| {
273 let previous = current.replace(Some(self.clone()));
274 let result = operation();
275 current.replace(previous);
276 result
277 })
278 }
279
280 pub fn current_trace_id() -> Option<String> {
281 CURRENT_REQUEST_CONTEXT.with(|current| {
282 current
283 .borrow()
284 .as_ref()
285 .map(|context| context.trace_id.clone())
286 })
287 }
288
289 pub fn current_request_id() -> Option<String> {
290 CURRENT_REQUEST_CONTEXT.with(|current| {
291 current
292 .borrow()
293 .as_ref()
294 .map(|context| context.request_id.clone())
295 })
296 }
297
298 pub fn current_session_id() -> Option<String> {
299 CURRENT_REQUEST_CONTEXT.with(|current| {
300 current
301 .borrow()
302 .as_ref()
303 .map(|context| context.session_id.clone())
304 })
305 }
306
307 pub fn validate(&self) -> OclaResult<()> {
308 for (label, value) in [
309 ("request_id", &self.request_id),
310 ("session_id", &self.session_id),
311 ("agent_id", &self.agent_id),
312 ("content_ref", &self.content_ref),
313 ("trace_id", &self.trace_id),
314 ] {
315 if value.trim().is_empty() {
316 return Err(OclaError::InvalidRequest(format!("{label} is required")));
317 }
318 }
319 Ok(())
320 }
321}
322
323#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
324#[serde(rename_all = "snake_case")]
325pub enum OclaCapabilityKind {
326 ObservationHook,
327 UsageSink,
328 MetricsExporter,
329 SavingsLedger,
330 IntentClassifier,
331 OutcomeTracker,
332 CompressionProvider,
333 ResponseOptimizer,
334 ModelRouter,
335 EfficiencyAnalyzer,
336 ConfigTuner,
337 ExperimentRunner,
338 ConnectorScheduler,
339 AgentGateway,
340 DeliveryRegistry,
341}
342
343impl OclaCapabilityKind {
344 pub const ALL: [Self; 15] = [
345 Self::ObservationHook,
346 Self::UsageSink,
347 Self::MetricsExporter,
348 Self::SavingsLedger,
349 Self::IntentClassifier,
350 Self::OutcomeTracker,
351 Self::CompressionProvider,
352 Self::ResponseOptimizer,
353 Self::ModelRouter,
354 Self::EfficiencyAnalyzer,
355 Self::ConfigTuner,
356 Self::ExperimentRunner,
357 Self::ConnectorScheduler,
358 Self::AgentGateway,
359 Self::DeliveryRegistry,
360 ];
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
365#[serde(rename_all = "snake_case")]
366pub enum FailMode {
367 Open,
368 Closed,
369}
370
371#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
372#[serde(rename_all = "snake_case")]
373pub enum OclaCapabilityStatus {
374 Available,
375 Degraded,
376 Unavailable,
377}
378
379#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
380pub struct OclaCapability {
381 pub kind: OclaCapabilityKind,
383 pub api_version: String,
385 pub status: OclaCapabilityStatus,
387 pub limits: BTreeMap<String, u64>,
389}
390
391impl OclaCapability {
392 #[must_use]
393 pub fn available(kind: OclaCapabilityKind) -> Self {
394 Self {
395 kind,
396 api_version: OCLA_API_VERSION.to_string(),
397 status: OclaCapabilityStatus::Available,
398 limits: BTreeMap::new(),
399 }
400 }
401}
402
403#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
404pub struct Observation {
405 pub context: OclaRequestContext,
407 pub name: String,
409 pub attributes: BTreeMap<String, String>,
411}
412
413#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
414pub struct UsageRecord {
415 pub context: OclaRequestContext,
417 pub model: String,
419 pub input_tokens: u64,
421 pub output_tokens: u64,
423 pub provider_billed_tokens: u64,
425}
426
427#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
428pub struct MetricPoint {
429 pub context: OclaRequestContext,
431 pub name: String,
433 pub value_milli: i64,
435 pub dimensions: BTreeMap<String, String>,
437}
438
439#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
440pub struct SavingsEvidence {
441 pub context: OclaRequestContext,
443 pub original_tokens: u64,
445 pub delivered_tokens: u64,
447 pub quality_ref: Option<String>,
449 pub evidence_ref: String,
451}
452
453#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
454pub struct IntentRequest {
455 pub context: OclaRequestContext,
457 pub candidate_intents: Vec<String>,
459}
460
461#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
462pub struct IntentDecision {
463 pub intent: String,
465 pub confidence_milli: u16,
467 pub rationale_ref: Option<String>,
469}
470
471#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
472pub struct Outcome {
473 pub context: OclaRequestContext,
475 pub accepted: Option<bool>,
477 pub quality_score_milli: Option<u16>,
479 pub outcome_ref: Option<String>,
481}
482
483#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
484pub struct CompressionRequest {
485 pub context: OclaRequestContext,
487 pub source_ref: String,
489 pub source_tokens: u64,
491 pub target_tokens: u64,
493 pub quality_policy_ref: Option<String>,
495}
496
497#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
498pub struct CompressionResult {
499 pub delivered_ref: String,
501 pub delivered_tokens: u64,
503 pub recovery_ref: Option<String>,
505}
506
507#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
508pub struct ResponseOptimizationRequest {
509 pub context: OclaRequestContext,
511 pub response_ref: String,
513 pub original_tokens: u64,
515 pub target_tokens: u64,
517}
518
519#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
520pub struct ResponseOptimizationResult {
521 pub response_ref: String,
523 pub delivered_tokens: u64,
525 pub recovery_ref: Option<String>,
527}
528
529#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
530pub struct ModelRouteRequest {
531 pub context: OclaRequestContext,
533 pub candidate_models: Vec<String>,
535 pub maximum_cost_micros: Option<u64>,
537 pub maximum_latency_ms: Option<u64>,
539}
540
541#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
542pub struct RoutingDecision {
543 pub model: String,
545 pub provider: String,
547 pub reasoning_budget_tokens: u64,
549 pub decision_ref: String,
551}
552
553#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
554pub struct EfficiencySample {
555 pub context: OclaRequestContext,
557 pub original_tokens: u64,
559 pub delivered_tokens: u64,
561 pub accepted: Option<bool>,
563 #[serde(default)]
565 pub cache_hits: u64,
566 #[serde(default)]
568 pub cache_reads: u64,
569}
570
571#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
572pub struct EfficiencyAnalysis {
573 pub etpao_milli: Option<u64>,
575 pub duplicate_ratio_milli: u16,
577 #[serde(default)]
579 pub compression_rate_milli: u16,
580 #[serde(default)]
582 pub cache_hit_rate_milli: u16,
583 pub recommendation_refs: Vec<String>,
585}
586
587#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
588pub struct ConfigTuningRequest {
589 pub context: OclaRequestContext,
591 pub config_ref: String,
593 pub objective_ref: String,
595}
596
597#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
598pub struct ConfigProposal {
599 pub proposal_ref: String,
601 pub rollback_ref: String,
603 pub requires_approval: bool,
605}
606
607#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
608pub struct HoldoutConfig {
610 pub holdout_pct: u8,
612 pub assignment_seed: String,
614 #[serde(default, skip_serializing_if = "Option::is_none")]
616 pub max_samples: Option<u64>,
617}
618
619#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
621pub struct ExperimentStopConditions {
622 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub max_samples: Option<u64>,
625 #[serde(default, skip_serializing_if = "Option::is_none")]
627 pub min_improvement_pct: Option<u8>,
628 #[serde(default, skip_serializing_if = "Option::is_none")]
630 pub max_duration_secs: Option<u64>,
631}
632
633#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
635pub struct ExperimentOutcome {
636 pub experiment_ref: String,
638 pub treatment_samples: u64,
640 pub control_samples: u64,
642 pub treatment_metric: f64,
644 pub control_metric: f64,
646 pub improvement_pct: f64,
648 pub stopped_reason: Option<String>,
650 pub is_significant: bool,
652}
653
654#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
655pub struct ExperimentRequest {
656 pub context: OclaRequestContext,
658 pub experiment_ref: String,
660 pub cohort_ref: String,
662 #[serde(default, skip_serializing_if = "Option::is_none")]
664 pub holdout: Option<HoldoutConfig>,
665 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub stop_conditions: Option<ExperimentStopConditions>,
668}
669
670#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
671pub struct ExperimentResult {
672 pub experiment_ref: String,
674 pub outcome_ref: String,
676 pub rollback_ref: Option<String>,
678}
679
680#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
681pub struct ConnectorJob {
682 pub context: OclaRequestContext,
684 pub connector_id: String,
686 pub payload_ref: String,
688 pub deadline_ms: Option<u64>,
690}
691
692#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
693pub struct ScheduledJob {
694 pub job_ref: String,
696 pub queue_ref: String,
698}
699
700#[derive(Clone, Debug, Serialize, Deserialize)]
702pub struct DeliveryRecord {
703 pub blake3: [u8; 12],
705 pub path: String,
707 pub line_count: u32,
709 pub token_count: u64,
711 pub agent_id: String,
713 pub conversation_id: String,
715 pub read_at: u64,
717 pub mtime: u64,
719 pub fresh: bool,
721}
722
723#[derive(Clone, Debug, Serialize, Deserialize)]
725pub struct DeliveryEntry {
726 pub blake3: [u8; 12],
728 pub path: String,
730 pub line_count: u32,
732 pub token_count: u64,
734 pub agent_id: String,
736 pub conversation_id: String,
738 pub mtime: u64,
740}
741
742#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
744pub struct DeliveryRecordResult {
745 pub already_recorded: bool,
747 pub updated: bool,
749}
750
751#[derive(Clone, Debug, Default, Serialize, Deserialize)]
753pub struct DeliveryStats {
754 pub total_entries: usize,
756 pub stubs_served: u64,
758 pub tokens_saved: u64,
760 pub unique_paths: usize,
762 pub unique_agents: usize,
764}
765
766#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
772#[serde(deny_unknown_fields)]
773pub struct AgentEnvelope {
774 pub schema_version: u16,
776 pub relay_id: String,
778 pub context: OclaRequestContext,
780 pub from_agent_id: String,
782 pub to_agent_id: String,
784 pub capsule_ref: String,
786 pub budget_tokens: u64,
788}
789
790impl AgentEnvelope {
791 pub fn assign_relay_id(&mut self) -> OclaResult<()> {
793 self.relay_id = self.computed_relay_id()?;
794 Ok(())
795 }
796
797 pub fn computed_relay_id(&self) -> OclaResult<String> {
799 let mut canonical = self.clone();
800 canonical.relay_id = "agent-relay:pending".to_string();
801 let bytes = serde_json::to_vec(&canonical).map_err(|error| {
802 OclaError::InvalidRequest(format!("cannot serialize agent relay: {error}"))
803 })?;
804 Ok(format!("agent-relay:{}", blake3::hash(&bytes).to_hex()))
805 }
806
807 pub fn validate(&self) -> OclaResult<()> {
808 if self.schema_version != AGENT_ENVELOPE_SCHEMA_VERSION {
809 return Err(OclaError::UnsupportedVersion(
810 self.schema_version.to_string(),
811 ));
812 }
813 self.context.validate()?;
814 for (label, value) in [
815 ("from_agent_id", &self.from_agent_id),
816 ("to_agent_id", &self.to_agent_id),
817 ] {
818 valid_agent_id(value)
819 .then_some(())
820 .ok_or_else(|| OclaError::InvalidRequest(format!("invalid {label}")))?;
821 }
822 if self.context.agent_id != self.from_agent_id {
823 return Err(OclaError::InvalidRequest(
824 "context agent_id must match from_agent_id".to_string(),
825 ));
826 }
827 valid_digest_ref("capsule", "capsule:", &self.capsule_ref)?;
828 valid_digest_ref("relay", "agent-relay:", &self.relay_id)?;
829 if self.budget_tokens == 0 {
830 return Err(OclaError::InvalidRequest(
831 "agent relay budget_tokens must be greater than zero".to_string(),
832 ));
833 }
834 if self.relay_id != self.computed_relay_id()? {
835 return Err(OclaError::InvalidRequest(
836 "agent relay_id does not match canonical relay content".to_string(),
837 ));
838 }
839 Ok(())
840 }
841}
842
843fn valid_agent_id(value: &str) -> bool {
844 !value.is_empty() && value.len() <= 256 && value.bytes().all(|byte| byte.is_ascii_graphic())
845}
846
847fn valid_digest_ref(label: &str, prefix: &str, value: &str) -> OclaResult<()> {
848 let digest = value.strip_prefix(prefix).ok_or_else(|| {
849 OclaError::InvalidRequest(format!("{label}_ref must use {prefix}BLAKE3-hex form"))
850 })?;
851 (digest.len() == 64
852 && digest.bytes().all(|byte| {
853 byte.is_ascii_digit() || (byte.is_ascii_lowercase() && byte.is_ascii_hexdigit())
854 }))
855 .then_some(())
856 .ok_or_else(|| OclaError::InvalidRequest(format!("invalid {label}_ref")))
857}
858
859#[derive(Debug, Error)]
860pub enum OclaError {
861 #[error("invalid OCLA request: {0}")]
862 InvalidRequest(String),
863 #[error("OCLA capability {0:?} is unavailable")]
864 Unavailable(OclaCapabilityKind),
865 #[error("OCLA capability {0:?} rejected the request: {1}")]
866 Rejected(OclaCapabilityKind, String),
867 #[error("unsupported OCLA contract version: {0}")]
868 UnsupportedVersion(String),
869}
870
871#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
872pub enum MessagePriority {
873 Low,
874 #[default]
875 Normal,
876 High,
877 Critical,
878}
879
880impl MessagePriority {
881 pub fn parse_str(s: &str) -> Self {
882 match s.to_lowercase().as_str() {
883 "low" => Self::Low,
884 "high" => Self::High,
885 "critical" => Self::Critical,
886 _ => Self::Normal,
887 }
888 }
889}
890
891impl std::fmt::Display for MessagePriority {
892 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
893 match self {
894 Self::Low => write!(f, "low"),
895 Self::Normal => write!(f, "normal"),
896 Self::High => write!(f, "high"),
897 Self::Critical => write!(f, "critical"),
898 }
899 }
900}
901
902#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
903pub enum PrivacyLevel {
904 Public,
905 #[default]
906 Team,
907 Private,
908}
909
910impl PrivacyLevel {
911 pub fn parse_str(s: &str) -> Self {
912 match s.to_lowercase().as_str() {
913 "public" => Self::Public,
914 "private" => Self::Private,
915 _ => Self::Team,
916 }
917 }
918
919 pub fn allows_access(&self, requester_is_sender: bool, requester_is_recipient: bool) -> bool {
920 match self {
921 Self::Public | Self::Team => true,
922 Self::Private => requester_is_sender || requester_is_recipient,
923 }
924 }
925}
926
927#[cfg(test)]
928mod tests {
929 use super::*;
930
931 #[test]
932 fn redact_path_masks_user_and_workspace_components() {
933 assert_eq!(
934 redact_path("/Users/alice/projects/my-app/src/main.rs"),
935 "***/***/my-app/src/main.rs"
936 );
937 }
938
939 #[test]
940 fn redact_id_retains_only_a_short_correlation_prefix() {
941 assert_eq!(redact_id("agent-0123456789"), "agent-01...");
942 }
943
944 #[test]
945 fn contract_has_exactly_fifteen_discoverable_capabilities() {
946 assert_eq!(OclaCapabilityKind::ALL.len(), 15);
947 let capability = OclaCapability::available(OclaCapabilityKind::AgentGateway);
948 assert_eq!(capability.api_version, OCLA_API_VERSION);
949 assert_eq!(capability.status, OclaCapabilityStatus::Available);
950 }
951
952 #[test]
953 fn request_context_rejects_incomplete_lineage() {
954 let context = OclaRequestContext {
955 request_id: "request".into(),
956 session_id: String::new(),
957 agent_id: "agent".into(),
958 content_ref: "blake3:content".into(),
959 tenant_id: None,
960 trace_id: "tr-test".into(),
961 };
962 assert!(matches!(
963 context.validate(),
964 Err(OclaError::InvalidRequest(_))
965 ));
966 }
967
968 #[test]
969 fn wire_context_requires_an_explicit_nullable_tenant_id() {
970 let missing = r#"{
971 "request_id":"request",
972 "session_id":"session",
973 "agent_id":"agent",
974 "content_ref":"blake3:content"
975 }"#;
976 assert!(serde_json::from_str::<WireContext>(missing).is_err());
977
978 let explicit_null = r#"{
979 "request_id":"request",
980 "session_id":"session",
981 "agent_id":"agent",
982 "content_ref":"blake3:content",
983 "tenant_id":null
984 }"#;
985 let context = serde_json::from_str::<WireContext>(explicit_null).expect("explicit null");
986 assert!(matches!(
987 context.tenant_id,
988 RequiredNullableString::Null(())
989 ));
990
991 let explicit_value = r#"{
992 "request_id":"request",
993 "session_id":"session",
994 "agent_id":"agent",
995 "content_ref":"blake3:content",
996 "tenant_id":"tenant"
997 }"#;
998 let context = serde_json::from_str::<WireContext>(explicit_value).expect("tenant string");
999 assert!(matches!(
1000 context.tenant_id,
1001 RequiredNullableString::Value(ref value) if value == "tenant"
1002 ));
1003
1004 let wrong_type = r#"{
1005 "request_id":"request",
1006 "session_id":"session",
1007 "agent_id":"agent",
1008 "content_ref":"blake3:content",
1009 "tenant_id":42
1010 }"#;
1011 assert!(serde_json::from_str::<WireContext>(wrong_type).is_err());
1012 }
1013
1014 #[test]
1015 fn request_context_generates_or_preserves_trace_id() {
1016 let generated = OclaRequestContext::new(
1017 "request".into(),
1018 "session".into(),
1019 "agent".into(),
1020 "blake3:content".into(),
1021 None,
1022 None,
1023 );
1024 assert!(generated.trace_id.starts_with("tr-"));
1025 assert_eq!(generated.trace_id.len(), 39);
1026
1027 let mut provided = serde_json::json!({
1028 "request_id": "request",
1029 "session_id": "session",
1030 "agent_id": "agent",
1031 "content_ref": "blake3:content",
1032 "tenant_id": null
1033 });
1034 provided["trace_id"] = serde_json::Value::String("tr-provided".into());
1035 let preserved: OclaRequestContext =
1036 serde_json::from_value(provided).expect("context preserves trace");
1037 assert_eq!(preserved.trace_id, "tr-provided");
1038 }
1039
1040 #[test]
1041 fn agent_envelope_is_canonical_and_rejects_lineage_or_budget_drift() {
1042 let mut envelope = AgentEnvelope {
1043 schema_version: AGENT_ENVELOPE_SCHEMA_VERSION,
1044 relay_id: "agent-relay:pending".to_string(),
1045 context: OclaRequestContext {
1046 request_id: "request".into(),
1047 session_id: "session".into(),
1048 agent_id: "owner-agent".into(),
1049 content_ref: "blake3:content".into(),
1050 tenant_id: None,
1051 trace_id: "tr-test".into(),
1052 },
1053 from_agent_id: "owner-agent".into(),
1054 to_agent_id: "reviewer-agent".into(),
1055 capsule_ref: format!("capsule:{}", "a".repeat(64)),
1056 budget_tokens: 900,
1057 };
1058 envelope.assign_relay_id().expect("relay identity assigns");
1059 envelope.validate().expect("canonical relay validates");
1060
1061 let mut wire = serde_json::to_value(&envelope).expect("relay serializes");
1062 wire.as_object_mut()
1063 .expect("relay is an object")
1064 .insert("unexpected".to_string(), serde_json::Value::Bool(true));
1065 assert!(serde_json::from_value::<AgentEnvelope>(wire).is_err());
1066
1067 envelope.budget_tokens = 0;
1068 assert!(matches!(
1069 envelope.validate(),
1070 Err(OclaError::InvalidRequest(_))
1071 ));
1072 }
1073}