1use serde::{Deserialize, Serialize};
30use uuid::Uuid;
31
32use crate::store::AgentKind;
33
34pub const PROTOCOL_VERSION: u16 = 2;
41
42pub const MAX_FRAME_SIZE: usize = 65_536;
50
51#[derive(Debug, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct Request {
60 pub v: u16,
62 pub id: Uuid,
64 pub session: Uuid,
68 #[serde(default)]
73 pub agent: Option<AgentKind>,
74 pub cmd: Command,
76}
77
78#[derive(Debug, Serialize)]
82#[serde(tag = "status")]
83pub enum Response {
84 #[serde(rename = "ok")]
86 Ok { id: Uuid, data: serde_json::Value },
87 #[serde(rename = "err")]
90 Err {
91 id: Uuid,
92 code: ErrorCode,
93 message: String,
94 },
95}
96
97impl Response {
98 pub fn ok(id: Uuid, data: serde_json::Value) -> Self {
100 Self::Ok { id, data }
101 }
102
103 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124#[serde(rename_all = "snake_case")]
125pub enum ErrorCode {
126 VersionMismatch,
128 FrameTooLarge,
130 MalformedRequest,
132 SessionMismatch,
135 ValidationFailed,
137 NotFound,
139 Conflict,
141 InvalidStateTransition,
143 StoreError,
145 Internal,
147}
148
149#[derive(Debug, Serialize, Deserialize)]
158#[serde(tag = "type")]
159pub enum Command {
160 #[serde(rename = "ping")]
163 Ping,
164
165 #[serde(rename = "metrics")]
168 Metrics,
169
170 #[serde(rename = "get")]
172 Get(GetInput),
173
174 #[serde(rename = "hook_evaluate")]
176 HookEvaluate(HookEvaluateInput),
177
178 #[serde(rename = "scan_prefix")]
180 ScanPrefix(ScanPrefixInput),
181
182 #[serde(rename = "scan_keys")]
186 ScanKeys(ScanKeysInput),
187
188 #[serde(rename = "history")]
190 History(HistoryInput),
191
192 #[serde(rename = "history_since")]
194 HistorySince(HistorySinceInput),
195
196 #[serde(rename = "session_check_consulted")]
198 SessionCheckConsulted(SessionCheckConsultedInput),
199
200 #[serde(rename = "session_check_consulted_recent")]
202 SessionCheckConsultedRecent(SessionCheckConsultedRecentInput),
203
204 #[serde(rename = "mem_query")]
206 MemQuery(MemQueryInput),
207
208 #[serde(rename = "scan_enforcement_events")]
210 ScanEnforcementEvents(ScanEnforcementEventsInput),
211
212 #[serde(rename = "config_get")]
215 ConfigGet(ConfigGetInput),
216
217 #[serde(rename = "mem_get")]
220 MemGet(MemGetInput),
221
222 #[serde(rename = "mem_bootstrap")]
224 MemBootstrap(MemBootstrapInput),
225
226 #[serde(rename = "gotcha_upsert")]
229 GotchaUpsert(GotchaDraftInput),
230
231 #[serde(rename = "gotcha_confirm")]
233 GotchaConfirm(GotchaConfirmInput),
234
235 #[serde(rename = "gotcha_tombstone")]
237 GotchaTombstone(GotchaTombstoneInput),
238
239 #[serde(rename = "file_enrich")]
242 FileEnrich(FileEnrichInput),
243
244 #[serde(rename = "file_reparse")]
246 FileReparse(FileReparseInput),
247
248 #[serde(rename = "file_edit_hook")]
250 FileEditHook(FileEditHookInput),
251
252 #[serde(rename = "doc_capture")]
254 DocCapture(DocCaptureInput),
255
256 #[serde(rename = "decision_upsert")]
258 DecisionUpsert(DecisionUpsertInput),
259
260 #[serde(rename = "dev_note_upsert")]
262 DevNoteUpsert(DevNoteUpsertInput),
263
264 #[serde(rename = "config_set")]
267 ConfigSet(ConfigSetInput),
268
269 #[serde(rename = "sandbox_audit")]
273 SandboxAudit(SandboxAuditInput),
274
275 #[serde(rename = "session_log")]
277 SessionLog(SessionLogInput),
278
279 #[serde(rename = "consultation_hit")]
281 ConsultationHit(ConsultationHitInput),
282
283 #[serde(rename = "session_flush")]
285 SessionFlush,
286
287 #[serde(rename = "session_harvest")]
289 SessionHarvest,
290
291 #[serde(rename = "session_clear_consults")]
293 SessionClearConsults,
294
295 #[serde(rename = "record_import")]
307 RecordImport(RecordImportInput),
308}
309
310#[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 #[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#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
419#[serde(rename_all = "snake_case")]
420pub enum QueryMode {
421 Text,
423 Tag,
425 Graph,
427 Semantic,
429}
430
431#[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#[derive(Debug, Serialize, Deserialize)]
454#[serde(deny_unknown_fields)]
455pub struct GotchaDraftInput {
456 pub key: String,
458 pub rule: String,
460 pub reason: String,
462 pub severity: Severity,
464 #[serde(default)]
466 pub affected_files: Vec<String>,
467 #[serde(default)]
469 pub ref_url: Option<String>,
470 #[serde(default)]
472 pub tags: Vec<String>,
473 #[serde(default)]
475 pub priority: Priority,
476 #[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#[derive(Debug, Serialize, Deserialize)]
502#[serde(deny_unknown_fields)]
503pub struct FileEnrichInput {
504 pub path: String,
506 pub purpose: String,
508 #[serde(default)]
510 pub entry_points: Vec<String>,
511 #[serde(default)]
513 pub decision_keys: Vec<String>,
514 #[serde(default)]
516 pub todos: Vec<String>,
517 #[serde(default)]
519 pub tags: Vec<String>,
520 #[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#[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 pub slug: String,
550 pub value: String,
552 pub summary: String,
554 pub rationale: String,
556 #[serde(default)]
558 pub tags: Vec<String>,
559 #[serde(default)]
561 pub priority: Priority,
562}
563
564#[derive(Debug, Serialize, Deserialize)]
565#[serde(deny_unknown_fields)]
566pub struct DevNoteUpsertInput {
567 #[serde(default)]
570 pub key: Option<String>,
571 pub text: String,
573 #[serde(default)]
575 pub tags: Vec<String>,
576 #[serde(default)]
578 pub priority: Priority,
579}
580
581#[derive(Debug, Serialize, Deserialize)]
582#[serde(deny_unknown_fields)]
583pub struct SessionLogInput {
584 pub event: SessionEvent,
586 pub key: String,
588 #[serde(default)]
592 pub session_id: Option<String>,
593}
594
595#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
600#[serde(rename_all = "snake_case")]
601pub enum SessionEvent {
602 Miss,
603 ComplianceMiss,
604 ComplianceHit,
605 EditConsulted,
610 EditBlocked,
613 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 #[serde(default)]
629 pub session_id: Option<String>,
630 #[serde(default)]
632 pub agent_id: Option<String>,
633}
634
635#[derive(Debug, Serialize, Deserialize)]
639#[serde(deny_unknown_fields)]
640pub struct RecordImportInput {
641 pub records: Vec<crate::store::Record>,
642}
643
644#[derive(Debug, Serialize, Deserialize)]
647#[serde(deny_unknown_fields)]
648pub struct ConfigGetInput {
649 pub key: String,
650}
651
652#[derive(Debug, Serialize, Deserialize)]
655#[serde(deny_unknown_fields)]
656pub struct ConfigSetInput {
657 pub key: String,
658 pub value: String,
659}
660
661#[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#[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#[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
695impl 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
719impl Command {
722 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 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 pub fn is_mutation(&self) -> bool {
807 matches!(
808 self,
809 Self::MemGet(_)
811 | Self::MemBootstrap(_)
812 | 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#[derive(Debug, Clone, Serialize, Deserialize)]
845pub struct AuditEntry {
846 pub ts: u64,
848 pub peer_uid: u32,
850 pub peer_pid: Option<u32>,
852 pub daemon_session: Uuid,
854 pub request_id: Uuid,
856 pub command_kind: String,
858 pub target_key: String,
860 pub accepted: bool,
862 #[serde(skip_serializing_if = "Option::is_none")]
864 pub error_code: Option<ErrorCode>,
865}
866
867pub fn v1_to_v2_command(cmd: &str, args: &serde_json::Value) -> serde_json::Value {
882 use serde_json::json;
883
884 match cmd {
885 "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 "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#[cfg(test)]
948mod tests {
949 use super::*;
950
951 #[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 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); }
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()); }
1102 _ => panic!("expected FileEnrich"),
1103 }
1104 }
1105
1106 #[test]
1109 fn bad_version_still_decodes_for_error_handling() {
1110 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 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 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 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 #[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 #[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 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()); 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 #[test]
1435 fn command_kind_covers_all_variants() {
1436 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 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 assert!(Command::MemGet(MemGetInput { key: "k".into() }).is_mutation());
1617 assert!(Command::MemBootstrap(MemBootstrapInput {
1618 context_files: vec![]
1619 })
1620 .is_mutation());
1621
1622 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 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 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 #[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 #[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 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 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 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 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 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 #[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 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 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 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 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}