1use core::marker::PhantomData;
14use std::collections::HashMap;
15
16use arkhe_forge_core::activity::{ActivityId, ActivityRecord, EntityShellId};
17use arkhe_forge_core::actor::{ActorId, ActorProfile, UserBinding};
18use arkhe_forge_core::brand::ShellId;
19use arkhe_forge_core::context::EventRecord;
20use arkhe_forge_core::entry::{EntryBody, EntryCore, EntryId, EntryParentDepth};
21use arkhe_forge_core::event::{ArkheEvent, CrossShellActivity};
22use arkhe_forge_core::space::{ParentChainDepth, SpaceConfig, SpaceId, SpaceMembership};
23use arkhe_kernel::abi::{InstanceId, Tick, TypeCode};
24use serde::{Deserialize, Serialize};
25
26use crate::manifest::ManifestSnapshot;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum ObserverState {
40 Passive,
42 Active,
44 Draining,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50#[non_exhaustive]
51pub enum PromotionDecision {
52 Promote,
54 Wait,
56}
57
58pub const HF2_HEALTH_QUORUM_MIN: usize = 2;
63
64pub struct ProjectionContext<'i> {
69 pub tick: Tick,
71 pub instance_id: InstanceId,
73 pub manifest: Option<&'i ManifestSnapshot>,
77 _phantom: PhantomData<&'i ()>,
78}
79
80impl<'i> ProjectionContext<'i> {
81 #[inline]
83 #[must_use]
84 pub fn new(tick: Tick, instance_id: InstanceId) -> Self {
85 Self {
86 tick,
87 instance_id,
88 manifest: None,
89 _phantom: PhantomData,
90 }
91 }
92
93 #[inline]
97 #[must_use]
98 pub fn with_manifest(
99 tick: Tick,
100 instance_id: InstanceId,
101 manifest: &'i ManifestSnapshot,
102 ) -> Self {
103 Self {
104 tick,
105 instance_id,
106 manifest: Some(manifest),
107 _phantom: PhantomData,
108 }
109 }
110}
111
112#[derive(Debug, thiserror::Error)]
114#[non_exhaustive]
115pub enum ProjectionError {
116 #[error("projection sequence backward: last {last}, incoming {incoming}")]
118 SequenceBackward {
119 last: u64,
121 incoming: u64,
123 },
124
125 #[error("projection sequence gap: last {last}, incoming {incoming}")]
128 SequenceGap {
129 last: u64,
131 incoming: u64,
133 },
134
135 #[error("observer not active: current state {state:?}")]
138 NotActive {
139 state: ObserverState,
141 },
142
143 #[error("projection storage error: {0}")]
145 Storage(&'static str),
146
147 #[error("event decode failed: {0}")]
149 DecodeFailed(&'static str),
150
151 #[error("projection row missing")]
154 MissingRow,
155}
156
157pub trait Projection: Send + Sync {
166 fn observes(&self) -> &[TypeCode];
169
170 fn on_event(
176 &mut self,
177 event: &EventRecord,
178 ctx: &ProjectionContext<'_>,
179 ) -> Result<(), ProjectionError>;
180
181 fn on_state_change(&mut self, _new_state: ObserverState) -> Result<(), ProjectionError> {
184 Ok(())
185 }
186
187 fn last_applied(&self) -> Option<(u64, Tick)>;
189}
190
191#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
195pub struct ProjectionCursor {
196 pub sequence: u64,
198 pub tick: Tick,
200}
201
202#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
204pub struct ActorProjection {
205 pub schema_version: u16,
207 pub actor_id: ActorId,
209 pub profile: ActorProfile,
211 pub user_binding: Option<UserBinding>,
213 pub cursor: Option<ProjectionCursor>,
215}
216
217#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
219pub struct SpaceProjection {
220 pub schema_version: u16,
222 pub space_id: SpaceId,
224 pub config: SpaceConfig,
226 pub parent_chain_depth: Option<ParentChainDepth>,
228 pub membership: Option<SpaceMembership>,
230 pub cursor: Option<ProjectionCursor>,
232}
233
234#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
236pub struct EntryProjection {
237 pub schema_version: u16,
239 pub entry_id: EntryId,
241 pub core: EntryCore,
243 pub body: Option<EntryBody>,
245 pub parent_depth: Option<EntryParentDepth>,
247 pub cursor: Option<ProjectionCursor>,
249}
250
251#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
254pub struct ActivityProjection {
255 pub schema_version: u16,
257 pub activity_id: ActivityId,
259 pub record: ActivityRecord,
261 pub entity_shell_id: Option<EntityShellId>,
263 pub cursor: Option<ProjectionCursor>,
265}
266
267pub trait ProjectionStore: Send + Sync {
273 fn upsert_actor(&mut self, row: &ActorProjection) -> Result<(), ProjectionError>;
275
276 fn upsert_space(&mut self, row: &SpaceProjection) -> Result<(), ProjectionError>;
278
279 fn upsert_entry(&mut self, row: &EntryProjection) -> Result<(), ProjectionError>;
281
282 fn upsert_activity(&mut self, row: &ActivityProjection) -> Result<(), ProjectionError>;
284
285 fn get_actor(&self, actor_id: ActorId) -> Option<ActorProjection>;
287 fn get_space(&self, space_id: SpaceId) -> Option<SpaceProjection>;
289 fn get_entry(&self, entry_id: EntryId) -> Option<EntryProjection>;
291 fn get_activity(&self, activity_id: ActivityId) -> Option<ActivityProjection>;
293}
294
295#[derive(Debug, Default)]
297pub struct InMemoryProjectionStore {
298 actors: HashMap<ActorId, ActorProjection>,
299 spaces: HashMap<SpaceId, SpaceProjection>,
300 entries: HashMap<EntryId, EntryProjection>,
301 activities: HashMap<ActivityId, ActivityProjection>,
302}
303
304impl InMemoryProjectionStore {
305 #[inline]
307 #[must_use]
308 pub fn new() -> Self {
309 Self::default()
310 }
311}
312
313impl ProjectionStore for InMemoryProjectionStore {
314 fn upsert_actor(&mut self, row: &ActorProjection) -> Result<(), ProjectionError> {
315 self.actors.insert(row.actor_id, row.clone());
316 Ok(())
317 }
318 fn upsert_space(&mut self, row: &SpaceProjection) -> Result<(), ProjectionError> {
319 self.spaces.insert(row.space_id, row.clone());
320 Ok(())
321 }
322 fn upsert_entry(&mut self, row: &EntryProjection) -> Result<(), ProjectionError> {
323 self.entries.insert(row.entry_id, row.clone());
324 Ok(())
325 }
326 fn upsert_activity(&mut self, row: &ActivityProjection) -> Result<(), ProjectionError> {
327 self.activities.insert(row.activity_id, row.clone());
328 Ok(())
329 }
330 fn get_actor(&self, actor_id: ActorId) -> Option<ActorProjection> {
331 self.actors.get(&actor_id).cloned()
332 }
333 fn get_space(&self, space_id: SpaceId) -> Option<SpaceProjection> {
334 self.spaces.get(&space_id).cloned()
335 }
336 fn get_entry(&self, entry_id: EntryId) -> Option<EntryProjection> {
337 self.entries.get(&entry_id).cloned()
338 }
339 fn get_activity(&self, activity_id: ActivityId) -> Option<ActivityProjection> {
340 self.activities.get(&activity_id).cloned()
341 }
342}
343
344pub struct ProjectionRouter {
350 projections: Vec<Box<dyn Projection>>,
351 observers_by_code: HashMap<TypeCode, Vec<usize>>,
356 state: ObserverState,
357}
358
359impl ProjectionRouter {
360 #[inline]
363 #[must_use]
364 pub fn new() -> Self {
365 Self {
366 projections: Vec::new(),
367 observers_by_code: HashMap::new(),
368 state: ObserverState::Passive,
369 }
370 }
371
372 pub fn register(&mut self, projection: Box<dyn Projection>) {
375 let idx = self.projections.len();
376 for &tc in projection.observes() {
377 self.observers_by_code.entry(tc).or_default().push(idx);
378 }
379 self.projections.push(projection);
380 }
381
382 #[inline]
384 #[must_use]
385 pub fn state(&self) -> ObserverState {
386 self.state
387 }
388
389 pub fn promote_to_active(&mut self) -> Result<(), ProjectionError> {
391 if self.state == ObserverState::Draining {
392 return Err(ProjectionError::NotActive { state: self.state });
393 }
394 self.transition(ObserverState::Active)
395 }
396
397 #[must_use]
414 pub fn evaluate_auto_promote(
415 &self,
416 manifest: &crate::manifest::ManifestSnapshot,
417 primary_down_duration: core::time::Duration,
418 health: &crate::hf2_kms::health::MultiChannelHealth,
419 threshold_ready: bool,
420 ) -> Option<PromotionDecision> {
421 match manifest.audit.kms_auto_promote.as_str() {
422 "manual" => Some(PromotionDecision::Wait),
423 "after_60min" => {
424 let elapsed_ok = primary_down_duration >= core::time::Duration::from_secs(60 * 60);
425 let health_ok = health.healthy_count() >= HF2_HEALTH_QUORUM_MIN;
426 if elapsed_ok && health_ok {
427 Some(PromotionDecision::Promote)
428 } else {
429 Some(PromotionDecision::Wait)
430 }
431 }
432 "threshold_hsm" => {
433 if threshold_ready {
434 Some(PromotionDecision::Promote)
435 } else {
436 Some(PromotionDecision::Wait)
437 }
438 }
439 _ => None,
440 }
441 }
442
443 pub fn demote_to_passive(&mut self) -> Result<(), ProjectionError> {
445 self.transition(ObserverState::Passive)
446 }
447
448 pub fn begin_draining(&mut self) -> Result<(), ProjectionError> {
450 self.transition(ObserverState::Draining)
451 }
452
453 fn transition(&mut self, next: ObserverState) -> Result<(), ProjectionError> {
454 for p in &mut self.projections {
455 p.on_state_change(next)?;
456 }
457 self.state = next;
458 Ok(())
459 }
460
461 pub fn dispatch(
477 &mut self,
478 event: &EventRecord,
479 ctx: &ProjectionContext<'_>,
480 ) -> Result<usize, ProjectionError> {
481 if self.state != ObserverState::Active {
482 return Err(ProjectionError::NotActive { state: self.state });
483 }
484 let tc = TypeCode(event.type_code);
485 let Self {
489 projections,
490 observers_by_code,
491 ..
492 } = self;
493 let Some(matching) = observers_by_code.get(&tc) else {
494 return Ok(0);
495 };
496 let mut applied = 0usize;
498 for &i in matching {
499 let p = &mut projections[i];
500 if let Some((last_seq, _)) = p.last_applied() {
501 if event.sequence == last_seq {
502 continue; }
504 if event.sequence < last_seq {
505 return Err(ProjectionError::SequenceBackward {
506 last: last_seq,
507 incoming: event.sequence,
508 });
509 }
510 if event.sequence > last_seq.saturating_add(1) {
511 return Err(ProjectionError::SequenceGap {
512 last: last_seq,
513 incoming: event.sequence,
514 });
515 }
516 }
517 p.on_event(event, ctx)?;
518 applied += 1;
519 }
520 Ok(applied)
521 }
522}
523
524impl Default for ProjectionRouter {
525 fn default() -> Self {
526 Self::new()
527 }
528}
529
530#[derive(Debug)]
536pub struct CrossShellActivityFanout {
537 observes: [TypeCode; 1],
538 by_target_shell: HashMap<ShellId, Vec<CrossShellActivity>>,
539 cursor: Option<ProjectionCursor>,
540}
541
542impl Default for CrossShellActivityFanout {
543 fn default() -> Self {
544 Self::new()
545 }
546}
547
548impl CrossShellActivityFanout {
549 #[inline]
551 #[must_use]
552 pub fn new() -> Self {
553 Self {
554 observes: [TypeCode(CrossShellActivity::TYPE_CODE)],
555 by_target_shell: HashMap::new(),
556 cursor: None,
557 }
558 }
559
560 #[inline]
562 #[must_use]
563 pub fn notifications_for(&self, shell: &ShellId) -> &[CrossShellActivity] {
564 self.by_target_shell
565 .get(shell)
566 .map(Vec::as_slice)
567 .unwrap_or(&[])
568 }
569}
570
571impl Projection for CrossShellActivityFanout {
572 fn observes(&self) -> &[TypeCode] {
573 &self.observes
574 }
575
576 fn on_event(
577 &mut self,
578 event: &EventRecord,
579 _ctx: &ProjectionContext<'_>,
580 ) -> Result<(), ProjectionError> {
581 let notice: CrossShellActivity = postcard::from_bytes(&event.payload)
582 .map_err(|_| ProjectionError::DecodeFailed("CrossShellActivity payload"))?;
583 self.by_target_shell
584 .entry(notice.target_shell_id)
585 .or_default()
586 .push(notice);
587 self.cursor = Some(ProjectionCursor {
588 sequence: event.sequence,
589 tick: event.tick,
590 });
591 Ok(())
592 }
593
594 fn last_applied(&self) -> Option<(u64, Tick)> {
595 self.cursor.map(|c| (c.sequence, c.tick))
596 }
597}
598
599#[cfg(test)]
602#[allow(clippy::unwrap_used, clippy::expect_used)]
603mod tests {
604 use super::*;
605 use arkhe_forge_core::actor::ActorKind;
606 use arkhe_forge_core::component::BoundedString;
607 use arkhe_kernel::abi::EntityId;
608 use bytes::Bytes;
609
610 fn sid(byte: u8) -> ShellId {
611 ShellId([byte; 16])
612 }
613
614 fn ent(v: u64) -> EntityId {
615 EntityId::new(v).unwrap()
616 }
617
618 fn make_cross_shell_event(seq: u64, tick: u64, target: ShellId) -> EventRecord {
619 let notice = CrossShellActivity {
620 schema_version: 1,
621 actor: ActorId::new(ent(1)),
622 target_shell_id: target,
623 record_shell_id: sid(0xAA),
624 detected_tick: Tick(tick),
625 };
626 EventRecord {
627 type_code: CrossShellActivity::TYPE_CODE,
628 sequence: seq,
629 tick: Tick(tick),
630 payload: Bytes::from(postcard::to_stdvec(¬ice).unwrap()),
631 }
632 }
633
634 fn ctx(tick: u64) -> ProjectionContext<'static> {
635 ProjectionContext::new(Tick(tick), InstanceId::new(1).unwrap())
636 }
637
638 #[test]
639 fn router_defaults_to_passive() {
640 let r = ProjectionRouter::new();
641 assert_eq!(r.state(), ObserverState::Passive);
642 }
643
644 #[test]
645 fn router_promote_then_demote_then_drain() {
646 let mut r = ProjectionRouter::new();
647 r.promote_to_active().unwrap();
648 assert_eq!(r.state(), ObserverState::Active);
649 r.demote_to_passive().unwrap();
650 assert_eq!(r.state(), ObserverState::Passive);
651 r.begin_draining().unwrap();
652 assert_eq!(r.state(), ObserverState::Draining);
653 assert!(r.promote_to_active().is_err());
655 }
656
657 #[test]
658 fn cross_shell_fanout_routes_to_target_shell_only() {
659 let mut r = ProjectionRouter::new();
660 r.promote_to_active().unwrap();
661 r.register(Box::new(CrossShellActivityFanout::new()));
662 let target = sid(0x33);
663 let ev = make_cross_shell_event(0, 100, target);
664 let applied = r.dispatch(&ev, &ctx(100)).unwrap();
665 assert_eq!(applied, 1);
666 }
667
668 #[test]
672 fn dispatcher_fans_out_to_all_matching_in_registration_order() {
673 let mut r = ProjectionRouter::new();
674 r.promote_to_active().unwrap();
675 r.register(Box::new(CrossShellActivityFanout::new()));
676 r.register(Box::new(CrossShellActivityFanout::new()));
677 let target = sid(0x44);
678 let ev = make_cross_shell_event(0, 100, target);
679 let applied = r.dispatch(&ev, &ctx(100)).unwrap();
680 assert_eq!(applied, 2, "both matching projections must apply the event");
681 }
682
683 #[test]
686 fn dispatcher_unobserved_typecode_is_index_miss() {
687 let mut r = ProjectionRouter::new();
688 r.promote_to_active().unwrap();
689 r.register(Box::new(CrossShellActivityFanout::new()));
690 let other_event = EventRecord {
691 type_code: 0x0003_0F02, sequence: 0,
693 tick: Tick(1),
694 payload: Bytes::new(),
695 };
696 assert_eq!(r.dispatch(&other_event, &ctx(1)).unwrap(), 0);
697 }
698
699 #[test]
700 fn dispatcher_skips_projection_with_no_matching_observer() {
701 let mut r = ProjectionRouter::new();
702 r.promote_to_active().unwrap();
703 r.register(Box::new(CrossShellActivityFanout::new()));
704 let other_event = EventRecord {
705 type_code: 0x0003_0F02, sequence: 0,
707 tick: Tick(1),
708 payload: Bytes::new(),
709 };
710 let applied = r.dispatch(&other_event, &ctx(1)).unwrap();
711 assert_eq!(applied, 0, "non-observed TypeCode must not hit the fanout");
712 }
713
714 #[test]
715 fn dispatcher_dedups_duplicate_sequence() {
716 let mut r = ProjectionRouter::new();
717 r.promote_to_active().unwrap();
718 r.register(Box::new(CrossShellActivityFanout::new()));
719 let target = sid(0x10);
720 let ev = make_cross_shell_event(0, 5, target);
721 r.dispatch(&ev, &ctx(5)).unwrap();
722 let applied_again = r.dispatch(&ev, &ctx(5)).unwrap();
723 assert_eq!(applied_again, 0, "duplicate sequence must no-op");
724 }
725
726 #[test]
727 fn dispatcher_rejects_gap() {
728 let mut r = ProjectionRouter::new();
729 r.promote_to_active().unwrap();
730 r.register(Box::new(CrossShellActivityFanout::new()));
731 let target = sid(0x10);
732 r.dispatch(&make_cross_shell_event(0, 5, target), &ctx(5))
733 .unwrap();
734 let err = r
736 .dispatch(&make_cross_shell_event(5, 6, target), &ctx(6))
737 .unwrap_err();
738 assert!(matches!(err, ProjectionError::SequenceGap { .. }));
739 }
740
741 #[test]
742 fn dispatcher_rejects_backward_sequence() {
743 let mut r = ProjectionRouter::new();
744 r.promote_to_active().unwrap();
745 r.register(Box::new(CrossShellActivityFanout::new()));
746 let target = sid(0x10);
747 r.dispatch(&make_cross_shell_event(2, 5, target), &ctx(5))
748 .unwrap();
749 let err = r
750 .dispatch(&make_cross_shell_event(1, 5, target), &ctx(5))
751 .unwrap_err();
752 assert!(matches!(err, ProjectionError::SequenceBackward { .. }));
753 }
754
755 #[test]
756 fn draining_rejects_dispatch() {
757 let mut r = ProjectionRouter::new();
758 r.begin_draining().unwrap();
759 let err = r
760 .dispatch(&make_cross_shell_event(0, 1, sid(0)), &ctx(1))
761 .unwrap_err();
762 assert!(matches!(
763 err,
764 ProjectionError::NotActive {
765 state: ObserverState::Draining
766 }
767 ));
768 }
769
770 #[test]
771 fn passive_rejects_dispatch() {
772 let mut r = ProjectionRouter::new();
776 assert_eq!(r.state(), ObserverState::Passive);
777 let err = r
778 .dispatch(&make_cross_shell_event(0, 1, sid(0)), &ctx(1))
779 .unwrap_err();
780 assert!(matches!(
781 err,
782 ProjectionError::NotActive {
783 state: ObserverState::Passive
784 }
785 ));
786 }
787
788 #[test]
789 fn demote_to_passive_blocks_subsequent_dispatch() {
790 let mut r = ProjectionRouter::new();
793 r.promote_to_active().unwrap();
794 r.register(Box::new(CrossShellActivityFanout::new()));
795 r.dispatch(&make_cross_shell_event(0, 5, sid(0x10)), &ctx(5))
796 .unwrap();
797 r.demote_to_passive().unwrap();
798 let err = r
799 .dispatch(&make_cross_shell_event(1, 6, sid(0x10)), &ctx(6))
800 .unwrap_err();
801 assert!(matches!(
802 err,
803 ProjectionError::NotActive {
804 state: ObserverState::Passive
805 }
806 ));
807 }
808
809 #[test]
810 fn in_memory_store_roundtrip_actor() {
811 let mut store = InMemoryProjectionStore::new();
812 let row = ActorProjection {
813 schema_version: 1,
814 actor_id: ActorId::new(ent(42)),
815 profile: ActorProfile {
816 schema_version: 1,
817 shell_id: sid(0x01),
818 handle: BoundedString::<32>::new("alice").unwrap(),
819 kind: ActorKind::Human,
820 created_tick: Tick(1),
821 },
822 user_binding: None,
823 cursor: None,
824 };
825 store.upsert_actor(&row).unwrap();
826 let fetched = store.get_actor(ActorId::new(ent(42))).unwrap();
827 assert_eq!(fetched, row);
828 }
829
830 #[test]
831 fn cross_shell_fanout_preserves_shell_partition() {
832 let mut fanout = CrossShellActivityFanout::new();
833 let shell_a = sid(0xAA);
834 let shell_b = sid(0xBB);
835 fanout
836 .on_event(&make_cross_shell_event(0, 10, shell_a), &ctx(10))
837 .unwrap();
838 fanout
839 .on_event(&make_cross_shell_event(1, 11, shell_b), &ctx(11))
840 .unwrap();
841 assert_eq!(fanout.notifications_for(&shell_a).len(), 1);
842 assert_eq!(fanout.notifications_for(&shell_b).len(), 1);
843 assert_eq!(fanout.last_applied(), Some((1, Tick(11))));
844 }
845
846 #[test]
847 fn projection_cursor_roundtrip() {
848 let c = ProjectionCursor {
849 sequence: 5,
850 tick: Tick(10),
851 };
852 let bytes = postcard::to_stdvec(&c).unwrap();
853 let back: ProjectionCursor = postcard::from_bytes(&bytes).unwrap();
854 assert_eq!(c, back);
855 }
856
857 #[test]
861 fn auto_promote_policy_matrix() {
862 use crate::hf2_kms::health::{Channel, MultiChannelHealth, Status};
863 use crate::manifest::{
864 AuditSection, FrontendSection, ManifestSnapshot, RuntimeSection, ShellSection,
865 };
866 use core::time::Duration;
867
868 fn manifest_with(policy: &str) -> ManifestSnapshot {
869 ManifestSnapshot {
870 schema_version: 1,
871 shell: ShellSection {
872 shell_id: "test".to_string(),
873 display_name: "Test".to_string(),
874 },
875 runtime: RuntimeSection {
876 runtime_max: "0.15".to_string(),
877 runtime_current: "0.13".to_string(),
878 },
879 audit: AuditSection {
880 pii_cipher: "xchacha20-poly1305".to_string(),
881 dek_backend: "software-kek".to_string(),
882 kms_auto_promote: policy.to_string(),
883 signature_class: "ed25519".to_string(),
884 compliance_tier: 0,
885 },
886 frontend: FrontendSection::default(),
887 }
888 }
889
890 fn healthy_trio() -> MultiChannelHealth {
891 let mut h = MultiChannelHealth::new(&[
892 Channel::Default,
893 Channel::DnsOverHttps,
894 Channel::StaticIp,
895 ]);
896 for c in [Channel::Default, Channel::DnsOverHttps, Channel::StaticIp] {
897 h.set_status(c, Status::Healthy);
898 }
899 h
900 }
901
902 fn degraded_trio() -> MultiChannelHealth {
903 let mut h = MultiChannelHealth::new(&[
905 Channel::Default,
906 Channel::DnsOverHttps,
907 Channel::StaticIp,
908 ]);
909 h.set_status(Channel::Default, Status::Healthy);
910 h.set_status(Channel::DnsOverHttps, Status::Failing);
911 h.set_status(Channel::StaticIp, Status::Failing);
912 h
913 }
914
915 let r = ProjectionRouter::new();
916 let healthy = healthy_trio();
917 let degraded = degraded_trio();
918
919 assert_eq!(
921 r.evaluate_auto_promote(
922 &manifest_with("manual"),
923 Duration::from_secs(7200),
924 &healthy,
925 true,
926 ),
927 Some(PromotionDecision::Wait),
928 );
929
930 assert_eq!(
932 r.evaluate_auto_promote(
933 &manifest_with("after_60min"),
934 Duration::from_secs(59 * 60),
935 &healthy,
936 false,
937 ),
938 Some(PromotionDecision::Wait),
939 );
940
941 assert_eq!(
943 r.evaluate_auto_promote(
944 &manifest_with("after_60min"),
945 Duration::from_secs(60 * 60),
946 °raded,
947 false,
948 ),
949 Some(PromotionDecision::Wait),
950 );
951
952 assert_eq!(
954 r.evaluate_auto_promote(
955 &manifest_with("after_60min"),
956 Duration::from_secs(60 * 60),
957 &healthy,
958 false,
959 ),
960 Some(PromotionDecision::Promote),
961 );
962
963 assert_eq!(
965 r.evaluate_auto_promote(
966 &manifest_with("threshold_hsm"),
967 Duration::from_secs(60 * 60),
968 °raded,
969 false,
970 ),
971 Some(PromotionDecision::Wait),
972 );
973
974 assert_eq!(
976 r.evaluate_auto_promote(
977 &manifest_with("threshold_hsm"),
978 Duration::from_secs(0),
979 °raded,
980 true,
981 ),
982 Some(PromotionDecision::Promote),
983 );
984
985 assert!(r
987 .evaluate_auto_promote(
988 &manifest_with("unknown"),
989 Duration::from_secs(86_400),
990 &healthy,
991 true,
992 )
993 .is_none());
994 }
995}