Skip to main content

mati_core/mcp/
protocol.rs

1//! Daemon IPC protocol v2 — wire types for the Unix socket boundary.
2//!
3//! All mutation commands are semantic (no raw `put`/`delete`). Trust-sensitive
4//! fields (timestamps, confidence, quality, lifecycle) are daemon-controlled
5//! and never cross the wire as client input.
6//!
7//! ## Wire format
8//!
9//! Framing: newline-delimited JSON. One JSON object per line, terminated by `\n`.
10//! Request size is capped at [`MAX_FRAME_SIZE`] bytes (enforced by the server
11//! before full buffering). Oversized requests receive [`ErrorCode::FrameTooLarge`].
12//!
13//! ## Security properties
14//!
15//! - All input DTOs use `#[serde(deny_unknown_fields)]`
16//! - `Command` is a closed enum — unknown commands are rejected at decode
17//! - Session UUID is required on every request (session marker, not auth)
18//! - Request ID is correlation only, not idempotency
19//!
20//! ## Transaction model
21//!
22//! SurrealKV supports multi-key atomic transactions within a single tree.
23//! The real constraint is mati's two-tree architecture: no single transaction
24//! can span both the `knowledge` tree and the `sessions` tree.
25//!
26//! - Same-tree commands: mutation + audit committed in one transaction
27//! - Mixed-tree commands: per-tree atomic batches with explicit substep audit
28
29use serde::{Deserialize, Serialize};
30use uuid::Uuid;
31
32use crate::store::AgentKind;
33
34// ── Protocol constants ──────────────────────────────────────────────────────
35
36/// Protocol version. Bump on incompatible wire format changes.
37/// v1: newline-delimited JSON, flat cmd/args
38/// v2: newline-delimited JSON, typed Command enum, session UUID required,
39///     request size capped at [`MAX_FRAME_SIZE`]
40pub const PROTOCOL_VERSION: u16 = 2;
41
42/// Maximum request size in bytes (including the trailing newline).
43/// Enforced by `socket_handle_connection` via `AsyncReadExt::take` before
44/// any JSON parsing occurs. Oversized requests receive
45/// [`ErrorCode::FrameTooLarge`] without triggering handler side effects.
46///
47/// Chosen to comfortably fit the largest normal request (FileEnrich ~2-4 KiB)
48/// with headroom, while rejecting pathological payloads.
49pub const MAX_FRAME_SIZE: usize = 65_536;
50
51// ── Request ─────────────────────────────────────────────────────────────────
52
53/// Daemon IPC request. Deserialized from a bounded frame.
54///
55/// Unknown top-level fields are rejected. The `cmd` field is internally tagged
56/// by `type`, and each command's input DTO independently rejects unknown fields.
57#[derive(Debug, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct Request {
60    /// Protocol version — validated at the wire layer before dispatch.
61    pub v: u16,
62    /// Correlation ID — used to match responses to requests. Not idempotency.
63    pub id: Uuid,
64    /// Session UUID — required on every request. This is a session marker for
65    /// audit/provenance, NOT an authentication token. Peer identity is
66    /// established via Unix peer credentials (`peer_cred()`).
67    pub session: Uuid,
68    /// Client-declared agent identity for attribution (ADR-018).
69    /// Optional and additive: pre-multi-agent clients omit this field;
70    /// the daemon stamps `Unknown` server-side when absent. NOT verified —
71    /// same-UID processes are trusted (THREAT_MODEL.md §3.I).
72    #[serde(default)]
73    pub agent: Option<AgentKind>,
74    /// The command to execute.
75    pub cmd: Command,
76}
77
78// ── Response ────────────────────────────────────────────────────────────────
79
80/// Daemon IPC response. Serialized into a bounded frame.
81#[derive(Debug, Serialize)]
82#[serde(tag = "status")]
83pub enum Response {
84    /// Command succeeded. `data` contains the command-specific result.
85    #[serde(rename = "ok")]
86    Ok { id: Uuid, data: serde_json::Value },
87    /// Command failed. `code` is a structured error code for programmatic
88    /// handling; `message` is a human-readable description.
89    #[serde(rename = "err")]
90    Err {
91        id: Uuid,
92        code: ErrorCode,
93        message: String,
94    },
95}
96
97impl Response {
98    /// Construct a success response.
99    pub fn ok(id: Uuid, data: serde_json::Value) -> Self {
100        Self::Ok { id, data }
101    }
102
103    /// Construct an error response.
104    pub fn err(id: Uuid, code: ErrorCode, message: impl Into<String>) -> Self {
105        Self::Err {
106            id,
107            code,
108            message: message.into(),
109        }
110    }
111}
112
113// ── Error codes ─────────────────────────────────────────────────────────────
114
115/// Structured error codes for programmatic handling by the CLI proxy.
116///
117/// Protocol-level errors (before dispatch):
118/// - `VersionMismatch`, `FrameTooLarge`, `MalformedRequest`, `SessionMismatch`
119///
120/// Command-level errors (during dispatch):
121/// - `ValidationFailed`, `NotFound`, `Conflict`, `InvalidStateTransition`,
122///   `StoreError`, `Internal`
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124#[serde(rename_all = "snake_case")]
125pub enum ErrorCode {
126    /// Request protocol version does not match daemon's PROTOCOL_VERSION.
127    VersionMismatch,
128    /// Request exceeds [`MAX_FRAME_SIZE`] bytes. Rejected before JSON parsing.
129    FrameTooLarge,
130    /// JSON parse error, unknown fields, or type mismatch.
131    MalformedRequest,
132    /// Request session UUID does not match daemon's current session.
133    /// Client should re-read daemon metadata and retry once.
134    SessionMismatch,
135    /// Input validation failed (e.g., empty key, invalid slug, bad enum value).
136    ValidationFailed,
137    /// Referenced record does not exist.
138    NotFound,
139    /// Key collision (e.g., creating a gotcha that already exists).
140    Conflict,
141    /// State transition not allowed (e.g., confirming a tombstoned record).
142    InvalidStateTransition,
143    /// Underlying SurrealKV or tantivy error.
144    StoreError,
145    /// Unexpected internal error.
146    Internal,
147}
148
149// ── Command enum ────────────────────────────────────────────────────────────
150
151/// All commands available over the daemon IPC protocol.
152///
153/// Internally tagged by `"type"`. Each variant either has no arguments (unit)
154/// or wraps a typed input DTO with `#[serde(deny_unknown_fields)]`.
155///
156/// There is no public `put` or `delete` command. All mutations are semantic.
157#[derive(Debug, Serialize, Deserialize)]
158#[serde(tag = "type")]
159pub enum Command {
160    // ── A. Pure reads ───────────────────────────────────────────────────
161    /// Health check. No arguments.
162    #[serde(rename = "ping")]
163    Ping,
164
165    /// Snapshot of live daemon metrics — per-command counters and latency
166    /// percentiles. Pure read, no audit, no side effects.
167    #[serde(rename = "metrics")]
168    Metrics,
169
170    /// Single record lookup by key.
171    #[serde(rename = "get")]
172    Get(GetInput),
173
174    /// Bulk lookup for hook decision: file record + linked gotchas + consultation status.
175    #[serde(rename = "hook_evaluate")]
176    HookEvaluate(HookEvaluateInput),
177
178    /// Scan all records whose key starts with a prefix.
179    #[serde(rename = "scan_prefix")]
180    ScanPrefix(ScanPrefixInput),
181
182    /// Version history for a single key.
183    #[serde(rename = "history")]
184    History(HistoryInput),
185
186    /// Version history for a single key since a timestamp.
187    #[serde(rename = "history_since")]
188    HistorySince(HistorySinceInput),
189
190    /// Check whether a consultation receipt exists for a key.
191    #[serde(rename = "session_check_consulted")]
192    SessionCheckConsulted(SessionCheckConsultedInput),
193
194    /// Check whether a recent consultation receipt exists (within TTL).
195    #[serde(rename = "session_check_consulted_recent")]
196    SessionCheckConsultedRecent(SessionCheckConsultedRecentInput),
197
198    /// BM25 text search or graph traversal.
199    #[serde(rename = "mem_query")]
200    MemQuery(MemQueryInput),
201
202    /// Scan enforcement events stored as raw JSON in the knowledge tree.
203    #[serde(rename = "scan_enforcement_events")]
204    ScanEnforcementEvents(ScanEnforcementEventsInput),
205
206    /// Read a runtime configuration value (e.g. enforcement.mode).
207    /// Pure read — no audit, no side effects.
208    #[serde(rename = "config_get")]
209    ConfigGet(ConfigGetInput),
210
211    // ── B. Reads with audited side effects ──────────────────────────────
212    /// Single record lookup with consultation receipt side effect.
213    #[serde(rename = "mem_get")]
214    MemGet(MemGetInput),
215
216    /// Assemble a token-budgeted context packet for session startup.
217    #[serde(rename = "mem_bootstrap")]
218    MemBootstrap(MemBootstrapInput),
219
220    // ── C. Semantic mutations ───────────────────────────────────────────
221    /// Create or update a gotcha record. Always sets confirmed=false.
222    #[serde(rename = "gotcha_upsert")]
223    GotchaUpsert(GotchaDraftInput),
224
225    /// Confirm a gotcha for hook enforcement. Sets confirmed=true.
226    #[serde(rename = "gotcha_confirm")]
227    GotchaConfirm(GotchaConfirmInput),
228
229    /// Tombstone a gotcha and clean up file links + graph edges.
230    #[serde(rename = "gotcha_tombstone")]
231    GotchaTombstone(GotchaTombstoneInput),
232
233    /// Enrich a file record with LLM-derived purpose, entry points, etc.
234    /// File record must already exist (created by init/reparse).
235    #[serde(rename = "file_enrich")]
236    FileEnrich(FileEnrichInput),
237
238    /// Re-analyze a file from disk and update structural fields.
239    #[serde(rename = "file_reparse")]
240    FileReparse(FileReparseInput),
241
242    /// Post-edit hook compound: consultation hit + file reparse.
243    #[serde(rename = "file_edit_hook")]
244    FileEditHook(FileEditHookInput),
245
246    /// Extract doc comment from file on disk and update file record purpose.
247    #[serde(rename = "doc_capture")]
248    DocCapture(DocCaptureInput),
249
250    /// Create or update a decision record.
251    #[serde(rename = "decision_upsert")]
252    DecisionUpsert(DecisionUpsertInput),
253
254    /// Create or update a dev note.
255    #[serde(rename = "dev_note_upsert")]
256    DevNoteUpsert(DevNoteUpsertInput),
257
258    /// Write a runtime configuration value. Records an
259    /// `EnforcementConfigChanged` event when the value actually changes.
260    #[serde(rename = "config_set")]
261    ConfigSet(ConfigSetInput),
262
263    /// Append a session analytics event (6 homogeneous event types).
264    #[serde(rename = "session_log")]
265    SessionLog(SessionLogInput),
266
267    /// Record a consultation hit: receipt + access metrics + daily agg.
268    #[serde(rename = "consultation_hit")]
269    ConsultationHit(ConsultationHitInput),
270
271    /// Flush session data (collect consulted markers into session:current).
272    #[serde(rename = "session_flush")]
273    SessionFlush,
274
275    /// Archive session, run promotions, collect stale reviews.
276    #[serde(rename = "session_harvest")]
277    SessionHarvest,
278
279    /// Bulk-import a batch of pre-built `Record`s into the knowledge tree.
280    /// Bypasses the semantic upsert handlers — records are written verbatim
281    /// so an `export → import` round-trip preserves every field
282    /// (`confirmed`, `source`, `confidence`, `lifecycle`, etc.) without
283    /// the destructive resets the typed upsert commands apply.
284    ///
285    /// Only `gotcha:*`, `decision:*`, `dev_note:*`, `file:*`, `stage:*`,
286    /// and `dep:*` keys are accepted (the knowledge-tree namespaces).
287    /// Session-tree keys (`session:*`, `analytics:*`, `compliance:*`,
288    /// `audit:*`) are rejected at the boundary — those are daemon-owned
289    /// telemetry that an `export` should never round-trip.
290    #[serde(rename = "record_import")]
291    RecordImport(RecordImportInput),
292}
293
294// ── Input DTOs ──────────────────────────────────────────────────────────────
295//
296// Each DTO uses `deny_unknown_fields` so extra fields from a malicious or
297// misconfigured client are rejected at decode time, not silently dropped.
298
299// ── A. Pure read inputs ─────────────────────────────────────────────────────
300
301#[derive(Debug, Serialize, Deserialize)]
302#[serde(deny_unknown_fields)]
303pub struct GetInput {
304    pub key: String,
305}
306
307#[derive(Debug, Serialize, Deserialize)]
308#[serde(deny_unknown_fields)]
309pub struct HookEvaluateInput {
310    pub file_key: String,
311    #[serde(default)]
312    pub include_recent: bool,
313}
314
315#[derive(Debug, Serialize, Deserialize)]
316#[serde(deny_unknown_fields)]
317pub struct ScanPrefixInput {
318    pub prefix: String,
319}
320
321#[derive(Debug, Serialize, Deserialize)]
322#[serde(deny_unknown_fields)]
323pub struct ScanEnforcementEventsInput {
324    #[serde(default)]
325    pub since_seq: u64,
326    #[serde(default = "default_until_seq")]
327    pub until_seq: u64,
328}
329
330fn default_until_seq() -> u64 {
331    u64::MAX
332}
333
334#[derive(Debug, Serialize, Deserialize)]
335#[serde(deny_unknown_fields)]
336pub struct HistoryInput {
337    pub key: String,
338    #[serde(default = "default_history_limit")]
339    pub limit: u64,
340}
341
342#[derive(Debug, Serialize, Deserialize)]
343#[serde(deny_unknown_fields)]
344pub struct HistorySinceInput {
345    pub key: String,
346    pub since_ts: u64,
347    #[serde(default = "default_history_limit")]
348    pub limit: u64,
349}
350
351fn default_history_limit() -> u64 {
352    50
353}
354
355#[derive(Debug, Serialize, Deserialize)]
356#[serde(deny_unknown_fields)]
357pub struct SessionCheckConsultedInput {
358    pub key: String,
359}
360
361#[derive(Debug, Serialize, Deserialize)]
362#[serde(deny_unknown_fields)]
363pub struct SessionCheckConsultedRecentInput {
364    pub key: String,
365    #[serde(default = "default_ttl_secs")]
366    pub ttl_secs: u64,
367}
368
369fn default_ttl_secs() -> u64 {
370    900
371}
372
373#[derive(Debug, Serialize, Deserialize)]
374#[serde(deny_unknown_fields)]
375pub struct MemQueryInput {
376    pub query: String,
377    #[serde(default = "default_query_mode")]
378    pub mode: QueryMode,
379    #[serde(default = "default_query_limit")]
380    pub limit: u32,
381}
382
383fn default_query_mode() -> QueryMode {
384    QueryMode::Text
385}
386
387fn default_query_limit() -> u32 {
388    20
389}
390
391/// Search mode for mem_query.
392#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
393#[serde(rename_all = "snake_case")]
394pub enum QueryMode {
395    /// BM25 full-text search over record keys, values, and tags.
396    Text,
397    /// Filter records by tag (substring, case-insensitive).
398    Tag,
399    /// 1-hop graph traversal from a seed key.
400    Graph,
401    /// Semantic search (requires --features semantic).
402    Semantic,
403}
404
405// ── B. Read-with-side-effect inputs ─────────────────────────────────────────
406
407#[derive(Debug, Serialize, Deserialize)]
408#[serde(deny_unknown_fields)]
409pub struct MemGetInput {
410    pub key: String,
411}
412
413#[derive(Debug, Serialize, Deserialize)]
414#[serde(deny_unknown_fields)]
415pub struct MemBootstrapInput {
416    #[serde(default)]
417    pub context_files: Vec<String>,
418}
419
420// ── C. Semantic mutation inputs ─────────────────────────────────────────────
421
422/// Gotcha creation/update input. The client expresses intent only — the daemon
423/// derives confirmation state, confidence, quality, timestamps, and version.
424///
425/// Confirmation is ALWAYS reset to `false` on upsert. Use `GotchaConfirm`
426/// to re-confirm after editing.
427#[derive(Debug, Serialize, Deserialize)]
428#[serde(deny_unknown_fields)]
429pub struct GotchaDraftInput {
430    /// Gotcha key, must match `gotcha:<slug>`.
431    pub key: String,
432    /// Actionable rule text (imperative verb).
433    pub rule: String,
434    /// Causality sentence explaining why this rule exists.
435    pub reason: String,
436    /// Severity level.
437    pub severity: Severity,
438    /// File paths this gotcha applies to.
439    #[serde(default)]
440    pub affected_files: Vec<String>,
441    /// Optional external reference URL.
442    #[serde(default)]
443    pub ref_url: Option<String>,
444    /// Optional tags.
445    #[serde(default)]
446    pub tags: Vec<String>,
447    /// Record-level priority.
448    #[serde(default)]
449    pub priority: Priority,
450    /// Record source — when set, the handler uses this instead of defaulting
451    /// to `ClaudeEnrich`. CLI `gotcha add` sends `DeveloperManual` here.
452    #[serde(default)]
453    pub source: Option<String>,
454}
455
456#[derive(Debug, Serialize, Deserialize)]
457#[serde(deny_unknown_fields)]
458pub struct GotchaConfirmInput {
459    pub key: String,
460}
461
462#[derive(Debug, Serialize, Deserialize)]
463#[serde(deny_unknown_fields)]
464pub struct GotchaTombstoneInput {
465    pub key: String,
466}
467
468/// File enrichment input from LLM analysis (e.g., /mati-enrich workflow).
469/// The file record must already exist (created by init/reparse).
470///
471/// Fields that are daemon-managed and MUST NOT appear:
472/// - `gotcha_keys` (managed by gotcha lifecycle commands)
473/// - `imports` (derived from tree-sitter)
474/// - All structural/internal fields (unsafe_count, unwrap_count, etc.)
475#[derive(Debug, Serialize, Deserialize)]
476#[serde(deny_unknown_fields)]
477pub struct FileEnrichInput {
478    /// File path (maps to `file:<path>`).
479    pub path: String,
480    /// Purpose sentence (verb-led).
481    pub purpose: String,
482    /// Function/method entry points identified by enrichment.
483    #[serde(default)]
484    pub entry_points: Vec<String>,
485    /// Decision records that affect this file.
486    #[serde(default)]
487    pub decision_keys: Vec<String>,
488    /// TODO items found during enrichment.
489    #[serde(default)]
490    pub todos: Vec<String>,
491    /// Optional tags.
492    #[serde(default)]
493    pub tags: Vec<String>,
494    /// Record-level priority.
495    #[serde(default)]
496    pub priority: Priority,
497}
498
499#[derive(Debug, Serialize, Deserialize)]
500#[serde(deny_unknown_fields)]
501pub struct FileReparseInput {
502    pub path: String,
503}
504
505#[derive(Debug, Serialize, Deserialize)]
506#[serde(deny_unknown_fields)]
507pub struct FileEditHookInput {
508    pub path: String,
509}
510
511/// Path-only doc capture. The daemon reads the file from disk and extracts
512/// the doc comment — no content crosses the wire.
513#[derive(Debug, Serialize, Deserialize)]
514#[serde(deny_unknown_fields)]
515pub struct DocCaptureInput {
516    pub path: String,
517}
518
519#[derive(Debug, Serialize, Deserialize)]
520#[serde(deny_unknown_fields)]
521pub struct DecisionUpsertInput {
522    /// Key slug (daemon prepends `decision:`).
523    pub slug: String,
524    /// Human-readable summary ("We use X because Y").
525    pub value: String,
526    /// Concise decision summary (payload field).
527    pub summary: String,
528    /// Rationale text (payload field).
529    pub rationale: String,
530    /// Optional tags.
531    #[serde(default)]
532    pub tags: Vec<String>,
533    /// Record-level priority.
534    #[serde(default)]
535    pub priority: Priority,
536}
537
538#[derive(Debug, Serialize, Deserialize)]
539#[serde(deny_unknown_fields)]
540pub struct DevNoteUpsertInput {
541    /// If absent, daemon auto-generates `dev_note:<slug>-<timestamp>`.
542    /// If present, must match an existing `dev_note:*` key (update mode).
543    #[serde(default)]
544    pub key: Option<String>,
545    /// Freeform note text.
546    pub text: String,
547    /// Optional tags.
548    #[serde(default)]
549    pub tags: Vec<String>,
550    /// Record-level priority.
551    #[serde(default)]
552    pub priority: Priority,
553}
554
555#[derive(Debug, Serialize, Deserialize)]
556#[serde(deny_unknown_fields)]
557pub struct SessionLogInput {
558    /// The event type (closed enum, 6 variants).
559    pub event: SessionEvent,
560    /// The record key this event pertains to.
561    pub key: String,
562}
563
564/// Session analytics event types. Each maps to a daily aggregation key prefix.
565///
566/// `Hit` is NOT included — it has richer side effects and uses the separate
567/// `ConsultationHit` command.
568#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
569#[serde(rename_all = "snake_case")]
570pub enum SessionEvent {
571    Miss,
572    ComplianceMiss,
573    ComplianceHit,
574    CodexShellMiss,
575    Bootstrap,
576    PromptNudge,
577}
578
579#[derive(Debug, Serialize, Deserialize)]
580#[serde(deny_unknown_fields)]
581pub struct ConsultationHitInput {
582    pub key: String,
583}
584
585/// Input for `Command::RecordImport`. Records are written verbatim into the
586/// knowledge tree, preserving every field. The daemon validates each record's
587/// key prefix against the knowledge-namespace allowlist before writing.
588#[derive(Debug, Serialize, Deserialize)]
589#[serde(deny_unknown_fields)]
590pub struct RecordImportInput {
591    pub records: Vec<crate::store::Record>,
592}
593
594/// Input for `Command::ConfigGet`. `key` is the dotted config name
595/// (e.g. `enforcement.mode`, `enforcement.retention`).
596#[derive(Debug, Serialize, Deserialize)]
597#[serde(deny_unknown_fields)]
598pub struct ConfigGetInput {
599    pub key: String,
600}
601
602/// Input for `Command::ConfigSet`. Values are always sent as strings on the
603/// wire and parsed/validated by the dispatcher.
604#[derive(Debug, Serialize, Deserialize)]
605#[serde(deny_unknown_fields)]
606pub struct ConfigSetInput {
607    pub key: String,
608    pub value: String,
609}
610
611// ── Shared enums ────────────────────────────────────────────────────────────
612
613/// Severity level for gotcha records. Closed enum.
614#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
615#[serde(rename_all = "snake_case")]
616pub enum Severity {
617    Critical,
618    High,
619    #[default]
620    Normal,
621    Low,
622}
623
624/// Record-level priority. Closed enum.
625#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
626#[serde(rename_all = "snake_case")]
627pub enum Priority {
628    Critical,
629    High,
630    #[default]
631    Normal,
632    Low,
633}
634
635// ── Conversions from store types ────────────────────────────────────────────
636
637impl From<crate::store::Priority> for Severity {
638    fn from(p: crate::store::Priority) -> Self {
639        match p {
640            crate::store::Priority::Low => Severity::Low,
641            crate::store::Priority::Normal => Severity::Normal,
642            crate::store::Priority::High => Severity::High,
643            crate::store::Priority::Critical => Severity::Critical,
644        }
645    }
646}
647
648impl From<crate::store::Priority> for Priority {
649    fn from(p: crate::store::Priority) -> Self {
650        match p {
651            crate::store::Priority::Low => Priority::Low,
652            crate::store::Priority::Normal => Priority::Normal,
653            crate::store::Priority::High => Priority::High,
654            crate::store::Priority::Critical => Priority::Critical,
655        }
656    }
657}
658
659// ── Command helpers ──────────────────────────────────────────────────────────
660
661impl Command {
662    /// Returns the serde rename string for this command variant.
663    /// Used for audit logging and tracing spans.
664    pub fn kind(&self) -> &'static str {
665        match self {
666            Self::Ping => "ping",
667            Self::Metrics => "metrics",
668            Self::Get(_) => "get",
669            Self::HookEvaluate(_) => "hook_evaluate",
670            Self::ScanPrefix(_) => "scan_prefix",
671            Self::History(_) => "history",
672            Self::HistorySince(_) => "history_since",
673            Self::SessionCheckConsulted(_) => "session_check_consulted",
674            Self::SessionCheckConsultedRecent(_) => "session_check_consulted_recent",
675            Self::MemQuery(_) => "mem_query",
676            Self::ScanEnforcementEvents(_) => "scan_enforcement_events",
677            Self::ConfigGet(_) => "config_get",
678            Self::ConfigSet(_) => "config_set",
679            Self::MemGet(_) => "mem_get",
680            Self::MemBootstrap(_) => "mem_bootstrap",
681            Self::GotchaUpsert(_) => "gotcha_upsert",
682            Self::GotchaConfirm(_) => "gotcha_confirm",
683            Self::GotchaTombstone(_) => "gotcha_tombstone",
684            Self::FileEnrich(_) => "file_enrich",
685            Self::FileReparse(_) => "file_reparse",
686            Self::FileEditHook(_) => "file_edit_hook",
687            Self::DocCapture(_) => "doc_capture",
688            Self::DecisionUpsert(_) => "decision_upsert",
689            Self::DevNoteUpsert(_) => "dev_note_upsert",
690            Self::SessionLog(_) => "session_log",
691            Self::ConsultationHit(_) => "consultation_hit",
692            Self::SessionFlush => "session_flush",
693            Self::SessionHarvest => "session_harvest",
694            Self::RecordImport(_) => "record_import",
695        }
696    }
697
698    /// Returns the primary target key for this command, if applicable.
699    /// Used for audit trail correlation.
700    pub fn target_key(&self) -> &str {
701        match self {
702            Self::Get(i) => &i.key,
703            Self::HookEvaluate(i) => &i.file_key,
704            Self::ScanPrefix(i) => &i.prefix,
705            Self::History(i) => &i.key,
706            Self::HistorySince(i) => &i.key,
707            Self::SessionCheckConsulted(i) => &i.key,
708            Self::SessionCheckConsultedRecent(i) => &i.key,
709            Self::MemQuery(i) => &i.query,
710            Self::MemGet(i) => &i.key,
711            Self::GotchaUpsert(i) => &i.key,
712            Self::GotchaConfirm(i) => &i.key,
713            Self::GotchaTombstone(i) => &i.key,
714            Self::FileEnrich(i) => &i.path,
715            Self::FileReparse(i) => &i.path,
716            Self::FileEditHook(i) => &i.path,
717            Self::DocCapture(i) => &i.path,
718            Self::DecisionUpsert(i) => &i.slug,
719            Self::DevNoteUpsert(i) => i.key.as_deref().unwrap_or(""),
720            Self::SessionLog(i) => &i.key,
721            Self::ConsultationHit(i) => &i.key,
722            Self::ConfigGet(i) => &i.key,
723            Self::ConfigSet(i) => &i.key,
724            Self::Ping
725            | Self::Metrics
726            | Self::MemBootstrap(_)
727            | Self::ScanEnforcementEvents(_)
728            | Self::SessionFlush
729            | Self::SessionHarvest
730            | Self::RecordImport(_) => "",
731        }
732    }
733
734    /// Returns true for commands that mutate state (categories B and C).
735    ///
736    /// Category B (reads with audited side effects): MemGet, MemBootstrap
737    /// Category C (semantic mutations): all 13 mutation commands
738    ///
739    /// Audit entries are written for all of these.
740    pub fn is_mutation(&self) -> bool {
741        matches!(
742            self,
743            // B. Reads with audited side effects
744            Self::MemGet(_)
745            | Self::MemBootstrap(_)
746            // C. Semantic mutations
747            | Self::GotchaUpsert(_)
748            | Self::GotchaConfirm(_)
749            | Self::GotchaTombstone(_)
750            | Self::FileEnrich(_)
751            | Self::FileReparse(_)
752            | Self::FileEditHook(_)
753            | Self::DocCapture(_)
754            | Self::DecisionUpsert(_)
755            | Self::DevNoteUpsert(_)
756            | Self::SessionLog(_)
757            | Self::ConsultationHit(_)
758            | Self::ConfigSet(_)
759            | Self::SessionFlush
760            | Self::SessionHarvest
761            | Self::RecordImport(_)
762        )
763    }
764}
765
766// ── Audit ───────────────────────────────────────────────────────────────────
767
768/// Audit trail entry for commands dispatched through the v2 protocol.
769///
770/// Written to the sessions tree under `session:audit:<timestamp_ns>`.
771/// Lightweight struct — not a full `Record` — to keep audit writes cheap.
772///
773/// Every mutating command (categories B and C) produces an audit entry.
774/// Rejected commands (validation failure, version mismatch) also produce
775/// an entry with `accepted = false`.
776#[derive(Debug, Clone, Serialize, Deserialize)]
777pub struct AuditEntry {
778    /// Wall-clock timestamp (seconds since epoch).
779    pub ts: u64,
780    /// Effective UID of the peer that sent the command.
781    pub peer_uid: u32,
782    /// PID of the peer process (None on platforms that don't expose it).
783    pub peer_pid: Option<u32>,
784    /// Daemon session UUID — correlates entries within one daemon lifetime.
785    pub daemon_session: Uuid,
786    /// Request correlation ID from the v2 protocol.
787    pub request_id: Uuid,
788    /// Command kind string (e.g., "gotcha_upsert", "file_enrich").
789    pub command_kind: String,
790    /// Primary key affected by this command (empty for unit commands).
791    pub target_key: String,
792    /// Whether the command was accepted (dispatched to handler) or rejected.
793    pub accepted: bool,
794    /// Error code if rejected, None if accepted.
795    #[serde(skip_serializing_if = "Option::is_none")]
796    pub error_code: Option<ErrorCode>,
797}
798
799// ── V1→V2 command mapping ───────────────────────────────────────────────────
800//
801// Used by the CLI proxy and MCP proxy to convert legacy v1-style (cmd, args)
802// calls into v2 Command JSON. This is a transitional bridge — callers that
803// are updated to construct typed Commands directly do not need this.
804
805/// Map a v1-style `(cmd_str, args_json)` pair to a v2 Command JSON object.
806///
807/// **Pure reads only.** All mutation and side-effecting-read callers have been
808/// migrated to construct typed `protocol::Command` values directly via
809/// `daemon_v2()`. This function is retained only for pure-read commands used
810/// by `daemon_result()` and `proxy_daemon_result()`.
811///
812/// Panics in debug builds if called with a mutation or side-effecting command.
813pub fn v1_to_v2_command(cmd: &str, args: &serde_json::Value) -> serde_json::Value {
814    use serde_json::json;
815
816    match cmd {
817        // Pure reads — the only commands that still use this mapping.
818        "ping" => json!({"type": "ping"}),
819        "metrics" => json!({"type": "metrics"}),
820        "get" => json!({"type": "get", "key": args["key"]}),
821        "hook_evaluate" => json!({
822            "type": "hook_evaluate",
823            "file_key": args["file_key"],
824            "include_recent": args.get("include_recent").and_then(|v| v.as_bool()).unwrap_or(false),
825        }),
826        "scan_prefix" => json!({"type": "scan_prefix", "prefix": args["prefix"]}),
827        "history" => {
828            json!({"type": "history", "key": args["key"], "limit": args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50)})
829        }
830        "history_since" => json!({
831            "type": "history_since",
832            "key": args["key"],
833            "since_ts": args.get("since_ts").and_then(|v| v.as_u64()).unwrap_or(0),
834            "limit": args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50),
835        }),
836        "session_check_consulted" => json!({"type": "session_check_consulted", "key": args["key"]}),
837        "session_check_consulted_recent" => json!({
838            "type": "session_check_consulted_recent",
839            "key": args["key"],
840            "ttl_secs": args.get("ttl_secs").and_then(|v| v.as_u64()).unwrap_or(900),
841        }),
842        "mem_query" => json!({
843            "type": "mem_query",
844            "query": args["query"],
845            "mode": args.get("mode").and_then(|v| v.as_str()).unwrap_or("text"),
846            "limit": args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20),
847        }),
848        "scan_enforcement_events" => json!({
849            "type": "scan_enforcement_events",
850            "since_seq": args.get("since_seq").and_then(|v| v.as_u64()).unwrap_or(0),
851            "until_seq": args.get("until_seq").and_then(|v| v.as_u64()).unwrap_or(u64::MAX),
852        }),
853        // Side-effecting reads — pure read shape on the wire, sessions-tree
854        // side effects (consultation receipt, audit) live entirely on the
855        // daemon side. Routing these through the typed Command enum is
856        // strictly preferable, but the MCP Socket-backend tools.rs paths
857        // call into this mapper today; without these arms every mem_get /
858        // mem_bootstrap call against a Socket-mode `mati serve` panics the
859        // rmcp task and surfaces as `Transport closed` to the client.
860        "mem_get" => json!({"type": "mem_get", "key": args["key"]}),
861        "mem_bootstrap" => json!({
862            "type": "mem_bootstrap",
863            "context_files": args.get("context_files").cloned().unwrap_or_else(|| serde_json::json!([])),
864        }),
865        other => {
866            panic!(
867                "v1_to_v2_command called with unsupported command '{other}' — \
868                 only pure reads are supported; mutation/side-effecting callers \
869                 must use daemon_v2() with typed Command"
870            );
871        }
872    }
873}
874
875// ── Tests ───────────────────────────────────────────────────────────────────
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880
881    // ── Wire / protocol ─────────────────────────────────────────────────
882
883    /// γ-C3a: QueryMode owns string-to-enum validation at the protocol
884    /// boundary now that tools::mem_query no longer accepts a free-form
885    /// string. Pin the unknown-variant rejection so future schema changes
886    /// don't silently accept invalid modes.
887    #[test]
888    fn query_mode_deserialize_rejects_unknown_variant() {
889        let result: Result<QueryMode, _> = serde_json::from_str("\"invalid_mode\"");
890        assert!(
891            result.is_err(),
892            "QueryMode deserialization must reject unknown variants, got: {result:?}"
893        );
894    }
895
896    #[test]
897    fn query_mode_deserialize_accepts_all_known_variants() {
898        // Snake-case wire form per `#[serde(rename_all = "snake_case")]`.
899        for variant in &["text", "tag", "graph", "semantic"] {
900            let json = format!("\"{variant}\"");
901            let result: Result<QueryMode, _> = serde_json::from_str(&json);
902            assert!(
903                result.is_ok(),
904                "QueryMode must accept {variant:?}, got: {result:?}"
905            );
906        }
907    }
908
909    #[test]
910    fn valid_v2_ping_request_decodes() {
911        let json = serde_json::json!({
912            "v": 2,
913            "id": "550e8400-e29b-41d4-a716-446655440000",
914            "session": "660e8400-e29b-41d4-a716-446655440000",
915            "cmd": { "type": "ping" }
916        });
917        let req: Request = serde_json::from_value(json).unwrap();
918        assert_eq!(req.v, PROTOCOL_VERSION);
919        assert!(matches!(req.cmd, Command::Ping));
920    }
921
922    #[test]
923    fn valid_v2_get_request_decodes() {
924        let json = serde_json::json!({
925            "v": 2,
926            "id": "550e8400-e29b-41d4-a716-446655440000",
927            "session": "660e8400-e29b-41d4-a716-446655440000",
928            "cmd": { "type": "get", "key": "file:src/main.rs" }
929        });
930        let req: Request = serde_json::from_value(json).unwrap();
931        match req.cmd {
932            Command::Get(input) => assert_eq!(input.key, "file:src/main.rs"),
933            _ => panic!("expected Get"),
934        }
935    }
936
937    #[test]
938    fn valid_gotcha_upsert_decodes() {
939        let json = serde_json::json!({
940            "v": 2,
941            "id": "550e8400-e29b-41d4-a716-446655440000",
942            "session": "660e8400-e29b-41d4-a716-446655440000",
943            "cmd": {
944                "type": "gotcha_upsert",
945                "key": "gotcha:stripe-idempotency",
946                "rule": "Always include an idempotency key",
947                "reason": "Stripe retries without it cause double charges",
948                "severity": "high",
949                "affected_files": ["src/payments/stripe.rs"],
950                "tags": ["payments", "stripe"]
951            }
952        });
953        let req: Request = serde_json::from_value(json).unwrap();
954        match req.cmd {
955            Command::GotchaUpsert(input) => {
956                assert_eq!(input.key, "gotcha:stripe-idempotency");
957                assert_eq!(input.severity, Severity::High);
958                assert_eq!(input.affected_files, vec!["src/payments/stripe.rs"]);
959                assert_eq!(input.priority, Priority::Normal); // default
960            }
961            _ => panic!("expected GotchaUpsert"),
962        }
963    }
964
965    #[test]
966    fn valid_decision_upsert_decodes() {
967        let json = serde_json::json!({
968            "v": 2,
969            "id": "550e8400-e29b-41d4-a716-446655440000",
970            "session": "660e8400-e29b-41d4-a716-446655440000",
971            "cmd": {
972                "type": "decision_upsert",
973                "slug": "unified-retry-strategy",
974                "value": "We use exponential backoff because linear retry overloads downstream",
975                "summary": "Exponential backoff for all retries",
976                "rationale": "Linear retry caused cascading failures in prod 2024-01"
977            }
978        });
979        let req: Request = serde_json::from_value(json).unwrap();
980        match req.cmd {
981            Command::DecisionUpsert(input) => {
982                assert_eq!(input.slug, "unified-retry-strategy");
983                assert!(!input.rationale.is_empty());
984            }
985            _ => panic!("expected DecisionUpsert"),
986        }
987    }
988
989    #[test]
990    fn valid_session_log_decodes() {
991        let json = serde_json::json!({
992            "v": 2,
993            "id": "550e8400-e29b-41d4-a716-446655440000",
994            "session": "660e8400-e29b-41d4-a716-446655440000",
995            "cmd": {
996                "type": "session_log",
997                "event": "compliance_miss",
998                "key": "file:src/main.rs"
999            }
1000        });
1001        let req: Request = serde_json::from_value(json).unwrap();
1002        match req.cmd {
1003            Command::SessionLog(input) => {
1004                assert_eq!(input.event, SessionEvent::ComplianceMiss);
1005                assert_eq!(input.key, "file:src/main.rs");
1006            }
1007            _ => panic!("expected SessionLog"),
1008        }
1009    }
1010
1011    #[test]
1012    fn valid_file_enrich_decodes() {
1013        let json = serde_json::json!({
1014            "v": 2,
1015            "id": "550e8400-e29b-41d4-a716-446655440000",
1016            "session": "660e8400-e29b-41d4-a716-446655440000",
1017            "cmd": {
1018                "type": "file_enrich",
1019                "path": "src/store/db.rs",
1020                "purpose": "Own the storage boundary for all SurrealKV operations",
1021                "entry_points": ["open", "put", "get"],
1022                "decision_keys": ["decision:storage-engine"]
1023            }
1024        });
1025        let req: Request = serde_json::from_value(json).unwrap();
1026        match req.cmd {
1027            Command::FileEnrich(input) => {
1028                assert_eq!(input.path, "src/store/db.rs");
1029                assert_eq!(input.entry_points.len(), 3);
1030                assert!(input.todos.is_empty()); // default
1031            }
1032            _ => panic!("expected FileEnrich"),
1033        }
1034    }
1035
1036    // ── Rejection tests ─────────────────────────────────────────────────
1037
1038    #[test]
1039    fn bad_version_still_decodes_for_error_handling() {
1040        // v=99 is parseable but the handler must reject it after decode.
1041        let json = serde_json::json!({
1042            "v": 99,
1043            "id": "550e8400-e29b-41d4-a716-446655440000",
1044            "session": "660e8400-e29b-41d4-a716-446655440000",
1045            "cmd": { "type": "ping" }
1046        });
1047        let req: Request = serde_json::from_value(json).unwrap();
1048        assert_ne!(req.v, PROTOCOL_VERSION);
1049    }
1050
1051    #[test]
1052    fn unknown_field_in_request_rejected() {
1053        let json = serde_json::json!({
1054            "v": 2,
1055            "id": "550e8400-e29b-41d4-a716-446655440000",
1056            "session": "660e8400-e29b-41d4-a716-446655440000",
1057            "cmd": { "type": "ping" },
1058            "extra_field": true
1059        });
1060        let result = serde_json::from_value::<Request>(json);
1061        assert!(result.is_err(), "unknown top-level field must be rejected");
1062    }
1063
1064    #[test]
1065    fn unknown_field_in_command_args_rejected() {
1066        let json = serde_json::json!({
1067            "v": 2,
1068            "id": "550e8400-e29b-41d4-a716-446655440000",
1069            "session": "660e8400-e29b-41d4-a716-446655440000",
1070            "cmd": { "type": "get", "key": "file:foo", "smuggled": true }
1071        });
1072        let result = serde_json::from_value::<Request>(json);
1073        assert!(
1074            result.is_err(),
1075            "unknown field in command args must be rejected"
1076        );
1077    }
1078
1079    #[test]
1080    fn unknown_command_type_rejected() {
1081        let json = serde_json::json!({
1082            "v": 2,
1083            "id": "550e8400-e29b-41d4-a716-446655440000",
1084            "session": "660e8400-e29b-41d4-a716-446655440000",
1085            "cmd": { "type": "raw_put", "key": "gotcha:x", "value": "hacked" }
1086        });
1087        let result = serde_json::from_value::<Request>(json);
1088        assert!(result.is_err(), "unknown command type must be rejected");
1089    }
1090
1091    #[test]
1092    fn malformed_uuid_rejected() {
1093        let json = serde_json::json!({
1094            "v": 2,
1095            "id": "not-a-uuid",
1096            "session": "660e8400-e29b-41d4-a716-446655440000",
1097            "cmd": { "type": "ping" }
1098        });
1099        let result = serde_json::from_value::<Request>(json);
1100        assert!(result.is_err(), "malformed UUID must be rejected");
1101    }
1102
1103    #[test]
1104    fn missing_session_rejected() {
1105        let json = serde_json::json!({
1106            "v": 2,
1107            "id": "550e8400-e29b-41d4-a716-446655440000",
1108            "cmd": { "type": "ping" }
1109        });
1110        let result = serde_json::from_value::<Request>(json);
1111        assert!(result.is_err(), "missing session UUID must be rejected");
1112    }
1113
1114    #[test]
1115    fn gotcha_upsert_rejects_server_owned_fields() {
1116        // Attempt to smuggle `confirmed` through the wire
1117        let json = serde_json::json!({
1118            "v": 2,
1119            "id": "550e8400-e29b-41d4-a716-446655440000",
1120            "session": "660e8400-e29b-41d4-a716-446655440000",
1121            "cmd": {
1122                "type": "gotcha_upsert",
1123                "key": "gotcha:test",
1124                "rule": "test rule",
1125                "reason": "test reason",
1126                "severity": "normal",
1127                "confirmed": true
1128            }
1129        });
1130        let result = serde_json::from_value::<Request>(json);
1131        assert!(
1132            result.is_err(),
1133            "server-owned field `confirmed` must be rejected"
1134        );
1135    }
1136
1137    #[test]
1138    fn file_enrich_rejects_gotcha_keys() {
1139        // gotcha_keys is daemon-managed, must not cross the wire
1140        let json = serde_json::json!({
1141            "v": 2,
1142            "id": "550e8400-e29b-41d4-a716-446655440000",
1143            "session": "660e8400-e29b-41d4-a716-446655440000",
1144            "cmd": {
1145                "type": "file_enrich",
1146                "path": "src/main.rs",
1147                "purpose": "entry point",
1148                "gotcha_keys": ["gotcha:smuggled"]
1149            }
1150        });
1151        let result = serde_json::from_value::<Request>(json);
1152        assert!(
1153            result.is_err(),
1154            "daemon-managed field `gotcha_keys` must be rejected"
1155        );
1156    }
1157
1158    #[test]
1159    fn file_enrich_rejects_imports() {
1160        // imports is daemon-derived from tree-sitter
1161        let json = serde_json::json!({
1162            "v": 2,
1163            "id": "550e8400-e29b-41d4-a716-446655440000",
1164            "session": "660e8400-e29b-41d4-a716-446655440000",
1165            "cmd": {
1166                "type": "file_enrich",
1167                "path": "src/main.rs",
1168                "purpose": "entry point",
1169                "imports": ["std::io"]
1170            }
1171        });
1172        let result = serde_json::from_value::<Request>(json);
1173        assert!(
1174            result.is_err(),
1175            "daemon-derived field `imports` must be rejected"
1176        );
1177    }
1178
1179    #[test]
1180    fn invalid_severity_rejected() {
1181        let json = serde_json::json!({
1182            "v": 2,
1183            "id": "550e8400-e29b-41d4-a716-446655440000",
1184            "session": "660e8400-e29b-41d4-a716-446655440000",
1185            "cmd": {
1186                "type": "gotcha_upsert",
1187                "key": "gotcha:test",
1188                "rule": "test",
1189                "reason": "test",
1190                "severity": "EXTREME"
1191            }
1192        });
1193        let result = serde_json::from_value::<Request>(json);
1194        assert!(
1195            result.is_err(),
1196            "invalid severity enum value must be rejected"
1197        );
1198    }
1199
1200    #[test]
1201    fn invalid_session_event_rejected() {
1202        let json = serde_json::json!({
1203            "v": 2,
1204            "id": "550e8400-e29b-41d4-a716-446655440000",
1205            "session": "660e8400-e29b-41d4-a716-446655440000",
1206            "cmd": {
1207                "type": "session_log",
1208                "event": "hit",
1209                "key": "file:foo"
1210            }
1211        });
1212        let result = serde_json::from_value::<Request>(json);
1213        assert!(
1214            result.is_err(),
1215            "hit is not a SessionEvent variant — must use consultation_hit command"
1216        );
1217    }
1218
1219    // ── Response serialization ──────────────────────────────────────────
1220
1221    #[test]
1222    fn ok_response_serializes() {
1223        let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
1224        let resp = Response::ok(id, serde_json::json!({"pong": true}));
1225        let json = serde_json::to_value(&resp).unwrap();
1226        assert_eq!(json["status"], "ok");
1227        assert_eq!(json["data"]["pong"], true);
1228    }
1229
1230    #[test]
1231    fn err_response_serializes_with_code() {
1232        let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
1233        let resp = Response::err(id, ErrorCode::ValidationFailed, "key must not be empty");
1234        let json = serde_json::to_value(&resp).unwrap();
1235        assert_eq!(json["status"], "err");
1236        assert_eq!(json["code"], "validation_failed");
1237        assert_eq!(json["message"], "key must not be empty");
1238    }
1239
1240    #[test]
1241    fn error_code_roundtrips() {
1242        let codes = vec![
1243            ErrorCode::VersionMismatch,
1244            ErrorCode::FrameTooLarge,
1245            ErrorCode::MalformedRequest,
1246            ErrorCode::SessionMismatch,
1247            ErrorCode::ValidationFailed,
1248            ErrorCode::NotFound,
1249            ErrorCode::Conflict,
1250            ErrorCode::InvalidStateTransition,
1251            ErrorCode::StoreError,
1252            ErrorCode::Internal,
1253        ];
1254        for code in codes {
1255            let json = serde_json::to_value(&code).unwrap();
1256            let back: ErrorCode = serde_json::from_value(json).unwrap();
1257            assert_eq!(back, code);
1258        }
1259    }
1260
1261    // ── Unit variant commands ───────────────────────────────────────────
1262
1263    #[test]
1264    fn session_flush_decodes() {
1265        let json = serde_json::json!({
1266            "v": 2,
1267            "id": "550e8400-e29b-41d4-a716-446655440000",
1268            "session": "660e8400-e29b-41d4-a716-446655440000",
1269            "cmd": { "type": "session_flush" }
1270        });
1271        let req: Request = serde_json::from_value(json).unwrap();
1272        assert!(matches!(req.cmd, Command::SessionFlush));
1273    }
1274
1275    #[test]
1276    fn session_harvest_decodes() {
1277        let json = serde_json::json!({
1278            "v": 2,
1279            "id": "550e8400-e29b-41d4-a716-446655440000",
1280            "session": "660e8400-e29b-41d4-a716-446655440000",
1281            "cmd": { "type": "session_harvest" }
1282        });
1283        let req: Request = serde_json::from_value(json).unwrap();
1284        assert!(matches!(req.cmd, Command::SessionHarvest));
1285    }
1286
1287    #[test]
1288    fn dev_note_upsert_create_mode() {
1289        let json = serde_json::json!({
1290            "v": 2,
1291            "id": "550e8400-e29b-41d4-a716-446655440000",
1292            "session": "660e8400-e29b-41d4-a716-446655440000",
1293            "cmd": {
1294                "type": "dev_note_upsert",
1295                "text": "Remember to update the changelog"
1296            }
1297        });
1298        let req: Request = serde_json::from_value(json).unwrap();
1299        match req.cmd {
1300            Command::DevNoteUpsert(input) => {
1301                assert!(input.key.is_none()); // create mode
1302                assert_eq!(input.text, "Remember to update the changelog");
1303            }
1304            _ => panic!("expected DevNoteUpsert"),
1305        }
1306    }
1307
1308    #[test]
1309    fn dev_note_upsert_update_mode() {
1310        let json = serde_json::json!({
1311            "v": 2,
1312            "id": "550e8400-e29b-41d4-a716-446655440000",
1313            "session": "660e8400-e29b-41d4-a716-446655440000",
1314            "cmd": {
1315                "type": "dev_note_upsert",
1316                "key": "dev_note:changelog-reminder-1712345678",
1317                "text": "Updated: remember to update changelog AND version"
1318            }
1319        });
1320        let req: Request = serde_json::from_value(json).unwrap();
1321        match req.cmd {
1322            Command::DevNoteUpsert(input) => {
1323                assert_eq!(
1324                    input.key.as_deref(),
1325                    Some("dev_note:changelog-reminder-1712345678")
1326                );
1327            }
1328            _ => panic!("expected DevNoteUpsert"),
1329        }
1330    }
1331
1332    // ── Command helper tests ────────────────────────────────────────────
1333
1334    #[test]
1335    fn command_kind_covers_all_variants() {
1336        // Build one instance of each variant and verify kind() matches serde rename.
1337        let cases: Vec<(&str, Command)> = vec![
1338            ("ping", Command::Ping),
1339            ("metrics", Command::Metrics),
1340            ("get", Command::Get(GetInput { key: "k".into() })),
1341            (
1342                "hook_evaluate",
1343                Command::HookEvaluate(HookEvaluateInput {
1344                    file_key: "f".into(),
1345                    include_recent: false,
1346                }),
1347            ),
1348            (
1349                "scan_prefix",
1350                Command::ScanPrefix(ScanPrefixInput { prefix: "p".into() }),
1351            ),
1352            (
1353                "history",
1354                Command::History(HistoryInput {
1355                    key: "k".into(),
1356                    limit: 10,
1357                }),
1358            ),
1359            (
1360                "history_since",
1361                Command::HistorySince(HistorySinceInput {
1362                    key: "k".into(),
1363                    since_ts: 0,
1364                    limit: 10,
1365                }),
1366            ),
1367            (
1368                "session_check_consulted",
1369                Command::SessionCheckConsulted(SessionCheckConsultedInput { key: "k".into() }),
1370            ),
1371            (
1372                "session_check_consulted_recent",
1373                Command::SessionCheckConsultedRecent(SessionCheckConsultedRecentInput {
1374                    key: "k".into(),
1375                    ttl_secs: 900,
1376                }),
1377            ),
1378            (
1379                "mem_query",
1380                Command::MemQuery(MemQueryInput {
1381                    query: "q".into(),
1382                    mode: QueryMode::Text,
1383                    limit: 20,
1384                }),
1385            ),
1386            ("mem_get", Command::MemGet(MemGetInput { key: "k".into() })),
1387            (
1388                "mem_bootstrap",
1389                Command::MemBootstrap(MemBootstrapInput {
1390                    context_files: vec![],
1391                }),
1392            ),
1393            (
1394                "gotcha_upsert",
1395                Command::GotchaUpsert(GotchaDraftInput {
1396                    key: "gotcha:t".into(),
1397                    rule: "r".into(),
1398                    reason: "r".into(),
1399                    severity: Severity::Normal,
1400                    affected_files: vec![],
1401                    ref_url: None,
1402                    tags: vec![],
1403                    priority: Priority::Normal,
1404                    source: None,
1405                }),
1406            ),
1407            (
1408                "gotcha_confirm",
1409                Command::GotchaConfirm(GotchaConfirmInput {
1410                    key: "gotcha:t".into(),
1411                }),
1412            ),
1413            (
1414                "gotcha_tombstone",
1415                Command::GotchaTombstone(GotchaTombstoneInput {
1416                    key: "gotcha:t".into(),
1417                }),
1418            ),
1419            (
1420                "file_enrich",
1421                Command::FileEnrich(FileEnrichInput {
1422                    path: "p".into(),
1423                    purpose: "p".into(),
1424                    entry_points: vec![],
1425                    decision_keys: vec![],
1426                    todos: vec![],
1427                    tags: vec![],
1428                    priority: Priority::Normal,
1429                }),
1430            ),
1431            (
1432                "file_reparse",
1433                Command::FileReparse(FileReparseInput { path: "p".into() }),
1434            ),
1435            (
1436                "file_edit_hook",
1437                Command::FileEditHook(FileEditHookInput { path: "p".into() }),
1438            ),
1439            (
1440                "doc_capture",
1441                Command::DocCapture(DocCaptureInput { path: "p".into() }),
1442            ),
1443            (
1444                "decision_upsert",
1445                Command::DecisionUpsert(DecisionUpsertInput {
1446                    slug: "s".into(),
1447                    value: "v".into(),
1448                    summary: "s".into(),
1449                    rationale: "r".into(),
1450                    tags: vec![],
1451                    priority: Priority::Normal,
1452                }),
1453            ),
1454            (
1455                "dev_note_upsert",
1456                Command::DevNoteUpsert(DevNoteUpsertInput {
1457                    key: None,
1458                    text: "t".into(),
1459                    tags: vec![],
1460                    priority: Priority::Normal,
1461                }),
1462            ),
1463            (
1464                "session_log",
1465                Command::SessionLog(SessionLogInput {
1466                    event: SessionEvent::Miss,
1467                    key: "k".into(),
1468                }),
1469            ),
1470            (
1471                "consultation_hit",
1472                Command::ConsultationHit(ConsultationHitInput { key: "k".into() }),
1473            ),
1474            ("session_flush", Command::SessionFlush),
1475            ("session_harvest", Command::SessionHarvest),
1476        ];
1477
1478        assert_eq!(cases.len(), 25, "must cover all 25 command variants");
1479        for (expected_kind, cmd) in &cases {
1480            assert_eq!(
1481                cmd.kind(),
1482                *expected_kind,
1483                "kind() mismatch for {:?}",
1484                expected_kind
1485            );
1486        }
1487    }
1488
1489    #[test]
1490    fn command_is_mutation_classification() {
1491        // Pure reads — must NOT be mutations
1492        assert!(!Command::Ping.is_mutation());
1493        assert!(!Command::Metrics.is_mutation());
1494        assert!(!Command::Get(GetInput { key: "k".into() }).is_mutation());
1495        assert!(!Command::MemQuery(MemQueryInput {
1496            query: "q".into(),
1497            mode: QueryMode::Text,
1498            limit: 20,
1499        })
1500        .is_mutation());
1501
1502        // Reads with side effects — ARE mutations (audited)
1503        assert!(Command::MemGet(MemGetInput { key: "k".into() }).is_mutation());
1504        assert!(Command::MemBootstrap(MemBootstrapInput {
1505            context_files: vec![]
1506        })
1507        .is_mutation());
1508
1509        // Semantic mutations — ARE mutations
1510        assert!(Command::GotchaConfirm(GotchaConfirmInput {
1511            key: "gotcha:t".into()
1512        })
1513        .is_mutation());
1514        assert!(Command::SessionLog(SessionLogInput {
1515            event: SessionEvent::Miss,
1516            key: "k".into(),
1517        })
1518        .is_mutation());
1519        assert!(Command::SessionFlush.is_mutation());
1520        assert!(Command::SessionHarvest.is_mutation());
1521    }
1522
1523    #[test]
1524    fn command_target_key_returns_expected_values() {
1525        assert_eq!(Command::Ping.target_key(), "");
1526        assert_eq!(
1527            Command::Get(GetInput {
1528                key: "file:src/main.rs".into()
1529            })
1530            .target_key(),
1531            "file:src/main.rs"
1532        );
1533        assert_eq!(
1534            Command::GotchaUpsert(GotchaDraftInput {
1535                key: "gotcha:test".into(),
1536                rule: "r".into(),
1537                reason: "r".into(),
1538                severity: Severity::Normal,
1539                affected_files: vec![],
1540                ref_url: None,
1541                tags: vec![],
1542                priority: Priority::Normal,
1543                source: None,
1544            })
1545            .target_key(),
1546            "gotcha:test"
1547        );
1548        assert_eq!(
1549            Command::DecisionUpsert(DecisionUpsertInput {
1550                slug: "my-decision".into(),
1551                value: "v".into(),
1552                summary: "s".into(),
1553                rationale: "r".into(),
1554                tags: vec![],
1555                priority: Priority::Normal,
1556            })
1557            .target_key(),
1558            "my-decision"
1559        );
1560        // DevNoteUpsert in create mode — no key
1561        assert_eq!(
1562            Command::DevNoteUpsert(DevNoteUpsertInput {
1563                key: None,
1564                text: "t".into(),
1565                tags: vec![],
1566                priority: Priority::Normal,
1567            })
1568            .target_key(),
1569            ""
1570        );
1571        assert_eq!(Command::SessionFlush.target_key(), "");
1572    }
1573
1574    #[test]
1575    fn audit_entry_serializes() {
1576        let entry = AuditEntry {
1577            ts: 1700000000,
1578            peer_uid: 501,
1579            peer_pid: Some(1234),
1580            daemon_session: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
1581            request_id: Uuid::parse_str("660e8400-e29b-41d4-a716-446655440000").unwrap(),
1582            command_kind: "gotcha_upsert".into(),
1583            target_key: "gotcha:test".into(),
1584            accepted: true,
1585            error_code: None,
1586        };
1587        let json = serde_json::to_value(&entry).unwrap();
1588        assert_eq!(json["peer_uid"], 501);
1589        assert_eq!(json["command_kind"], "gotcha_upsert");
1590        assert_eq!(json["accepted"], true);
1591        // error_code should be absent (skip_serializing_if)
1592        assert!(json.get("error_code").is_none());
1593    }
1594
1595    #[test]
1596    fn audit_entry_rejected_includes_error_code() {
1597        let entry = AuditEntry {
1598            ts: 1700000000,
1599            peer_uid: 501,
1600            peer_pid: None,
1601            daemon_session: Uuid::nil(),
1602            request_id: Uuid::nil(),
1603            command_kind: "gotcha_confirm".into(),
1604            target_key: "gotcha:missing".into(),
1605            accepted: false,
1606            error_code: Some(ErrorCode::NotFound),
1607        };
1608        let json = serde_json::to_value(&entry).unwrap();
1609        assert_eq!(json["accepted"], false);
1610        assert_eq!(json["error_code"], "not_found");
1611        assert!(json["peer_pid"].is_null());
1612    }
1613
1614    // ── store::Priority → protocol type conversions ────────────────────
1615
1616    #[test]
1617    fn store_priority_to_protocol_severity_preserves_all_variants() {
1618        use crate::store::Priority as SP;
1619        assert_eq!(Severity::from(SP::Low), Severity::Low);
1620        assert_eq!(Severity::from(SP::Normal), Severity::Normal);
1621        assert_eq!(Severity::from(SP::High), Severity::High);
1622        assert_eq!(Severity::from(SP::Critical), Severity::Critical);
1623    }
1624
1625    #[test]
1626    fn store_priority_to_protocol_priority_preserves_all_variants() {
1627        use crate::store::Priority as SP;
1628        assert_eq!(Priority::from(SP::Low), Priority::Low);
1629        assert_eq!(Priority::from(SP::Normal), Priority::Normal);
1630        assert_eq!(Priority::from(SP::High), Priority::High);
1631        assert_eq!(Priority::from(SP::Critical), Priority::Critical);
1632    }
1633
1634    // ── v1_to_v2_command translation tests (pass-29 regression) ─────────
1635    //
1636    // Pass 28 shipped a panic-on-default mapper that crashed every Socket-
1637    // backed `mem_get` and `mem_bootstrap` call (rmcp task panic →
1638    // "Transport closed"). The test below locks the mapper to the same
1639    // wire shape the daemon's typed DTOs (`MemGetInput`, `MemBootstrapInput`)
1640    // expect — both have `deny_unknown_fields`, so the test doubles as a
1641    // contract check between the proxy layer and `dispatch_v2`.
1642
1643    #[test]
1644    fn v1_to_v2_command_handles_mem_get() {
1645        let mapped = v1_to_v2_command("mem_get", &serde_json::json!({ "key": "file:src/main.rs" }));
1646        assert_eq!(
1647            mapped,
1648            serde_json::json!({ "type": "mem_get", "key": "file:src/main.rs" })
1649        );
1650
1651        // Round-trip into a typed Command — proves the wire shape decodes
1652        // through `MemGetInput::deny_unknown_fields`.
1653        let cmd: Command = serde_json::from_value(mapped).expect("mem_get must decode as Command");
1654        match cmd {
1655            Command::MemGet(input) => assert_eq!(input.key, "file:src/main.rs"),
1656            other => panic!("expected Command::MemGet, got {:?}", other.kind()),
1657        }
1658    }
1659
1660    #[test]
1661    fn v1_to_v2_command_handles_mem_bootstrap() {
1662        // Args present.
1663        let mapped = v1_to_v2_command(
1664            "mem_bootstrap",
1665            &serde_json::json!({ "context_files": ["src/lib.rs", "src/main.rs"] }),
1666        );
1667        let cmd: Command =
1668            serde_json::from_value(mapped).expect("mem_bootstrap must decode as Command");
1669        match cmd {
1670            Command::MemBootstrap(input) => {
1671                assert_eq!(input.context_files, vec!["src/lib.rs", "src/main.rs"]);
1672            }
1673            other => panic!("expected Command::MemBootstrap, got {:?}", other.kind()),
1674        }
1675
1676        // Args missing — must default to an empty list, not panic.
1677        let mapped_empty = v1_to_v2_command("mem_bootstrap", &serde_json::json!({}));
1678        let cmd_empty: Command = serde_json::from_value(mapped_empty).unwrap();
1679        match cmd_empty {
1680            Command::MemBootstrap(input) => assert!(input.context_files.is_empty()),
1681            other => panic!("expected MemBootstrap, got {:?}", other.kind()),
1682        }
1683    }
1684
1685    #[test]
1686    #[should_panic(expected = "v1_to_v2_command called with unsupported command")]
1687    fn v1_to_v2_command_panic_message_lists_only_unsupported() {
1688        // Genuinely unsupported strings (mutations / typos) must still
1689        // panic loudly — that signals a misrouted Socket-backend caller
1690        // that should be using `daemon_v2()` with a typed Command.
1691        let _ = v1_to_v2_command("totally_bogus_cmd_xyz", &serde_json::json!({}));
1692    }
1693
1694    #[test]
1695    fn v1_to_v2_command_no_mutations_silently_accepted() {
1696        // Fence: every mutating command name must panic — they have no
1697        // place in the mapper. If a future contributor adds (say) "mem_set"
1698        // here, this test must catch it.
1699        let mutation_names = [
1700            "mem_set",
1701            "gotcha_upsert",
1702            "gotcha_confirm",
1703            "gotcha_tombstone",
1704            "decision_upsert",
1705            "dev_note_upsert",
1706            "file_enrich",
1707            "file_reparse",
1708            "file_edit_hook",
1709            "doc_capture",
1710            "session_log",
1711            "consultation_hit",
1712            "session_flush",
1713            "session_harvest",
1714        ];
1715        for name in mutation_names {
1716            let result = std::panic::catch_unwind(|| {
1717                v1_to_v2_command(name, &serde_json::json!({}));
1718            });
1719            assert!(
1720                result.is_err(),
1721                "mutation command '{name}' must panic in v1_to_v2_command — \
1722                 mutating callers must use daemon_v2() with typed Command"
1723            );
1724        }
1725    }
1726
1727    // ── ADR-018: Request.agent additive field ───────────────────────────
1728
1729    /// Pre-multi-agent clients send wire JSON without an `agent` field.
1730    /// ADR-018 requires this to keep deserializing. This test is the
1731    /// backward-compatibility regression bar.
1732    #[test]
1733    fn request_without_agent_field_deserializes_as_none() {
1734        let json = serde_json::json!({
1735            "v": 2,
1736            "id": "550e8400-e29b-41d4-a716-446655440000",
1737            "session": "660e8400-e29b-41d4-a716-446655440000",
1738            "cmd": { "type": "ping" }
1739        });
1740        let req: Request = serde_json::from_value(json).unwrap();
1741        assert!(
1742            req.agent.is_none(),
1743            "missing `agent` must decode to None (ADR-018 additive contract)"
1744        );
1745    }
1746
1747    #[test]
1748    fn request_with_agent_field_deserializes_and_preserves_value() {
1749        for (wire, expected) in [
1750            ("claude", AgentKind::Claude),
1751            ("codex", AgentKind::Codex),
1752            ("cli", AgentKind::Cli),
1753            ("supervisor", AgentKind::Supervisor),
1754            ("unknown", AgentKind::Unknown),
1755        ] {
1756            let json = serde_json::json!({
1757                "v": 2,
1758                "id": "550e8400-e29b-41d4-a716-446655440000",
1759                "session": "660e8400-e29b-41d4-a716-446655440000",
1760                "agent": wire,
1761                "cmd": { "type": "ping" }
1762            });
1763            let req: Request = serde_json::from_value(json)
1764                .unwrap_or_else(|e| panic!("decode failed for agent={wire}: {e}"));
1765            assert_eq!(req.agent, Some(expected));
1766        }
1767    }
1768
1769    #[test]
1770    fn request_with_unknown_agent_variant_rejected() {
1771        let json = serde_json::json!({
1772            "v": 2,
1773            "id": "550e8400-e29b-41d4-a716-446655440000",
1774            "session": "660e8400-e29b-41d4-a716-446655440000",
1775            "agent": "gemini",
1776            "cmd": { "type": "ping" }
1777        });
1778        let res = serde_json::from_value::<Request>(json);
1779        assert!(
1780            res.is_err(),
1781            "unknown agent variant must reject at decode (closed enum)"
1782        );
1783    }
1784
1785    #[test]
1786    fn request_with_agent_round_trips_through_serialize_deserialize() {
1787        let original = Request {
1788            v: PROTOCOL_VERSION,
1789            id: Uuid::new_v4(),
1790            session: Uuid::new_v4(),
1791            agent: Some(AgentKind::Codex),
1792            cmd: Command::Ping,
1793        };
1794        let bytes = serde_json::to_vec(&original).unwrap();
1795        let round_tripped: Request = serde_json::from_slice(&bytes).unwrap();
1796        assert_eq!(round_tripped.agent, Some(AgentKind::Codex));
1797        assert_eq!(round_tripped.v, PROTOCOL_VERSION);
1798    }
1799}