1use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Deserializer, Serialize};
10use termivar_core::{
11 EntityId, EvidenceId, EvidenceValue, HttpEvidencePredicate, Outcome, OutcomeStatus,
12 ReasoningModelError, VerificationStage,
13};
14use thiserror::Error;
15
16use crate::{Expression, ExpressionEvaluation, KnowledgeLayer, KnowledgeSnapshot, RuleEngineError};
17
18#[derive(Debug, Error)]
20#[non_exhaustive]
21pub enum AdaptivePipelineError {
22 #[error("{field} must not be empty")]
24 EmptyValue { field: &'static str },
25
26 #[error("outcome selector must contain at least one status")]
28 EmptyStatuses,
29
30 #[error("outcome selector must contain at least one verification stage")]
32 EmptyStages,
33
34 #[error("adaptation rule {rule_id} must allow at least one application")]
36 ZeroRuleApplications { rule_id: String },
37
38 #[error("scheduled action id must not be empty")]
40 EmptyScheduledAction,
41
42 #[error("throttle delay must be greater than zero")]
44 ZeroThrottleDelay,
45
46 #[error("adaptation rule identity {id} already has a different definition")]
48 RuleIdentityConflict { id: String },
49
50 #[error("adaptive outcome subject {expected} does not match snapshot subject {actual}")]
52 SnapshotSubjectMismatch {
53 expected: EntityId,
55 actual: EntityId,
57 },
58
59 #[error("adaptive snapshot is missing outcome evidence {evidence_id}")]
61 MissingOutcomeEvidence { evidence_id: EvidenceId },
62
63 #[error("matched adaptation rule {rule_id} did not cite any evidence")]
65 MissingContributingEvidence { rule_id: String },
66
67 #[error("maximum adaptive transitions must be greater than zero")]
69 ZeroTransitions,
70
71 #[error("maximum action schedules must be greater than zero")]
73 ZeroActionSchedules,
74
75 #[error("adaptive decision sequence {actual} does not match ledger sequence {expected}")]
77 DecisionSequenceMismatch { expected: u32, actual: u32 },
78
79 #[error("adaptive ledger counter overflowed")]
81 CounterOverflow,
82
83 #[error("invalid adaptive ledger entry for {field}")]
85 InvalidLedgerEntry { field: &'static str },
86
87 #[error(transparent)]
89 Rule(#[from] RuleEngineError),
90
91 #[error(transparent)]
93 Reasoning(#[from] ReasoningModelError),
94}
95
96fn non_empty(
97 value: impl Into<String>,
98 field: &'static str,
99) -> Result<String, AdaptivePipelineError> {
100 let value = value.into();
101 if value.trim().is_empty() {
102 return Err(AdaptivePipelineError::EmptyValue { field });
103 }
104 Ok(value)
105}
106
107fn is_false(value: &bool) -> bool {
108 !*value
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113pub struct OutcomeSelector {
114 statuses: BTreeSet<OutcomeStatus>,
115 stages: BTreeSet<VerificationStage>,
116}
117
118impl OutcomeSelector {
119 pub fn new(
121 statuses: BTreeSet<OutcomeStatus>,
122 stages: BTreeSet<VerificationStage>,
123 ) -> Result<Self, AdaptivePipelineError> {
124 if statuses.is_empty() {
125 return Err(AdaptivePipelineError::EmptyStatuses);
126 }
127 if stages.is_empty() {
128 return Err(AdaptivePipelineError::EmptyStages);
129 }
130 Ok(Self { statuses, stages })
131 }
132
133 pub fn any_stage(statuses: BTreeSet<OutcomeStatus>) -> Result<Self, AdaptivePipelineError> {
135 Self::new(
136 statuses,
137 BTreeSet::from([VerificationStage::Passive, VerificationStage::Active]),
138 )
139 }
140
141 pub fn statuses(&self) -> &BTreeSet<OutcomeStatus> {
143 &self.statuses
144 }
145
146 pub fn stages(&self) -> &BTreeSet<VerificationStage> {
148 &self.stages
149 }
150
151 pub fn matches(&self, outcome: &Outcome) -> bool {
153 self.statuses.contains(&outcome.status()) && self.stages.contains(&outcome.stage())
154 }
155}
156
157impl<'de> Deserialize<'de> for OutcomeSelector {
158 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
159 where
160 D: Deserializer<'de>,
161 {
162 #[derive(Deserialize)]
163 #[serde(deny_unknown_fields)]
164 struct WireSelector {
165 statuses: BTreeSet<OutcomeStatus>,
166 stages: BTreeSet<VerificationStage>,
167 }
168
169 let wire = WireSelector::deserialize(deserializer)?;
170 Self::new(wire.statuses, wire.stages).map_err(serde::de::Error::custom)
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
176#[serde(tag = "directive", rename_all = "snake_case")]
177#[non_exhaustive]
178pub enum PipelineDirective {
179 Complete,
181 ScheduleAction {
183 action_id: String,
185 },
186 Replan {
188 suppress_current_action: bool,
190 },
191 Throttle {
193 delay_ms: u64,
195 retry_current_action: bool,
197 },
198 AwaitActiveVerification,
200 AwaitHumanReview,
202 Halt,
204}
205
206impl<'de> Deserialize<'de> for PipelineDirective {
207 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
208 where
209 D: Deserializer<'de>,
210 {
211 #[derive(Deserialize)]
212 #[serde(tag = "directive", rename_all = "snake_case", deny_unknown_fields)]
213 enum WireDirective {
214 Complete {},
215 ScheduleAction {
216 action_id: String,
217 },
218 Replan {
219 suppress_current_action: bool,
220 },
221 Throttle {
222 delay_ms: u64,
223 retry_current_action: bool,
224 },
225 AwaitActiveVerification {},
226 AwaitHumanReview {},
227 Halt {},
228 }
229
230 let directive = match WireDirective::deserialize(deserializer)? {
231 WireDirective::Complete {} => Self::Complete,
232 WireDirective::ScheduleAction { action_id } => Self::ScheduleAction { action_id },
233 WireDirective::Replan {
234 suppress_current_action,
235 } => Self::Replan {
236 suppress_current_action,
237 },
238 WireDirective::Throttle {
239 delay_ms,
240 retry_current_action,
241 } => Self::Throttle {
242 delay_ms,
243 retry_current_action,
244 },
245 WireDirective::AwaitActiveVerification {} => Self::AwaitActiveVerification,
246 WireDirective::AwaitHumanReview {} => Self::AwaitHumanReview,
247 WireDirective::Halt {} => Self::Halt,
248 };
249 directive.validate().map_err(serde::de::Error::custom)?;
250 Ok(directive)
251 }
252}
253
254impl PipelineDirective {
255 fn validate(&self) -> Result<(), AdaptivePipelineError> {
256 match self {
257 Self::ScheduleAction { action_id } if action_id.trim().is_empty() => {
258 Err(AdaptivePipelineError::EmptyScheduledAction)
259 },
260 Self::Throttle { delay_ms: 0, .. } => Err(AdaptivePipelineError::ZeroThrottleDelay),
261 Self::Complete
262 | Self::ScheduleAction { .. }
263 | Self::Replan { .. }
264 | Self::Throttle { .. }
265 | Self::AwaitActiveVerification
266 | Self::AwaitHumanReview
267 | Self::Halt => Ok(()),
268 }
269 }
270
271 pub fn scheduled_action_id(&self) -> Option<&str> {
273 match self {
274 Self::ScheduleAction { action_id } => Some(action_id),
275 _ => None,
276 }
277 }
278
279 fn repeated_action_id<'a>(&'a self, source_action_id: &'a str) -> Option<&'a str> {
280 match self {
281 Self::ScheduleAction { action_id } => Some(action_id),
282 Self::Throttle {
283 retry_current_action: true,
284 ..
285 } => Some(source_action_id),
286 _ => None,
287 }
288 }
289}
290
291#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
293pub struct AdaptationRule {
294 id: String,
295 selector: OutcomeSelector,
296 priority: u16,
297 condition: Option<Expression>,
298 #[serde(skip_serializing_if = "is_false")]
299 condition_policy_guard: bool,
300 directive: PipelineDirective,
301 rationale: String,
302 max_applications: u16,
303}
304
305impl AdaptationRule {
306 #[allow(clippy::too_many_arguments)]
308 pub fn new(
309 id: impl Into<String>,
310 selector: OutcomeSelector,
311 priority: u16,
312 condition: Option<Expression>,
313 directive: PipelineDirective,
314 rationale: impl Into<String>,
315 max_applications: u16,
316 ) -> Result<Self, AdaptivePipelineError> {
317 let id = non_empty(id, "adaptation rule id")?;
318 if max_applications == 0 {
319 return Err(AdaptivePipelineError::ZeroRuleApplications { rule_id: id });
320 }
321 directive.validate()?;
322 Ok(Self {
323 id,
324 selector,
325 priority,
326 condition_policy_guard: condition.is_some(),
327 condition,
328 directive,
329 rationale: non_empty(rationale, "adaptation rationale")?,
330 max_applications,
331 })
332 }
333
334 pub fn id(&self) -> &str {
336 &self.id
337 }
338
339 pub fn selector(&self) -> &OutcomeSelector {
341 &self.selector
342 }
343
344 pub fn priority(&self) -> u16 {
346 self.priority
347 }
348
349 pub fn condition(&self) -> Option<&Expression> {
351 self.condition.as_ref()
352 }
353
354 pub fn directive(&self) -> &PipelineDirective {
356 &self.directive
357 }
358
359 pub fn rationale(&self) -> &str {
361 &self.rationale
362 }
363
364 pub fn max_applications(&self) -> u16 {
366 self.max_applications
367 }
368}
369
370impl<'de> Deserialize<'de> for AdaptationRule {
371 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
372 where
373 D: Deserializer<'de>,
374 {
375 #[derive(Deserialize)]
376 #[serde(transparent)]
377 struct RequiredCondition(Option<Expression>);
378
379 #[derive(Deserialize)]
380 #[serde(deny_unknown_fields)]
381 struct WireRule {
382 id: String,
383 selector: OutcomeSelector,
384 priority: u16,
385 condition: RequiredCondition,
386 #[serde(default)]
387 condition_policy_guard: Option<bool>,
388 directive: PipelineDirective,
389 rationale: String,
390 max_applications: u16,
391 }
392
393 let wire = WireRule::deserialize(deserializer)?;
394 if let Some(condition_policy_guard) = wire.condition_policy_guard {
395 if !condition_policy_guard || wire.condition.0.is_none() {
396 return Err(serde::de::Error::custom(
397 "adaptation rule condition guard is inconsistent",
398 ));
399 }
400 }
401 Self::new(
402 wire.id,
403 wire.selector,
404 wire.priority,
405 wire.condition.0,
406 wire.directive,
407 wire.rationale,
408 wire.max_applications,
409 )
410 .map_err(serde::de::Error::custom)
411 }
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
416#[serde(rename_all = "snake_case")]
417#[non_exhaustive]
418pub enum AdaptiveRuleWrite {
419 Inserted,
421 Unchanged,
423}
424
425#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
427pub struct AdaptationLimits {
428 max_transitions: u32,
429 max_action_schedules: u16,
430}
431
432impl AdaptationLimits {
433 pub fn new(
435 max_transitions: u32,
436 max_action_schedules: u16,
437 ) -> Result<Self, AdaptivePipelineError> {
438 if max_transitions == 0 {
439 return Err(AdaptivePipelineError::ZeroTransitions);
440 }
441 if max_action_schedules == 0 {
442 return Err(AdaptivePipelineError::ZeroActionSchedules);
443 }
444 Ok(Self {
445 max_transitions,
446 max_action_schedules,
447 })
448 }
449
450 pub fn max_transitions(self) -> u32 {
452 self.max_transitions
453 }
454
455 pub fn max_action_schedules(self) -> u16 {
457 self.max_action_schedules
458 }
459}
460
461impl Default for AdaptationLimits {
462 fn default() -> Self {
463 Self {
464 max_transitions: 64,
465 max_action_schedules: 3,
466 }
467 }
468}
469
470impl<'de> Deserialize<'de> for AdaptationLimits {
471 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
472 where
473 D: Deserializer<'de>,
474 {
475 #[derive(Deserialize)]
476 struct WireLimits {
477 max_transitions: u32,
478 max_action_schedules: u16,
479 }
480
481 let wire = WireLimits::deserialize(deserializer)?;
482 Self::new(wire.max_transitions, wire.max_action_schedules).map_err(serde::de::Error::custom)
483 }
484}
485
486#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
488pub struct AdaptationLedger {
489 transitions: u32,
490 rule_applications: BTreeMap<String, u16>,
491 action_schedules: BTreeMap<String, u16>,
492 suppressed_actions: BTreeSet<String>,
493}
494
495impl AdaptationLedger {
496 pub fn new() -> Self {
498 Self::default()
499 }
500
501 pub fn transitions(&self) -> u32 {
503 self.transitions
504 }
505
506 pub fn rule_applications(&self, rule_id: &str) -> u16 {
508 self.rule_applications.get(rule_id).copied().unwrap_or(0)
509 }
510
511 pub fn action_schedules(&self, action_id: &str) -> u16 {
513 self.action_schedules.get(action_id).copied().unwrap_or(0)
514 }
515
516 pub fn suppressed_actions(&self) -> &BTreeSet<String> {
518 &self.suppressed_actions
519 }
520
521 pub fn record(&mut self, decision: &AdaptiveDecision) -> Result<(), AdaptivePipelineError> {
523 if decision.sequence != self.transitions {
524 return Err(AdaptivePipelineError::DecisionSequenceMismatch {
525 expected: self.transitions,
526 actual: decision.sequence,
527 });
528 }
529 if decision.transition_limit_reached {
530 return Ok(());
531 }
532 let mut candidate = self.clone();
533 candidate.transitions = candidate
534 .transitions
535 .checked_add(1)
536 .ok_or(AdaptivePipelineError::CounterOverflow)?;
537 if let Some(rule_id) = decision.selected_rule_id() {
538 increment(&mut candidate.rule_applications, rule_id)?;
539 }
540 if let Some(action_id) = decision.directive().scheduled_action_id() {
541 increment(&mut candidate.action_schedules, action_id)?;
542 }
543 if matches!(
544 decision.directive(),
545 PipelineDirective::Replan {
546 suppress_current_action: true
547 }
548 ) {
549 candidate
550 .suppressed_actions
551 .insert(decision.source_action_id.clone());
552 }
553 *self = candidate;
554 Ok(())
555 }
556}
557
558impl<'de> Deserialize<'de> for AdaptationLedger {
559 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
560 where
561 D: Deserializer<'de>,
562 {
563 #[derive(Deserialize)]
564 struct WireLedger {
565 transitions: u32,
566 rule_applications: BTreeMap<String, u16>,
567 action_schedules: BTreeMap<String, u16>,
568 suppressed_actions: BTreeSet<String>,
569 }
570
571 let wire = WireLedger::deserialize(deserializer)?;
572 validate_counter_map(&wire.rule_applications, "rule applications")
573 .map_err(serde::de::Error::custom)?;
574 validate_counter_map(&wire.action_schedules, "action schedules")
575 .map_err(serde::de::Error::custom)?;
576 let transitions = u64::from(wire.transitions);
577 let rule_applications: u64 = wire
578 .rule_applications
579 .values()
580 .map(|count| u64::from(*count))
581 .sum();
582 let action_schedules: u64 = wire
583 .action_schedules
584 .values()
585 .map(|count| u64::from(*count))
586 .sum();
587 if rule_applications > transitions
588 || action_schedules > transitions
589 || wire.suppressed_actions.len() as u64 > transitions
590 {
591 return Err(serde::de::Error::custom(
592 AdaptivePipelineError::InvalidLedgerEntry {
593 field: "counters exceed transitions",
594 },
595 ));
596 }
597 if wire
598 .suppressed_actions
599 .iter()
600 .any(|action_id| action_id.trim().is_empty())
601 {
602 return Err(serde::de::Error::custom(
603 AdaptivePipelineError::InvalidLedgerEntry {
604 field: "suppressed actions",
605 },
606 ));
607 }
608 Ok(Self {
609 transitions: wire.transitions,
610 rule_applications: wire.rule_applications,
611 action_schedules: wire.action_schedules,
612 suppressed_actions: wire.suppressed_actions,
613 })
614 }
615}
616
617fn validate_counter_map(
618 counters: &BTreeMap<String, u16>,
619 field: &'static str,
620) -> Result<(), AdaptivePipelineError> {
621 if counters
622 .iter()
623 .any(|(identity, count)| identity.trim().is_empty() || *count == 0)
624 {
625 return Err(AdaptivePipelineError::InvalidLedgerEntry { field });
626 }
627 Ok(())
628}
629
630fn increment(
631 counters: &mut BTreeMap<String, u16>,
632 identity: &str,
633) -> Result<(), AdaptivePipelineError> {
634 let next = counters
635 .get(identity)
636 .copied()
637 .unwrap_or(0)
638 .checked_add(1)
639 .ok_or(AdaptivePipelineError::CounterOverflow)?;
640 counters.insert(identity.to_owned(), next);
641 Ok(())
642}
643
644#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
646pub struct AdaptationRuleEvaluation {
647 rule_id: String,
648 selector_matched: bool,
649 condition: Option<ExpressionEvaluation>,
650 outcome_evidence_matched: bool,
651 rule_limit_exhausted: bool,
652 action_limit_exhausted: bool,
653 policy_suppressed: bool,
654 eligible: bool,
655 selected: bool,
656}
657
658impl AdaptationRuleEvaluation {
659 pub fn rule_id(&self) -> &str {
661 &self.rule_id
662 }
663
664 pub fn selector_matched(&self) -> bool {
666 self.selector_matched
667 }
668
669 pub fn condition(&self) -> Option<&ExpressionEvaluation> {
671 self.condition.as_ref()
672 }
673
674 pub fn outcome_evidence_matched(&self) -> bool {
676 self.outcome_evidence_matched
677 }
678
679 pub fn rule_limit_exhausted(&self) -> bool {
681 self.rule_limit_exhausted
682 }
683
684 pub fn action_limit_exhausted(&self) -> bool {
686 self.action_limit_exhausted
687 }
688
689 pub fn policy_suppressed(&self) -> bool {
691 self.policy_suppressed
692 }
693
694 pub fn eligible(&self) -> bool {
696 self.eligible
697 }
698
699 pub fn selected(&self) -> bool {
701 self.selected
702 }
703}
704
705#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
707pub struct AdaptiveDecision {
708 sequence: u32,
709 subject: EntityId,
710 case_id: String,
711 source_action_id: String,
712 outcome_status: OutcomeStatus,
713 outcome_stage: VerificationStage,
714 selected_rule_id: Option<String>,
715 directive: PipelineDirective,
716 rationale: String,
717 evaluations: Vec<AdaptationRuleEvaluation>,
718 transition_limit_reached: bool,
719}
720
721impl AdaptiveDecision {
722 pub fn sequence(&self) -> u32 {
724 self.sequence
725 }
726
727 pub fn subject(&self) -> &EntityId {
729 &self.subject
730 }
731
732 pub fn case_id(&self) -> &str {
734 &self.case_id
735 }
736
737 pub fn source_action_id(&self) -> &str {
739 &self.source_action_id
740 }
741
742 pub fn outcome_status(&self) -> OutcomeStatus {
744 self.outcome_status
745 }
746
747 pub fn outcome_stage(&self) -> VerificationStage {
749 self.outcome_stage
750 }
751
752 pub fn selected_rule_id(&self) -> Option<&str> {
754 self.selected_rule_id.as_deref()
755 }
756
757 pub fn directive(&self) -> &PipelineDirective {
759 &self.directive
760 }
761
762 pub fn rationale(&self) -> &str {
764 &self.rationale
765 }
766
767 pub fn evaluations(&self) -> &[AdaptationRuleEvaluation] {
769 &self.evaluations
770 }
771
772 pub fn transition_limit_reached(&self) -> bool {
774 self.transition_limit_reached
775 }
776}
777
778#[derive(Debug, Clone, Default)]
790pub struct AdaptivePipeline {
791 rules: BTreeMap<String, AdaptationRule>,
792}
793
794impl AdaptivePipeline {
795 pub fn new() -> Self {
797 Self::default()
798 }
799
800 pub fn with_standard_policies() -> Result<Self, AdaptivePipelineError> {
812 let mut pipeline = Self::new();
813 let unresolved = OutcomeSelector::any_stage(BTreeSet::from([
814 OutcomeStatus::Blocked,
815 OutcomeStatus::Unknown,
816 OutcomeStatus::NeedsReview,
817 ]))?;
818 let status = HttpEvidencePredicate::RESPONSE_STATUS.into_knowledge();
819 pipeline.register(AdaptationRule::new(
820 "http.429.throttle",
821 unresolved.clone(),
822 300,
823 Some(Expression::equals(
824 KnowledgeLayer::Evidence,
825 status.clone(),
826 EvidenceValue::Unsigned(429),
827 )),
828 PipelineDirective::Throttle {
829 delay_ms: 2_000,
830 retry_current_action: true,
831 },
832 "rate limiting requires backpressure before retry",
833 3,
834 )?)?;
835 pipeline.register(AdaptationRule::new(
836 "http.403.bypass",
837 unresolved.clone(),
838 200,
839 Some(Expression::equals(
840 KnowledgeLayer::Evidence,
841 status.clone(),
842 EvidenceValue::Unsigned(403),
843 )),
844 PipelineDirective::ScheduleAction {
845 action_id: "http.403-bypass".into(),
846 },
847 "access control response requires a dedicated bypass action",
848 2,
849 )?)?;
850 pipeline.register(AdaptationRule::new(
851 "http.404.enumerate",
852 unresolved,
853 100,
854 Some(Expression::equals(
855 KnowledgeLayer::Evidence,
856 status,
857 EvidenceValue::Unsigned(404),
858 )),
859 PipelineDirective::ScheduleAction {
860 action_id: "http.enumeration".into(),
861 },
862 "missing resource response redirects discovery to enumeration",
863 1,
864 )?)?;
865 Ok(pipeline)
866 }
867
868 pub fn register(
870 &mut self,
871 rule: AdaptationRule,
872 ) -> Result<AdaptiveRuleWrite, AdaptivePipelineError> {
873 if let Some(existing) = self.rules.get(rule.id()) {
874 return if existing == &rule {
875 Ok(AdaptiveRuleWrite::Unchanged)
876 } else {
877 Err(AdaptivePipelineError::RuleIdentityConflict {
878 id: rule.id.clone(),
879 })
880 };
881 }
882 self.rules.insert(rule.id.clone(), rule);
883 Ok(AdaptiveRuleWrite::Inserted)
884 }
885
886 pub fn len(&self) -> usize {
888 self.rules.len()
889 }
890
891 pub fn is_empty(&self) -> bool {
893 self.rules.is_empty()
894 }
895
896 pub fn decide(
898 &self,
899 outcome: &Outcome,
900 snapshot: &KnowledgeSnapshot,
901 ledger: &AdaptationLedger,
902 limits: AdaptationLimits,
903 ) -> Result<AdaptiveDecision, AdaptivePipelineError> {
904 self.decide_with_suppressed_actions(outcome, snapshot, ledger, limits, &BTreeSet::new())
905 }
906
907 pub fn decide_with_suppressed_actions(
913 &self,
914 outcome: &Outcome,
915 snapshot: &KnowledgeSnapshot,
916 ledger: &AdaptationLedger,
917 limits: AdaptationLimits,
918 suppressed_actions: &BTreeSet<String>,
919 ) -> Result<AdaptiveDecision, AdaptivePipelineError> {
920 validate_snapshot(outcome, snapshot)?;
921 if ledger.transitions >= limits.max_transitions {
922 return Ok(AdaptiveDecision {
923 sequence: ledger.transitions,
924 subject: outcome.subject().clone(),
925 case_id: outcome.case_id().to_owned(),
926 source_action_id: outcome.action_id().to_owned(),
927 outcome_status: outcome.status(),
928 outcome_stage: outcome.stage(),
929 selected_rule_id: None,
930 directive: PipelineDirective::Halt,
931 rationale: "adaptive transition budget exhausted".into(),
932 evaluations: Vec::new(),
933 transition_limit_reached: true,
934 });
935 }
936
937 let mut evaluations = Vec::with_capacity(self.rules.len());
938 for rule in self.rules.values() {
939 let selector_matched = rule.selector.matches(outcome);
940 let condition = if selector_matched {
941 rule.condition
942 .as_ref()
943 .map(|condition| condition.evaluate(snapshot))
944 .transpose()?
945 } else {
946 None
947 };
948 if condition
949 .as_ref()
950 .is_some_and(|condition| condition.matched() && condition.evidence_ids().is_empty())
951 {
952 return Err(AdaptivePipelineError::MissingContributingEvidence {
953 rule_id: rule.id.clone(),
954 });
955 }
956 let condition_matched = condition
957 .as_ref()
958 .map_or(rule.condition.is_none(), ExpressionEvaluation::matched);
959 let outcome_evidence_matched = condition.as_ref().is_none_or(|condition| {
960 !condition.evidence_ids().is_disjoint(outcome.evidence_ids())
961 });
962 let rule_limit_exhausted = ledger.rule_applications(rule.id()) >= rule.max_applications;
963 let action_limit_exhausted =
964 rule.directive
965 .scheduled_action_id()
966 .is_some_and(|action_id| {
967 ledger.action_schedules(action_id) >= limits.max_action_schedules
968 });
969 let policy_suppressed = rule
970 .directive
971 .repeated_action_id(outcome.action_id())
972 .is_some_and(|action_id| {
973 suppressed_actions.contains(action_id)
974 || ledger.suppressed_actions().contains(action_id)
975 });
976 let eligible = selector_matched
977 && condition_matched
978 && outcome_evidence_matched
979 && !rule_limit_exhausted
980 && !action_limit_exhausted
981 && !policy_suppressed;
982 evaluations.push(AdaptationRuleEvaluation {
983 rule_id: rule.id.clone(),
984 selector_matched,
985 condition,
986 outcome_evidence_matched,
987 rule_limit_exhausted,
988 action_limit_exhausted,
989 policy_suppressed,
990 eligible,
991 selected: false,
992 });
993 }
994
995 let mut candidates: Vec<_> = evaluations
996 .iter()
997 .filter(|evaluation| evaluation.eligible)
998 .map(|evaluation| evaluation.rule_id.clone())
999 .collect();
1000 candidates.sort_by(|left, right| {
1001 self.rules[right]
1002 .priority
1003 .cmp(&self.rules[left].priority)
1004 .then_with(|| left.cmp(right))
1005 });
1006 let selected_rule_id = candidates.first().cloned();
1007 if let Some(selected) = &selected_rule_id {
1008 if let Some(evaluation) = evaluations
1009 .iter_mut()
1010 .find(|evaluation| &evaluation.rule_id == selected)
1011 {
1012 evaluation.selected = true;
1013 }
1014 }
1015
1016 let (directive, rationale) = selected_rule_id
1017 .as_ref()
1018 .map(|rule_id| {
1019 let rule = &self.rules[rule_id];
1020 (rule.directive.clone(), rule.rationale.clone())
1021 })
1022 .unwrap_or_else(|| fallback(outcome));
1023
1024 Ok(AdaptiveDecision {
1025 sequence: ledger.transitions,
1026 subject: outcome.subject().clone(),
1027 case_id: outcome.case_id().to_owned(),
1028 source_action_id: outcome.action_id().to_owned(),
1029 outcome_status: outcome.status(),
1030 outcome_stage: outcome.stage(),
1031 selected_rule_id,
1032 directive,
1033 rationale,
1034 evaluations,
1035 transition_limit_reached: false,
1036 })
1037 }
1038
1039 pub fn decide_and_record(
1041 &self,
1042 outcome: &Outcome,
1043 snapshot: &KnowledgeSnapshot,
1044 ledger: &mut AdaptationLedger,
1045 limits: AdaptationLimits,
1046 ) -> Result<AdaptiveDecision, AdaptivePipelineError> {
1047 self.decide_and_record_with_suppressed_actions(
1048 outcome,
1049 snapshot,
1050 ledger,
1051 limits,
1052 &BTreeSet::new(),
1053 )
1054 }
1055
1056 pub fn decide_and_record_with_suppressed_actions(
1058 &self,
1059 outcome: &Outcome,
1060 snapshot: &KnowledgeSnapshot,
1061 ledger: &mut AdaptationLedger,
1062 limits: AdaptationLimits,
1063 suppressed_actions: &BTreeSet<String>,
1064 ) -> Result<AdaptiveDecision, AdaptivePipelineError> {
1065 let decision = self.decide_with_suppressed_actions(
1066 outcome,
1067 snapshot,
1068 ledger,
1069 limits,
1070 suppressed_actions,
1071 )?;
1072 ledger.record(&decision)?;
1073 Ok(decision)
1074 }
1075}
1076
1077fn fallback(outcome: &Outcome) -> (PipelineDirective, String) {
1078 match outcome.status() {
1079 OutcomeStatus::Success => (
1080 PipelineDirective::Complete,
1081 "verified objective completed".into(),
1082 ),
1083 OutcomeStatus::FalsePositive | OutcomeStatus::ConfirmedNegative => (
1084 PipelineDirective::Replan {
1085 suppress_current_action: true,
1086 },
1087 "negative conclusion suppresses the source action before replanning".into(),
1088 ),
1089 OutcomeStatus::Unknown | OutcomeStatus::NeedsReview
1090 if outcome.stage() == VerificationStage::Passive =>
1091 {
1092 (
1093 PipelineDirective::AwaitActiveVerification,
1094 "passive evidence is inconclusive".into(),
1095 )
1096 },
1097 OutcomeStatus::Blocked | OutcomeStatus::Unknown | OutcomeStatus::NeedsReview => (
1098 PipelineDirective::AwaitHumanReview,
1099 "no eligible automated adaptation policy remains".into(),
1100 ),
1101 _ => (
1102 PipelineDirective::AwaitHumanReview,
1103 "outcome status is not covered by the current adaptation policy".into(),
1104 ),
1105 }
1106}
1107
1108fn validate_snapshot(
1109 outcome: &Outcome,
1110 snapshot: &KnowledgeSnapshot,
1111) -> Result<(), AdaptivePipelineError> {
1112 if outcome.subject() != snapshot.subject() {
1113 return Err(AdaptivePipelineError::SnapshotSubjectMismatch {
1114 expected: outcome.subject().clone(),
1115 actual: snapshot.subject().clone(),
1116 });
1117 }
1118 let evidence_ids: BTreeSet<_> = snapshot
1119 .evidence()
1120 .iter()
1121 .map(|evidence| evidence.id())
1122 .collect();
1123 for evidence_id in outcome.evidence_ids() {
1124 if !evidence_ids.contains(evidence_id) {
1125 return Err(AdaptivePipelineError::MissingOutcomeEvidence {
1126 evidence_id: evidence_id.clone(),
1127 });
1128 }
1129 }
1130 Ok(())
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135 use super::*;
1136 use crate::{
1137 ExperiencePolicy, ExperienceStore, PassiveVerifier, VerificationCase, VerificationRule,
1138 VerifierWrite,
1139 };
1140 use termivar_core::{
1141 BayesianEvidence, ConfidenceScore, Evidence, EvidenceKind, EvidenceSource, Hypothesis,
1142 HypothesisState, HypothesisStrength, KnowledgePredicate, Probability,
1143 };
1144
1145 struct Fixture {
1146 knowledge: crate::KnowledgeBase,
1147 evidence_id: EvidenceId,
1148 }
1149
1150 fn subject() -> EntityId {
1151 EntityId::new("endpoint:https://example.test").unwrap()
1152 }
1153
1154 fn status_predicate() -> KnowledgePredicate {
1155 HttpEvidencePredicate::RESPONSE_STATUS.into_knowledge()
1156 }
1157
1158 fn fixture(status: u64) -> Fixture {
1159 let knowledge = crate::KnowledgeBase::new();
1160 let evidence = Evidence::new(
1161 subject(),
1162 EvidenceKind::Http,
1163 status_predicate(),
1164 EvidenceValue::Unsigned(status),
1165 EvidenceSource::new("http.executor", "response-status").unwrap(),
1166 ConfidenceScore::MAX,
1167 );
1168 let evidence_id = evidence.id().clone();
1169 knowledge.insert_evidence(evidence).unwrap();
1170 let mut hypothesis = Hypothesis::with_id(
1171 "hypothesis:http",
1172 subject(),
1173 KnowledgePredicate::new("vulnerability", "candidate").unwrap(),
1174 EvidenceValue::Boolean(true),
1175 Probability::from_percent(50).unwrap(),
1176 )
1177 .unwrap();
1178 hypothesis
1179 .observe(
1180 BayesianEvidence::new(
1181 evidence_id.clone(),
1182 Probability::from_percent(80).unwrap(),
1183 Probability::from_percent(20).unwrap(),
1184 "HTTP response contributes to the candidate",
1185 )
1186 .unwrap(),
1187 )
1188 .unwrap();
1189 hypothesis.set_strength(HypothesisStrength::Strong);
1190 hypothesis.set_state(HypothesisState::Supported);
1191 knowledge.upsert_hypothesis(hypothesis).unwrap();
1192 Fixture {
1193 knowledge,
1194 evidence_id,
1195 }
1196 }
1197
1198 fn verified_outcome(
1199 status: OutcomeStatus,
1200 stage: VerificationStage,
1201 evidence_id: EvidenceId,
1202 ) -> Outcome {
1203 Outcome::verified(
1204 "case:http:1",
1205 subject(),
1206 "http.probe",
1207 "hypothesis:http",
1208 "verify.http-status",
1209 stage,
1210 status,
1211 Probability::from_percent(90).unwrap(),
1212 "HTTP status verification",
1213 BTreeSet::from([evidence_id]),
1214 )
1215 .unwrap()
1216 }
1217
1218 fn unknown_outcome(stage: VerificationStage) -> Outcome {
1219 Outcome::unknown(
1220 "case:http:1",
1221 subject(),
1222 "http.probe",
1223 "hypothesis:http",
1224 stage,
1225 "No verifier rule matched",
1226 )
1227 .unwrap()
1228 }
1229
1230 #[test]
1231 fn verifier_blocked_403_schedules_bypass_action() {
1232 let fixture = fixture(403);
1233 let mut verifier = PassiveVerifier::new();
1234 assert_eq!(
1235 verifier
1236 .register(
1237 VerificationRule::new(
1238 "verify.403",
1239 VerificationStage::Passive,
1240 100,
1241 Expression::equals(
1242 KnowledgeLayer::Evidence,
1243 status_predicate(),
1244 EvidenceValue::Unsigned(403),
1245 ),
1246 OutcomeStatus::Blocked,
1247 Probability::from_percent(95).unwrap(),
1248 "Access was blocked",
1249 )
1250 .unwrap(),
1251 )
1252 .unwrap(),
1253 VerifierWrite::Inserted
1254 );
1255 let outcome = verifier
1256 .verify(
1257 &fixture.knowledge,
1258 &VerificationCase::new("case:http:1", subject(), "http.probe", "hypothesis:http")
1259 .unwrap(),
1260 )
1261 .unwrap();
1262 let pipeline = AdaptivePipeline::with_standard_policies().unwrap();
1263 let snapshot = fixture.knowledge.snapshot_for_subject(&subject());
1264 let decision = pipeline
1265 .decide(
1266 outcome.outcome(),
1267 &snapshot,
1268 &AdaptationLedger::new(),
1269 AdaptationLimits::default(),
1270 )
1271 .unwrap();
1272
1273 assert_eq!(decision.selected_rule_id(), Some("http.403.bypass"));
1274 assert_eq!(
1275 decision.directive(),
1276 &PipelineDirective::ScheduleAction {
1277 action_id: "http.403-bypass".into()
1278 }
1279 );
1280 }
1281
1282 #[test]
1283 fn review_404_redirects_to_enumeration() {
1284 let fixture = fixture(404);
1285 let pipeline = AdaptivePipeline::with_standard_policies().unwrap();
1286 let snapshot = fixture.knowledge.snapshot_for_subject(&subject());
1287 let outcome = verified_outcome(
1288 OutcomeStatus::NeedsReview,
1289 VerificationStage::Passive,
1290 fixture.evidence_id,
1291 );
1292 let decision = pipeline
1293 .decide(
1294 &outcome,
1295 &snapshot,
1296 &AdaptationLedger::new(),
1297 AdaptationLimits::default(),
1298 )
1299 .unwrap();
1300
1301 assert_eq!(decision.selected_rule_id(), Some("http.404.enumerate"));
1302 assert_eq!(
1303 decision.directive().scheduled_action_id(),
1304 Some("http.enumeration")
1305 );
1306 let selected: Vec<_> = decision
1307 .evaluations()
1308 .iter()
1309 .filter(|evaluation| evaluation.selected())
1310 .map(AdaptationRuleEvaluation::rule_id)
1311 .collect();
1312 assert_eq!(selected, vec!["http.404.enumerate"]);
1313 }
1314
1315 #[test]
1316 fn stale_status_evidence_cannot_redirect_a_new_outcome() {
1317 let fixture = fixture(403);
1318 let current = Evidence::new(
1319 subject(),
1320 EvidenceKind::Http,
1321 status_predicate(),
1322 EvidenceValue::Unsigned(200),
1323 EvidenceSource::new("http.executor", "current-response-status").unwrap(),
1324 ConfidenceScore::MAX,
1325 );
1326 let current_id = current.id().clone();
1327 fixture.knowledge.insert_evidence(current).unwrap();
1328 let snapshot = fixture.knowledge.snapshot_for_subject(&subject());
1329 let outcome = verified_outcome(
1330 OutcomeStatus::NeedsReview,
1331 VerificationStage::Passive,
1332 current_id,
1333 );
1334 let pipeline = AdaptivePipeline::with_standard_policies().unwrap();
1335 let decision = pipeline
1336 .decide(
1337 &outcome,
1338 &snapshot,
1339 &AdaptationLedger::new(),
1340 AdaptationLimits::default(),
1341 )
1342 .unwrap();
1343
1344 assert!(decision.selected_rule_id().is_none());
1345 assert_eq!(
1346 decision.directive(),
1347 &PipelineDirective::AwaitActiveVerification
1348 );
1349 let stale = decision
1350 .evaluations()
1351 .iter()
1352 .find(|evaluation| evaluation.rule_id() == "http.403.bypass")
1353 .unwrap();
1354 assert!(stale.condition().unwrap().matched());
1355 assert!(!stale.outcome_evidence_matched());
1356 assert!(!stale.eligible());
1357 }
1358
1359 #[test]
1360 fn blocked_429_throttles_and_retries() {
1361 let fixture = fixture(429);
1362 let pipeline = AdaptivePipeline::with_standard_policies().unwrap();
1363 let snapshot = fixture.knowledge.snapshot_for_subject(&subject());
1364 let outcome = verified_outcome(
1365 OutcomeStatus::Blocked,
1366 VerificationStage::Active,
1367 fixture.evidence_id,
1368 );
1369 let decision = pipeline
1370 .decide(
1371 &outcome,
1372 &snapshot,
1373 &AdaptationLedger::new(),
1374 AdaptationLimits::default(),
1375 )
1376 .unwrap();
1377
1378 assert_eq!(decision.selected_rule_id(), Some("http.429.throttle"));
1379 assert_eq!(
1380 decision.directive(),
1381 &PipelineDirective::Throttle {
1382 delay_ms: 2_000,
1383 retry_current_action: true
1384 }
1385 );
1386 }
1387
1388 #[test]
1389 fn learned_suppression_prevents_repeating_a_scheduled_action() {
1390 let fixture = fixture(403);
1391 let mut experience = ExperienceStore::new();
1392 for attempt in 0..10 {
1393 experience
1394 .observe(
1395 Outcome::verified(
1396 format!("case:bypass:{attempt}"),
1397 subject(),
1398 "http.403-bypass",
1399 "hypothesis:http",
1400 "verify.403-bypass",
1401 VerificationStage::Active,
1402 OutcomeStatus::ConfirmedNegative,
1403 Probability::from_percent(90).unwrap(),
1404 "active negative control rejected the bypass",
1405 BTreeSet::from([fixture.evidence_id.clone()]),
1406 )
1407 .unwrap(),
1408 )
1409 .unwrap();
1410 }
1411 let suppressed = experience.suppressed_actions(&subject(), ExperiencePolicy::default());
1412 let outcome = verified_outcome(
1413 OutcomeStatus::Blocked,
1414 VerificationStage::Passive,
1415 fixture.evidence_id,
1416 );
1417 let decision = AdaptivePipeline::with_standard_policies()
1418 .unwrap()
1419 .decide_with_suppressed_actions(
1420 &outcome,
1421 &fixture.knowledge.snapshot_for_subject(&subject()),
1422 &AdaptationLedger::new(),
1423 AdaptationLimits::default(),
1424 &suppressed,
1425 )
1426 .unwrap();
1427
1428 assert_eq!(decision.directive(), &PipelineDirective::AwaitHumanReview);
1429 let bypass = decision
1430 .evaluations()
1431 .iter()
1432 .find(|evaluation| evaluation.rule_id() == "http.403.bypass")
1433 .unwrap();
1434 assert!(bypass.policy_suppressed());
1435 assert!(!bypass.eligible());
1436 }
1437
1438 #[test]
1439 fn rule_application_limit_prevents_adaptation_loops() {
1440 let fixture = fixture(403);
1441 let pipeline = AdaptivePipeline::with_standard_policies().unwrap();
1442 let snapshot = fixture.knowledge.snapshot_for_subject(&subject());
1443 let outcome = verified_outcome(
1444 OutcomeStatus::Blocked,
1445 VerificationStage::Passive,
1446 fixture.evidence_id,
1447 );
1448 let mut ledger = AdaptationLedger::new();
1449
1450 for _ in 0..2 {
1451 let decision = pipeline
1452 .decide_and_record(
1453 &outcome,
1454 &snapshot,
1455 &mut ledger,
1456 AdaptationLimits::default(),
1457 )
1458 .unwrap();
1459 assert_eq!(decision.selected_rule_id(), Some("http.403.bypass"));
1460 }
1461 let exhausted = pipeline
1462 .decide(&outcome, &snapshot, &ledger, AdaptationLimits::default())
1463 .unwrap();
1464
1465 assert_eq!(ledger.rule_applications("http.403.bypass"), 2);
1466 assert_eq!(ledger.action_schedules("http.403-bypass"), 2);
1467 assert_eq!(
1468 serde_json::from_value::<AdaptationLedger>(serde_json::to_value(&ledger).unwrap())
1469 .unwrap(),
1470 ledger
1471 );
1472 assert_eq!(exhausted.directive(), &PipelineDirective::AwaitHumanReview);
1473 let evaluation = exhausted
1474 .evaluations()
1475 .iter()
1476 .find(|evaluation| evaluation.rule_id() == "http.403.bypass")
1477 .unwrap();
1478 assert!(evaluation.rule_limit_exhausted());
1479 assert!(!evaluation.eligible());
1480 }
1481
1482 #[test]
1483 fn action_schedule_limit_applies_across_policy_rules() {
1484 let fixture = fixture(403);
1485 let mut pipeline = AdaptivePipeline::with_standard_policies().unwrap();
1486 pipeline
1487 .register(
1488 AdaptationRule::new(
1489 "http.403.alternate",
1490 OutcomeSelector::any_stage(BTreeSet::from([OutcomeStatus::Blocked])).unwrap(),
1491 250,
1492 Some(Expression::equals(
1493 KnowledgeLayer::Evidence,
1494 status_predicate(),
1495 EvidenceValue::Unsigned(403),
1496 )),
1497 PipelineDirective::ScheduleAction {
1498 action_id: "http.403-bypass".into(),
1499 },
1500 "alternate policy schedules the same bounded action",
1501 2,
1502 )
1503 .unwrap(),
1504 )
1505 .unwrap();
1506 let snapshot = fixture.knowledge.snapshot_for_subject(&subject());
1507 let outcome = verified_outcome(
1508 OutcomeStatus::Blocked,
1509 VerificationStage::Passive,
1510 fixture.evidence_id,
1511 );
1512 let limits = AdaptationLimits::new(64, 1).unwrap();
1513 let mut ledger = AdaptationLedger::new();
1514 pipeline
1515 .decide_and_record(&outcome, &snapshot, &mut ledger, limits)
1516 .unwrap();
1517 let exhausted = pipeline
1518 .decide(&outcome, &snapshot, &ledger, limits)
1519 .unwrap();
1520
1521 assert_eq!(ledger.action_schedules("http.403-bypass"), 1);
1522 assert_eq!(exhausted.directive(), &PipelineDirective::AwaitHumanReview);
1523 assert!(exhausted
1524 .evaluations()
1525 .iter()
1526 .filter(|evaluation| {
1527 evaluation
1528 .condition()
1529 .is_some_and(ExpressionEvaluation::matched)
1530 })
1531 .all(|evaluation| evaluation.action_limit_exhausted()));
1532 }
1533
1534 #[test]
1535 fn global_transition_limit_emits_unrecorded_halt() {
1536 let fixture = fixture(404);
1537 let pipeline = AdaptivePipeline::with_standard_policies().unwrap();
1538 let snapshot = fixture.knowledge.snapshot_for_subject(&subject());
1539 let outcome = unknown_outcome(VerificationStage::Passive);
1540 let limits = AdaptationLimits::new(1, 3).unwrap();
1541 let mut ledger = AdaptationLedger::new();
1542 pipeline
1543 .decide_and_record(&outcome, &snapshot, &mut ledger, limits)
1544 .unwrap();
1545 let halted = pipeline
1546 .decide_and_record(&outcome, &snapshot, &mut ledger, limits)
1547 .unwrap();
1548
1549 assert_eq!(halted.directive(), &PipelineDirective::Halt);
1550 assert!(halted.transition_limit_reached());
1551 assert_eq!(ledger.transitions(), 1);
1552 }
1553
1554 #[test]
1555 fn fallback_transitions_cover_outcome_lifecycle() {
1556 let fixture = fixture(200);
1557 let snapshot = fixture.knowledge.snapshot_for_subject(&subject());
1558 let pipeline = AdaptivePipeline::new();
1559 let ledger = AdaptationLedger::new();
1560 let limits = AdaptationLimits::default();
1561
1562 let success = verified_outcome(
1563 OutcomeStatus::Success,
1564 VerificationStage::Passive,
1565 fixture.evidence_id.clone(),
1566 );
1567 assert_eq!(
1568 pipeline
1569 .decide(&success, &snapshot, &ledger, limits)
1570 .unwrap()
1571 .directive(),
1572 &PipelineDirective::Complete
1573 );
1574 let false_positive = verified_outcome(
1575 OutcomeStatus::FalsePositive,
1576 VerificationStage::Active,
1577 fixture.evidence_id.clone(),
1578 );
1579 let mut replanning_ledger = AdaptationLedger::new();
1580 let replan = pipeline
1581 .decide_and_record(&false_positive, &snapshot, &mut replanning_ledger, limits)
1582 .unwrap();
1583 assert_eq!(
1584 replan.directive(),
1585 &PipelineDirective::Replan {
1586 suppress_current_action: true
1587 }
1588 );
1589 assert!(replanning_ledger
1590 .suppressed_actions()
1591 .contains("http.probe"));
1592 let confirmed_negative = verified_outcome(
1593 OutcomeStatus::ConfirmedNegative,
1594 VerificationStage::Active,
1595 fixture.evidence_id,
1596 );
1597 assert_eq!(
1598 pipeline
1599 .decide(&confirmed_negative, &snapshot, &ledger, limits)
1600 .unwrap()
1601 .directive(),
1602 &PipelineDirective::Replan {
1603 suppress_current_action: true
1604 }
1605 );
1606 assert_eq!(
1607 pipeline
1608 .decide(
1609 &unknown_outcome(VerificationStage::Passive),
1610 &snapshot,
1611 &ledger,
1612 limits,
1613 )
1614 .unwrap()
1615 .directive(),
1616 &PipelineDirective::AwaitActiveVerification
1617 );
1618 assert_eq!(
1619 pipeline
1620 .decide(
1621 &unknown_outcome(VerificationStage::Active),
1622 &snapshot,
1623 &ledger,
1624 limits,
1625 )
1626 .unwrap()
1627 .directive(),
1628 &PipelineDirective::AwaitHumanReview
1629 );
1630 }
1631
1632 #[test]
1633 fn equal_priority_rules_use_stable_identity_order() {
1634 let fixture = fixture(200);
1635 let snapshot = fixture.knowledge.snapshot_for_subject(&subject());
1636 let mut pipeline = AdaptivePipeline::new();
1637 let selector =
1638 OutcomeSelector::any_stage(BTreeSet::from([OutcomeStatus::Blocked])).unwrap();
1639 for id in ["zeta", "alpha"] {
1640 pipeline
1641 .register(
1642 AdaptationRule::new(
1643 id,
1644 selector.clone(),
1645 10,
1646 None,
1647 PipelineDirective::AwaitHumanReview,
1648 format!("{id} rationale"),
1649 1,
1650 )
1651 .unwrap(),
1652 )
1653 .unwrap();
1654 }
1655 let outcome = verified_outcome(
1656 OutcomeStatus::Blocked,
1657 VerificationStage::Passive,
1658 fixture.evidence_id,
1659 );
1660
1661 let first = pipeline
1662 .decide(
1663 &outcome,
1664 &snapshot,
1665 &AdaptationLedger::new(),
1666 AdaptationLimits::default(),
1667 )
1668 .unwrap();
1669 let second = pipeline
1670 .decide(
1671 &outcome,
1672 &snapshot,
1673 &AdaptationLedger::new(),
1674 AdaptationLimits::default(),
1675 )
1676 .unwrap();
1677
1678 assert_eq!(first, second);
1679 assert_eq!(first.selected_rule_id(), Some("alpha"));
1680 }
1681
1682 #[test]
1683 fn wire_invariants_reject_invalid_rules_limits_and_ledgers() {
1684 let rule = AdaptationRule::new(
1685 "schedule",
1686 OutcomeSelector::any_stage(BTreeSet::from([OutcomeStatus::Blocked])).unwrap(),
1687 10,
1688 None,
1689 PipelineDirective::ScheduleAction {
1690 action_id: "http.bypass".into(),
1691 },
1692 "schedule bypass",
1693 1,
1694 )
1695 .unwrap();
1696 let mut encoded = serde_json::to_value(&rule).unwrap();
1697 assert_eq!(
1698 serde_json::from_value::<AdaptationRule>(encoded.clone()).unwrap(),
1699 rule
1700 );
1701 encoded["directive"]["action_id"] = serde_json::json!("");
1702 assert!(serde_json::from_value::<AdaptationRule>(encoded).is_err());
1703
1704 assert!(
1705 serde_json::from_value::<PipelineDirective>(serde_json::json!({
1706 "directive": "schedule_action",
1707 "action_id": ""
1708 }))
1709 .is_err()
1710 );
1711 assert!(
1712 serde_json::from_value::<PipelineDirective>(serde_json::json!({
1713 "directive": "throttle",
1714 "delay_ms": 0,
1715 "retry_current_action": true
1716 }))
1717 .is_err()
1718 );
1719 assert!(
1720 serde_json::from_value::<PipelineDirective>(serde_json::json!({
1721 "directive": "complete",
1722 "action_id": "must-not-be-ignored"
1723 }))
1724 .is_err()
1725 );
1726
1727 assert!(
1728 serde_json::from_value::<OutcomeSelector>(serde_json::json!({
1729 "statuses": [],
1730 "stages": ["passive"]
1731 }))
1732 .is_err()
1733 );
1734 assert!(
1735 serde_json::from_value::<OutcomeSelector>(serde_json::json!({
1736 "statuses": ["blocked"],
1737 "stages": ["passive"],
1738 "condition": "must-not-be-ignored"
1739 }))
1740 .is_err()
1741 );
1742 assert!(
1743 serde_json::from_value::<AdaptationLimits>(serde_json::json!({
1744 "max_transitions": 0,
1745 "max_action_schedules": 1
1746 }))
1747 .is_err()
1748 );
1749 assert!(
1750 serde_json::from_value::<AdaptationLedger>(serde_json::json!({
1751 "transitions": 1,
1752 "rule_applications": {"": 1},
1753 "action_schedules": {},
1754 "suppressed_actions": []
1755 }))
1756 .is_err()
1757 );
1758 assert!(
1759 serde_json::from_value::<AdaptationLedger>(serde_json::json!({
1760 "transitions": 1,
1761 "rule_applications": {"rule": 2},
1762 "action_schedules": {},
1763 "suppressed_actions": []
1764 }))
1765 .is_err()
1766 );
1767 }
1768
1769 #[test]
1770 fn adaptation_rule_wire_cannot_broaden_a_condition_to_unconditional() {
1771 let conditional = AdaptationRule::new(
1772 "conditional.schedule",
1773 OutcomeSelector::any_stage(BTreeSet::from([OutcomeStatus::Blocked])).unwrap(),
1774 10,
1775 Some(Expression::equals(
1776 KnowledgeLayer::Evidence,
1777 status_predicate(),
1778 EvidenceValue::Unsigned(403),
1779 )),
1780 PipelineDirective::ScheduleAction {
1781 action_id: "http.bypass".into(),
1782 },
1783 "schedule only for a correlated 403",
1784 1,
1785 )
1786 .unwrap();
1787 let encoded = serde_json::to_value(&conditional).unwrap();
1788 assert_eq!(encoded["condition_policy_guard"], true);
1789
1790 let mut legacy_guardless = encoded.clone();
1791 legacy_guardless
1792 .as_object_mut()
1793 .unwrap()
1794 .remove("condition_policy_guard");
1795 let restored_legacy = serde_json::from_value::<AdaptationRule>(legacy_guardless).unwrap();
1796 assert_eq!(restored_legacy, conditional);
1797 assert_eq!(
1798 serde_json::to_value(&restored_legacy).unwrap()["condition_policy_guard"],
1799 true
1800 );
1801
1802 let mismatch = fixture(200);
1803 let mismatch_outcome = verified_outcome(
1804 OutcomeStatus::Blocked,
1805 VerificationStage::Passive,
1806 mismatch.evidence_id,
1807 );
1808 let mut pipeline = AdaptivePipeline::new();
1809 pipeline.register(restored_legacy).unwrap();
1810 assert_eq!(
1811 pipeline
1812 .decide(
1813 &mismatch_outcome,
1814 &mismatch.knowledge.snapshot_for_subject(&subject()),
1815 &AdaptationLedger::new(),
1816 AdaptationLimits::default(),
1817 )
1818 .unwrap()
1819 .directive(),
1820 &PipelineDirective::AwaitHumanReview
1821 );
1822
1823 let mut misspelled = encoded.clone();
1824 let condition = misspelled
1825 .as_object_mut()
1826 .unwrap()
1827 .remove("condition")
1828 .unwrap();
1829 misspelled["conditon"] = condition;
1830 assert!(serde_json::from_value::<AdaptationRule>(misspelled).is_err());
1831
1832 let mut missing = encoded.clone();
1833 missing.as_object_mut().unwrap().remove("condition");
1834 assert!(serde_json::from_value::<AdaptationRule>(missing).is_err());
1835
1836 let mut nulled = encoded;
1837 nulled["condition"] = serde_json::Value::Null;
1838 assert!(serde_json::from_value::<AdaptationRule>(nulled).is_err());
1839
1840 let mut inconsistent = serde_json::to_value(&conditional).unwrap();
1841 inconsistent["condition_policy_guard"] = serde_json::json!(false);
1842 assert!(serde_json::from_value::<AdaptationRule>(inconsistent).is_err());
1843
1844 let unconditional = AdaptationRule::new(
1845 "unconditional.review",
1846 OutcomeSelector::any_stage(BTreeSet::from([OutcomeStatus::NeedsReview])).unwrap(),
1847 1,
1848 None,
1849 PipelineDirective::AwaitHumanReview,
1850 "explicit unconditional fallback",
1851 1,
1852 )
1853 .unwrap();
1854 let unconditional_wire = serde_json::to_value(&unconditional).unwrap();
1855 assert!(unconditional_wire["condition"].is_null());
1856 assert!(unconditional_wire.get("condition_policy_guard").is_none());
1857 assert_eq!(
1858 serde_json::from_value::<AdaptationRule>(unconditional_wire).unwrap(),
1859 unconditional
1860 );
1861 }
1862
1863 #[test]
1864 fn outcome_provenance_must_exist_in_snapshot() {
1865 let outcome_fixture = fixture(403);
1866 let snapshot_fixture = fixture(403);
1867 let outcome = verified_outcome(
1868 OutcomeStatus::Blocked,
1869 VerificationStage::Passive,
1870 outcome_fixture.evidence_id,
1871 );
1872 let snapshot = snapshot_fixture.knowledge.snapshot_for_subject(&subject());
1873
1874 assert!(matches!(
1875 AdaptivePipeline::new().decide(
1876 &outcome,
1877 &snapshot,
1878 &AdaptationLedger::new(),
1879 AdaptationLimits::default(),
1880 ),
1881 Err(AdaptivePipelineError::MissingOutcomeEvidence { .. })
1882 ));
1883 }
1884}