1use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14use crate::skill::manifest::SkillManifest;
15use crate::{Actor, Pattern, Scope};
16
17pub const SIGNAL_SCHEMA_VERSION: u32 = 1;
35
36#[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 #[serde(default = "default_confidence")]
49 pub confidence: f64,
50 #[serde(default = "current_schema_version")]
54 pub schema_version: u32,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub sig: Option<String>,
61 #[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
76pub const SIGNAL_SIG_INPUT_VERSION: u32 = 1;
79
80fn 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
101pub 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 pub fn sign(&mut self, identity: &crate::identity::AgentIdentity) {
116 self.sig = Some(identity.sign_multibase(&sign_input(self)));
117 }
118
119 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#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct SignalBatch {
136 pub batch_id: Uuid,
137 #[serde(default = "current_schema_version")]
139 pub schema_version: u32,
140 pub signals: Vec<Signal>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct SignalBatchResponse {
146 pub accepted: usize,
147 pub deduplicated: usize,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(tag = "kind", rename_all = "snake_case")]
153pub enum SignalTarget {
154 Pattern { name: String, scope: Scope },
156 NewDraftPattern { payload: Box<Pattern> },
159 Skill { name: String, scope: Scope },
161 NewDraftSkill { payload: Box<SkillManifest> },
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167#[serde(tag = "type", rename_all = "snake_case")]
168pub enum SignalKind {
169 ExecutionSuccess,
171 ExecutionFailure { error: String },
173 UserOverrideAtBreakpoint { reason: Option<String> },
175 AutoFixApplied { step: String },
177 NewPatternProposal { origin_context: String },
179 SkillExecutionSuccess,
181 SkillExecutionFailure { error: String },
183 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 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 }; 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 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 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}