Skip to main content

a3s_code_core/evaluation/
journal.rs

1//! Append-only, bounded execution facts.
2
3use super::identity::{
4    digest_bytes, digest_json, validate_digest, ExecutionFrameV1, ExecutionTargetV1,
5};
6use crate::agent::AgentEvent;
7use crate::core_identity::canonical_event_payload;
8use crate::run::RunEventRecord;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, VecDeque};
11use std::sync::{Arc, RwLock};
12use thiserror::Error;
13
14pub const EXECUTION_FACT_SCHEMA_V1: &str = "a3s.code.execution-fact.v1";
15const MAX_EVENT_TYPE_BYTES: usize = 256;
16const MAX_ARTIFACT_REFS: usize = 128;
17const MAX_ARTIFACT_URI_BYTES: usize = 1024;
18const MAX_PAYLOAD_BYTES: usize = 4 * 1024 * 1024;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum ExecutionFactKindV1 {
23    Lifecycle,
24    Turn,
25    Model,
26    Tool,
27    Permission,
28    Context,
29    Child,
30    Control,
31    Memory,
32    Other,
33}
34
35impl ExecutionFactKindV1 {
36    pub(crate) fn from_event_type(event_type: &str) -> Self {
37        if event_type == "agent_start"
38            || event_type == "agent_end"
39            || event_type == "error"
40            || event_type == "agent_mode_changed"
41        {
42            return Self::Lifecycle;
43        }
44        if event_type == "turn_start" || event_type == "turn_end" {
45            return Self::Turn;
46        }
47        if event_type.starts_with("model_") {
48            return Self::Model;
49        }
50        if event_type.starts_with("tool_") {
51            return Self::Tool;
52        }
53        if event_type == "permission_denied" || event_type.starts_with("confirmation_") {
54            return Self::Permission;
55        }
56        if event_type.starts_with("context_") || event_type == "cognitive_context_bound" {
57            return Self::Context;
58        }
59        if event_type.starts_with("subagent_") {
60            return Self::Child;
61        }
62        if event_type.starts_with("memory") || event_type == "memories_searched" {
63            return Self::Memory;
64        }
65        if event_type == "run_control_applied"
66            || event_type == "external_task_pending"
67            || event_type == "external_task_completed"
68            || event_type == "persistence_failed"
69        {
70            return Self::Control;
71        }
72        Self::Other
73    }
74}
75
76/// Input accepted by the journal.  Payload bytes are counted and represented
77/// by a digest; raw model/tool content belongs in an explicitly authorized
78/// artifact store or evidence reader.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ExecutionFactInputV1 {
81    pub frame: ExecutionFrameV1,
82    pub sequence: u64,
83    pub observed_at_ms: u64,
84    pub event_type: String,
85    pub payload_digest: String,
86    pub payload_bytes: u64,
87    pub artifact_refs: Vec<String>,
88}
89
90impl ExecutionFactInputV1 {
91    pub fn from_event(
92        frame: ExecutionFrameV1,
93        sequence: u64,
94        observed_at_ms: u64,
95        event: &AgentEvent,
96    ) -> Result<Self, JournalError> {
97        let canonical = canonical_event_payload(event)
98            .map_err(|error| JournalError::Serialization(error.to_string()))?;
99        let value = serde_json::to_value(event)
100            .map_err(|error| JournalError::Serialization(error.to_string()))?;
101        Ok(Self {
102            frame,
103            sequence,
104            observed_at_ms,
105            event_type: canonical.event_type,
106            payload_digest: digest_bytes("a3s.code.execution-fact.payload.v1", &canonical.wire),
107            payload_bytes: u64::try_from(canonical.wire.len())
108                .map_err(|_| JournalError::InvalidField("payload_bytes"))?,
109            artifact_refs: collect_artifact_refs(&value),
110        })
111    }
112
113    pub fn from_run_event(
114        frame: ExecutionFrameV1,
115        event: &RunEventRecord,
116    ) -> Result<Self, JournalError> {
117        let sequence =
118            u64::try_from(event.sequence).map_err(|_| JournalError::InvalidField("sequence"))?;
119        Self::from_event(frame, sequence, event.timestamp_ms, &event.event)
120    }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(deny_unknown_fields)]
125pub struct ExecutionFactV1 {
126    pub schema: String,
127    pub frame: ExecutionFrameV1,
128    pub sequence: u64,
129    pub observed_at_ms: u64,
130    pub event_type: String,
131    pub kind: ExecutionFactKindV1,
132    pub payload_digest: String,
133    pub payload_bytes: u64,
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub artifact_refs: Vec<String>,
136    pub fact_digest: String,
137}
138
139impl ExecutionFactV1 {
140    pub fn from_run_event(
141        frame: ExecutionFrameV1,
142        record: &RunEventRecord,
143    ) -> Result<Self, JournalError> {
144        let input = ExecutionFactInputV1::from_run_event(frame, record)?;
145        Self::from_input(input)
146    }
147
148    pub fn from_input(input: ExecutionFactInputV1) -> Result<Self, JournalError> {
149        let mut fact = Self {
150            schema: EXECUTION_FACT_SCHEMA_V1.to_string(),
151            frame: input.frame,
152            sequence: input.sequence,
153            observed_at_ms: input.observed_at_ms,
154            kind: ExecutionFactKindV1::from_event_type(&input.event_type),
155            event_type: input.event_type,
156            payload_digest: input.payload_digest,
157            payload_bytes: input.payload_bytes,
158            artifact_refs: input.artifact_refs,
159            fact_digest: String::new(),
160        };
161        fact.validate_without_digest()?;
162        fact.fact_digest = fact.expected_digest()?;
163        Ok(fact)
164    }
165
166    pub fn validate(&self) -> Result<(), JournalError> {
167        self.validate_without_digest()?;
168        validate_digest(&self.fact_digest)
169            .map_err(|_| JournalError::InvalidField("fact_digest"))?;
170        if self.fact_digest != self.expected_digest()? {
171            return Err(JournalError::DigestMismatch("fact_digest"));
172        }
173        Ok(())
174    }
175
176    pub fn expected_digest(&self) -> Result<String, JournalError> {
177        #[derive(Serialize)]
178        struct Identity<'a> {
179            schema: &'a str,
180            frame: &'a ExecutionFrameV1,
181            sequence: u64,
182            observed_at_ms: u64,
183            event_type: &'a str,
184            kind: ExecutionFactKindV1,
185            payload_digest: &'a str,
186            payload_bytes: u64,
187            artifact_refs: &'a [String],
188        }
189        digest_json(
190            "a3s.code.execution-fact.identity.v1",
191            &Identity {
192                schema: &self.schema,
193                frame: &self.frame,
194                sequence: self.sequence,
195                observed_at_ms: self.observed_at_ms,
196                event_type: &self.event_type,
197                kind: self.kind,
198                payload_digest: &self.payload_digest,
199                payload_bytes: self.payload_bytes,
200                artifact_refs: &self.artifact_refs,
201            },
202        )
203        .map_err(|error| JournalError::Serialization(error.to_string()))
204    }
205
206    fn validate_without_digest(&self) -> Result<(), JournalError> {
207        if self.schema != EXECUTION_FACT_SCHEMA_V1 {
208            return Err(JournalError::UnsupportedSchema);
209        }
210        self.frame
211            .validate()
212            .map_err(|_| JournalError::InvalidField("frame"))?;
213        if self.event_type.is_empty()
214            || self.event_type.len() > MAX_EVENT_TYPE_BYTES
215            || self.event_type.contains('\0')
216            || self.event_type.lines().count() != 1
217        {
218            return Err(JournalError::InvalidField("event_type"));
219        }
220        if self.kind != ExecutionFactKindV1::from_event_type(&self.event_type) {
221            return Err(JournalError::InvalidField("kind"));
222        }
223        validate_digest(&self.payload_digest)
224            .map_err(|_| JournalError::InvalidField("payload_digest"))?;
225        if self.payload_bytes == 0
226            || self.payload_bytes > u64::try_from(MAX_PAYLOAD_BYTES).unwrap_or(u64::MAX)
227        {
228            return Err(JournalError::InvalidField("payload_bytes"));
229        }
230        if self.artifact_refs.len() > MAX_ARTIFACT_REFS
231            || self.artifact_refs.iter().any(|uri| {
232                uri.is_empty()
233                    || uri.len() > MAX_ARTIFACT_URI_BYTES
234                    || uri.contains('\0')
235                    || uri.lines().count() != 1
236            })
237        {
238            return Err(JournalError::InvalidField("artifact_refs"));
239        }
240        if self
241            .artifact_refs
242            .windows(2)
243            .any(|window| window[0] >= window[1])
244        {
245            return Err(JournalError::InvalidField("artifact_refs"));
246        }
247        Ok(())
248    }
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct ExecutionFactPageV1 {
254    pub facts: Vec<ExecutionFactV1>,
255    pub first_available_sequence: Option<u64>,
256    pub latest_sequence_exclusive: u64,
257    pub next_cursor: Option<u64>,
258    pub retention_gap: bool,
259    pub has_more: bool,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(deny_unknown_fields)]
264pub struct ExecutionFactSnapshotV1 {
265    pub target: ExecutionTargetV1,
266    pub page: ExecutionFactPageV1,
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct FactAppendOutcomeV1 {
272    pub appended: bool,
273    pub replayed: bool,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Error)]
277pub enum JournalError {
278    #[error("execution fact schema is unsupported")]
279    UnsupportedSchema,
280    #[error("execution fact field `{0}` is invalid")]
281    InvalidField(&'static str),
282    #[error("execution fact `{0}` does not match its contents")]
283    DigestMismatch(&'static str),
284    #[error("execution fact target does not match the journal key")]
285    TargetMismatch,
286    #[error("execution fact frame conflicts with existing target history")]
287    FrameConflict,
288    #[error("execution fact sequence is not contiguous")]
289    SequenceGap,
290    #[error("execution fact sequence conflicts with an existing fact")]
291    SequenceConflict,
292    #[error("execution fact limit is invalid")]
293    InvalidLimit,
294    #[error("execution fact serialization failed: {0}")]
295    Serialization(String),
296}
297
298pub trait ExecutionFactJournal: Send + Sync {
299    fn append(&self, fact: ExecutionFactV1) -> Result<FactAppendOutcomeV1, JournalError>;
300
301    /// Adapt an existing Code run event into the digest-only fact contract.
302    /// Implementations may override this when their durable journal has a
303    /// native event encoder; the default keeps the conversion identical for
304    /// every host.
305    fn append_run_event(
306        &self,
307        frame: ExecutionFrameV1,
308        record: &RunEventRecord,
309    ) -> Result<FactAppendOutcomeV1, JournalError> {
310        self.append(ExecutionFactV1::from_run_event(frame, record)?)
311    }
312
313    fn page(
314        &self,
315        target: &ExecutionTargetV1,
316        after_sequence: Option<u64>,
317        limit: usize,
318    ) -> Option<ExecutionFactPageV1>;
319    fn snapshot(&self, target: &ExecutionTargetV1) -> Option<ExecutionFactSnapshotV1>;
320}
321
322/// Small adapter for runtimes that already receive `RunEventRecord` values.
323/// It makes the frame binding explicit at construction and keeps event
324/// ingestion out of product-specific evaluator code.
325#[derive(Clone)]
326pub struct ExecutionFactRecorder {
327    journal: Arc<dyn ExecutionFactJournal>,
328    frame: ExecutionFrameV1,
329}
330
331impl std::fmt::Debug for ExecutionFactRecorder {
332    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        formatter
334            .debug_struct("ExecutionFactRecorder")
335            .field("target", &self.frame.target)
336            .finish()
337    }
338}
339
340impl ExecutionFactRecorder {
341    pub fn new(journal: Arc<dyn ExecutionFactJournal>, frame: ExecutionFrameV1) -> Self {
342        Self { journal, frame }
343    }
344
345    pub fn record(&self, event: &RunEventRecord) -> Result<FactAppendOutcomeV1, JournalError> {
346        self.journal.append_run_event(self.frame.clone(), event)
347    }
348
349    pub fn journal(&self) -> Arc<dyn ExecutionFactJournal> {
350        Arc::clone(&self.journal)
351    }
352}
353
354#[derive(Debug, Default)]
355struct FactBuffer {
356    facts: VecDeque<ExecutionFactV1>,
357    serialized_bytes: usize,
358    latest_sequence_exclusive: u64,
359    /// The frame is part of the target's immutable identity plane. Keep it
360    /// separately from the retained FIFO window so a fully trimmed journal
361    /// cannot silently accept a different parent or generation later.
362    frame: Option<ExecutionFrameV1>,
363}
364
365/// Bounded in-memory implementation used by the native harness and tests.
366/// Hosts may implement [`ExecutionFactJournal`] over a durable append log.
367#[derive(Debug, Clone)]
368pub struct InMemoryExecutionFactJournal {
369    inner: Arc<RwLock<HashMap<ExecutionTargetV1, FactBuffer>>>,
370    max_facts_per_target: Option<usize>,
371    max_bytes_per_target: Option<usize>,
372}
373
374impl InMemoryExecutionFactJournal {
375    pub fn new() -> Self {
376        Self::with_limits(None, None)
377    }
378
379    pub fn with_limits(
380        max_facts_per_target: Option<usize>,
381        max_bytes_per_target: Option<usize>,
382    ) -> Self {
383        Self {
384            inner: Arc::new(RwLock::new(HashMap::new())),
385            max_facts_per_target,
386            max_bytes_per_target,
387        }
388    }
389
390    pub fn append_event(
391        &self,
392        frame: ExecutionFrameV1,
393        record: &RunEventRecord,
394    ) -> Result<FactAppendOutcomeV1, JournalError> {
395        <Self as ExecutionFactJournal>::append_run_event(self, frame, record)
396    }
397
398    fn trim(buffer: &mut FactBuffer, max_facts: Option<usize>, max_bytes: Option<usize>) {
399        while max_facts.is_some_and(|limit| buffer.facts.len() > limit)
400            || max_bytes.is_some_and(|limit| buffer.serialized_bytes > limit)
401        {
402            let Some(fact) = buffer.facts.pop_front() else {
403                break;
404            };
405            buffer.serialized_bytes = buffer
406                .serialized_bytes
407                .saturating_sub(serialized_fact_len(&fact));
408        }
409    }
410}
411
412impl Default for InMemoryExecutionFactJournal {
413    fn default() -> Self {
414        Self::new()
415    }
416}
417
418impl ExecutionFactJournal for InMemoryExecutionFactJournal {
419    fn append(&self, fact: ExecutionFactV1) -> Result<FactAppendOutcomeV1, JournalError> {
420        fact.validate()?;
421        let target = fact.frame.target.clone();
422        let mut state = self
423            .inner
424            .write()
425            .map_err(|_| JournalError::InvalidField("lock"))?;
426        let buffer = state.entry(target).or_default();
427        if let Some(frame) = &buffer.frame {
428            if frame != &fact.frame {
429                return Err(JournalError::FrameConflict);
430            }
431        } else {
432            buffer.frame = Some(fact.frame.clone());
433        }
434        if let Some(existing) = buffer
435            .facts
436            .iter()
437            .find(|entry| entry.sequence == fact.sequence)
438        {
439            if existing == &fact {
440                return Ok(FactAppendOutcomeV1 {
441                    appended: false,
442                    replayed: true,
443                });
444            }
445            return Err(JournalError::SequenceConflict);
446        }
447        // `latest_sequence_exclusive` is cumulative and survives FIFO
448        // retention.  Basing admission on the last retained fact would allow
449        // a stream whose whole window was trimmed to restart at an arbitrary
450        // sequence (or to accept a replayed old event).
451        let expected = buffer.latest_sequence_exclusive;
452        if fact.sequence != expected {
453            return Err(JournalError::SequenceGap);
454        }
455        let next_sequence = fact
456            .sequence
457            .checked_add(1)
458            .ok_or(JournalError::InvalidField("sequence"))?;
459        buffer.serialized_bytes = buffer
460            .serialized_bytes
461            .saturating_add(serialized_fact_len(&fact));
462        buffer.latest_sequence_exclusive = next_sequence;
463        buffer.facts.push_back(fact);
464        Self::trim(buffer, self.max_facts_per_target, self.max_bytes_per_target);
465        Ok(FactAppendOutcomeV1 {
466            appended: true,
467            replayed: false,
468        })
469    }
470
471    fn page(
472        &self,
473        target: &ExecutionTargetV1,
474        after_sequence: Option<u64>,
475        limit: usize,
476    ) -> Option<ExecutionFactPageV1> {
477        if limit == 0 {
478            return None;
479        }
480        let state = self.inner.read().ok()?;
481        let buffer = state.get(target)?;
482        let first_available_sequence = buffer.facts.front().map(|fact| fact.sequence);
483        let latest_sequence_exclusive = buffer.latest_sequence_exclusive;
484        let requested_start = after_sequence
485            .map(|value| value.saturating_add(1))
486            .unwrap_or(0);
487        let retention_gap = if requested_start >= latest_sequence_exclusive {
488            false
489        } else {
490            first_available_sequence
491                .map(|first| requested_start < first)
492                .unwrap_or(true)
493        };
494        let mut matching = buffer
495            .facts
496            .iter()
497            .filter(|fact| after_sequence.is_none_or(|cursor| fact.sequence > cursor));
498        let facts = matching.by_ref().take(limit).cloned().collect::<Vec<_>>();
499        let has_more = matching.next().is_some();
500        let next_cursor = facts.last().map(|fact| fact.sequence).or(after_sequence);
501        Some(ExecutionFactPageV1 {
502            facts,
503            first_available_sequence,
504            latest_sequence_exclusive,
505            next_cursor,
506            retention_gap,
507            has_more,
508        })
509    }
510
511    fn snapshot(&self, target: &ExecutionTargetV1) -> Option<ExecutionFactSnapshotV1> {
512        self.page(target, None, usize::MAX)
513            .map(|page| ExecutionFactSnapshotV1 {
514                target: target.clone(),
515                page,
516            })
517    }
518}
519
520fn serialized_fact_len(fact: &ExecutionFactV1) -> usize {
521    serde_json::to_vec(fact)
522        .map(|bytes| bytes.len())
523        .unwrap_or(usize::MAX)
524}
525
526fn collect_artifact_refs(value: &serde_json::Value) -> Vec<String> {
527    let mut refs = Vec::new();
528    collect_artifact_refs_inner(value, &mut refs);
529    refs.sort();
530    refs.dedup();
531    refs.truncate(MAX_ARTIFACT_REFS);
532    refs
533}
534
535fn collect_artifact_refs_inner(value: &serde_json::Value, refs: &mut Vec<String>) {
536    match value {
537        serde_json::Value::Object(object) => {
538            for key in ["artifact_uri", "content_ref", "content_uri"] {
539                if let Some(uri) = object.get(key).and_then(serde_json::Value::as_str) {
540                    if uri.len() <= MAX_ARTIFACT_URI_BYTES {
541                        refs.push(uri.to_string());
542                    }
543                }
544            }
545            for child in object.values() {
546                collect_artifact_refs_inner(child, refs);
547            }
548        }
549        serde_json::Value::Array(items) => {
550            for child in items {
551                collect_artifact_refs_inner(child, refs);
552            }
553        }
554        _ => {}
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561    use crate::agent::AgentEvent;
562
563    fn frame() -> ExecutionFrameV1 {
564        ExecutionFrameV1::root(ExecutionTargetV1::new("session-1", "run-1"))
565    }
566
567    fn fact(sequence: u64) -> ExecutionFactV1 {
568        ExecutionFactV1::from_input(ExecutionFactInputV1 {
569            frame: frame(),
570            sequence,
571            observed_at_ms: sequence + 1,
572            event_type: "tool_end".to_string(),
573            payload_digest: digest_bytes("test", &[sequence as u8]),
574            payload_bytes: 1,
575            artifact_refs: Vec::new(),
576        })
577        .unwrap()
578    }
579
580    #[test]
581    fn event_input_is_digest_only_and_extracts_artifact_refs() {
582        let event = AgentEvent::ToolEnd {
583            id: "tool-1".to_string(),
584            name: "read".to_string(),
585            args: None,
586            output: "secret output".to_string(),
587            exit_code: 0,
588            metadata: Some(serde_json::json!({
589                "artifact": {"artifact_uri": "a3s://artifact/1"}
590            })),
591            error_kind: None,
592        };
593        let input = ExecutionFactInputV1::from_event(frame(), 0, 10, &event).unwrap();
594        assert_eq!(input.event_type, "tool_end");
595        assert!(input.payload_digest.starts_with("sha256:"));
596        assert_eq!(input.artifact_refs, vec!["a3s://artifact/1"]);
597        let fact = ExecutionFactV1::from_input(input).unwrap();
598        let encoded = serde_json::to_string(&fact).unwrap();
599        assert!(!encoded.contains("secret output"));
600        assert!(fact.validate().is_ok());
601    }
602
603    #[test]
604    fn journal_is_contiguous_idempotent_and_conflict_safe() {
605        let journal = InMemoryExecutionFactJournal::new();
606        assert!(journal.append(fact(0)).unwrap().appended);
607        assert!(journal.append(fact(0)).unwrap().replayed);
608        assert!(matches!(
609            journal.append(fact(2)),
610            Err(JournalError::SequenceGap)
611        ));
612        let mut conflicting = fact(0);
613        conflicting.payload_bytes = 99;
614        conflicting.fact_digest = conflicting.expected_digest().unwrap();
615        assert!(matches!(
616            journal.append(conflicting),
617            Err(JournalError::SequenceConflict)
618        ));
619    }
620
621    #[test]
622    fn fact_kind_is_derived_from_the_event_type() {
623        let mut forged = fact(0);
624        forged.kind = ExecutionFactKindV1::Lifecycle;
625        forged.fact_digest = forged.expected_digest().unwrap();
626        assert!(matches!(
627            forged.validate(),
628            Err(JournalError::InvalidField("kind"))
629        ));
630    }
631
632    #[test]
633    fn journal_keeps_frame_identity_after_fifo_retention() {
634        let journal = InMemoryExecutionFactJournal::with_limits(Some(1), None);
635        journal.append(fact(0)).unwrap();
636        journal.append(fact(1)).unwrap();
637        let target = ExecutionTargetV1::new("session-1", "run-1");
638        let forged = ExecutionFactV1::from_input(ExecutionFactInputV1 {
639            frame: ExecutionFrameV1::child(
640                target,
641                ExecutionTargetV1::new("session-parent", "parent-run"),
642            ),
643            sequence: 2,
644            observed_at_ms: 3,
645            event_type: "tool_end".to_string(),
646            payload_digest: digest_bytes("test", &[2]),
647            payload_bytes: 1,
648            artifact_refs: Vec::new(),
649        })
650        .unwrap();
651        assert!(matches!(
652            journal.append(forged),
653            Err(JournalError::FrameConflict)
654        ));
655    }
656
657    #[test]
658    fn journal_reports_retention_gap() {
659        let journal = InMemoryExecutionFactJournal::with_limits(Some(2), None);
660        for sequence in 0..3 {
661            journal.append(fact(sequence)).unwrap();
662        }
663        let target = ExecutionTargetV1::new("session-1", "run-1");
664        let page = journal.page(&target, None, 10).unwrap();
665        assert_eq!(page.first_available_sequence, Some(1));
666        assert_eq!(page.latest_sequence_exclusive, 3);
667        assert!(page.retention_gap);
668        assert_eq!(page.facts.len(), 2);
669    }
670
671    #[test]
672    fn journal_requires_zero_for_a_new_stream_and_keeps_cursor_after_full_trim() {
673        let journal = InMemoryExecutionFactJournal::new();
674        assert!(matches!(
675            journal.append(fact(1)),
676            Err(JournalError::SequenceGap)
677        ));
678
679        let trimmed = InMemoryExecutionFactJournal::with_limits(Some(1), Some(1));
680        trimmed.append(fact(0)).unwrap();
681        let target = ExecutionTargetV1::new("session-1", "run-1");
682        let page = trimmed.page(&target, None, 10).unwrap();
683        assert!(page.facts.is_empty());
684        assert_eq!(page.latest_sequence_exclusive, 1);
685        assert!(page.retention_gap);
686        assert!(matches!(
687            trimmed.append(fact(0)),
688            Err(JournalError::SequenceGap)
689        ));
690        trimmed.append(fact(1)).unwrap();
691
692        let no_future = trimmed.page(&target, Some(u64::MAX), 10).unwrap();
693        assert!(!no_future.retention_gap);
694        assert!(no_future.facts.is_empty());
695    }
696}