1use arkhe_forge_core::context::EventRecord;
18use arkhe_forge_core::event::{
19 ArkheEvent, PerRegionErasureProgress, ProgressScope, RuntimeSignatureClass,
20 UserErasureCompleted, UserErasureScheduled,
21};
22use arkhe_forge_core::pii::DekId;
23use arkhe_forge_core::user::UserId;
24use arkhe_kernel::abi::{Tick, TypeCode};
25use bytes::Bytes;
26use std::collections::HashMap;
27
28use crate::projection::{
29 ObserverState, Projection, ProjectionContext, ProjectionCursor, ProjectionError,
30};
31
32pub trait DekShredder: Send + Sync {
47 fn shred(&mut self, dek_id: DekId) -> Result<DekShredAttestation, DekShredError>;
52
53 fn shred_with_regions(
62 &mut self,
63 dek_id: DekId,
64 shred_tick: Tick,
65 ) -> Result<ShredResult, DekShredError> {
66 let overall = self.shred(dek_id)?;
67 let region = RegionProgress {
68 scope: default_region_scope(),
69 shred_tick,
70 attestation_class: overall.attestation_class,
71 attestation_bytes: overall.attestation_bytes.clone(),
72 };
73 Ok(ShredResult {
74 regions: vec![region],
75 overall,
76 })
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct RegionProgress {
87 pub scope: ProgressScope,
89 pub shred_tick: Tick,
91 pub attestation_class: RuntimeSignatureClass,
93 pub attestation_bytes: Bytes,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct ShredResult {
104 pub regions: Vec<RegionProgress>,
106 pub overall: DekShredAttestation,
110}
111
112#[allow(clippy::expect_used)]
118fn default_region_scope() -> ProgressScope {
119 let label = arkhe_forge_core::component::BoundedString::<64>::new("default-region")
120 .expect("'default-region' is 14 bytes, within the BoundedString<64> cap");
121 ProgressScope::Region(label)
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct DekShredAttestation {
134 pub attestation_class: RuntimeSignatureClass,
136 pub attestation_bytes: Bytes,
138 pub log_index: Option<u64>,
147}
148
149#[non_exhaustive]
151#[derive(Debug, thiserror::Error)]
152pub enum DekShredError {
153 #[error("DEK id unknown to the shredder")]
156 UnknownDek,
157 #[error("DEK already shredded")]
160 AlreadyShredded,
161 #[error("shredder backend error: {0}")]
164 Backend(&'static str),
165}
166
167#[derive(Debug, Default)]
175pub struct InMemoryDekShredder {
176 live: HashMap<DekId, ()>,
177 shredded: HashMap<DekId, DekShredAttestation>,
178 next_log_index: u64,
179}
180
181impl InMemoryDekShredder {
182 #[inline]
184 #[must_use]
185 pub fn new() -> Self {
186 Self::default()
187 }
188
189 pub fn register(&mut self, dek_id: DekId) {
192 self.live.insert(dek_id, ());
193 }
194
195 #[must_use]
197 pub fn is_shredded(&self, dek_id: &DekId) -> bool {
198 self.shredded.contains_key(dek_id)
199 }
200
201 #[cfg(test)]
204 fn set_next_log_index_for_test(&mut self, n: u64) {
205 self.next_log_index = n;
206 }
207
208 fn issue_attestation(&mut self, dek_id: DekId) -> Result<DekShredAttestation, DekShredError> {
209 let log_index = self.next_log_index;
210 self.next_log_index = self
216 .next_log_index
217 .checked_add(1)
218 .ok_or(DekShredError::Backend("DEK shred log index overflow"))?;
219 let key = blake3::derive_key("arkhe-forge-dek-shred-attestation", &dek_id.0);
224 let mut h = blake3::Hasher::new_keyed(&key);
225 h.update(&log_index.to_be_bytes());
226 let digest = h.finalize();
227 Ok(DekShredAttestation {
228 attestation_class: RuntimeSignatureClass::None,
229 attestation_bytes: Bytes::copy_from_slice(digest.as_bytes()),
230 log_index: Some(log_index),
231 })
232 }
233
234 #[cfg(feature = "tier-2-pqc-receipts")]
239 pub(crate) fn set_cached_attestation(&mut self, dek_id: DekId, att: DekShredAttestation) {
240 self.shredded.insert(dek_id, att);
241 }
242}
243
244impl DekShredder for InMemoryDekShredder {
245 fn shred(&mut self, dek_id: DekId) -> Result<DekShredAttestation, DekShredError> {
246 if let Some(cached) = self.shredded.get(&dek_id) {
247 return Ok(cached.clone());
248 }
249 if self.live.remove(&dek_id).is_none() {
250 return Err(DekShredError::UnknownDek);
251 }
252 let attestation = self.issue_attestation(dek_id)?;
253 self.shredded.insert(dek_id, attestation.clone());
254 Ok(attestation)
255 }
256}
257
258#[cfg(feature = "tier-2-pqc-receipts")]
266#[derive(Debug)]
267pub struct SigningDekShredder {
268 inner: InMemoryDekShredder,
269 signer: crate::verifier::ReceiptSigner,
270}
271
272#[cfg(feature = "tier-2-pqc-receipts")]
273impl SigningDekShredder {
274 #[must_use]
276 pub fn new(inner: InMemoryDekShredder, signer: crate::verifier::ReceiptSigner) -> Self {
277 Self { inner, signer }
278 }
279
280 pub fn register(&mut self, dek_id: DekId) {
282 self.inner.register(dek_id);
283 }
284
285 #[must_use]
288 pub fn public_key_bytes(&self) -> &[u8] {
289 self.signer.public_key_bytes()
290 }
291}
292
293#[cfg(feature = "tier-2-pqc-receipts")]
294impl DekShredder for SigningDekShredder {
295 fn shred(&mut self, dek_id: DekId) -> Result<DekShredAttestation, DekShredError> {
296 let placeholder = self.inner.shred(dek_id)?;
299 let Some(log_index) = placeholder.log_index else {
300 return Ok(placeholder);
303 };
304 let message = crate::verifier::dek_shred_message(&dek_id, log_index);
305 let sig = self.signer.sign(&message);
306 let signed = DekShredAttestation {
307 attestation_class: RuntimeSignatureClass::MlDsa65,
308 attestation_bytes: Bytes::from(sig),
309 log_index: Some(log_index),
310 };
311 self.inner.set_cached_attestation(dek_id, signed.clone());
314 Ok(signed)
315 }
316}
317
318#[derive(Debug, Clone, Default)]
324pub struct UserPiiRows {
325 pub rows: Vec<u64>,
327 pub dek_id: Option<DekId>,
329}
330
331pub trait PiiRowStore: Send + Sync {
334 fn rows_for(&self, user: UserId) -> UserPiiRows;
337 fn tombstone(&mut self, user: UserId) -> Result<(), ProjectionError>;
340 fn is_tombstoned(&self, user: UserId) -> bool;
342}
343
344#[derive(Debug, Default)]
346pub struct InMemoryPiiRowStore {
347 users: HashMap<UserId, UserPiiRows>,
348 tombstoned: HashMap<UserId, ()>,
349}
350
351impl InMemoryPiiRowStore {
352 #[inline]
354 #[must_use]
355 pub fn new() -> Self {
356 Self::default()
357 }
358
359 pub fn upsert(&mut self, user: UserId, rows: Vec<u64>, dek_id: DekId) {
362 self.users.insert(
363 user,
364 UserPiiRows {
365 rows,
366 dek_id: Some(dek_id),
367 },
368 );
369 }
370}
371
372impl PiiRowStore for InMemoryPiiRowStore {
373 fn rows_for(&self, user: UserId) -> UserPiiRows {
374 self.users.get(&user).cloned().unwrap_or_default()
375 }
376
377 fn tombstone(&mut self, user: UserId) -> Result<(), ProjectionError> {
378 self.users.remove(&user);
379 self.tombstoned.insert(user, ());
380 Ok(())
381 }
382
383 fn is_tombstoned(&self, user: UserId) -> bool {
384 self.tombstoned.contains_key(&user)
385 }
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
395pub struct ErasureCompletion {
396 pub user: UserId,
398 pub completed_tick: Tick,
400 pub tombstoned_rows: usize,
402 pub attestation: DekShredAttestation,
405 pub regions: Vec<RegionProgress>,
410}
411
412pub struct ErasureCascadeObserver {
425 observes: [TypeCode; 1],
426 cursor: Option<ProjectionCursor>,
427 rows: Box<dyn PiiRowStore>,
428 shredder: Box<dyn DekShredder>,
429 completions: Vec<ErasureCompletion>,
430}
431
432impl ErasureCascadeObserver {
433 #[must_use]
435 pub fn new(rows: Box<dyn PiiRowStore>, shredder: Box<dyn DekShredder>) -> Self {
436 Self {
437 observes: [TypeCode(UserErasureScheduled::TYPE_CODE)],
438 cursor: None,
439 rows,
440 shredder,
441 completions: Vec::new(),
442 }
443 }
444
445 pub fn drain_completions(&mut self) -> Vec<ErasureCompletion> {
449 core::mem::take(&mut self.completions)
450 }
451
452 #[must_use]
454 pub fn pii_rows(&self) -> &dyn PiiRowStore {
455 self.rows.as_ref()
456 }
457
458 #[must_use]
460 pub fn shredder(&self) -> &dyn DekShredder {
461 self.shredder.as_ref()
462 }
463
464 #[must_use]
469 pub fn into_completed_event(
470 completion: &ErasureCompletion,
471 schema_version: u16,
472 transparency_log_index: u64,
473 ) -> UserErasureCompleted {
474 UserErasureCompleted {
475 schema_version,
476 user: completion.user,
477 dek_shred_tick: completion.completed_tick,
478 attestation_class: completion.attestation.attestation_class,
479 attestation_bytes: completion.attestation.attestation_bytes.clone(),
480 transparency_log_index,
481 }
482 }
483
484 #[must_use]
491 pub fn per_region_events(
492 completion: &ErasureCompletion,
493 schema_version: u16,
494 ) -> Vec<PerRegionErasureProgress> {
495 completion
496 .regions
497 .iter()
498 .map(|r| PerRegionErasureProgress {
499 schema_version,
500 user: completion.user,
501 scope: r.scope.clone(),
502 shred_tick: r.shred_tick,
503 attestation_class: r.attestation_class,
504 attestation_bytes: r.attestation_bytes.clone(),
505 })
506 .collect()
507 }
508}
509
510impl Projection for ErasureCascadeObserver {
511 fn observes(&self) -> &[TypeCode] {
512 &self.observes
513 }
514
515 fn on_event(
516 &mut self,
517 event: &EventRecord,
518 ctx: &ProjectionContext<'_>,
519 ) -> Result<(), ProjectionError> {
520 let scheduled: UserErasureScheduled = postcard::from_bytes(&event.payload)
521 .map_err(|_| ProjectionError::DecodeFailed("UserErasureScheduled payload"))?;
522 let user = scheduled.user;
523 let rows = self.rows.rows_for(user);
524 let tombstoned = rows.rows.len();
525
526 let result: ShredResult = match rows.dek_id {
536 Some(dek_id) => match self.shredder.shred_with_regions(dek_id, ctx.tick) {
537 Ok(r) => r,
538 Err(DekShredError::AlreadyShredded) => {
539 return Err(ProjectionError::Storage(
545 "shredder returned AlreadyShredded; implementations must cache attestation",
546 ));
547 }
548 Err(DekShredError::UnknownDek) => {
549 return Err(ProjectionError::Storage("DEK unknown to shredder"));
550 }
551 Err(DekShredError::Backend(msg)) => return Err(ProjectionError::Storage(msg)),
552 },
553 None => ShredResult {
554 regions: Vec::new(),
555 overall: DekShredAttestation {
556 attestation_class: RuntimeSignatureClass::None,
557 attestation_bytes: Bytes::new(),
558 log_index: None,
559 },
560 },
561 };
562
563 self.rows.tombstone(user)?;
567
568 self.completions.push(ErasureCompletion {
569 user,
570 completed_tick: ctx.tick,
571 tombstoned_rows: tombstoned,
572 attestation: result.overall,
573 regions: result.regions,
574 });
575
576 self.cursor = Some(ProjectionCursor {
577 sequence: event.sequence,
578 tick: event.tick,
579 });
580 Ok(())
581 }
582
583 fn on_state_change(&mut self, _new_state: ObserverState) -> Result<(), ProjectionError> {
584 Ok(())
588 }
589
590 fn last_applied(&self) -> Option<(u64, Tick)> {
591 self.cursor.map(|c| (c.sequence, c.tick))
592 }
593}
594
595#[cfg(test)]
598#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
599mod tests {
600 use super::*;
601 use crate::projection::ProjectionRouter;
602 use arkhe_kernel::abi::{EntityId, InstanceId};
603
604 fn uid(v: u64) -> UserId {
605 UserId::new(EntityId::new(v).unwrap())
606 }
607
608 fn make_scheduled_event(user: UserId, seq: u64, tick: u64) -> EventRecord {
609 let ev = UserErasureScheduled {
610 schema_version: 1,
611 user,
612 scheduled_tick: Tick(tick),
613 };
614 EventRecord {
615 type_code: UserErasureScheduled::TYPE_CODE,
616 sequence: seq,
617 tick: Tick(tick),
618 payload: Bytes::from(postcard::to_stdvec(&ev).unwrap()),
619 }
620 }
621
622 fn ctx(tick: u64) -> ProjectionContext<'static> {
623 ProjectionContext::new(Tick(tick), InstanceId::new(1).unwrap())
624 }
625
626 #[test]
627 fn observer_observes_user_erasure_scheduled_only() {
628 let obs = ErasureCascadeObserver::new(
629 Box::new(InMemoryPiiRowStore::new()),
630 Box::new(InMemoryDekShredder::new()),
631 );
632 assert_eq!(obs.observes(), &[TypeCode(UserErasureScheduled::TYPE_CODE)]);
633 }
634
635 #[test]
636 fn cascade_tombstones_rows_and_shreds_dek() {
637 let mut store = InMemoryPiiRowStore::new();
638 let user = uid(42);
639 let dek_id = DekId([0xAB; 16]);
640 store.upsert(user, vec![10, 11, 12], dek_id);
641 let mut shredder = InMemoryDekShredder::new();
642 shredder.register(dek_id);
643 let mut obs = ErasureCascadeObserver::new(Box::new(store), Box::new(shredder));
644
645 obs.on_event(&make_scheduled_event(user, 0, 100), &ctx(100))
646 .unwrap();
647
648 assert!(obs.pii_rows().is_tombstoned(user));
649 let completions = obs.drain_completions();
650 assert_eq!(completions.len(), 1);
651 assert_eq!(completions[0].user, user);
652 assert_eq!(completions[0].tombstoned_rows, 3);
653 assert_eq!(completions[0].completed_tick, Tick(100));
654 assert_eq!(
655 completions[0].attestation.attestation_class,
656 RuntimeSignatureClass::None
657 );
658 }
659
660 #[test]
661 fn cascade_no_rows_still_emits_completion() {
662 let obs_store = InMemoryPiiRowStore::new();
663 let shredder = InMemoryDekShredder::new();
664 let mut obs = ErasureCascadeObserver::new(Box::new(obs_store), Box::new(shredder));
665 let user = uid(7);
666 obs.on_event(&make_scheduled_event(user, 0, 5), &ctx(5))
667 .unwrap();
668 let completions = obs.drain_completions();
669 assert_eq!(completions.len(), 1);
670 assert_eq!(completions[0].tombstoned_rows, 0);
671 assert_eq!(
672 completions[0].attestation.attestation_class,
673 RuntimeSignatureClass::None
674 );
675 assert_eq!(completions[0].attestation.log_index, None);
678 }
679
680 #[test]
681 fn first_shred_log_index_is_some_zero_distinct_from_no_dek() {
682 let mut store = InMemoryPiiRowStore::new();
687 let user_real = uid(101);
688 let dek_id = DekId([0xAA; 16]);
689 store.upsert(user_real, vec![1], dek_id);
690 let mut shredder = InMemoryDekShredder::new();
691 shredder.register(dek_id);
692 let mut obs = ErasureCascadeObserver::new(Box::new(store), Box::new(shredder));
693
694 obs.on_event(&make_scheduled_event(user_real, 0, 50), &ctx(50))
695 .unwrap();
696 let real = obs.drain_completions();
697 assert_eq!(real[0].attestation.log_index, Some(0));
698
699 let user_empty = uid(202);
700 obs.on_event(&make_scheduled_event(user_empty, 1, 51), &ctx(51))
701 .unwrap();
702 let empty = obs.drain_completions();
703 assert_eq!(empty[0].attestation.log_index, None);
704 }
705
706 #[test]
707 fn shred_log_index_overflow_surfaces_backend_error() {
708 let mut shredder = InMemoryDekShredder::new();
715
716 let dek_below = DekId([0x01; 16]);
719 shredder.register(dek_below);
720 shredder.set_next_log_index_for_test(u64::MAX - 1);
721 let below = shredder.shred(dek_below).unwrap();
722 assert_eq!(below.log_index, Some(u64::MAX - 1));
723
724 let dek_overflow = DekId([0x02; 16]);
726 shredder.register(dek_overflow);
727 let err = shredder.shred(dek_overflow).unwrap_err();
728 assert!(
729 matches!(err, DekShredError::Backend(msg) if msg.contains("overflow")),
730 "log index overflow must surface as a backend error, got {err:?}"
731 );
732 }
733
734 #[test]
735 fn cascade_unknown_dek_surfaces_storage_error() {
736 let mut store = InMemoryPiiRowStore::new();
737 let user = uid(9);
738 store.upsert(user, vec![1], DekId([0x99; 16]));
740 let shredder = InMemoryDekShredder::new();
741 let mut obs = ErasureCascadeObserver::new(Box::new(store), Box::new(shredder));
742 let err = obs
743 .on_event(&make_scheduled_event(user, 0, 5), &ctx(5))
744 .unwrap_err();
745 assert!(matches!(err, ProjectionError::Storage(_)));
746 assert!(!obs.pii_rows().is_tombstoned(user));
748 }
749
750 #[test]
751 fn shred_failure_keeps_rows_live() {
752 struct FailingShredder;
755 impl DekShredder for FailingShredder {
756 fn shred(&mut self, _dek_id: DekId) -> Result<DekShredAttestation, DekShredError> {
757 Err(DekShredError::Backend("inject: KMS unavailable"))
758 }
759 }
760
761 let mut store = InMemoryPiiRowStore::new();
762 let user = uid(777);
763 let dek_id = DekId([0xAA; 16]);
764 store.upsert(user, vec![1, 2, 3], dek_id);
765 let mut obs = ErasureCascadeObserver::new(Box::new(store), Box::new(FailingShredder));
766
767 let err = obs
768 .on_event(&make_scheduled_event(user, 0, 10), &ctx(10))
769 .unwrap_err();
770 assert!(matches!(err, ProjectionError::Storage(_)));
771
772 assert!(!obs.pii_rows().is_tombstoned(user));
774 assert_eq!(obs.pii_rows().rows_for(user).rows.len(), 3);
775
776 assert!(obs.drain_completions().is_empty());
778 }
779
780 #[test]
781 fn already_shredded_surfaces_as_storage_error() {
782 struct BrokenShredder;
787 impl DekShredder for BrokenShredder {
788 fn shred(&mut self, _dek_id: DekId) -> Result<DekShredAttestation, DekShredError> {
789 Err(DekShredError::AlreadyShredded)
790 }
791 }
792
793 let mut store = InMemoryPiiRowStore::new();
794 let user = uid(999);
795 let dek_id = DekId([0xCC; 16]);
796 store.upsert(user, vec![1], dek_id);
797 let mut obs = ErasureCascadeObserver::new(Box::new(store), Box::new(BrokenShredder));
798
799 let err = obs
800 .on_event(&make_scheduled_event(user, 0, 30), &ctx(30))
801 .unwrap_err();
802 assert!(matches!(err, ProjectionError::Storage(_)));
803 assert!(!obs.pii_rows().is_tombstoned(user));
805 assert!(obs.drain_completions().is_empty());
806 }
807
808 #[test]
809 fn cascade_replay_after_tombstone_holds_rows_dead() {
810 let mut store = InMemoryPiiRowStore::new();
817 let user = uid(1234);
818 let dek_id = DekId([0xEF; 16]);
819 store.upsert(user, vec![1, 2], dek_id);
820 let mut shredder = InMemoryDekShredder::new();
821 shredder.register(dek_id);
822 let mut obs = ErasureCascadeObserver::new(Box::new(store), Box::new(shredder));
823
824 obs.on_event(&make_scheduled_event(user, 0, 40), &ctx(40))
825 .unwrap();
826 let first = obs.drain_completions();
827 assert_eq!(first.len(), 1);
828 assert_eq!(
829 first[0].attestation.attestation_class,
830 RuntimeSignatureClass::None
831 );
832 assert!(obs.pii_rows().is_tombstoned(user));
833
834 obs.on_event(&make_scheduled_event(user, 1, 41), &ctx(41))
836 .unwrap();
837 let replayed = obs.drain_completions();
838 assert_eq!(replayed.len(), 1);
839 assert_eq!(
840 replayed[0].attestation.attestation_class,
841 RuntimeSignatureClass::None
842 );
843 assert!(obs.pii_rows().is_tombstoned(user));
844 }
845
846 #[test]
847 fn cascade_participates_in_projection_router_dispatch() {
848 let mut store = InMemoryPiiRowStore::new();
849 let user = uid(123);
850 let dek_id = DekId([0xEE; 16]);
851 store.upsert(user, vec![1, 2], dek_id);
852 let mut shredder = InMemoryDekShredder::new();
853 shredder.register(dek_id);
854
855 let mut router = ProjectionRouter::new();
856 router.promote_to_active().unwrap();
857 router.register(Box::new(ErasureCascadeObserver::new(
858 Box::new(store),
859 Box::new(shredder),
860 )));
861
862 let applied = router
863 .dispatch(&make_scheduled_event(user, 0, 300), &ctx(300))
864 .unwrap();
865 assert_eq!(applied, 1);
866 }
867
868 #[test]
869 fn completed_event_roundtrip_via_helper() {
870 let completion = ErasureCompletion {
871 user: uid(1),
872 completed_tick: Tick(250),
873 tombstoned_rows: 4,
874 attestation: DekShredAttestation {
875 attestation_class: RuntimeSignatureClass::Hybrid,
876 attestation_bytes: Bytes::from_static(&[0u8; 128]),
877 log_index: Some(7),
878 },
879 regions: Vec::new(),
880 };
881 let event = ErasureCascadeObserver::into_completed_event(&completion, 1, 99);
882 assert_eq!(event.user, uid(1));
883 assert_eq!(event.dek_shred_tick, Tick(250));
884 assert_eq!(event.attestation_class, RuntimeSignatureClass::Hybrid);
885 assert_eq!(event.transparency_log_index, 99);
886
887 let bytes = postcard::to_stdvec(&event).unwrap();
889 let back: UserErasureCompleted = postcard::from_bytes(&bytes).unwrap();
890 assert_eq!(back, event);
891 }
892
893 #[test]
894 fn per_region_events_default_emits_one_entry_per_completion() {
895 let mut store = InMemoryPiiRowStore::new();
899 let user = uid(11);
900 let dek_id = DekId([0xAA; 16]);
901 store.upsert(user, vec![1, 2, 3], dek_id);
902 let mut shredder = InMemoryDekShredder::new();
903 shredder.register(dek_id);
904 let mut obs = ErasureCascadeObserver::new(Box::new(store), Box::new(shredder));
905
906 obs.on_event(&make_scheduled_event(user, 0, 100), &ctx(100))
907 .unwrap();
908 let completions = obs.drain_completions();
909 assert_eq!(completions.len(), 1);
910 let completion = &completions[0];
911 assert_eq!(
912 completion.regions.len(),
913 1,
914 "single-region default emits 1 entry"
915 );
916 let region = &completion.regions[0];
917 assert!(matches!(region.scope, ProgressScope::Region(_)));
918 assert_eq!(region.shred_tick, Tick(100));
919 assert_eq!(region.attestation_class, RuntimeSignatureClass::None);
920
921 let events = ErasureCascadeObserver::per_region_events(completion, 1);
922 assert_eq!(events.len(), 1);
923 assert_eq!(events[0].user, user);
924 assert_eq!(events[0].shred_tick, Tick(100));
925
926 let bytes = postcard::to_stdvec(&events[0]).unwrap();
928 let back: PerRegionErasureProgress = postcard::from_bytes(&bytes).unwrap();
929 assert_eq!(back, events[0]);
930 }
931
932 #[test]
933 fn per_region_events_no_dek_user_emits_zero_entries() {
934 let store = InMemoryPiiRowStore::new();
938 let user = uid(12);
939 let shredder = InMemoryDekShredder::new();
940 let mut obs = ErasureCascadeObserver::new(Box::new(store), Box::new(shredder));
941
942 obs.on_event(&make_scheduled_event(user, 0, 200), &ctx(200))
943 .unwrap();
944 let completions = obs.drain_completions();
945 assert_eq!(completions.len(), 1);
946 assert!(completions[0].regions.is_empty());
947 let events = ErasureCascadeObserver::per_region_events(&completions[0], 1);
948 assert!(events.is_empty());
949 }
950
951 #[test]
957 fn e_user_3_cascade_activates_end_to_end() {
958 use arkhe_forge_core::action::ActionCompute;
959 use arkhe_forge_core::context::ActionContext as L1ActionContext;
960 use arkhe_forge_core::user::GdprEraseUser;
961 use arkhe_kernel::abi::{CapabilityMask, Principal};
962
963 let user = uid(7777);
964
965 let act = GdprEraseUser {
967 schema_version: 1,
968 user,
969 };
970 let mut l1 = L1ActionContext::new(
971 [0u8; 32],
972 InstanceId::new(1).unwrap(),
973 Tick(100),
974 Principal::System,
975 CapabilityMask::SYSTEM,
976 );
977 act.compute(&mut l1).unwrap();
978 let mut events = l1.drain_events();
979 assert_eq!(events.len(), 1);
980 let scheduling_record = events.pop().unwrap();
981
982 let mut store = InMemoryPiiRowStore::new();
984 let dek_id = DekId([0xCD; 16]);
985 store.upsert(user, vec![100, 101, 102, 103], dek_id);
986 let mut shredder = InMemoryDekShredder::new();
987 shredder.register(dek_id);
988 let mut router = ProjectionRouter::new();
989 router.promote_to_active().unwrap();
990 router.register(Box::new(ErasureCascadeObserver::new(
991 Box::new(store),
992 Box::new(shredder),
993 )));
994 let event_record = EventRecord {
995 type_code: scheduling_record.type_code,
996 sequence: 0,
997 tick: scheduling_record.tick,
998 payload: scheduling_record.payload.clone(),
999 };
1000 let applied = router.dispatch(&event_record, &ctx(100)).unwrap();
1001 assert_eq!(applied, 1);
1002 }
1003}