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::PolicyViolation { role, tool, reason } => Some(json!({
70            "type": "policy_violation",
71            "role": role,
72            "tool": tool,
73            "reason": reason,
74        })),
75        EventKind::RoleChanged { from, to } => Some(json!({
76            "type": "role_changed",
77            "from": from,
78            "to": to,
79        })),
80        _ => None,
81    }
82}
83
84/// OCP Part 2 grant set for a role: which capabilities the subject holds.
85pub fn capability_grant_set(role_name: &str) -> Value {
86    let mut caps: Vec<&'static str> = role_capabilities(role_name)
87        .into_iter()
88        .map(|c| c.display_name())
89        .collect();
90    caps.sort_unstable();
91    json!({ "subject": role_name, "capabilities": caps })
92}
93
94/// OCP Part 2 check result: may `role_name` invoke `tool_name`?
95pub fn capability_check_result(role_name: &str, tool_name: &str) -> Value {
96    let result = check_capabilities(role_name, tool_name);
97    let missing: Vec<&'static str> = result
98        .missing
99        .iter()
100        .map(super::capabilities::Capability::display_name)
101        .collect();
102    json!({ "allowed": result.allowed, "missing": missing })
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn governance_events_export_with_snake_case_tags() {
111        let event = LeanCtxEvent {
112            id: 1,
113            timestamp: chrono::Utc::now().to_rfc3339(),
114            kind: EventKind::PolicyViolation {
115                role: "reviewer".into(),
116                tool: "ctx_shell".into(),
117                reason: "denied".into(),
118            },
119        };
120        let exported = export_event(&event).expect("governance event must export");
121        assert_eq!(exported["kind"]["type"], "policy_violation");
122    }
123
124    #[test]
125    fn non_governance_events_are_not_exported() {
126        let event = LeanCtxEvent {
127            id: 2,
128            timestamp: chrono::Utc::now().to_rfc3339(),
129            kind: EventKind::CacheHit {
130                path: "src/lib.rs".into(),
131                saved_tokens: 10,
132            },
133        };
134        assert!(export_event(&event).is_none());
135    }
136
137    #[test]
138    fn grant_set_uses_registry_identifiers() {
139        let grant = capability_grant_set("admin");
140        let caps = grant["capabilities"].as_array().unwrap();
141        assert!(caps.iter().any(|c| c == "exec:unrestricted"));
142        assert!(caps.iter().any(|c| c == "fs:read"));
143    }
144
145    #[test]
146    fn check_result_lists_missing_on_denial() {
147        let denied = capability_check_result("minimal", "ctx_shell");
148        assert_eq!(denied["allowed"], false);
149        assert!(
150            !denied["missing"].as_array().unwrap().is_empty(),
151            "`denied[\"missing\"].as_array().unwrap()` is empty"
152        );
153
154        let allowed = capability_check_result("admin", "ctx_shell");
155        assert_eq!(allowed["allowed"], true);
156        {
157            let found = &allowed["missing"].as_array().unwrap();
158            assert!(found.is_empty(), "{found:?}");
159        }
160    }
161}