use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::store::AgentKind;
pub const PROTOCOL_VERSION: u16 = 2;
pub const MAX_FRAME_SIZE: usize = 65_536;
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub v: u16,
pub id: Uuid,
pub session: Uuid,
#[serde(default)]
pub agent: Option<AgentKind>,
pub cmd: Command,
}
#[derive(Debug, Serialize)]
#[serde(tag = "status")]
pub enum Response {
#[serde(rename = "ok")]
Ok { id: Uuid, data: serde_json::Value },
#[serde(rename = "err")]
Err {
id: Uuid,
code: ErrorCode,
message: String,
},
}
impl Response {
pub fn ok(id: Uuid, data: serde_json::Value) -> Self {
Self::Ok { id, data }
}
pub fn err(id: Uuid, code: ErrorCode, message: impl Into<String>) -> Self {
Self::Err {
id,
code,
message: message.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
VersionMismatch,
FrameTooLarge,
MalformedRequest,
SessionMismatch,
ValidationFailed,
NotFound,
Conflict,
InvalidStateTransition,
StoreError,
Internal,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Command {
#[serde(rename = "ping")]
Ping,
#[serde(rename = "metrics")]
Metrics,
#[serde(rename = "get")]
Get(GetInput),
#[serde(rename = "hook_evaluate")]
HookEvaluate(HookEvaluateInput),
#[serde(rename = "scan_prefix")]
ScanPrefix(ScanPrefixInput),
#[serde(rename = "history")]
History(HistoryInput),
#[serde(rename = "history_since")]
HistorySince(HistorySinceInput),
#[serde(rename = "session_check_consulted")]
SessionCheckConsulted(SessionCheckConsultedInput),
#[serde(rename = "session_check_consulted_recent")]
SessionCheckConsultedRecent(SessionCheckConsultedRecentInput),
#[serde(rename = "mem_query")]
MemQuery(MemQueryInput),
#[serde(rename = "scan_enforcement_events")]
ScanEnforcementEvents(ScanEnforcementEventsInput),
#[serde(rename = "config_get")]
ConfigGet(ConfigGetInput),
#[serde(rename = "mem_get")]
MemGet(MemGetInput),
#[serde(rename = "mem_bootstrap")]
MemBootstrap(MemBootstrapInput),
#[serde(rename = "gotcha_upsert")]
GotchaUpsert(GotchaDraftInput),
#[serde(rename = "gotcha_confirm")]
GotchaConfirm(GotchaConfirmInput),
#[serde(rename = "gotcha_tombstone")]
GotchaTombstone(GotchaTombstoneInput),
#[serde(rename = "file_enrich")]
FileEnrich(FileEnrichInput),
#[serde(rename = "file_reparse")]
FileReparse(FileReparseInput),
#[serde(rename = "file_edit_hook")]
FileEditHook(FileEditHookInput),
#[serde(rename = "doc_capture")]
DocCapture(DocCaptureInput),
#[serde(rename = "decision_upsert")]
DecisionUpsert(DecisionUpsertInput),
#[serde(rename = "dev_note_upsert")]
DevNoteUpsert(DevNoteUpsertInput),
#[serde(rename = "config_set")]
ConfigSet(ConfigSetInput),
#[serde(rename = "session_log")]
SessionLog(SessionLogInput),
#[serde(rename = "consultation_hit")]
ConsultationHit(ConsultationHitInput),
#[serde(rename = "session_flush")]
SessionFlush,
#[serde(rename = "session_harvest")]
SessionHarvest,
#[serde(rename = "record_import")]
RecordImport(RecordImportInput),
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GetInput {
pub key: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HookEvaluateInput {
pub file_key: String,
#[serde(default)]
pub include_recent: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScanPrefixInput {
pub prefix: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScanEnforcementEventsInput {
#[serde(default)]
pub since_seq: u64,
#[serde(default = "default_until_seq")]
pub until_seq: u64,
}
fn default_until_seq() -> u64 {
u64::MAX
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HistoryInput {
pub key: String,
#[serde(default = "default_history_limit")]
pub limit: u64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HistorySinceInput {
pub key: String,
pub since_ts: u64,
#[serde(default = "default_history_limit")]
pub limit: u64,
}
fn default_history_limit() -> u64 {
50
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionCheckConsultedInput {
pub key: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionCheckConsultedRecentInput {
pub key: String,
#[serde(default = "default_ttl_secs")]
pub ttl_secs: u64,
}
fn default_ttl_secs() -> u64 {
900
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemQueryInput {
pub query: String,
#[serde(default = "default_query_mode")]
pub mode: QueryMode,
#[serde(default = "default_query_limit")]
pub limit: u32,
}
fn default_query_mode() -> QueryMode {
QueryMode::Text
}
fn default_query_limit() -> u32 {
20
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum QueryMode {
Text,
Tag,
Graph,
Semantic,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemGetInput {
pub key: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemBootstrapInput {
#[serde(default)]
pub context_files: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GotchaDraftInput {
pub key: String,
pub rule: String,
pub reason: String,
pub severity: Severity,
#[serde(default)]
pub affected_files: Vec<String>,
#[serde(default)]
pub ref_url: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub priority: Priority,
#[serde(default)]
pub source: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GotchaConfirmInput {
pub key: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GotchaTombstoneInput {
pub key: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileEnrichInput {
pub path: String,
pub purpose: String,
#[serde(default)]
pub entry_points: Vec<String>,
#[serde(default)]
pub decision_keys: Vec<String>,
#[serde(default)]
pub todos: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub priority: Priority,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileReparseInput {
pub path: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileEditHookInput {
pub path: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DocCaptureInput {
pub path: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DecisionUpsertInput {
pub slug: String,
pub value: String,
pub summary: String,
pub rationale: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub priority: Priority,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DevNoteUpsertInput {
#[serde(default)]
pub key: Option<String>,
pub text: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub priority: Priority,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionLogInput {
pub event: SessionEvent,
pub key: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SessionEvent {
Miss,
ComplianceMiss,
ComplianceHit,
CodexShellMiss,
Bootstrap,
PromptNudge,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConsultationHitInput {
pub key: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RecordImportInput {
pub records: Vec<crate::store::Record>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigGetInput {
pub key: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigSetInput {
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Critical,
High,
#[default]
Normal,
Low,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Priority {
Critical,
High,
#[default]
Normal,
Low,
}
impl From<crate::store::Priority> for Severity {
fn from(p: crate::store::Priority) -> Self {
match p {
crate::store::Priority::Low => Severity::Low,
crate::store::Priority::Normal => Severity::Normal,
crate::store::Priority::High => Severity::High,
crate::store::Priority::Critical => Severity::Critical,
}
}
}
impl From<crate::store::Priority> for Priority {
fn from(p: crate::store::Priority) -> Self {
match p {
crate::store::Priority::Low => Priority::Low,
crate::store::Priority::Normal => Priority::Normal,
crate::store::Priority::High => Priority::High,
crate::store::Priority::Critical => Priority::Critical,
}
}
}
impl Command {
pub fn kind(&self) -> &'static str {
match self {
Self::Ping => "ping",
Self::Metrics => "metrics",
Self::Get(_) => "get",
Self::HookEvaluate(_) => "hook_evaluate",
Self::ScanPrefix(_) => "scan_prefix",
Self::History(_) => "history",
Self::HistorySince(_) => "history_since",
Self::SessionCheckConsulted(_) => "session_check_consulted",
Self::SessionCheckConsultedRecent(_) => "session_check_consulted_recent",
Self::MemQuery(_) => "mem_query",
Self::ScanEnforcementEvents(_) => "scan_enforcement_events",
Self::ConfigGet(_) => "config_get",
Self::ConfigSet(_) => "config_set",
Self::MemGet(_) => "mem_get",
Self::MemBootstrap(_) => "mem_bootstrap",
Self::GotchaUpsert(_) => "gotcha_upsert",
Self::GotchaConfirm(_) => "gotcha_confirm",
Self::GotchaTombstone(_) => "gotcha_tombstone",
Self::FileEnrich(_) => "file_enrich",
Self::FileReparse(_) => "file_reparse",
Self::FileEditHook(_) => "file_edit_hook",
Self::DocCapture(_) => "doc_capture",
Self::DecisionUpsert(_) => "decision_upsert",
Self::DevNoteUpsert(_) => "dev_note_upsert",
Self::SessionLog(_) => "session_log",
Self::ConsultationHit(_) => "consultation_hit",
Self::SessionFlush => "session_flush",
Self::SessionHarvest => "session_harvest",
Self::RecordImport(_) => "record_import",
}
}
pub fn target_key(&self) -> &str {
match self {
Self::Get(i) => &i.key,
Self::HookEvaluate(i) => &i.file_key,
Self::ScanPrefix(i) => &i.prefix,
Self::History(i) => &i.key,
Self::HistorySince(i) => &i.key,
Self::SessionCheckConsulted(i) => &i.key,
Self::SessionCheckConsultedRecent(i) => &i.key,
Self::MemQuery(i) => &i.query,
Self::MemGet(i) => &i.key,
Self::GotchaUpsert(i) => &i.key,
Self::GotchaConfirm(i) => &i.key,
Self::GotchaTombstone(i) => &i.key,
Self::FileEnrich(i) => &i.path,
Self::FileReparse(i) => &i.path,
Self::FileEditHook(i) => &i.path,
Self::DocCapture(i) => &i.path,
Self::DecisionUpsert(i) => &i.slug,
Self::DevNoteUpsert(i) => i.key.as_deref().unwrap_or(""),
Self::SessionLog(i) => &i.key,
Self::ConsultationHit(i) => &i.key,
Self::ConfigGet(i) => &i.key,
Self::ConfigSet(i) => &i.key,
Self::Ping
| Self::Metrics
| Self::MemBootstrap(_)
| Self::ScanEnforcementEvents(_)
| Self::SessionFlush
| Self::SessionHarvest
| Self::RecordImport(_) => "",
}
}
pub fn is_mutation(&self) -> bool {
matches!(
self,
Self::MemGet(_)
| Self::MemBootstrap(_)
| Self::GotchaUpsert(_)
| Self::GotchaConfirm(_)
| Self::GotchaTombstone(_)
| Self::FileEnrich(_)
| Self::FileReparse(_)
| Self::FileEditHook(_)
| Self::DocCapture(_)
| Self::DecisionUpsert(_)
| Self::DevNoteUpsert(_)
| Self::SessionLog(_)
| Self::ConsultationHit(_)
| Self::ConfigSet(_)
| Self::SessionFlush
| Self::SessionHarvest
| Self::RecordImport(_)
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub ts: u64,
pub peer_uid: u32,
pub peer_pid: Option<u32>,
pub daemon_session: Uuid,
pub request_id: Uuid,
pub command_kind: String,
pub target_key: String,
pub accepted: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<ErrorCode>,
}
pub fn v1_to_v2_command(cmd: &str, args: &serde_json::Value) -> serde_json::Value {
use serde_json::json;
match cmd {
"ping" => json!({"type": "ping"}),
"metrics" => json!({"type": "metrics"}),
"get" => json!({"type": "get", "key": args["key"]}),
"hook_evaluate" => json!({
"type": "hook_evaluate",
"file_key": args["file_key"],
"include_recent": args.get("include_recent").and_then(|v| v.as_bool()).unwrap_or(false),
}),
"scan_prefix" => json!({"type": "scan_prefix", "prefix": args["prefix"]}),
"history" => {
json!({"type": "history", "key": args["key"], "limit": args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50)})
}
"history_since" => json!({
"type": "history_since",
"key": args["key"],
"since_ts": args.get("since_ts").and_then(|v| v.as_u64()).unwrap_or(0),
"limit": args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50),
}),
"session_check_consulted" => json!({"type": "session_check_consulted", "key": args["key"]}),
"session_check_consulted_recent" => json!({
"type": "session_check_consulted_recent",
"key": args["key"],
"ttl_secs": args.get("ttl_secs").and_then(|v| v.as_u64()).unwrap_or(900),
}),
"mem_query" => json!({
"type": "mem_query",
"query": args["query"],
"mode": args.get("mode").and_then(|v| v.as_str()).unwrap_or("text"),
"limit": args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20),
}),
"scan_enforcement_events" => json!({
"type": "scan_enforcement_events",
"since_seq": args.get("since_seq").and_then(|v| v.as_u64()).unwrap_or(0),
"until_seq": args.get("until_seq").and_then(|v| v.as_u64()).unwrap_or(u64::MAX),
}),
"mem_get" => json!({"type": "mem_get", "key": args["key"]}),
"mem_bootstrap" => json!({
"type": "mem_bootstrap",
"context_files": args.get("context_files").cloned().unwrap_or_else(|| serde_json::json!([])),
}),
other => {
panic!(
"v1_to_v2_command called with unsupported command '{other}' — \
only pure reads are supported; mutation/side-effecting callers \
must use daemon_v2() with typed Command"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn query_mode_deserialize_rejects_unknown_variant() {
let result: Result<QueryMode, _> = serde_json::from_str("\"invalid_mode\"");
assert!(
result.is_err(),
"QueryMode deserialization must reject unknown variants, got: {result:?}"
);
}
#[test]
fn query_mode_deserialize_accepts_all_known_variants() {
for variant in &["text", "tag", "graph", "semantic"] {
let json = format!("\"{variant}\"");
let result: Result<QueryMode, _> = serde_json::from_str(&json);
assert!(
result.is_ok(),
"QueryMode must accept {variant:?}, got: {result:?}"
);
}
}
#[test]
fn valid_v2_ping_request_decodes() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": { "type": "ping" }
});
let req: Request = serde_json::from_value(json).unwrap();
assert_eq!(req.v, PROTOCOL_VERSION);
assert!(matches!(req.cmd, Command::Ping));
}
#[test]
fn valid_v2_get_request_decodes() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": { "type": "get", "key": "file:src/main.rs" }
});
let req: Request = serde_json::from_value(json).unwrap();
match req.cmd {
Command::Get(input) => assert_eq!(input.key, "file:src/main.rs"),
_ => panic!("expected Get"),
}
}
#[test]
fn valid_gotcha_upsert_decodes() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": {
"type": "gotcha_upsert",
"key": "gotcha:stripe-idempotency",
"rule": "Always include an idempotency key",
"reason": "Stripe retries without it cause double charges",
"severity": "high",
"affected_files": ["src/payments/stripe.rs"],
"tags": ["payments", "stripe"]
}
});
let req: Request = serde_json::from_value(json).unwrap();
match req.cmd {
Command::GotchaUpsert(input) => {
assert_eq!(input.key, "gotcha:stripe-idempotency");
assert_eq!(input.severity, Severity::High);
assert_eq!(input.affected_files, vec!["src/payments/stripe.rs"]);
assert_eq!(input.priority, Priority::Normal); }
_ => panic!("expected GotchaUpsert"),
}
}
#[test]
fn valid_decision_upsert_decodes() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": {
"type": "decision_upsert",
"slug": "unified-retry-strategy",
"value": "We use exponential backoff because linear retry overloads downstream",
"summary": "Exponential backoff for all retries",
"rationale": "Linear retry caused cascading failures in prod 2024-01"
}
});
let req: Request = serde_json::from_value(json).unwrap();
match req.cmd {
Command::DecisionUpsert(input) => {
assert_eq!(input.slug, "unified-retry-strategy");
assert!(!input.rationale.is_empty());
}
_ => panic!("expected DecisionUpsert"),
}
}
#[test]
fn valid_session_log_decodes() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": {
"type": "session_log",
"event": "compliance_miss",
"key": "file:src/main.rs"
}
});
let req: Request = serde_json::from_value(json).unwrap();
match req.cmd {
Command::SessionLog(input) => {
assert_eq!(input.event, SessionEvent::ComplianceMiss);
assert_eq!(input.key, "file:src/main.rs");
}
_ => panic!("expected SessionLog"),
}
}
#[test]
fn valid_file_enrich_decodes() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": {
"type": "file_enrich",
"path": "src/store/db.rs",
"purpose": "Own the storage boundary for all SurrealKV operations",
"entry_points": ["open", "put", "get"],
"decision_keys": ["decision:storage-engine"]
}
});
let req: Request = serde_json::from_value(json).unwrap();
match req.cmd {
Command::FileEnrich(input) => {
assert_eq!(input.path, "src/store/db.rs");
assert_eq!(input.entry_points.len(), 3);
assert!(input.todos.is_empty()); }
_ => panic!("expected FileEnrich"),
}
}
#[test]
fn bad_version_still_decodes_for_error_handling() {
let json = serde_json::json!({
"v": 99,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": { "type": "ping" }
});
let req: Request = serde_json::from_value(json).unwrap();
assert_ne!(req.v, PROTOCOL_VERSION);
}
#[test]
fn unknown_field_in_request_rejected() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": { "type": "ping" },
"extra_field": true
});
let result = serde_json::from_value::<Request>(json);
assert!(result.is_err(), "unknown top-level field must be rejected");
}
#[test]
fn unknown_field_in_command_args_rejected() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": { "type": "get", "key": "file:foo", "smuggled": true }
});
let result = serde_json::from_value::<Request>(json);
assert!(
result.is_err(),
"unknown field in command args must be rejected"
);
}
#[test]
fn unknown_command_type_rejected() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": { "type": "raw_put", "key": "gotcha:x", "value": "hacked" }
});
let result = serde_json::from_value::<Request>(json);
assert!(result.is_err(), "unknown command type must be rejected");
}
#[test]
fn malformed_uuid_rejected() {
let json = serde_json::json!({
"v": 2,
"id": "not-a-uuid",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": { "type": "ping" }
});
let result = serde_json::from_value::<Request>(json);
assert!(result.is_err(), "malformed UUID must be rejected");
}
#[test]
fn missing_session_rejected() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"cmd": { "type": "ping" }
});
let result = serde_json::from_value::<Request>(json);
assert!(result.is_err(), "missing session UUID must be rejected");
}
#[test]
fn gotcha_upsert_rejects_server_owned_fields() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": {
"type": "gotcha_upsert",
"key": "gotcha:test",
"rule": "test rule",
"reason": "test reason",
"severity": "normal",
"confirmed": true
}
});
let result = serde_json::from_value::<Request>(json);
assert!(
result.is_err(),
"server-owned field `confirmed` must be rejected"
);
}
#[test]
fn file_enrich_rejects_gotcha_keys() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": {
"type": "file_enrich",
"path": "src/main.rs",
"purpose": "entry point",
"gotcha_keys": ["gotcha:smuggled"]
}
});
let result = serde_json::from_value::<Request>(json);
assert!(
result.is_err(),
"daemon-managed field `gotcha_keys` must be rejected"
);
}
#[test]
fn file_enrich_rejects_imports() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": {
"type": "file_enrich",
"path": "src/main.rs",
"purpose": "entry point",
"imports": ["std::io"]
}
});
let result = serde_json::from_value::<Request>(json);
assert!(
result.is_err(),
"daemon-derived field `imports` must be rejected"
);
}
#[test]
fn invalid_severity_rejected() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": {
"type": "gotcha_upsert",
"key": "gotcha:test",
"rule": "test",
"reason": "test",
"severity": "EXTREME"
}
});
let result = serde_json::from_value::<Request>(json);
assert!(
result.is_err(),
"invalid severity enum value must be rejected"
);
}
#[test]
fn invalid_session_event_rejected() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": {
"type": "session_log",
"event": "hit",
"key": "file:foo"
}
});
let result = serde_json::from_value::<Request>(json);
assert!(
result.is_err(),
"hit is not a SessionEvent variant — must use consultation_hit command"
);
}
#[test]
fn ok_response_serializes() {
let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let resp = Response::ok(id, serde_json::json!({"pong": true}));
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["status"], "ok");
assert_eq!(json["data"]["pong"], true);
}
#[test]
fn err_response_serializes_with_code() {
let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let resp = Response::err(id, ErrorCode::ValidationFailed, "key must not be empty");
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["status"], "err");
assert_eq!(json["code"], "validation_failed");
assert_eq!(json["message"], "key must not be empty");
}
#[test]
fn error_code_roundtrips() {
let codes = vec![
ErrorCode::VersionMismatch,
ErrorCode::FrameTooLarge,
ErrorCode::MalformedRequest,
ErrorCode::SessionMismatch,
ErrorCode::ValidationFailed,
ErrorCode::NotFound,
ErrorCode::Conflict,
ErrorCode::InvalidStateTransition,
ErrorCode::StoreError,
ErrorCode::Internal,
];
for code in codes {
let json = serde_json::to_value(&code).unwrap();
let back: ErrorCode = serde_json::from_value(json).unwrap();
assert_eq!(back, code);
}
}
#[test]
fn session_flush_decodes() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": { "type": "session_flush" }
});
let req: Request = serde_json::from_value(json).unwrap();
assert!(matches!(req.cmd, Command::SessionFlush));
}
#[test]
fn session_harvest_decodes() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": { "type": "session_harvest" }
});
let req: Request = serde_json::from_value(json).unwrap();
assert!(matches!(req.cmd, Command::SessionHarvest));
}
#[test]
fn dev_note_upsert_create_mode() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": {
"type": "dev_note_upsert",
"text": "Remember to update the changelog"
}
});
let req: Request = serde_json::from_value(json).unwrap();
match req.cmd {
Command::DevNoteUpsert(input) => {
assert!(input.key.is_none()); assert_eq!(input.text, "Remember to update the changelog");
}
_ => panic!("expected DevNoteUpsert"),
}
}
#[test]
fn dev_note_upsert_update_mode() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": {
"type": "dev_note_upsert",
"key": "dev_note:changelog-reminder-1712345678",
"text": "Updated: remember to update changelog AND version"
}
});
let req: Request = serde_json::from_value(json).unwrap();
match req.cmd {
Command::DevNoteUpsert(input) => {
assert_eq!(
input.key.as_deref(),
Some("dev_note:changelog-reminder-1712345678")
);
}
_ => panic!("expected DevNoteUpsert"),
}
}
#[test]
fn command_kind_covers_all_variants() {
let cases: Vec<(&str, Command)> = vec![
("ping", Command::Ping),
("metrics", Command::Metrics),
("get", Command::Get(GetInput { key: "k".into() })),
(
"hook_evaluate",
Command::HookEvaluate(HookEvaluateInput {
file_key: "f".into(),
include_recent: false,
}),
),
(
"scan_prefix",
Command::ScanPrefix(ScanPrefixInput { prefix: "p".into() }),
),
(
"history",
Command::History(HistoryInput {
key: "k".into(),
limit: 10,
}),
),
(
"history_since",
Command::HistorySince(HistorySinceInput {
key: "k".into(),
since_ts: 0,
limit: 10,
}),
),
(
"session_check_consulted",
Command::SessionCheckConsulted(SessionCheckConsultedInput { key: "k".into() }),
),
(
"session_check_consulted_recent",
Command::SessionCheckConsultedRecent(SessionCheckConsultedRecentInput {
key: "k".into(),
ttl_secs: 900,
}),
),
(
"mem_query",
Command::MemQuery(MemQueryInput {
query: "q".into(),
mode: QueryMode::Text,
limit: 20,
}),
),
("mem_get", Command::MemGet(MemGetInput { key: "k".into() })),
(
"mem_bootstrap",
Command::MemBootstrap(MemBootstrapInput {
context_files: vec![],
}),
),
(
"gotcha_upsert",
Command::GotchaUpsert(GotchaDraftInput {
key: "gotcha:t".into(),
rule: "r".into(),
reason: "r".into(),
severity: Severity::Normal,
affected_files: vec![],
ref_url: None,
tags: vec![],
priority: Priority::Normal,
source: None,
}),
),
(
"gotcha_confirm",
Command::GotchaConfirm(GotchaConfirmInput {
key: "gotcha:t".into(),
}),
),
(
"gotcha_tombstone",
Command::GotchaTombstone(GotchaTombstoneInput {
key: "gotcha:t".into(),
}),
),
(
"file_enrich",
Command::FileEnrich(FileEnrichInput {
path: "p".into(),
purpose: "p".into(),
entry_points: vec![],
decision_keys: vec![],
todos: vec![],
tags: vec![],
priority: Priority::Normal,
}),
),
(
"file_reparse",
Command::FileReparse(FileReparseInput { path: "p".into() }),
),
(
"file_edit_hook",
Command::FileEditHook(FileEditHookInput { path: "p".into() }),
),
(
"doc_capture",
Command::DocCapture(DocCaptureInput { path: "p".into() }),
),
(
"decision_upsert",
Command::DecisionUpsert(DecisionUpsertInput {
slug: "s".into(),
value: "v".into(),
summary: "s".into(),
rationale: "r".into(),
tags: vec![],
priority: Priority::Normal,
}),
),
(
"dev_note_upsert",
Command::DevNoteUpsert(DevNoteUpsertInput {
key: None,
text: "t".into(),
tags: vec![],
priority: Priority::Normal,
}),
),
(
"session_log",
Command::SessionLog(SessionLogInput {
event: SessionEvent::Miss,
key: "k".into(),
}),
),
(
"consultation_hit",
Command::ConsultationHit(ConsultationHitInput { key: "k".into() }),
),
("session_flush", Command::SessionFlush),
("session_harvest", Command::SessionHarvest),
];
assert_eq!(cases.len(), 25, "must cover all 25 command variants");
for (expected_kind, cmd) in &cases {
assert_eq!(
cmd.kind(),
*expected_kind,
"kind() mismatch for {:?}",
expected_kind
);
}
}
#[test]
fn command_is_mutation_classification() {
assert!(!Command::Ping.is_mutation());
assert!(!Command::Metrics.is_mutation());
assert!(!Command::Get(GetInput { key: "k".into() }).is_mutation());
assert!(!Command::MemQuery(MemQueryInput {
query: "q".into(),
mode: QueryMode::Text,
limit: 20,
})
.is_mutation());
assert!(Command::MemGet(MemGetInput { key: "k".into() }).is_mutation());
assert!(Command::MemBootstrap(MemBootstrapInput {
context_files: vec![]
})
.is_mutation());
assert!(Command::GotchaConfirm(GotchaConfirmInput {
key: "gotcha:t".into()
})
.is_mutation());
assert!(Command::SessionLog(SessionLogInput {
event: SessionEvent::Miss,
key: "k".into(),
})
.is_mutation());
assert!(Command::SessionFlush.is_mutation());
assert!(Command::SessionHarvest.is_mutation());
}
#[test]
fn command_target_key_returns_expected_values() {
assert_eq!(Command::Ping.target_key(), "");
assert_eq!(
Command::Get(GetInput {
key: "file:src/main.rs".into()
})
.target_key(),
"file:src/main.rs"
);
assert_eq!(
Command::GotchaUpsert(GotchaDraftInput {
key: "gotcha:test".into(),
rule: "r".into(),
reason: "r".into(),
severity: Severity::Normal,
affected_files: vec![],
ref_url: None,
tags: vec![],
priority: Priority::Normal,
source: None,
})
.target_key(),
"gotcha:test"
);
assert_eq!(
Command::DecisionUpsert(DecisionUpsertInput {
slug: "my-decision".into(),
value: "v".into(),
summary: "s".into(),
rationale: "r".into(),
tags: vec![],
priority: Priority::Normal,
})
.target_key(),
"my-decision"
);
assert_eq!(
Command::DevNoteUpsert(DevNoteUpsertInput {
key: None,
text: "t".into(),
tags: vec![],
priority: Priority::Normal,
})
.target_key(),
""
);
assert_eq!(Command::SessionFlush.target_key(), "");
}
#[test]
fn audit_entry_serializes() {
let entry = AuditEntry {
ts: 1700000000,
peer_uid: 501,
peer_pid: Some(1234),
daemon_session: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
request_id: Uuid::parse_str("660e8400-e29b-41d4-a716-446655440000").unwrap(),
command_kind: "gotcha_upsert".into(),
target_key: "gotcha:test".into(),
accepted: true,
error_code: None,
};
let json = serde_json::to_value(&entry).unwrap();
assert_eq!(json["peer_uid"], 501);
assert_eq!(json["command_kind"], "gotcha_upsert");
assert_eq!(json["accepted"], true);
assert!(json.get("error_code").is_none());
}
#[test]
fn audit_entry_rejected_includes_error_code() {
let entry = AuditEntry {
ts: 1700000000,
peer_uid: 501,
peer_pid: None,
daemon_session: Uuid::nil(),
request_id: Uuid::nil(),
command_kind: "gotcha_confirm".into(),
target_key: "gotcha:missing".into(),
accepted: false,
error_code: Some(ErrorCode::NotFound),
};
let json = serde_json::to_value(&entry).unwrap();
assert_eq!(json["accepted"], false);
assert_eq!(json["error_code"], "not_found");
assert!(json["peer_pid"].is_null());
}
#[test]
fn store_priority_to_protocol_severity_preserves_all_variants() {
use crate::store::Priority as SP;
assert_eq!(Severity::from(SP::Low), Severity::Low);
assert_eq!(Severity::from(SP::Normal), Severity::Normal);
assert_eq!(Severity::from(SP::High), Severity::High);
assert_eq!(Severity::from(SP::Critical), Severity::Critical);
}
#[test]
fn store_priority_to_protocol_priority_preserves_all_variants() {
use crate::store::Priority as SP;
assert_eq!(Priority::from(SP::Low), Priority::Low);
assert_eq!(Priority::from(SP::Normal), Priority::Normal);
assert_eq!(Priority::from(SP::High), Priority::High);
assert_eq!(Priority::from(SP::Critical), Priority::Critical);
}
#[test]
fn v1_to_v2_command_handles_mem_get() {
let mapped = v1_to_v2_command("mem_get", &serde_json::json!({ "key": "file:src/main.rs" }));
assert_eq!(
mapped,
serde_json::json!({ "type": "mem_get", "key": "file:src/main.rs" })
);
let cmd: Command = serde_json::from_value(mapped).expect("mem_get must decode as Command");
match cmd {
Command::MemGet(input) => assert_eq!(input.key, "file:src/main.rs"),
other => panic!("expected Command::MemGet, got {:?}", other.kind()),
}
}
#[test]
fn v1_to_v2_command_handles_mem_bootstrap() {
let mapped = v1_to_v2_command(
"mem_bootstrap",
&serde_json::json!({ "context_files": ["src/lib.rs", "src/main.rs"] }),
);
let cmd: Command =
serde_json::from_value(mapped).expect("mem_bootstrap must decode as Command");
match cmd {
Command::MemBootstrap(input) => {
assert_eq!(input.context_files, vec!["src/lib.rs", "src/main.rs"]);
}
other => panic!("expected Command::MemBootstrap, got {:?}", other.kind()),
}
let mapped_empty = v1_to_v2_command("mem_bootstrap", &serde_json::json!({}));
let cmd_empty: Command = serde_json::from_value(mapped_empty).unwrap();
match cmd_empty {
Command::MemBootstrap(input) => assert!(input.context_files.is_empty()),
other => panic!("expected MemBootstrap, got {:?}", other.kind()),
}
}
#[test]
#[should_panic(expected = "v1_to_v2_command called with unsupported command")]
fn v1_to_v2_command_panic_message_lists_only_unsupported() {
let _ = v1_to_v2_command("totally_bogus_cmd_xyz", &serde_json::json!({}));
}
#[test]
fn v1_to_v2_command_no_mutations_silently_accepted() {
let mutation_names = [
"mem_set",
"gotcha_upsert",
"gotcha_confirm",
"gotcha_tombstone",
"decision_upsert",
"dev_note_upsert",
"file_enrich",
"file_reparse",
"file_edit_hook",
"doc_capture",
"session_log",
"consultation_hit",
"session_flush",
"session_harvest",
];
for name in mutation_names {
let result = std::panic::catch_unwind(|| {
v1_to_v2_command(name, &serde_json::json!({}));
});
assert!(
result.is_err(),
"mutation command '{name}' must panic in v1_to_v2_command — \
mutating callers must use daemon_v2() with typed Command"
);
}
}
#[test]
fn request_without_agent_field_deserializes_as_none() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"cmd": { "type": "ping" }
});
let req: Request = serde_json::from_value(json).unwrap();
assert!(
req.agent.is_none(),
"missing `agent` must decode to None (ADR-018 additive contract)"
);
}
#[test]
fn request_with_agent_field_deserializes_and_preserves_value() {
for (wire, expected) in [
("claude", AgentKind::Claude),
("codex", AgentKind::Codex),
("cli", AgentKind::Cli),
("supervisor", AgentKind::Supervisor),
("unknown", AgentKind::Unknown),
] {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"agent": wire,
"cmd": { "type": "ping" }
});
let req: Request = serde_json::from_value(json)
.unwrap_or_else(|e| panic!("decode failed for agent={wire}: {e}"));
assert_eq!(req.agent, Some(expected));
}
}
#[test]
fn request_with_unknown_agent_variant_rejected() {
let json = serde_json::json!({
"v": 2,
"id": "550e8400-e29b-41d4-a716-446655440000",
"session": "660e8400-e29b-41d4-a716-446655440000",
"agent": "gemini",
"cmd": { "type": "ping" }
});
let res = serde_json::from_value::<Request>(json);
assert!(
res.is_err(),
"unknown agent variant must reject at decode (closed enum)"
);
}
#[test]
fn request_with_agent_round_trips_through_serialize_deserialize() {
let original = Request {
v: PROTOCOL_VERSION,
id: Uuid::new_v4(),
session: Uuid::new_v4(),
agent: Some(AgentKind::Codex),
cmd: Command::Ping,
};
let bytes = serde_json::to_vec(&original).unwrap();
let round_tripped: Request = serde_json::from_slice(&bytes).unwrap();
assert_eq!(round_tripped.agent, Some(AgentKind::Codex));
assert_eq!(round_tripped.v, PROTOCOL_VERSION);
}
}