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