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