Skip to main content

orcs_event/
signal.rs

1//! Signal types for control and interrupt.
2//!
3//! Signals are high-priority control messages that can interrupt
4//! ongoing operations. Unlike Requests, Signals do not expect
5//! a response and are processed immediately.
6//!
7//! # Signal Flow
8//!
9//! ```text
10//! ┌─────────────┐  Signal    ┌─────────────┐
11//! │   Session   │ ─────────► │   EventBus  │ ─────► Components
12//! │ (Principal) │            │             │
13//! └─────────────┘            └─────────────┘
14//! ```
15//!
16//! # Permission Model
17//!
18//! Signal permission is **not** embedded in the signal itself.
19//! The runtime layer's Session carries the privilege level,
20//! and permission checking happens at the EventBus layer.
21//!
22//! This separation enables:
23//!
24//! - Same principal with different privilege levels over time
25//! - Policy changes without modifying signal types
26//! - Clear audit trails (who sent what, with what privilege)
27//!
28//! # Example
29//!
30//! ```
31//! use orcs_event::{Signal, SignalKind};
32//! use orcs_types::{Principal, ChannelId, PrincipalId, SignalScope};
33//!
34//! // Create a cancel signal for a specific channel
35//! let principal = Principal::User(PrincipalId::new());
36//! let channel = ChannelId::new();
37//! let signal = Signal::cancel(channel, principal.clone());
38//!
39//! // Create a veto signal (requires elevated privilege at runtime)
40//! let veto = Signal::veto(principal);
41//! ```
42
43use orcs_types::{ChannelId, Principal, SignalScope};
44use serde::{Deserialize, Serialize};
45
46/// The type of signal being sent.
47///
48/// # Variants
49///
50/// | Kind | Scope | Effect |
51/// |------|-------|--------|
52/// | `Veto` | Global only | Stop all operations |
53/// | `Cancel` | Channel | Stop operations in channel |
54/// | `Pause` | Channel/Component | Pause operations |
55/// | `Resume` | Channel/Component | Resume paused operations |
56/// | `Steer` | Channel | Modify direction with message |
57/// | `Approve` | Component | Approve pending HIL request |
58/// | `Reject` | Component | Reject pending HIL request |
59/// | `Modify` | Component | Approve with modifications |
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub enum SignalKind {
62    // === Stop signals ===
63    /// Global stop - requires elevated privilege.
64    ///
65    /// Stops all operations across all channels.
66    /// This is the "emergency stop" for the entire system.
67    Veto,
68
69    /// Cancel operations in a specific scope.
70    ///
71    /// Stops operations in the targeted channel(s).
72    Cancel,
73
74    // === Control signals ===
75    /// Pause operations in a specific scope.
76    ///
77    /// Paused operations can be resumed with [`Resume`](SignalKind::Resume).
78    Pause,
79
80    /// Resume paused operations.
81    ///
82    /// Resumes operations that were paused with [`Pause`](SignalKind::Pause).
83    Resume,
84
85    /// Modify direction with a message.
86    ///
87    /// Allows Human to steer the execution without stopping.
88    Steer {
89        /// Instruction for how to change direction.
90        message: String,
91    },
92
93    // === HIL signals ===
94    /// Approve a pending HIL request.
95    ///
96    /// Sent by Human to approve an operation that requires approval.
97    Approve {
98        /// ID of the approval request to approve.
99        approval_id: String,
100    },
101
102    /// Reject a pending HIL request.
103    ///
104    /// Sent by Human to reject an operation that requires approval.
105    Reject {
106        /// ID of the approval request to reject.
107        approval_id: String,
108        /// Optional reason for rejection.
109        reason: Option<String>,
110    },
111
112    /// Approve with modifications.
113    ///
114    /// Sent by Human to approve an operation with modified parameters.
115    /// This allows Human to adjust the proposed action before execution.
116    ///
117    /// # Example Use Cases
118    ///
119    /// - Modify file path before write
120    /// - Adjust command arguments before execution
121    /// - Change scope of operation
122    Modify {
123        /// ID of the approval request to modify.
124        approval_id: String,
125        /// Modified payload to use instead of original.
126        modified_payload: serde_json::Value,
127    },
128}
129
130/// A control signal message.
131///
132/// Signals are used for immediate control operations like
133/// stopping or canceling work in progress.
134///
135/// # Permission Checking
136///
137/// The signal itself does not contain permission information.
138/// Permission is determined by:
139///
140/// 1. The runtime layer's Session that sends the signal
141/// 2. The [`SignalScope`] being targeted
142/// 3. The policy configured in the EventBus
143///
144/// # Why No Default?
145///
146/// **DO NOT implement `Default` for Signal.**
147///
148/// A signal requires:
149/// - A valid [`SignalKind`]
150/// - A valid [`SignalScope`]
151/// - A valid [`Principal`] (who is sending)
152///
153/// There is no sensible default for any of these.
154///
155/// # Example
156///
157/// ```
158/// use orcs_event::{Signal, SignalKind};
159/// use orcs_types::{Principal, ChannelId, PrincipalId, SignalScope};
160///
161/// let principal = Principal::User(PrincipalId::new());
162/// let channel = ChannelId::new();
163///
164/// let signal = Signal::new(
165///     SignalKind::Cancel,
166///     SignalScope::Channel(channel),
167///     principal,
168/// );
169/// ```
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct Signal {
172    /// What type of signal.
173    pub kind: SignalKind,
174    /// What scope is affected.
175    pub scope: SignalScope,
176    /// Who is sending the signal.
177    pub source: Principal,
178}
179
180impl Signal {
181    /// Creates a new signal.
182    ///
183    /// # Arguments
184    ///
185    /// * `kind` - The type of signal
186    /// * `scope` - What scope is affected
187    /// * `source` - Who is sending the signal
188    ///
189    /// # Note
190    ///
191    /// This does **not** check permissions. Permission checking
192    /// is done at the EventBus layer using the Session's privilege.
193    #[must_use]
194    pub fn new(kind: SignalKind, scope: SignalScope, source: Principal) -> Self {
195        Self {
196            kind,
197            scope,
198            source,
199        }
200    }
201
202    /// Creates a Veto (global stop) signal.
203    ///
204    /// **Requires elevated privilege to execute.**
205    ///
206    /// # Arguments
207    ///
208    /// * `source` - Who is sending the signal
209    ///
210    /// # Example
211    ///
212    /// ```
213    /// use orcs_event::Signal;
214    /// use orcs_types::{Principal, PrincipalId};
215    ///
216    /// let principal = Principal::User(PrincipalId::new());
217    /// let signal = Signal::veto(principal);
218    ///
219    /// assert!(signal.is_veto());
220    /// assert!(signal.is_global());
221    /// ```
222    #[must_use]
223    pub fn veto(source: Principal) -> Self {
224        Self {
225            kind: SignalKind::Veto,
226            scope: SignalScope::Global,
227            source,
228        }
229    }
230
231    /// Creates a Cancel signal for a specific channel.
232    ///
233    /// # Arguments
234    ///
235    /// * `channel` - The channel to cancel
236    /// * `source` - Who is sending the signal
237    ///
238    /// # Example
239    ///
240    /// ```
241    /// use orcs_event::Signal;
242    /// use orcs_types::{Principal, ChannelId, PrincipalId};
243    ///
244    /// let principal = Principal::User(PrincipalId::new());
245    /// let channel = ChannelId::new();
246    /// let signal = Signal::cancel(channel, principal);
247    ///
248    /// assert!(!signal.is_veto());
249    /// assert!(signal.affects_channel(channel));
250    /// ```
251    #[must_use]
252    pub fn cancel(channel: ChannelId, source: Principal) -> Self {
253        Self {
254            kind: SignalKind::Cancel,
255            scope: SignalScope::Channel(channel),
256            source,
257        }
258    }
259
260    /// Returns `true` if this is a Veto signal.
261    #[must_use]
262    pub fn is_veto(&self) -> bool {
263        matches!(self.kind, SignalKind::Veto)
264    }
265
266    /// Returns `true` if this signal has Global scope.
267    #[must_use]
268    pub fn is_global(&self) -> bool {
269        matches!(self.scope, SignalScope::Global)
270    }
271
272    /// Returns `true` if this signal affects the given channel.
273    ///
274    /// Global signals affect all channels.
275    #[must_use]
276    pub fn affects_channel(&self, channel: ChannelId) -> bool {
277        self.scope.affects(channel)
278    }
279
280    /// Creates a Pause signal for a specific channel.
281    ///
282    /// # Arguments
283    ///
284    /// * `channel` - The channel to pause
285    /// * `source` - Who is sending the signal
286    #[must_use]
287    pub fn pause(channel: ChannelId, source: Principal) -> Self {
288        Self {
289            kind: SignalKind::Pause,
290            scope: SignalScope::Channel(channel),
291            source,
292        }
293    }
294
295    /// Creates a Resume signal for a specific channel.
296    ///
297    /// # Arguments
298    ///
299    /// * `channel` - The channel to resume
300    /// * `source` - Who is sending the signal
301    #[must_use]
302    pub fn resume(channel: ChannelId, source: Principal) -> Self {
303        Self {
304            kind: SignalKind::Resume,
305            scope: SignalScope::Channel(channel),
306            source,
307        }
308    }
309
310    /// Creates a Steer signal for a specific channel.
311    ///
312    /// # Arguments
313    ///
314    /// * `channel` - The channel to steer
315    /// * `message` - Instruction for how to change direction
316    /// * `source` - Who is sending the signal
317    #[must_use]
318    pub fn steer(channel: ChannelId, message: impl Into<String>, source: Principal) -> Self {
319        Self {
320            kind: SignalKind::Steer {
321                message: message.into(),
322            },
323            scope: SignalScope::Channel(channel),
324            source,
325        }
326    }
327
328    /// Creates an Approve signal for a pending HIL request.
329    ///
330    /// # Arguments
331    ///
332    /// * `approval_id` - ID of the approval request to approve
333    /// * `source` - Who is sending the signal (typically Human)
334    #[must_use]
335    pub fn approve(approval_id: impl Into<String>, source: Principal) -> Self {
336        Self {
337            kind: SignalKind::Approve {
338                approval_id: approval_id.into(),
339            },
340            scope: SignalScope::Global,
341            source,
342        }
343    }
344
345    /// Creates a Reject signal for a pending HIL request.
346    ///
347    /// # Arguments
348    ///
349    /// * `approval_id` - ID of the approval request to reject
350    /// * `reason` - Optional reason for rejection
351    /// * `source` - Who is sending the signal (typically Human)
352    #[must_use]
353    pub fn reject(
354        approval_id: impl Into<String>,
355        reason: Option<String>,
356        source: Principal,
357    ) -> Self {
358        Self {
359            kind: SignalKind::Reject {
360                approval_id: approval_id.into(),
361                reason,
362            },
363            scope: SignalScope::Global,
364            source,
365        }
366    }
367
368    /// Returns `true` if this is a Pause signal.
369    #[must_use]
370    pub fn is_pause(&self) -> bool {
371        matches!(self.kind, SignalKind::Pause)
372    }
373
374    /// Returns `true` if this is a Resume signal.
375    #[must_use]
376    pub fn is_resume(&self) -> bool {
377        matches!(self.kind, SignalKind::Resume)
378    }
379
380    /// Returns `true` if this is an Approve signal.
381    #[must_use]
382    pub fn is_approve(&self) -> bool {
383        matches!(self.kind, SignalKind::Approve { .. })
384    }
385
386    /// Returns `true` if this is a Reject signal.
387    #[must_use]
388    pub fn is_reject(&self) -> bool {
389        matches!(self.kind, SignalKind::Reject { .. })
390    }
391
392    /// Returns `true` if this is a Modify signal.
393    #[must_use]
394    pub fn is_modify(&self) -> bool {
395        matches!(self.kind, SignalKind::Modify { .. })
396    }
397
398    /// Returns `true` if this is a HIL response signal (Approve, Reject, or Modify).
399    #[must_use]
400    pub fn is_hil_response(&self) -> bool {
401        self.is_approve() || self.is_reject() || self.is_modify()
402    }
403
404    /// Creates a Modify signal for a pending HIL request.
405    ///
406    /// Approves the request but with modified parameters.
407    ///
408    /// # Arguments
409    ///
410    /// * `approval_id` - ID of the approval request to modify
411    /// * `modified_payload` - Modified payload to use instead of original
412    /// * `source` - Who is sending the signal (typically Human)
413    #[must_use]
414    pub fn modify(
415        approval_id: impl Into<String>,
416        modified_payload: serde_json::Value,
417        source: Principal,
418    ) -> Self {
419        Self {
420            kind: SignalKind::Modify {
421                approval_id: approval_id.into(),
422                modified_payload,
423            },
424            scope: SignalScope::Global,
425            source,
426        }
427    }
428}
429
430/// Response from a component after receiving a signal.
431#[derive(Debug, Clone, Copy, PartialEq, Eq)]
432pub enum SignalResponse {
433    /// Signal was handled successfully.
434    Handled,
435    /// Signal was ignored (out of scope or not relevant).
436    Ignored,
437    /// Component is aborting due to the signal.
438    Abort,
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use orcs_types::{ComponentId, PrincipalId};
445
446    fn test_user() -> Principal {
447        Principal::User(PrincipalId::new())
448    }
449
450    fn test_component() -> Principal {
451        Principal::Component(ComponentId::builtin("test"))
452    }
453
454    #[test]
455    fn veto_signal() {
456        let signal = Signal::veto(test_user());
457        assert!(signal.is_veto());
458        assert!(signal.is_global());
459        assert_eq!(signal.scope, SignalScope::Global);
460    }
461
462    #[test]
463    fn cancel_signal() {
464        let channel = ChannelId::new();
465        let signal = Signal::cancel(channel, test_user());
466
467        assert!(!signal.is_veto());
468        assert!(!signal.is_global());
469        assert_eq!(signal.scope, SignalScope::Channel(channel));
470    }
471
472    #[test]
473    fn cancel_from_component() {
474        let channel = ChannelId::new();
475        let signal = Signal::cancel(channel, test_component());
476
477        assert!(signal.source.is_component());
478    }
479
480    #[test]
481    fn affects_channel_global() {
482        let channel1 = ChannelId::new();
483        let channel2 = ChannelId::new();
484
485        let veto = Signal::veto(test_user());
486        assert!(veto.affects_channel(channel1));
487        assert!(veto.affects_channel(channel2));
488    }
489
490    #[test]
491    fn affects_channel_specific() {
492        let channel1 = ChannelId::new();
493        let channel2 = ChannelId::new();
494
495        let cancel = Signal::cancel(channel1, test_user());
496        assert!(cancel.affects_channel(channel1));
497        assert!(!cancel.affects_channel(channel2));
498    }
499
500    #[test]
501    fn signal_new() {
502        let channel = ChannelId::new();
503        let signal = Signal::new(
504            SignalKind::Cancel,
505            SignalScope::Channel(channel),
506            test_user(),
507        );
508
509        assert!(!signal.is_veto());
510        assert!(signal.affects_channel(channel));
511    }
512
513    #[test]
514    fn signal_response_variants() {
515        assert_eq!(SignalResponse::Handled, SignalResponse::Handled);
516        assert_eq!(SignalResponse::Ignored, SignalResponse::Ignored);
517        assert_eq!(SignalResponse::Abort, SignalResponse::Abort);
518        assert_ne!(SignalResponse::Handled, SignalResponse::Ignored);
519    }
520
521    #[test]
522    fn signal_kind_variants() {
523        assert_eq!(SignalKind::Veto, SignalKind::Veto);
524        assert_eq!(SignalKind::Cancel, SignalKind::Cancel);
525        assert_ne!(SignalKind::Veto, SignalKind::Cancel);
526    }
527
528    #[test]
529    fn signal_scope_equality() {
530        let ch1 = ChannelId::new();
531        let ch2 = ChannelId::new();
532
533        assert_eq!(SignalScope::Global, SignalScope::Global);
534        assert_eq!(SignalScope::Channel(ch1), SignalScope::Channel(ch1));
535        assert_ne!(SignalScope::Channel(ch1), SignalScope::Channel(ch2));
536        assert_ne!(SignalScope::Global, SignalScope::Channel(ch1));
537    }
538
539    // === M2 HIL Signal Tests ===
540
541    #[test]
542    fn pause_signal() {
543        let channel = ChannelId::new();
544        let signal = Signal::pause(channel, test_user());
545
546        assert!(signal.is_pause());
547        assert!(!signal.is_resume());
548        assert!(signal.affects_channel(channel));
549    }
550
551    #[test]
552    fn resume_signal() {
553        let channel = ChannelId::new();
554        let signal = Signal::resume(channel, test_user());
555
556        assert!(signal.is_resume());
557        assert!(!signal.is_pause());
558        assert!(signal.affects_channel(channel));
559    }
560
561    #[test]
562    fn steer_signal() {
563        let channel = ChannelId::new();
564        let signal = Signal::steer(channel, "focus on error handling", test_user());
565
566        if let SignalKind::Steer { message } = &signal.kind {
567            assert_eq!(message, "focus on error handling");
568        } else {
569            panic!("Expected Steer signal");
570        }
571        assert!(signal.affects_channel(channel));
572    }
573
574    #[test]
575    fn approve_signal() {
576        let signal = Signal::approve("req-123", test_user());
577
578        assert!(signal.is_approve());
579        assert!(signal.is_hil_response());
580        assert!(!signal.is_reject());
581
582        if let SignalKind::Approve { approval_id } = &signal.kind {
583            assert_eq!(approval_id, "req-123");
584        } else {
585            panic!("Expected Approve signal");
586        }
587    }
588
589    #[test]
590    fn reject_signal_with_reason() {
591        let signal = Signal::reject("req-456", Some("dangerous operation".into()), test_user());
592
593        assert!(signal.is_reject());
594        assert!(signal.is_hil_response());
595        assert!(!signal.is_approve());
596
597        if let SignalKind::Reject {
598            approval_id,
599            reason,
600        } = &signal.kind
601        {
602            assert_eq!(approval_id, "req-456");
603            assert_eq!(reason, &Some("dangerous operation".to_string()));
604        } else {
605            panic!("Expected Reject signal");
606        }
607    }
608
609    #[test]
610    fn reject_signal_without_reason() {
611        let signal = Signal::reject("req-789", None, test_user());
612
613        if let SignalKind::Reject {
614            approval_id,
615            reason,
616        } = &signal.kind
617        {
618            assert_eq!(approval_id, "req-789");
619            assert!(reason.is_none());
620        } else {
621            panic!("Expected Reject signal");
622        }
623    }
624
625    #[test]
626    fn signal_kind_equality_with_data() {
627        assert_eq!(SignalKind::Pause, SignalKind::Pause);
628        assert_eq!(SignalKind::Resume, SignalKind::Resume);
629
630        let approve1 = SignalKind::Approve {
631            approval_id: "a".into(),
632        };
633        let approve2 = SignalKind::Approve {
634            approval_id: "a".into(),
635        };
636        let approve3 = SignalKind::Approve {
637            approval_id: "b".into(),
638        };
639
640        assert_eq!(approve1, approve2);
641        assert_ne!(approve1, approve3);
642    }
643
644    #[test]
645    fn modify_signal() {
646        let modified_payload = serde_json::json!({
647            "path": "/safe/path/file.txt",
648            "content": "modified content"
649        });
650        let signal = Signal::modify("req-mod-1", modified_payload.clone(), test_user());
651
652        assert!(signal.is_modify());
653        assert!(signal.is_hil_response());
654        assert!(!signal.is_approve());
655        assert!(!signal.is_reject());
656
657        if let SignalKind::Modify {
658            approval_id,
659            modified_payload: payload,
660        } = &signal.kind
661        {
662            assert_eq!(approval_id, "req-mod-1");
663            assert_eq!(payload, &modified_payload);
664        } else {
665            panic!("Expected Modify signal");
666        }
667    }
668
669    #[test]
670    fn modify_signal_kind_equality() {
671        let payload1 = serde_json::json!({"key": "value1"});
672        let payload2 = serde_json::json!({"key": "value1"});
673        let payload3 = serde_json::json!({"key": "value2"});
674
675        let modify1 = SignalKind::Modify {
676            approval_id: "a".into(),
677            modified_payload: payload1,
678        };
679        let modify2 = SignalKind::Modify {
680            approval_id: "a".into(),
681            modified_payload: payload2,
682        };
683        let modify3 = SignalKind::Modify {
684            approval_id: "a".into(),
685            modified_payload: payload3,
686        };
687
688        assert_eq!(modify1, modify2);
689        assert_ne!(modify1, modify3);
690    }
691}