mati_core/mcp/protocol/core.rs
1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4use crate::store::AgentKind;
5
6use super::*;
7// ── Protocol constants ──────────────────────────────────────────────────────
8
9/// Protocol version. Bump on incompatible wire format changes.
10/// v1: newline-delimited JSON, flat cmd/args
11/// v2: newline-delimited JSON, typed Command enum, session UUID required,
12/// request size capped at [`MAX_FRAME_SIZE`]
13pub const PROTOCOL_VERSION: u16 = 2;
14
15/// Maximum request size in bytes (including the trailing newline).
16/// Enforced by `socket_handle_connection` via `AsyncReadExt::take` before
17/// any JSON parsing occurs. Oversized requests receive
18/// [`ErrorCode::FrameTooLarge`] without triggering handler side effects.
19///
20/// Chosen to comfortably fit the largest normal request (FileEnrich ~2-4 KiB)
21/// with headroom, while rejecting pathological payloads.
22pub const MAX_FRAME_SIZE: usize = 65_536;
23
24// ── Request ─────────────────────────────────────────────────────────────────
25
26/// Daemon IPC request. Deserialized from a bounded frame.
27///
28/// Unknown top-level fields are rejected. The `cmd` field is internally tagged
29/// by `type`, and each command's input DTO independently rejects unknown fields.
30#[derive(Debug, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct Request {
33 /// Protocol version — validated at the wire layer before dispatch.
34 pub v: u16,
35 /// Correlation ID — used to match responses to requests. Not idempotency.
36 pub id: Uuid,
37 /// Session UUID — required on every request. This is a session marker for
38 /// audit/provenance, NOT an authentication token. Peer identity is
39 /// established via Unix peer credentials (`peer_cred()`).
40 pub session: Uuid,
41 /// Client-declared agent identity for attribution (ADR-018).
42 /// Optional and additive: pre-multi-agent clients omit this field;
43 /// the daemon stamps `Unknown` server-side when absent. NOT verified —
44 /// same-UID processes are trusted (THREAT_MODEL.md section 3.I).
45 #[serde(default)]
46 pub agent: Option<AgentKind>,
47 /// The command to execute.
48 pub cmd: Command,
49}
50
51// ── Response ────────────────────────────────────────────────────────────────
52
53/// Daemon IPC response. Serialized into a bounded frame.
54#[derive(Debug, Serialize)]
55#[serde(tag = "status")]
56pub enum Response {
57 /// Command succeeded. `data` contains the command-specific result.
58 #[serde(rename = "ok")]
59 Ok { id: Uuid, data: serde_json::Value },
60 /// Command failed. `code` is a structured error code for programmatic
61 /// handling; `message` is a human-readable description.
62 #[serde(rename = "err")]
63 Err {
64 id: Uuid,
65 code: ErrorCode,
66 message: String,
67 },
68}
69
70impl Response {
71 /// Construct a success response.
72 pub fn ok(id: Uuid, data: serde_json::Value) -> Self {
73 Self::Ok { id, data }
74 }
75
76 /// Construct an error response.
77 pub fn err(id: Uuid, code: ErrorCode, message: impl Into<String>) -> Self {
78 Self::Err {
79 id,
80 code,
81 message: message.into(),
82 }
83 }
84}
85
86// ── Error codes ─────────────────────────────────────────────────────────────
87
88/// Structured error codes for programmatic handling by the CLI proxy.
89///
90/// Protocol-level errors (before dispatch):
91/// - `VersionMismatch`, `FrameTooLarge`, `MalformedRequest`, `SessionMismatch`
92///
93/// Command-level errors (during dispatch):
94/// - `ValidationFailed`, `NotFound`, `Conflict`, `InvalidStateTransition`,
95/// `StoreError`, `Internal`
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
97#[serde(rename_all = "snake_case")]
98pub enum ErrorCode {
99 /// Request protocol version does not match daemon's PROTOCOL_VERSION.
100 VersionMismatch,
101 /// Request exceeds [`MAX_FRAME_SIZE`] bytes. Rejected before JSON parsing.
102 FrameTooLarge,
103 /// JSON parse error, unknown fields, or type mismatch.
104 MalformedRequest,
105 /// Request session UUID does not match daemon's current session.
106 /// Client should re-read daemon metadata and retry once.
107 SessionMismatch,
108 /// Input validation failed (e.g., empty key, invalid slug, bad enum value).
109 ValidationFailed,
110 /// Referenced record does not exist.
111 NotFound,
112 /// Key collision (e.g., creating a gotcha that already exists).
113 Conflict,
114 /// State transition not allowed (e.g., confirming a tombstoned record).
115 InvalidStateTransition,
116 /// Underlying SurrealKV or tantivy error.
117 StoreError,
118 /// Unexpected internal error.
119 Internal,
120}
121
122// ── Command enum ────────────────────────────────────────────────────────────
123
124/// All commands available over the daemon IPC protocol.
125///
126/// Internally tagged by `"type"`. Each variant either has no arguments (unit)
127/// or wraps a typed input DTO with `#[serde(deny_unknown_fields)]`.
128///
129/// There is no public `put` or `delete` command. All mutations are semantic.
130#[derive(Debug, Serialize, Deserialize)]
131#[serde(tag = "type")]
132pub enum Command {
133 // ── A. Pure reads ───────────────────────────────────────────────────
134 /// Health check. No arguments.
135 #[serde(rename = "ping")]
136 Ping,
137
138 /// Snapshot of live daemon metrics — per-command counters and latency
139 /// percentiles. Pure read, no audit, no side effects.
140 #[serde(rename = "metrics")]
141 Metrics,
142
143 /// Single record lookup by key.
144 #[serde(rename = "get")]
145 Get(GetInput),
146
147 /// Bulk lookup for hook decision: file record + linked gotchas + consultation status.
148 #[serde(rename = "hook_evaluate")]
149 HookEvaluate(HookEvaluateInput),
150
151 /// Evaluate a normalized action against the daemon-resident local policies.
152 #[serde(rename = "policy_evaluate")]
153 PolicyEvaluate(PolicyEvaluateInput),
154
155 /// Scan all records whose key starts with a prefix.
156 #[serde(rename = "scan_prefix")]
157 ScanPrefix(ScanPrefixInput),
158
159 /// Scan raw keys under a prefix, without deserializing values.
160 /// Unlike `scan_prefix`, this also returns keys whose values are not
161 /// serialized `Record`s (e.g. `graph:edge:*` raw timestamps).
162 #[serde(rename = "scan_keys")]
163 ScanKeys(ScanKeysInput),
164
165 /// Version history for a single key.
166 #[serde(rename = "history")]
167 History(HistoryInput),
168
169 /// Version history for a single key since a timestamp.
170 #[serde(rename = "history_since")]
171 HistorySince(HistorySinceInput),
172
173 /// Check whether a consultation receipt exists for a key.
174 #[serde(rename = "session_check_consulted")]
175 SessionCheckConsulted(SessionCheckConsultedInput),
176
177 /// Check whether a recent consultation receipt exists (within TTL).
178 #[serde(rename = "session_check_consulted_recent")]
179 SessionCheckConsultedRecent(SessionCheckConsultedRecentInput),
180
181 /// BM25 text search or graph traversal.
182 #[serde(rename = "mem_query")]
183 MemQuery(MemQueryInput),
184
185 /// Scan enforcement events stored as raw JSON in the knowledge tree.
186 #[serde(rename = "scan_enforcement_events")]
187 ScanEnforcementEvents(ScanEnforcementEventsInput),
188
189 /// As `scan_enforcement_events`, but the response also carries the seq
190 /// numbers whose JSON failed to parse. Chain verification needs those to
191 /// tell an unreadable event from a deleted one.
192 #[serde(rename = "scan_enforcement_events_with_skips")]
193 ScanEnforcementEventsWithSkips(ScanEnforcementEventsInput),
194
195 /// Time-bounded enforcement scan used by policy activity reporting.
196 #[serde(rename = "scan_enforcement_events_since_ms")]
197 ScanEnforcementEventsSinceMs(ScanEnforcementEventsSinceMsInput),
198
199 /// Read a runtime configuration value (e.g. audit.write_durability).
200 /// Pure read — no audit, no side effects.
201 #[serde(rename = "config_get")]
202 ConfigGet(ConfigGetInput),
203
204 // ── B. Reads with audited side effects ──────────────────────────────
205 /// Single record lookup with consultation receipt side effect.
206 #[serde(rename = "mem_get")]
207 MemGet(MemGetInput),
208
209 /// Assemble a token-budgeted context packet for session startup.
210 #[serde(rename = "mem_bootstrap")]
211 MemBootstrap(MemBootstrapInput),
212
213 // ── C. Semantic mutations ───────────────────────────────────────────
214 /// Create or update a gotcha record. Always sets confirmed=false.
215 #[serde(rename = "gotcha_upsert")]
216 GotchaUpsert(GotchaDraftInput),
217
218 /// Confirm a gotcha for hook enforcement. Sets confirmed=true.
219 #[serde(rename = "gotcha_confirm")]
220 GotchaConfirm(GotchaConfirmInput),
221
222 /// Tombstone a gotcha and clean up file links + graph edges.
223 #[serde(rename = "gotcha_tombstone")]
224 GotchaTombstone(GotchaTombstoneInput),
225
226 /// Create, enable, disable, or tombstone a local policy.
227 #[serde(rename = "policy_write")]
228 PolicyWrite(PolicyWriteInput),
229
230 /// Enrich a file record with LLM-derived purpose, entry points, etc.
231 /// File record must already exist (created by init/reparse).
232 #[serde(rename = "file_enrich")]
233 FileEnrich(FileEnrichInput),
234
235 /// Re-analyze a file from disk and update structural fields.
236 #[serde(rename = "file_reparse")]
237 FileReparse(FileReparseInput),
238
239 /// Post-edit hook compound: consultation hit + file reparse.
240 #[serde(rename = "file_edit_hook")]
241 FileEditHook(FileEditHookInput),
242
243 /// Extract doc comment from file on disk and update file record purpose.
244 #[serde(rename = "doc_capture")]
245 DocCapture(DocCaptureInput),
246
247 /// Create or update a decision record.
248 #[serde(rename = "decision_upsert")]
249 DecisionUpsert(DecisionUpsertInput),
250
251 /// Create or update a dev note.
252 #[serde(rename = "dev_note_upsert")]
253 DevNoteUpsert(DevNoteUpsertInput),
254
255 /// Write a runtime configuration value. Records an
256 /// `EnforcementConfigChanged` event when the value actually changes.
257 #[serde(rename = "config_set")]
258 ConfigSet(ConfigSetInput),
259
260 /// Record an `EnforcementConfigChanged` audit event for an L3 sandbox-floor
261 /// change (`mati sandbox` apply/clear/protect/unprotect). Lets the CLI log
262 /// the change even when a daemon holds the store (socket mode).
263 #[serde(rename = "sandbox_audit")]
264 SandboxAudit(SandboxAuditInput),
265
266 /// Append a session analytics event (6 homogeneous event types).
267 #[serde(rename = "session_log")]
268 SessionLog(SessionLogInput),
269
270 /// Record the exact ambient instruction file payload from Claude Code.
271 /// This is internal hook telemetry, not an MCP tool or decision.
272 #[serde(rename = "instructions_loaded")]
273 InstructionsLoaded(InstructionsLoadedInput),
274
275 /// Record a consultation hit: receipt + access metrics + daily agg.
276 #[serde(rename = "consultation_hit")]
277 ConsultationHit(ConsultationHitInput),
278
279 /// Record a policy shadow observation in the eventual session store.
280 #[serde(rename = "policy_shadow_observe")]
281 PolicyShadowObserve(PolicyShadowObserveInput),
282
283 /// Flush session data (collect consulted markers into session:current).
284 #[serde(rename = "session_flush")]
285 SessionFlush,
286
287 /// Archive session, run promotions, collect stale reviews.
288 #[serde(rename = "session_harvest")]
289 SessionHarvest,
290
291 /// Clear all consult receipts (PostCompact: force re-block after compaction).
292 #[serde(rename = "session_clear_consults")]
293 SessionClearConsults,
294
295 /// Record a finished subagent's summary (from the SubagentStop hook) into
296 /// `session:summary:latest`, read back by `mem_bootstrap` as `recent_session`.
297 #[serde(rename = "subagent_harvest")]
298 SubagentHarvest(SubagentHarvestInput),
299
300 /// Record a subagent's presence (from the SubagentStart hook) as a
301 /// hash-chained `SubagentSpawned` enforcement event.
302 #[serde(rename = "subagent_spawned")]
303 SubagentSpawned(SubagentSpawnedInput),
304
305 /// Record a nested subagent→subagent spawn edge (from the `Agent`-tool
306 /// `PostToolUse` hook) as a hash-chained `SubagentEdge` enforcement event.
307 #[serde(rename = "subagent_edge")]
308 SubagentEdge(SubagentEdgeInput),
309
310 /// Bulk-import a batch of pre-built `Record`s into the knowledge tree.
311 /// Bypasses the semantic upsert handlers — records are written verbatim
312 /// so an `export → import` round-trip preserves every field
313 /// (`confirmed`, `source`, `confidence`, `lifecycle`, etc.) without
314 /// the destructive resets the typed upsert commands apply.
315 ///
316 /// Only `gotcha:*`, `decision:*`, `dev_note:*`, `file:*`, `stage:*`,
317 /// and `dep:*` keys are accepted (the knowledge-tree namespaces).
318 /// Session-tree keys (`session:*`, `analytics:*`, `compliance:*`,
319 /// `audit:*`) are rejected at the boundary — those are daemon-owned
320 /// telemetry that an `export` should never round-trip.
321 #[serde(rename = "record_import")]
322 RecordImport(RecordImportInput),
323}
324
325// ── Input DTOs ──────────────────────────────────────────────────────────────
326//
327// Each DTO uses `deny_unknown_fields` so extra fields from a malicious or
328// misconfigured client are rejected at decode time, not silently dropped.
329
330// ── A. Pure read inputs ─────────────────────────────────────────────────────