Skip to main content

mati_core/mcp/protocol/
wire.rs

1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4use super::*;
5impl Command {
6    /// Returns the serde rename string for this command variant.
7    /// Used for audit logging and tracing spans.
8    pub fn kind(&self) -> &'static str {
9        match self {
10            Self::Ping => "ping",
11            Self::Metrics => "metrics",
12            Self::Get(_) => "get",
13            Self::HookEvaluate(_) => "hook_evaluate",
14            Self::PolicyEvaluate(_) => "policy_evaluate",
15            Self::ScanPrefix(_) => "scan_prefix",
16            Self::ScanKeys(_) => "scan_keys",
17            Self::History(_) => "history",
18            Self::HistorySince(_) => "history_since",
19            Self::SessionCheckConsulted(_) => "session_check_consulted",
20            Self::SessionCheckConsultedRecent(_) => "session_check_consulted_recent",
21            Self::MemQuery(_) => "mem_query",
22            Self::ScanEnforcementEvents(_) => "scan_enforcement_events",
23            Self::ScanEnforcementEventsWithSkips(_) => "scan_enforcement_events_with_skips",
24            Self::ScanEnforcementEventsSinceMs(_) => "scan_enforcement_events_since_ms",
25            Self::ConfigGet(_) => "config_get",
26            Self::ConfigSet(_) => "config_set",
27            Self::SandboxAudit(_) => "sandbox_audit",
28            Self::MemGet(_) => "mem_get",
29            Self::MemBootstrap(_) => "mem_bootstrap",
30            Self::GotchaUpsert(_) => "gotcha_upsert",
31            Self::GotchaConfirm(_) => "gotcha_confirm",
32            Self::GotchaTombstone(_) => "gotcha_tombstone",
33            Self::PolicyWrite(_) => "policy_write",
34            Self::FileEnrich(_) => "file_enrich",
35            Self::FileReparse(_) => "file_reparse",
36            Self::FileEditHook(_) => "file_edit_hook",
37            Self::DocCapture(_) => "doc_capture",
38            Self::DecisionUpsert(_) => "decision_upsert",
39            Self::DevNoteUpsert(_) => "dev_note_upsert",
40            Self::SessionLog(_) => "session_log",
41            Self::InstructionsLoaded(_) => "instructions_loaded",
42            Self::ConsultationHit(_) => "consultation_hit",
43            Self::PolicyShadowObserve(_) => "policy_shadow_observe",
44            Self::SessionFlush => "session_flush",
45            Self::SessionHarvest => "session_harvest",
46            Self::SessionClearConsults => "session_clear_consults",
47            Self::SubagentHarvest(_) => "subagent_harvest",
48            Self::SubagentSpawned(_) => "subagent_spawned",
49            Self::SubagentEdge(_) => "subagent_edge",
50            Self::RecordImport(_) => "record_import",
51        }
52    }
53
54    /// Returns the primary target key for this command, if applicable.
55    /// Used for audit trail correlation.
56    pub fn target_key(&self) -> &str {
57        match self {
58            Self::Get(i) => &i.key,
59            Self::HookEvaluate(i) => &i.file_key,
60            Self::PolicyEvaluate(_) => "",
61            Self::ScanPrefix(i) => &i.prefix,
62            Self::ScanKeys(i) => &i.prefix,
63            Self::History(i) => &i.key,
64            Self::HistorySince(i) => &i.key,
65            Self::SessionCheckConsulted(i) => &i.key,
66            Self::SessionCheckConsultedRecent(i) => &i.key,
67            Self::MemQuery(i) => &i.query,
68            Self::MemGet(i) => &i.key,
69            Self::GotchaUpsert(i) => &i.key,
70            Self::GotchaConfirm(i) => &i.key,
71            Self::GotchaTombstone(i) => &i.key,
72            Self::PolicyWrite(i) => &i.key,
73            Self::FileEnrich(i) => &i.path,
74            Self::FileReparse(i) => &i.path,
75            Self::FileEditHook(i) => &i.path,
76            Self::DocCapture(i) => &i.path,
77            Self::DecisionUpsert(i) => &i.slug,
78            Self::DevNoteUpsert(i) => i.key.as_deref().unwrap_or(""),
79            Self::SessionLog(i) => &i.key,
80            Self::InstructionsLoaded(i) => &i.payload.file_path,
81            Self::ConsultationHit(i) => &i.key,
82            Self::PolicyShadowObserve(i) => &i.policy_key,
83            Self::ConfigGet(i) => &i.key,
84            Self::ConfigSet(i) => &i.key,
85            Self::SandboxAudit(i) => &i.setting,
86            Self::Ping
87            | Self::Metrics
88            | Self::MemBootstrap(_)
89            | Self::ScanEnforcementEvents(_)
90            | Self::ScanEnforcementEventsWithSkips(_)
91            | Self::ScanEnforcementEventsSinceMs(_)
92            | Self::SessionFlush
93            | Self::SessionHarvest
94            | Self::SessionClearConsults
95            | Self::SubagentHarvest(_)
96            | Self::SubagentSpawned(_)
97            | Self::SubagentEdge(_)
98            | Self::RecordImport(_) => "",
99        }
100    }
101
102    /// Returns true for commands that mutate state (categories B and C).
103    ///
104    /// Category B (reads with audited side effects): MemGet, MemBootstrap
105    /// Category C (semantic mutations): all 13 mutation commands
106    ///
107    /// Audit entries are written for all of these.
108    pub fn is_mutation(&self) -> bool {
109        matches!(
110            self,
111            // B. Reads with audited side effects
112            Self::MemGet(_)
113            | Self::MemBootstrap(_)
114            // C. Semantic mutations
115            | Self::GotchaUpsert(_)
116            | Self::GotchaConfirm(_)
117            | Self::GotchaTombstone(_)
118            | Self::PolicyWrite(_)
119            | Self::FileEnrich(_)
120            | Self::FileReparse(_)
121            | Self::FileEditHook(_)
122            | Self::DocCapture(_)
123            | Self::DecisionUpsert(_)
124            | Self::DevNoteUpsert(_)
125            | Self::SessionLog(_)
126            | Self::InstructionsLoaded(_)
127            | Self::ConsultationHit(_)
128            | Self::PolicyShadowObserve(_)
129            | Self::ConfigSet(_)
130            | Self::SandboxAudit(_)
131            | Self::SessionFlush
132            | Self::SessionHarvest
133            | Self::SessionClearConsults
134            | Self::SubagentHarvest(_)
135            | Self::SubagentSpawned(_)
136            | Self::SubagentEdge(_)
137            | Self::RecordImport(_)
138        )
139    }
140}
141
142// ── Audit ───────────────────────────────────────────────────────────────────
143
144/// Audit trail entry for commands dispatched through the v2 protocol.
145///
146/// Written to the sessions tree under `session:audit:<timestamp_ns>`.
147/// Lightweight struct — not a full `Record` — to keep audit writes cheap.
148///
149/// Every mutating command (categories B and C) produces an audit entry.
150/// Rejected commands (validation failure, version mismatch) also produce
151/// an entry with `accepted = false`.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct AuditEntry {
154    /// Wall-clock timestamp (seconds since epoch).
155    pub ts: u64,
156    /// Effective UID of the peer that sent the command.
157    pub peer_uid: u32,
158    /// PID of the peer process (None on platforms that don't expose it).
159    pub peer_pid: Option<u32>,
160    /// Daemon session UUID — correlates entries within one daemon lifetime.
161    pub daemon_session: Uuid,
162    /// Request correlation ID from the v2 protocol.
163    pub request_id: Uuid,
164    /// Command kind string (e.g., "gotcha_upsert", "file_enrich").
165    pub command_kind: String,
166    /// Primary key affected by this command (empty for unit commands).
167    pub target_key: String,
168    /// Whether the command was accepted (dispatched to handler) or rejected.
169    pub accepted: bool,
170    /// Error code if rejected, None if accepted.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub error_code: Option<ErrorCode>,
173}
174
175// ── V1→V2 command mapping ───────────────────────────────────────────────────
176//
177// Used by the CLI proxy and MCP proxy to convert legacy v1-style (cmd, args)
178// calls into v2 Command JSON. This is a transitional bridge — callers that
179// are updated to construct typed Commands directly do not need this.
180
181/// Map a v1-style `(cmd_str, args_json)` pair to a v2 Command JSON object.
182///
183/// **Pure reads only.** All mutation and side-effecting-read callers have been
184/// migrated to construct typed `protocol::Command` values directly via
185/// `daemon_v2()`. This function is retained only for pure-read commands used
186/// by `daemon_result()` and `proxy_daemon_result()`.
187///
188/// Panics in debug builds if called with a mutation or side-effecting command.
189pub fn v1_to_v2_command(cmd: &str, args: &serde_json::Value) -> serde_json::Value {
190    use serde_json::json;
191
192    match cmd {
193        // Pure reads — the only commands that still use this mapping.
194        "ping" => json!({"type": "ping"}),
195        "metrics" => json!({"type": "metrics"}),
196        "get" => json!({"type": "get", "key": args["key"]}),
197        "hook_evaluate" => json!({
198            "type": "hook_evaluate",
199            "file_key": args["file_key"],
200            "include_recent": args.get("include_recent").and_then(|v| v.as_bool()).unwrap_or(false),
201            "actor": args["actor"],
202        }),
203        "scan_prefix" => json!({"type": "scan_prefix", "prefix": args["prefix"]}),
204        "scan_keys" => json!({"type": "scan_keys", "prefix": args["prefix"]}),
205        "history" => {
206            json!({"type": "history", "key": args["key"], "limit": args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50)})
207        }
208        "history_since" => json!({
209            "type": "history_since",
210            "key": args["key"],
211            "since_ts": args.get("since_ts").and_then(|v| v.as_u64()).unwrap_or(0),
212            "limit": args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50),
213        }),
214        "session_check_consulted" => json!({"type": "session_check_consulted", "key": args["key"]}),
215        "session_check_consulted_recent" => json!({
216            "type": "session_check_consulted_recent",
217            "key": args["key"],
218            "ttl_secs": args.get("ttl_secs").and_then(|v| v.as_u64()).unwrap_or(900),
219        }),
220        "mem_query" => json!({
221            "type": "mem_query",
222            "query": args["query"],
223            "mode": args.get("mode").and_then(|v| v.as_str()).unwrap_or("text"),
224            "limit": args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20),
225            "since": args.get("since").and_then(|v| v.as_u64()),
226        }),
227        "scan_enforcement_events" => json!({
228            "type": "scan_enforcement_events",
229            "since_seq": args.get("since_seq").and_then(|v| v.as_u64()).unwrap_or(0),
230            "until_seq": args.get("until_seq").and_then(|v| v.as_u64()).unwrap_or(u64::MAX),
231        }),
232        "scan_enforcement_events_with_skips" => json!({
233            "type": "scan_enforcement_events_with_skips",
234            "since_seq": args.get("since_seq").and_then(|v| v.as_u64()).unwrap_or(0),
235            "until_seq": args.get("until_seq").and_then(|v| v.as_u64()).unwrap_or(u64::MAX),
236        }),
237        "scan_enforcement_events_since_ms" => json!({
238            "type": "scan_enforcement_events_since_ms",
239            "since_ms": args.get("since_ms").and_then(|v| v.as_u64()).unwrap_or(0),
240            "until_ms": args.get("until_ms").and_then(|v| v.as_u64()).unwrap_or(u64::MAX),
241        }),
242        // Side-effecting reads — pure read shape on the wire, sessions-tree
243        // side effects (consultation receipt, audit) live entirely on the
244        // daemon side. Routing these through the typed Command enum is
245        // strictly preferable, but the MCP Socket-backend tools.rs paths
246        // call into this mapper today; without these arms every mem_get /
247        // mem_bootstrap call against a Socket-mode `mati serve` panics the
248        // rmcp task and surfaces as `Transport closed` to the client.
249        "mem_get" => json!({"type": "mem_get", "key": args["key"], "actor": args["actor"]}),
250        "mem_bootstrap" => json!({
251            "type": "mem_bootstrap",
252            "context_files": args.get("context_files").cloned().unwrap_or_else(|| serde_json::json!([])),
253        }),
254        other => {
255            panic!(
256                "v1_to_v2_command called with unsupported command '{other}' — \
257                 only pure reads are supported; mutation/side-effecting callers \
258                 must use daemon_v2() with typed Command"
259            );
260        }
261    }
262}
263
264// ── Tests ───────────────────────────────────────────────────────────────────