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    /// Multibase (Base58Btc) Ed25519 signature over [`sign_input`] — federation
56    /// P2c-2, following the v3d `ChannelEvent` precedent. `None` = legacy
57    /// unsigned signal (tolerated on ingest unless `MUR_SIGNAL_REQUIRE_SIG`).
58    /// Additive `#[serde(default)]` field — allowed within frozen schema v1.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub sig: Option<String>,
61    /// Key-rotation version; 0 = initial identity key.
62    #[serde(default, skip_serializing_if = "is_zero")]
63    pub key_version: u32,
64}
65
66fn default_confidence() -> f64 {
67    1.0
68}
69fn current_schema_version() -> u32 {
70    SIGNAL_SCHEMA_VERSION
71}
72pub(crate) fn is_zero(v: &u32) -> bool {
73    *v == 0
74}
75
76/// Canonicalization version — bump if the sign-input shape changes so an old
77/// signature is never silently checked against a new canonicalization.
78pub const SIGNAL_SIG_INPUT_VERSION: u32 = 1;
79
80/// Canonical signed bytes for a [`Signal`]: every semantic field, `sig`
81/// excluded. `serde_json` sorts object keys (no preserve_order), so this is
82/// deterministic for a given input. The `domain` tag prevents a signature
83/// minted here from verifying in any other MUR signing context.
84fn sign_input(s: &Signal) -> Vec<u8> {
85    let canon = serde_json::json!({
86        "domain": "mur-signal",
87        "v": SIGNAL_SIG_INPUT_VERSION,
88        "id": s.id,
89        "emitted_at": s.emitted_at,
90        "actor": s.actor,
91        "target": s.target,
92        "kind": s.kind,
93        "scope": s.scope,
94        "confidence": s.confidence,
95        "schema_version": s.schema_version,
96        "key_version": s.key_version,
97    });
98    serde_json::to_vec(&canon).unwrap_or_default()
99}
100
101/// Parse `MUR_SIGNAL_REQUIRE_SIG`: only explicit truthy values enable
102/// signature enforcement (`=0` / `=false`, or unset, must NOT turn it on) —
103/// default-off is migration safety AND the commander wire (frozen v1, signals
104/// arrive bearer-token-authed but unsigned). One parser for every reader,
105/// mirroring `MUR_CHANNEL_REQUIRE_SIG`.
106pub fn require_sig_from_env() -> bool {
107    std::env::var("MUR_SIGNAL_REQUIRE_SIG")
108        .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes"))
109        .unwrap_or(false)
110}
111
112impl Signal {
113    /// Sign this signal in place with the emitting agent's identity key.
114    /// The signature covers every field except `sig` itself.
115    pub fn sign(&mut self, identity: &crate::identity::AgentIdentity) {
116        self.sig = Some(identity.sign_multibase(&sign_input(self)));
117    }
118
119    /// Fail-closed signature check against `pubkey`. An unsigned signal
120    /// never verifies — callers decide whether unsigned is tolerated.
121    pub fn verify(&self, pubkey: &[u8; 32]) -> bool {
122        match &self.sig {
123            Some(sig) => crate::identity::verify_bytes(pubkey, &sign_input(self), sig),
124            None => false,
125        }
126    }
127}
128
129/// HTTP batch wrapper for `POST /v1/signals/batch`.
130///
131/// Carries 1–N signals in a single request. `batch_id` enables at-most-once
132/// retry semantics: the server deduplicates on `batch_id` (HTTP layer) and on
133/// individual `Signal.id` (inbox layer).
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct SignalBatch {
136    pub batch_id: Uuid,
137    /// Must equal `SIGNAL_SCHEMA_VERSION` (1). Server rejects mismatches.
138    #[serde(default = "current_schema_version")]
139    pub schema_version: u32,
140    pub signals: Vec<Signal>,
141}
142
143/// Response body for `POST /v1/signals/batch`.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct SignalBatchResponse {
146    pub accepted: usize,
147    pub deduplicated: usize,
148}
149
150/// What the signal refers to.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(tag = "kind", rename_all = "snake_case")]
153pub enum SignalTarget {
154    /// Refers to an existing pattern by name within a scope.
155    Pattern { name: String, scope: Scope },
156    /// Carries a fully-formed Pattern as a draft proposal (Channel 2/3).
157    /// Boxed to keep the enum variant sizes comparable.
158    NewDraftPattern { payload: Box<Pattern> },
159    /// Refers to an installed skill by name.
160    Skill { name: String, scope: Scope },
161    /// Carries a fully-formed SkillManifest as a draft proposal.
162    NewDraftSkill { payload: Box<SkillManifest> },
163}
164
165/// What happened to the target.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167#[serde(tag = "type", rename_all = "snake_case")]
168pub enum SignalKind {
169    /// Workflow/step using this pattern completed successfully. (Channel 1)
170    ExecutionSuccess,
171    /// Workflow/step using this pattern failed. (Channel 1)
172    ExecutionFailure { error: String },
173    /// User rejected a breakpoint while this pattern was active. (Channel 1, 3x weight)
174    UserOverrideAtBreakpoint { reason: Option<String> },
175    /// AutoFix ran on a step that used this pattern. (Channel 1, signals pattern inadequacy)
176    AutoFixApplied { step: String },
177    /// Proposal to add a new pattern. (Channel 2 — chat extraction, Channel 3 — procedural)
178    NewPatternProposal { origin_context: String },
179    /// Skill execution succeeded. (Channel 1)
180    SkillExecutionSuccess,
181    /// Skill execution failed. (Channel 1)
182    SkillExecutionFailure { error: String },
183    /// Proposal to add a new skill. (Channel 2 / Channel 3)
184    NewDraftSkill {
185        payload: Box<SkillManifest>,
186        origin_context: String,
187    },
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::ActorSource;
194
195    fn sample_actor() -> Actor {
196        Actor {
197            source: ActorSource::CommanderDaemon,
198            native_id: "svc-1".into(),
199            display_name: None,
200            resolved_user_id: None,
201        }
202    }
203
204    fn sample_signal() -> Signal {
205        Signal {
206            id: Uuid::new_v4(),
207            emitted_at: Utc::now(),
208            actor: sample_actor(),
209            target: SignalTarget::Pattern {
210                name: "rust-err-handling".into(),
211                scope: Scope::Personal,
212            },
213            kind: SignalKind::ExecutionSuccess,
214            scope: Scope::Personal,
215            confidence: 0.9,
216            schema_version: SIGNAL_SCHEMA_VERSION,
217            sig: None,
218            key_version: 0,
219        }
220    }
221
222    #[test]
223    fn signal_roundtrip_execution_success() {
224        let s = sample_signal();
225        let y = serde_yaml::to_string(&s).unwrap();
226        let back: Signal = serde_yaml::from_str(&y).unwrap();
227        assert_eq!(back.id, s.id);
228        assert!(matches!(back.kind, SignalKind::ExecutionSuccess));
229        assert!((back.confidence - 0.9).abs() < 1e-9);
230    }
231
232    #[test]
233    fn signal_confidence_defaults_to_one() {
234        let y = r#"
235id: 00000000-0000-0000-0000-000000000001
236emitted_at: 2026-04-18T10:00:00Z
237actor: { source: commander_daemon, native_id: x }
238target: { kind: pattern, name: foo, scope: { kind: personal } }
239kind: { type: execution_success }
240scope: { kind: personal }
241"#;
242        let s: Signal = serde_yaml::from_str(y).unwrap();
243        assert!((s.confidence - 1.0).abs() < 1e-9);
244        assert_eq!(s.schema_version, 1);
245    }
246
247    #[test]
248    fn signal_kind_execution_failure_carries_error() {
249        let s = Signal {
250            kind: SignalKind::ExecutionFailure {
251                error: "db timeout".into(),
252            },
253            ..sample_signal()
254        };
255        let y = serde_yaml::to_string(&s).unwrap();
256        let back: Signal = serde_yaml::from_str(&y).unwrap();
257        match back.kind {
258            SignalKind::ExecutionFailure { error } => assert_eq!(error, "db timeout"),
259            _ => panic!("wrong variant"),
260        }
261    }
262
263    #[test]
264    fn signal_kind_override_with_reason() {
265        let y = r#"
266id: 00000000-0000-0000-0000-000000000002
267emitted_at: 2026-04-18T10:00:00Z
268actor: { source: slack, native_id: U999 }
269target: { kind: pattern, name: x, scope: { kind: personal } }
270kind: { type: user_override_at_breakpoint, reason: "wrong step" }
271scope: { kind: personal }
272"#;
273        let s: Signal = serde_yaml::from_str(y).unwrap();
274        match s.kind {
275            SignalKind::UserOverrideAtBreakpoint { reason } => {
276                assert_eq!(reason.as_deref(), Some("wrong step"));
277            }
278            _ => panic!("wrong variant"),
279        }
280    }
281
282    #[test]
283    fn signal_kind_override_without_reason() {
284        let y = r#"
285id: 00000000-0000-0000-0000-000000000003
286emitted_at: 2026-04-18T10:00:00Z
287actor: { source: slack, native_id: U999 }
288target: { kind: pattern, name: x, scope: { kind: personal } }
289kind: { type: user_override_at_breakpoint }
290scope: { kind: personal }
291"#;
292        let s: Signal = serde_yaml::from_str(y).unwrap();
293        assert!(matches!(
294            s.kind,
295            SignalKind::UserOverrideAtBreakpoint { reason: None }
296        ));
297    }
298
299    #[test]
300    fn signal_kind_autofix() {
301        let s = Signal {
302            kind: SignalKind::AutoFixApplied {
303                step: "run-tests".into(),
304            },
305            ..sample_signal()
306        };
307        let y = serde_yaml::to_string(&s).unwrap();
308        let back: Signal = serde_yaml::from_str(&y).unwrap();
309        match back.kind {
310            SignalKind::AutoFixApplied { step } => assert_eq!(step, "run-tests"),
311            _ => panic!("wrong variant"),
312        }
313    }
314
315    #[test]
316    fn signal_kind_new_pattern_proposal() {
317        let s = Signal {
318            kind: SignalKind::NewPatternProposal {
319                origin_context: "slack DM from alice: use pnpm".into(),
320            },
321            ..sample_signal()
322        };
323        let y = serde_yaml::to_string(&s).unwrap();
324        let back: Signal = serde_yaml::from_str(&y).unwrap();
325        match back.kind {
326            SignalKind::NewPatternProposal { origin_context } => {
327                assert!(origin_context.contains("alice"));
328            }
329            _ => panic!("wrong variant"),
330        }
331    }
332
333    #[test]
334    fn signal_target_pattern_roundtrip() {
335        let p = SignalTarget::Pattern {
336            name: "foo".into(),
337            scope: Scope::Team {
338                team_id: "ops".into(),
339            },
340        };
341        let y = serde_yaml::to_string(&p).unwrap();
342        assert!(y.contains("kind: pattern"));
343        let back: SignalTarget = serde_yaml::from_str(&y).unwrap();
344        assert!(matches!(back, SignalTarget::Pattern { .. }));
345    }
346
347    #[test]
348    fn signal_with_new_draft_pattern_roundtrip() {
349        use crate::knowledge::KnowledgeBase;
350        use crate::pattern::{Content, Tier};
351
352        // Build a minimal Pattern to box into the target payload.
353        let kb = KnowledgeBase {
354            name: "draft-pat".into(),
355            description: "chat-extracted draft".into(),
356            content: Content::Plain("use pnpm not npm".into()),
357            tier: Tier::Session,
358            ..Default::default()
359        };
360        let pat = Pattern {
361            base: kb,
362            kind: None,
363            origin: None,
364            attachments: Vec::new(),
365        };
366
367        let sig = Signal {
368            id: Uuid::new_v4(),
369            emitted_at: Utc::now(),
370            actor: sample_actor(),
371            target: SignalTarget::NewDraftPattern {
372                payload: Box::new(pat.clone()),
373            },
374            kind: SignalKind::NewPatternProposal {
375                origin_context: "slack DM".into(),
376            },
377            scope: Scope::Personal,
378            confidence: 0.75,
379            schema_version: SIGNAL_SCHEMA_VERSION,
380            sig: None,
381            key_version: 0,
382        };
383        let y = serde_yaml::to_string(&sig).unwrap();
384        assert!(y.contains("kind: new_draft_pattern"));
385        let back: Signal = serde_yaml::from_str(&y).unwrap();
386        match back.target {
387            SignalTarget::NewDraftPattern { payload } => {
388                assert_eq!(payload.name, "draft-pat");
389            }
390            _ => panic!("expected NewDraftPattern variant"),
391        }
392    }
393
394    #[test]
395    fn schema_version_constant() {
396        assert_eq!(SIGNAL_SCHEMA_VERSION, 1);
397    }
398
399    #[test]
400    fn sign_verify_roundtrip_and_yaml_preserves_sig() {
401        let id = crate::identity::AgentIdentity::generate();
402        let mut s = sample_signal();
403        s.sign(&id);
404        assert!(s.verify(&id.verifying_key_bytes()));
405
406        let yaml = serde_yaml::to_string(&s).unwrap();
407        let back: Signal = serde_yaml::from_str(&yaml).unwrap();
408        assert!(back.verify(&id.verifying_key_bytes()));
409    }
410
411    #[test]
412    fn tampered_field_fails_verification() {
413        let id = crate::identity::AgentIdentity::generate();
414        let mut s = sample_signal();
415        s.sign(&id);
416        s.scope = Scope::Team {
417            team_id: "ops".into(),
418        }; // scope-escalation attempt after signing
419        assert!(!s.verify(&id.verifying_key_bytes()));
420    }
421
422    #[test]
423    fn wrong_key_and_unsigned_fail_verification() {
424        let id = crate::identity::AgentIdentity::generate();
425        let other = crate::identity::AgentIdentity::generate();
426        let mut s = sample_signal();
427        assert!(
428            !s.verify(&id.verifying_key_bytes()),
429            "unsigned never verifies"
430        );
431        s.sign(&id);
432        assert!(!s.verify(&other.verifying_key_bytes()));
433    }
434
435    #[test]
436    fn legacy_unsigned_yaml_deserializes_with_defaults() {
437        // Pre-P2c-2 signal yaml — no sig/key_version fields.
438        let y = r#"
439id: 00000000-0000-0000-0000-000000000009
440emitted_at: 2026-04-18T10:00:00Z
441actor: { source: commander_daemon, native_id: x }
442target: { kind: pattern, name: foo, scope: { kind: personal } }
443kind: { type: execution_success }
444scope: { kind: personal }
445"#;
446        let s: Signal = serde_yaml::from_str(y).unwrap();
447        assert!(s.sig.is_none());
448        assert_eq!(s.key_version, 0);
449        // And an unsigned signal serializes WITHOUT the new keys (wire-stable).
450        let out = serde_yaml::to_string(&s).unwrap();
451        assert!(!out.contains("sig:"));
452        assert!(!out.contains("key_version:"));
453    }
454
455    #[test]
456    fn signal_target_skill_roundtrips() {
457        let t = SignalTarget::Skill {
458            name: "my-skill".into(),
459            scope: Scope::Personal,
460        };
461        let s = serde_json::to_string(&t).unwrap();
462        assert!(s.contains("\"kind\":\"skill\""), "got: {s}");
463        let back: SignalTarget = serde_json::from_str(&s).unwrap();
464        assert!(matches!(back, SignalTarget::Skill { .. }));
465    }
466
467    #[test]
468    fn signal_kind_new_draft_skill_roundtrips() {
469        let k = SignalKind::NewDraftSkill {
470            payload: Box::new(
471                serde_json::from_str::<SkillManifest>(
472                    r#"{"name":"x","version":"1","publisher":"human:t","description":"d","category":"context","content":{"abstract":"a"}}"#,
473                )
474                .unwrap(),
475            ),
476            origin_context: "test".into(),
477        };
478        let s = serde_json::to_string(&k).unwrap();
479        let back: SignalKind = serde_json::from_str(&s).unwrap();
480        assert!(matches!(back, SignalKind::NewDraftSkill { .. }));
481    }
482}