Skip to main content

mur_common/
signal.rs

1//! Signal wire format for cross-process memory sync events.
2//!
3//! Flows:
4//! - commander writes → `~/.mur/commander/outbox/*.yaml` → POST /v1/signals/batch → mur-server
5//! - mur CLI `mur fetch` ← GET /v1/signals/pending ← mur-server → `~/.mur/inbox/*.yaml`
6//!
7//! Schema version is bumped on breaking wire changes. Additive changes (new fields)
8//! are serde-default and backward compatible within the same major version.
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14use crate::{Actor, Pattern, Scope};
15
16// ─── FROZEN SCHEMA — v1 ──────────────────────────────────────────────────
17// This module is the canonical wire format between commander and mur.
18// SCHEMA FREEZE DATE: 2026-05-18
19// Spec: docs/superpowers/specs/2026-05-18-commander-feedback-wire-protocol-design.md
20//
21// Changes to Signal, SignalKind, SignalTarget, Actor, ActorSource, or
22// SIGNAL_SCHEMA_VERSION require:
23//   1. Bumping SIGNAL_SCHEMA_VERSION to 2
24//   2. Coordinated update in the commander repo (closed-source)
25//   3. Adding a v2 HTTP endpoint at /v2/signals/...
26//   4. Migration plan in a new design spec
27//
28// Additive changes (new fields with #[serde(default)]) are allowed within v1.
29// ─────────────────────────────────────────────────────────────────────────
30
31/// Current schema version of the Signal wire format. FROZEN at v1 — see
32/// module-level comment for change rules.
33pub const SIGNAL_SCHEMA_VERSION: u32 = 1;
34
35/// A single event envelope: who produced what kind of event about which target,
36/// with provenance. Carried verbatim through outbox → server → inbox.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Signal {
39    pub id: Uuid,
40    pub emitted_at: DateTime<Utc>,
41    pub actor: Actor,
42    pub target: SignalTarget,
43    pub kind: SignalKind,
44    pub scope: Scope,
45    /// Confidence weight in [0.0, 1.0] applied server-side during aggregation.
46    /// Default 1.0 (full weight).
47    #[serde(default = "default_confidence")]
48    pub confidence: f64,
49    /// Wire-format version of this signal. Server-side rejects signals with
50    /// unsupported major versions; additive fields with `#[serde(default)]`
51    /// keep signals within the same major forward-compatible.
52    #[serde(default = "current_schema_version")]
53    pub schema_version: u32,
54}
55
56fn default_confidence() -> f64 {
57    1.0
58}
59fn current_schema_version() -> u32 {
60    SIGNAL_SCHEMA_VERSION
61}
62
63/// HTTP batch wrapper for `POST /v1/signals/batch`.
64///
65/// Carries 1–N signals in a single request. `batch_id` enables at-most-once
66/// retry semantics: the server deduplicates on `batch_id` (HTTP layer) and on
67/// individual `Signal.id` (inbox layer).
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct SignalBatch {
70    pub batch_id: Uuid,
71    /// Must equal `SIGNAL_SCHEMA_VERSION` (1). Server rejects mismatches.
72    #[serde(default = "current_schema_version")]
73    pub schema_version: u32,
74    pub signals: Vec<Signal>,
75}
76
77/// Response body for `POST /v1/signals/batch`.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct SignalBatchResponse {
80    pub accepted: usize,
81    pub deduplicated: usize,
82}
83
84/// What the signal refers to.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(tag = "kind", rename_all = "snake_case")]
87pub enum SignalTarget {
88    /// Refers to an existing pattern by name within a scope.
89    Pattern { name: String, scope: Scope },
90    /// Carries a fully-formed Pattern as a draft proposal (Channel 2/3).
91    /// Boxed to keep the enum variant sizes comparable.
92    NewDraftPattern { payload: Box<Pattern> },
93}
94
95/// What happened to the target.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(tag = "type", rename_all = "snake_case")]
98pub enum SignalKind {
99    /// Workflow/step using this pattern completed successfully. (Channel 1)
100    ExecutionSuccess,
101    /// Workflow/step using this pattern failed. (Channel 1)
102    ExecutionFailure { error: String },
103    /// User rejected a breakpoint while this pattern was active. (Channel 1, 3x weight)
104    UserOverrideAtBreakpoint { reason: Option<String> },
105    /// AutoFix ran on a step that used this pattern. (Channel 1, signals pattern inadequacy)
106    AutoFixApplied { step: String },
107    /// Proposal to add a new pattern. (Channel 2 — chat extraction, Channel 3 — procedural)
108    NewPatternProposal { origin_context: String },
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::ActorSource;
115
116    fn sample_actor() -> Actor {
117        Actor {
118            source: ActorSource::CommanderDaemon,
119            native_id: "svc-1".into(),
120            display_name: None,
121            resolved_user_id: None,
122        }
123    }
124
125    fn sample_signal() -> Signal {
126        Signal {
127            id: Uuid::new_v4(),
128            emitted_at: Utc::now(),
129            actor: sample_actor(),
130            target: SignalTarget::Pattern {
131                name: "rust-err-handling".into(),
132                scope: Scope::Personal,
133            },
134            kind: SignalKind::ExecutionSuccess,
135            scope: Scope::Personal,
136            confidence: 0.9,
137            schema_version: SIGNAL_SCHEMA_VERSION,
138        }
139    }
140
141    #[test]
142    fn signal_roundtrip_execution_success() {
143        let s = sample_signal();
144        let y = serde_yaml::to_string(&s).unwrap();
145        let back: Signal = serde_yaml::from_str(&y).unwrap();
146        assert_eq!(back.id, s.id);
147        assert!(matches!(back.kind, SignalKind::ExecutionSuccess));
148        assert!((back.confidence - 0.9).abs() < 1e-9);
149    }
150
151    #[test]
152    fn signal_confidence_defaults_to_one() {
153        let y = r#"
154id: 00000000-0000-0000-0000-000000000001
155emitted_at: 2026-04-18T10:00:00Z
156actor: { source: commander_daemon, native_id: x }
157target: { kind: pattern, name: foo, scope: { kind: personal } }
158kind: { type: execution_success }
159scope: { kind: personal }
160"#;
161        let s: Signal = serde_yaml::from_str(y).unwrap();
162        assert!((s.confidence - 1.0).abs() < 1e-9);
163        assert_eq!(s.schema_version, 1);
164    }
165
166    #[test]
167    fn signal_kind_execution_failure_carries_error() {
168        let s = Signal {
169            kind: SignalKind::ExecutionFailure {
170                error: "db timeout".into(),
171            },
172            ..sample_signal()
173        };
174        let y = serde_yaml::to_string(&s).unwrap();
175        let back: Signal = serde_yaml::from_str(&y).unwrap();
176        match back.kind {
177            SignalKind::ExecutionFailure { error } => assert_eq!(error, "db timeout"),
178            _ => panic!("wrong variant"),
179        }
180    }
181
182    #[test]
183    fn signal_kind_override_with_reason() {
184        let y = r#"
185id: 00000000-0000-0000-0000-000000000002
186emitted_at: 2026-04-18T10:00:00Z
187actor: { source: slack, native_id: U999 }
188target: { kind: pattern, name: x, scope: { kind: personal } }
189kind: { type: user_override_at_breakpoint, reason: "wrong step" }
190scope: { kind: personal }
191"#;
192        let s: Signal = serde_yaml::from_str(y).unwrap();
193        match s.kind {
194            SignalKind::UserOverrideAtBreakpoint { reason } => {
195                assert_eq!(reason.as_deref(), Some("wrong step"));
196            }
197            _ => panic!("wrong variant"),
198        }
199    }
200
201    #[test]
202    fn signal_kind_override_without_reason() {
203        let y = r#"
204id: 00000000-0000-0000-0000-000000000003
205emitted_at: 2026-04-18T10:00:00Z
206actor: { source: slack, native_id: U999 }
207target: { kind: pattern, name: x, scope: { kind: personal } }
208kind: { type: user_override_at_breakpoint }
209scope: { kind: personal }
210"#;
211        let s: Signal = serde_yaml::from_str(y).unwrap();
212        assert!(matches!(
213            s.kind,
214            SignalKind::UserOverrideAtBreakpoint { reason: None }
215        ));
216    }
217
218    #[test]
219    fn signal_kind_autofix() {
220        let s = Signal {
221            kind: SignalKind::AutoFixApplied {
222                step: "run-tests".into(),
223            },
224            ..sample_signal()
225        };
226        let y = serde_yaml::to_string(&s).unwrap();
227        let back: Signal = serde_yaml::from_str(&y).unwrap();
228        match back.kind {
229            SignalKind::AutoFixApplied { step } => assert_eq!(step, "run-tests"),
230            _ => panic!("wrong variant"),
231        }
232    }
233
234    #[test]
235    fn signal_kind_new_pattern_proposal() {
236        let s = Signal {
237            kind: SignalKind::NewPatternProposal {
238                origin_context: "slack DM from alice: use pnpm".into(),
239            },
240            ..sample_signal()
241        };
242        let y = serde_yaml::to_string(&s).unwrap();
243        let back: Signal = serde_yaml::from_str(&y).unwrap();
244        match back.kind {
245            SignalKind::NewPatternProposal { origin_context } => {
246                assert!(origin_context.contains("alice"));
247            }
248            _ => panic!("wrong variant"),
249        }
250    }
251
252    #[test]
253    fn signal_target_pattern_roundtrip() {
254        let p = SignalTarget::Pattern {
255            name: "foo".into(),
256            scope: Scope::Team {
257                team_id: "ops".into(),
258            },
259        };
260        let y = serde_yaml::to_string(&p).unwrap();
261        assert!(y.contains("kind: pattern"));
262        let back: SignalTarget = serde_yaml::from_str(&y).unwrap();
263        assert!(matches!(back, SignalTarget::Pattern { .. }));
264    }
265
266    #[test]
267    fn signal_with_new_draft_pattern_roundtrip() {
268        use crate::knowledge::KnowledgeBase;
269        use crate::pattern::{Content, Tier};
270
271        // Build a minimal Pattern to box into the target payload.
272        let kb = KnowledgeBase {
273            name: "draft-pat".into(),
274            description: "chat-extracted draft".into(),
275            content: Content::Plain("use pnpm not npm".into()),
276            tier: Tier::Session,
277            ..Default::default()
278        };
279        let pat = Pattern {
280            base: kb,
281            kind: None,
282            origin: None,
283            attachments: Vec::new(),
284        };
285
286        let sig = Signal {
287            id: Uuid::new_v4(),
288            emitted_at: Utc::now(),
289            actor: sample_actor(),
290            target: SignalTarget::NewDraftPattern {
291                payload: Box::new(pat.clone()),
292            },
293            kind: SignalKind::NewPatternProposal {
294                origin_context: "slack DM".into(),
295            },
296            scope: Scope::Personal,
297            confidence: 0.75,
298            schema_version: SIGNAL_SCHEMA_VERSION,
299        };
300        let y = serde_yaml::to_string(&sig).unwrap();
301        assert!(y.contains("kind: new_draft_pattern"));
302        let back: Signal = serde_yaml::from_str(&y).unwrap();
303        match back.target {
304            SignalTarget::NewDraftPattern { payload } => {
305                assert_eq!(payload.name, "draft-pat");
306            }
307            _ => panic!("expected NewDraftPattern variant"),
308        }
309    }
310
311    #[test]
312    fn schema_version_constant() {
313        assert_eq!(SIGNAL_SCHEMA_VERSION, 1);
314    }
315}