1use 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";
27pub 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#[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 latest_cursor_exclusive: u64,
224 tail_entry_digest: Option<String>,
227 generation_max: Option<u64>,
229 source_revision_max: u64,
231}
232
233#[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 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 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 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 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 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 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
483fn 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 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 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 assert!(matches!(
786 log.append(entry(0)),
787 Err(CoreEventLogError::CursorGap)
788 ));
789 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}