converge_core/
experience_store.rs

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