Skip to main content

converge_core/
experience_store.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! # Experience Store Types — Append-only ledger boundary
5//!
6//! This module defines the **portable contract** for Converge's experience-store
7//! subsystem. It captures append-only events, provenance, and lifecycle
8//! transitions without binding to any storage backend.
9//!
10//! ## Axioms
11//!
12//! - **Append-only**: Corrections are new events, not mutations
13//! - **Audit-first**: Every promotion and policy snapshot is explicit
14//! - **Replay clarity**: Replayability downgrades are explicit
15//!
16//! ## What lives here (converge-core)
17//!
18//! - `ExperienceEvent` + `ExperienceEventEnvelope`
19//! - `ExperienceStore` trait (boundary only)
20//! - Query types for events and artifacts
21//!
22//! ## What stays out
23//!
24//! - Storage implementation (SurrealDB, SQLite, etc.)
25//! - Index definitions and migrations
26
27use serde::{Deserialize, Serialize};
28
29use crate::governed_artifact::{GovernedArtifactState, LifecycleEvent, RollbackRecord};
30use crate::kernel_boundary::{
31    DecisionStep, KernelPolicy, KernelProposal, ReplayTrace, Replayability,
32    ReplayabilityDowngradeReason, RoutingPolicy,
33};
34use crate::recall::{RecallPolicy, RecallProvenanceEnvelope, RecallQuery};
35
36// ============================================================================
37// Event Envelope
38// ============================================================================
39
40/// Append-only event envelope.
41///
42/// The envelope carries stable metadata (ids, timestamps, correlation) and a
43/// typed event payload. Implementations store and index envelopes, not raw
44/// payloads, to keep provenance queryable without decoding payload JSON.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ExperienceEventEnvelope {
47    /// Unique event identifier (ULID/UUID)
48    pub event_id: String,
49    /// ISO 8601 timestamp of occurrence
50    pub occurred_at: String,
51    /// Optional tenant scope
52    pub tenant_id: Option<String>,
53    /// Correlation ID for chain/run grouping
54    pub correlation_id: Option<String>,
55    /// Typed event payload
56    pub event: ExperienceEvent,
57}
58
59impl ExperienceEventEnvelope {
60    /// Create a new envelope with a placeholder timestamp.
61    ///
62    /// Production systems should call `with_timestamp()` to set a trusted time.
63    #[must_use]
64    pub fn new(event_id: impl Into<String>, event: ExperienceEvent) -> Self {
65        Self {
66            event_id: event_id.into(),
67            occurred_at: Self::now_iso8601(),
68            tenant_id: None,
69            correlation_id: None,
70            event,
71        }
72    }
73
74    /// Add a tenant scope.
75    #[must_use]
76    pub fn with_tenant(mut self, tenant_id: impl Into<String>) -> Self {
77        self.tenant_id = Some(tenant_id.into());
78        self
79    }
80
81    /// Add a correlation ID.
82    #[must_use]
83    pub fn with_correlation(mut self, correlation_id: impl Into<String>) -> Self {
84        self.correlation_id = Some(correlation_id.into());
85        self
86    }
87
88    /// Set explicit timestamp (for replay/testing).
89    #[must_use]
90    pub fn with_timestamp(mut self, occurred_at: impl Into<String>) -> Self {
91        self.occurred_at = occurred_at.into();
92        self
93    }
94
95    /// Generate ISO 8601 timestamp.
96    ///
97    /// Note: This returns a placeholder. Production systems should use
98    /// `with_timestamp()` to inject a timestamp from a trusted source.
99    fn now_iso8601() -> String {
100        "1970-01-01T00:00:00Z".to_string()
101    }
102}
103
104// ============================================================================
105// Experience Events
106// ============================================================================
107
108/// High-level event kinds for query filtering.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
110pub enum ExperienceEventKind {
111    ProposalCreated,
112    ProposalValidated,
113    FactPromoted,
114    RecallExecuted,
115    ReplayTraceRecorded,
116    ReplayabilityDowngraded,
117    ArtifactStateTransitioned,
118    ArtifactRollbackRecorded,
119    BackendInvoked,
120    OutcomeRecorded,
121    BudgetExceeded,
122    PolicySnapshotCaptured,
123}
124
125/// Append-only experience event payloads.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127#[serde(tag = "type", content = "data")]
128pub enum ExperienceEvent {
129    /// Kernel proposal was created.
130    ProposalCreated {
131        proposal: KernelProposal,
132        chain_id: String,
133        step: DecisionStep,
134        policy_snapshot_hash: Option<String>,
135    },
136    /// Proposal was validated (contracts/truths evaluated).
137    ProposalValidated {
138        proposal_id: String,
139        chain_id: String,
140        step: DecisionStep,
141        contract_results: Vec<ContractResultSnapshot>,
142        all_passed: bool,
143        validator: String,
144    },
145    /// Proposal was promoted into a fact.
146    FactPromoted {
147        proposal_id: String,
148        fact_id: String,
149        promoted_by: String,
150        reason: String,
151        requires_human: bool,
152    },
153    /// Recall operation executed with full provenance.
154    RecallExecuted {
155        query: RecallQuery,
156        provenance: RecallProvenanceEnvelope,
157        trace_link_id: Option<String>,
158    },
159    /// Trace link recorded as a first-class object.
160    ReplayTraceRecorded {
161        trace_link_id: String,
162        trace_link: ReplayTrace,
163    },
164    /// Replayability downgraded for a trace.
165    ReplayabilityDowngraded {
166        trace_link_id: String,
167        from: Replayability,
168        to: Replayability,
169        reason: ReplayabilityDowngradeReason,
170    },
171    /// Governed artifact state transition recorded.
172    ArtifactStateTransitioned {
173        artifact_id: String,
174        artifact_kind: ArtifactKind,
175        event: LifecycleEvent,
176    },
177    /// Governed artifact rollback recorded.
178    ArtifactRollbackRecorded { rollback: RollbackRecord },
179    /// Backend invocation occurred (useful for audit/latency analysis).
180    BackendInvoked {
181        backend_name: String,
182        adapter_id: Option<String>,
183        trace_link_id: String,
184        step: DecisionStep,
185        policy_snapshot_hash: Option<String>,
186    },
187    /// Outcome recorded for a chain step.
188    OutcomeRecorded {
189        chain_id: String,
190        step: DecisionStep,
191        passed: bool,
192        stop_reason: Option<String>,
193        latency_ms: Option<u64>,
194        tokens: Option<u64>,
195        cost_microdollars: Option<u64>,
196        backend: Option<String>,
197    },
198    /// Budget exceeded event for a chain/run.
199    BudgetExceeded {
200        chain_id: String,
201        resource: String,
202        limit: String,
203        observed: Option<String>,
204    },
205    /// Policy snapshot captured for provenance.
206    PolicySnapshotCaptured {
207        policy_id: String,
208        policy: PolicySnapshot,
209        snapshot_hash: String,
210        captured_by: String,
211    },
212}
213
214impl ExperienceEvent {
215    /// Get the event kind for filtering.
216    #[must_use]
217    pub fn kind(&self) -> ExperienceEventKind {
218        match self {
219            Self::ProposalCreated { .. } => ExperienceEventKind::ProposalCreated,
220            Self::ProposalValidated { .. } => ExperienceEventKind::ProposalValidated,
221            Self::FactPromoted { .. } => ExperienceEventKind::FactPromoted,
222            Self::RecallExecuted { .. } => ExperienceEventKind::RecallExecuted,
223            Self::ReplayTraceRecorded { .. } => ExperienceEventKind::ReplayTraceRecorded,
224            Self::ReplayabilityDowngraded { .. } => ExperienceEventKind::ReplayabilityDowngraded,
225            Self::ArtifactStateTransitioned { .. } => {
226                ExperienceEventKind::ArtifactStateTransitioned
227            }
228            Self::ArtifactRollbackRecorded { .. } => ExperienceEventKind::ArtifactRollbackRecorded,
229            Self::BackendInvoked { .. } => ExperienceEventKind::BackendInvoked,
230            Self::OutcomeRecorded { .. } => ExperienceEventKind::OutcomeRecorded,
231            Self::BudgetExceeded { .. } => ExperienceEventKind::BudgetExceeded,
232            Self::PolicySnapshotCaptured { .. } => ExperienceEventKind::PolicySnapshotCaptured,
233        }
234    }
235}
236
237// ============================================================================
238// Supporting Types
239// ============================================================================
240
241/// Snapshot of a contract result for validation events.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct ContractResultSnapshot {
244    pub name: String,
245    pub passed: bool,
246    pub failure_reason: Option<String>,
247}
248
249impl From<crate::kernel_boundary::ContractResult> for ContractResultSnapshot {
250    fn from(result: crate::kernel_boundary::ContractResult) -> Self {
251        Self {
252            name: result.name,
253            passed: result.passed,
254            failure_reason: result.failure_reason,
255        }
256    }
257}
258
259/// Kind of governed artifact.
260#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
261pub enum ArtifactKind {
262    Adapter,
263    Pack,
264    Policy,
265    TruthFile,
266    EvalSuite,
267    Other(String),
268}
269
270/// Policy snapshot payload.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(tag = "type", content = "policy")]
273pub enum PolicySnapshot {
274    Kernel(KernelPolicy),
275    Routing(RoutingPolicy),
276    Recall(RecallPolicy),
277}
278
279/// Query for experience events.
280#[derive(Debug, Clone, Serialize, Deserialize, Default)]
281pub struct EventQuery {
282    pub tenant_id: Option<String>,
283    pub time_range: Option<TimeRange>,
284    pub kinds: Vec<ExperienceEventKind>,
285    pub correlation_id: Option<String>,
286    pub chain_id: Option<String>,
287    pub limit: Option<usize>,
288}
289
290/// Query for governed artifacts.
291#[derive(Debug, Clone, Serialize, Deserialize, Default)]
292pub struct ArtifactQuery {
293    pub tenant_id: Option<String>,
294    pub artifact_id: Option<String>,
295    pub kind: Option<ArtifactKind>,
296    pub state: Option<GovernedArtifactState>,
297    pub limit: Option<usize>,
298}
299
300/// Inclusive time range filter (ISO 8601 strings).
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct TimeRange {
303    pub start: Option<String>,
304    pub end: Option<String>,
305}
306
307// ============================================================================
308// Experience Store Trait
309// ============================================================================
310
311/// Experience store trait (append-only ledger boundary).
312///
313/// This is the canonical audit trail interface. Implementations provide
314/// append-only event storage and query access for governance, debugging,
315/// and downstream analytics.
316///
317/// See [`converge_experience`] for concrete implementations:
318/// `InMemoryExperienceStore`, `SurrealDbExperienceStore`, `LanceDbExperienceStore`.
319pub trait ExperienceStore: Send + Sync {
320    /// Append a single event.
321    fn append_event(&self, event: ExperienceEventEnvelope) -> ExperienceStoreResult<()>;
322
323    /// Append multiple events (best-effort atomicity per implementation).
324    fn append_events(&self, events: &[ExperienceEventEnvelope]) -> ExperienceStoreResult<()> {
325        for event in events {
326            self.append_event(event.clone())?;
327        }
328        Ok(())
329    }
330
331    /// Query events by tenant/time/kind/etc.
332    fn query_events(
333        &self,
334        query: &EventQuery,
335    ) -> ExperienceStoreResult<Vec<ExperienceEventEnvelope>>;
336
337    /// Write an artifact lifecycle transition event.
338    fn write_artifact_state_transition(
339        &self,
340        artifact_id: &str,
341        artifact_kind: ArtifactKind,
342        event: LifecycleEvent,
343    ) -> ExperienceStoreResult<()>;
344
345    /// Fetch a trace link by id.
346    fn get_trace_link(&self, trace_link_id: &str) -> ExperienceStoreResult<Option<ReplayTrace>>;
347}
348
349/// Experience store error type.
350#[derive(Debug, Clone, PartialEq, Eq)]
351pub enum ExperienceStoreError {
352    /// Storage layer error with message
353    StorageError { message: String },
354    /// Query was invalid or unsupported
355    InvalidQuery { message: String },
356    /// Record not found
357    NotFound { message: String },
358}
359
360impl std::fmt::Display for ExperienceStoreError {
361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        match self {
363            Self::StorageError { message } => write!(f, "Storage error: {}", message),
364            Self::InvalidQuery { message } => write!(f, "Invalid query: {}", message),
365            Self::NotFound { message } => write!(f, "Not found: {}", message),
366        }
367    }
368}
369
370impl std::error::Error for ExperienceStoreError {}
371
372/// Result type for experience store operations.
373pub type ExperienceStoreResult<T> = Result<T, ExperienceStoreError>;
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn event_kind_mapping() {
381        let event = ExperienceEvent::BudgetExceeded {
382            chain_id: "chain-1".to_string(),
383            resource: "tokens".to_string(),
384            limit: "1024".to_string(),
385            observed: Some("2048".to_string()),
386        };
387        assert_eq!(event.kind(), ExperienceEventKind::BudgetExceeded);
388    }
389
390    #[test]
391    fn envelope_builder_sets_fields() {
392        let event = ExperienceEvent::OutcomeRecorded {
393            chain_id: "chain-1".to_string(),
394            step: DecisionStep::Planning,
395            passed: true,
396            stop_reason: None,
397            latency_ms: Some(12),
398            tokens: Some(42),
399            cost_microdollars: None,
400            backend: Some("local".to_string()),
401        };
402        let envelope = ExperienceEventEnvelope::new("evt-1", event)
403            .with_tenant("tenant-a")
404            .with_correlation("corr-1")
405            .with_timestamp("2026-01-21T12:00:00Z");
406
407        assert_eq!(envelope.event_id, "evt-1");
408        assert_eq!(envelope.tenant_id.as_deref(), Some("tenant-a"));
409        assert_eq!(envelope.correlation_id.as_deref(), Some("corr-1"));
410        assert_eq!(envelope.occurred_at, "2026-01-21T12:00:00Z");
411    }
412}