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