1use 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#[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 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#[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 frame: Option<ExecutionFrameV1>,
363}
364
365#[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 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}