Skip to main content

traverse_runtime/events/
durable.rs

1//! Durable write path for event publication.
2//!
3//! Governed by spec 067-durable-journal-retention-and-write-limits
4//! (FR-003..FR-005): `publish()` waits for the durable journal write only up
5//! to a configured timeout, a timed-out event is rejected — never silently
6//! downgraded to in-memory-only delivery — and every timeout produces a
7//! structured audit record (spec 036 NFR-006).
8
9use std::collections::HashMap;
10use std::path::Path;
11use std::sync::mpsc;
12use std::sync::{Arc, Condvar, Mutex, PoisonError, RwLock};
13use std::time::Duration;
14
15use serde::Serialize;
16
17use super::broker::BrokerClock;
18use super::journal::{DurableEventJournal, JournalConfig, JournalError};
19use super::types::{
20    BrokerEvent, EventBroker, EventError, Subscription, SubscriptionPoll, TraverseEvent,
21};
22
23/// Prefix distinguishing durable-journal-backed subscription ids from the
24/// inner broker's own `sub-*` ids, so [`DurableBroker::poll`] and
25/// [`DurableBroker::cancel`] can route without a second lookup table.
26const DURABLE_SUBSCRIPTION_PREFIX: &str = "durable-sub-";
27
28/// Read-side of a durable journal: serves replay for cursors the live
29/// broker's in-memory window no longer retains (spec 066 FR-005, FR-008).
30/// [`RwLock<DurableEventJournal>`] is the production implementation, shared
31/// with the write path via [`DurableBroker::open`]; tests inject a stub for
32/// write-path-only scenarios that never exercise replay.
33pub trait JournalSource: Send + Sync {
34    /// Replay up to `max_events` events strictly after `cursor`.
35    ///
36    /// # Errors
37    ///
38    /// Returns [`JournalError`] when the cursor is malformed or expired, or
39    /// the durable read fails.
40    fn replay_from(
41        &self,
42        cursor: &str,
43        max_events: usize,
44    ) -> Result<Vec<(String, TraverseEvent)>, JournalError>;
45}
46
47impl JournalSource for RwLock<DurableEventJournal> {
48    fn replay_from(
49        &self,
50        cursor: &str,
51        max_events: usize,
52    ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
53        self.read()
54            .unwrap_or_else(PoisonError::into_inner)
55            .replay_from(cursor, max_events)
56    }
57}
58
59/// Write-side adapter that locks a shared journal for each append, letting
60/// [`DurableBroker::open`] give the writer thread and the read path the same
61/// underlying storage so cursors stay consistent between them.
62struct SharedJournalSink(Arc<RwLock<DurableEventJournal>>);
63
64impl JournalSink for SharedJournalSink {
65    fn append_event(&mut self, event: &TraverseEvent) -> Result<String, JournalError> {
66        self.0
67            .write()
68            .unwrap_or_else(PoisonError::into_inner)
69            .append(event)
70    }
71
72    fn append_revocation(&mut self, revoked_cursor: &str) -> Result<String, JournalError> {
73        self.0
74            .write()
75            .unwrap_or_else(PoisonError::into_inner)
76            .append_revocation(revoked_cursor)
77    }
78}
79
80struct DurableSubscriptionState {
81    event_type: String,
82    subject_id: Option<String>,
83    cursor: String,
84}
85
86#[derive(Default)]
87struct DurableSubscriptions {
88    next_id: u64,
89    entries: HashMap<String, DurableSubscriptionState>,
90}
91
92fn map_journal_read_error(event_type: &str, error: JournalError) -> EventError {
93    match error {
94        JournalError::CursorExpired {
95            oldest_available_cursor,
96        } => EventError::CursorExpired {
97            event_type: event_type.to_string(),
98            oldest_available_cursor,
99        },
100        JournalError::InvalidCursor(msg) => EventError::InvalidCursor(msg),
101        JournalError::Io(msg) | JournalError::InvalidConfig(msg) => EventError::JournalRead(msg),
102        JournalError::Corrupt {
103            path,
104            line,
105            message,
106        } => EventError::JournalRead(format!(
107            "corrupt journal record at {path}:{line}: {message}"
108        )),
109    }
110}
111
112/// Durable sink the writer thread appends through. [`DurableEventJournal`]
113/// is the production implementation; tests inject slow or failing sinks to
114/// drive the timeout and revocation paths deterministically.
115pub trait JournalSink: Send {
116    /// Durably append an event, returning its cursor.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`JournalError`] when the durable write fails.
121    fn append_event(&mut self, event: &TraverseEvent) -> Result<String, JournalError>;
122
123    /// Durably suppress a previously written cursor from replay.
124    ///
125    /// # Errors
126    ///
127    /// Returns [`JournalError`] when the durable write fails.
128    fn append_revocation(&mut self, revoked_cursor: &str) -> Result<String, JournalError>;
129}
130
131impl JournalSink for DurableEventJournal {
132    fn append_event(&mut self, event: &TraverseEvent) -> Result<String, JournalError> {
133        self.append(event)
134    }
135
136    fn append_revocation(&mut self, revoked_cursor: &str) -> Result<String, JournalError> {
137        DurableEventJournal::append_revocation(self, revoked_cursor)
138    }
139}
140
141/// Configuration for the durable write path (067 FR-003 default: 2 seconds).
142#[derive(Debug, Clone, Copy)]
143pub struct DurableBrokerConfig {
144    /// Maximum time `publish()` waits for the durable write to complete.
145    pub write_timeout: Duration,
146}
147
148impl Default for DurableBrokerConfig {
149    fn default() -> Self {
150        Self {
151            write_timeout: Duration::from_secs(2),
152        }
153    }
154}
155
156/// Structured audit record for durable write-path failures (067 FR-005,
157/// consistent with spec 036 NFR-006 observability).
158#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
159pub struct JournalWriteAuditRecord {
160    /// `journal_write_timeout` or `journal_revocation_failed`.
161    pub kind: String,
162    /// Id of the affected event.
163    pub event_id: String,
164    /// Type of the affected event.
165    pub event_type: String,
166    /// Human-readable failure detail.
167    pub detail: String,
168}
169
170/// Receives structured audit records from the durable write path.
171pub trait JournalWriteAuditSink: Send + Sync {
172    /// Record one audit entry.
173    fn record(&self, record: &JournalWriteAuditRecord);
174}
175
176enum AckState {
177    Pending,
178    Done(String),
179    Failed(JournalError),
180    Abandoned,
181}
182
183type Ack = Arc<(Mutex<AckState>, Condvar)>;
184
185enum WriterJob {
186    Write {
187        event: Box<TraverseEvent>,
188        ack: Ack,
189    },
190    Revoke {
191        cursor: String,
192        event_id: String,
193        event_type: String,
194        ack: Option<Ack>,
195    },
196}
197
198/// [`EventBroker`] decorator that makes every published event durable before
199/// it is delivered: the event is journaled with fsync-before-acknowledgement
200/// (066 FR-006) and only then forwarded to the inner broker for live
201/// delivery, adopting the journal-assigned cursor as the inner broker's own
202/// cursor for that event (spec 066 FR-007) so the two stay numerically
203/// consistent. A write that exceeds the configured timeout rejects the event
204/// with `journal_write_timeout` (067 FR-003/FR-004); if the abandoned write
205/// completes later, the writer durably revokes it so it can never surface
206/// through replay.
207///
208/// `subscribe`/`poll` normally delegate to the inner broker's fast,
209/// in-memory delivery. When a requested cursor is older than the inner
210/// broker's retention window, [`DurableBroker`] falls back to the durable
211/// journal (spec 066 FR-005, FR-008): if the journal still retains the
212/// cursor, a durable-mode subscription is created that reads through
213/// [`JournalSource::replay_from`] for the rest of its lifetime (applying the
214/// identical `subject_id` filter as live delivery, FR-003); if the journal
215/// has also reclaimed that history, `cursor_expired` is returned with the
216/// journal's own oldest available cursor.
217pub struct DurableBroker<B: EventBroker> {
218    inner: B,
219    jobs: mpsc::Sender<WriterJob>,
220    write_timeout: Duration,
221    audit: Arc<dyn JournalWriteAuditSink>,
222    source: Arc<dyn JournalSource>,
223    subscriptions: Mutex<DurableSubscriptions>,
224}
225
226impl<B: EventBroker> DurableBroker<B> {
227    /// Wrap `inner` with a durable write path backed by `sink`, and a
228    /// journal-backed replay fallback backed by `source`. Production callers
229    /// should use [`DurableBroker::open`], which guarantees `sink` and
230    /// `source` share the same underlying journal storage; this lower-level
231    /// constructor exists so tests can inject independent write- and
232    /// read-side doubles.
233    pub fn new(
234        inner: B,
235        sink: impl JournalSink + 'static,
236        source: Arc<dyn JournalSource>,
237        config: DurableBrokerConfig,
238        audit: Arc<dyn JournalWriteAuditSink>,
239    ) -> Self {
240        let (jobs, queue) = mpsc::channel();
241        let writer_audit = Arc::clone(&audit);
242        drop(std::thread::spawn(move || {
243            run_writer(sink, &queue, writer_audit.as_ref());
244        }));
245        Self {
246            inner,
247            jobs,
248            write_timeout: config.write_timeout,
249            audit,
250            source,
251            subscriptions: Mutex::new(DurableSubscriptions::default()),
252        }
253    }
254
255    /// Opens (or creates and recovers) a durable journal at `root` and wraps
256    /// `inner` with a write path and journal-backed replay fallback sharing
257    /// that same storage, so cursors stay consistent across live delivery
258    /// and durable replay (spec 066 FR-007).
259    ///
260    /// # Errors
261    ///
262    /// Returns [`JournalError`] when the journal cannot be opened or
263    /// recovered (066 FR-009).
264    pub fn open(
265        root: &Path,
266        inner: B,
267        journal_config: JournalConfig,
268        broker_config: DurableBrokerConfig,
269        audit: Arc<dyn JournalWriteAuditSink>,
270        clock: Arc<dyn BrokerClock>,
271    ) -> Result<Self, JournalError> {
272        let journal = Arc::new(RwLock::new(DurableEventJournal::open(
273            root,
274            journal_config,
275            clock,
276        )?));
277        // A freshly constructed `inner` has no memory of history that
278        // predates this process (spec 066 FR-007 restart continuity): seed
279        // it with the journal's own latest cursor so it correctly defers
280        // cursors it cannot itself vouch for to durable replay, rather than
281        // optimistically accepting them because it has simply never seen
282        // this event type before.
283        inner.seed_restart_floor(
284            journal
285                .read()
286                .unwrap_or_else(PoisonError::into_inner)
287                .latest_cursor(),
288        );
289        let source: Arc<dyn JournalSource> = Arc::clone(&journal) as Arc<dyn JournalSource>;
290        Ok(Self::new(
291            inner,
292            SharedJournalSink(journal),
293            source,
294            broker_config,
295            audit,
296        ))
297    }
298}
299
300impl<B: EventBroker> EventBroker for DurableBroker<B> {
301    fn publish(&self, event: TraverseEvent) -> Result<(), EventError> {
302        let ack: Ack = Arc::new((Mutex::new(AckState::Pending), Condvar::new()));
303        self.jobs
304            .send(WriterJob::Write {
305                event: Box::new(event.clone()),
306                ack: Arc::clone(&ack),
307            })
308            .map_err(|_| EventError::JournalWrite("journal writer is unavailable".to_string()))?;
309
310        let (lock, cvar) = &*ack;
311        let guard = lock.lock().unwrap_or_else(PoisonError::into_inner);
312        let (mut state, _) = cvar
313            .wait_timeout_while(guard, self.write_timeout, |state| {
314                matches!(state, AckState::Pending)
315            })
316            .unwrap_or_else(PoisonError::into_inner);
317        // Whatever happens next, the writer must treat this job as abandoned
318        // unless we already saw its outcome.
319        let outcome = std::mem::replace(&mut *state, AckState::Abandoned);
320        drop(state);
321
322        match outcome {
323            AckState::Done(cursor) => {
324                let event_id = event.id.clone();
325                let event_type = event.event_type.clone();
326                match self.inner.publish_with_cursor(event, &cursor) {
327                    Ok(()) => Ok(()),
328                    Err(error) => {
329                        // Durably written but undeliverable: revoke so replay
330                        // never surfaces an event that was never delivered live.
331                        // Wait for the writer acknowledgement before returning;
332                        // otherwise an immediate replay can race the revocation.
333                        let revoke_ack: Ack =
334                            Arc::new((Mutex::new(AckState::Pending), Condvar::new()));
335                        let revoke = WriterJob::Revoke {
336                            cursor,
337                            event_id: event_id.clone(),
338                            event_type: event_type.clone(),
339                            ack: Some(Arc::clone(&revoke_ack)),
340                        };
341                        enqueue_revocation(
342                            &self.jobs,
343                            revoke,
344                            self.audit.as_ref(),
345                            &event_id,
346                            &event_type,
347                        )
348                        .and_then(|()| {
349                            let (lock, cvar) = &*revoke_ack;
350                            let guard = lock.lock().unwrap_or_else(PoisonError::into_inner);
351                            let (mut state, _) = cvar
352                                .wait_timeout_while(guard, self.write_timeout, |state| {
353                                    matches!(state, AckState::Pending)
354                                })
355                                .unwrap_or_else(PoisonError::into_inner);
356                            let revoke_outcome =
357                                std::mem::replace(&mut *state, AckState::Abandoned);
358                            map_revocation_outcome(
359                                revoke_outcome,
360                                self.audit.as_ref(),
361                                &event_id,
362                                &event_type,
363                                self.write_timeout,
364                            )
365                        })
366                        .and(Err(error))
367                    }
368                }
369            }
370            AckState::Failed(error) => Err(EventError::JournalWrite(error.to_string())),
371            AckState::Pending | AckState::Abandoned => {
372                let detail = format!(
373                    "durable write exceeded {}ms; event rejected",
374                    self.write_timeout.as_millis()
375                );
376                self.audit.record(&JournalWriteAuditRecord {
377                    kind: "journal_write_timeout".to_string(),
378                    event_id: event.id.clone(),
379                    event_type: event.event_type.clone(),
380                    detail: detail.clone(),
381                });
382                Err(EventError::JournalWriteTimeout(detail))
383            }
384        }
385    }
386
387    fn subscribe(&self, event_type: &str, from_cursor: &str) -> Result<Subscription, EventError> {
388        self.subscribe_for_subject(event_type, from_cursor, None)
389    }
390
391    fn subscribe_for_subject(
392        &self,
393        event_type: &str,
394        from_cursor: &str,
395        subject_id: Option<&str>,
396    ) -> Result<Subscription, EventError> {
397        match self
398            .inner
399            .subscribe_for_subject(event_type, from_cursor, subject_id)
400        {
401            Ok(subscription) => Ok(subscription),
402            Err(EventError::CursorExpired {
403                event_type: expired_event_type,
404                ..
405            }) => {
406                // The in-memory window no longer retains this cursor; check
407                // whether the durable journal still does (066 FR-005,
408                // FR-008). `max_events: 0` only validates the cursor.
409                match self.source.replay_from(from_cursor, 0) {
410                    Ok(_) => {
411                        let cursor = normalize_cursor(from_cursor)?;
412                        let mut subscriptions = self
413                            .subscriptions
414                            .lock()
415                            .unwrap_or_else(PoisonError::into_inner);
416                        subscriptions.next_id = subscriptions.next_id.saturating_add(1);
417                        let subscription_id =
418                            format!("{DURABLE_SUBSCRIPTION_PREFIX}{}", subscriptions.next_id);
419                        subscriptions.entries.insert(
420                            subscription_id.clone(),
421                            DurableSubscriptionState {
422                                event_type: event_type.to_string(),
423                                subject_id: subject_id.map(str::to_owned),
424                                cursor: cursor.clone(),
425                            },
426                        );
427                        Ok(Subscription {
428                            subscription_id,
429                            event_type: event_type.to_string(),
430                            cursor,
431                        })
432                    }
433                    Err(JournalError::CursorExpired {
434                        oldest_available_cursor,
435                    }) => Err(EventError::CursorExpired {
436                        event_type: expired_event_type,
437                        oldest_available_cursor,
438                    }),
439                    Err(other) => Err(map_journal_read_error(event_type, other)),
440                }
441            }
442            Err(other) => Err(other),
443        }
444    }
445
446    fn poll(
447        &self,
448        subscription_id: &str,
449        max_events: usize,
450    ) -> Result<SubscriptionPoll, EventError> {
451        if !subscription_id.starts_with(DURABLE_SUBSCRIPTION_PREFIX) {
452            return self.inner.poll(subscription_id, max_events);
453        }
454
455        let mut subscriptions = self
456            .subscriptions
457            .lock()
458            .unwrap_or_else(PoisonError::into_inner);
459        let subscription = subscriptions
460            .entries
461            .get_mut(subscription_id)
462            .ok_or_else(|| EventError::SubscriptionNotFound(subscription_id.to_string()))?;
463
464        let mut delivered = Vec::new();
465        let mut cursor = subscription.cursor.clone();
466        if max_events > 0 {
467            loop {
468                let batch = self
469                    .source
470                    .replay_from(&cursor, max_events)
471                    .map_err(|error| map_journal_read_error(&subscription.event_type, error))?;
472                if batch.is_empty() {
473                    break;
474                }
475                let batch_len = batch.len();
476                for (record_cursor, event) in batch {
477                    cursor = record_cursor;
478                    if event.event_type != subscription.event_type {
479                        continue;
480                    }
481                    if subscription
482                        .subject_id
483                        .as_deref()
484                        .is_some_and(|subject| event.subject_id.as_deref() != Some(subject))
485                    {
486                        continue;
487                    }
488                    delivered.push(BrokerEvent {
489                        cursor: cursor.clone(),
490                        event,
491                    });
492                    if delivered.len() >= max_events {
493                        break;
494                    }
495                }
496                if delivered.len() >= max_events || batch_len < max_events {
497                    break;
498                }
499            }
500        }
501        subscription.cursor.clone_from(&cursor);
502
503        Ok(SubscriptionPoll {
504            subscription_id: subscription_id.to_string(),
505            event_type: subscription.event_type.clone(),
506            cursor,
507            events: delivered,
508        })
509    }
510
511    fn cancel(&self, subscription_id: &str) -> Result<(), EventError> {
512        if subscription_id.starts_with(DURABLE_SUBSCRIPTION_PREFIX) {
513            let mut subscriptions = self
514                .subscriptions
515                .lock()
516                .unwrap_or_else(PoisonError::into_inner);
517            return if subscriptions.entries.remove(subscription_id).is_some() {
518                Ok(())
519            } else {
520                Err(EventError::SubscriptionNotFound(
521                    subscription_id.to_string(),
522                ))
523            };
524        }
525        self.inner.cancel(subscription_id)
526    }
527}
528
529fn revocation_writer_unavailable(
530    audit: &dyn JournalWriteAuditSink,
531    event_id: &str,
532    event_type: &str,
533) -> EventError {
534    audit.record(&JournalWriteAuditRecord {
535        kind: "journal_revocation_failed".to_string(),
536        event_id: event_id.to_string(),
537        event_type: event_type.to_string(),
538        detail: "journal writer is unavailable".to_string(),
539    });
540    EventError::JournalWrite("journal revocation failed: journal writer is unavailable".to_string())
541}
542
543fn enqueue_revocation(
544    jobs: &mpsc::Sender<WriterJob>,
545    revoke: WriterJob,
546    audit: &dyn JournalWriteAuditSink,
547    event_id: &str,
548    event_type: &str,
549) -> Result<(), EventError> {
550    jobs.send(revoke)
551        .map_err(|_| revocation_writer_unavailable(audit, event_id, event_type))
552}
553
554fn map_revocation_outcome(
555    outcome: AckState,
556    audit: &dyn JournalWriteAuditSink,
557    event_id: &str,
558    event_type: &str,
559    timeout: Duration,
560) -> Result<(), EventError> {
561    match outcome {
562        AckState::Done(_) => Ok(()),
563        AckState::Failed(error) => Err(EventError::JournalWrite(format!(
564            "journal revocation failed: {error}"
565        ))),
566        AckState::Pending | AckState::Abandoned => {
567            let detail = format!(
568                "journal revocation exceeded {}ms; event remains rejected",
569                timeout.as_millis()
570            );
571            audit.record(&JournalWriteAuditRecord {
572                kind: "journal_revocation_failed".to_string(),
573                event_id: event_id.to_string(),
574                event_type: event_type.to_string(),
575                detail: detail.clone(),
576            });
577            Err(EventError::JournalWriteTimeout(detail))
578        }
579    }
580}
581
582fn normalize_cursor(raw: &str) -> Result<String, EventError> {
583    raw.parse::<u64>()
584        .map(|parsed| parsed.to_string())
585        .map_err(|_| EventError::InvalidCursor(raw.to_string()))
586}
587
588fn run_writer(
589    mut sink: impl JournalSink,
590    jobs: &mpsc::Receiver<WriterJob>,
591    audit: &dyn JournalWriteAuditSink,
592) {
593    while let Ok(job) = jobs.recv() {
594        match job {
595            WriterJob::Write { event, ack } => {
596                let result = sink.append_event(&event);
597                let (lock, cvar) = &*ack;
598                let mut state = lock.lock().unwrap_or_else(PoisonError::into_inner);
599                if matches!(*state, AckState::Abandoned) {
600                    drop(state);
601                    // The publisher already rejected this event; if the write
602                    // completed anyway, durably suppress it (067 FR-004).
603                    if let Ok(cursor) = result {
604                        let _ = revoke(&mut sink, audit, &cursor, &event.id, &event.event_type);
605                    }
606                } else {
607                    *state = match result {
608                        Ok(cursor) => AckState::Done(cursor),
609                        Err(error) => AckState::Failed(error),
610                    };
611                    cvar.notify_one();
612                }
613            }
614            WriterJob::Revoke {
615                cursor,
616                event_id,
617                event_type,
618                ack,
619            } => {
620                let result = revoke(&mut sink, audit, &cursor, &event_id, &event_type);
621                let _ = ack.map(|ack| acknowledge_revocation(&ack, result));
622            }
623        }
624    }
625}
626
627fn acknowledge_revocation(ack: &Ack, result: Result<(), JournalError>) {
628    let (lock, cvar) = &**ack;
629    let mut state = lock.lock().unwrap_or_else(PoisonError::into_inner);
630    *state = match result {
631        Ok(()) => AckState::Done(String::new()),
632        Err(error) => AckState::Failed(error),
633    };
634    cvar.notify_one();
635}
636
637fn revoke(
638    sink: &mut impl JournalSink,
639    audit: &dyn JournalWriteAuditSink,
640    cursor: &str,
641    event_id: &str,
642    event_type: &str,
643) -> Result<(), JournalError> {
644    sink.append_revocation(cursor)
645        .map(|_| ())
646        .inspect_err(|error| {
647            audit.record(&JournalWriteAuditRecord {
648                kind: "journal_revocation_failed".to_string(),
649                event_id: event_id.to_string(),
650                event_type: event_type.to_string(),
651                detail: error.to_string(),
652            });
653        })
654}
655
656#[cfg(test)]
657#[allow(clippy::expect_used)]
658mod tests {
659    use super::*;
660    use crate::events::broker::InProcessBroker;
661    use crate::events::catalog::{EventCatalog, EventCatalogEntry};
662    use crate::events::journal::JournalConfig;
663    use crate::events::types::LifecycleStatus;
664    use std::sync::Mutex as StdMutex;
665    use std::sync::mpsc::{Receiver, Sender, channel};
666    use uuid::Uuid;
667
668    const EVENT_TYPE: &str = "dev.traverse.test.durable";
669
670    #[derive(Default)]
671    struct RecordingAudit {
672        records: StdMutex<Vec<JournalWriteAuditRecord>>,
673        notify: StdMutex<Option<Sender<JournalWriteAuditRecord>>>,
674    }
675
676    impl RecordingAudit {
677        fn with_notify(sender: Sender<JournalWriteAuditRecord>) -> Arc<Self> {
678            Arc::new(Self {
679                records: StdMutex::new(Vec::new()),
680                notify: StdMutex::new(Some(sender)),
681            })
682        }
683
684        fn kinds(&self) -> Vec<String> {
685            self.records
686                .lock()
687                .expect("audit lock must not poison")
688                .iter()
689                .map(|record| record.kind.clone())
690                .collect()
691        }
692    }
693
694    impl JournalWriteAuditSink for RecordingAudit {
695        fn record(&self, record: &JournalWriteAuditRecord) {
696            self.records
697                .lock()
698                .expect("audit lock must not poison")
699                .push(record.clone());
700            if let Some(sender) = &*self.notify.lock().expect("notify lock must not poison") {
701                let _ = sender.send(record.clone());
702            }
703        }
704    }
705
706    /// Sink whose `append_event` blocks until the test releases it, then
707    /// reports the outcome the test scripted; revocations are reported back
708    /// over a channel so tests can rendezvous deterministically.
709    struct ScriptedSink {
710        gate: Receiver<Result<String, JournalError>>,
711        revocations: Sender<String>,
712        fail_revocation: bool,
713    }
714
715    impl JournalSink for ScriptedSink {
716        fn append_event(&mut self, _event: &TraverseEvent) -> Result<String, JournalError> {
717            self.gate
718                .recv()
719                .unwrap_or_else(|_| Err(JournalError::Io("gate closed".to_string())))
720        }
721
722        fn append_revocation(&mut self, revoked_cursor: &str) -> Result<String, JournalError> {
723            let _ = self.revocations.send(revoked_cursor.to_string());
724            if self.fail_revocation {
725                return Err(JournalError::Io("revocation rejected".to_string()));
726            }
727            Ok("0".to_string())
728        }
729    }
730
731    fn test_event() -> TraverseEvent {
732        TraverseEvent {
733            id: Uuid::new_v4().to_string(),
734            source: "traverse-runtime/test.capability".to_string(),
735            event_type: EVENT_TYPE.to_string(),
736            datacontenttype: "application/json".to_string(),
737            time: "2026-07-13T00:00:00Z".to_string(),
738            data: serde_json::json!({ "ok": true }),
739            owner: "test.capability".to_string(),
740            version: "1.0.0".to_string(),
741            lifecycle_status: LifecycleStatus::Active,
742            deduplication_id: Some("durable-test".to_string()),
743            ordering_scope: Some("test".to_string()),
744            correlation_id: Some("correlation-test".to_string()),
745            causation_id: Some("command-test".to_string()),
746            subject_id: None,
747            actor_id: None,
748        }
749    }
750
751    fn active_catalog() -> Arc<EventCatalog> {
752        let catalog = EventCatalog::new();
753        catalog
754            .register(EventCatalogEntry {
755                event_type: EVENT_TYPE.to_string(),
756                version: "1.0.0".to_string(),
757                owner: "test.capability".to_string(),
758                lifecycle_status: LifecycleStatus::Active,
759                consumer_count: 0,
760            })
761            .expect("catalog entry must register");
762        Arc::new(catalog)
763    }
764
765    fn inner_broker() -> InProcessBroker {
766        InProcessBroker::new(active_catalog()).expect("broker must build")
767    }
768
769    fn test_root(name: &str) -> std::path::PathBuf {
770        std::env::temp_dir().join(format!("traverse-durable-{name}-{}", Uuid::new_v4()))
771    }
772
773    /// Read-side stub for write-path-only tests that never exercise durable
774    /// replay: every read returns empty results, so any accidental fallback
775    /// attempt fails loudly with `CursorExpired` rather than silently
776    /// succeeding.
777    struct NullJournalSource;
778
779    impl JournalSource for NullJournalSource {
780        fn replay_from(
781            &self,
782            _cursor: &str,
783            _max_events: usize,
784        ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
785            Err(JournalError::CursorExpired {
786                oldest_available_cursor: "0".to_string(),
787            })
788        }
789    }
790
791    fn null_source() -> Arc<dyn JournalSource> {
792        Arc::new(NullJournalSource)
793    }
794
795    fn open_shared_journal(root: &std::path::Path) -> Arc<RwLock<DurableEventJournal>> {
796        Arc::new(RwLock::new(
797            DurableEventJournal::open(
798                root,
799                JournalConfig::default(),
800                Arc::new(crate::events::broker::SystemClock),
801            )
802            .expect("journal must open"),
803        ))
804    }
805
806    #[test]
807    fn journal_sink_impl_appends_and_revokes_through_the_real_journal() {
808        let root = test_root("sink-impl");
809        let mut journal = DurableEventJournal::open(
810            &root,
811            JournalConfig::default(),
812            Arc::new(crate::events::broker::SystemClock),
813        )
814        .expect("journal must open");
815        let cursor =
816            JournalSink::append_event(&mut journal, &test_event()).expect("append must succeed");
817        JournalSink::append_revocation(&mut journal, &cursor).expect("revocation must succeed");
818        assert!(
819            journal
820                .replay_from("0", 10)
821                .expect("replay must succeed")
822                .is_empty(),
823            "the revoked event must not replay"
824        );
825    }
826
827    #[test]
828    fn durable_publish_journals_then_delivers() {
829        let root = test_root("happy");
830        let audit = Arc::new(RecordingAudit::default());
831        let broker = DurableBroker::open(
832            &root,
833            inner_broker(),
834            JournalConfig::default(),
835            DurableBrokerConfig::default(),
836            audit.clone(),
837            Arc::new(crate::events::broker::SystemClock),
838        )
839        .expect("durable broker must open");
840
841        let subscription = broker
842            .subscribe(EVENT_TYPE, "0")
843            .expect("subscribe must succeed");
844        broker.publish(test_event()).expect("publish must succeed");
845
846        let poll = broker
847            .poll(&subscription.subscription_id, 10)
848            .expect("poll must succeed");
849        assert_eq!(poll.events.len(), 1, "live delivery must still work");
850        broker
851            .cancel(&subscription.subscription_id)
852            .expect("cancel must succeed");
853
854        let reader = DurableEventJournal::open(
855            &root,
856            JournalConfig::default(),
857            Arc::new(crate::events::broker::SystemClock),
858        )
859        .expect("journal must reopen");
860        let replayed = reader.replay_from("0", 10).expect("replay must succeed");
861        assert_eq!(replayed.len(), 1, "the event must be durable");
862        assert!(audit.kinds().is_empty(), "no audit records on success");
863    }
864
865    #[test]
866    fn durable_subject_subscription_delegates_to_inner_broker() {
867        let root = test_root("subject-subscription");
868        let broker = DurableBroker::open(
869            &root,
870            inner_broker(),
871            JournalConfig::default(),
872            DurableBrokerConfig::default(),
873            Arc::new(RecordingAudit::default()),
874            Arc::new(crate::events::broker::SystemClock),
875        )
876        .expect("durable broker must open");
877        let mut event = test_event();
878        event.subject_id = Some("subject-match".to_string());
879        broker.publish(event).expect("publish must succeed");
880
881        let subscription = broker
882            .subscribe_for_subject(EVENT_TYPE, "0", Some("subject-match"))
883            .expect("subject subscription must succeed");
884        let poll = broker
885            .poll(&subscription.subscription_id, 10)
886            .expect("poll must succeed");
887        assert_eq!(poll.events.len(), 1);
888        assert_eq!(
889            poll.events[0].event.subject_id.as_deref(),
890            Some("subject-match")
891        );
892    }
893
894    #[test]
895    fn undeliverable_event_is_revoked_after_durable_write() {
896        let (gate_tx, gate_rx) = channel();
897        let (revoked_tx, revoked_rx) = channel();
898        let sink = ScriptedSink {
899            gate: gate_rx,
900            revocations: revoked_tx,
901            fail_revocation: false,
902        };
903        let audit = Arc::new(RecordingAudit::default());
904        let broker = DurableBroker::new(
905            inner_broker(),
906            sink,
907            null_source(),
908            DurableBrokerConfig::default(),
909            audit,
910        );
911
912        let mut unregistered = test_event();
913        unregistered.event_type = "dev.traverse.test.unknown".to_string();
914        gate_tx
915            .send(Ok("41".to_string()))
916            .expect("gate must accept");
917        let err = broker
918            .publish(unregistered)
919            .expect_err("unregistered event type must be rejected by the inner broker");
920        assert!(matches!(err, EventError::UnregisteredEventType(_)), "{err}");
921
922        let revoked = revoked_rx
923            .recv_timeout(Duration::from_secs(5))
924            .expect("the durably written but undelivered event must be revoked");
925        assert_eq!(revoked, "41");
926    }
927
928    #[test]
929    fn slow_durable_write_times_out_rejects_and_revokes() {
930        let (gate_tx, gate_rx) = channel();
931        let (revoked_tx, revoked_rx) = channel();
932        let (audit_tx, audit_rx) = channel();
933        let sink = ScriptedSink {
934            gate: gate_rx,
935            revocations: revoked_tx,
936            fail_revocation: false,
937        };
938        let audit = RecordingAudit::with_notify(audit_tx);
939        let broker = DurableBroker::new(
940            inner_broker(),
941            sink,
942            null_source(),
943            DurableBrokerConfig {
944                write_timeout: Duration::from_millis(50),
945            },
946            audit.clone(),
947        );
948
949        let subscription = broker
950            .subscribe(EVENT_TYPE, "0")
951            .expect("subscribe must succeed");
952        let err = broker
953            .publish(test_event())
954            .expect_err("a stalled durable write must time out");
955        assert!(matches!(err, EventError::JournalWriteTimeout(_)), "{err}");
956        assert!(
957            err.to_string().contains("journal_write_timeout"),
958            "the error code must be distinct: {err}"
959        );
960
961        let timeout_audit = audit_rx
962            .recv_timeout(Duration::from_secs(5))
963            .expect("timeout must produce a structured audit record");
964        assert_eq!(timeout_audit.kind, "journal_write_timeout");
965        assert_eq!(timeout_audit.event_type, EVENT_TYPE);
966
967        let poll = broker
968            .poll(&subscription.subscription_id, 10)
969            .expect("poll must succeed");
970        assert!(
971            poll.events.is_empty(),
972            "a rejected event must not be delivered live"
973        );
974
975        // Release the stalled write; the writer must observe the abandoned
976        // ack and durably revoke the late record.
977        gate_tx.send(Ok("7".to_string())).expect("gate must accept");
978        let revoked = revoked_rx
979            .recv_timeout(Duration::from_secs(5))
980            .expect("the late write must be revoked");
981        assert_eq!(revoked, "7");
982    }
983
984    #[test]
985    fn failed_durable_write_rejects_the_event() {
986        let (gate_tx, gate_rx) = channel();
987        let (revoked_tx, _revoked_rx) = channel();
988        let sink = ScriptedSink {
989            gate: gate_rx,
990            revocations: revoked_tx,
991            fail_revocation: false,
992        };
993        let audit = Arc::new(RecordingAudit::default());
994        let broker = DurableBroker::new(
995            inner_broker(),
996            sink,
997            null_source(),
998            DurableBrokerConfig::default(),
999            audit,
1000        );
1001
1002        gate_tx
1003            .send(Err(JournalError::Io("disk gone".to_string())))
1004            .expect("gate must accept");
1005        let err = broker
1006            .publish(test_event())
1007            .expect_err("a failed durable write must reject the event");
1008        assert!(matches!(err, EventError::JournalWrite(_)), "{err}");
1009        assert!(err.to_string().contains("disk gone"), "{err}");
1010    }
1011
1012    #[test]
1013    fn revocation_failures_are_audited() {
1014        let (gate_tx, gate_rx) = channel();
1015        let (revoked_tx, revoked_rx) = channel();
1016        let (audit_tx, audit_rx) = channel();
1017        let sink = ScriptedSink {
1018            gate: gate_rx,
1019            revocations: revoked_tx,
1020            fail_revocation: true,
1021        };
1022        let audit = RecordingAudit::with_notify(audit_tx);
1023        let broker = DurableBroker::new(
1024            inner_broker(),
1025            sink,
1026            null_source(),
1027            DurableBrokerConfig {
1028                write_timeout: Duration::from_millis(50),
1029            },
1030            audit.clone(),
1031        );
1032
1033        let err = broker
1034            .publish(test_event())
1035            .expect_err("a stalled durable write must time out");
1036        assert!(matches!(err, EventError::JournalWriteTimeout(_)), "{err}");
1037        let timeout_audit = audit_rx
1038            .recv_timeout(Duration::from_secs(5))
1039            .expect("timeout audit must arrive");
1040        assert_eq!(timeout_audit.kind, "journal_write_timeout");
1041
1042        gate_tx.send(Ok("9".to_string())).expect("gate must accept");
1043        let _ = revoked_rx
1044            .recv_timeout(Duration::from_secs(5))
1045            .expect("revocation must be attempted");
1046        let failure_audit = audit_rx
1047            .recv_timeout(Duration::from_secs(5))
1048            .expect("revocation failure must be audited");
1049        assert_eq!(failure_audit.kind, "journal_revocation_failed");
1050        assert!(failure_audit.detail.contains("revocation rejected"));
1051    }
1052
1053    #[test]
1054    fn immediate_revocation_failure_returns_a_stable_error() {
1055        let (gate_tx, gate_rx) = channel();
1056        let (revoked_tx, _revoked_rx) = channel();
1057        let sink = ScriptedSink {
1058            gate: gate_rx,
1059            revocations: revoked_tx,
1060            fail_revocation: true,
1061        };
1062        let broker = DurableBroker::new(
1063            inner_broker(),
1064            sink,
1065            null_source(),
1066            DurableBrokerConfig::default(),
1067            Arc::new(RecordingAudit::default()),
1068        );
1069
1070        let mut unregistered = test_event();
1071        unregistered.event_type = "dev.traverse.test.unknown".to_string();
1072        gate_tx
1073            .send(Ok("11".to_string()))
1074            .expect("gate must accept");
1075        let error = broker
1076            .publish(unregistered)
1077            .expect_err("delivery and revocation failure must be returned");
1078        assert!(matches!(error, EventError::JournalWrite(_)), "{error}");
1079        assert!(error.to_string().contains("revocation rejected"));
1080    }
1081
1082    #[test]
1083    fn disconnected_revocation_writer_is_audited() {
1084        let (jobs, receiver) = channel();
1085        drop(receiver);
1086        let revoke = WriterJob::Revoke {
1087            cursor: "1".to_string(),
1088            event_id: "event-1".to_string(),
1089            event_type: EVENT_TYPE.to_string(),
1090            ack: Some(Arc::new((Mutex::new(AckState::Pending), Condvar::new()))),
1091        };
1092        let audit = RecordingAudit::default();
1093        let error = enqueue_revocation(&jobs, revoke, &audit, "event-1", EVENT_TYPE)
1094            .expect_err("disconnected writer must fail closed");
1095        assert!(matches!(error, EventError::JournalWrite(_)), "{error}");
1096    }
1097
1098    #[test]
1099    fn pending_revocation_ack_reports_timeout_and_audits_failure() {
1100        let audit = RecordingAudit::default();
1101        let timeout = map_revocation_outcome(
1102            AckState::Pending,
1103            &audit,
1104            "event-1",
1105            EVENT_TYPE,
1106            Duration::from_millis(25),
1107        )
1108        .expect_err("pending acknowledgement must time out");
1109        assert!(matches!(timeout, EventError::JournalWriteTimeout(_)));
1110        assert_eq!(audit.kinds(), vec!["journal_revocation_failed"]);
1111    }
1112
1113    // --- journal-backed replay fallback (spec 066 FR-005, FR-007, FR-008; final #659 slice) ---
1114
1115    #[derive(Debug)]
1116    struct ManualClock(StdMutex<std::time::SystemTime>);
1117
1118    impl ManualClock {
1119        fn new() -> Self {
1120            Self(StdMutex::new(std::time::SystemTime::now()))
1121        }
1122
1123        fn advance(&self, by: Duration) {
1124            if let Ok(mut guard) = self.0.lock()
1125                && let Some(next) = guard.checked_add(by)
1126            {
1127                *guard = next;
1128            }
1129        }
1130    }
1131
1132    impl crate::events::broker::BrokerClock for ManualClock {
1133        fn now(&self) -> std::time::SystemTime {
1134            self.0
1135                .lock()
1136                .ok()
1137                .map_or(std::time::SystemTime::UNIX_EPOCH, |guard| *guard)
1138        }
1139    }
1140
1141    /// Deterministically simulates a journal that has also reclaimed the
1142    /// requested history, independent of real segment rollover/pruning
1143    /// mechanics (already covered by `journal.rs`'s own test suite).
1144    struct AlwaysExpiredSource;
1145
1146    impl JournalSource for AlwaysExpiredSource {
1147        fn replay_from(
1148            &self,
1149            _cursor: &str,
1150            _max_events: usize,
1151        ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1152            Err(JournalError::CursorExpired {
1153                oldest_available_cursor: "5".to_string(),
1154            })
1155        }
1156    }
1157
1158    fn short_retention_inner(
1159        clock: Arc<dyn crate::events::broker::BrokerClock>,
1160    ) -> crate::events::broker::InProcessBroker {
1161        crate::events::broker::InProcessBroker::with_clock(
1162            active_catalog(),
1163            crate::events::broker::BrokerConfig {
1164                retention_window: Duration::from_millis(10),
1165                max_queue_len: 16,
1166            },
1167            clock,
1168        )
1169        .expect("short-retention broker must build")
1170    }
1171
1172    #[test]
1173    fn poll_falls_back_to_the_durable_journal_once_the_in_memory_window_expires() {
1174        let root = test_root("fallback-live");
1175        let clock = Arc::new(ManualClock::new());
1176        let journal = open_shared_journal(&root);
1177        let audit = Arc::new(RecordingAudit::default());
1178        let broker = DurableBroker::new(
1179            short_retention_inner(clock.clone()),
1180            SharedJournalSink(Arc::clone(&journal)),
1181            Arc::clone(&journal) as Arc<dyn JournalSource>,
1182            DurableBrokerConfig::default(),
1183            audit,
1184        );
1185
1186        // Publish two events: cursor "0" is a sentinel that never expires
1187        // (it means "start of retention"), so triggering real expiry
1188        // requires resuming from a genuine prior cursor ("1") that is now
1189        // older than the latest retained cursor ("2").
1190        broker
1191            .publish(test_event())
1192            .expect("first publish must succeed");
1193        broker
1194            .publish(test_event())
1195            .expect("second publish must succeed");
1196
1197        // Age the in-memory buffer out; `subscribe` must transparently fall
1198        // back to the durable journal within this single call rather than
1199        // surfacing the inner broker's `CursorExpired` to the caller.
1200        clock.advance(Duration::from_secs(1));
1201        let subscription = broker
1202            .subscribe(EVENT_TYPE, "1")
1203            .expect("subscribing on an expired cursor must fall back to the durable journal");
1204        assert!(
1205            subscription
1206                .subscription_id
1207                .starts_with(DURABLE_SUBSCRIPTION_PREFIX),
1208            "a durable fallback subscription must use the durable-mode id prefix"
1209        );
1210
1211        let poll = broker
1212            .poll(&subscription.subscription_id, 10)
1213            .expect("durable poll must succeed");
1214        assert_eq!(
1215            poll.events.len(),
1216            1,
1217            "only the event strictly after cursor \"1\" must replay"
1218        );
1219
1220        // Publish a second event *after* the durable subscription was
1221        // created and prove it still replays: durable-mode subscriptions
1222        // are not a one-time catchup, they keep working for new events too.
1223        broker
1224            .publish(test_event())
1225            .expect("second publish must succeed");
1226        let poll_again = broker
1227            .poll(&subscription.subscription_id, 10)
1228            .expect("second durable poll must succeed");
1229        assert_eq!(poll_again.events.len(), 1, "the new event must also replay");
1230
1231        broker
1232            .cancel(&subscription.subscription_id)
1233            .expect("cancelling a durable-mode subscription must succeed");
1234        let after_cancel = broker.poll(&subscription.subscription_id, 10);
1235        assert!(matches!(
1236            after_cancel,
1237            Err(EventError::SubscriptionNotFound(_))
1238        ));
1239    }
1240
1241    #[test]
1242    fn durable_fallback_applies_the_identical_subject_filter_as_live_delivery() {
1243        let root = test_root("fallback-subject");
1244        let clock = Arc::new(ManualClock::new());
1245        let journal = open_shared_journal(&root);
1246        let broker = DurableBroker::new(
1247            short_retention_inner(clock.clone()),
1248            SharedJournalSink(Arc::clone(&journal)),
1249            Arc::clone(&journal) as Arc<dyn JournalSource>,
1250            DurableBrokerConfig::default(),
1251            Arc::new(RecordingAudit::default()),
1252        );
1253
1254        // A leading sentinel-only publish so the real assertions below can
1255        // resume from a genuine prior cursor ("1"), not the "0" sentinel
1256        // that never expires.
1257        broker
1258            .publish(test_event())
1259            .expect("leading publish must succeed");
1260        let mut matching = test_event();
1261        matching.subject_id = Some("subject-match".to_string());
1262        broker.publish(matching).expect("publish must succeed");
1263        let mut other = test_event();
1264        other.subject_id = Some("subject-other".to_string());
1265        broker.publish(other).expect("publish must succeed");
1266
1267        clock.advance(Duration::from_secs(1));
1268        let subscription = broker
1269            .subscribe_for_subject(EVENT_TYPE, "1", Some("subject-match"))
1270            .expect("subject subscription must fall back to the durable journal");
1271        assert!(
1272            subscription
1273                .subscription_id
1274                .starts_with(DURABLE_SUBSCRIPTION_PREFIX)
1275        );
1276
1277        let poll = broker
1278            .poll(&subscription.subscription_id, 10)
1279            .expect("durable poll must succeed");
1280        assert_eq!(
1281            poll.events.len(),
1282            1,
1283            "only the matching subject must replay"
1284        );
1285        assert_eq!(
1286            poll.events[0].event.subject_id.as_deref(),
1287            Some("subject-match")
1288        );
1289    }
1290
1291    #[test]
1292    fn durable_fallback_surfaces_cursor_expired_when_the_journal_has_also_reclaimed_history() {
1293        let clock = Arc::new(ManualClock::new());
1294        let (gate_tx, gate_rx) = channel();
1295        let (revoked_tx, _revoked_rx) = channel();
1296        let sink = ScriptedSink {
1297            gate: gate_rx,
1298            revocations: revoked_tx,
1299            fail_revocation: false,
1300        };
1301        let broker = DurableBroker::new(
1302            short_retention_inner(clock.clone()),
1303            sink,
1304            Arc::new(AlwaysExpiredSource) as Arc<dyn JournalSource>,
1305            DurableBrokerConfig::default(),
1306            Arc::new(RecordingAudit::default()),
1307        );
1308
1309        gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1310        broker
1311            .publish(test_event())
1312            .expect("first publish must succeed");
1313        gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1314        broker
1315            .publish(test_event())
1316            .expect("second publish must succeed");
1317
1318        clock.advance(Duration::from_secs(1));
1319        let err = broker
1320            .subscribe(EVENT_TYPE, "1")
1321            .expect_err("both the in-memory and durable layers have expired this cursor");
1322        assert_eq!(
1323            err,
1324            EventError::CursorExpired {
1325                event_type: EVENT_TYPE.to_string(),
1326                oldest_available_cursor: "5".to_string(),
1327            },
1328            "the durable journal's own oldest-available cursor must be surfaced"
1329        );
1330    }
1331
1332    #[test]
1333    fn durable_fallback_propagates_non_cursor_journal_read_errors() {
1334        struct FailingSource;
1335        impl JournalSource for FailingSource {
1336            fn replay_from(
1337                &self,
1338                _cursor: &str,
1339                _max_events: usize,
1340            ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1341                Err(JournalError::Io("disk unavailable".to_string()))
1342            }
1343        }
1344
1345        let clock = Arc::new(ManualClock::new());
1346        let (gate_tx, gate_rx) = channel();
1347        let (revoked_tx, _revoked_rx) = channel();
1348        let sink = ScriptedSink {
1349            gate: gate_rx,
1350            revocations: revoked_tx,
1351            fail_revocation: false,
1352        };
1353        let broker = DurableBroker::new(
1354            short_retention_inner(clock.clone()),
1355            sink,
1356            Arc::new(FailingSource) as Arc<dyn JournalSource>,
1357            DurableBrokerConfig::default(),
1358            Arc::new(RecordingAudit::default()),
1359        );
1360
1361        gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1362        broker
1363            .publish(test_event())
1364            .expect("first publish must succeed");
1365        gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1366        broker
1367            .publish(test_event())
1368            .expect("second publish must succeed");
1369
1370        clock.advance(Duration::from_secs(1));
1371        let err = broker
1372            .subscribe(EVENT_TYPE, "1")
1373            .expect_err("a durable read failure must not be silently swallowed");
1374        assert!(matches!(err, EventError::JournalRead(_)), "{err}");
1375    }
1376
1377    #[test]
1378    fn poll_on_an_unknown_durable_subscription_id_returns_subscription_not_found() {
1379        let root = test_root("fallback-unknown-poll");
1380        let journal = open_shared_journal(&root);
1381        let broker = DurableBroker::new(
1382            inner_broker(),
1383            SharedJournalSink(Arc::clone(&journal)),
1384            Arc::clone(&journal) as Arc<dyn JournalSource>,
1385            DurableBrokerConfig::default(),
1386            Arc::new(RecordingAudit::default()),
1387        );
1388
1389        let err = broker
1390            .poll(&format!("{DURABLE_SUBSCRIPTION_PREFIX}999"), 10)
1391            .expect_err("unknown durable subscription id must be rejected");
1392        assert!(matches!(err, EventError::SubscriptionNotFound(_)));
1393
1394        let cancel_err = broker
1395            .cancel(&format!("{DURABLE_SUBSCRIPTION_PREFIX}999"))
1396            .expect_err("cancelling an unknown durable subscription id must be rejected");
1397        assert!(matches!(cancel_err, EventError::SubscriptionNotFound(_)));
1398    }
1399
1400    #[test]
1401    fn poll_with_zero_max_events_on_a_durable_subscription_returns_no_events() {
1402        let root = test_root("fallback-zero-poll");
1403        let clock = Arc::new(ManualClock::new());
1404        let journal = open_shared_journal(&root);
1405        let broker = DurableBroker::new(
1406            short_retention_inner(clock.clone()),
1407            SharedJournalSink(Arc::clone(&journal)),
1408            Arc::clone(&journal) as Arc<dyn JournalSource>,
1409            DurableBrokerConfig::default(),
1410            Arc::new(RecordingAudit::default()),
1411        );
1412
1413        broker
1414            .publish(test_event())
1415            .expect("first publish must succeed");
1416        broker
1417            .publish(test_event())
1418            .expect("second publish must succeed");
1419        clock.advance(Duration::from_secs(1));
1420        let subscription = broker
1421            .subscribe(EVENT_TYPE, "1")
1422            .expect("subscribe must fall back to the durable journal");
1423
1424        let poll = broker
1425            .poll(&subscription.subscription_id, 0)
1426            .expect("poll with max_events=0 must succeed");
1427        assert!(poll.events.is_empty());
1428        assert_eq!(poll.cursor, "1", "an unread cursor must not advance");
1429    }
1430
1431    #[test]
1432    fn cursor_survives_a_full_broker_restart_at_the_same_journal_root() {
1433        let root = test_root("restart-continuity");
1434        let cursor_before_restart = {
1435            let broker = DurableBroker::open(
1436                &root,
1437                inner_broker(),
1438                JournalConfig::default(),
1439                DurableBrokerConfig::default(),
1440                Arc::new(RecordingAudit::default()),
1441                Arc::new(crate::events::broker::SystemClock),
1442            )
1443            .expect("durable broker must open");
1444
1445            let subscription = broker
1446                .subscribe(EVENT_TYPE, "0")
1447                .expect("subscribe must succeed");
1448            broker
1449                .publish(test_event())
1450                .expect("first publish must succeed");
1451            let poll = broker
1452                .poll(&subscription.subscription_id, 10)
1453                .expect("poll must succeed");
1454            assert_eq!(poll.events.len(), 1);
1455            // A second event that the subscriber never got around to
1456            // consuming before the (simulated) restart, so resuming its
1457            // cursor genuinely has a gap only the durable journal can fill.
1458            broker
1459                .publish(test_event())
1460                .expect("second publish must succeed");
1461            poll.cursor
1462            // `broker` and its in-process buffers are dropped here,
1463            // simulating a full process restart: nothing in memory
1464            // survives, only what was fsynced to `root`.
1465        };
1466
1467        // Reopen at the same root with an entirely fresh in-memory broker —
1468        // the durable journal is the only thing that persisted.
1469        let restarted = DurableBroker::open(
1470            &root,
1471            inner_broker(),
1472            JournalConfig::default(),
1473            DurableBrokerConfig::default(),
1474            Arc::new(RecordingAudit::default()),
1475            Arc::new(crate::events::broker::SystemClock),
1476        )
1477        .expect("durable broker must reopen at the same root");
1478
1479        // The fresh in-memory broker has never seen this event type, so
1480        // without a seeded restart floor it could not tell this cursor is
1481        // behind the durable tip; resuming it must fall back to the
1482        // durable journal and pick up the event missed before restart.
1483        let subscription = restarted
1484            .subscribe(EVENT_TYPE, &cursor_before_restart)
1485            .expect("resuming the pre-restart cursor must succeed after restart");
1486        assert!(
1487            subscription
1488                .subscription_id
1489                .starts_with(DURABLE_SUBSCRIPTION_PREFIX)
1490        );
1491
1492        let poll = restarted
1493            .poll(&subscription.subscription_id, 10)
1494            .expect("poll after restart must succeed");
1495        assert_eq!(
1496            poll.events.len(),
1497            1,
1498            "the event published before restart but never consumed must replay"
1499        );
1500
1501        restarted
1502            .publish(test_event())
1503            .expect("publish after restart must succeed");
1504        let poll = restarted
1505            .poll(&subscription.subscription_id, 10)
1506            .expect("second poll after restart must succeed");
1507        assert_eq!(
1508            poll.events.len(),
1509            1,
1510            "the event published after restart must replay from the resumed cursor"
1511        );
1512    }
1513
1514    #[test]
1515    fn open_surfaces_an_invalid_journal_config() {
1516        let root = test_root("open-invalid-config");
1517        let err = DurableBroker::open(
1518            &root,
1519            inner_broker(),
1520            JournalConfig {
1521                max_segment_bytes: 0,
1522                ..JournalConfig::default()
1523            },
1524            DurableBrokerConfig::default(),
1525            Arc::new(RecordingAudit::default()),
1526            Arc::new(crate::events::broker::SystemClock),
1527        )
1528        .err()
1529        .expect("an invalid journal config must not open");
1530        assert!(matches!(err, JournalError::InvalidConfig(_)), "{err}");
1531    }
1532
1533    #[test]
1534    fn subscribe_propagates_non_cursor_errors_from_the_inner_broker_unchanged() {
1535        let root = test_root("subscribe-non-cursor-error");
1536        let broker = DurableBroker::open(
1537            &root,
1538            inner_broker(),
1539            JournalConfig::default(),
1540            DurableBrokerConfig::default(),
1541            Arc::new(RecordingAudit::default()),
1542            Arc::new(crate::events::broker::SystemClock),
1543        )
1544        .expect("durable broker must open");
1545
1546        let err = broker
1547            .subscribe("dev.traverse.test.unregistered", "0")
1548            .expect_err(
1549                "an unregistered event type must be rejected without consulting the journal",
1550            );
1551        assert!(matches!(err, EventError::UnregisteredEventType(_)), "{err}");
1552    }
1553
1554    #[test]
1555    fn durable_poll_returns_no_events_when_already_caught_up_to_the_journal_tip() {
1556        let root = test_root("fallback-caught-up");
1557        let clock = Arc::new(ManualClock::new());
1558        let journal = open_shared_journal(&root);
1559        let broker = DurableBroker::new(
1560            short_retention_inner(clock.clone()),
1561            SharedJournalSink(Arc::clone(&journal)),
1562            Arc::clone(&journal) as Arc<dyn JournalSource>,
1563            DurableBrokerConfig::default(),
1564            Arc::new(RecordingAudit::default()),
1565        );
1566
1567        broker
1568            .publish(test_event())
1569            .expect("first publish must succeed");
1570        broker
1571            .publish(test_event())
1572            .expect("second publish must succeed");
1573        clock.advance(Duration::from_secs(1));
1574        let subscription = broker
1575            .subscribe(EVENT_TYPE, "1")
1576            .expect("subscribe must fall back to the durable journal");
1577
1578        // Immediately caught up: the durable journal has nothing after
1579        // cursor "2" yet, so `replay_from` returns an empty batch on the
1580        // very first loop iteration.
1581        let poll = broker
1582            .poll(&subscription.subscription_id, 10)
1583            .expect("first poll must succeed");
1584        assert_eq!(poll.events.len(), 1, "only the missed event must replay");
1585        let poll_again = broker
1586            .poll(&subscription.subscription_id, 10)
1587            .expect("second poll must succeed");
1588        assert!(
1589            poll_again.events.is_empty(),
1590            "nothing new since the last poll must yield an empty batch"
1591        );
1592    }
1593
1594    #[test]
1595    fn durable_poll_respects_max_events_and_can_be_paged() {
1596        let root = test_root("fallback-paging");
1597        let clock = Arc::new(ManualClock::new());
1598        let journal = open_shared_journal(&root);
1599        let broker = DurableBroker::new(
1600            short_retention_inner(clock.clone()),
1601            SharedJournalSink(Arc::clone(&journal)),
1602            Arc::clone(&journal) as Arc<dyn JournalSource>,
1603            DurableBrokerConfig::default(),
1604            Arc::new(RecordingAudit::default()),
1605        );
1606
1607        for _ in 0..4 {
1608            broker.publish(test_event()).expect("publish must succeed");
1609        }
1610        clock.advance(Duration::from_secs(1));
1611        let subscription = broker
1612            .subscribe(EVENT_TYPE, "1")
1613            .expect("subscribe must fall back to the durable journal");
1614
1615        let first_page = broker
1616            .poll(&subscription.subscription_id, 2)
1617            .expect("first page must succeed");
1618        assert_eq!(
1619            first_page.events.len(),
1620            2,
1621            "the page must stop at max_events"
1622        );
1623        assert_eq!(first_page.cursor, "3");
1624
1625        let second_page = broker
1626            .poll(&subscription.subscription_id, 2)
1627            .expect("second page must succeed");
1628        assert_eq!(second_page.events.len(), 1, "only one event remains");
1629        assert_eq!(second_page.cursor, "4");
1630    }
1631
1632    #[test]
1633    fn durable_poll_skips_records_of_other_event_types() {
1634        const OTHER_EVENT_TYPE: &str = "dev.traverse.test.durable.other";
1635        let root = test_root("fallback-other-type");
1636        let clock = Arc::new(ManualClock::new());
1637        let journal = open_shared_journal(&root);
1638        let catalog = active_catalog();
1639        catalog
1640            .register(EventCatalogEntry {
1641                event_type: OTHER_EVENT_TYPE.to_string(),
1642                version: "1.0.0".to_string(),
1643                owner: "test.capability".to_string(),
1644                lifecycle_status: LifecycleStatus::Active,
1645                consumer_count: 0,
1646            })
1647            .expect("second catalog entry must register");
1648        let inner = crate::events::broker::InProcessBroker::with_clock(
1649            catalog,
1650            crate::events::broker::BrokerConfig {
1651                retention_window: Duration::from_millis(10),
1652                max_queue_len: 16,
1653            },
1654            clock.clone() as Arc<dyn crate::events::broker::BrokerClock>,
1655        )
1656        .expect("broker must build");
1657        let broker = DurableBroker::new(
1658            inner,
1659            SharedJournalSink(Arc::clone(&journal)),
1660            Arc::clone(&journal) as Arc<dyn JournalSource>,
1661            DurableBrokerConfig::default(),
1662            Arc::new(RecordingAudit::default()),
1663        );
1664
1665        broker
1666            .publish(test_event())
1667            .expect("first publish must succeed");
1668        let mut other = test_event();
1669        other.event_type = OTHER_EVENT_TYPE.to_string();
1670        broker
1671            .publish(other)
1672            .expect("other-type publish must succeed");
1673        broker
1674            .publish(test_event())
1675            .expect("third publish must succeed");
1676
1677        clock.advance(Duration::from_secs(1));
1678        let subscription = broker
1679            .subscribe(EVENT_TYPE, "1")
1680            .expect("subscribe must fall back to the durable journal");
1681        let poll = broker
1682            .poll(&subscription.subscription_id, 10)
1683            .expect("poll must succeed");
1684        assert_eq!(
1685            poll.events.len(),
1686            1,
1687            "the interleaved other-type record must be skipped"
1688        );
1689        assert_eq!(poll.events[0].event.event_type, EVENT_TYPE);
1690    }
1691
1692    #[test]
1693    fn durable_poll_surfaces_cursor_expired_when_the_journal_prunes_mid_subscription() {
1694        struct ExpiresAfterFirstCallSource(std::sync::atomic::AtomicUsize);
1695
1696        impl JournalSource for ExpiresAfterFirstCallSource {
1697            fn replay_from(
1698                &self,
1699                _cursor: &str,
1700                _max_events: usize,
1701            ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1702                let call = self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1703                if call == 0 {
1704                    Ok(Vec::new())
1705                } else {
1706                    Err(JournalError::CursorExpired {
1707                        oldest_available_cursor: "9".to_string(),
1708                    })
1709                }
1710            }
1711        }
1712
1713        let clock = Arc::new(ManualClock::new());
1714        let (gate_tx, gate_rx) = channel();
1715        let (revoked_tx, _revoked_rx) = channel();
1716        let sink = ScriptedSink {
1717            gate: gate_rx,
1718            revocations: revoked_tx,
1719            fail_revocation: false,
1720        };
1721        let broker = DurableBroker::new(
1722            short_retention_inner(clock.clone()),
1723            sink,
1724            Arc::new(ExpiresAfterFirstCallSource(
1725                std::sync::atomic::AtomicUsize::new(0),
1726            )) as Arc<dyn JournalSource>,
1727            DurableBrokerConfig::default(),
1728            Arc::new(RecordingAudit::default()),
1729        );
1730
1731        gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1732        broker
1733            .publish(test_event())
1734            .expect("first publish must succeed");
1735        gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1736        broker
1737            .publish(test_event())
1738            .expect("second publish must succeed");
1739
1740        clock.advance(Duration::from_secs(1));
1741        let subscription = broker
1742            .subscribe(EVENT_TYPE, "1")
1743            .expect("subscribe must fall back to the durable journal (first source call)");
1744
1745        let err = broker.poll(&subscription.subscription_id, 10).expect_err(
1746            "the journal pruning this cursor mid-subscription must surface cursor_expired",
1747        );
1748        assert_eq!(
1749            err,
1750            EventError::CursorExpired {
1751                event_type: EVENT_TYPE.to_string(),
1752                oldest_available_cursor: "9".to_string(),
1753            }
1754        );
1755    }
1756
1757    #[test]
1758    fn durable_poll_propagates_a_corrupt_journal_read_as_journal_read() {
1759        struct CorruptSource;
1760        impl JournalSource for CorruptSource {
1761            fn replay_from(
1762                &self,
1763                _cursor: &str,
1764                _max_events: usize,
1765            ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1766                Err(JournalError::Corrupt {
1767                    path: "segment-1.jsonl".to_string(),
1768                    line: 3,
1769                    message: "truncated record".to_string(),
1770                })
1771            }
1772        }
1773
1774        let clock = Arc::new(ManualClock::new());
1775        let (gate_tx, gate_rx) = channel();
1776        let (revoked_tx, _revoked_rx) = channel();
1777        let sink = ScriptedSink {
1778            gate: gate_rx,
1779            revocations: revoked_tx,
1780            fail_revocation: false,
1781        };
1782        let broker = DurableBroker::new(
1783            short_retention_inner(clock.clone()),
1784            sink,
1785            Arc::new(CorruptSource) as Arc<dyn JournalSource>,
1786            DurableBrokerConfig::default(),
1787            Arc::new(RecordingAudit::default()),
1788        );
1789
1790        gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1791        broker
1792            .publish(test_event())
1793            .expect("first publish must succeed");
1794        gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1795        broker
1796            .publish(test_event())
1797            .expect("second publish must succeed");
1798
1799        clock.advance(Duration::from_secs(1));
1800        let err = broker
1801            .subscribe(EVENT_TYPE, "1")
1802            .expect_err("a corrupt journal read must not be silently swallowed");
1803        assert!(
1804            matches!(&err, EventError::JournalRead(msg) if msg.contains("truncated record")),
1805            "{err}"
1806        );
1807    }
1808
1809    #[test]
1810    fn null_source_fails_loudly_instead_of_silently_serving_a_fallback() {
1811        let clock = Arc::new(ManualClock::new());
1812        let broker = DurableBroker::new(
1813            short_retention_inner(clock.clone()),
1814            SharedJournalSink(open_shared_journal(&test_root("null-source-fallback"))),
1815            null_source(),
1816            DurableBrokerConfig::default(),
1817            Arc::new(RecordingAudit::default()),
1818        );
1819
1820        broker
1821            .publish(test_event())
1822            .expect("first publish must succeed");
1823        broker
1824            .publish(test_event())
1825            .expect("second publish must succeed");
1826        clock.advance(Duration::from_secs(1));
1827
1828        let err = broker.subscribe(EVENT_TYPE, "1").expect_err(
1829            "a broker with no real durable source must not silently accept a stale cursor",
1830        );
1831        assert!(matches!(err, EventError::CursorExpired { .. }), "{err}");
1832    }
1833
1834    #[test]
1835    fn map_journal_read_error_translates_invalid_cursor() {
1836        struct InvalidCursorSource;
1837        impl JournalSource for InvalidCursorSource {
1838            fn replay_from(
1839                &self,
1840                _cursor: &str,
1841                _max_events: usize,
1842            ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1843                Err(JournalError::InvalidCursor("not a real cursor".to_string()))
1844            }
1845        }
1846
1847        let clock = Arc::new(ManualClock::new());
1848        let (gate_tx, gate_rx) = channel();
1849        let (revoked_tx, _revoked_rx) = channel();
1850        let sink = ScriptedSink {
1851            gate: gate_rx,
1852            revocations: revoked_tx,
1853            fail_revocation: false,
1854        };
1855        let broker = DurableBroker::new(
1856            short_retention_inner(clock.clone()),
1857            sink,
1858            Arc::new(InvalidCursorSource) as Arc<dyn JournalSource>,
1859            DurableBrokerConfig::default(),
1860            Arc::new(RecordingAudit::default()),
1861        );
1862
1863        gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1864        broker
1865            .publish(test_event())
1866            .expect("first publish must succeed");
1867        gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1868        broker
1869            .publish(test_event())
1870            .expect("second publish must succeed");
1871
1872        clock.advance(Duration::from_secs(1));
1873        let err = broker
1874            .subscribe(EVENT_TYPE, "1")
1875            .expect_err("an invalid durable cursor must not be silently accepted");
1876        assert!(matches!(err, EventError::InvalidCursor(_)), "{err}");
1877    }
1878
1879    #[test]
1880    fn shared_journal_sink_revokes_a_durably_written_but_undeliverable_event() {
1881        // Uses the real, production `SharedJournalSink` (via `DurableBroker::open`)
1882        // rather than a scripted test double, so the revocation actually
1883        // exercises the journal-backed write path (067 FR-004).
1884        let root = test_root("shared-sink-revocation");
1885        let broker = DurableBroker::open(
1886            &root,
1887            inner_broker(),
1888            JournalConfig::default(),
1889            DurableBrokerConfig::default(),
1890            Arc::new(RecordingAudit::default()),
1891            Arc::new(crate::events::broker::SystemClock),
1892        )
1893        .expect("durable broker must open");
1894
1895        let mut unregistered = test_event();
1896        unregistered.event_type = "dev.traverse.test.unknown".to_string();
1897        let err = broker
1898            .publish(unregistered)
1899            .expect_err("an unregistered event type must be rejected by the inner broker");
1900        assert!(matches!(err, EventError::UnregisteredEventType(_)), "{err}");
1901
1902        // The event was durably written before delivery failed, then
1903        // revoked; a fresh reader over the same root must never see it.
1904        let reader = DurableEventJournal::open(
1905            &root,
1906            JournalConfig::default(),
1907            Arc::new(crate::events::broker::SystemClock),
1908        )
1909        .expect("journal must reopen");
1910        let replayed = reader.replay_from("0", 10).expect("replay must succeed");
1911        assert!(
1912            replayed.is_empty(),
1913            "the revoked, undeliverable event must never replay"
1914        );
1915    }
1916
1917    #[test]
1918    fn durable_poll_fetches_additional_batches_when_a_full_batch_matches_nothing() {
1919        const OTHER_EVENT_TYPE: &str = "dev.traverse.test.durable.other-batching";
1920        let root = test_root("fallback-multi-batch");
1921        let clock = Arc::new(ManualClock::new());
1922        let journal = open_shared_journal(&root);
1923        let catalog = active_catalog();
1924        catalog
1925            .register(EventCatalogEntry {
1926                event_type: OTHER_EVENT_TYPE.to_string(),
1927                version: "1.0.0".to_string(),
1928                owner: "test.capability".to_string(),
1929                lifecycle_status: LifecycleStatus::Active,
1930                consumer_count: 0,
1931            })
1932            .expect("second catalog entry must register");
1933        let inner = crate::events::broker::InProcessBroker::with_clock(
1934            catalog,
1935            crate::events::broker::BrokerConfig {
1936                retention_window: Duration::from_millis(10),
1937                max_queue_len: 16,
1938            },
1939            clock.clone() as Arc<dyn crate::events::broker::BrokerClock>,
1940        )
1941        .expect("broker must build");
1942        let broker = DurableBroker::new(
1943            inner,
1944            SharedJournalSink(Arc::clone(&journal)),
1945            Arc::clone(&journal) as Arc<dyn JournalSource>,
1946            DurableBrokerConfig::default(),
1947            Arc::new(RecordingAudit::default()),
1948        );
1949
1950        broker
1951            .publish(test_event())
1952            .expect("leading publish must succeed");
1953        // Two consecutive other-type records: with max_events=1, each
1954        // `replay_from` batch is entirely filtered out, forcing the poll
1955        // loop to fetch another batch rather than stopping after one.
1956        let mut other_one = test_event();
1957        other_one.event_type = OTHER_EVENT_TYPE.to_string();
1958        broker
1959            .publish(other_one)
1960            .expect("first other-type publish must succeed");
1961        let mut other_two = test_event();
1962        other_two.event_type = OTHER_EVENT_TYPE.to_string();
1963        broker
1964            .publish(other_two)
1965            .expect("second other-type publish must succeed");
1966        broker
1967            .publish(test_event())
1968            .expect("trailing publish must succeed");
1969
1970        clock.advance(Duration::from_secs(1));
1971        let subscription = broker
1972            .subscribe(EVENT_TYPE, "1")
1973            .expect("subscribe must fall back to the durable journal");
1974
1975        let poll = broker
1976            .poll(&subscription.subscription_id, 1)
1977            .expect("poll must succeed across multiple internal batches");
1978        assert_eq!(poll.events.len(), 1);
1979        assert_eq!(poll.events[0].event.event_type, EVENT_TYPE);
1980        assert_eq!(
1981            poll.cursor, "4",
1982            "cursor must advance past the skipped batch"
1983        );
1984    }
1985}