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::StopReason as EngineStopReason;
30use crate::governed_artifact::{GovernedArtifactState, LifecycleEvent, RollbackRecord};
31use crate::kernel_boundary::{
32    DecisionStep, KernelPolicy, KernelProposal, ReplayTrace, Replayability,
33    ReplayabilityDowngradeReason, RoutingPolicy,
34};
35use crate::recall::{RecallPolicy, RecallProvenanceEnvelope, RecallQuery};
36use crate::types::{
37    ActorId, ArtifactId, BackendId, ChainId, ContentHash, CorrelationId, DomainId, EventId, FactId,
38    PolicyId, ProposalId, TenantId, TensionId, Timestamp, TraceLinkId,
39};
40
41// ============================================================================
42// Event Envelope
43// ============================================================================
44
45/// Append-only event envelope.
46///
47/// The envelope carries stable metadata (ids, timestamps, correlation) and a
48/// typed event payload. Implementations store and index envelopes, not raw
49/// payloads, to keep provenance queryable without decoding payload JSON.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ExperienceEventEnvelope {
52    /// Unique event identifier (ULID/UUID)
53    pub event_id: EventId,
54    /// ISO 8601 timestamp of occurrence
55    pub occurred_at: Timestamp,
56    /// Optional tenant scope
57    pub tenant_id: Option<TenantId>,
58    /// Correlation ID for chain/run grouping
59    pub correlation_id: Option<CorrelationId>,
60    /// Typed event payload
61    pub event: ExperienceEvent,
62}
63
64impl ExperienceEventEnvelope {
65    /// Create a new envelope with a placeholder timestamp.
66    ///
67    /// Production systems should call `with_timestamp()` to set a trusted time.
68    #[must_use]
69    pub fn new(event_id: impl Into<EventId>, event: ExperienceEvent) -> Self {
70        Self {
71            event_id: event_id.into(),
72            occurred_at: Self::now_iso8601(),
73            tenant_id: None,
74            correlation_id: None,
75            event,
76        }
77    }
78
79    /// Add a tenant scope.
80    #[must_use]
81    pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
82        self.tenant_id = Some(tenant_id.into());
83        self
84    }
85
86    /// Add a correlation ID.
87    #[must_use]
88    pub fn with_correlation(mut self, correlation_id: impl Into<CorrelationId>) -> Self {
89        self.correlation_id = Some(correlation_id.into());
90        self
91    }
92
93    /// Set explicit timestamp (for replay/testing).
94    #[must_use]
95    pub fn with_timestamp(mut self, occurred_at: impl Into<Timestamp>) -> Self {
96        self.occurred_at = occurred_at.into();
97        self
98    }
99
100    /// Generate ISO 8601 timestamp.
101    ///
102    /// Note: This returns a placeholder. Production systems should use
103    /// `with_timestamp()` to inject a timestamp from a trusted source.
104    fn now_iso8601() -> Timestamp {
105        Timestamp::epoch()
106    }
107}
108
109// ============================================================================
110// Experience Events
111// ============================================================================
112
113/// High-level event kinds for query filtering.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
115pub enum ExperienceEventKind {
116    ProposalCreated,
117    ProposalValidated,
118    FactPromoted,
119    RecallExecuted,
120    ReplayTraceRecorded,
121    ReplayabilityDowngraded,
122    ArtifactStateTransitioned,
123    ArtifactRollbackRecorded,
124    BackendInvoked,
125    OutcomeRecorded,
126    BudgetExceeded,
127    PolicySnapshotCaptured,
128    HypothesisResolved,
129}
130
131/// Append-only experience event payloads.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(tag = "type", content = "data")]
134pub enum ExperienceEvent {
135    /// Kernel proposal was created.
136    ProposalCreated {
137        proposal: KernelProposal,
138        chain_id: ChainId,
139        step: DecisionStep,
140        policy_snapshot_hash: Option<ContentHash>,
141    },
142    /// Proposal was validated (contracts/truths evaluated).
143    ProposalValidated {
144        proposal_id: ProposalId,
145        chain_id: ChainId,
146        step: DecisionStep,
147        contract_results: Vec<ContractResultSnapshot>,
148        all_passed: bool,
149        validator: ActorId,
150    },
151    /// Proposal was promoted into a fact.
152    FactPromoted {
153        proposal_id: ProposalId,
154        fact_id: FactId,
155        promoted_by: ActorId,
156        reason: String,
157        requires_human: bool,
158    },
159    /// Recall operation executed with full provenance.
160    RecallExecuted {
161        query: RecallQuery,
162        provenance: RecallProvenanceEnvelope,
163        trace_link_id: Option<TraceLinkId>,
164    },
165    /// Trace link recorded as a first-class object.
166    ReplayTraceRecorded {
167        trace_link_id: TraceLinkId,
168        trace_link: ReplayTrace,
169    },
170    /// Replayability downgraded for a trace.
171    ReplayabilityDowngraded {
172        trace_link_id: TraceLinkId,
173        from: Replayability,
174        to: Replayability,
175        reason: ReplayabilityDowngradeReason,
176    },
177    /// Governed artifact state transition recorded.
178    ArtifactStateTransitioned {
179        artifact_id: ArtifactId,
180        artifact_kind: ArtifactKind,
181        event: LifecycleEvent,
182    },
183    /// Governed artifact rollback recorded.
184    ArtifactRollbackRecorded { rollback: RollbackRecord },
185    /// Backend invocation occurred (useful for audit/latency analysis).
186    BackendInvoked {
187        backend_name: BackendId,
188        adapter_id: Option<BackendId>,
189        trace_link_id: TraceLinkId,
190        step: DecisionStep,
191        policy_snapshot_hash: Option<ContentHash>,
192    },
193    /// Outcome recorded for a chain step.
194    OutcomeRecorded {
195        chain_id: ChainId,
196        step: DecisionStep,
197        passed: bool,
198        stop_reason: Option<EngineStopReason>,
199        latency_ms: Option<u64>,
200        tokens: Option<u64>,
201        cost_microdollars: Option<u64>,
202        backend: Option<BackendId>,
203        /// Provider/gateway metadata (Kong headers, OpenRouter cost, etc.).
204        #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
205        metadata: std::collections::HashMap<String, String>,
206    },
207    /// Budget exceeded event for a chain/run.
208    BudgetExceeded {
209        chain_id: ChainId,
210        resource: BudgetResource,
211        limit: String,
212        observed: Option<String>,
213    },
214    /// Policy snapshot captured for provenance.
215    PolicySnapshotCaptured {
216        policy_id: PolicyId,
217        policy: PolicySnapshot,
218        snapshot_hash: ContentHash,
219        captured_by: ActorId,
220    },
221    /// A tracked hypothesis reached a terminal outcome.
222    HypothesisResolved {
223        chain_id: ChainId,
224        fact_id: FactId,
225        domain: DomainId,
226        claim: String,
227        confidence: f64,
228        outcome: HypothesisOutcome,
229        #[serde(default, skip_serializing_if = "Option::is_none")]
230        contradiction_id: Option<TensionId>,
231        formed_cycle: u32,
232        resolved_cycle: u32,
233    },
234}
235
236impl ExperienceEvent {
237    /// Get the event kind for filtering.
238    #[must_use]
239    pub fn kind(&self) -> ExperienceEventKind {
240        match self {
241            Self::ProposalCreated { .. } => ExperienceEventKind::ProposalCreated,
242            Self::ProposalValidated { .. } => ExperienceEventKind::ProposalValidated,
243            Self::FactPromoted { .. } => ExperienceEventKind::FactPromoted,
244            Self::RecallExecuted { .. } => ExperienceEventKind::RecallExecuted,
245            Self::ReplayTraceRecorded { .. } => ExperienceEventKind::ReplayTraceRecorded,
246            Self::ReplayabilityDowngraded { .. } => ExperienceEventKind::ReplayabilityDowngraded,
247            Self::ArtifactStateTransitioned { .. } => {
248                ExperienceEventKind::ArtifactStateTransitioned
249            }
250            Self::ArtifactRollbackRecorded { .. } => ExperienceEventKind::ArtifactRollbackRecorded,
251            Self::BackendInvoked { .. } => ExperienceEventKind::BackendInvoked,
252            Self::OutcomeRecorded { .. } => ExperienceEventKind::OutcomeRecorded,
253            Self::BudgetExceeded { .. } => ExperienceEventKind::BudgetExceeded,
254            Self::PolicySnapshotCaptured { .. } => ExperienceEventKind::PolicySnapshotCaptured,
255            Self::HypothesisResolved { .. } => ExperienceEventKind::HypothesisResolved,
256        }
257    }
258}
259
260// ============================================================================
261// Supporting Types
262// ============================================================================
263
264/// Snapshot of a contract result for validation events.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct ContractResultSnapshot {
267    pub name: String,
268    pub passed: bool,
269    pub failure_reason: Option<String>,
270}
271
272/// Budget dimension that was exhausted.
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274pub enum BudgetResource {
275    EngineBudget,
276    Tokens,
277    Facts,
278    Cycles,
279    Time,
280    Cost,
281    Other(String),
282}
283
284/// Terminal outcome for a tracked hypothesis.
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
286pub enum HypothesisOutcome {
287    Confirmed,
288    Falsified,
289    Superseded,
290    Unresolved,
291}
292
293impl From<crate::kernel_boundary::ContractResult> for ContractResultSnapshot {
294    fn from(result: crate::kernel_boundary::ContractResult) -> Self {
295        Self {
296            name: result.name,
297            passed: result.passed,
298            failure_reason: result.failure_reason,
299        }
300    }
301}
302
303/// Kind of governed artifact.
304#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
305pub enum ArtifactKind {
306    Adapter,
307    Pack,
308    Policy,
309    TruthFile,
310    EvalSuite,
311    Other(String),
312}
313
314/// Policy snapshot payload.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316#[serde(tag = "type", content = "policy")]
317pub enum PolicySnapshot {
318    Kernel(KernelPolicy),
319    Routing(RoutingPolicy),
320    Recall(RecallPolicy),
321}
322
323/// Query for experience events.
324#[derive(Debug, Clone, Serialize, Deserialize, Default)]
325pub struct EventQuery {
326    pub tenant_id: Option<TenantId>,
327    pub time_range: Option<TimeRange>,
328    pub kinds: Vec<ExperienceEventKind>,
329    pub correlation_id: Option<CorrelationId>,
330    pub chain_id: Option<ChainId>,
331    pub limit: Option<usize>,
332}
333
334/// Query for governed artifacts.
335#[derive(Debug, Clone, Serialize, Deserialize, Default)]
336pub struct ArtifactQuery {
337    pub tenant_id: Option<TenantId>,
338    pub artifact_id: Option<ArtifactId>,
339    pub kind: Option<ArtifactKind>,
340    pub state: Option<GovernedArtifactState>,
341    pub limit: Option<usize>,
342}
343
344/// Inclusive time range filter (ISO 8601 strings).
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct TimeRange {
347    pub start: Option<Timestamp>,
348    pub end: Option<Timestamp>,
349}
350
351// ============================================================================
352// Experience Store Trait
353// ============================================================================
354
355/// Experience store trait (append-only ledger boundary).
356///
357/// This is the canonical audit trail interface. Implementations provide
358/// append-only event storage and query access for governance, debugging,
359/// and downstream analytics.
360///
361/// See [`converge_experience`] for concrete implementations:
362/// `InMemoryExperienceStore`, `SurrealDbExperienceStore`, `LanceDbExperienceStore`.
363pub trait ExperienceStore: Send + Sync {
364    /// Append a single event.
365    fn append_event(&self, event: ExperienceEventEnvelope) -> ExperienceStoreResult<()>;
366
367    /// Append multiple events (best-effort atomicity per implementation).
368    fn append_events(&self, events: &[ExperienceEventEnvelope]) -> ExperienceStoreResult<()> {
369        for event in events {
370            self.append_event(event.clone())?;
371        }
372        Ok(())
373    }
374
375    /// Query events by tenant/time/kind/etc.
376    fn query_events(
377        &self,
378        query: &EventQuery,
379    ) -> ExperienceStoreResult<Vec<ExperienceEventEnvelope>>;
380
381    /// Write an artifact lifecycle transition event.
382    fn write_artifact_state_transition(
383        &self,
384        artifact_id: &ArtifactId,
385        artifact_kind: ArtifactKind,
386        event: LifecycleEvent,
387    ) -> ExperienceStoreResult<()>;
388
389    /// Fetch a trace link by id.
390    fn get_trace_link(
391        &self,
392        trace_link_id: &TraceLinkId,
393    ) -> ExperienceStoreResult<Option<ReplayTrace>>;
394}
395
396/// Experience store error type.
397#[derive(Debug, Clone, PartialEq, Eq)]
398pub enum ExperienceStoreError {
399    /// Storage layer error with message
400    StorageError { message: String },
401    /// Query was invalid or unsupported
402    InvalidQuery { message: String },
403    /// Record not found
404    NotFound { message: String },
405}
406
407impl std::fmt::Display for ExperienceStoreError {
408    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409        match self {
410            Self::StorageError { message } => write!(f, "Storage error: {}", message),
411            Self::InvalidQuery { message } => write!(f, "Invalid query: {}", message),
412            Self::NotFound { message } => write!(f, "Not found: {}", message),
413        }
414    }
415}
416
417impl std::error::Error for ExperienceStoreError {}
418
419/// Result type for experience store operations.
420pub type ExperienceStoreResult<T> = Result<T, ExperienceStoreError>;
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn event_kind_mapping() {
428        let event = ExperienceEvent::BudgetExceeded {
429            chain_id: "chain-1".into(),
430            resource: BudgetResource::Tokens,
431            limit: "1024".to_string(),
432            observed: Some("2048".to_string()),
433        };
434        assert_eq!(event.kind(), ExperienceEventKind::BudgetExceeded);
435    }
436
437    #[test]
438    fn envelope_builder_sets_fields() {
439        let event = ExperienceEvent::OutcomeRecorded {
440            chain_id: "chain-1".into(),
441            step: DecisionStep::Planning,
442            passed: true,
443            stop_reason: None,
444            latency_ms: Some(12),
445            tokens: Some(42),
446            cost_microdollars: None,
447            backend: Some("local".into()),
448            metadata: Default::default(),
449        };
450        let envelope = ExperienceEventEnvelope::new("evt-1", event)
451            .with_tenant("tenant-a")
452            .with_correlation("corr-1")
453            .with_timestamp("2026-01-21T12:00:00Z");
454
455        assert_eq!(envelope.event_id, "evt-1");
456        assert_eq!(envelope.tenant_id.as_deref(), Some("tenant-a"));
457        assert_eq!(envelope.correlation_id.as_deref(), Some("corr-1"));
458        assert_eq!(envelope.occurred_at, "2026-01-21T12:00:00Z");
459    }
460
461    // ── ExperienceEvent::kind() exhaustive coverage ──────────────────────────
462
463    #[test]
464    fn event_kind_proposal_created() {
465        let event = ExperienceEvent::ProposalCreated {
466            proposal: crate::kernel_boundary::KernelProposal {
467                id: "p-1".into(),
468                kind: crate::kernel_boundary::ProposalKind::Claims,
469                payload: "test".into(),
470                structured_payload: None,
471                trace_link: crate::kernel_boundary::ReplayTrace::Local(
472                    crate::kernel_boundary::LocalReplayTrace {
473                        base_model_hash: "abc".into(),
474                        adapter: None,
475                        tokenizer_hash: "tok".into(),
476                        seed: 42,
477                        sampler: crate::kernel_boundary::SamplerParams::default(),
478                        prompt_version: "v1".into(),
479                        recall: None,
480                        weights_mutated: false,
481                        execution_env: crate::kernel_boundary::ExecutionEnv::default(),
482                    },
483                ),
484                contract_results: vec![crate::kernel_boundary::ContractResult::passed(
485                    "grounded-answering",
486                )],
487                requires_human: false,
488                confidence: Some(0.9),
489            },
490            chain_id: "c-1".into(),
491            step: DecisionStep::Planning,
492            policy_snapshot_hash: None,
493        };
494        assert_eq!(event.kind(), ExperienceEventKind::ProposalCreated);
495    }
496
497    #[test]
498    fn event_kind_fact_promoted() {
499        let event = ExperienceEvent::FactPromoted {
500            proposal_id: "p-1".into(),
501            fact_id: "f-1".into(),
502            promoted_by: "engine".into(),
503            reason: "validated".into(),
504            requires_human: false,
505        };
506        assert_eq!(event.kind(), ExperienceEventKind::FactPromoted);
507    }
508
509    #[test]
510    fn event_kind_hypothesis_resolved() {
511        let event = ExperienceEvent::HypothesisResolved {
512            chain_id: "c-1".into(),
513            fact_id: "f-1".into(),
514            domain: "market".into(),
515            claim: "price will increase".into(),
516            confidence: 0.85,
517            outcome: HypothesisOutcome::Confirmed,
518            contradiction_id: None,
519            formed_cycle: 1,
520            resolved_cycle: 3,
521        };
522        assert_eq!(event.kind(), ExperienceEventKind::HypothesisResolved);
523    }
524
525    #[test]
526    fn event_kind_policy_snapshot_captured() {
527        let event = ExperienceEvent::PolicySnapshotCaptured {
528            policy_id: "pol-1".into(),
529            policy: PolicySnapshot::Routing(crate::kernel_boundary::RoutingPolicy::default()),
530            snapshot_hash: ContentHash::zero(),
531            captured_by: "engine".into(),
532        };
533        assert_eq!(event.kind(), ExperienceEventKind::PolicySnapshotCaptured);
534    }
535
536    // ── ExperienceStoreError ─────────────────────────────────────────────────
537
538    #[test]
539    fn store_error_display_storage() {
540        let e = ExperienceStoreError::StorageError {
541            message: "disk full".into(),
542        };
543        assert!(e.to_string().contains("disk full"));
544    }
545
546    #[test]
547    fn store_error_display_invalid_query() {
548        let e = ExperienceStoreError::InvalidQuery {
549            message: "bad filter".into(),
550        };
551        assert!(e.to_string().contains("bad filter"));
552    }
553
554    #[test]
555    fn store_error_display_not_found() {
556        let e = ExperienceStoreError::NotFound {
557            message: "trace-99".into(),
558        };
559        assert!(e.to_string().contains("trace-99"));
560    }
561
562    #[test]
563    fn store_error_is_std_error() {
564        let e: Box<dyn std::error::Error> = Box::new(ExperienceStoreError::StorageError {
565            message: "test".into(),
566        });
567        assert!(!e.to_string().is_empty());
568    }
569
570    // ── ArtifactKind equality ────────────────────────────────────────────────
571
572    #[test]
573    fn artifact_kind_equality_named() {
574        assert_eq!(ArtifactKind::Adapter, ArtifactKind::Adapter);
575        assert_ne!(ArtifactKind::Pack, ArtifactKind::Policy);
576    }
577
578    #[test]
579    fn artifact_kind_other_variant() {
580        let a = ArtifactKind::Other("custom".into());
581        let b = ArtifactKind::Other("custom".into());
582        assert_eq!(a, b);
583        assert_ne!(
584            ArtifactKind::Other("x".into()),
585            ArtifactKind::Other("y".into())
586        );
587    }
588
589    // ── ContractResultSnapshot From conversion ───────────────────────────────
590
591    #[test]
592    fn contract_result_snapshot_from_contract_result() {
593        let cr = crate::kernel_boundary::ContractResult {
594            name: "schema-check".into(),
595            passed: false,
596            failure_reason: Some("missing field".into()),
597        };
598        let snap: ContractResultSnapshot = cr.into();
599        assert_eq!(snap.name, "schema-check");
600        assert!(!snap.passed);
601        assert_eq!(snap.failure_reason.as_deref(), Some("missing field"));
602    }
603
604    // ── EventQuery defaults ──────────────────────────────────────────────────
605
606    #[test]
607    fn event_query_default_is_empty() {
608        let q = EventQuery::default();
609        assert!(q.tenant_id.is_none());
610        assert!(q.kinds.is_empty());
611        assert!(q.correlation_id.is_none());
612        assert!(q.chain_id.is_none());
613        assert!(q.limit.is_none());
614    }
615
616    // ── Envelope without optional fields ─────────────────────────────────────
617
618    #[test]
619    fn envelope_minimal_no_optional_fields() {
620        let event = ExperienceEvent::BudgetExceeded {
621            chain_id: "c".into(),
622            resource: BudgetResource::Cycles,
623            limit: "10".into(),
624            observed: None,
625        };
626        let env = ExperienceEventEnvelope::new("e-1", event);
627        assert!(env.tenant_id.is_none());
628        assert!(env.correlation_id.is_none());
629        assert_eq!(env.occurred_at, "1970-01-01T00:00:00Z");
630    }
631
632    // ── Serde roundtrips ─────────────────────────────────────────────────────
633
634    #[test]
635    fn experience_event_kind_serde_roundtrip() {
636        let kinds = [
637            ExperienceEventKind::ProposalCreated,
638            ExperienceEventKind::ProposalValidated,
639            ExperienceEventKind::FactPromoted,
640            ExperienceEventKind::RecallExecuted,
641            ExperienceEventKind::ReplayTraceRecorded,
642            ExperienceEventKind::ReplayabilityDowngraded,
643            ExperienceEventKind::ArtifactStateTransitioned,
644            ExperienceEventKind::ArtifactRollbackRecorded,
645            ExperienceEventKind::BackendInvoked,
646            ExperienceEventKind::OutcomeRecorded,
647            ExperienceEventKind::BudgetExceeded,
648            ExperienceEventKind::PolicySnapshotCaptured,
649            ExperienceEventKind::HypothesisResolved,
650        ];
651        for kind in kinds {
652            let json = serde_json::to_string(&kind).unwrap();
653            let back: ExperienceEventKind = serde_json::from_str(&json).unwrap();
654            assert_eq!(back, kind);
655        }
656    }
657
658    #[test]
659    fn artifact_kind_serde_roundtrip() {
660        let kinds = [
661            ArtifactKind::Adapter,
662            ArtifactKind::Pack,
663            ArtifactKind::Policy,
664            ArtifactKind::TruthFile,
665            ArtifactKind::EvalSuite,
666            ArtifactKind::Other("custom".into()),
667        ];
668        for kind in kinds {
669            let json = serde_json::to_string(&kind).unwrap();
670            let back: ArtifactKind = serde_json::from_str(&json).unwrap();
671            assert_eq!(back, kind);
672        }
673    }
674}