Skip to main content

lean_ctx/core/ocla/
openapi.rs

1//! OpenAPI 3.1 projection of the public OCLA wire contract.
2
3use serde_json::{Value, json};
4
5use super::{
6    OCLA_API_VERSION,
7    wire::{agent_envelope_schema, canonical_envelope_schema},
8};
9
10fn schema_ref(name: &str) -> Value {
11    json!({"$ref": format!("#/components/schemas/{name}")})
12}
13
14fn error_response(description: &str) -> Value {
15    json!({
16        "description": description,
17        "content": {
18            "application/json": {"schema": schema_ref("OclaError")}
19        }
20    })
21}
22
23fn capability_schema() -> Value {
24    json!({
25        "type": "object",
26        "required": ["kind", "api_version", "status", "limits"],
27        "properties": {
28            "kind": {
29                "type": "string",
30                "enum": [
31                    "observation_hook", "usage_sink", "metrics_exporter",
32                    "savings_ledger", "intent_classifier", "outcome_tracker",
33                    "compression_provider", "response_optimizer", "model_router",
34                    "efficiency_analyzer", "config_tuner", "experiment_runner",
35                    "connector_scheduler", "agent_gateway"
36                ]
37            },
38            "api_version": {"type": "string"},
39            "status": {
40                "type": "string",
41                "enum": ["available", "degraded", "unavailable"]
42            },
43            "limits": {
44                "type": "object",
45                "additionalProperties": {"type": "integer", "minimum": 0}
46            }
47        }
48    })
49}
50
51fn savings_event_schema() -> Value {
52    let required = json!([
53        "ts",
54        "tool",
55        "mechanism",
56        "model_id",
57        "tokenizer",
58        "baseline_tokens",
59        "actual_tokens",
60        "saved_tokens",
61        "bounce_adjustment",
62        "unit_price_per_m_usd",
63        "saved_usd",
64        "repo_hash",
65        "agent_id",
66        "prev_hash",
67        "entry_hash",
68        "version"
69    ]);
70    let mut properties = serde_json::Map::new();
71    for fields in [
72        json!({
73            "ts": {"type": "string"},
74            "tool": {"type": "string"},
75            "mechanism": {"type": "string", "enum": ["compression", "routing", "caching"]},
76            "model_id": {"type": "string"},
77            "tokenizer": {"type": "string"},
78            "baseline_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
79            "actual_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
80            "saved_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
81            "bounce_adjustment": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
82            "unit_price_per_m_usd": {"type": "number"},
83            "saved_usd": {"type": "number"},
84            "repo_hash": {"type": "string"},
85            "agent_id": {"type": "string"},
86            "trace_id": {"type": ["string", "null"]},
87            "prev_hash": {"type": "string"},
88            "entry_hash": {"type": "string"},
89            "version": {"type": "string"},
90        }),
91        json!({
92            "intent_tag": {"type": ["string", "null"]},
93            "outcome": {"type": ["string", "null"]},
94            "model_original": {"type": ["string", "null"]},
95            "model_routed": {"type": ["string", "null"]},
96            "routing_savings": {"type": ["integer", "null"], "minimum": 0, "maximum": u64::MAX},
97            "response_original_tokens": {"type": ["integer", "null"], "minimum": 0, "maximum": u64::MAX},
98            "response_delivered_tokens": {"type": ["integer", "null"], "minimum": 0, "maximum": u64::MAX},
99            "agent_chain_id": {"type": ["string", "null"]},
100            "chain_depth": {"type": ["integer", "null"], "minimum": 0, "maximum": u8::MAX},
101            "measurement_method": {
102                "type": ["string", "null"],
103                "enum": ["direct_count", "holdout", "baseline_estimate", "provider_reconciled", "unknown", null]
104            },
105            "evidence_class": {
106                "type": ["string", "null"],
107                "enum": ["measured", "approximated", "statistical", "declared", "unclassified", null]
108            },
109            "confidence": {"type": ["number", "null"], "minimum": 0, "maximum": 1},
110            "quality_signal": {"type": ["string", "null"]},
111            "attribution_group": {"type": ["string", "null"]},
112            "attribution_id": {"type": ["string", "null"]},
113            "baseline_ref": {"type": ["string", "null"]},
114            "price_version": {"type": ["string", "null"]},
115        }),
116        json!({
117            "customer_approval": {
118                "type": ["string", "null"],
119                "enum": ["pending", "approved", "disputed", "superseded", null]
120            },
121            "settlement_status": {
122                "type": ["string", "null"],
123                "enum": ["ineligible", "eligible", "settled", "reversed", null]
124            }
125        }),
126    ] {
127        properties.extend(fields.as_object().expect("event fields object").clone());
128    }
129    json!({"type": "object", "required": required, "properties": properties})
130}
131
132fn ledger_summary_schema() -> Value {
133    json!({
134        "type": "object",
135        "required": [
136            "total_events", "saved_tokens", "saved_usd", "bounce_tokens",
137            "bounce_events", "tokenizers", "by_model", "by_day",
138            "by_tool", "by_mechanism", "net_saved_tokens"
139        ],
140        "properties": {
141            "total_events": {"type": "integer", "minimum": 0},
142            "saved_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
143            "saved_usd": {"type": "number"},
144            "bounce_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
145            "bounce_events": {"type": "integer", "minimum": 0},
146            "tokenizers": {"type": "array", "items": {"type": "string"}},
147            "by_model": {"type": "array", "items": {"$ref": "#/components/schemas/LedgerModelTotals"}},
148            "by_day": {"type": "array", "items": {"$ref": "#/components/schemas/LedgerDayTotals"}},
149            "by_tool": {"type": "array", "items": {"$ref": "#/components/schemas/LedgerToolTotals"}},
150            "by_mechanism": {"type": "array", "items": {"$ref": "#/components/schemas/LedgerMechanismTotals"}},
151            "net_saved_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX}
152        }
153    })
154}
155
156fn idempotency_key_parameter() -> Value {
157    json!({
158        "name": "Idempotency-Key",
159        "in": "header",
160        "required": true,
161        "description": "Client-supplied key used to make envelope submission idempotent.",
162        "schema": {"type": "string", "minLength": 1}
163    })
164}
165
166fn agent_schema() -> Value {
167    json!({
168        "type": "object",
169        "required": ["agent_id", "status"],
170        "properties": {
171            "agent_id": {"type": "string", "minLength": 1},
172            "status": {"type": "string", "enum": ["active", "idle", "offline"]},
173            "last_seen": {"type": ["string", "null"]},
174            "capabilities": {"type": "array", "items": {"type": "string"}}
175        }
176    })
177}
178
179fn agents_response_schema() -> Value {
180    json!({
181        "type": "object",
182        "required": ["api_version", "agents"],
183        "properties": {
184            "api_version": {"const": OCLA_API_VERSION},
185            "agents": {"type": "array", "items": schema_ref("OclaAgent")}
186        }
187    })
188}
189
190fn metric_schema() -> Value {
191    json!({
192        "type": "object",
193        "required": ["name", "value_milli"],
194        "properties": {
195            "name": {"type": "string", "minLength": 1},
196            "value_milli": {"type": "integer"},
197            "dimensions": {"type": "object", "additionalProperties": {"type": "string"}}
198        }
199    })
200}
201
202fn metrics_response_schema() -> Value {
203    json!({
204        "type": "object",
205        "required": ["api_version", "metrics"],
206        "properties": {
207            "api_version": {"const": OCLA_API_VERSION},
208            "metrics": {"type": "array", "items": schema_ref("OclaMetric")}
209        }
210    })
211}
212
213fn dlq_entry_schema() -> Value {
214    json!({
215        "type": "object",
216        "required": [
217            "id", "original_message", "target_agent", "error", "attempts",
218            "first_failed_at", "last_failed_at"
219        ],
220        "properties": {
221            "id": {"type": "string", "minLength": 1},
222            "original_message": {"type": "string"},
223            "target_agent": {"type": "string"},
224            "error": {"type": "string"},
225            "attempts": {"type": "integer", "minimum": 0, "maximum": 255},
226            "first_failed_at": {"type": "string"},
227            "last_failed_at": {"type": "string"}
228        }
229    })
230}
231
232fn dlq_stats_schema() -> Value {
233    json!({
234        "type": "object",
235        "required": ["total", "oldest_age_seconds", "by_target_agent"],
236        "properties": {
237            "total": {"type": "integer", "minimum": 0},
238            "oldest_age_seconds": {"type": "integer", "minimum": 0},
239            "by_target_agent": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 0}}
240        }
241    })
242}
243
244fn dlq_response_schema() -> Value {
245    json!({
246        "type": "object",
247        "required": ["dead_letters", "stats"],
248        "properties": {
249            "dead_letters": {"type": "array", "items": schema_ref("DeadLetter")},
250            "stats": schema_ref("DlqStats")
251        }
252    })
253}
254
255fn dlq_retry_response_schema() -> Value {
256    json!({
257        "type": "object",
258        "required": ["id", "retried"],
259        "properties": {"id": {"type": "string"}, "retried": {"const": true}}
260    })
261}
262
263fn health_status_schema() -> Value {
264    json!({
265        "oneOf": [
266            {"const": "healthy"},
267            {
268                "type": "object",
269                "required": ["degraded"],
270                "properties": {"degraded": {"type": "string"}}
271            },
272            {
273                "type": "object",
274                "required": ["unhealthy"],
275                "properties": {"unhealthy": {"type": "string"}}
276            }
277        ]
278    })
279}
280
281fn health_component_schema() -> Value {
282    json!({
283        "type": "object",
284        "required": ["name", "status", "latency_ms"],
285        "properties": {
286            "name": {"type": "string"},
287            "status": health_status_schema(),
288            "latency_ms": {"type": ["integer", "null"], "minimum": 0},
289            "details": {
290                "type": "object",
291                "required": ["total", "oldest_age_secs"],
292                "properties": {
293                    "total": {"type": "integer", "minimum": 0},
294                    "oldest_age_secs": {"type": "integer", "minimum": 0}
295                }
296            }
297        }
298    })
299}
300
301fn health_response_schema() -> Value {
302    json!({
303        "type": "object",
304        "required": ["overall", "components", "uptime_seconds", "version"],
305        "properties": {
306            "overall": health_status_schema(),
307            "components": {"type": "array", "items": health_component_schema()},
308            "uptime_seconds": {"type": "integer", "minimum": 0},
309            "version": {"type": "string", "const": OCLA_API_VERSION}
310        }
311    })
312}
313
314fn envelope_batch_response_schema() -> Value {
315    json!({
316        "type": "object",
317        "required": ["api_version", "accepted", "rejected", "envelopes"],
318        "properties": {
319            "api_version": {"const": OCLA_API_VERSION},
320            "accepted": {"type": "integer", "minimum": 0},
321            "rejected": {"type": "integer", "minimum": 0},
322            "envelopes": {
323                "type": "array",
324                "items": {
325                    "oneOf": [
326                        schema_ref("CanonicalTokenEnvelopeV1"),
327                        schema_ref("AgentEnvelopeV1")
328                    ]
329                }
330            }
331        }
332    })
333}
334
335fn budget_request_schema() -> Value {
336    json!({
337        "type": "object",
338        "required": ["scope", "max_tokens_per_day", "max_usd_per_day"],
339        "properties": {
340            "scope": {
341                "type": "string",
342                "pattern": "^(org|team|user):.+$",
343                "minLength": 5
344            },
345            "max_tokens_per_day": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
346            "max_usd_per_day": {"type": "number", "minimum": 0}
347        }
348    })
349}
350
351fn budget_response_schema() -> Value {
352    json!({
353        "type": "object",
354        "required": [
355            "scope", "max_tokens_per_day", "max_usd_per_day",
356            "consumed_tokens", "consumed_usd"
357        ],
358        "properties": {
359            "scope": {"type": "string"},
360            "max_tokens_per_day": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
361            "max_usd_per_day": {"type": "number", "minimum": 0},
362            "consumed_tokens": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
363            "consumed_usd": {"type": "number", "minimum": 0}
364        }
365    })
366}
367
368fn budget_scope_parameter() -> Value {
369    json!({
370        "name": "scope",
371        "in": "path",
372        "required": true,
373        "schema": {"type": "string", "pattern": "^(org|team|user):.+$"}
374    })
375}
376
377/// Builds the CI-visible OpenAPI 3.1 document for the OCLA OSS surface.
378#[must_use]
379pub fn ocla_openapi_spec() -> Value {
380    let envelope_request = json!({
381        "oneOf": [
382            schema_ref("CanonicalTokenEnvelopeV1"),
383            schema_ref("AgentEnvelopeV1")
384        ]
385    });
386
387    let mut spec = json!({
388        "openapi": "3.1.0",
389        "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
390        "info": {
391            "title": "LeanCTX OCLA API",
392            "description": "Provider-neutral Open Context & Token Lifecycle Architecture contract.",
393            "version": OCLA_API_VERSION,
394            "license": {"name": "Apache-2.0"}
395        },
396        "servers": [{"url": "/"}],
397        "paths": {
398            "/ocla/v1/health": {
399                "get": {
400                    "operationId": "oclaHealth",
401                    "summary": "Check OCLA availability",
402                    "responses": {
403                        "200": {"description": "OCLA is available", "content": {"application/json": {"schema": schema_ref("HealthResponse")}}},
404                        "503": error_response("OCLA is unavailable")
405                    }
406                }
407            },
408            "/ocla/v1/capabilities": {
409                "get": {
410                    "operationId": "oclaCapabilities",
411                    "summary": "List registered OCLA capabilities",
412                    "responses": {
413                        "200": {"description": "Registered capabilities", "content": {"application/json": {"schema": schema_ref("CapabilitiesResponse")}}},
414                        "503": error_response("Capability registry is unavailable")
415                    }
416                }
417            },
418            "/ocla/v1/agents": {
419                "get": {
420                    "operationId": "oclaAgents",
421                    "summary": "List connected OCLA agents",
422                    "responses": {
423                        "200": {"description": "Connected agents", "content": {"application/json": {"schema": schema_ref("AgentsResponse")}}},
424                        "503": error_response("Agent registry is unavailable")
425                    }
426                }
427            },
428            "/ocla/v1/metrics": {
429                "get": {
430                    "operationId": "oclaMetrics",
431                    "summary": "Read OCLA metrics",
432                    "responses": {
433                        "200": {"description": "Current OCLA metrics", "content": {"application/json": {"schema": schema_ref("MetricsResponse")}}},
434                        "503": error_response("Metrics exporter is unavailable")
435                    }
436                }
437            },
438            "/ocla/v1/envelope": {
439                "post": {
440                    "operationId": "submitOclaEnvelope",
441                    "summary": "Validate and accept a payload-free OCLA envelope",
442                    "parameters": [idempotency_key_parameter()],
443                    "requestBody": {"required": true, "content": {"application/json": {"schema": envelope_request}}},
444                    "responses": {
445                        "200": {"description": "Envelope accepted", "content": {"application/json": {"schema": envelope_request}}},
446                        "400": error_response("Envelope failed validation")
447                    }
448                }
449            },
450            "/ocla/v1/envelope/batch": {
451                "post": {
452                    "operationId": "submitOclaEnvelopeBatch",
453                    "summary": "Validate and accept multiple OCLA envelopes",
454                    "parameters": [idempotency_key_parameter()],
455                    "requestBody": {
456                        "required": true,
457                        "content": {"application/json": {"schema": {
458                            "type": "array",
459                            "minItems": 1,
460                            "maxItems": 1000,
461                            "items": envelope_request
462                        }}}
463                    },
464                    "responses": {
465                        "200": {"description": "Envelope batch accepted", "content": {"application/json": {"schema": schema_ref("EnvelopeBatchResponse")}}},
466                        "400": error_response("One or more envelopes failed validation")
467                    }
468                }
469            },
470            "/ocla/v1/ledger/summary": {
471                "get": {
472                    "operationId": "getOclaLedger",
473                    "summary": "Read the verified local savings ledger",
474                    "parameters": [
475                        {"name": "limit", "in": "query", "required": false, "schema": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 100}},
476                        {"name": "mechanism", "in": "query", "required": false, "schema": {"type": "string", "enum": ["compression", "routing", "caching"]}}
477                    ],
478                    "responses": {
479                        "200": {"description": "Verified ledger snapshot", "content": {"application/json": {"schema": schema_ref("LedgerResponse")}}},
480                        "503": error_response("Ledger is unavailable or invalid")
481                    }
482                }
483            },
484            "/ocla/v1/budget": {
485                "post": {
486                    "operationId": "setOclaBudget",
487                    "summary": "Set a daily OCLA budget limit",
488                    "requestBody": {
489                        "required": true,
490                        "content": {"application/json": {"schema": schema_ref("BudgetRequest")}}
491                    },
492                    "responses": {
493                        "200": {"description": "Budget limit set", "content": {"application/json": {"schema": schema_ref("BudgetResponse")}}},
494                        "400": error_response("Budget request is invalid")
495                    }
496                }
497            },
498            "/ocla/v1/budget/{scope}": {
499                "get": {
500                    "operationId": "getOclaBudget",
501                    "summary": "Read a daily OCLA budget limit and consumption",
502                    "parameters": [budget_scope_parameter()],
503                    "responses": {
504                        "200": {"description": "Budget limit and consumption", "content": {"application/json": {"schema": schema_ref("BudgetResponse")}}},
505                        "400": error_response("Budget scope is invalid"),
506                        "404": error_response("Budget limit was not found")
507                    }
508                },
509                "delete": {
510                    "operationId": "deleteOclaBudget",
511                    "summary": "Remove a daily OCLA budget limit",
512                    "parameters": [budget_scope_parameter()],
513                    "responses": {
514                        "204": {"description": "Budget limit removed"},
515                        "400": error_response("Budget scope is invalid"),
516                        "404": error_response("Budget limit was not found")
517                    }
518                }
519            },
520            "/ocla/v1/dlq": {
521                "get": {
522                    "operationId": "getOclaDlq",
523                    "summary": "List dead letters and queue statistics",
524                    "responses": {
525                        "200": {"description": "Dead-letter queue snapshot", "content": {"application/json": {"schema": schema_ref("DlqResponse")}}}
526                    }
527                }
528            },
529            "/ocla/v1/dlq/{id}/retry": {
530                "post": {
531                    "operationId": "retryOclaDeadLetter",
532                    "summary": "Retry delivery of a dead letter",
533                    "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "minLength": 1}}],
534                    "responses": {
535                        "200": {"description": "Dead letter retried", "content": {"application/json": {"schema": schema_ref("DlqRetryResponse")}}},
536                        "400": error_response("Dead letter retry failed")
537                    }
538                }
539            },
540            "/ocla/v1/dlq/{id}": {
541                "delete": {
542                    "operationId": "deleteOclaDeadLetter",
543                    "summary": "Remove a dead letter without retrying",
544                    "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string", "minLength": 1}}],
545                    "responses": {
546                        "204": {"description": "Dead letter removed"},
547                        "404": error_response("Dead letter was not found")
548                    }
549                }
550            },
551
552        },
553        "components": {
554            "schemas": {
555                "CanonicalTokenEnvelopeV1": canonical_envelope_schema(),
556                "AgentEnvelopeV1": agent_envelope_schema(),
557                "AgentsResponse": agents_response_schema(),
558                "OclaAgent": agent_schema(),
559                "MetricsResponse": metrics_response_schema(),
560                "OclaMetric": metric_schema(),
561                "EnvelopeBatchResponse": envelope_batch_response_schema(),
562                "BudgetRequest": budget_request_schema(),
563                "BudgetResponse": budget_response_schema(),
564                "DeadLetter": dlq_entry_schema(),
565                "DlqStats": dlq_stats_schema(),
566                "DlqResponse": dlq_response_schema(),
567                "DlqRetryResponse": dlq_retry_response_schema(),
568                "HealthResponse": health_response_schema(),
569
570                "CapabilitiesResponse": {
571                    "type": "object",
572                    "required": ["api_version", "capabilities"],
573                    "properties": {"api_version": {"const": OCLA_API_VERSION}, "capabilities": {"type": "array", "items": schema_ref("OclaCapability")}}
574                },
575                "OclaCapability": capability_schema(),
576                "OclaError": {
577                    "type": "object",
578                    "required": ["error"],
579                    "properties": {"error": {"type": "string"}, "code": {"type": "string"}}
580                },
581                "SavingsEvent": savings_event_schema(),
582                "LedgerModelTotals": {"type": "array", "prefixItems": [{"type": "string"}, {"type": "integer", "minimum": 0}, {"type": "number"}], "minItems": 3, "maxItems": 3},
583                "LedgerDayTotals": {"type": "array", "prefixItems": [{"type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$"}, {"type": "integer", "minimum": 0}, {"type": "number"}], "minItems": 3, "maxItems": 3},
584                "LedgerToolTotals": {"type": "array", "prefixItems": [{"type": "string"}, {"type": "integer", "minimum": 0}], "minItems": 2, "maxItems": 2},
585                "LedgerMechanismTotals": {"type": "array", "prefixItems": [{"type": "string"}, {"type": "integer", "minimum": 0}, {"type": "number"}], "minItems": 3, "maxItems": 3},
586                "LedgerSummary": ledger_summary_schema(),
587                "LedgerResponse": {
588                    "type": "object",
589                    "required": ["api_version", "verified", "events", "summary"],
590                    "properties": {
591                        "api_version": {"const": OCLA_API_VERSION},
592                        "verified": {"type": "boolean"},
593                        "events": {"type": "array", "items": schema_ref("SavingsEvent")},
594                        "summary": schema_ref("LedgerSummary")
595                    }
596                }
597            }
598        }
599    });
600    if let Some(paths) = spec["paths"].as_object_mut() {
601        paths.insert("/ocla/v1/capsule".into(), capsule_register_path());
602        paths.insert("/ocla/v1/capsule/{ref}".into(), capsule_resolve_path());
603        paths.insert("/ocla/v1/capsule/{ref}/fork".into(), capsule_fork_path());
604    }
605    if let Some(schemas) = spec["components"]["schemas"].as_object_mut() {
606        schemas.insert("CapsuleRegisterResponse".into(), json!({"type": "object", "required": ["capsule_ref"], "properties": {"capsule_ref": {"type": "string", "pattern": "^capsule:[0-9a-f]{64}$"}}}));
607        schemas.insert("CapsuleResolveResponse".into(), json!({"type": "object", "required": ["capsule_ref", "data"], "properties": {"capsule_ref": {"type": "string"}, "data": {"type": "string"}}}));
608        schemas.insert("CapsuleForkRequest".into(), json!({"type": "object", "required": ["budget_tokens"], "properties": {"budget_tokens": {"type": "integer", "minimum": 0}}}));
609    }
610    spec
611}
612
613fn capsule_register_path() -> Value {
614    json!({"post": {"operationId": "registerCapsule", "summary": "Register a new content-addressed capsule", "requestBody": {"required": true, "content": {"text/plain": {"schema": {"type": "string"}}}}, "responses": {"201": {"description": "Capsule registered", "content": {"application/json": {"schema": schema_ref("CapsuleRegisterResponse")}}}}}})
615}
616
617fn capsule_resolve_path() -> Value {
618    json!({"get": {"operationId": "resolveCapsule", "summary": "Resolve a capsule to its materialized data", "parameters": [{"name": "ref", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "Capsule data", "content": {"application/json": {"schema": schema_ref("CapsuleResolveResponse")}}}, "404": error_response("Capsule not found")}}})
619}
620
621fn capsule_fork_path() -> Value {
622    json!({"post": {"operationId": "forkCapsule", "summary": "Create a CoW fork of an existing capsule", "parameters": [{"name": "ref", "in": "path", "required": true, "schema": {"type": "string"}}], "requestBody": {"required": true, "content": {"application/json": {"schema": schema_ref("CapsuleForkRequest")}}}, "responses": {"201": {"description": "Fork created", "content": {"application/json": {"schema": schema_ref("CapsuleRegisterResponse")}}}, "404": error_response("Parent capsule not found")}}})
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    #[test]
630    fn openapi_snapshot_matches_checked_in_contract() {
631        if std::env::var_os("LEANCTX_UPDATE_OCLA_OPENAPI_SNAPSHOT").is_some() {
632            let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
633                .join("tests/fixtures/ocla_openapi_snapshot.json");
634            let json = serde_json::to_string(&ocla_openapi_spec())
635                .expect("serialize OCLA OpenAPI snapshot");
636            std::fs::write(path, json + "\n").expect("write OCLA OpenAPI snapshot");
637            return;
638        }
639        let expected = include_str!("../../../tests/fixtures/ocla_openapi_snapshot.json").trim();
640        let actual =
641            serde_json::to_string(&ocla_openapi_spec()).expect("serialize OCLA OpenAPI snapshot");
642        assert_eq!(actual, expected);
643    }
644
645    #[test]
646    fn openapi_exposes_all_ocla_endpoints_and_wire_schemas() {
647        let spec = ocla_openapi_spec();
648        let paths = spec["paths"].as_object().expect("paths object");
649        assert!(paths.contains_key("/ocla/v1/health"));
650        assert!(paths.contains_key("/ocla/v1/capabilities"));
651        assert!(paths.contains_key("/ocla/v1/agents"));
652        assert!(paths.contains_key("/ocla/v1/metrics"));
653        assert!(paths.contains_key("/ocla/v1/envelope"));
654        assert!(paths.contains_key("/ocla/v1/envelope/batch"));
655        assert!(paths.contains_key("/ocla/v1/ledger/summary"));
656        assert!(paths.contains_key("/ocla/v1/budget"));
657        assert!(paths.contains_key("/ocla/v1/budget/{scope}"));
658        assert!(paths.contains_key("/ocla/v1/dlq"));
659        assert!(paths.contains_key("/ocla/v1/dlq/{id}/retry"));
660        assert!(paths.contains_key("/ocla/v1/dlq/{id}"));
661        assert!(paths.contains_key("/ocla/v1/capsule"));
662        assert!(paths.contains_key("/ocla/v1/capsule/{ref}"));
663        assert!(paths.contains_key("/ocla/v1/capsule/{ref}/fork"));
664        assert!(spec["components"]["schemas"]["CanonicalTokenEnvelopeV1"].is_object());
665        assert!(spec["components"]["schemas"]["AgentEnvelopeV1"].is_object());
666        assert!(spec["components"]["schemas"]["AgentsResponse"].is_object());
667        assert!(spec["components"]["schemas"]["MetricsResponse"].is_object());
668        assert!(spec["components"]["schemas"]["EnvelopeBatchResponse"].is_object());
669        assert!(spec["components"]["schemas"]["BudgetRequest"].is_object());
670        assert!(spec["components"]["schemas"]["BudgetResponse"].is_object());
671        assert!(spec["components"]["schemas"]["DeadLetter"].is_object());
672        assert!(spec["components"]["schemas"]["DlqStats"].is_object());
673        assert!(spec["components"]["schemas"]["DlqResponse"].is_object());
674        assert!(spec["components"]["schemas"]["DlqRetryResponse"].is_object());
675        assert!(spec["components"]["schemas"]["HealthResponse"].is_object());
676        assert!(spec["components"]["schemas"]["CapsuleRegisterResponse"].is_object());
677        assert!(spec["components"]["schemas"]["CapsuleResolveResponse"].is_object());
678        assert!(spec["components"]["schemas"]["CapsuleForkRequest"].is_object());
679        let serialized = serde_json::to_string(&spec).expect("serialize OpenAPI spec");
680        serde_json::from_str::<Value>(&serialized).expect("OpenAPI spec is valid JSON");
681        assert_eq!(
682            spec["paths"]["/ocla/v1/envelope"]["post"]["parameters"][0]["name"],
683            "Idempotency-Key"
684        );
685        assert_eq!(
686            spec["paths"]["/ocla/v1/envelope/batch"]["post"]["parameters"][0]["name"],
687            "Idempotency-Key"
688        );
689    }
690}