Skip to main content

lean_ctx_ocla/
types.rs

1//! # Data Classification
2//! Fields are annotated with sensitivity levels:
3//! - `[PII]` — personally identifiable or workspace-identifying data, requires redaction before cross-boundary transmission
4//! - `[INTERNAL]` — business-sensitive data, visible to operators but not external parties
5//! - `[PUBLIC]` — safe for any consumer
6
7use 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/// Replaces the first two user/workspace path components with redaction markers.
20#[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/// Retains only an identifier's first eight characters for correlation.
46#[must_use]
47pub fn redact_id(id: &str) -> String {
48    format!("{}...", id.chars().take(8).collect::<String>())
49}
50
51/// Stable identifiers required to join decisions across interception surfaces.
52/// Payload bytes intentionally never belong in this contract.
53#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
54pub struct OclaRequestContext {
55    /// [PII] Request identifier that can correlate a user's activity.
56    pub request_id: String,
57    /// [PII] Session identifier.
58    pub session_id: String,
59    /// [PII] Agent identifier.
60    pub agent_id: String,
61    /// [PII] Content reference that can identify workspace data.
62    pub content_ref: String,
63    /// [PII] Tenant identifier.
64    pub tenant_id: Option<String>,
65    /// [PII] Trace identifier that correlates requests across boundaries.
66    #[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    /// [PII] Request identifier that can correlate a user's activity.
123    request_id: String,
124    /// [PII] Session identifier.
125    session_id: String,
126    /// [PII] Agent identifier.
127    agent_id: String,
128    /// [PII] Content reference that can identify workspace data.
129    content_ref: String,
130    /// [PII] Tenant identifier.
131    tenant_id: RequiredNullableString,
132    /// [PII] Trace identifier that correlates requests across boundaries.
133    #[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/// Provider-neutral token accounting. Each field reflects a distinct lifecycle
171/// stage and prevents a cache or delivery mechanism from being double-counted.
172#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
173#[serde(deny_unknown_fields)]
174pub struct TokenBalanceV1 {
175    /// [INTERNAL] Token count before materialization.
176    pub original_tokens: u64,
177    /// [INTERNAL] Token count after materialization.
178    pub materialized_tokens: u64,
179    /// [INTERNAL] Token count delivered to the consumer.
180    pub delivered_tokens: u64,
181    /// [INTERNAL] Token count billed by the provider.
182    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/// Canonical, payload-free representation of a token decision at any engine
202/// boundary. Provider adapters project into this type before ledger, policy or
203/// external SDK code observes the request.
204#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
205#[serde(deny_unknown_fields)]
206pub struct CanonicalTokenEnvelopeV1 {
207    /// [PUBLIC] OCLA schema version.
208    pub schema_version: u16,
209    /// [PII] Request context containing correlation identifiers.
210    pub context: OclaRequestContext,
211    /// [PUBLIC] Interception surface name.
212    pub surface: TokenEnvelopeSurface,
213    /// [PUBLIC] Token-flow direction.
214    pub direction: TokenFlowDirection,
215    /// [PUBLIC] Provider name.
216    pub provider: String,
217    /// [INTERNAL] Model name.
218    pub model: String,
219    /// [INTERNAL] Token-accounting metrics.
220    pub token_balance: TokenBalanceV1,
221    /// [INTERNAL] Internal route reference.
222    pub route_ref: Option<String>,
223    /// [INTERNAL] Internal policy reference.
224    pub policy_ref: Option<String>,
225    /// [PII] Idempotency key that correlates requests.
226    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/// Fail behavior when a subsystem cannot evaluate policy.
364#[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    /// [PUBLIC] Capability name.
382    pub kind: OclaCapabilityKind,
383    /// [PUBLIC] Supported API version string.
384    pub api_version: String,
385    /// [PUBLIC] Capability availability status.
386    pub status: OclaCapabilityStatus,
387    /// [INTERNAL] Named operating limits, e.g. `max_input_tokens` or `max_fanout`.
388    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    /// [PII] Request context containing correlation identifiers.
406    pub context: OclaRequestContext,
407    /// [PUBLIC] Observation name.
408    pub name: String,
409    /// [PII] Observation attributes, which may contain user or workspace identifiers.
410    pub attributes: BTreeMap<String, String>,
411}
412
413#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
414pub struct UsageRecord {
415    /// [PII] Request context containing correlation identifiers.
416    pub context: OclaRequestContext,
417    /// [INTERNAL] Model name.
418    pub model: String,
419    /// [INTERNAL] Input token count.
420    pub input_tokens: u64,
421    /// [INTERNAL] Output token count.
422    pub output_tokens: u64,
423    /// [INTERNAL] Provider-billed token count.
424    pub provider_billed_tokens: u64,
425}
426
427#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
428pub struct MetricPoint {
429    /// [PII] Request context containing correlation identifiers.
430    pub context: OclaRequestContext,
431    /// [PUBLIC] Metric name.
432    pub name: String,
433    /// [INTERNAL] Metric value.
434    pub value_milli: i64,
435    /// [PII] Metric dimensions, which may contain user or workspace identifiers.
436    pub dimensions: BTreeMap<String, String>,
437}
438
439#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
440pub struct SavingsEvidence {
441    /// [PII] Request context containing correlation identifiers.
442    pub context: OclaRequestContext,
443    /// [INTERNAL] Original token count.
444    pub original_tokens: u64,
445    /// [INTERNAL] Delivered token count.
446    pub delivered_tokens: u64,
447    /// [INTERNAL] Internal quality reference.
448    pub quality_ref: Option<String>,
449    /// [INTERNAL] Internal evidence reference.
450    pub evidence_ref: String,
451}
452
453#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
454pub struct IntentRequest {
455    /// [PII] Request context containing correlation identifiers.
456    pub context: OclaRequestContext,
457    /// [INTERNAL] Candidate intent labels derived from a request.
458    pub candidate_intents: Vec<String>,
459}
460
461#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
462pub struct IntentDecision {
463    /// [INTERNAL] Selected request intent.
464    pub intent: String,
465    /// [INTERNAL] Decision confidence metric.
466    pub confidence_milli: u16,
467    /// [INTERNAL] Internal rationale reference.
468    pub rationale_ref: Option<String>,
469}
470
471#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
472pub struct Outcome {
473    /// [PII] Request context containing correlation identifiers.
474    pub context: OclaRequestContext,
475    /// [INTERNAL] Whether the user accepted the outcome.
476    pub accepted: Option<bool>,
477    /// [INTERNAL] Outcome quality metric.
478    pub quality_score_milli: Option<u16>,
479    /// [INTERNAL] Internal outcome reference.
480    pub outcome_ref: Option<String>,
481}
482
483#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
484pub struct CompressionRequest {
485    /// [PII] Request context containing correlation identifiers.
486    pub context: OclaRequestContext,
487    /// [PII] Source reference that can identify workspace content.
488    pub source_ref: String,
489    /// [INTERNAL] Source token count.
490    pub source_tokens: u64,
491    /// [INTERNAL] Requested target token count.
492    pub target_tokens: u64,
493    /// [INTERNAL] Internal quality-policy reference.
494    pub quality_policy_ref: Option<String>,
495}
496
497#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
498pub struct CompressionResult {
499    /// [PII] Delivered-content reference that can identify workspace data.
500    pub delivered_ref: String,
501    /// [INTERNAL] Delivered token count.
502    pub delivered_tokens: u64,
503    /// [INTERNAL] Internal recovery reference.
504    pub recovery_ref: Option<String>,
505}
506
507#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
508pub struct ResponseOptimizationRequest {
509    /// [PII] Request context containing correlation identifiers.
510    pub context: OclaRequestContext,
511    /// [PII] Response reference that can identify workspace content.
512    pub response_ref: String,
513    /// [INTERNAL] Original token count.
514    pub original_tokens: u64,
515    /// [INTERNAL] Requested target token count.
516    pub target_tokens: u64,
517}
518
519#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
520pub struct ResponseOptimizationResult {
521    /// [PII] Response reference that can identify workspace content.
522    pub response_ref: String,
523    /// [INTERNAL] Delivered token count.
524    pub delivered_tokens: u64,
525    /// [INTERNAL] Internal recovery reference.
526    pub recovery_ref: Option<String>,
527}
528
529#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
530pub struct ModelRouteRequest {
531    /// [PII] Request context containing correlation identifiers.
532    pub context: OclaRequestContext,
533    /// [INTERNAL] Candidate model names.
534    pub candidate_models: Vec<String>,
535    /// [INTERNAL] Maximum permitted cost metric.
536    pub maximum_cost_micros: Option<u64>,
537    /// [INTERNAL] Maximum permitted latency metric.
538    pub maximum_latency_ms: Option<u64>,
539}
540
541#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
542pub struct RoutingDecision {
543    /// [INTERNAL] Selected model name.
544    pub model: String,
545    /// [PUBLIC] Provider name.
546    pub provider: String,
547    /// [INTERNAL] Reasoning budget token count.
548    pub reasoning_budget_tokens: u64,
549    /// [INTERNAL] Internal decision reference.
550    pub decision_ref: String,
551}
552
553#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
554pub struct EfficiencySample {
555    /// [PII] Request context containing correlation identifiers.
556    pub context: OclaRequestContext,
557    /// [INTERNAL] Original token count.
558    pub original_tokens: u64,
559    /// [INTERNAL] Delivered token count.
560    pub delivered_tokens: u64,
561    /// [INTERNAL] Whether the user accepted the result.
562    pub accepted: Option<bool>,
563    /// [INTERNAL] Cache-hit metric.
564    #[serde(default)]
565    pub cache_hits: u64,
566    /// [INTERNAL] Cache-read metric.
567    #[serde(default)]
568    pub cache_reads: u64,
569}
570
571#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
572pub struct EfficiencyAnalysis {
573    /// [INTERNAL] Efficiency metric.
574    pub etpao_milli: Option<u64>,
575    /// [INTERNAL] Duplicate-content metric.
576    pub duplicate_ratio_milli: u16,
577    /// [INTERNAL] Compression-rate metric.
578    #[serde(default)]
579    pub compression_rate_milli: u16,
580    /// [INTERNAL] Cache-hit-rate metric.
581    #[serde(default)]
582    pub cache_hit_rate_milli: u16,
583    /// [INTERNAL] Internal recommendation references.
584    pub recommendation_refs: Vec<String>,
585}
586
587#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
588pub struct ConfigTuningRequest {
589    /// [PII] Request context containing correlation identifiers.
590    pub context: OclaRequestContext,
591    /// [INTERNAL] Internal configuration reference.
592    pub config_ref: String,
593    /// [INTERNAL] Internal objective reference.
594    pub objective_ref: String,
595}
596
597#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
598pub struct ConfigProposal {
599    /// [INTERNAL] Internal proposal reference.
600    pub proposal_ref: String,
601    /// [INTERNAL] Internal rollback reference.
602    pub rollback_ref: String,
603    /// [INTERNAL] Approval workflow state.
604    pub requires_approval: bool,
605}
606
607#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
608/// Holdout configuration for experiments.
609pub struct HoldoutConfig {
610    /// [INTERNAL] Percentage of traffic to hold out (0-100).
611    pub holdout_pct: u8,
612    /// [INTERNAL] Deterministic assignment seed for reproducibility.
613    pub assignment_seed: String,
614    /// [INTERNAL] Maximum number of samples before stopping.
615    #[serde(default, skip_serializing_if = "Option::is_none")]
616    pub max_samples: Option<u64>,
617}
618
619/// Stop conditions that can terminate an experiment early.
620#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
621pub struct ExperimentStopConditions {
622    /// [INTERNAL] Stop if this many samples are collected.
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub max_samples: Option<u64>,
625    /// [INTERNAL] Stop if improvement over control is below this threshold.
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub min_improvement_pct: Option<u8>,
628    /// [INTERNAL] Stop after this many seconds.
629    #[serde(default, skip_serializing_if = "Option::is_none")]
630    pub max_duration_secs: Option<u64>,
631}
632
633/// Extended experiment result with holdout data.
634#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
635pub struct ExperimentOutcome {
636    /// [INTERNAL] Internal experiment reference.
637    pub experiment_ref: String,
638    /// [INTERNAL] Treatment sample count.
639    pub treatment_samples: u64,
640    /// [INTERNAL] Control sample count.
641    pub control_samples: u64,
642    /// [INTERNAL] Treatment metric.
643    pub treatment_metric: f64,
644    /// [INTERNAL] Control metric.
645    pub control_metric: f64,
646    /// [INTERNAL] Improvement metric.
647    pub improvement_pct: f64,
648    /// [INTERNAL] Internal experiment stop reason.
649    pub stopped_reason: Option<String>,
650    /// [INTERNAL] Statistical significance state.
651    pub is_significant: bool,
652}
653
654#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
655pub struct ExperimentRequest {
656    /// [PII] Request context containing correlation identifiers.
657    pub context: OclaRequestContext,
658    /// [INTERNAL] Internal experiment reference.
659    pub experiment_ref: String,
660    /// [PII] Cohort reference that can identify a user group.
661    pub cohort_ref: String,
662    /// [INTERNAL] Experiment holdout configuration.
663    #[serde(default, skip_serializing_if = "Option::is_none")]
664    pub holdout: Option<HoldoutConfig>,
665    /// [INTERNAL] Experiment stop conditions.
666    #[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    /// [INTERNAL] Internal experiment reference.
673    pub experiment_ref: String,
674    /// [INTERNAL] Internal outcome reference.
675    pub outcome_ref: String,
676    /// [INTERNAL] Internal rollback reference.
677    pub rollback_ref: Option<String>,
678}
679
680#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
681pub struct ConnectorJob {
682    /// [PII] Request context containing correlation identifiers.
683    pub context: OclaRequestContext,
684    /// [INTERNAL] Connector identifier.
685    pub connector_id: String,
686    /// [PII] Payload reference that can identify workspace content.
687    pub payload_ref: String,
688    /// [INTERNAL] Deadline metric in milliseconds.
689    pub deadline_ms: Option<u64>,
690}
691
692#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
693pub struct ScheduledJob {
694    /// [INTERNAL] Internal job reference.
695    pub job_ref: String,
696    /// [INTERNAL] Internal queue reference.
697    pub queue_ref: String,
698}
699
700/// Cross-agent delivery record: tracks that file content was read by an agent.
701#[derive(Clone, Debug, Serialize, Deserialize)]
702pub struct DeliveryRecord {
703    /// [INTERNAL] Content-derived digest.
704    pub blake3: [u8; 12],
705    /// [PII] File path.
706    pub path: String,
707    /// [INTERNAL] File line-count metric.
708    pub line_count: u32,
709    /// [INTERNAL] File token-count metric.
710    pub token_count: u64,
711    /// [PII] Agent identifier.
712    pub agent_id: String,
713    /// [PII] Conversation identifier.
714    pub conversation_id: String,
715    /// [INTERNAL] Read timestamp.
716    pub read_at: u64,
717    /// [INTERNAL] File modification timestamp.
718    pub mtime: u64,
719    /// [INTERNAL] Freshness state.
720    pub fresh: bool,
721}
722
723/// Entry for recording a new delivery.
724#[derive(Clone, Debug, Serialize, Deserialize)]
725pub struct DeliveryEntry {
726    /// [INTERNAL] Content-derived digest.
727    pub blake3: [u8; 12],
728    /// [PII] File path.
729    pub path: String,
730    /// [INTERNAL] File line-count metric.
731    pub line_count: u32,
732    /// [INTERNAL] File token-count metric.
733    pub token_count: u64,
734    /// [PII] Agent identifier.
735    pub agent_id: String,
736    /// [PII] Conversation identifier.
737    pub conversation_id: String,
738    /// [INTERNAL] File modification timestamp.
739    pub mtime: u64,
740}
741
742/// Result of an idempotent delivery-record attempt.
743#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
744pub struct DeliveryRecordResult {
745    /// The existing record already represented the same source version.
746    pub already_recorded: bool,
747    /// An existing record was refreshed because its source version changed.
748    pub updated: bool,
749}
750
751/// Statistics for the delivery registry.
752#[derive(Clone, Debug, Default, Serialize, Deserialize)]
753pub struct DeliveryStats {
754    /// [INTERNAL] Registry entry count.
755    pub total_entries: usize,
756    /// [INTERNAL] Stub-delivery count.
757    pub stubs_served: u64,
758    /// [INTERNAL] Token-savings metric.
759    pub tokens_saved: u64,
760    /// [INTERNAL] Unique-path count.
761    pub unique_paths: usize,
762    /// [INTERNAL] Unique-agent count.
763    pub unique_agents: usize,
764}
765
766/// Canonical, payload-free admission contract for one A2A relay.
767///
768/// `budget_tokens` is an authorization ceiling, never observed delivery or
769/// savings evidence. A transport must create its own measured token envelope
770/// only after it actually materializes and delivers the handoff.
771#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
772#[serde(deny_unknown_fields)]
773pub struct AgentEnvelope {
774    /// [PUBLIC] Agent-envelope schema version.
775    pub schema_version: u16,
776    /// [PII] Content-derived relay identifier for idempotent admission and event joins.
777    pub relay_id: String,
778    /// [PII] Request context containing correlation identifiers.
779    pub context: OclaRequestContext,
780    /// [PII] Sending agent identifier.
781    pub from_agent_id: String,
782    /// [PII] Receiving agent identifier.
783    pub to_agent_id: String,
784    /// [PII] Capsule reference that can identify workspace content.
785    pub capsule_ref: String,
786    /// [INTERNAL] Authorized token budget.
787    pub budget_tokens: u64,
788}
789
790impl AgentEnvelope {
791    /// Assigns the deterministic identity after all relay fields are set.
792    pub fn assign_relay_id(&mut self) -> OclaResult<()> {
793        self.relay_id = self.computed_relay_id()?;
794        Ok(())
795    }
796
797    /// Derives a stable relay ID without including payload bytes or the ID itself.
798    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}