Skip to main content

arkhe_forge_platform/
projection.rs

1//! L2 Projection observer pipeline.
2//!
3//! L0 emits deterministic events; L2 turns those events into denormalized
4//! read-model rows that PG (or another store) serves to higher layers. This
5//! module exposes: the [`Projection`] trait, a
6//! [`ProjectionRouter`] that dispatches [`EventRecord`]s by `TypeCode`,
7//! a [`ProjectionStore`] abstraction, an in-memory store, and active /
8//! passive / draining lifecycle transitions.
9//!
10//! The PG-backed store, the L0 observer bridge, and the
11//! `kernel_projection_state` chain-anchored view route through the trait surface.
12
13use 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// ===================== Lifecycle + Context + Errors =====================
29
30/// Observer worker lifecycle (active-passive HA).
31///
32/// * `Passive` — read-only secondary. Consumes events for warm standby but
33///   does not commit writes upstream.
34/// * `Active` — primary writer. The only projection state permitted to call
35///   `ProjectionStore` mutators.
36/// * `Draining` — graceful shutdown. Rejects new work, flushes in-flight.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum ObserverState {
40    /// Not primary; events are observed but writes are blocked.
41    Passive,
42    /// Primary writer.
43    Active,
44    /// Winding down — rejects new work.
45    Draining,
46}
47
48/// Outcome of an auto-promote policy evaluation.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50#[non_exhaustive]
51pub enum PromotionDecision {
52    /// Policy + elapsed time justify a `Passive → Active` transition.
53    Promote,
54    /// Policy requires additional wait / manual operator approval.
55    Wait,
56}
57
58/// Minimum number of KMS health channels that must report `Healthy` for the
59/// `after_60min` auto-promote policy to clear its guardrail. Matches the
60/// default 2-of-3 quorum — operators that provision more channels
61/// can re-tune via a future manifest field.
62pub const HF2_HEALTH_QUORUM_MIN: usize = 2;
63
64/// Per-dispatch context carried alongside an [`EventRecord`]. The `'i`
65/// lifetime reserves the slot that binds to the L0
66/// `Effect<'i, Authorized>` borrow, and also scopes the optional manifest
67/// snapshot reference.
68pub struct ProjectionContext<'i> {
69    /// Tick at which the event is being applied.
70    pub tick: Tick,
71    /// Runtime instance identifier.
72    pub instance_id: InstanceId,
73    /// Active manifest snapshot, if one has been loaded. `None` is legal
74    /// for Tier-0 dev bootstrap paths that run before the first manifest
75    /// has been emitted via `RuntimeBootstrap`.
76    pub manifest: Option<&'i ManifestSnapshot>,
77    _phantom: PhantomData<&'i ()>,
78}
79
80impl<'i> ProjectionContext<'i> {
81    /// Construct a projection dispatch context without a manifest.
82    #[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    /// Construct a projection dispatch context with an attached manifest
94    /// snapshot. Callers that have loaded a manifest should use this path
95    /// so projection workers can key on shell policy (tier, cipher, etc.).
96    #[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/// Projection-side failure taxonomy.
113#[derive(Debug, thiserror::Error)]
114#[non_exhaustive]
115pub enum ProjectionError {
116    /// Event sequence moved backward (corruption or mis-routed dispatch).
117    #[error("projection sequence backward: last {last}, incoming {incoming}")]
118    SequenceBackward {
119        /// Last sequence applied by this projection.
120        last: u64,
121        /// Sequence number of the rejected incoming event.
122        incoming: u64,
123    },
124
125    /// A sequence number was skipped — the replay harness needs to fetch the
126    /// missing range before this projection can advance.
127    #[error("projection sequence gap: last {last}, incoming {incoming}")]
128    SequenceGap {
129        /// Last sequence applied by this projection.
130        last: u64,
131        /// Sequence number of the event that exposed the gap.
132        incoming: u64,
133    },
134
135    /// Caller attempted a mutation in a non-`Active` state (observer is
136    /// Passive or Draining).
137    #[error("observer not active: current state {state:?}")]
138    NotActive {
139        /// Observer state at the time of the attempted mutation.
140        state: ObserverState,
141    },
142
143    /// Storage-layer error (in-memory corruption, PG driver, …).
144    #[error("projection storage error: {0}")]
145    Storage(&'static str),
146
147    /// Event payload failed to decode.
148    #[error("event decode failed: {0}")]
149    DecodeFailed(&'static str),
150
151    /// An event targeted an Actor / Space / Entry / Activity that the
152    /// projection has no row for.
153    #[error("projection row missing")]
154    MissingRow,
155}
156
157// ===================== Projection trait =====================
158
159/// L2 projection worker. Each implementor owns a read-model view that is
160/// kept in sync with the L1 event stream for a specific set of `TypeCode`s.
161///
162/// Implementors must be `Send + Sync` so the `ProjectionRouter` can run
163/// across worker threads; dedup / gap detection is centralised in the
164/// router using [`Projection::last_applied`].
165pub trait Projection: Send + Sync {
166    /// TypeCodes this projection observes — the router filters incoming
167    /// events against this slice.
168    fn observes(&self) -> &[TypeCode];
169
170    /// Apply an event. Called only after router-side dedup + gap checks
171    /// have succeeded. Implementations must:
172    ///
173    /// 1. Update their internal view state.
174    /// 2. Bump `last_applied` to the event's `(sequence, tick)`.
175    fn on_event(
176        &mut self,
177        event: &EventRecord,
178        ctx: &ProjectionContext<'_>,
179    ) -> Result<(), ProjectionError>;
180
181    /// React to a worker-state transition (Passive ↔ Active ↔ Draining).
182    /// Default is no-op.
183    fn on_state_change(&mut self, _new_state: ObserverState) -> Result<(), ProjectionError> {
184        Ok(())
185    }
186
187    /// Last `(sequence, tick)` applied — `None` if the projection is fresh.
188    fn last_applied(&self) -> Option<(u64, Tick)>;
189}
190
191// ===================== Projection view structs =====================
192
193/// `(u64, Tick)` pair tracking the last event applied.
194#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
195pub struct ProjectionCursor {
196    /// Last sequence number applied.
197    pub sequence: u64,
198    /// Tick at which the last event was applied.
199    pub tick: Tick,
200}
201
202/// Actor-facing read-model row — `ActorProfile` + optional `UserBinding`.
203#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
204pub struct ActorProjection {
205    /// Wire schema version.
206    pub schema_version: u16,
207    /// Actor identity.
208    pub actor_id: ActorId,
209    /// Authoritative `ActorProfile` Component.
210    pub profile: ActorProfile,
211    /// `UserBinding` is present iff the actor is `Authenticated` (E-actor-2).
212    pub user_binding: Option<UserBinding>,
213    /// Event cursor — dedup / gap anchor.
214    pub cursor: Option<ProjectionCursor>,
215}
216
217/// Space read-model row — `SpaceConfig` + parent-chain cache + membership.
218#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
219pub struct SpaceProjection {
220    /// Wire schema version.
221    pub schema_version: u16,
222    /// Space identity.
223    pub space_id: SpaceId,
224    /// Authoritative `SpaceConfig` Component.
225    pub config: SpaceConfig,
226    /// Cached parent-chain depth (E-space-4 O(1)).
227    pub parent_chain_depth: Option<ParentChainDepth>,
228    /// Membership list for PrivateInvite Spaces.
229    pub membership: Option<SpaceMembership>,
230    /// Event cursor.
231    pub cursor: Option<ProjectionCursor>,
232}
233
234/// Entry read-model row — `EntryCore` + body metadata + depth cache.
235#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
236pub struct EntryProjection {
237    /// Wire schema version.
238    pub schema_version: u16,
239    /// Entry identity.
240    pub entry_id: EntryId,
241    /// Authoritative `EntryCore` Component.
242    pub core: EntryCore,
243    /// `EntryBody` — absent when soft-deleted (E-entry-5).
244    pub body: Option<EntryBody>,
245    /// Cached parent-chain depth (E-entry-3).
246    pub parent_depth: Option<EntryParentDepth>,
247    /// Event cursor.
248    pub cursor: Option<ProjectionCursor>,
249}
250
251/// Activity read-model row — `ActivityRecord` + optional Extension-target
252/// shell marker.
253#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
254pub struct ActivityProjection {
255    /// Wire schema version.
256    pub schema_version: u16,
257    /// Activity identity.
258    pub activity_id: ActivityId,
259    /// Authoritative `ActivityRecord` Component.
260    pub record: ActivityRecord,
261    /// Shell marker for Extension-target Activities (E-act-7).
262    pub entity_shell_id: Option<EntityShellId>,
263    /// Event cursor.
264    pub cursor: Option<ProjectionCursor>,
265}
266
267// ===================== ProjectionStore =====================
268
269/// Storage abstraction for projection rows. An in-memory
270/// implementation ships with this crate; PG-backed storage plugs in
271/// at the L2 service layer.
272pub trait ProjectionStore: Send + Sync {
273    /// Upsert an Actor row. `Active` observer only.
274    fn upsert_actor(&mut self, row: &ActorProjection) -> Result<(), ProjectionError>;
275
276    /// Upsert a Space row. `Active` observer only.
277    fn upsert_space(&mut self, row: &SpaceProjection) -> Result<(), ProjectionError>;
278
279    /// Upsert an Entry row. `Active` observer only.
280    fn upsert_entry(&mut self, row: &EntryProjection) -> Result<(), ProjectionError>;
281
282    /// Upsert an Activity row. `Active` observer only.
283    fn upsert_activity(&mut self, row: &ActivityProjection) -> Result<(), ProjectionError>;
284
285    /// Read an Actor row.
286    fn get_actor(&self, actor_id: ActorId) -> Option<ActorProjection>;
287    /// Read a Space row.
288    fn get_space(&self, space_id: SpaceId) -> Option<SpaceProjection>;
289    /// Read an Entry row.
290    fn get_entry(&self, entry_id: EntryId) -> Option<EntryProjection>;
291    /// Read an Activity row.
292    fn get_activity(&self, activity_id: ActivityId) -> Option<ActivityProjection>;
293}
294
295/// In-memory [`ProjectionStore`] — intended for tests and Tier-0 dev runs.
296#[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    /// Construct an empty store.
306    #[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
344// ===================== Router =====================
345
346/// Event-stream dispatcher. Matches incoming events to registered
347/// projections by `TypeCode`, enforces dedup + gap detection via
348/// `Projection::last_applied`, and propagates observer state transitions.
349pub struct ProjectionRouter {
350    projections: Vec<Box<dyn Projection>>,
351    /// `TypeCode -> projection indices that observe it`, built at
352    /// `register()` time. Indices are stored in registration order, so
353    /// dispatch visits matching projections in the same order a linear scan
354    /// would — dispatch-order semantics are preserved (see `dispatch`).
355    observers_by_code: HashMap<TypeCode, Vec<usize>>,
356    state: ObserverState,
357}
358
359impl ProjectionRouter {
360    /// Build a router in the `Passive` state — promote to `Active` before
361    /// committing writes.
362    #[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    /// Register a projection. Its observed `TypeCode`s are folded into the
373    /// dispatch index so `dispatch` only visits matching projections.
374    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    /// Current observer state.
383    #[inline]
384    #[must_use]
385    pub fn state(&self) -> ObserverState {
386        self.state
387    }
388
389    /// Transition to `Active` — fails if currently `Draining`.
390    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    /// Evaluate the shell's `kms_auto_promote` policy against three inputs:
398    /// the elapsed outage, the multi-channel KMS health quorum, and the
399    /// threshold-HSM share readiness.
400    ///
401    /// Policy values:
402    ///
403    /// | `kms_auto_promote`  | Decision matrix |
404    /// |---------------------|-----------------|
405    /// | `"manual"`          | Always `Some(Wait)` — operator drives the promotion manually. |
406    /// | `"after_60min"`     | `Some(Promote)` iff `primary_down_duration >= 1h` **and** the KMS health quorum has at least [`HF2_HEALTH_QUORUM_MIN`] channels healthy; otherwise `Some(Wait)`. |
407    /// | `"threshold_hsm"`   | `Some(Promote)` iff `threshold_ready` (t-of-n shares collected); otherwise `Some(Wait)`. |
408    /// | other               | `None` — unrecognised policy string, operator intervention required. |
409    ///
410    /// Returning `None` is conservative by design: callers treat unknown
411    /// policies as "do not auto-promote" and fall back to manual operator
412    /// action.
413    #[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    /// Transition to `Passive`. Used when ceding primary to a peer.
444    pub fn demote_to_passive(&mut self) -> Result<(), ProjectionError> {
445        self.transition(ObserverState::Passive)
446    }
447
448    /// Transition to `Draining`. Terminal — no further state changes.
449    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    /// Dispatch an event to every matching projection. Returns `Ok(n)`
462    /// where `n` is the number of projections that applied the event.
463    ///
464    /// Matching projections are visited in registration order (the dispatch
465    /// index stores their indices in that order), so a projection registered
466    /// earlier always sees an event before one registered later — callers may
467    /// rely on that ordering. A `TypeCode` with no registered observer is a
468    /// cheap miss (no index entry) returning `Ok(0)`.
469    ///
470    /// Only the `Active` state may dispatch — `Passive` and `Draining`
471    /// reject with [`ProjectionError::NotActive`]. The `Passive` rejection
472    /// is the production guardrail for active-passive HA: a secondary
473    /// observer that mistakenly accepts writes would create split-brain
474    /// rows in the PG-backed store. The dylint `arkhe-trait-default-check`
475    /// CI gate ensures the contract is honoured by every L2 deployment.
476    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        // Split-borrow the disjoint fields: the index slice (immutable) and
486        // the projection vec (mutable) are borrowed independently so the loop
487        // can mutate `projections[i]` while reading the matching index list.
488        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        // Indices are already in registration order.
497        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; // duplicate — silent no-op
503                }
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// ===================== CrossShellActivity fanout =====================
531
532/// Read-only fanout projection for `CrossShellActivity`. Stores cross-shell
533/// notifications keyed by the target shell — never touches the source
534/// shell's rows, preserving shell isolation (E-act-2 RA tier).
535#[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    /// Construct a fresh fanout, pre-wired to observe `CrossShellActivity`.
550    #[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    /// Borrow the notification queue for a shell (read-only).
561    #[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// ===================== Tests =====================
600
601#[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(&notice).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        // Draining is terminal — promote rejects.
654        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    /// #21 — the dispatch index must fan an event out to EVERY projection
669    /// that observes its TypeCode, in registration order. Two fanouts both
670    /// observing `CrossShellActivity` must each apply the same event.
671    #[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    /// #21 — a TypeCode with no registered observer is a cheap index miss
684    /// (no entry) and returns Ok(0) without scanning any projection.
685    #[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, // UserErasureScheduled — not observed
692            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, // UserErasureScheduled
706            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        // Jump to sequence 5 — gap (1..=4 missing).
735        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        // `ProjectionRouter::new()` starts in Passive — dispatch must reject
773        // until the worker is promoted, otherwise an active-passive HA pair
774        // could create split-brain rows in the PG-backed store.
775        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        // After a successful Active dispatch, demoting back to Passive must
791        // immediately stop accepting writes — covers the failover-back path.
792        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    /// Manifest auto-promote decision table.
858    /// Run via a small helper that builds a `ManifestSnapshot` with a
859    /// configurable `kms_auto_promote` string.
860    #[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            // Only one channel healthy — below 2/3 quorum.
904            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        // Manual policy → always Wait (operator drives the promotion).
920        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        // after_60min, short outage → Wait even with full health.
931        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        // after_60min, outage cleared but health below quorum → Wait.
942        assert_eq!(
943            r.evaluate_auto_promote(
944                &manifest_with("after_60min"),
945                Duration::from_secs(60 * 60),
946                &degraded,
947                false,
948            ),
949            Some(PromotionDecision::Wait),
950        );
951
952        // after_60min, outage cleared AND health quorum met → Promote.
953        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        // threshold_hsm, shares not collected → Wait.
964        assert_eq!(
965            r.evaluate_auto_promote(
966                &manifest_with("threshold_hsm"),
967                Duration::from_secs(60 * 60),
968                &degraded,
969                false,
970            ),
971            Some(PromotionDecision::Wait),
972        );
973
974        // threshold_hsm, shares collected → Promote (health is not gating here).
975        assert_eq!(
976            r.evaluate_auto_promote(
977                &manifest_with("threshold_hsm"),
978                Duration::from_secs(0),
979                &degraded,
980                true,
981            ),
982            Some(PromotionDecision::Promote),
983        );
984
985        // Unknown policy string → None (conservative default — operator must act).
986        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}