Skip to main content

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 ─────────────────────────────────────────────────────