Skip to main content

a3s_code_core/
core_event_log.rs

1//! Append-only core event log shared by runtime projections.
2//!
3//! This is the KRN-1 event fabric: one bounded, hash-chained log of
4//! [`CoreEventIdentity`] values per
5//! operation. The existing run journal, evaluation fact journal, and research
6//! projections remain the compatibility authorities; this log lets a host
7//! append and replay one canonical event stream where duplicate, reordered,
8//! stale-generation, and cursor-skipping writes fail closed.
9//!
10//! The log deliberately stores digest-only identities plus bounded artifact
11//! URI references. Raw payloads stay in their existing stores; nothing here
12//! becomes a second audit database.
13
14use crate::agent::AgentEvent;
15use crate::core_identity::{
16    CoreEventIdentity, CoreIdentity, CoreIdentityError, EvidenceCursor, LogicalClock, OperationId,
17    SourceRevision,
18};
19use crate::run::RunEventRecord;
20use serde::{Deserialize, Serialize};
21use std::collections::{HashMap, VecDeque};
22use std::sync::{Arc, RwLock};
23use thiserror::Error;
24
25pub const CORE_LOG_ENTRY_SCHEMA_V1: &str = "a3s.code.core-event-log-entry.v1";
26pub const CORE_LOG_ENTRY_DIGEST_DOMAIN_V1: &str = "a3s.code.core-event-log.entry.v1";
27/// Chain anchor used by the first entry of every operation stream.
28pub const CORE_LOG_GENESIS_PREVIOUS_DIGEST: &str =
29    "sha256:0000000000000000000000000000000000000000000000000000000000000000";
30const MAX_LOG_ENTRIES_PER_OPERATION: usize = 65_536;
31const MAX_LOG_BYTES_PER_OPERATION: usize = 64 * 1024 * 1024;
32const MAX_ARTIFACT_REFS: usize = 128;
33const MAX_ARTIFACT_URI_BYTES: usize = 1024;
34
35#[derive(Debug, Clone, PartialEq, Eq, Error)]
36pub enum CoreEventLogError {
37    #[error("core event log entry schema is unsupported")]
38    UnsupportedSchema,
39    #[error("core event log field `{0}` is invalid")]
40    InvalidField(&'static str),
41    #[error("core event log `{0}` does not match its contents")]
42    DigestMismatch(&'static str),
43    #[error("core event log cursor is not contiguous with the stream tail")]
44    CursorGap,
45    #[error("core event log cursor conflicts with a different retained entry")]
46    CursorConflict,
47    #[error("core event log entry does not chain to the current stream tail")]
48    CausalityConflict,
49    #[error("core event log rejects an older capability generation than the stream pin")]
50    StaleGeneration,
51    #[error("core event log rejects a regressing source revision")]
52    StaleSourceRevision,
53    #[error("core event log serialization failed: {0}")]
54    Serialization(String),
55    #[error("core event log lock is poisoned")]
56    LockPoisoned,
57}
58
59impl From<CoreIdentityError> for CoreEventLogError {
60    fn from(error: CoreIdentityError) -> Self {
61        match error {
62            CoreIdentityError::UnsupportedSchema => Self::UnsupportedSchema,
63            CoreIdentityError::InvalidDigest(field) => Self::DigestMismatch(field),
64            CoreIdentityError::Serialization(message) => Self::Serialization(message),
65            other => Self::InvalidField(match other {
66                CoreIdentityError::InvalidField(field) => field,
67                _ => "identity",
68            }),
69        }
70    }
71}
72
73/// One hash-chained log entry: a validated core event identity plus the
74/// bounded artifact references that locate its payload.
75#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
76#[serde(rename_all = "camelCase", deny_unknown_fields)]
77pub struct CoreLogEntryV1 {
78    pub schema: String,
79    pub event: CoreEventIdentity,
80    pub previous_digest: String,
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub artifact_refs: Vec<String>,
83    pub entry_digest: String,
84}
85
86impl CoreLogEntryV1 {
87    pub fn new(
88        event: CoreEventIdentity,
89        previous_digest: impl Into<String>,
90        artifact_refs: Vec<String>,
91    ) -> Result<Self, CoreEventLogError> {
92        let mut entry = Self {
93            schema: CORE_LOG_ENTRY_SCHEMA_V1.to_owned(),
94            event,
95            previous_digest: previous_digest.into(),
96            artifact_refs,
97            entry_digest: String::new(),
98        };
99        entry.validate_without_digest()?;
100        entry.entry_digest = entry.expected_digest()?;
101        Ok(entry)
102    }
103
104    pub fn validate(&self) -> Result<(), CoreEventLogError> {
105        self.validate_without_digest()?;
106        validate_digest("entry_digest", &self.entry_digest)?;
107        if self.entry_digest != self.expected_digest()? {
108            return Err(CoreEventLogError::DigestMismatch("entry_digest"));
109        }
110        Ok(())
111    }
112
113    fn expected_digest(&self) -> Result<String, CoreEventLogError> {
114        #[derive(Serialize)]
115        struct Identity<'a> {
116            schema: &'a str,
117            event_digest: &'a str,
118            previous_digest: &'a str,
119            artifact_refs: &'a [String],
120        }
121        let bytes = serde_json::to_vec(&Identity {
122            schema: &self.schema,
123            event_digest: &self.event.event_digest,
124            previous_digest: &self.previous_digest,
125            artifact_refs: &self.artifact_refs,
126        })
127        .map_err(|error| CoreEventLogError::Serialization(error.to_string()))?;
128        Ok(digest_bytes(CORE_LOG_ENTRY_DIGEST_DOMAIN_V1, &bytes))
129    }
130
131    fn validate_without_digest(&self) -> Result<(), CoreEventLogError> {
132        if self.schema != CORE_LOG_ENTRY_SCHEMA_V1 {
133            return Err(CoreEventLogError::UnsupportedSchema);
134        }
135        self.event.validate()?;
136        let is_genesis = self.previous_digest == CORE_LOG_GENESIS_PREVIOUS_DIGEST;
137        if !is_genesis {
138            validate_digest("previous_digest", &self.previous_digest)?;
139        }
140        if self.artifact_refs.len() > MAX_ARTIFACT_REFS
141            || self.artifact_refs.iter().any(|uri| {
142                uri.is_empty()
143                    || uri.len() > MAX_ARTIFACT_URI_BYTES
144                    || uri.contains('\0')
145                    || uri.lines().count() != 1
146            })
147        {
148            return Err(CoreEventLogError::InvalidField("artifact_refs"));
149        }
150        if self
151            .artifact_refs
152            .windows(2)
153            .any(|window| window[0] >= window[1])
154        {
155            return Err(CoreEventLogError::InvalidField("artifact_refs"));
156        }
157        Ok(())
158    }
159}
160
161impl<'de> Deserialize<'de> for CoreLogEntryV1 {
162    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
163    where
164        D: serde::Deserializer<'de>,
165    {
166        #[derive(Deserialize)]
167        #[serde(rename_all = "camelCase", deny_unknown_fields)]
168        struct Wire {
169            schema: String,
170            event: CoreEventIdentity,
171            previous_digest: String,
172            #[serde(default)]
173            artifact_refs: Vec<String>,
174            entry_digest: String,
175        }
176
177        let wire = Wire::deserialize(deserializer)?;
178        let value = Self {
179            schema: wire.schema,
180            event: wire.event,
181            previous_digest: wire.previous_digest,
182            artifact_refs: wire.artifact_refs,
183            entry_digest: wire.entry_digest,
184        };
185        value.validate().map_err(serde::de::Error::custom)?;
186        Ok(value)
187    }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(deny_unknown_fields, rename_all = "camelCase")]
192pub struct CoreLogAppendOutcomeV1 {
193    pub appended: bool,
194    pub replayed: bool,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(deny_unknown_fields, rename_all = "camelCase")]
199pub struct CoreLogPageV1 {
200    pub entries: Vec<CoreLogEntryV1>,
201    pub first_available_cursor: Option<u64>,
202    pub latest_cursor_exclusive: u64,
203    pub next_cursor: Option<u64>,
204    pub retention_gap: bool,
205    pub has_more: bool,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(deny_unknown_fields, rename_all = "camelCase")]
210pub struct CoreLogVerificationV1 {
211    pub verified_entries: usize,
212    pub first_available_cursor: Option<u64>,
213    pub latest_cursor_exclusive: u64,
214    pub chain_head_digest: String,
215}
216
217#[derive(Debug, Default)]
218struct LogBuffer {
219    entries: VecDeque<CoreLogEntryV1>,
220    serialized_bytes: usize,
221    /// Cumulative evidence cursor bound; survives FIFO retention so a fully
222    /// trimmed stream cannot restart at an arbitrary cursor.
223    latest_cursor_exclusive: u64,
224    /// Current chain head; survives FIFO retention so causality stays exact
225    /// even when the newest retained entry has been evicted.
226    tail_entry_digest: Option<String>,
227    /// Highest capability generation ever admitted; stale generations fail.
228    generation_max: Option<u64>,
229    /// Highest source revision ever admitted; regressions fail.
230    source_revision_max: u64,
231}
232
233/// Bounded in-memory append-only log over core event identities.
234///
235/// Hosts may adapt durable stores behind the same admission rules; the
236/// in-memory implementation is the reference semantics and test oracle.
237#[derive(Debug, Clone)]
238pub struct CoreEventLog {
239    inner: Arc<RwLock<HashMap<OperationId, LogBuffer>>>,
240    max_entries_per_operation: Option<usize>,
241    max_bytes_per_operation: Option<usize>,
242}
243
244impl CoreEventLog {
245    pub fn new() -> Self {
246        Self::with_limits(None, None)
247    }
248
249    pub fn with_limits(
250        max_entries_per_operation: Option<usize>,
251        max_bytes_per_operation: Option<usize>,
252    ) -> Self {
253        Self {
254            inner: Arc::new(RwLock::new(HashMap::new())),
255            max_entries_per_operation: max_entries_per_operation
256                .map(|limit| limit.min(MAX_LOG_ENTRIES_PER_OPERATION)),
257            max_bytes_per_operation: max_bytes_per_operation
258                .map(|limit| limit.min(MAX_LOG_BYTES_PER_OPERATION)),
259        }
260    }
261
262    /// Append one already-chained entry under the fail-closed admission rules.
263    pub fn append(
264        &self,
265        entry: CoreLogEntryV1,
266    ) -> Result<CoreLogAppendOutcomeV1, CoreEventLogError> {
267        entry.validate()?;
268        let operation = entry.event.identity.operation_id.clone();
269        let mut state = self
270            .inner
271            .write()
272            .map_err(|_| CoreEventLogError::LockPoisoned)?;
273        let buffer = state.entry(operation).or_default();
274        let appended = admit(buffer, &entry)?;
275        if appended {
276            buffer.serialized_bytes = buffer
277                .serialized_bytes
278                .saturating_add(serialized_entry_len(&entry));
279            buffer.entries.push_back(entry);
280            trim(
281                buffer,
282                self.max_entries_per_operation,
283                self.max_bytes_per_operation,
284            );
285        }
286        Ok(CoreLogAppendOutcomeV1 {
287            appended,
288            replayed: !appended,
289        })
290    }
291
292    /// Adapt, chain, and append one agent event through the shared identity
293    /// plane. The chain anchor is resolved under the same lock as admission,
294    /// so concurrent appenders cannot fork the chain.
295    pub fn append_agent_event(
296        &self,
297        identity: CoreIdentity,
298        clock: &dyn LogicalClock,
299        event: &AgentEvent,
300        artifact_refs: Vec<String>,
301    ) -> Result<CoreLogAppendOutcomeV1, CoreEventLogError> {
302        let operation = identity.operation_id.clone();
303        let core_event = CoreEventIdentity::from_agent_event_at(identity, clock, event)?;
304        {
305            let state = self
306                .inner
307                .read()
308                .map_err(|_| CoreEventLogError::LockPoisoned)?;
309            if let Some(buffer) = state.get(&operation) {
310                if let Some(existing) = buffer.entries.iter().find(|entry| {
311                    entry.event.identity.evidence_cursor == core_event.identity.evidence_cursor
312                }) {
313                    if existing.event == core_event {
314                        return Ok(CoreLogAppendOutcomeV1 {
315                            appended: false,
316                            replayed: true,
317                        });
318                    }
319                    return Err(CoreEventLogError::CursorConflict);
320                }
321            }
322        }
323        let previous_digest = {
324            let state = self
325                .inner
326                .read()
327                .map_err(|_| CoreEventLogError::LockPoisoned)?;
328            state
329                .get(&operation)
330                .and_then(|buffer| buffer.tail_entry_digest.clone())
331                .unwrap_or_else(|| CORE_LOG_GENESIS_PREVIOUS_DIGEST.to_owned())
332        };
333        let entry = CoreLogEntryV1::new(core_event, previous_digest, artifact_refs)?;
334        self.append(entry)
335    }
336
337    /// Adapt the event representation already retained by a Code run.
338    pub fn append_run_event(
339        &self,
340        operation_id: OperationId,
341        source_revision: SourceRevision,
342        capability_stamp: Option<crate::core_identity::CapabilityStamp>,
343        record: &RunEventRecord,
344    ) -> Result<CoreLogAppendOutcomeV1, CoreEventLogError> {
345        let identity = CoreIdentity::new(
346            operation_id,
347            source_revision,
348            capability_stamp,
349            EvidenceCursor::new(0),
350        );
351        let core_event = CoreEventIdentity::from_run_event(
352            identity.operation_id.clone(),
353            identity.source_revision,
354            identity.capability_stamp.clone(),
355            record,
356        )?;
357        let artifact_refs = collect_artifact_refs(
358            &serde_json::to_value(&record.event)
359                .map_err(|error| CoreEventLogError::Serialization(error.to_string()))?,
360        );
361        self.append_core_event(core_event, artifact_refs)
362    }
363
364    /// Chain and append an already adapted core event identity.
365    pub fn append_core_event(
366        &self,
367        core_event: CoreEventIdentity,
368        artifact_refs: Vec<String>,
369    ) -> Result<CoreLogAppendOutcomeV1, CoreEventLogError> {
370        let operation = core_event.identity.operation_id.clone();
371        let previous_digest = {
372            let state = self
373                .inner
374                .read()
375                .map_err(|_| CoreEventLogError::LockPoisoned)?;
376            state
377                .get(&operation)
378                .and_then(|buffer| buffer.tail_entry_digest.clone())
379                .unwrap_or_else(|| CORE_LOG_GENESIS_PREVIOUS_DIGEST.to_owned())
380        };
381        let entry = CoreLogEntryV1::new(core_event, previous_digest, artifact_refs)?;
382        self.append(entry)
383    }
384
385    /// Replay retained entries after a cursor. Every paged entry is validated
386    /// (identity digest plus chain position) so tampering fails closed.
387    pub fn page(
388        &self,
389        operation: &OperationId,
390        after_cursor: Option<EvidenceCursor>,
391        limit: usize,
392    ) -> Result<Option<CoreLogPageV1>, CoreEventLogError> {
393        if limit == 0 {
394            return Err(CoreEventLogError::InvalidField("limit"));
395        }
396        let state = self
397            .inner
398            .read()
399            .map_err(|_| CoreEventLogError::LockPoisoned)?;
400        let Some(buffer) = state.get(operation) else {
401            return Ok(None);
402        };
403        let after = after_cursor.map(|cursor| cursor.sequence());
404        let first_available_cursor = buffer
405            .entries
406            .front()
407            .map(|entry| entry.event.identity.evidence_cursor.sequence());
408        let latest_cursor_exclusive = buffer.latest_cursor_exclusive;
409        let requested_start = after.map(|value| value + 1).unwrap_or(0);
410        let retention_gap = if requested_start >= latest_cursor_exclusive {
411            false
412        } else {
413            first_available_cursor
414                .map(|first| requested_start < first)
415                .unwrap_or(true)
416        };
417        let mut expected_previous = buffer
418            .entries
419            .front()
420            .map(|entry| entry.previous_digest.clone());
421        let mut matching = buffer.entries.iter().filter(|entry| {
422            after.is_none_or(|cursor| entry.event.identity.evidence_cursor.sequence() > cursor)
423        });
424        let mut entries = Vec::new();
425        let mut has_more = false;
426        for entry in matching.by_ref() {
427            entry.validate()?;
428            if let Some(previous) = &expected_previous {
429                if entry.previous_digest != *previous {
430                    return Err(CoreEventLogError::CausalityConflict);
431                }
432            }
433            expected_previous = Some(entry.entry_digest.clone());
434            if entries.len() < limit {
435                entries.push(entry.clone());
436            } else {
437                has_more = true;
438                break;
439            }
440        }
441        if entries.len() == limit {
442            has_more = matching.next().is_some();
443        }
444        let next_cursor = entries
445            .last()
446            .map(|entry| entry.event.identity.evidence_cursor.sequence())
447            .or(after);
448        Ok(Some(CoreLogPageV1 {
449            entries,
450            first_available_cursor,
451            latest_cursor_exclusive,
452            next_cursor,
453            retention_gap,
454            has_more,
455        }))
456    }
457
458    /// Verify the complete retained window of one operation stream.
459    pub fn verify(
460        &self,
461        operation: &OperationId,
462    ) -> Result<Option<CoreLogVerificationV1>, CoreEventLogError> {
463        let page = self.page(operation, None, usize::MAX)?;
464        Ok(page.map(|page| CoreLogVerificationV1 {
465            verified_entries: page.entries.len(),
466            first_available_cursor: page.first_available_cursor,
467            latest_cursor_exclusive: page.latest_cursor_exclusive,
468            chain_head_digest: page
469                .entries
470                .last()
471                .map(|entry| entry.entry_digest.clone())
472                .unwrap_or_else(|| CORE_LOG_GENESIS_PREVIOUS_DIGEST.to_owned()),
473        }))
474    }
475}
476
477impl Default for CoreEventLog {
478    fn default() -> Self {
479        Self::new()
480    }
481}
482
483/// Admission rules shared by every append path. Mutates the buffer's
484/// cumulative identity pins on success; pushes nothing (the caller owns the
485/// queue so it can account retention first).
486fn admit(buffer: &mut LogBuffer, entry: &CoreLogEntryV1) -> Result<bool, CoreEventLogError> {
487    let cursor = entry.event.identity.evidence_cursor.sequence();
488    if let Some(generation) = entry.event.identity.capability_stamp.as_ref() {
489        if buffer
490            .generation_max
491            .is_some_and(|current| generation.generation() < current)
492        {
493            return Err(CoreEventLogError::StaleGeneration);
494        }
495    }
496    if entry.event.identity.source_revision.value() < buffer.source_revision_max {
497        return Err(CoreEventLogError::StaleSourceRevision);
498    }
499    if let Some(existing) = buffer
500        .entries
501        .iter()
502        .find(|retained| retained.event.identity.evidence_cursor.sequence() == cursor)
503    {
504        if existing == entry {
505            return Ok(false);
506        }
507        return Err(CoreEventLogError::CursorConflict);
508    }
509    if cursor != buffer.latest_cursor_exclusive {
510        return Err(CoreEventLogError::CursorGap);
511    }
512    let expected_previous = buffer
513        .tail_entry_digest
514        .clone()
515        .unwrap_or_else(|| CORE_LOG_GENESIS_PREVIOUS_DIGEST.to_owned());
516    if entry.previous_digest != expected_previous {
517        return Err(CoreEventLogError::CausalityConflict);
518    }
519    let next_cursor = cursor
520        .checked_add(1)
521        .ok_or(CoreEventLogError::InvalidField("evidence_cursor"))?;
522    buffer.latest_cursor_exclusive = next_cursor;
523    buffer.tail_entry_digest = Some(entry.entry_digest.clone());
524    if let Some(stamp) = entry.event.identity.capability_stamp.as_ref() {
525        buffer.generation_max = Some(buffer.generation_max.map_or_else(
526            || stamp.generation(),
527            |current| current.max(stamp.generation()),
528        ));
529    }
530    buffer.source_revision_max = buffer
531        .source_revision_max
532        .max(entry.event.identity.source_revision.value());
533    Ok(true)
534}
535
536fn trim(buffer: &mut LogBuffer, max_entries: Option<usize>, max_bytes: Option<usize>) {
537    while max_entries.is_some_and(|limit| buffer.entries.len() > limit)
538        || max_bytes.is_some_and(|limit| buffer.serialized_bytes > limit)
539    {
540        let Some(entry) = buffer.entries.pop_front() else {
541            break;
542        };
543        buffer.serialized_bytes = buffer
544            .serialized_bytes
545            .saturating_sub(serialized_entry_len(&entry));
546    }
547}
548
549fn serialized_entry_len(entry: &CoreLogEntryV1) -> usize {
550    serde_json::to_vec(entry)
551        .map(|bytes| bytes.len())
552        .unwrap_or(usize::MAX)
553}
554
555fn collect_artifact_refs(value: &serde_json::Value) -> Vec<String> {
556    let mut refs = Vec::new();
557    collect_artifact_refs_inner(value, &mut refs);
558    refs.sort();
559    refs.dedup();
560    refs.truncate(MAX_ARTIFACT_REFS);
561    refs
562}
563
564fn collect_artifact_refs_inner(value: &serde_json::Value, refs: &mut Vec<String>) {
565    match value {
566        serde_json::Value::Object(object) => {
567            for key in ["artifact_uri", "content_ref", "content_uri"] {
568                if let Some(uri) = object.get(key).and_then(serde_json::Value::as_str) {
569                    if uri.len() <= MAX_ARTIFACT_URI_BYTES {
570                        refs.push(uri.to_string());
571                    }
572                }
573            }
574            for child in object.values() {
575                collect_artifact_refs_inner(child, refs);
576            }
577        }
578        serde_json::Value::Array(items) => {
579            for child in items {
580                collect_artifact_refs_inner(child, refs);
581            }
582        }
583        _ => {}
584    }
585}
586
587fn validate_digest(field: &'static str, value: &str) -> Result<(), CoreEventLogError> {
588    if value.len() != 71
589        || !value.starts_with("sha256:")
590        || !value[7..]
591            .bytes()
592            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
593    {
594        return Err(CoreEventLogError::DigestMismatch(field));
595    }
596    Ok(())
597}
598
599fn digest_bytes(domain: &str, bytes: &[u8]) -> String {
600    use sha2::{Digest, Sha256};
601    let mut hasher = Sha256::new();
602    hasher.update(domain.as_bytes());
603    hasher.update([0]);
604    hasher.update(bytes);
605    format!("sha256:{:x}", hasher.finalize())
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use crate::core_identity::{CapabilityStamp, ManualLogicalClock, OperationId, SourceRevision};
612
613    fn operation() -> OperationId {
614        OperationId::new("session-1/run-1").unwrap()
615    }
616
617    fn identity(cursor: u64) -> CoreIdentity {
618        CoreIdentity::new(
619            operation(),
620            SourceRevision::new(7),
621            Some(
622                CapabilityStamp::new(
623                    3,
624                    "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
625                )
626                .unwrap(),
627            ),
628            EvidenceCursor::new(cursor),
629        )
630    }
631
632    fn entry(cursor: u64) -> CoreLogEntryV1 {
633        let clock = ManualLogicalClock::new(1_000 + cursor);
634        let event = AgentEvent::TurnStart {
635            turn: cursor as usize,
636        };
637        let core_event =
638            CoreEventIdentity::from_agent_event_at(identity(cursor), &clock, &event).unwrap();
639        let previous = if cursor == 0 {
640            CORE_LOG_GENESIS_PREVIOUS_DIGEST.to_owned()
641        } else {
642            entry(cursor - 1).entry_digest
643        };
644        CoreLogEntryV1::new(core_event, previous, Vec::new()).unwrap()
645    }
646
647    #[test]
648    fn appending_and_replaying_twice_is_idempotent() {
649        let log = CoreEventLog::new();
650        for cursor in 0..4 {
651            let outcome = log.append(entry(cursor)).unwrap();
652            assert!(outcome.appended && !outcome.replayed);
653        }
654        // Re-appending the identical batch replays without duplicating.
655        for cursor in 0..4 {
656            let outcome = log.append(entry(cursor)).unwrap();
657            assert!(!outcome.appended && outcome.replayed);
658        }
659        let first = log.page(&operation(), None, usize::MAX).unwrap().unwrap();
660        let second = log.page(&operation(), None, usize::MAX).unwrap().unwrap();
661        assert_eq!(first, second);
662        assert_eq!(first.entries.len(), 4);
663        assert_eq!(first.latest_cursor_exclusive, 4);
664        assert!(!first.retention_gap);
665        let verification = log.verify(&operation()).unwrap().unwrap();
666        assert_eq!(verification.verified_entries, 4);
667    }
668
669    #[test]
670    fn duplicate_cursor_with_different_content_fails_closed() {
671        let log = CoreEventLog::new();
672        log.append(entry(0)).unwrap();
673        let mut forged = entry(0);
674        let clock = ManualLogicalClock::new(9_999);
675        let event = AgentEvent::Error {
676            message: "forged".to_owned(),
677        };
678        forged.event = CoreEventIdentity::from_agent_event_at(identity(0), &clock, &event).unwrap();
679        forged.entry_digest = forged.expected_digest().unwrap();
680        assert!(matches!(
681            log.append(forged),
682            Err(CoreEventLogError::CursorConflict)
683        ));
684    }
685
686    #[test]
687    fn reordered_and_cursor_skipping_writes_fail_closed() {
688        let log = CoreEventLog::new();
689        assert!(matches!(
690            log.append(entry(1)),
691            Err(CoreEventLogError::CursorGap)
692        ));
693        log.append(entry(0)).unwrap();
694        assert!(matches!(
695            log.append(entry(2)),
696            Err(CoreEventLogError::CursorGap)
697        ));
698        // A next-cursor entry that bypasses the current tail cannot fork the
699        // stream: the causality check fails it closed even though its cursor
700        // is exactly the next expected one.
701        let mut fork = entry(1);
702        fork.previous_digest = CORE_LOG_GENESIS_PREVIOUS_DIGEST.to_owned();
703        fork.entry_digest = fork.expected_digest().unwrap();
704        assert!(matches!(
705            log.append(fork),
706            Err(CoreEventLogError::CausalityConflict)
707        ));
708    }
709
710    #[test]
711    fn causality_conflicts_fail_closed() {
712        let log = CoreEventLog::new();
713        log.append(entry(0)).unwrap();
714        let mut orphan = entry(1);
715        orphan.previous_digest = CORE_LOG_GENESIS_PREVIOUS_DIGEST.to_owned();
716        orphan.entry_digest = orphan.expected_digest().unwrap();
717        assert!(matches!(
718            log.append(orphan),
719            Err(CoreEventLogError::CausalityConflict)
720        ));
721    }
722
723    #[test]
724    fn stale_generation_and_source_regression_fail_closed() {
725        let log = CoreEventLog::new();
726        log.append(entry(0)).unwrap();
727        let clock = ManualLogicalClock::new(2_000);
728        let event = AgentEvent::TurnStart { turn: 1 };
729        let stale_generation = CoreIdentity::new(
730            operation(),
731            SourceRevision::new(7),
732            Some(
733                CapabilityStamp::new(
734                    2,
735                    "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
736                )
737                .unwrap(),
738            ),
739            EvidenceCursor::new(1),
740        );
741        let stale_event =
742            CoreEventIdentity::from_agent_event_at(stale_generation, &clock, &event).unwrap();
743        let stale_entry =
744            CoreLogEntryV1::new(stale_event, entry(0).entry_digest, Vec::new()).unwrap();
745        assert!(matches!(
746            log.append(stale_entry),
747            Err(CoreEventLogError::StaleGeneration)
748        ));
749
750        let stale_source = CoreIdentity::new(
751            operation(),
752            SourceRevision::new(6),
753            Some(
754                CapabilityStamp::new(
755                    3,
756                    "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
757                )
758                .unwrap(),
759            ),
760            EvidenceCursor::new(1),
761        );
762        let stale_source_event =
763            CoreEventIdentity::from_agent_event_at(stale_source, &clock, &event).unwrap();
764        let stale_source_entry =
765            CoreLogEntryV1::new(stale_source_event, entry(0).entry_digest, Vec::new()).unwrap();
766        assert!(matches!(
767            log.append(stale_source_entry),
768            Err(CoreEventLogError::StaleSourceRevision)
769        ));
770    }
771
772    #[test]
773    fn retention_reports_gaps_and_keeps_identity_pins() {
774        let log = CoreEventLog::with_limits(Some(2), None);
775        for cursor in 0..4 {
776            log.append(entry(cursor)).unwrap();
777        }
778        let page = log.page(&operation(), None, usize::MAX).unwrap().unwrap();
779        assert_eq!(page.entries.len(), 2);
780        assert_eq!(page.first_available_cursor, Some(2));
781        assert_eq!(page.latest_cursor_exclusive, 4);
782        assert!(page.retention_gap);
783        // A fully trimmed replay of an old cursor fails closed instead of
784        // restarting the stream.
785        assert!(matches!(
786            log.append(entry(0)),
787            Err(CoreEventLogError::CursorGap)
788        ));
789        // The generation pin survives retention.
790        let clock = ManualLogicalClock::new(9_000);
791        let event = AgentEvent::TurnStart { turn: 99 };
792        let stale = CoreIdentity::new(
793            operation(),
794            SourceRevision::new(7),
795            Some(
796                CapabilityStamp::new(
797                    2,
798                    "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
799                )
800                .unwrap(),
801            ),
802            EvidenceCursor::new(4),
803        );
804        let stale_event = CoreEventIdentity::from_agent_event_at(stale, &clock, &event).unwrap();
805        let stale_entry = CoreLogEntryV1::new(
806            stale_event,
807            page.entries[1].entry_digest.clone(),
808            Vec::new(),
809        )
810        .unwrap();
811        assert!(matches!(
812            log.append(stale_entry),
813            Err(CoreEventLogError::StaleGeneration)
814        ));
815    }
816
817    #[test]
818    fn replay_detects_a_tampered_chain() {
819        let log = CoreEventLog::new();
820        for cursor in 0..3 {
821            log.append(entry(cursor)).unwrap();
822        }
823        {
824            let mut state = log.inner.write().unwrap();
825            let buffer = state.get_mut(&operation()).unwrap();
826            buffer.entries[1].artifact_refs = vec!["a3s://artifact/1".to_owned()];
827        }
828        assert!(matches!(
829            log.verify(&operation()),
830            Err(CoreEventLogError::DigestMismatch(_))
831        ));
832    }
833
834    #[test]
835    fn agent_event_adapter_chains_and_replays() {
836        let log = CoreEventLog::new();
837        let clock = ManualLogicalClock::new(500);
838        let event = AgentEvent::TurnStart { turn: 0 };
839        let outcome = log
840            .append_agent_event(identity(0), &clock, &event, Vec::new())
841            .unwrap();
842        assert!(outcome.appended);
843        let replay = log
844            .append_agent_event(identity(0), &clock, &event, Vec::new())
845            .unwrap();
846        assert!(replay.replayed && !replay.appended);
847        let page = log.page(&operation(), None, usize::MAX).unwrap().unwrap();
848        assert_eq!(page.entries.len(), 1);
849        assert_eq!(
850            page.entries[0].event.identity.operation_id.as_str(),
851            "session-1/run-1"
852        );
853    }
854
855    #[cfg(feature = "research")]
856    #[test]
857    fn projections_share_one_identity_through_the_log() {
858        use crate::research::ResearchEventV1;
859        let log = CoreEventLog::new();
860        let clock = ManualLogicalClock::new(1_000);
861        let event = AgentEvent::TurnStart { turn: 0 };
862        log.append_agent_event(identity(0), &clock, &event, Vec::new())
863            .unwrap();
864        let page = log.page(&operation(), None, usize::MAX).unwrap().unwrap();
865        let core = page.entries[0].event.clone();
866        let projected = ResearchEventV1::from_core_event("project-1", 3, &core).unwrap();
867        let replayed = ResearchEventV1::from_core_event("project-1", 3, &core).unwrap();
868        assert_eq!(projected, replayed);
869        assert_eq!(projected.payload_digest, core.payload_digest);
870        assert_eq!(projected.observed_at_ms, core.observed_at_ms);
871    }
872}