Skip to main content

lean_ctx/core/ocla/
wire.rs

1//! Stable JSON projection for the public OCLA contract.
2
3use serde_json::{Value, json};
4
5use super::types::{
6    AGENT_ENVELOPE_SCHEMA_VERSION, AgentEnvelope, CANONICAL_TOKEN_ENVELOPE_SCHEMA_VERSION,
7    CanonicalTokenEnvelopeV1, OCLA_API_VERSION, OclaError, OclaResult,
8};
9
10pub const OCLA_WIRE_SCHEMA_ID: &str =
11    "https://leanctx.com/schemas/ocla/v1/canonical-token-envelope.json";
12pub const OCLA_AGENT_ENVELOPE_WIRE_SCHEMA_ID: &str =
13    "https://leanctx.com/schemas/ocla/v1/agent-envelope.json";
14pub const MAX_OCLA_WIRE_BYTES: usize = 64 * 1024;
15
16fn validate_wire_size(json: &str) -> OclaResult<()> {
17    if json.len() > MAX_OCLA_WIRE_BYTES {
18        return Err(OclaError::InvalidRequest(format!(
19            "wire document exceeds {MAX_OCLA_WIRE_BYTES} bytes"
20        )));
21    }
22    Ok(())
23}
24
25pub fn encode_envelope(envelope: &CanonicalTokenEnvelopeV1) -> OclaResult<String> {
26    envelope.validate()?;
27    let json = serde_json::to_string(envelope)
28        .map_err(|error| OclaError::InvalidRequest(format!("cannot encode envelope: {error}")))?;
29    validate_wire_size(&json)?;
30    Ok(json)
31}
32
33pub fn decode_envelope(json: &str) -> OclaResult<CanonicalTokenEnvelopeV1> {
34    validate_wire_size(json)?;
35    let envelope: CanonicalTokenEnvelopeV1 = serde_json::from_str(json)
36        .map_err(|error| OclaError::InvalidRequest(format!("cannot decode envelope: {error}")))?;
37    envelope.validate()?;
38    Ok(envelope)
39}
40
41/// Encodes a validated A2A admission contract. The budget remains an
42/// authorization ceiling; this projection never denotes delivered tokens.
43pub fn encode_agent_envelope(envelope: &AgentEnvelope) -> OclaResult<String> {
44    envelope.validate()?;
45    let json = serde_json::to_string(envelope).map_err(|error| {
46        OclaError::InvalidRequest(format!("cannot encode agent envelope: {error}"))
47    })?;
48    validate_wire_size(&json)?;
49    Ok(json)
50}
51
52/// Decodes an A2A admission contract and rejects schema, lineage, relay-ID, or
53/// budget drift before an adapter can observe it.
54pub fn decode_agent_envelope(json: &str) -> OclaResult<AgentEnvelope> {
55    validate_wire_size(json)?;
56    let envelope: AgentEnvelope = serde_json::from_str(json).map_err(|error| {
57        OclaError::InvalidRequest(format!("cannot decode agent envelope: {error}"))
58    })?;
59    envelope.validate()?;
60    Ok(envelope)
61}
62
63/// Source-generated JSON Schema used by SDKs and checked against the committed
64/// public projection in CI. Keep this small and strict: compatibility changes
65/// require an intentional schema-version decision.
66#[must_use]
67pub fn canonical_envelope_schema() -> Value {
68    json!({
69        "$schema": "https://json-schema.org/draft/2020-12/schema",
70        "$id": OCLA_WIRE_SCHEMA_ID,
71        "title": "LeanCTX CanonicalTokenEnvelopeV1",
72        "type": "object",
73        "additionalProperties": false,
74        "required": ["schema_version", "context", "surface", "direction", "provider", "model", "token_balance", "idempotency_key"],
75        "properties": {
76            "schema_version": {"const": CANONICAL_TOKEN_ENVELOPE_SCHEMA_VERSION},
77            "context": {
78                "type": "object",
79                "additionalProperties": false,
80                "required": ["request_id", "session_id", "agent_id", "content_ref", "tenant_id"],
81                "properties": {
82                    "request_id": {"type": "string", "minLength": 1, "pattern": ".*\\S.*"},
83                    "session_id": {"type": "string", "minLength": 1, "pattern": ".*\\S.*"},
84                    "agent_id": {"type": "string", "minLength": 1, "pattern": ".*\\S.*"},
85                    "content_ref": {"type": "string", "minLength": 1, "pattern": ".*\\S.*"},
86                    "tenant_id": {"type": ["string", "null"]}
87                }
88            },
89            "surface": {"enum": ["mcp", "proxy", "shell", "agent"]},
90            "direction": {"enum": ["input", "output"]},
91            "provider": {"type": "string", "minLength": 1, "pattern": ".*\\S.*"},
92            "model": {"type": "string", "minLength": 1, "pattern": ".*\\S.*"},
93            "token_balance": {
94                "type": "object",
95                "additionalProperties": false,
96                "required": ["original_tokens", "materialized_tokens", "delivered_tokens", "provider_billed_tokens"],
97                "properties": {
98                    "original_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
99                    "materialized_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
100                    "delivered_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
101                    "provider_billed_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX}
102                }
103            },
104            "route_ref": {"type": ["string", "null"]},
105            "policy_ref": {"type": ["string", "null"]},
106            "idempotency_key": {"type": "string", "minLength": 1, "pattern": ".*\\S.*"}
107        },
108        "x-ocla-api-version": OCLA_API_VERSION
109    })
110}
111
112/// Source-generated public JSON Schema for the payload-free A2A admission
113/// contract. Cross-field owner and content-derived identity checks remain in
114/// [`decode_agent_envelope`], where they can fail closed.
115#[must_use]
116pub fn agent_envelope_schema() -> Value {
117    json!({
118        "$schema": "https://json-schema.org/draft/2020-12/schema",
119        "$id": OCLA_AGENT_ENVELOPE_WIRE_SCHEMA_ID,
120        "title": "LeanCTX AgentEnvelopeV1",
121        "type": "object",
122        "additionalProperties": false,
123        "required": ["schema_version", "relay_id", "context", "from_agent_id", "to_agent_id", "capsule_ref", "budget_tokens"],
124        "properties": {
125            "schema_version": {"const": AGENT_ENVELOPE_SCHEMA_VERSION},
126            "relay_id": {"type": "string", "pattern": "^agent-relay:[0-9a-f]{64}$"},
127            "context": {
128                "type": "object",
129                "additionalProperties": false,
130                "required": ["request_id", "session_id", "agent_id", "content_ref", "tenant_id"],
131                "properties": {
132                    "request_id": {"type": "string", "minLength": 1, "pattern": ".*\\S.*"},
133                    "session_id": {"type": "string", "minLength": 1, "pattern": ".*\\S.*"},
134                    "agent_id": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[!-~]+$"},
135                    "content_ref": {"type": "string", "minLength": 1, "pattern": ".*\\S.*"},
136                    "tenant_id": {"type": ["string", "null"]}
137                }
138            },
139            "from_agent_id": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[!-~]+$"},
140            "to_agent_id": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[!-~]+$"},
141            "capsule_ref": {"type": "string", "pattern": "^capsule:[0-9a-f]{64}$"},
142            "budget_tokens": {"type": "integer", "minimum": 1, "maximum": u64::MAX}
143        },
144        "x-ocla-api-version": OCLA_API_VERSION,
145        "x-evidence-boundary": "admission_only"
146    })
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::core::ocla::{
153        AGENT_ENVELOPE_SCHEMA_VERSION, OclaRequestContext, TokenBalanceV1, TokenEnvelopeSurface,
154        TokenFlowDirection,
155    };
156
157    fn envelope() -> CanonicalTokenEnvelopeV1 {
158        CanonicalTokenEnvelopeV1 {
159            schema_version: CANONICAL_TOKEN_ENVELOPE_SCHEMA_VERSION,
160            context: OclaRequestContext {
161                request_id: "request-1".into(),
162                session_id: "session-1".into(),
163                agent_id: "agent-1".into(),
164                content_ref: "blake3:content".into(),
165                tenant_id: None,
166                trace_id: "tr-unit".into(),
167            },
168            surface: TokenEnvelopeSurface::Proxy,
169            direction: TokenFlowDirection::Input,
170            provider: "openai".into(),
171            model: "gpt-5".into(),
172            token_balance: TokenBalanceV1 {
173                original_tokens: 100,
174                materialized_tokens: 80,
175                delivered_tokens: 60,
176                provider_billed_tokens: 60,
177            },
178            route_ref: Some("route-1".into()),
179            policy_ref: None,
180            idempotency_key: "request-1:input".into(),
181        }
182    }
183
184    fn agent_envelope() -> AgentEnvelope {
185        let mut envelope = AgentEnvelope {
186            schema_version: AGENT_ENVELOPE_SCHEMA_VERSION,
187            relay_id: "agent-relay:pending".to_string(),
188            context: OclaRequestContext {
189                request_id: "agent-request-1".into(),
190                session_id: "agent-session-1".into(),
191                agent_id: "owner-agent".into(),
192                content_ref: "blake3:agent-content".into(),
193                tenant_id: None,
194                trace_id: "tr-unit".into(),
195            },
196            from_agent_id: "owner-agent".into(),
197            to_agent_id: "reviewer-agent".into(),
198            capsule_ref: format!("capsule:{}", "a".repeat(64)),
199            budget_tokens: 900,
200        };
201        envelope.assign_relay_id().expect("relay ID assigns");
202        envelope
203    }
204
205    #[test]
206    fn canonical_envelope_roundtrips_without_payload() {
207        let original = envelope();
208        let wire = encode_envelope(&original).expect("valid envelope");
209        assert!(!wire.contains("payload"));
210        assert_eq!(decode_envelope(&wire).expect("decode"), original);
211    }
212
213    #[test]
214    fn invalid_token_order_and_unknown_fields_are_rejected() {
215        let mut invalid = envelope();
216        invalid.token_balance.delivered_tokens = 81;
217        assert!(encode_envelope(&invalid).is_err());
218        assert!(decode_envelope(r#"{"schema_version":1,"extra":true}"#).is_err());
219    }
220
221    #[test]
222    fn agent_envelope_roundtrips_without_payload_or_delivery_claim() {
223        let original = agent_envelope();
224        let wire = encode_agent_envelope(&original).expect("valid agent envelope");
225        assert!(!wire.contains("payload"));
226        assert!(!wire.contains("delivered_tokens"));
227        assert_eq!(decode_agent_envelope(&wire).expect("decode"), original);
228        assert!(decode_agent_envelope(r#"{"schema_version":1,"unexpected":true}"#).is_err());
229    }
230
231    #[test]
232    fn missing_tenant_id_is_rejected_for_both_public_envelopes() {
233        let token_wire = encode_envelope(&envelope()).expect("encode token envelope");
234        let mut token_value: Value = serde_json::from_str(&token_wire).expect("token JSON");
235        token_value["context"]
236            .as_object_mut()
237            .expect("token context object")
238            .remove("tenant_id");
239        assert!(decode_envelope(&token_value.to_string()).is_err());
240
241        let agent_wire = encode_agent_envelope(&agent_envelope()).expect("encode agent envelope");
242        let mut agent_value: Value = serde_json::from_str(&agent_wire).expect("agent JSON");
243        agent_value["context"]
244            .as_object_mut()
245            .expect("agent context object")
246            .remove("tenant_id");
247        assert!(decode_agent_envelope(&agent_value.to_string()).is_err());
248    }
249
250    #[test]
251    fn both_public_decoders_enforce_the_exact_wire_size_boundary() {
252        let token_wire = encode_envelope(&envelope()).expect("encode token envelope");
253        let token_at_limit = format!(
254            "{token_wire}{}",
255            " ".repeat(MAX_OCLA_WIRE_BYTES - token_wire.len())
256        );
257        assert_eq!(token_at_limit.len(), MAX_OCLA_WIRE_BYTES);
258        assert!(decode_envelope(&token_at_limit).is_ok());
259        assert!(decode_envelope(&(token_at_limit + " ")).is_err());
260
261        let agent_wire = encode_agent_envelope(&agent_envelope()).expect("encode agent envelope");
262        let agent_at_limit = format!(
263            "{agent_wire}{}",
264            " ".repeat(MAX_OCLA_WIRE_BYTES - agent_wire.len())
265        );
266        assert_eq!(agent_at_limit.len(), MAX_OCLA_WIRE_BYTES);
267        assert!(decode_agent_envelope(&agent_at_limit).is_ok());
268        assert!(decode_agent_envelope(&(agent_at_limit + " ")).is_err());
269    }
270
271    #[test]
272    fn both_public_encoders_reject_oversize_valid_envelopes() {
273        let mut oversized_token = envelope();
274        oversized_token.provider = "p".repeat(MAX_OCLA_WIRE_BYTES);
275        assert!(matches!(
276            encode_envelope(&oversized_token),
277            Err(OclaError::InvalidRequest(message))
278                if message == format!("wire document exceeds {MAX_OCLA_WIRE_BYTES} bytes")
279        ));
280
281        let mut oversized_agent = agent_envelope();
282        oversized_agent.context.content_ref = "c".repeat(MAX_OCLA_WIRE_BYTES);
283        oversized_agent
284            .assign_relay_id()
285            .expect("oversize relay identity assigns");
286        assert!(matches!(
287            encode_agent_envelope(&oversized_agent),
288            Err(OclaError::InvalidRequest(message))
289                if message == format!("wire document exceeds {MAX_OCLA_WIRE_BYTES} bytes")
290        ));
291    }
292
293    #[test]
294    fn schema_numeric_limits_match_engine_u64_boundaries() {
295        let token_schema = canonical_envelope_schema();
296        for field in [
297            "original_tokens",
298            "materialized_tokens",
299            "delivered_tokens",
300            "provider_billed_tokens",
301        ] {
302            assert_eq!(
303                token_schema["properties"]["token_balance"]["properties"][field]["maximum"],
304                Value::from(u64::MAX)
305            );
306        }
307        assert_eq!(
308            agent_envelope_schema()["properties"]["budget_tokens"]["maximum"],
309            Value::from(u64::MAX)
310        );
311
312        let mut max_token = envelope();
313        max_token.token_balance = TokenBalanceV1 {
314            original_tokens: u64::MAX,
315            materialized_tokens: u64::MAX,
316            delivered_tokens: u64::MAX,
317            provider_billed_tokens: u64::MAX,
318        };
319        let wire = encode_envelope(&max_token).expect("encode u64 maximum");
320        assert_eq!(
321            decode_envelope(&wire).expect("decode u64 maximum"),
322            max_token
323        );
324
325        let mut max_agent = agent_envelope();
326        max_agent.budget_tokens = u64::MAX;
327        max_agent
328            .assign_relay_id()
329            .expect("assign maximum-budget relay ID");
330        let wire = encode_agent_envelope(&max_agent).expect("encode u64 maximum");
331        assert_eq!(
332            decode_agent_envelope(&wire).expect("decode u64 maximum"),
333            max_agent
334        );
335    }
336
337    #[test]
338    fn committed_json_schema_cannot_drift_from_the_rust_projection() {
339        let committed: Value = serde_json::from_str(include_str!(
340            "../../../../docs/contracts/ocla-wire-v1.schema.json"
341        ))
342        .expect("valid committed OCLA schema");
343        assert_eq!(committed, canonical_envelope_schema());
344    }
345
346    #[test]
347    fn committed_agent_envelope_schema_cannot_drift_from_the_rust_projection() {
348        let committed: Value = serde_json::from_str(include_str!(
349            "../../../../docs/contracts/ocla-agent-envelope-v1.schema.json"
350        ))
351        .expect("valid committed agent-envelope schema");
352        assert_eq!(committed, agent_envelope_schema());
353    }
354}