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    GateId, 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    /// Append a single user-side experience event.
404    ///
405    /// Default implementation returns `Unsupported`. In-process backends
406    /// override this to record the event in the same ledger as engine events.
407    fn append_user_event(&self, _event: UserExperienceEventEnvelope) -> ExperienceStoreResult<()> {
408        Err(ExperienceStoreError::StorageError {
409            message: "user-side events are not supported by this backend".to_string(),
410        })
411    }
412
413    /// Query both engine-side and user-side records, ordered by occurrence.
414    ///
415    /// Default implementation lifts engine events through the
416    /// [`ExperienceRecord::Engine`] variant. Backends that store user events
417    /// override this to interleave both record kinds.
418    fn query_records(&self, query: &EventQuery) -> ExperienceStoreResult<Vec<ExperienceRecord>> {
419        Ok(self
420            .query_events(query)?
421            .into_iter()
422            .map(ExperienceRecord::Engine)
423            .collect())
424    }
425}
426
427/// Experience store error type.
428#[derive(Debug, Clone, PartialEq, Eq)]
429pub enum ExperienceStoreError {
430    /// Storage layer error with message
431    StorageError { message: String },
432    /// Query was invalid or unsupported
433    InvalidQuery { message: String },
434    /// Record not found
435    NotFound { message: String },
436}
437
438impl std::fmt::Display for ExperienceStoreError {
439    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
440        match self {
441            Self::StorageError { message } => write!(f, "Storage error: {}", message),
442            Self::InvalidQuery { message } => write!(f, "Invalid query: {}", message),
443            Self::NotFound { message } => write!(f, "Not found: {}", message),
444        }
445    }
446}
447
448impl std::error::Error for ExperienceStoreError {}
449
450/// Result type for experience store operations.
451pub type ExperienceStoreResult<T> = Result<T, ExperienceStoreError>;
452
453// ============================================================================
454// User-side Experience Events (sibling type to ExperienceEvent)
455// ============================================================================
456
457/// What a user override applies to.
458#[derive(Debug, Clone, Serialize, Deserialize)]
459#[serde(tag = "kind", content = "id")]
460pub enum OverrideTarget {
461    Fact(FactId),
462    Proposal(ProposalId),
463    Constraint(String),
464}
465
466/// User-side experience event.
467///
468/// This is the trust-transfer counterpart to [`ExperienceEvent`]: every variant
469/// records a deliberate human act that adjusts engine state — approval,
470/// rejection, override, correction, or boundary change. Operator surfaces such
471/// as Helms emit these events; planning consumes them through recall to weight
472/// future priors.
473///
474/// Kept as a sibling enum (not a variant of `ExperienceEvent`) so additions on
475/// either side stay non-breaking for downstream crates.io consumers.
476#[derive(Debug, Clone, Serialize, Deserialize)]
477#[serde(tag = "type", content = "data")]
478pub enum UserExperienceEvent {
479    /// A human approved a paused gate request.
480    UserApprovalGranted {
481        gate_request_id: GateId,
482        actor: ActorId,
483        policy_snapshot_hash: Option<ContentHash>,
484        reason: Option<String>,
485    },
486    /// A human issued an override against a fact, proposal, or constraint.
487    UserOverrideIssued {
488        target: OverrideTarget,
489        actor: ActorId,
490        policy_snapshot_hash: Option<ContentHash>,
491        reason: String,
492    },
493}
494
495/// Envelope for a [`UserExperienceEvent`] — mirrors [`ExperienceEventEnvelope`]
496/// for the user-side ledger.
497#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct UserExperienceEventEnvelope {
499    pub event_id: EventId,
500    pub occurred_at: Timestamp,
501    pub tenant_id: Option<TenantId>,
502    pub correlation_id: Option<CorrelationId>,
503    pub event: UserExperienceEvent,
504}
505
506impl UserExperienceEventEnvelope {
507    #[must_use]
508    pub fn new(event_id: impl Into<EventId>, event: UserExperienceEvent) -> Self {
509        Self {
510            event_id: event_id.into(),
511            occurred_at: Timestamp::epoch(),
512            tenant_id: None,
513            correlation_id: None,
514            event,
515        }
516    }
517
518    #[must_use]
519    pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
520        self.tenant_id = Some(tenant_id.into());
521        self
522    }
523
524    #[must_use]
525    pub fn with_correlation(mut self, correlation_id: impl Into<CorrelationId>) -> Self {
526        self.correlation_id = Some(correlation_id.into());
527        self
528    }
529
530    #[must_use]
531    pub fn with_timestamp(mut self, occurred_at: impl Into<Timestamp>) -> Self {
532        self.occurred_at = occurred_at.into();
533        self
534    }
535}
536
537/// Unified query result spanning both ledger sides.
538///
539/// Recall and audit consumers iterate `ExperienceRecord` rather than the two
540/// envelope types directly, so a UserOverrideIssued and an OutcomeRecorded can
541/// both feed the same prior calibration without the consumer needing to call
542/// two stores.
543#[derive(Debug, Clone, Serialize, Deserialize)]
544#[serde(tag = "kind", content = "value")]
545pub enum ExperienceRecord {
546    Engine(ExperienceEventEnvelope),
547    User(UserExperienceEventEnvelope),
548}
549
550impl ExperienceRecord {
551    #[must_use]
552    pub fn correlation_id(&self) -> Option<&CorrelationId> {
553        match self {
554            Self::Engine(env) => env.correlation_id.as_ref(),
555            Self::User(env) => env.correlation_id.as_ref(),
556        }
557    }
558
559    #[must_use]
560    pub fn tenant_id(&self) -> Option<&TenantId> {
561        match self {
562            Self::Engine(env) => env.tenant_id.as_ref(),
563            Self::User(env) => env.tenant_id.as_ref(),
564        }
565    }
566
567    #[must_use]
568    pub fn occurred_at(&self) -> &Timestamp {
569        match self {
570            Self::Engine(env) => &env.occurred_at,
571            Self::User(env) => &env.occurred_at,
572        }
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn event_kind_mapping() {
582        let event = ExperienceEvent::BudgetExceeded {
583            chain_id: "chain-1".into(),
584            resource: BudgetResource::Tokens,
585            limit: "1024".to_string(),
586            observed: Some("2048".to_string()),
587        };
588        assert_eq!(event.kind(), ExperienceEventKind::BudgetExceeded);
589    }
590
591    #[test]
592    fn envelope_builder_sets_fields() {
593        let event = ExperienceEvent::OutcomeRecorded {
594            chain_id: "chain-1".into(),
595            step: DecisionStep::Planning,
596            passed: true,
597            stop_reason: None,
598            latency_ms: Some(12),
599            tokens: Some(42),
600            cost_microdollars: None,
601            backend: Some("local".into()),
602            metadata: Default::default(),
603        };
604        let envelope = ExperienceEventEnvelope::new("evt-1", event)
605            .with_tenant("tenant-a")
606            .with_correlation("corr-1")
607            .with_timestamp("2026-01-21T12:00:00Z");
608
609        assert_eq!(envelope.event_id, "evt-1");
610        assert_eq!(envelope.tenant_id.as_deref(), Some("tenant-a"));
611        assert_eq!(envelope.correlation_id.as_deref(), Some("corr-1"));
612        assert_eq!(envelope.occurred_at, "2026-01-21T12:00:00Z");
613    }
614
615    // ── ExperienceEvent::kind() exhaustive coverage ──────────────────────────
616
617    #[test]
618    fn event_kind_proposal_created() {
619        let event = ExperienceEvent::ProposalCreated {
620            proposal: crate::kernel_boundary::KernelProposal {
621                id: "p-1".into(),
622                kind: crate::kernel_boundary::ProposalKind::Claims,
623                payload: "test".into(),
624                structured_payload: None,
625                trace_link: crate::kernel_boundary::ReplayTrace::Local(
626                    crate::kernel_boundary::LocalReplayTrace {
627                        base_model_hash: "abc".into(),
628                        adapter: None,
629                        tokenizer_hash: "tok".into(),
630                        seed: 42,
631                        sampler: crate::kernel_boundary::SamplerParams::default(),
632                        prompt_version: "v1".into(),
633                        recall: None,
634                        weights_mutated: false,
635                        execution_env: crate::kernel_boundary::ExecutionEnv::default(),
636                    },
637                ),
638                contract_results: vec![crate::kernel_boundary::ContractResult::passed(
639                    "grounded-answering",
640                )],
641                requires_human: false,
642                confidence: Some(0.9),
643            },
644            chain_id: "c-1".into(),
645            step: DecisionStep::Planning,
646            policy_snapshot_hash: None,
647        };
648        assert_eq!(event.kind(), ExperienceEventKind::ProposalCreated);
649    }
650
651    #[test]
652    fn event_kind_fact_promoted() {
653        let event = ExperienceEvent::FactPromoted {
654            proposal_id: "p-1".into(),
655            fact_id: "f-1".into(),
656            promoted_by: "engine".into(),
657            reason: "validated".into(),
658            requires_human: false,
659        };
660        assert_eq!(event.kind(), ExperienceEventKind::FactPromoted);
661    }
662
663    #[test]
664    fn event_kind_hypothesis_resolved() {
665        let event = ExperienceEvent::HypothesisResolved {
666            chain_id: "c-1".into(),
667            fact_id: "f-1".into(),
668            domain: "market".into(),
669            claim: "price will increase".into(),
670            confidence: 0.85,
671            outcome: HypothesisOutcome::Confirmed,
672            contradiction_id: None,
673            formed_cycle: 1,
674            resolved_cycle: 3,
675        };
676        assert_eq!(event.kind(), ExperienceEventKind::HypothesisResolved);
677    }
678
679    #[test]
680    fn event_kind_policy_snapshot_captured() {
681        let event = ExperienceEvent::PolicySnapshotCaptured {
682            policy_id: "pol-1".into(),
683            policy: PolicySnapshot::Routing(crate::kernel_boundary::RoutingPolicy::default()),
684            snapshot_hash: ContentHash::zero(),
685            captured_by: "engine".into(),
686        };
687        assert_eq!(event.kind(), ExperienceEventKind::PolicySnapshotCaptured);
688    }
689
690    // ── ExperienceStoreError ─────────────────────────────────────────────────
691
692    #[test]
693    fn store_error_display_storage() {
694        let e = ExperienceStoreError::StorageError {
695            message: "disk full".into(),
696        };
697        assert!(e.to_string().contains("disk full"));
698    }
699
700    #[test]
701    fn store_error_display_invalid_query() {
702        let e = ExperienceStoreError::InvalidQuery {
703            message: "bad filter".into(),
704        };
705        assert!(e.to_string().contains("bad filter"));
706    }
707
708    #[test]
709    fn store_error_display_not_found() {
710        let e = ExperienceStoreError::NotFound {
711            message: "trace-99".into(),
712        };
713        assert!(e.to_string().contains("trace-99"));
714    }
715
716    #[test]
717    fn store_error_is_std_error() {
718        let e: Box<dyn std::error::Error> = Box::new(ExperienceStoreError::StorageError {
719            message: "test".into(),
720        });
721        assert!(!e.to_string().is_empty());
722    }
723
724    // ── ArtifactKind equality ────────────────────────────────────────────────
725
726    #[test]
727    fn artifact_kind_equality_named() {
728        assert_eq!(ArtifactKind::Adapter, ArtifactKind::Adapter);
729        assert_ne!(ArtifactKind::Pack, ArtifactKind::Policy);
730    }
731
732    #[test]
733    fn artifact_kind_other_variant() {
734        let a = ArtifactKind::Other("custom".into());
735        let b = ArtifactKind::Other("custom".into());
736        assert_eq!(a, b);
737        assert_ne!(
738            ArtifactKind::Other("x".into()),
739            ArtifactKind::Other("y".into())
740        );
741    }
742
743    // ── ContractResultSnapshot From conversion ───────────────────────────────
744
745    #[test]
746    fn contract_result_snapshot_from_contract_result() {
747        let cr = crate::kernel_boundary::ContractResult {
748            name: "schema-check".into(),
749            passed: false,
750            failure_reason: Some("missing field".into()),
751        };
752        let snap: ContractResultSnapshot = cr.into();
753        assert_eq!(snap.name, "schema-check");
754        assert!(!snap.passed);
755        assert_eq!(snap.failure_reason.as_deref(), Some("missing field"));
756    }
757
758    // ── EventQuery defaults ──────────────────────────────────────────────────
759
760    #[test]
761    fn event_query_default_is_empty() {
762        let q = EventQuery::default();
763        assert!(q.tenant_id.is_none());
764        assert!(q.kinds.is_empty());
765        assert!(q.correlation_id.is_none());
766        assert!(q.chain_id.is_none());
767        assert!(q.limit.is_none());
768    }
769
770    // ── Envelope without optional fields ─────────────────────────────────────
771
772    #[test]
773    fn envelope_minimal_no_optional_fields() {
774        let event = ExperienceEvent::BudgetExceeded {
775            chain_id: "c".into(),
776            resource: BudgetResource::Cycles,
777            limit: "10".into(),
778            observed: None,
779        };
780        let env = ExperienceEventEnvelope::new("e-1", event);
781        assert!(env.tenant_id.is_none());
782        assert!(env.correlation_id.is_none());
783        assert_eq!(env.occurred_at, "1970-01-01T00:00:00Z");
784    }
785
786    // ── Serde roundtrips ─────────────────────────────────────────────────────
787
788    #[test]
789    fn experience_event_kind_serde_roundtrip() {
790        let kinds = [
791            ExperienceEventKind::ProposalCreated,
792            ExperienceEventKind::ProposalValidated,
793            ExperienceEventKind::FactPromoted,
794            ExperienceEventKind::RecallExecuted,
795            ExperienceEventKind::ReplayTraceRecorded,
796            ExperienceEventKind::ReplayabilityDowngraded,
797            ExperienceEventKind::ArtifactStateTransitioned,
798            ExperienceEventKind::ArtifactRollbackRecorded,
799            ExperienceEventKind::BackendInvoked,
800            ExperienceEventKind::OutcomeRecorded,
801            ExperienceEventKind::BudgetExceeded,
802            ExperienceEventKind::PolicySnapshotCaptured,
803            ExperienceEventKind::HypothesisResolved,
804        ];
805        for kind in kinds {
806            let json = serde_json::to_string(&kind).unwrap();
807            let back: ExperienceEventKind = serde_json::from_str(&json).unwrap();
808            assert_eq!(back, kind);
809        }
810    }
811
812    #[test]
813    fn artifact_kind_serde_roundtrip() {
814        let kinds = [
815            ArtifactKind::Adapter,
816            ArtifactKind::Pack,
817            ArtifactKind::Policy,
818            ArtifactKind::TruthFile,
819            ArtifactKind::EvalSuite,
820            ArtifactKind::Other("custom".into()),
821        ];
822        for kind in kinds {
823            let json = serde_json::to_string(&kind).unwrap();
824            let back: ArtifactKind = serde_json::from_str(&json).unwrap();
825            assert_eq!(back, kind);
826        }
827    }
828
829    // ── Envelope serialization (pack) ────────────────────────────────────────
830
831    #[test]
832    fn envelope_pack_with_all_fields() {
833        let event = ExperienceEvent::BudgetExceeded {
834            chain_id: "c-1".into(),
835            resource: BudgetResource::Tokens,
836            limit: "1024".to_string(),
837            observed: Some("2048".to_string()),
838        };
839        let env = ExperienceEventEnvelope::new("evt-abc123", event)
840            .with_tenant("tenant-prod")
841            .with_correlation("corr-xyz789")
842            .with_timestamp("2026-04-28T15:30:45Z");
843
844        let json = serde_json::to_string(&env).unwrap();
845        assert!(json.contains("evt-abc123"));
846        assert!(json.contains("tenant-prod"));
847        assert!(json.contains("corr-xyz789"));
848        assert!(json.contains("2026-04-28T15:30:45Z"));
849    }
850
851    #[test]
852    fn envelope_pack_minimal() {
853        let event = ExperienceEvent::BudgetExceeded {
854            chain_id: "c".into(),
855            resource: BudgetResource::Cycles,
856            limit: "5".to_string(),
857            observed: None,
858        };
859        let env = ExperienceEventEnvelope::new("e1", event);
860
861        let json = serde_json::to_string(&env).unwrap();
862        assert!(json.contains("e1"));
863        assert!(json.contains("1970-01-01T00:00:00Z"));
864        // Optional fields should still serialize as null
865        assert!(json.contains("tenant_id"));
866        assert!(json.contains("correlation_id"));
867    }
868
869    // ── Envelope deserialization (unpack) ────────────────────────────────────
870
871    #[test]
872    fn envelope_unpack_with_all_fields() {
873        let json = r#"{
874            "event_id": "evt-1",
875            "occurred_at": "2026-04-28T12:00:00Z",
876            "tenant_id": "tenant-x",
877            "correlation_id": "corr-1",
878            "event": {
879                "type": "BudgetExceeded",
880                "data": {
881                    "chain_id": "c-1",
882                    "resource": "Tokens",
883                    "limit": "999",
884                    "observed": "500"
885                }
886            }
887        }"#;
888
889        let env: ExperienceEventEnvelope = serde_json::from_str(json).unwrap();
890        assert_eq!(env.event_id, "evt-1");
891        assert_eq!(env.occurred_at, "2026-04-28T12:00:00Z");
892        assert_eq!(env.tenant_id.as_deref(), Some("tenant-x"));
893        assert_eq!(env.correlation_id.as_deref(), Some("corr-1"));
894    }
895
896    #[test]
897    fn envelope_unpack_missing_optional_fields() {
898        let json = r#"{
899            "event_id": "evt-minimal",
900            "occurred_at": "2026-01-01T00:00:00Z",
901            "tenant_id": null,
902            "correlation_id": null,
903            "event": {
904                "type": "BudgetExceeded",
905                "data": {
906                    "chain_id": "c",
907                    "resource": "Cycles",
908                    "limit": "1",
909                    "observed": null
910                }
911            }
912        }"#;
913
914        let env: ExperienceEventEnvelope = serde_json::from_str(json).unwrap();
915        assert!(env.tenant_id.is_none());
916        assert!(env.correlation_id.is_none());
917    }
918
919    #[test]
920    fn envelope_unpack_missing_optional_keys_entirely() {
921        let json = r#"{
922            "event_id": "evt-sparse",
923            "occurred_at": "2026-01-01T00:00:00Z",
924            "event": {
925                "type": "BudgetExceeded",
926                "data": {
927                    "chain_id": "c",
928                    "resource": "Facts",
929                    "limit": "10",
930                    "observed": null
931                }
932            }
933        }"#;
934
935        let env: ExperienceEventEnvelope = serde_json::from_str(json).unwrap();
936        assert_eq!(env.event_id, "evt-sparse");
937        assert!(env.tenant_id.is_none());
938        assert!(env.correlation_id.is_none());
939    }
940
941    // ── Roundtrip: pack then unpack ──────────────────────────────────────────
942
943    #[test]
944    fn envelope_roundtrip_complete() {
945        let event = ExperienceEvent::BudgetExceeded {
946            chain_id: "chain-rt".into(),
947            resource: BudgetResource::Tokens,
948            limit: "777".to_string(),
949            observed: Some("333".to_string()),
950        };
951        let original = ExperienceEventEnvelope::new("evt-rt", event)
952            .with_tenant("tenant-rt")
953            .with_correlation("corr-rt")
954            .with_timestamp("2026-04-28T10:15:30Z");
955
956        let json = serde_json::to_string(&original).unwrap();
957        let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
958
959        assert_eq!(restored.event_id, original.event_id);
960        assert_eq!(restored.occurred_at, original.occurred_at);
961        assert_eq!(restored.tenant_id, original.tenant_id);
962        assert_eq!(restored.correlation_id, original.correlation_id);
963    }
964
965    #[test]
966    fn envelope_roundtrip_minimal() {
967        let event = ExperienceEvent::BudgetExceeded {
968            chain_id: "c".into(),
969            resource: BudgetResource::Cycles,
970            limit: "2".to_string(),
971            observed: None,
972        };
973        let original = ExperienceEventEnvelope::new("evt-min", event);
974
975        let json = serde_json::to_string(&original).unwrap();
976        let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
977
978        assert_eq!(restored.event_id, original.event_id);
979        assert!(restored.tenant_id.is_none());
980        assert!(restored.correlation_id.is_none());
981    }
982
983    // ── Edge cases: empty/special/long strings ───────────────────────────────
984
985    #[test]
986    fn envelope_edge_case_empty_event_id() {
987        let event = ExperienceEvent::BudgetExceeded {
988            chain_id: "c".into(),
989            resource: BudgetResource::Cycles,
990            limit: "1".to_string(),
991            observed: None,
992        };
993        let env = ExperienceEventEnvelope::new("", event);
994        assert_eq!(env.event_id, "");
995
996        let json = serde_json::to_string(&env).unwrap();
997        let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
998        assert_eq!(restored.event_id, "");
999    }
1000
1001    #[test]
1002    fn envelope_edge_case_special_chars_in_ids() {
1003        let event = ExperienceEvent::BudgetExceeded {
1004            chain_id: "c".into(),
1005            resource: BudgetResource::Cycles,
1006            limit: "1".to_string(),
1007            observed: None,
1008        };
1009        let special_id = "evt-🚀-/\\\"'";
1010        let env = ExperienceEventEnvelope::new(special_id, event)
1011            .with_tenant("tenant-@#$%^&*()")
1012            .with_correlation("corr-\n\t\r");
1013
1014        let json = serde_json::to_string(&env).unwrap();
1015        let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1016
1017        assert_eq!(restored.event_id, special_id);
1018        assert_eq!(restored.tenant_id.as_deref(), Some("tenant-@#$%^&*()"));
1019        assert_eq!(restored.correlation_id.as_deref(), Some("corr-\n\t\r"));
1020    }
1021
1022    #[test]
1023    fn envelope_edge_case_very_long_strings() {
1024        let long_id = "x".repeat(10_000);
1025        let event = ExperienceEvent::BudgetExceeded {
1026            chain_id: "c".into(),
1027            resource: BudgetResource::Cycles,
1028            limit: "1".to_string(),
1029            observed: None,
1030        };
1031        let env = ExperienceEventEnvelope::new(long_id.clone(), event)
1032            .with_tenant("y".repeat(5_000))
1033            .with_correlation("z".repeat(3_000));
1034
1035        let json = serde_json::to_string(&env).unwrap();
1036        let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1037
1038        assert_eq!(restored.event_id, long_id);
1039        assert_eq!(restored.tenant_id.as_ref().map(|s| s.len()), Some(5_000));
1040        assert_eq!(
1041            restored.correlation_id.as_ref().map(|s| s.len()),
1042            Some(3_000)
1043        );
1044    }
1045
1046    #[test]
1047    fn envelope_edge_case_unicode_in_ids() {
1048        let event = ExperienceEvent::BudgetExceeded {
1049            chain_id: "c".into(),
1050            resource: BudgetResource::Cycles,
1051            limit: "1".to_string(),
1052            observed: None,
1053        };
1054        let env = ExperienceEventEnvelope::new("evt-中文-العربية-русский", event)
1055            .with_tenant("テナント-यन्त्र")
1056            .with_correlation("相関-συσχέτιση");
1057
1058        let json = serde_json::to_string(&env).unwrap();
1059        let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1060
1061        assert_eq!(restored.event_id, "evt-中文-العربية-русский");
1062        assert_eq!(restored.tenant_id.as_deref(), Some("テナント-यन्त्र"));
1063        assert_eq!(restored.correlation_id.as_deref(), Some("相関-συσχέτιση"));
1064    }
1065
1066    // ── Malformed JSON / deserialization errors ──────────────────────────────
1067
1068    #[test]
1069    fn envelope_unpack_missing_required_event_id() {
1070        let json = r#"{
1071            "occurred_at": "2026-01-01T00:00:00Z",
1072            "tenant_id": null,
1073            "correlation_id": null,
1074            "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}}
1075        }"#;
1076
1077        let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1078        assert!(result.is_err());
1079    }
1080
1081    #[test]
1082    fn envelope_unpack_missing_required_occurred_at() {
1083        let json = r#"{
1084            "event_id": "evt-1",
1085            "tenant_id": null,
1086            "correlation_id": null,
1087            "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}}
1088        }"#;
1089
1090        let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1091        assert!(result.is_err());
1092    }
1093
1094    #[test]
1095    fn envelope_unpack_missing_required_event() {
1096        let json = r#"{
1097            "event_id": "evt-1",
1098            "occurred_at": "2026-01-01T00:00:00Z",
1099            "tenant_id": null,
1100            "correlation_id": null
1101        }"#;
1102
1103        let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1104        assert!(result.is_err());
1105    }
1106
1107    #[test]
1108    fn envelope_unpack_wrong_type_for_field() {
1109        let json = r#"{
1110            "event_id": 12345,
1111            "occurred_at": "2026-01-01T00:00:00Z",
1112            "tenant_id": null,
1113            "correlation_id": null,
1114            "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}}
1115        }"#;
1116
1117        let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1118        assert!(result.is_err());
1119    }
1120
1121    #[test]
1122    fn envelope_unpack_invalid_json_syntax() {
1123        let invalid = r#"{"event_id": "evt-1", "occurred_at": "2026-01-01"#;
1124        let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(invalid);
1125        assert!(result.is_err());
1126    }
1127
1128    #[test]
1129    fn envelope_unpack_extra_unknown_fields_ignored() {
1130        let json = r#"{
1131            "event_id": "evt-1",
1132            "occurred_at": "2026-01-01T00:00:00Z",
1133            "tenant_id": null,
1134            "correlation_id": null,
1135            "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}},
1136            "unknown_field": "ignored",
1137            "another_extra": 42
1138        }"#;
1139
1140        let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1141        // Extra fields are ignored by default serde behavior
1142        assert!(result.is_ok());
1143        assert_eq!(result.unwrap().event_id, "evt-1");
1144    }
1145
1146    #[test]
1147    fn envelope_unpack_null_for_event_id_invalid() {
1148        let json = r#"{
1149            "event_id": null,
1150            "occurred_at": "2026-01-01T00:00:00Z",
1151            "tenant_id": null,
1152            "correlation_id": null,
1153            "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}}
1154        }"#;
1155
1156        let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1157        assert!(result.is_err());
1158    }
1159
1160    // ── Builder chaining and override behavior ────────────────────────────────
1161
1162    #[test]
1163    fn envelope_builder_chaining_consistency() {
1164        let event = ExperienceEvent::BudgetExceeded {
1165            chain_id: "c".into(),
1166            resource: BudgetResource::Cycles,
1167            limit: "1".to_string(),
1168            observed: None,
1169        };
1170        let env = ExperienceEventEnvelope::new("evt-1", event)
1171            .with_tenant("t1")
1172            .with_tenant("t2")
1173            .with_correlation("corr1")
1174            .with_correlation("corr2")
1175            .with_timestamp("2026-01-01T00:00:00Z")
1176            .with_timestamp("2026-12-31T23:59:59Z");
1177
1178        assert_eq!(env.tenant_id.as_deref(), Some("t2"));
1179        assert_eq!(env.correlation_id.as_deref(), Some("corr2"));
1180        assert_eq!(env.occurred_at, "2026-12-31T23:59:59Z");
1181    }
1182
1183    #[test]
1184    fn envelope_builder_override_to_last_value() {
1185        let event = ExperienceEvent::BudgetExceeded {
1186            chain_id: "c".into(),
1187            resource: BudgetResource::Cycles,
1188            limit: "1".to_string(),
1189            observed: None,
1190        };
1191        let final_tenant = "final-tenant";
1192        let final_corr = "final-corr";
1193        let final_ts = "2099-01-01T00:00:00Z";
1194
1195        let env = ExperienceEventEnvelope::new("evt", event)
1196            .with_tenant("ignored1")
1197            .with_tenant("ignored2")
1198            .with_tenant(final_tenant)
1199            .with_correlation("ignored1")
1200            .with_correlation(final_corr)
1201            .with_timestamp("ignored1")
1202            .with_timestamp(final_ts);
1203
1204        assert_eq!(env.tenant_id.as_deref(), Some(final_tenant));
1205        assert_eq!(env.correlation_id.as_deref(), Some(final_corr));
1206        assert_eq!(env.occurred_at, final_ts);
1207    }
1208}