1use std::sync::Arc;
16
17use chio_kernel::{Guard, GuardContext, KernelError, Verdict};
18use serde::{Deserialize, Serialize};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum AdvisorySeverity {
28 Info,
30 Low,
32 Medium,
34 High,
36 Critical,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct AdvisorySignal {
43 pub guard_name: String,
45 pub description: String,
47 pub severity: AdvisorySeverity,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub metadata: Option<serde_json::Value>,
52 #[serde(default)]
55 pub promoted: bool,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(tag = "type", rename_all = "snake_case")]
61pub enum GuardOutput {
62 Deterministic {
64 guard_name: String,
65 verdict: bool,
66 details: Option<String>,
67 },
68 Advisory(AdvisorySignal),
70}
71
72pub trait AdvisoryGuard: Send + Sync {
81 fn name(&self) -> &str;
83
84 fn evaluate(&self, ctx: &GuardContext) -> Result<Vec<AdvisorySignal>, KernelError>;
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PromotionRule {
101 pub guard_name: String,
103 pub min_severity: AdvisorySeverity,
106}
107
108#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct PromotionPolicy {
111 pub rules: Vec<PromotionRule>,
113}
114
115impl PromotionPolicy {
116 pub fn new() -> Self {
118 Self { rules: Vec::new() }
119 }
120
121 pub fn add_rule(&mut self, rule: PromotionRule) {
123 self.rules.push(rule);
124 }
125
126 pub fn should_promote(&self, signal: &AdvisorySignal) -> bool {
128 for rule in &self.rules {
129 if rule.guard_name == signal.guard_name
130 && severity_ord(signal.severity) >= severity_ord(rule.min_severity)
131 {
132 return true;
133 }
134 }
135 false
136 }
137}
138
139fn severity_ord(s: AdvisorySeverity) -> u8 {
141 match s {
142 AdvisorySeverity::Info => 0,
143 AdvisorySeverity::Low => 1,
144 AdvisorySeverity::Medium => 2,
145 AdvisorySeverity::High => 3,
146 AdvisorySeverity::Critical => 4,
147 }
148}
149
150pub struct AdvisoryPipeline {
160 guards: Vec<Box<dyn AdvisoryGuard>>,
161 policy: PromotionPolicy,
162 signals: std::sync::Mutex<Vec<AdvisorySignal>>,
164}
165
166impl AdvisoryPipeline {
167 pub fn new(policy: PromotionPolicy) -> Self {
169 Self {
170 guards: Vec::new(),
171 policy,
172 signals: std::sync::Mutex::new(Vec::new()),
173 }
174 }
175
176 pub fn add(&mut self, guard: Box<dyn AdvisoryGuard>) {
178 self.guards.push(guard);
179 }
180
181 pub fn len(&self) -> usize {
183 self.guards.len()
184 }
185
186 pub fn is_empty(&self) -> bool {
188 self.guards.is_empty()
189 }
190
191 pub fn last_signals(&self) -> Result<Vec<AdvisorySignal>, KernelError> {
193 let signals = self
194 .signals
195 .lock()
196 .map_err(|_| KernelError::Internal("advisory pipeline lock poisoned".to_string()))?;
197 Ok(signals.clone())
198 }
199
200 pub fn last_outputs(&self) -> Result<Vec<GuardOutput>, KernelError> {
202 let signals = self.last_signals()?;
203 Ok(signals.into_iter().map(GuardOutput::Advisory).collect())
204 }
205}
206
207impl Guard for AdvisoryPipeline {
208 fn name(&self) -> &str {
209 "advisory-pipeline"
210 }
211
212 fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
213 let mut collected = Vec::new();
214 let mut should_deny = false;
215
216 for guard in &self.guards {
217 let signals = guard.evaluate(ctx)?;
218 for mut signal in signals {
219 if self.policy.should_promote(&signal) {
220 signal.promoted = true;
221 should_deny = true;
222 }
223 collected.push(signal);
224 }
225 }
226
227 let mut stored = self
229 .signals
230 .lock()
231 .map_err(|_| KernelError::Internal("advisory pipeline lock poisoned".to_string()))?;
232 *stored = collected;
233
234 if should_deny {
235 Ok(Verdict::Deny)
236 } else {
237 Ok(Verdict::Allow)
238 }
239 }
240}
241
242pub struct AnomalyAdvisoryGuard {
252 journal: Arc<chio_http_session::SessionJournal>,
253 invocation_threshold: u64,
255 depth_threshold: u32,
257}
258
259impl AnomalyAdvisoryGuard {
260 pub fn new(
262 journal: Arc<chio_http_session::SessionJournal>,
263 invocation_threshold: u64,
264 depth_threshold: u32,
265 ) -> Self {
266 Self {
267 journal,
268 invocation_threshold,
269 depth_threshold,
270 }
271 }
272}
273
274impl AdvisoryGuard for AnomalyAdvisoryGuard {
275 fn name(&self) -> &str {
276 "anomaly-advisory"
277 }
278
279 fn evaluate(&self, ctx: &GuardContext) -> Result<Vec<AdvisorySignal>, KernelError> {
280 let mut signals = Vec::new();
281
282 let tool_counts = self
283 .journal
284 .tool_counts()
285 .map_err(|e| KernelError::Internal(format!("anomaly advisory journal error: {e}")))?;
286
287 if let Some(count) = tool_counts.get(&ctx.request.tool_name) {
289 if *count >= self.invocation_threshold {
290 signals.push(AdvisorySignal {
291 guard_name: "anomaly-advisory".to_string(),
292 description: format!(
293 "tool '{}' invoked {} times (threshold: {})",
294 ctx.request.tool_name, count, self.invocation_threshold
295 ),
296 severity: if *count >= self.invocation_threshold * 2 {
297 AdvisorySeverity::High
298 } else {
299 AdvisorySeverity::Medium
300 },
301 metadata: Some(serde_json::json!({
302 "tool_name": ctx.request.tool_name,
303 "count": count,
304 "threshold": self.invocation_threshold,
305 })),
306 promoted: false,
307 });
308 }
309 }
310
311 let data_flow = self
313 .journal
314 .data_flow()
315 .map_err(|e| KernelError::Internal(format!("anomaly advisory journal error: {e}")))?;
316
317 if data_flow.max_delegation_depth >= self.depth_threshold {
318 signals.push(AdvisorySignal {
319 guard_name: "anomaly-advisory".to_string(),
320 description: format!(
321 "delegation depth {} exceeds threshold {}",
322 data_flow.max_delegation_depth, self.depth_threshold
323 ),
324 severity: AdvisorySeverity::High,
325 metadata: Some(serde_json::json!({
326 "max_delegation_depth": data_flow.max_delegation_depth,
327 "threshold": self.depth_threshold,
328 })),
329 promoted: false,
330 });
331 }
332
333 Ok(signals)
334 }
335}
336
337pub struct DataTransferAdvisoryGuard {
339 journal: Arc<chio_http_session::SessionJournal>,
340 bytes_threshold: u64,
342}
343
344impl DataTransferAdvisoryGuard {
345 pub fn new(journal: Arc<chio_http_session::SessionJournal>, bytes_threshold: u64) -> Self {
347 Self {
348 journal,
349 bytes_threshold,
350 }
351 }
352}
353
354impl AdvisoryGuard for DataTransferAdvisoryGuard {
355 fn name(&self) -> &str {
356 "data-transfer-advisory"
357 }
358
359 fn evaluate(&self, _ctx: &GuardContext) -> Result<Vec<AdvisorySignal>, KernelError> {
360 let flow = self.journal.data_flow().map_err(|e| {
361 KernelError::Internal(format!("data-transfer advisory journal error: {e}"))
362 })?;
363
364 let total = flow
365 .total_bytes_read
366 .saturating_add(flow.total_bytes_written);
367
368 if total >= self.bytes_threshold {
369 let severity = if total >= self.bytes_threshold.saturating_mul(3) {
370 AdvisorySeverity::Critical
371 } else if total >= self.bytes_threshold.saturating_mul(2) {
372 AdvisorySeverity::High
373 } else {
374 AdvisorySeverity::Medium
375 };
376
377 Ok(vec![AdvisorySignal {
378 guard_name: "data-transfer-advisory".to_string(),
379 description: format!(
380 "cumulative data transfer {} bytes exceeds threshold {} bytes",
381 total, self.bytes_threshold
382 ),
383 severity,
384 metadata: Some(serde_json::json!({
385 "total_bytes": total,
386 "bytes_read": flow.total_bytes_read,
387 "bytes_written": flow.total_bytes_written,
388 "threshold": self.bytes_threshold,
389 })),
390 promoted: false,
391 }])
392 } else {
393 Ok(vec![])
394 }
395 }
396}
397
398#[cfg(test)]
403mod tests {
404 use super::*;
405 use chio_http_session::{RecordParams, SessionJournal};
406
407 fn make_journal(session_id: &str) -> Arc<SessionJournal> {
408 Arc::new(SessionJournal::new(session_id.to_string()))
409 }
410
411 fn record(journal: &SessionJournal, tool: &str, bytes_read: u64, depth: u32) {
412 journal
413 .record(RecordParams {
414 tool_name: tool.to_string(),
415 server_id: "srv".to_string(),
416 agent_id: "agent".to_string(),
417 bytes_read,
418 bytes_written: 0,
419 delegation_depth: depth,
420 allowed: true,
421 })
422 .expect("record");
423 }
424
425 fn make_ctx() -> (
426 chio_kernel::ToolCallRequest,
427 chio_core::capability::ChioScope,
428 String,
429 String,
430 ) {
431 let kp = chio_core::crypto::Keypair::generate();
432 let scope = chio_core::capability::ChioScope::default();
433 let agent_id = kp.public_key().to_hex();
434 let server_id = "srv-test".to_string();
435
436 let cap_body = chio_core::capability::CapabilityTokenBody {
437 id: "cap-test".to_string(),
438 issuer: kp.public_key(),
439 subject: kp.public_key(),
440 scope: scope.clone(),
441 issued_at: 0,
442 expires_at: u64::MAX,
443 delegation_chain: vec![],
444 };
445 let cap = chio_core::capability::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
446
447 let request = chio_kernel::ToolCallRequest {
448 request_id: "req-test".to_string(),
449 capability: cap,
450 tool_name: "read_file".to_string(),
451 server_id: server_id.clone(),
452 agent_id: agent_id.clone(),
453 arguments: serde_json::json!({"path": "/app/src/main.rs"}),
454 dpop_proof: None,
455 governed_intent: None,
456 approval_token: None,
457 model_metadata: None,
458 federated_origin_kernel_id: None,
459 };
460
461 (request, scope, agent_id, server_id)
462 }
463
464 fn guard_ctx<'a>(
465 request: &'a chio_kernel::ToolCallRequest,
466 scope: &'a chio_core::capability::ChioScope,
467 agent_id: &'a String,
468 server_id: &'a String,
469 ) -> chio_kernel::GuardContext<'a> {
470 chio_kernel::GuardContext {
471 request,
472 scope,
473 agent_id,
474 server_id,
475 session_filesystem_roots: None,
476 matched_grant_index: None,
477 }
478 }
479
480 #[test]
483 fn advisory_signal_serde_roundtrip() {
484 let signal = AdvisorySignal {
485 guard_name: "test-guard".to_string(),
486 description: "test observation".to_string(),
487 severity: AdvisorySeverity::Medium,
488 metadata: Some(serde_json::json!({"key": "value"})),
489 promoted: false,
490 };
491
492 let json = serde_json::to_string(&signal).expect("serialize");
493 let restored: AdvisorySignal = serde_json::from_str(&json).expect("deserialize");
494 assert_eq!(restored.guard_name, "test-guard");
495 assert_eq!(restored.severity, AdvisorySeverity::Medium);
496 assert!(!restored.promoted);
497 }
498
499 #[test]
500 fn guard_output_distinguishes_types() {
501 let det = GuardOutput::Deterministic {
502 guard_name: "forbidden-path".to_string(),
503 verdict: false,
504 details: Some("blocked".to_string()),
505 };
506 let adv = GuardOutput::Advisory(AdvisorySignal {
507 guard_name: "anomaly".to_string(),
508 description: "unusual pattern".to_string(),
509 severity: AdvisorySeverity::Low,
510 metadata: None,
511 promoted: false,
512 });
513
514 let det_json = serde_json::to_string(&det).expect("serialize det");
515 let adv_json = serde_json::to_string(&adv).expect("serialize adv");
516
517 assert!(det_json.contains("\"type\":\"deterministic\""));
518 assert!(adv_json.contains("\"type\":\"advisory\""));
519 }
520
521 #[test]
524 fn promotion_policy_empty_never_promotes() {
525 let policy = PromotionPolicy::new();
526 let signal = AdvisorySignal {
527 guard_name: "test".to_string(),
528 description: "test".to_string(),
529 severity: AdvisorySeverity::Critical,
530 metadata: None,
531 promoted: false,
532 };
533 assert!(!policy.should_promote(&signal));
534 }
535
536 #[test]
537 fn promotion_policy_promotes_matching_signal() {
538 let mut policy = PromotionPolicy::new();
539 policy.add_rule(PromotionRule {
540 guard_name: "anomaly-advisory".to_string(),
541 min_severity: AdvisorySeverity::High,
542 });
543
544 let high_signal = AdvisorySignal {
545 guard_name: "anomaly-advisory".to_string(),
546 description: "test".to_string(),
547 severity: AdvisorySeverity::High,
548 metadata: None,
549 promoted: false,
550 };
551 assert!(policy.should_promote(&high_signal));
552
553 let critical_signal = AdvisorySignal {
554 guard_name: "anomaly-advisory".to_string(),
555 description: "test".to_string(),
556 severity: AdvisorySeverity::Critical,
557 metadata: None,
558 promoted: false,
559 };
560 assert!(policy.should_promote(&critical_signal));
561 }
562
563 #[test]
564 fn promotion_policy_does_not_promote_below_threshold() {
565 let mut policy = PromotionPolicy::new();
566 policy.add_rule(PromotionRule {
567 guard_name: "anomaly-advisory".to_string(),
568 min_severity: AdvisorySeverity::High,
569 });
570
571 let low_signal = AdvisorySignal {
572 guard_name: "anomaly-advisory".to_string(),
573 description: "test".to_string(),
574 severity: AdvisorySeverity::Medium,
575 metadata: None,
576 promoted: false,
577 };
578 assert!(!policy.should_promote(&low_signal));
579 }
580
581 #[test]
582 fn promotion_policy_does_not_promote_wrong_guard() {
583 let mut policy = PromotionPolicy::new();
584 policy.add_rule(PromotionRule {
585 guard_name: "anomaly-advisory".to_string(),
586 min_severity: AdvisorySeverity::Low,
587 });
588
589 let signal = AdvisorySignal {
590 guard_name: "other-guard".to_string(),
591 description: "test".to_string(),
592 severity: AdvisorySeverity::Critical,
593 metadata: None,
594 promoted: false,
595 };
596 assert!(!policy.should_promote(&signal));
597 }
598
599 struct NoOpAdvisory;
602 impl AdvisoryGuard for NoOpAdvisory {
603 fn name(&self) -> &str {
604 "no-op"
605 }
606 fn evaluate(&self, _ctx: &GuardContext) -> Result<Vec<AdvisorySignal>, KernelError> {
607 Ok(vec![])
608 }
609 }
610
611 struct AlwaysSignal {
612 guard_name: String,
613 severity: AdvisorySeverity,
614 }
615 impl AdvisoryGuard for AlwaysSignal {
616 fn name(&self) -> &str {
617 &self.guard_name
618 }
619 fn evaluate(&self, _ctx: &GuardContext) -> Result<Vec<AdvisorySignal>, KernelError> {
620 Ok(vec![AdvisorySignal {
621 guard_name: self.guard_name.clone(),
622 description: "always signals".to_string(),
623 severity: self.severity,
624 metadata: None,
625 promoted: false,
626 }])
627 }
628 }
629
630 #[test]
631 fn advisory_pipeline_allows_without_promotion() {
632 let mut pipeline = AdvisoryPipeline::new(PromotionPolicy::new());
633 pipeline.add(Box::new(AlwaysSignal {
634 guard_name: "test-signal".to_string(),
635 severity: AdvisorySeverity::High,
636 }));
637
638 let (request, scope, agent_id, server_id) = make_ctx();
639 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
640 let result = pipeline.evaluate(&ctx).expect("ok");
641 assert_eq!(result, Verdict::Allow);
642
643 let signals = pipeline.last_signals().expect("signals");
644 assert_eq!(signals.len(), 1);
645 assert!(!signals[0].promoted);
646 }
647
648 #[test]
649 fn advisory_pipeline_denies_with_promotion() {
650 let mut policy = PromotionPolicy::new();
651 policy.add_rule(PromotionRule {
652 guard_name: "test-signal".to_string(),
653 min_severity: AdvisorySeverity::High,
654 });
655
656 let mut pipeline = AdvisoryPipeline::new(policy);
657 pipeline.add(Box::new(AlwaysSignal {
658 guard_name: "test-signal".to_string(),
659 severity: AdvisorySeverity::High,
660 }));
661
662 let (request, scope, agent_id, server_id) = make_ctx();
663 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
664 let result = pipeline.evaluate(&ctx).expect("ok");
665 assert_eq!(result, Verdict::Deny);
666
667 let signals = pipeline.last_signals().expect("signals");
668 assert_eq!(signals.len(), 1);
669 assert!(signals[0].promoted);
670 }
671
672 #[test]
673 fn advisory_pipeline_no_guards_allows() {
674 let pipeline = AdvisoryPipeline::new(PromotionPolicy::new());
675
676 let (request, scope, agent_id, server_id) = make_ctx();
677 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
678 assert_eq!(pipeline.evaluate(&ctx).expect("ok"), Verdict::Allow);
679 }
680
681 #[test]
682 fn advisory_pipeline_collects_multiple_signals() {
683 let mut pipeline = AdvisoryPipeline::new(PromotionPolicy::new());
684 pipeline.add(Box::new(AlwaysSignal {
685 guard_name: "signal-a".to_string(),
686 severity: AdvisorySeverity::Low,
687 }));
688 pipeline.add(Box::new(AlwaysSignal {
689 guard_name: "signal-b".to_string(),
690 severity: AdvisorySeverity::Medium,
691 }));
692
693 let (request, scope, agent_id, server_id) = make_ctx();
694 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
695 pipeline.evaluate(&ctx).expect("ok");
696
697 let signals = pipeline.last_signals().expect("signals");
698 assert_eq!(signals.len(), 2);
699 assert_eq!(signals[0].guard_name, "signal-a");
700 assert_eq!(signals[1].guard_name, "signal-b");
701 }
702
703 #[test]
704 fn advisory_pipeline_guard_output_types() {
705 let mut pipeline = AdvisoryPipeline::new(PromotionPolicy::new());
706 pipeline.add(Box::new(AlwaysSignal {
707 guard_name: "test".to_string(),
708 severity: AdvisorySeverity::Info,
709 }));
710
711 let (request, scope, agent_id, server_id) = make_ctx();
712 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
713 pipeline.evaluate(&ctx).expect("ok");
714
715 let outputs = pipeline.last_outputs().expect("outputs");
716 assert_eq!(outputs.len(), 1);
717 assert!(matches!(outputs[0], GuardOutput::Advisory(_)));
718 }
719
720 #[test]
723 fn anomaly_advisory_no_signal_below_threshold() {
724 let journal = make_journal("sess-anomaly-1");
725 for _ in 0..4 {
726 record(&journal, "read_file", 100, 0);
727 }
728
729 let guard = AnomalyAdvisoryGuard::new(journal, 10, 5);
730 let (request, scope, agent_id, server_id) = make_ctx();
731 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
732 let signals = guard.evaluate(&ctx).expect("ok");
733 assert!(signals.is_empty());
734 }
735
736 #[test]
737 fn anomaly_advisory_signals_excessive_invocations() {
738 let journal = make_journal("sess-anomaly-2");
739 for _ in 0..10 {
740 record(&journal, "read_file", 100, 0);
741 }
742
743 let guard = AnomalyAdvisoryGuard::new(journal, 5, 10);
744 let (request, scope, agent_id, server_id) = make_ctx();
745 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
746 let signals = guard.evaluate(&ctx).expect("ok");
747 assert!(!signals.is_empty());
748 assert!(signals.iter().any(|s| s.description.contains("read_file")));
749 }
750
751 #[test]
752 fn anomaly_advisory_signals_deep_delegation() {
753 let journal = make_journal("sess-anomaly-3");
754 record(&journal, "read_file", 100, 8);
755
756 let guard = AnomalyAdvisoryGuard::new(journal, 100, 5);
757 let (request, scope, agent_id, server_id) = make_ctx();
758 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
759 let signals = guard.evaluate(&ctx).expect("ok");
760 assert!(!signals.is_empty());
761 assert!(signals
762 .iter()
763 .any(|s| s.description.contains("delegation depth")));
764 }
765
766 #[test]
769 fn data_transfer_advisory_no_signal_below_threshold() {
770 let journal = make_journal("sess-transfer-1");
771 record(&journal, "read_file", 100, 0);
772
773 let guard = DataTransferAdvisoryGuard::new(journal, 10_000);
774 let (request, scope, agent_id, server_id) = make_ctx();
775 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
776 let signals = guard.evaluate(&ctx).expect("ok");
777 assert!(signals.is_empty());
778 }
779
780 #[test]
781 fn data_transfer_advisory_signals_above_threshold() {
782 let journal = make_journal("sess-transfer-2");
783 for _ in 0..20 {
784 record(&journal, "read_file", 1000, 0);
785 }
786
787 let guard = DataTransferAdvisoryGuard::new(journal, 10_000);
788 let (request, scope, agent_id, server_id) = make_ctx();
789 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
790 let signals = guard.evaluate(&ctx).expect("ok");
791 assert_eq!(signals.len(), 1);
792 assert!(signals[0].description.contains("data transfer"));
793 }
794
795 #[test]
796 fn data_transfer_advisory_escalating_severity() {
797 let journal = make_journal("sess-transfer-3");
798 for _ in 0..30 {
800 record(&journal, "read_file", 1000, 0);
801 }
802
803 let guard = DataTransferAdvisoryGuard::new(journal, 10_000);
804 let (request, scope, agent_id, server_id) = make_ctx();
805 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
806 let signals = guard.evaluate(&ctx).expect("ok");
807 assert_eq!(signals.len(), 1);
808 assert_eq!(signals[0].severity, AdvisorySeverity::Critical);
809 }
810
811 #[test]
814 fn promoted_anomaly_denies_request() {
815 let journal = make_journal("sess-promote");
816 for _ in 0..20 {
817 record(&journal, "read_file", 100, 0);
818 }
819
820 let mut policy = PromotionPolicy::new();
821 policy.add_rule(PromotionRule {
822 guard_name: "anomaly-advisory".to_string(),
823 min_severity: AdvisorySeverity::Medium,
824 });
825
826 let mut pipeline = AdvisoryPipeline::new(policy);
827 pipeline.add(Box::new(AnomalyAdvisoryGuard::new(journal, 5, 10)));
828
829 let (request, scope, agent_id, server_id) = make_ctx();
830 let ctx = guard_ctx(&request, &scope, &agent_id, &server_id);
831 let result = pipeline.evaluate(&ctx).expect("ok");
832 assert_eq!(result, Verdict::Deny, "promoted advisory should deny");
833
834 let signals = pipeline.last_signals().expect("signals");
835 assert!(signals.iter().any(|s| s.promoted));
836 }
837
838 #[test]
839 fn len_and_is_empty() {
840 let mut pipeline = AdvisoryPipeline::new(PromotionPolicy::new());
841 assert!(pipeline.is_empty());
842 assert_eq!(pipeline.len(), 0);
843 pipeline.add(Box::new(NoOpAdvisory));
844 assert!(!pipeline.is_empty());
845 assert_eq!(pipeline.len(), 1);
846 }
847
848 #[test]
849 fn promotion_policy_serde_roundtrip() {
850 let mut policy = PromotionPolicy::new();
851 policy.add_rule(PromotionRule {
852 guard_name: "anomaly-advisory".to_string(),
853 min_severity: AdvisorySeverity::High,
854 });
855
856 let json = serde_json::to_string(&policy).expect("serialize");
857 let restored: PromotionPolicy = serde_json::from_str(&json).expect("deserialize");
858 assert_eq!(restored.rules.len(), 1);
859 assert_eq!(restored.rules[0].guard_name, "anomaly-advisory");
860 assert_eq!(restored.rules[0].min_severity, AdvisorySeverity::High);
861 }
862}