Skip to main content

arkhe_forge_platform/
crypto_erasure.rs

1//! Erasure cascade observer — E-user-3 cascade activation.
2//!
3//! When the L1 compute emits `UserErasureScheduled` (via `GdprEraseUser`
4//! lease), this observer drains the user's `EncryptedPii<T>` rows, writes
5//! per-row tombstones, and emits the terminal `UserErasureCompleted`
6//! receipt once the DEK has been shredded. The SLA is `p95 < 24h`; this
7//! module ships the deterministic in-memory path used for tests + the
8//! Tier-0 dev harness. The real HSM `delete_key` + multi-region 2PC
9//! fanout land alongside the `hf2_kms` backend — the observer's
10//! [`Projection`] surface is stable.
11//!
12//! The observer is **not** a Band-1 compute path; it is a Band-2 derived
13//! projection that consumes WAL-anchored events. Every mutation goes
14//! through the same `Projection::on_event` entry-point so the router's
15//! dedup + gap detection catches replay drift.
16
17use 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
32// ===================== DEK shredder =====================
33
34/// Per-user DEK shred backend. Production wires this
35/// to an HSM `delete_key` RPC; the in-memory implementation (below) is
36/// sufficient for the Tier-0 dev harness.
37///
38/// **Idempotency contract**: a compliant implementation caches the
39/// attestation emitted on the first shred call for `dek_id` and returns
40/// `Ok(cached)` on any subsequent call for the same id. `Err(AlreadyShredded)`
41/// is reserved for stateless backends that cannot cache; the cascade
42/// observer surfaces it as [`ProjectionError::Storage`] and refuses
43/// completion — the emit path must never synthesise a replacement
44/// attestation, otherwise the `UserErasureCompleted` receipt loses its
45/// cryptographic binding to the HSM destruction event.
46pub trait DekShredder: Send + Sync {
47    /// Drop plaintext DEK material for `dek_id` and return an
48    /// attestation payload the observer folds into the
49    /// `UserErasureCompleted.attestation_bytes` field. See the trait-
50    /// level idempotency contract for replay semantics.
51    fn shred(&mut self, dek_id: DekId) -> Result<DekShredAttestation, DekShredError>;
52
53    /// Multi-region 2PC variant. Real backends
54    /// override this to drive a multi-KMS / multi-region shred and return
55    /// the per-region progress entries so the cascade observer can emit
56    /// matching `PerRegionErasureProgress` events.
57    ///
58    /// Default implementation calls [`Self::shred`] and wraps the result
59    /// as a single-region [`ShredResult`] — preserves the current single-
60    /// region semantics for backends that do not opt in.
61    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/// Per-region shred progress entry (two-phase commit).
81///
82/// Single-region backends emit one entry with [`ProgressScope::Region`] /
83/// `"default-region"`; multi-region backends emit one entry per
84/// participating region or KMS endpoint.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct RegionProgress {
87    /// Region or KMS scope identifier.
88    pub scope: ProgressScope,
89    /// Tick at which this scope's DEK shred completed.
90    pub shred_tick: Tick,
91    /// Signature class used for this scope's attestation payload.
92    pub attestation_class: RuntimeSignatureClass,
93    /// Signed attestation bytes for this scope.
94    pub attestation_bytes: Bytes,
95}
96
97/// Multi-region aggregate result returned by
98/// [`DekShredder::shred_with_regions`]. The `overall` attestation is what
99/// the observer folds into the terminal `UserErasureCompleted` receipt;
100/// the per-region `regions` list drives the intermediate
101/// `PerRegionErasureProgress` events.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct ShredResult {
104    /// Per-region progress, in shred-completion order.
105    pub regions: Vec<RegionProgress>,
106    /// Aggregate attestation. For single-region backends this matches the
107    /// only `RegionProgress` entry; for multi-region backends, the
108    /// coordinator-signed roll-up.
109    pub overall: DekShredAttestation,
110}
111
112/// Single-region default scope — `Region("default-region")`. Stable
113/// sentinel for backends that do not opt into multi-region shred.
114///
115/// `"default-region"` is 14 ASCII bytes, well within `BoundedString::<64>`'s
116/// cap, so construction is infallible; the `expect` documents the invariant.
117#[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/// HSM / KMS key-destruction receipt — what the shredder returns once a
125/// DEK is gone. The attestation class + bytes are forwarded into the
126/// `UserErasureCompleted` event.
127///
128/// `log_index` is `Option<u64>` so the no-DEK observer branch (cascade
129/// for a user whose row store never held a DEK) can emit `None` rather
130/// than the sentinel value `0` — the previous placeholder collided with
131/// the genuine "first-shred" log entry of a real shredder.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct DekShredAttestation {
134    /// Signature class used for the attestation (`Ed25519`, `Hybrid`, …).
135    pub attestation_class: RuntimeSignatureClass,
136    /// Signed attestation payload.
137    pub attestation_bytes: Bytes,
138    /// Monotonic destruction-log sequence — used by the transparency
139    /// layer for gap detection. `None` when the
140    /// attestation is a synthetic no-DEK placeholder (no transparency
141    /// entry to anchor); `Some(n)` when a real shredder issued the
142    /// receipt. The type is **not** wire-serialised — observer-local
143    /// state forwarded into `UserErasureCompleted` via
144    /// [`ErasureCascadeObserver::into_completed_event`], where the
145    /// caller supplies the WAL `transparency_log_index` independently.
146    pub log_index: Option<u64>,
147}
148
149/// DEK shred failure taxonomy.
150#[non_exhaustive]
151#[derive(Debug, thiserror::Error)]
152pub enum DekShredError {
153    /// `dek_id` was not recognised — benign on replay of an already
154    /// shredded user, fatal if the observer is first-seeing the event.
155    #[error("DEK id unknown to the shredder")]
156    UnknownDek,
157    /// DEK had been shredded on a previous call — the observer treats
158    /// this as a no-op + reuses the cached attestation.
159    #[error("DEK already shredded")]
160    AlreadyShredded,
161    /// Backend-specific failure — network, auth, RPC error. Observers
162    /// surface this via `ProjectionError::Storage`.
163    #[error("shredder backend error: {0}")]
164    Backend(&'static str),
165}
166
167/// In-memory [`DekShredder`] — deterministic transparency-digest
168/// placeholder for tests and the Tier-0 harness. The payload is a BLAKE3
169/// keyed hash (a MAC / transparency digest), NOT a signature, so the
170/// attestation class is [`RuntimeSignatureClass::None`]: a verifier
171/// presented with this attestation correctly rejects it as "not a
172/// signature". Real ML-DSA-65 receipt signing is provided by
173/// `SigningDekShredder` under the `tier-2-pqc-receipts` feature.
174#[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    /// Construct an empty shredder — no DEKs registered.
183    #[inline]
184    #[must_use]
185    pub fn new() -> Self {
186        Self::default()
187    }
188
189    /// Register a DEK the observer will later be asked to shred. The
190    /// shredder does not store plaintext key material — just the id.
191    pub fn register(&mut self, dek_id: DekId) {
192        self.live.insert(dek_id, ());
193    }
194
195    /// Check whether a given DEK id was shredded.
196    #[must_use]
197    pub fn is_shredded(&self, dek_id: &DekId) -> bool {
198        self.shredded.contains_key(dek_id)
199    }
200
201    /// Test-only — force the next log index so the overflow guard in
202    /// [`Self::issue_attestation`] can be exercised without 2^64 shreds.
203    #[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        // `checked_add` rather than `saturating_add` — saturating would
211        // pin the index at `u64::MAX` and hand out colliding log entries,
212        // silently breaking the transparency layer's gap detection. An
213        // overflow here is operationally impossible (2^64 shreds) but we
214        // surface it as a hard backend error rather than corrupt the log.
215        self.next_log_index = self
216            .next_log_index
217            .checked_add(1)
218            .ok_or(DekShredError::Backend("DEK shred log index overflow"))?;
219        // Deterministic payload — domain-separated BLAKE3 keyed by
220        // `dek_id`. This is a MAC / transparency digest, NOT a signature,
221        // so the class is `None`. Production paths sign with an HSM-held
222        // or ML-DSA key (see `SigningDekShredder`).
223        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    /// Overwrite the cached attestation for `dek_id` (idempotency-cache
235    /// coherence). Used by [`SigningDekShredder`] to keep the cache in
236    /// sync after re-issuing a real ML-DSA-65 signature, so a replay
237    /// returns the signed attestation rather than the digest placeholder.
238    #[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/// ML-DSA-65 signing [`DekShredder`] — wraps an [`InMemoryDekShredder`]
259/// and re-issues a real 3309-byte ML-DSA-65 signature over the canonical
260/// DEK-shred message (`tier-2-pqc-receipts`). The inner shredder drives
261/// the live→shredded transition, the monotonic `log_index`, and the
262/// idempotency cache; this wrapper replaces the placeholder digest with a
263/// genuine signature (class [`RuntimeSignatureClass::MlDsa65`]) and keeps
264/// the inner cache coherent so a replay returns the signed attestation.
265#[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    /// Construct from an inner shredder and a receipt signer.
275    #[must_use]
276    pub fn new(inner: InMemoryDekShredder, signer: crate::verifier::ReceiptSigner) -> Self {
277        Self { inner, signer }
278    }
279
280    /// Register a DEK with the inner shredder.
281    pub fn register(&mut self, dek_id: DekId) {
282        self.inner.register(dek_id);
283    }
284
285    /// Borrow the signer's verifying-key bytes (1952 bytes) so a verifier
286    /// can check the emitted attestation under a policy-pinned class.
287    #[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        // Drive the live→shredded transition + log_index + idempotency via
297        // the inner shredder, then re-issue a real ML-DSA-65 signature.
298        let placeholder = self.inner.shred(dek_id)?;
299        let Some(log_index) = placeholder.log_index else {
300            // No transparency entry to anchor a signature against — pass
301            // the inner attestation through unchanged.
302            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        // Keep the inner idempotency cache coherent: a replay must return
312        // the signed attestation, not the digest placeholder.
313        self.inner.set_cached_attestation(dek_id, signed.clone());
314        Ok(signed)
315    }
316}
317
318// ===================== PII tombstone store =====================
319
320/// Per-user encrypted-PII row descriptor — opaque to the cascade. The
321/// observer never decrypts; it just rewrites the row into a tombstone
322/// and re-emits a DEK shred signal.
323#[derive(Debug, Clone, Default)]
324pub struct UserPiiRows {
325    /// Encrypted-PII row ids attached to the user.
326    pub rows: Vec<u64>,
327    /// `DekId` the user's ciphertexts are wrapped under.
328    pub dek_id: Option<DekId>,
329}
330
331/// Per-user store — production uses a PG-backed table; this in-memory
332/// map suits tests.
333pub trait PiiRowStore: Send + Sync {
334    /// Fetch the set of encrypted-PII rows attached to `user`. Returns
335    /// a fresh `UserPiiRows` (possibly empty) when the user has none.
336    fn rows_for(&self, user: UserId) -> UserPiiRows;
337    /// Mark every row as tombstoned. Idempotent — a repeat call after
338    /// cascade completion must be a no-op.
339    fn tombstone(&mut self, user: UserId) -> Result<(), ProjectionError>;
340    /// Whether `user`'s rows have already been tombstoned.
341    fn is_tombstoned(&self, user: UserId) -> bool;
342}
343
344/// In-memory [`PiiRowStore`] for tests.
345#[derive(Debug, Default)]
346pub struct InMemoryPiiRowStore {
347    users: HashMap<UserId, UserPiiRows>,
348    tombstoned: HashMap<UserId, ()>,
349}
350
351impl InMemoryPiiRowStore {
352    /// Empty store.
353    #[inline]
354    #[must_use]
355    pub fn new() -> Self {
356        Self::default()
357    }
358
359    /// Attach `rows` to `user` under the given DEK id. Test fixture
360    /// setter.
361    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// ===================== ErasureCascadeObserver =====================
389
390/// Completion record — one per user, accumulated as the observer runs.
391/// The router exposes these to callers that want to emit the matching
392/// `PerRegionErasureProgress` (one per `regions` entry) plus the terminal
393/// `UserErasureCompleted` event onto the WAL (`ctx.emit_event`).
394#[derive(Debug, Clone, PartialEq, Eq)]
395pub struct ErasureCompletion {
396    /// Subject.
397    pub user: UserId,
398    /// Tick at which the cascade finished.
399    pub completed_tick: Tick,
400    /// Row count tombstoned.
401    pub tombstoned_rows: usize,
402    /// Aggregate DEK shred attestation — folded into
403    /// `UserErasureCompleted.attestation_bytes`.
404    pub attestation: DekShredAttestation,
405    /// Per-region progress entries (single-element Vec for single-region
406    /// backends). Each entry maps 1:1 to a `PerRegionErasureProgress`
407    /// event the caller emits before the terminal `UserErasureCompleted`
408    /// (two-phase commit).
409    pub regions: Vec<RegionProgress>,
410}
411
412/// L2 observer that drives the **E-user-3 cascade**. On every
413/// `UserErasureScheduled` event it:
414///
415/// 1. Looks up the user's encrypted-PII rows via the attached
416///    [`PiiRowStore`].
417/// 2. Calls [`PiiRowStore::tombstone`] to mark every row soft-deleted.
418/// 3. Invokes the attached [`DekShredder`] to drop the underlying DEK.
419/// 4. Records an [`ErasureCompletion`] the caller can convert into a
420///    `UserErasureCompleted` event on the next tick.
421///
422/// The observer holds its own cursor — the router still centralises
423/// dedup + gap detection via the `Projection` trait.
424pub 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    /// Construct the observer with concrete backends.
434    #[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    /// Drain accumulated [`ErasureCompletion`]s. The caller feeds each
446    /// one back into an `ActionContext::emit_event(UserErasureCompleted
447    /// { ... })` so the next WAL tick anchors the receipt.
448    pub fn drain_completions(&mut self) -> Vec<ErasureCompletion> {
449        core::mem::take(&mut self.completions)
450    }
451
452    /// Borrow the store for inspection — tests only.
453    #[must_use]
454    pub fn pii_rows(&self) -> &dyn PiiRowStore {
455        self.rows.as_ref()
456    }
457
458    /// Borrow the shredder for inspection — tests only.
459    #[must_use]
460    pub fn shredder(&self) -> &dyn DekShredder {
461        self.shredder.as_ref()
462    }
463
464    /// Convenience — build a `UserErasureCompleted` event from a
465    /// drained completion. The caller chooses the `schema_version` /
466    /// transparency log index from its own anchor; this helper wires
467    /// the remaining five fields.
468    #[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    /// Convenience — fan out a completion's per-region progress entries
485    /// as `PerRegionErasureProgress` events (two-phase
486    /// commit). Single-region backends emit one event; multi-
487    /// region backends emit one event per participating region. The
488    /// caller emits these *before* the terminal `UserErasureCompleted`
489    /// receipt so external consumers see the full erasure transcript.
490    #[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        // 2PC ordering — **shred first**, tombstone second. A failure in
527        // the shred step aborts before any row state changes, so rows
528        // remain live + DEK live (a clean retry state). A failure in the
529        // tombstone step (post-shred) leaves the ciphertext already
530        // crypto-erased: replay re-runs tombstone under the cached
531        // attestation the idempotent shredder returns. The inverse order
532        // would expose a backup-replay window where rows look dead while
533        // the DEK is still live, letting an adversary with a pre-
534        // tombstone snapshot unwrap the ciphertext.
535        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                    // Shredder violated the idempotency contract (must
540                    // cache + replay `Ok`). Refuse completion so the
541                    // operator can re-register the DEK rather than emit
542                    // an empty-attestation receipt that a regulator
543                    // would reject as invalid proof of destruction.
544                    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        // DEK is destroyed; tombstone the rows for storage hygiene. If
564        // this step fails the ciphertext is already GDPR-erased and the
565        // next replay redoes the tombstone idempotently.
566        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        // No-op — completions persist across promotions. Demotion /
585        // drain callers pull pending completions via
586        // `drain_completions` before ceding primary.
587        Ok(())
588    }
589
590    fn last_applied(&self) -> Option<(u64, Tick)> {
591        self.cursor.map(|c| (c.sequence, c.tick))
592    }
593}
594
595// ===================== Tests =====================
596
597#[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        // m13 — synthetic no-DEK attestation must not collide with the
676        // genuine `Some(0)` log entry of a real shredder.
677        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        // m13 regression — `InMemoryDekShredder::issue_attestation`
683        // hands out `Some(0)` on the first shred. Cascading a user
684        // with a real DEK and another with no DEK must show the two
685        // attestations are distinguishable on `log_index` alone.
686        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        // #19 regression — when the next log index can no longer advance
709        // (`next_log_index == u64::MAX`, so the post-issue increment would
710        // overflow), `checked_add` must surface `DekShredError::Backend`
711        // rather than saturating. Saturating would pin the index at the
712        // ceiling and hand out colliding log entries, breaking the
713        // transparency layer's gap detection.
714        let mut shredder = InMemoryDekShredder::new();
715
716        // One shred just below the ceiling still succeeds (index advances
717        // from MAX-1 to MAX).
718        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        // The next shred would need to advance past u64::MAX — overflow.
725        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        // DEK referenced by the store is NOT registered with the shredder.
739        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        // Shred-first ordering — failure aborts before tombstone.
747        assert!(!obs.pii_rows().is_tombstoned(user));
748    }
749
750    #[test]
751    fn shred_failure_keeps_rows_live() {
752        // Backend error during shred must leave rows untombstoned so a
753        // replay can retry without a backup-replay window.
754        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        // Rows must survive — tombstone was never attempted.
773        assert!(!obs.pii_rows().is_tombstoned(user));
774        assert_eq!(obs.pii_rows().rows_for(user).rows.len(), 3);
775
776        // No completion emitted.
777        assert!(obs.drain_completions().is_empty());
778    }
779
780    #[test]
781    fn already_shredded_surfaces_as_storage_error() {
782        // A shredder that breaks the idempotency contract by returning
783        // `AlreadyShredded` instead of the cached attestation must be
784        // refused — the observer will not synthesise a replacement
785        // attestation that would fail regulator verification.
786        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        // Shred-first ordering preserved row state for retry.
804        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        // Backup-restore smoke — a crashed cascade replayed from the WAL
811        // finds the rows already tombstoned. The in-memory store drops
812        // the DEK reference on tombstone, so the replay takes the no-
813        // rows branch and emits a placeholder completion; the key
814        // invariant is that rows stay tombstoned throughout and no
815        // panic / data resurface occurs.
816        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        // Replay (WAL-driven recovery).
835        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        // Wire-level roundtrip — confirm the event struct postcard-encodes.
888        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        // Single-region default — `DekShredder::shred_with_regions` wraps
896        // the single attestation as a 1-element Vec, so the cascade emits
897        // exactly one `PerRegionErasureProgress` per user.
898        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        // Wire-level roundtrip on the per-region event.
927        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        // A user whose row store never carried a DEK has `regions` left
935        // empty (the synthetic `RuntimeSignatureClass::None` attestation
936        // is overall-only) — zero `PerRegionErasureProgress` events.
937        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    /// **E-user-3 integration** — the axiom harness in `arkhe-forge-core`
952    /// pins that `GdprEraseUser::compute` emits `UserErasureScheduled`.
953    /// This platform-level test shows the cascade observer picks the
954    /// event up and reaches the `UserErasureCompleted` completion
955    /// record — E-user-3 cascade trigger.
956    #[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        // 1. Run the L1 compute to produce the scheduling event.
966        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        // 2. Feed the event into the cascade observer.
983        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}