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}