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: 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: 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            subject_id: None,
743            actor_id: None,
744        }
745    }
746
747    fn active_catalog() -> Arc<EventCatalog> {
748        let catalog = EventCatalog::new();
749        catalog
750            .register(EventCatalogEntry {
751                event_type: EVENT_TYPE.to_string(),
752                version: "1.0.0".to_string(),
753                owner: "test.capability".to_string(),
754                lifecycle_status: LifecycleStatus::Active,
755                consumer_count: 0,
756            })
757            .expect("catalog entry must register");
758        Arc::new(catalog)
759    }
760
761    fn inner_broker() -> InProcessBroker {
762        InProcessBroker::new(active_catalog()).expect("broker must build")
763    }
764
765    fn test_root(name: &str) -> std::path::PathBuf {
766        std::env::temp_dir().join(format!("traverse-durable-{name}-{}", Uuid::new_v4()))
767    }
768
769    /// Read-side stub for write-path-only tests that never exercise durable
770    /// replay: every read returns empty results, so any accidental fallback
771    /// attempt fails loudly with `CursorExpired` rather than silently
772    /// succeeding.
773    struct NullJournalSource;
774
775    impl JournalSource for NullJournalSource {
776        fn replay_from(
777            &self,
778            _cursor: &str,
779            _max_events: usize,
780        ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
781            Err(JournalError::CursorExpired {
782                oldest_available_cursor: "0".to_string(),
783            })
784        }
785    }
786
787    fn null_source() -> Arc<dyn JournalSource> {
788        Arc::new(NullJournalSource)
789    }
790
791    fn open_shared_journal(root: &std::path::Path) -> Arc<RwLock<DurableEventJournal>> {
792        Arc::new(RwLock::new(
793            DurableEventJournal::open(
794                root,
795                JournalConfig::default(),
796                Arc::new(crate::events::broker::SystemClock),
797            )
798            .expect("journal must open"),
799        ))
800    }
801
802    #[test]
803    fn journal_sink_impl_appends_and_revokes_through_the_real_journal() {
804        let root = test_root("sink-impl");
805        let mut journal = DurableEventJournal::open(
806            &root,
807            JournalConfig::default(),
808            Arc::new(crate::events::broker::SystemClock),
809        )
810        .expect("journal must open");
811        let cursor =
812            JournalSink::append_event(&mut journal, &test_event()).expect("append must succeed");
813        JournalSink::append_revocation(&mut journal, &cursor).expect("revocation must succeed");
814        assert!(
815            journal
816                .replay_from("0", 10)
817                .expect("replay must succeed")
818                .is_empty(),
819            "the revoked event must not replay"
820        );
821    }
822
823    #[test]
824    fn durable_publish_journals_then_delivers() {
825        let root = test_root("happy");
826        let audit = Arc::new(RecordingAudit::default());
827        let broker = DurableBroker::open(
828            &root,
829            inner_broker(),
830            JournalConfig::default(),
831            DurableBrokerConfig::default(),
832            audit.clone(),
833            Arc::new(crate::events::broker::SystemClock),
834        )
835        .expect("durable broker must open");
836
837        let subscription = broker
838            .subscribe(EVENT_TYPE, "0")
839            .expect("subscribe must succeed");
840        broker.publish(test_event()).expect("publish must succeed");
841
842        let poll = broker
843            .poll(&subscription.subscription_id, 10)
844            .expect("poll must succeed");
845        assert_eq!(poll.events.len(), 1, "live delivery must still work");
846        broker
847            .cancel(&subscription.subscription_id)
848            .expect("cancel must succeed");
849
850        let reader = DurableEventJournal::open(
851            &root,
852            JournalConfig::default(),
853            Arc::new(crate::events::broker::SystemClock),
854        )
855        .expect("journal must reopen");
856        let replayed = reader.replay_from("0", 10).expect("replay must succeed");
857        assert_eq!(replayed.len(), 1, "the event must be durable");
858        assert!(audit.kinds().is_empty(), "no audit records on success");
859    }
860
861    #[test]
862    fn durable_subject_subscription_delegates_to_inner_broker() {
863        let root = test_root("subject-subscription");
864        let broker = DurableBroker::open(
865            &root,
866            inner_broker(),
867            JournalConfig::default(),
868            DurableBrokerConfig::default(),
869            Arc::new(RecordingAudit::default()),
870            Arc::new(crate::events::broker::SystemClock),
871        )
872        .expect("durable broker must open");
873        let mut event = test_event();
874        event.subject_id = Some("subject-match".to_string());
875        broker.publish(event).expect("publish must succeed");
876
877        let subscription = broker
878            .subscribe_for_subject(EVENT_TYPE, "0", Some("subject-match"))
879            .expect("subject subscription must succeed");
880        let poll = broker
881            .poll(&subscription.subscription_id, 10)
882            .expect("poll must succeed");
883        assert_eq!(poll.events.len(), 1);
884        assert_eq!(
885            poll.events[0].event.subject_id.as_deref(),
886            Some("subject-match")
887        );
888    }
889
890    #[test]
891    fn undeliverable_event_is_revoked_after_durable_write() {
892        let (gate_tx, gate_rx) = channel();
893        let (revoked_tx, revoked_rx) = channel();
894        let sink = ScriptedSink {
895            gate: gate_rx,
896            revocations: revoked_tx,
897            fail_revocation: false,
898        };
899        let audit = Arc::new(RecordingAudit::default());
900        let broker = DurableBroker::new(
901            inner_broker(),
902            sink,
903            null_source(),
904            DurableBrokerConfig::default(),
905            audit,
906        );
907
908        let mut unregistered = test_event();
909        unregistered.event_type = "dev.traverse.test.unknown".to_string();
910        gate_tx
911            .send(Ok("41".to_string()))
912            .expect("gate must accept");
913        let err = broker
914            .publish(unregistered)
915            .expect_err("unregistered event type must be rejected by the inner broker");
916        assert!(matches!(err, EventError::UnregisteredEventType(_)), "{err}");
917
918        let revoked = revoked_rx
919            .recv_timeout(Duration::from_secs(5))
920            .expect("the durably written but undelivered event must be revoked");
921        assert_eq!(revoked, "41");
922    }
923
924    #[test]
925    fn slow_durable_write_times_out_rejects_and_revokes() {
926        let (gate_tx, gate_rx) = channel();
927        let (revoked_tx, revoked_rx) = channel();
928        let (audit_tx, audit_rx) = channel();
929        let sink = ScriptedSink {
930            gate: gate_rx,
931            revocations: revoked_tx,
932            fail_revocation: false,
933        };
934        let audit = RecordingAudit::with_notify(audit_tx);
935        let broker = DurableBroker::new(
936            inner_broker(),
937            sink,
938            null_source(),
939            DurableBrokerConfig {
940                write_timeout: Duration::from_millis(50),
941            },
942            audit.clone(),
943        );
944
945        let subscription = broker
946            .subscribe(EVENT_TYPE, "0")
947            .expect("subscribe must succeed");
948        let err = broker
949            .publish(test_event())
950            .expect_err("a stalled durable write must time out");
951        assert!(matches!(err, EventError::JournalWriteTimeout(_)), "{err}");
952        assert!(
953            err.to_string().contains("journal_write_timeout"),
954            "the error code must be distinct: {err}"
955        );
956
957        let timeout_audit = audit_rx
958            .recv_timeout(Duration::from_secs(5))
959            .expect("timeout must produce a structured audit record");
960        assert_eq!(timeout_audit.kind, "journal_write_timeout");
961        assert_eq!(timeout_audit.event_type, EVENT_TYPE);
962
963        let poll = broker
964            .poll(&subscription.subscription_id, 10)
965            .expect("poll must succeed");
966        assert!(
967            poll.events.is_empty(),
968            "a rejected event must not be delivered live"
969        );
970
971        // Release the stalled write; the writer must observe the abandoned
972        // ack and durably revoke the late record.
973        gate_tx.send(Ok("7".to_string())).expect("gate must accept");
974        let revoked = revoked_rx
975            .recv_timeout(Duration::from_secs(5))
976            .expect("the late write must be revoked");
977        assert_eq!(revoked, "7");
978    }
979
980    #[test]
981    fn failed_durable_write_rejects_the_event() {
982        let (gate_tx, gate_rx) = channel();
983        let (revoked_tx, _revoked_rx) = channel();
984        let sink = ScriptedSink {
985            gate: gate_rx,
986            revocations: revoked_tx,
987            fail_revocation: false,
988        };
989        let audit = Arc::new(RecordingAudit::default());
990        let broker = DurableBroker::new(
991            inner_broker(),
992            sink,
993            null_source(),
994            DurableBrokerConfig::default(),
995            audit,
996        );
997
998        gate_tx
999            .send(Err(JournalError::Io("disk gone".to_string())))
1000            .expect("gate must accept");
1001        let err = broker
1002            .publish(test_event())
1003            .expect_err("a failed durable write must reject the event");
1004        assert!(matches!(err, EventError::JournalWrite(_)), "{err}");
1005        assert!(err.to_string().contains("disk gone"), "{err}");
1006    }
1007
1008    #[test]
1009    fn revocation_failures_are_audited() {
1010        let (gate_tx, gate_rx) = channel();
1011        let (revoked_tx, revoked_rx) = channel();
1012        let (audit_tx, audit_rx) = channel();
1013        let sink = ScriptedSink {
1014            gate: gate_rx,
1015            revocations: revoked_tx,
1016            fail_revocation: true,
1017        };
1018        let audit = RecordingAudit::with_notify(audit_tx);
1019        let broker = DurableBroker::new(
1020            inner_broker(),
1021            sink,
1022            null_source(),
1023            DurableBrokerConfig {
1024                write_timeout: Duration::from_millis(50),
1025            },
1026            audit.clone(),
1027        );
1028
1029        let err = broker
1030            .publish(test_event())
1031            .expect_err("a stalled durable write must time out");
1032        assert!(matches!(err, EventError::JournalWriteTimeout(_)), "{err}");
1033        let timeout_audit = audit_rx
1034            .recv_timeout(Duration::from_secs(5))
1035            .expect("timeout audit must arrive");
1036        assert_eq!(timeout_audit.kind, "journal_write_timeout");
1037
1038        gate_tx.send(Ok("9".to_string())).expect("gate must accept");
1039        let _ = revoked_rx
1040            .recv_timeout(Duration::from_secs(5))
1041            .expect("revocation must be attempted");
1042        let failure_audit = audit_rx
1043            .recv_timeout(Duration::from_secs(5))
1044            .expect("revocation failure must be audited");
1045        assert_eq!(failure_audit.kind, "journal_revocation_failed");
1046        assert!(failure_audit.detail.contains("revocation rejected"));
1047    }
1048
1049    #[test]
1050    fn immediate_revocation_failure_returns_a_stable_error() {
1051        let (gate_tx, gate_rx) = channel();
1052        let (revoked_tx, _revoked_rx) = channel();
1053        let sink = ScriptedSink {
1054            gate: gate_rx,
1055            revocations: revoked_tx,
1056            fail_revocation: true,
1057        };
1058        let broker = DurableBroker::new(
1059            inner_broker(),
1060            sink,
1061            null_source(),
1062            DurableBrokerConfig::default(),
1063            Arc::new(RecordingAudit::default()),
1064        );
1065
1066        let mut unregistered = test_event();
1067        unregistered.event_type = "dev.traverse.test.unknown".to_string();
1068        gate_tx
1069            .send(Ok("11".to_string()))
1070            .expect("gate must accept");
1071        let error = broker
1072            .publish(unregistered)
1073            .expect_err("delivery and revocation failure must be returned");
1074        assert!(matches!(error, EventError::JournalWrite(_)), "{error}");
1075        assert!(error.to_string().contains("revocation rejected"));
1076    }
1077
1078    #[test]
1079    fn disconnected_revocation_writer_is_audited() {
1080        let (jobs, receiver) = channel();
1081        drop(receiver);
1082        let revoke = WriterJob::Revoke {
1083            cursor: "1".to_string(),
1084            event_id: "event-1".to_string(),
1085            event_type: EVENT_TYPE.to_string(),
1086            ack: Some(Arc::new((Mutex::new(AckState::Pending), Condvar::new()))),
1087        };
1088        let audit = RecordingAudit::default();
1089        let error = enqueue_revocation(&jobs, revoke, &audit, "event-1", EVENT_TYPE)
1090            .expect_err("disconnected writer must fail closed");
1091        assert!(matches!(error, EventError::JournalWrite(_)), "{error}");
1092    }
1093
1094    #[test]
1095    fn pending_revocation_ack_reports_timeout_and_audits_failure() {
1096        let audit = RecordingAudit::default();
1097        let timeout = map_revocation_outcome(
1098            AckState::Pending,
1099            &audit,
1100            "event-1",
1101            EVENT_TYPE,
1102            Duration::from_millis(25),
1103        )
1104        .expect_err("pending acknowledgement must time out");
1105        assert!(matches!(timeout, EventError::JournalWriteTimeout(_)));
1106        assert_eq!(audit.kinds(), vec!["journal_revocation_failed"]);
1107    }
1108
1109    // --- journal-backed replay fallback (spec 066 FR-005, FR-007, FR-008; final #659 slice) ---
1110
1111    #[derive(Debug)]
1112    struct ManualClock(StdMutex<std::time::SystemTime>);
1113
1114    impl ManualClock {
1115        fn new() -> Self {
1116            Self(StdMutex::new(std::time::SystemTime::now()))
1117        }
1118
1119        fn advance(&self, by: Duration) {
1120            if let Ok(mut guard) = self.0.lock()
1121                && let Some(next) = guard.checked_add(by)
1122            {
1123                *guard = next;
1124            }
1125        }
1126    }
1127
1128    impl crate::events::broker::BrokerClock for ManualClock {
1129        fn now(&self) -> std::time::SystemTime {
1130            self.0
1131                .lock()
1132                .ok()
1133                .map_or(std::time::SystemTime::UNIX_EPOCH, |guard| *guard)
1134        }
1135    }
1136
1137    /// Deterministically simulates a journal that has also reclaimed the
1138    /// requested history, independent of real segment rollover/pruning
1139    /// mechanics (already covered by `journal.rs`'s own test suite).
1140    struct AlwaysExpiredSource;
1141
1142    impl JournalSource for AlwaysExpiredSource {
1143        fn replay_from(
1144            &self,
1145            _cursor: &str,
1146            _max_events: usize,
1147        ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1148            Err(JournalError::CursorExpired {
1149                oldest_available_cursor: "5".to_string(),
1150            })
1151        }
1152    }
1153
1154    fn short_retention_inner(
1155        clock: Arc<dyn crate::events::broker::BrokerClock>,
1156    ) -> crate::events::broker::InProcessBroker {
1157        crate::events::broker::InProcessBroker::with_clock(
1158            active_catalog(),
1159            crate::events::broker::BrokerConfig {
1160                retention_window: Duration::from_millis(10),
1161                max_queue_len: 16,
1162            },
1163            clock,
1164        )
1165        .expect("short-retention broker must build")
1166    }
1167
1168    #[test]
1169    fn poll_falls_back_to_the_durable_journal_once_the_in_memory_window_expires() {
1170        let root = test_root("fallback-live");
1171        let clock = Arc::new(ManualClock::new());
1172        let journal = open_shared_journal(&root);
1173        let audit = Arc::new(RecordingAudit::default());
1174        let broker = DurableBroker::new(
1175            short_retention_inner(clock.clone()),
1176            SharedJournalSink(Arc::clone(&journal)),
1177            Arc::clone(&journal) as Arc<dyn JournalSource>,
1178            DurableBrokerConfig::default(),
1179            audit,
1180        );
1181
1182        // Publish two events: cursor "0" is a sentinel that never expires
1183        // (it means "start of retention"), so triggering real expiry
1184        // requires resuming from a genuine prior cursor ("1") that is now
1185        // older than the latest retained cursor ("2").
1186        broker
1187            .publish(test_event())
1188            .expect("first publish must succeed");
1189        broker
1190            .publish(test_event())
1191            .expect("second publish must succeed");
1192
1193        // Age the in-memory buffer out; `subscribe` must transparently fall
1194        // back to the durable journal within this single call rather than
1195        // surfacing the inner broker's `CursorExpired` to the caller.
1196        clock.advance(Duration::from_secs(1));
1197        let subscription = broker
1198            .subscribe(EVENT_TYPE, "1")
1199            .expect("subscribing on an expired cursor must fall back to the durable journal");
1200        assert!(
1201            subscription
1202                .subscription_id
1203                .starts_with(DURABLE_SUBSCRIPTION_PREFIX),
1204            "a durable fallback subscription must use the durable-mode id prefix"
1205        );
1206
1207        let poll = broker
1208            .poll(&subscription.subscription_id, 10)
1209            .expect("durable poll must succeed");
1210        assert_eq!(
1211            poll.events.len(),
1212            1,
1213            "only the event strictly after cursor \"1\" must replay"
1214        );
1215
1216        // Publish a second event *after* the durable subscription was
1217        // created and prove it still replays: durable-mode subscriptions
1218        // are not a one-time catchup, they keep working for new events too.
1219        broker
1220            .publish(test_event())
1221            .expect("second publish must succeed");
1222        let poll_again = broker
1223            .poll(&subscription.subscription_id, 10)
1224            .expect("second durable poll must succeed");
1225        assert_eq!(poll_again.events.len(), 1, "the new event must also replay");
1226
1227        broker
1228            .cancel(&subscription.subscription_id)
1229            .expect("cancelling a durable-mode subscription must succeed");
1230        let after_cancel = broker.poll(&subscription.subscription_id, 10);
1231        assert!(matches!(
1232            after_cancel,
1233            Err(EventError::SubscriptionNotFound(_))
1234        ));
1235    }
1236
1237    #[test]
1238    fn durable_fallback_applies_the_identical_subject_filter_as_live_delivery() {
1239        let root = test_root("fallback-subject");
1240        let clock = Arc::new(ManualClock::new());
1241        let journal = open_shared_journal(&root);
1242        let broker = DurableBroker::new(
1243            short_retention_inner(clock.clone()),
1244            SharedJournalSink(Arc::clone(&journal)),
1245            Arc::clone(&journal) as Arc<dyn JournalSource>,
1246            DurableBrokerConfig::default(),
1247            Arc::new(RecordingAudit::default()),
1248        );
1249
1250        // A leading sentinel-only publish so the real assertions below can
1251        // resume from a genuine prior cursor ("1"), not the "0" sentinel
1252        // that never expires.
1253        broker
1254            .publish(test_event())
1255            .expect("leading publish must succeed");
1256        let mut matching = test_event();
1257        matching.subject_id = Some("subject-match".to_string());
1258        broker.publish(matching).expect("publish must succeed");
1259        let mut other = test_event();
1260        other.subject_id = Some("subject-other".to_string());
1261        broker.publish(other).expect("publish must succeed");
1262
1263        clock.advance(Duration::from_secs(1));
1264        let subscription = broker
1265            .subscribe_for_subject(EVENT_TYPE, "1", Some("subject-match"))
1266            .expect("subject subscription must fall back to the durable journal");
1267        assert!(
1268            subscription
1269                .subscription_id
1270                .starts_with(DURABLE_SUBSCRIPTION_PREFIX)
1271        );
1272
1273        let poll = broker
1274            .poll(&subscription.subscription_id, 10)
1275            .expect("durable poll must succeed");
1276        assert_eq!(
1277            poll.events.len(),
1278            1,
1279            "only the matching subject must replay"
1280        );
1281        assert_eq!(
1282            poll.events[0].event.subject_id.as_deref(),
1283            Some("subject-match")
1284        );
1285    }
1286
1287    #[test]
1288    fn durable_fallback_surfaces_cursor_expired_when_the_journal_has_also_reclaimed_history() {
1289        let clock = Arc::new(ManualClock::new());
1290        let (gate_tx, gate_rx) = channel();
1291        let (revoked_tx, _revoked_rx) = channel();
1292        let sink = ScriptedSink {
1293            gate: gate_rx,
1294            revocations: revoked_tx,
1295            fail_revocation: false,
1296        };
1297        let broker = DurableBroker::new(
1298            short_retention_inner(clock.clone()),
1299            sink,
1300            Arc::new(AlwaysExpiredSource) as Arc<dyn JournalSource>,
1301            DurableBrokerConfig::default(),
1302            Arc::new(RecordingAudit::default()),
1303        );
1304
1305        gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1306        broker
1307            .publish(test_event())
1308            .expect("first publish must succeed");
1309        gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1310        broker
1311            .publish(test_event())
1312            .expect("second publish must succeed");
1313
1314        clock.advance(Duration::from_secs(1));
1315        let err = broker
1316            .subscribe(EVENT_TYPE, "1")
1317            .expect_err("both the in-memory and durable layers have expired this cursor");
1318        assert_eq!(
1319            err,
1320            EventError::CursorExpired {
1321                event_type: EVENT_TYPE.to_string(),
1322                oldest_available_cursor: "5".to_string(),
1323            },
1324            "the durable journal's own oldest-available cursor must be surfaced"
1325        );
1326    }
1327
1328    #[test]
1329    fn durable_fallback_propagates_non_cursor_journal_read_errors() {
1330        struct FailingSource;
1331        impl JournalSource for FailingSource {
1332            fn replay_from(
1333                &self,
1334                _cursor: &str,
1335                _max_events: usize,
1336            ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1337                Err(JournalError::Io("disk unavailable".to_string()))
1338            }
1339        }
1340
1341        let clock = Arc::new(ManualClock::new());
1342        let (gate_tx, gate_rx) = channel();
1343        let (revoked_tx, _revoked_rx) = channel();
1344        let sink = ScriptedSink {
1345            gate: gate_rx,
1346            revocations: revoked_tx,
1347            fail_revocation: false,
1348        };
1349        let broker = DurableBroker::new(
1350            short_retention_inner(clock.clone()),
1351            sink,
1352            Arc::new(FailingSource) as Arc<dyn JournalSource>,
1353            DurableBrokerConfig::default(),
1354            Arc::new(RecordingAudit::default()),
1355        );
1356
1357        gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1358        broker
1359            .publish(test_event())
1360            .expect("first publish must succeed");
1361        gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1362        broker
1363            .publish(test_event())
1364            .expect("second publish must succeed");
1365
1366        clock.advance(Duration::from_secs(1));
1367        let err = broker
1368            .subscribe(EVENT_TYPE, "1")
1369            .expect_err("a durable read failure must not be silently swallowed");
1370        assert!(matches!(err, EventError::JournalRead(_)), "{err}");
1371    }
1372
1373    #[test]
1374    fn poll_on_an_unknown_durable_subscription_id_returns_subscription_not_found() {
1375        let root = test_root("fallback-unknown-poll");
1376        let journal = open_shared_journal(&root);
1377        let broker = DurableBroker::new(
1378            inner_broker(),
1379            SharedJournalSink(Arc::clone(&journal)),
1380            Arc::clone(&journal) as Arc<dyn JournalSource>,
1381            DurableBrokerConfig::default(),
1382            Arc::new(RecordingAudit::default()),
1383        );
1384
1385        let err = broker
1386            .poll(&format!("{DURABLE_SUBSCRIPTION_PREFIX}999"), 10)
1387            .expect_err("unknown durable subscription id must be rejected");
1388        assert!(matches!(err, EventError::SubscriptionNotFound(_)));
1389
1390        let cancel_err = broker
1391            .cancel(&format!("{DURABLE_SUBSCRIPTION_PREFIX}999"))
1392            .expect_err("cancelling an unknown durable subscription id must be rejected");
1393        assert!(matches!(cancel_err, EventError::SubscriptionNotFound(_)));
1394    }
1395
1396    #[test]
1397    fn poll_with_zero_max_events_on_a_durable_subscription_returns_no_events() {
1398        let root = test_root("fallback-zero-poll");
1399        let clock = Arc::new(ManualClock::new());
1400        let journal = open_shared_journal(&root);
1401        let broker = DurableBroker::new(
1402            short_retention_inner(clock.clone()),
1403            SharedJournalSink(Arc::clone(&journal)),
1404            Arc::clone(&journal) as Arc<dyn JournalSource>,
1405            DurableBrokerConfig::default(),
1406            Arc::new(RecordingAudit::default()),
1407        );
1408
1409        broker
1410            .publish(test_event())
1411            .expect("first publish must succeed");
1412        broker
1413            .publish(test_event())
1414            .expect("second publish must succeed");
1415        clock.advance(Duration::from_secs(1));
1416        let subscription = broker
1417            .subscribe(EVENT_TYPE, "1")
1418            .expect("subscribe must fall back to the durable journal");
1419
1420        let poll = broker
1421            .poll(&subscription.subscription_id, 0)
1422            .expect("poll with max_events=0 must succeed");
1423        assert!(poll.events.is_empty());
1424        assert_eq!(poll.cursor, "1", "an unread cursor must not advance");
1425    }
1426
1427    #[test]
1428    fn cursor_survives_a_full_broker_restart_at_the_same_journal_root() {
1429        let root = test_root("restart-continuity");
1430        let cursor_before_restart = {
1431            let broker = DurableBroker::open(
1432                &root,
1433                inner_broker(),
1434                JournalConfig::default(),
1435                DurableBrokerConfig::default(),
1436                Arc::new(RecordingAudit::default()),
1437                Arc::new(crate::events::broker::SystemClock),
1438            )
1439            .expect("durable broker must open");
1440
1441            let subscription = broker
1442                .subscribe(EVENT_TYPE, "0")
1443                .expect("subscribe must succeed");
1444            broker
1445                .publish(test_event())
1446                .expect("first publish must succeed");
1447            let poll = broker
1448                .poll(&subscription.subscription_id, 10)
1449                .expect("poll must succeed");
1450            assert_eq!(poll.events.len(), 1);
1451            // A second event that the subscriber never got around to
1452            // consuming before the (simulated) restart, so resuming its
1453            // cursor genuinely has a gap only the durable journal can fill.
1454            broker
1455                .publish(test_event())
1456                .expect("second publish must succeed");
1457            poll.cursor
1458            // `broker` and its in-process buffers are dropped here,
1459            // simulating a full process restart: nothing in memory
1460            // survives, only what was fsynced to `root`.
1461        };
1462
1463        // Reopen at the same root with an entirely fresh in-memory broker —
1464        // the durable journal is the only thing that persisted.
1465        let restarted = DurableBroker::open(
1466            &root,
1467            inner_broker(),
1468            JournalConfig::default(),
1469            DurableBrokerConfig::default(),
1470            Arc::new(RecordingAudit::default()),
1471            Arc::new(crate::events::broker::SystemClock),
1472        )
1473        .expect("durable broker must reopen at the same root");
1474
1475        // The fresh in-memory broker has never seen this event type, so
1476        // without a seeded restart floor it could not tell this cursor is
1477        // behind the durable tip; resuming it must fall back to the
1478        // durable journal and pick up the event missed before restart.
1479        let subscription = restarted
1480            .subscribe(EVENT_TYPE, &cursor_before_restart)
1481            .expect("resuming the pre-restart cursor must succeed after restart");
1482        assert!(
1483            subscription
1484                .subscription_id
1485                .starts_with(DURABLE_SUBSCRIPTION_PREFIX)
1486        );
1487
1488        let poll = restarted
1489            .poll(&subscription.subscription_id, 10)
1490            .expect("poll after restart must succeed");
1491        assert_eq!(
1492            poll.events.len(),
1493            1,
1494            "the event published before restart but never consumed must replay"
1495        );
1496
1497        restarted
1498            .publish(test_event())
1499            .expect("publish after restart must succeed");
1500        let poll = restarted
1501            .poll(&subscription.subscription_id, 10)
1502            .expect("second poll after restart must succeed");
1503        assert_eq!(
1504            poll.events.len(),
1505            1,
1506            "the event published after restart must replay from the resumed cursor"
1507        );
1508    }
1509
1510    #[test]
1511    fn open_surfaces_an_invalid_journal_config() {
1512        let root = test_root("open-invalid-config");
1513        let err = DurableBroker::open(
1514            &root,
1515            inner_broker(),
1516            JournalConfig {
1517                max_segment_bytes: 0,
1518                ..JournalConfig::default()
1519            },
1520            DurableBrokerConfig::default(),
1521            Arc::new(RecordingAudit::default()),
1522            Arc::new(crate::events::broker::SystemClock),
1523        )
1524        .err()
1525        .expect("an invalid journal config must not open");
1526        assert!(matches!(err, JournalError::InvalidConfig(_)), "{err}");
1527    }
1528
1529    #[test]
1530    fn subscribe_propagates_non_cursor_errors_from_the_inner_broker_unchanged() {
1531        let root = test_root("subscribe-non-cursor-error");
1532        let broker = DurableBroker::open(
1533            &root,
1534            inner_broker(),
1535            JournalConfig::default(),
1536            DurableBrokerConfig::default(),
1537            Arc::new(RecordingAudit::default()),
1538            Arc::new(crate::events::broker::SystemClock),
1539        )
1540        .expect("durable broker must open");
1541
1542        let err = broker
1543            .subscribe("dev.traverse.test.unregistered", "0")
1544            .expect_err(
1545                "an unregistered event type must be rejected without consulting the journal",
1546            );
1547        assert!(matches!(err, EventError::UnregisteredEventType(_)), "{err}");
1548    }
1549
1550    #[test]
1551    fn durable_poll_returns_no_events_when_already_caught_up_to_the_journal_tip() {
1552        let root = test_root("fallback-caught-up");
1553        let clock = Arc::new(ManualClock::new());
1554        let journal = open_shared_journal(&root);
1555        let broker = DurableBroker::new(
1556            short_retention_inner(clock.clone()),
1557            SharedJournalSink(Arc::clone(&journal)),
1558            Arc::clone(&journal) as Arc<dyn JournalSource>,
1559            DurableBrokerConfig::default(),
1560            Arc::new(RecordingAudit::default()),
1561        );
1562
1563        broker
1564            .publish(test_event())
1565            .expect("first publish must succeed");
1566        broker
1567            .publish(test_event())
1568            .expect("second publish must succeed");
1569        clock.advance(Duration::from_secs(1));
1570        let subscription = broker
1571            .subscribe(EVENT_TYPE, "1")
1572            .expect("subscribe must fall back to the durable journal");
1573
1574        // Immediately caught up: the durable journal has nothing after
1575        // cursor "2" yet, so `replay_from` returns an empty batch on the
1576        // very first loop iteration.
1577        let poll = broker
1578            .poll(&subscription.subscription_id, 10)
1579            .expect("first poll must succeed");
1580        assert_eq!(poll.events.len(), 1, "only the missed event must replay");
1581        let poll_again = broker
1582            .poll(&subscription.subscription_id, 10)
1583            .expect("second poll must succeed");
1584        assert!(
1585            poll_again.events.is_empty(),
1586            "nothing new since the last poll must yield an empty batch"
1587        );
1588    }
1589
1590    #[test]
1591    fn durable_poll_respects_max_events_and_can_be_paged() {
1592        let root = test_root("fallback-paging");
1593        let clock = Arc::new(ManualClock::new());
1594        let journal = open_shared_journal(&root);
1595        let broker = DurableBroker::new(
1596            short_retention_inner(clock.clone()),
1597            SharedJournalSink(Arc::clone(&journal)),
1598            Arc::clone(&journal) as Arc<dyn JournalSource>,
1599            DurableBrokerConfig::default(),
1600            Arc::new(RecordingAudit::default()),
1601        );
1602
1603        for _ in 0..4 {
1604            broker.publish(test_event()).expect("publish must succeed");
1605        }
1606        clock.advance(Duration::from_secs(1));
1607        let subscription = broker
1608            .subscribe(EVENT_TYPE, "1")
1609            .expect("subscribe must fall back to the durable journal");
1610
1611        let first_page = broker
1612            .poll(&subscription.subscription_id, 2)
1613            .expect("first page must succeed");
1614        assert_eq!(
1615            first_page.events.len(),
1616            2,
1617            "the page must stop at max_events"
1618        );
1619        assert_eq!(first_page.cursor, "3");
1620
1621        let second_page = broker
1622            .poll(&subscription.subscription_id, 2)
1623            .expect("second page must succeed");
1624        assert_eq!(second_page.events.len(), 1, "only one event remains");
1625        assert_eq!(second_page.cursor, "4");
1626    }
1627
1628    #[test]
1629    fn durable_poll_skips_records_of_other_event_types() {
1630        const OTHER_EVENT_TYPE: &str = "dev.traverse.test.durable.other";
1631        let root = test_root("fallback-other-type");
1632        let clock = Arc::new(ManualClock::new());
1633        let journal = open_shared_journal(&root);
1634        let catalog = active_catalog();
1635        catalog
1636            .register(EventCatalogEntry {
1637                event_type: OTHER_EVENT_TYPE.to_string(),
1638                version: "1.0.0".to_string(),
1639                owner: "test.capability".to_string(),
1640                lifecycle_status: LifecycleStatus::Active,
1641                consumer_count: 0,
1642            })
1643            .expect("second catalog entry must register");
1644        let inner = crate::events::broker::InProcessBroker::with_clock(
1645            catalog,
1646            crate::events::broker::BrokerConfig {
1647                retention_window: Duration::from_millis(10),
1648                max_queue_len: 16,
1649            },
1650            clock.clone() as Arc<dyn crate::events::broker::BrokerClock>,
1651        )
1652        .expect("broker must build");
1653        let broker = DurableBroker::new(
1654            inner,
1655            SharedJournalSink(Arc::clone(&journal)),
1656            Arc::clone(&journal) as Arc<dyn JournalSource>,
1657            DurableBrokerConfig::default(),
1658            Arc::new(RecordingAudit::default()),
1659        );
1660
1661        broker
1662            .publish(test_event())
1663            .expect("first publish must succeed");
1664        let mut other = test_event();
1665        other.event_type = OTHER_EVENT_TYPE.to_string();
1666        broker
1667            .publish(other)
1668            .expect("other-type publish must succeed");
1669        broker
1670            .publish(test_event())
1671            .expect("third publish must succeed");
1672
1673        clock.advance(Duration::from_secs(1));
1674        let subscription = broker
1675            .subscribe(EVENT_TYPE, "1")
1676            .expect("subscribe must fall back to the durable journal");
1677        let poll = broker
1678            .poll(&subscription.subscription_id, 10)
1679            .expect("poll must succeed");
1680        assert_eq!(
1681            poll.events.len(),
1682            1,
1683            "the interleaved other-type record must be skipped"
1684        );
1685        assert_eq!(poll.events[0].event.event_type, EVENT_TYPE);
1686    }
1687
1688    #[test]
1689    fn durable_poll_surfaces_cursor_expired_when_the_journal_prunes_mid_subscription() {
1690        struct ExpiresAfterFirstCallSource(std::sync::atomic::AtomicUsize);
1691
1692        impl JournalSource for ExpiresAfterFirstCallSource {
1693            fn replay_from(
1694                &self,
1695                _cursor: &str,
1696                _max_events: usize,
1697            ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1698                let call = self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1699                if call == 0 {
1700                    Ok(Vec::new())
1701                } else {
1702                    Err(JournalError::CursorExpired {
1703                        oldest_available_cursor: "9".to_string(),
1704                    })
1705                }
1706            }
1707        }
1708
1709        let clock = Arc::new(ManualClock::new());
1710        let (gate_tx, gate_rx) = channel();
1711        let (revoked_tx, _revoked_rx) = channel();
1712        let sink = ScriptedSink {
1713            gate: gate_rx,
1714            revocations: revoked_tx,
1715            fail_revocation: false,
1716        };
1717        let broker = DurableBroker::new(
1718            short_retention_inner(clock.clone()),
1719            sink,
1720            Arc::new(ExpiresAfterFirstCallSource(
1721                std::sync::atomic::AtomicUsize::new(0),
1722            )) as Arc<dyn JournalSource>,
1723            DurableBrokerConfig::default(),
1724            Arc::new(RecordingAudit::default()),
1725        );
1726
1727        gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1728        broker
1729            .publish(test_event())
1730            .expect("first publish must succeed");
1731        gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1732        broker
1733            .publish(test_event())
1734            .expect("second publish must succeed");
1735
1736        clock.advance(Duration::from_secs(1));
1737        let subscription = broker
1738            .subscribe(EVENT_TYPE, "1")
1739            .expect("subscribe must fall back to the durable journal (first source call)");
1740
1741        let err = broker.poll(&subscription.subscription_id, 10).expect_err(
1742            "the journal pruning this cursor mid-subscription must surface cursor_expired",
1743        );
1744        assert_eq!(
1745            err,
1746            EventError::CursorExpired {
1747                event_type: EVENT_TYPE.to_string(),
1748                oldest_available_cursor: "9".to_string(),
1749            }
1750        );
1751    }
1752
1753    #[test]
1754    fn durable_poll_propagates_a_corrupt_journal_read_as_journal_read() {
1755        struct CorruptSource;
1756        impl JournalSource for CorruptSource {
1757            fn replay_from(
1758                &self,
1759                _cursor: &str,
1760                _max_events: usize,
1761            ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1762                Err(JournalError::Corrupt {
1763                    path: "segment-1.jsonl".to_string(),
1764                    line: 3,
1765                    message: "truncated record".to_string(),
1766                })
1767            }
1768        }
1769
1770        let clock = Arc::new(ManualClock::new());
1771        let (gate_tx, gate_rx) = channel();
1772        let (revoked_tx, _revoked_rx) = channel();
1773        let sink = ScriptedSink {
1774            gate: gate_rx,
1775            revocations: revoked_tx,
1776            fail_revocation: false,
1777        };
1778        let broker = DurableBroker::new(
1779            short_retention_inner(clock.clone()),
1780            sink,
1781            Arc::new(CorruptSource) as Arc<dyn JournalSource>,
1782            DurableBrokerConfig::default(),
1783            Arc::new(RecordingAudit::default()),
1784        );
1785
1786        gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1787        broker
1788            .publish(test_event())
1789            .expect("first publish must succeed");
1790        gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1791        broker
1792            .publish(test_event())
1793            .expect("second publish must succeed");
1794
1795        clock.advance(Duration::from_secs(1));
1796        let err = broker
1797            .subscribe(EVENT_TYPE, "1")
1798            .expect_err("a corrupt journal read must not be silently swallowed");
1799        assert!(
1800            matches!(&err, EventError::JournalRead(msg) if msg.contains("truncated record")),
1801            "{err}"
1802        );
1803    }
1804
1805    #[test]
1806    fn null_source_fails_loudly_instead_of_silently_serving_a_fallback() {
1807        let clock = Arc::new(ManualClock::new());
1808        let broker = DurableBroker::new(
1809            short_retention_inner(clock.clone()),
1810            SharedJournalSink(open_shared_journal(&test_root("null-source-fallback"))),
1811            null_source(),
1812            DurableBrokerConfig::default(),
1813            Arc::new(RecordingAudit::default()),
1814        );
1815
1816        broker
1817            .publish(test_event())
1818            .expect("first publish must succeed");
1819        broker
1820            .publish(test_event())
1821            .expect("second publish must succeed");
1822        clock.advance(Duration::from_secs(1));
1823
1824        let err = broker.subscribe(EVENT_TYPE, "1").expect_err(
1825            "a broker with no real durable source must not silently accept a stale cursor",
1826        );
1827        assert!(matches!(err, EventError::CursorExpired { .. }), "{err}");
1828    }
1829
1830    #[test]
1831    fn map_journal_read_error_translates_invalid_cursor() {
1832        struct InvalidCursorSource;
1833        impl JournalSource for InvalidCursorSource {
1834            fn replay_from(
1835                &self,
1836                _cursor: &str,
1837                _max_events: usize,
1838            ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
1839                Err(JournalError::InvalidCursor("not a real cursor".to_string()))
1840            }
1841        }
1842
1843        let clock = Arc::new(ManualClock::new());
1844        let (gate_tx, gate_rx) = channel();
1845        let (revoked_tx, _revoked_rx) = channel();
1846        let sink = ScriptedSink {
1847            gate: gate_rx,
1848            revocations: revoked_tx,
1849            fail_revocation: false,
1850        };
1851        let broker = DurableBroker::new(
1852            short_retention_inner(clock.clone()),
1853            sink,
1854            Arc::new(InvalidCursorSource) as Arc<dyn JournalSource>,
1855            DurableBrokerConfig::default(),
1856            Arc::new(RecordingAudit::default()),
1857        );
1858
1859        gate_tx.send(Ok("1".to_string())).expect("gate must accept");
1860        broker
1861            .publish(test_event())
1862            .expect("first publish must succeed");
1863        gate_tx.send(Ok("2".to_string())).expect("gate must accept");
1864        broker
1865            .publish(test_event())
1866            .expect("second publish must succeed");
1867
1868        clock.advance(Duration::from_secs(1));
1869        let err = broker
1870            .subscribe(EVENT_TYPE, "1")
1871            .expect_err("an invalid durable cursor must not be silently accepted");
1872        assert!(matches!(err, EventError::InvalidCursor(_)), "{err}");
1873    }
1874
1875    #[test]
1876    fn shared_journal_sink_revokes_a_durably_written_but_undeliverable_event() {
1877        // Uses the real, production `SharedJournalSink` (via `DurableBroker::open`)
1878        // rather than a scripted test double, so the revocation actually
1879        // exercises the journal-backed write path (067 FR-004).
1880        let root = test_root("shared-sink-revocation");
1881        let broker = DurableBroker::open(
1882            &root,
1883            inner_broker(),
1884            JournalConfig::default(),
1885            DurableBrokerConfig::default(),
1886            Arc::new(RecordingAudit::default()),
1887            Arc::new(crate::events::broker::SystemClock),
1888        )
1889        .expect("durable broker must open");
1890
1891        let mut unregistered = test_event();
1892        unregistered.event_type = "dev.traverse.test.unknown".to_string();
1893        let err = broker
1894            .publish(unregistered)
1895            .expect_err("an unregistered event type must be rejected by the inner broker");
1896        assert!(matches!(err, EventError::UnregisteredEventType(_)), "{err}");
1897
1898        // The event was durably written before delivery failed, then
1899        // revoked; a fresh reader over the same root must never see it.
1900        let reader = DurableEventJournal::open(
1901            &root,
1902            JournalConfig::default(),
1903            Arc::new(crate::events::broker::SystemClock),
1904        )
1905        .expect("journal must reopen");
1906        let replayed = reader.replay_from("0", 10).expect("replay must succeed");
1907        assert!(
1908            replayed.is_empty(),
1909            "the revoked, undeliverable event must never replay"
1910        );
1911    }
1912
1913    #[test]
1914    fn durable_poll_fetches_additional_batches_when_a_full_batch_matches_nothing() {
1915        const OTHER_EVENT_TYPE: &str = "dev.traverse.test.durable.other-batching";
1916        let root = test_root("fallback-multi-batch");
1917        let clock = Arc::new(ManualClock::new());
1918        let journal = open_shared_journal(&root);
1919        let catalog = active_catalog();
1920        catalog
1921            .register(EventCatalogEntry {
1922                event_type: OTHER_EVENT_TYPE.to_string(),
1923                version: "1.0.0".to_string(),
1924                owner: "test.capability".to_string(),
1925                lifecycle_status: LifecycleStatus::Active,
1926                consumer_count: 0,
1927            })
1928            .expect("second catalog entry must register");
1929        let inner = crate::events::broker::InProcessBroker::with_clock(
1930            catalog,
1931            crate::events::broker::BrokerConfig {
1932                retention_window: Duration::from_millis(10),
1933                max_queue_len: 16,
1934            },
1935            clock.clone() as Arc<dyn crate::events::broker::BrokerClock>,
1936        )
1937        .expect("broker must build");
1938        let broker = DurableBroker::new(
1939            inner,
1940            SharedJournalSink(Arc::clone(&journal)),
1941            Arc::clone(&journal) as Arc<dyn JournalSource>,
1942            DurableBrokerConfig::default(),
1943            Arc::new(RecordingAudit::default()),
1944        );
1945
1946        broker
1947            .publish(test_event())
1948            .expect("leading publish must succeed");
1949        // Two consecutive other-type records: with max_events=1, each
1950        // `replay_from` batch is entirely filtered out, forcing the poll
1951        // loop to fetch another batch rather than stopping after one.
1952        let mut other_one = test_event();
1953        other_one.event_type = OTHER_EVENT_TYPE.to_string();
1954        broker
1955            .publish(other_one)
1956            .expect("first other-type publish must succeed");
1957        let mut other_two = test_event();
1958        other_two.event_type = OTHER_EVENT_TYPE.to_string();
1959        broker
1960            .publish(other_two)
1961            .expect("second other-type publish must succeed");
1962        broker
1963            .publish(test_event())
1964            .expect("trailing publish must succeed");
1965
1966        clock.advance(Duration::from_secs(1));
1967        let subscription = broker
1968            .subscribe(EVENT_TYPE, "1")
1969            .expect("subscribe must fall back to the durable journal");
1970
1971        let poll = broker
1972            .poll(&subscription.subscription_id, 1)
1973            .expect("poll must succeed across multiple internal batches");
1974        assert_eq!(poll.events.len(), 1);
1975        assert_eq!(poll.events[0].event.event_type, EVENT_TYPE);
1976        assert_eq!(
1977            poll.cursor, "4",
1978            "cursor must advance past the skipped batch"
1979        );
1980    }
1981}