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