Skip to main content

lean_ctx/core/
ocp.rs

1//! Open Context Protocol (OCP) v0.1 export adapter.
2//!
3//! OCP is the published exchange format (see the `open-context-protocol`
4//! repo; schemas vendored under `docs/contracts/ocp/`). Internal types stay
5//! free to evolve — this module is the compatibility boundary that projects
6//! them onto the spec'd wire shapes (ADR-0001 in the OCP repo, GL #430).
7//!
8//! Context-IR documents and evidence (audit) entries already serialize
9//! schema-conformant via serde; only surfaces whose internal encoding
10//! differs from the wire format need an adapter here.
11
12use crate::core::capabilities::{check_capabilities, role_capabilities};
13use crate::core::events::{EventKind, LeanCtxEvent};
14use serde_json::{Value, json};
15
16/// Project a runtime event onto the OCP Part 5 wire shape.
17///
18/// Only the seven governance-relevant kinds standardized in the OCP
19/// event-type registry are exported; product telemetry returns `None`.
20/// (Internal `EventKind` tags are PascalCase; the wire format is
21/// snake_case — that mapping is exactly why this adapter exists.)
22pub fn export_event(event: &LeanCtxEvent) -> Option<Value> {
23    let kind = export_event_kind(&event.kind)?;
24    Some(json!({
25        "id": event.id,
26        "timestamp": event.timestamp,
27        "kind": kind,
28    }))
29}
30
31fn export_event_kind(kind: &EventKind) -> Option<Value> {
32    match kind {
33        EventKind::ToolCall {
34            tool,
35            tokens_original,
36            tokens_saved,
37            mode,
38            duration_ms,
39            path,
40        } => Some(json!({
41            "type": "tool_call",
42            "tool": tool,
43            "tokens_original": tokens_original,
44            "tokens_saved": tokens_saved,
45            "mode": mode,
46            "duration_ms": duration_ms,
47            "path": path,
48        })),
49        EventKind::AgentAction {
50            agent_id,
51            action,
52            tool,
53        } => Some(json!({
54            "type": "agent_action",
55            "agent_id": agent_id,
56            "action": action,
57            "tool": tool,
58        })),
59        EventKind::KnowledgeUpdate {
60            category,
61            key,
62            action,
63        } => Some(json!({
64            "type": "knowledge_update",
65            "category": category,
66            "key": key,
67            "action": action,
68        })),
69        EventKind::BudgetWarning {
70            role,
71            dimension,
72            used,
73            limit,
74            percent,
75        } => Some(json!({
76            "type": "budget_warning",
77            "role": role,
78            "dimension": dimension,
79            "used": used,
80            "limit": limit,
81            "percent": percent,
82        })),
83        EventKind::BudgetExhausted {
84            role,
85            dimension,
86            used,
87            limit,
88        } => Some(json!({
89            "type": "budget_exhausted",
90            "role": role,
91            "dimension": dimension,
92            "used": used,
93            "limit": limit,
94        })),
95        EventKind::PolicyViolation { role, tool, reason } => Some(json!({
96            "type": "policy_violation",
97            "role": role,
98            "tool": tool,
99            "reason": reason,
100        })),
101        EventKind::RoleChanged { from, to } => Some(json!({
102            "type": "role_changed",
103            "from": from,
104            "to": to,
105        })),
106        _ => None,
107    }
108}
109
110/// OCP Part 2 grant set for a role: which capabilities the subject holds.
111pub fn capability_grant_set(role_name: &str) -> Value {
112    let mut caps: Vec<&'static str> = role_capabilities(role_name)
113        .into_iter()
114        .map(|c| c.display_name())
115        .collect();
116    caps.sort_unstable();
117    json!({ "subject": role_name, "capabilities": caps })
118}
119
120/// OCP Part 2 check result: may `role_name` invoke `tool_name`?
121pub fn capability_check_result(role_name: &str, tool_name: &str) -> Value {
122    let result = check_capabilities(role_name, tool_name);
123    let missing: Vec<&'static str> = result
124        .missing
125        .iter()
126        .map(super::capabilities::Capability::display_name)
127        .collect();
128    json!({ "allowed": result.allowed, "missing": missing })
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn governance_events_export_with_snake_case_tags() {
137        let event = LeanCtxEvent {
138            id: 1,
139            timestamp: chrono::Utc::now().to_rfc3339(),
140            kind: EventKind::PolicyViolation {
141                role: "reviewer".into(),
142                tool: "ctx_shell".into(),
143                reason: "denied".into(),
144            },
145        };
146        let exported = export_event(&event).expect("governance event must export");
147        assert_eq!(exported["kind"]["type"], "policy_violation");
148    }
149
150    #[test]
151    fn non_governance_events_are_not_exported() {
152        let event = LeanCtxEvent {
153            id: 2,
154            timestamp: chrono::Utc::now().to_rfc3339(),
155            kind: EventKind::CacheHit {
156                path: "src/lib.rs".into(),
157                saved_tokens: 10,
158            },
159        };
160        assert!(export_event(&event).is_none());
161    }
162
163    #[test]
164    fn grant_set_uses_registry_identifiers() {
165        let grant = capability_grant_set("admin");
166        let caps = grant["capabilities"].as_array().unwrap();
167        assert!(caps.iter().any(|c| c == "exec:unrestricted"));
168        assert!(caps.iter().any(|c| c == "fs:read"));
169    }
170
171    #[test]
172    fn check_result_lists_missing_on_denial() {
173        let denied = capability_check_result("minimal", "ctx_shell");
174        assert_eq!(denied["allowed"], false);
175        assert!(!denied["missing"].as_array().unwrap().is_empty());
176
177        let allowed = capability_check_result("admin", "ctx_shell");
178        assert_eq!(allowed["allowed"], true);
179        assert!(allowed["missing"].as_array().unwrap().is_empty());
180    }
181}