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                task_id: None,
168                parent_task_id: None,
169            },
170            surface: TokenEnvelopeSurface::Proxy,
171            direction: TokenFlowDirection::Input,
172            provider: "openai".into(),
173            model: "gpt-5".into(),
174            token_balance: TokenBalanceV1 {
175                original_tokens: 100,
176                materialized_tokens: 80,
177                delivered_tokens: 60,
178                provider_billed_tokens: 60,
179            },
180            route_ref: Some("route-1".into()),
181            policy_ref: None,
182            idempotency_key: "request-1:input".into(),
183        }
184    }
185
186    fn agent_envelope() -> AgentEnvelope {
187        let mut envelope = AgentEnvelope {
188            schema_version: AGENT_ENVELOPE_SCHEMA_VERSION,
189            relay_id: "agent-relay:pending".to_string(),
190            context: OclaRequestContext {
191                request_id: "agent-request-1".into(),
192                session_id: "agent-session-1".into(),
193                agent_id: "owner-agent".into(),
194                content_ref: "blake3:agent-content".into(),
195                tenant_id: None,
196                trace_id: "tr-unit".into(),
197                task_id: None,
198                parent_task_id: None,
199            },
200            from_agent_id: "owner-agent".into(),
201            to_agent_id: "reviewer-agent".into(),
202            capsule_ref: format!("capsule:{}", "a".repeat(64)),
203            budget_tokens: 900,
204        };
205        envelope.assign_relay_id().expect("relay ID assigns");
206        envelope
207    }
208
209    #[test]
210    fn canonical_envelope_roundtrips_without_payload() {
211        let original = envelope();
212        let wire = encode_envelope(&original).expect("valid envelope");
213        assert!(!wire.contains("payload"));
214        assert_eq!(decode_envelope(&wire).expect("decode"), original);
215    }
216
217    #[test]
218    fn invalid_token_order_and_unknown_fields_are_rejected() {
219        let mut invalid = envelope();
220        invalid.token_balance.delivered_tokens = 81;
221        assert!(encode_envelope(&invalid).is_err());
222        assert!(decode_envelope(r#"{"schema_version":1,"extra":true}"#).is_err());
223    }
224
225    #[test]
226    fn agent_envelope_roundtrips_without_payload_or_delivery_claim() {
227        let original = agent_envelope();
228        let wire = encode_agent_envelope(&original).expect("valid agent envelope");
229        assert!(!wire.contains("payload"));
230        assert!(!wire.contains("delivered_tokens"));
231        assert_eq!(decode_agent_envelope(&wire).expect("decode"), original);
232        assert!(decode_agent_envelope(r#"{"schema_version":1,"unexpected":true}"#).is_err());
233    }
234
235    #[test]
236    fn missing_tenant_id_is_rejected_for_both_public_envelopes() {
237        let token_wire = encode_envelope(&envelope()).expect("encode token envelope");
238        let mut token_value: Value = serde_json::from_str(&token_wire).expect("token JSON");
239        token_value["context"]
240            .as_object_mut()
241            .expect("token context object")
242            .remove("tenant_id");
243        assert!(decode_envelope(&token_value.to_string()).is_err());
244
245        let agent_wire = encode_agent_envelope(&agent_envelope()).expect("encode agent envelope");
246        let mut agent_value: Value = serde_json::from_str(&agent_wire).expect("agent JSON");
247        agent_value["context"]
248            .as_object_mut()
249            .expect("agent context object")
250            .remove("tenant_id");
251        assert!(decode_agent_envelope(&agent_value.to_string()).is_err());
252    }
253
254    #[test]
255    fn both_public_decoders_enforce_the_exact_wire_size_boundary() {
256        let token_wire = encode_envelope(&envelope()).expect("encode token envelope");
257        let token_at_limit = format!(
258            "{token_wire}{}",
259            " ".repeat(MAX_OCLA_WIRE_BYTES - token_wire.len())
260        );
261        assert_eq!(token_at_limit.len(), MAX_OCLA_WIRE_BYTES);
262        assert!(decode_envelope(&token_at_limit).is_ok());
263        assert!(decode_envelope(&(token_at_limit + " ")).is_err());
264
265        let agent_wire = encode_agent_envelope(&agent_envelope()).expect("encode agent envelope");
266        let agent_at_limit = format!(
267            "{agent_wire}{}",
268            " ".repeat(MAX_OCLA_WIRE_BYTES - agent_wire.len())
269        );
270        assert_eq!(agent_at_limit.len(), MAX_OCLA_WIRE_BYTES);
271        assert!(decode_agent_envelope(&agent_at_limit).is_ok());
272        assert!(decode_agent_envelope(&(agent_at_limit + " ")).is_err());
273    }
274
275    #[test]
276    fn both_public_encoders_reject_oversize_valid_envelopes() {
277        let mut oversized_token = envelope();
278        oversized_token.provider = "p".repeat(MAX_OCLA_WIRE_BYTES);
279        assert!(matches!(
280            encode_envelope(&oversized_token),
281            Err(OclaError::InvalidRequest(message))
282                if message == format!("wire document exceeds {MAX_OCLA_WIRE_BYTES} bytes")
283        ));
284
285        let mut oversized_agent = agent_envelope();
286        oversized_agent.context.content_ref = "c".repeat(MAX_OCLA_WIRE_BYTES);
287        oversized_agent
288            .assign_relay_id()
289            .expect("oversize relay identity assigns");
290        assert!(matches!(
291            encode_agent_envelope(&oversized_agent),
292            Err(OclaError::InvalidRequest(message))
293                if message == format!("wire document exceeds {MAX_OCLA_WIRE_BYTES} bytes")
294        ));
295    }
296
297    #[test]
298    fn schema_numeric_limits_match_engine_u64_boundaries() {
299        let token_schema = canonical_envelope_schema();
300        for field in [
301            "original_tokens",
302            "materialized_tokens",
303            "delivered_tokens",
304            "provider_billed_tokens",
305        ] {
306            assert_eq!(
307                token_schema["properties"]["token_balance"]["properties"][field]["maximum"],
308                Value::from(u64::MAX)
309            );
310        }
311        assert_eq!(
312            agent_envelope_schema()["properties"]["budget_tokens"]["maximum"],
313            Value::from(u64::MAX)
314        );
315
316        let mut max_token = envelope();
317        max_token.token_balance = TokenBalanceV1 {
318            original_tokens: u64::MAX,
319            materialized_tokens: u64::MAX,
320            delivered_tokens: u64::MAX,
321            provider_billed_tokens: u64::MAX,
322        };
323        let wire = encode_envelope(&max_token).expect("encode u64 maximum");
324        assert_eq!(
325            decode_envelope(&wire).expect("decode u64 maximum"),
326            max_token
327        );
328
329        let mut max_agent = agent_envelope();
330        max_agent.budget_tokens = u64::MAX;
331        max_agent
332            .assign_relay_id()
333            .expect("assign maximum-budget relay ID");
334        let wire = encode_agent_envelope(&max_agent).expect("encode u64 maximum");
335        assert_eq!(
336            decode_agent_envelope(&wire).expect("decode u64 maximum"),
337            max_agent
338        );
339    }
340
341    #[test]
342    fn committed_json_schema_cannot_drift_from_the_rust_projection() {
343        let committed: Value = serde_json::from_str(include_str!(
344            "../../../../docs/contracts/ocla-wire-v1.schema.json"
345        ))
346        .expect("valid committed OCLA schema");
347        assert_eq!(committed, canonical_envelope_schema());
348    }
349
350    #[test]
351    fn committed_agent_envelope_schema_cannot_drift_from_the_rust_projection() {
352        let committed: Value = serde_json::from_str(include_str!(
353            "../../../../docs/contracts/ocla-agent-envelope-v1.schema.json"
354        ))
355        .expect("valid committed agent-envelope schema");
356        assert_eq!(committed, agent_envelope_schema());
357    }
358}