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, ConstraintName, ContentHash, CorrelationId, DomainId,
39    EventId, FactContent, FactId, GateId, IntentId, PackId, PolicyId, ProposalId, TenantId,
40    TensionId, Timestamp, TraceLinkId,
41};
42
43// ============================================================================
44// Event Envelope
45// ============================================================================
46
47/// Append-only event envelope.
48///
49/// The envelope carries stable metadata (ids, timestamps, correlation) and a
50/// typed event payload. Implementations store and index envelopes, not raw
51/// payloads, to keep provenance queryable without decoding payload JSON.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ExperienceEventEnvelope {
54    /// Unique event identifier (ULID/UUID)
55    pub event_id: EventId,
56    /// ISO 8601 timestamp of occurrence
57    pub occurred_at: Timestamp,
58    /// Optional tenant scope
59    pub tenant_id: Option<TenantId>,
60    /// Correlation ID for chain/run grouping
61    pub correlation_id: Option<CorrelationId>,
62    /// Typed event payload
63    pub event: ExperienceEvent,
64}
65
66impl ExperienceEventEnvelope {
67    /// Create a new envelope with a placeholder timestamp.
68    ///
69    /// Production systems should call `with_timestamp()` to set a trusted time.
70    #[must_use]
71    pub fn new(event_id: impl Into<EventId>, event: ExperienceEvent) -> Self {
72        Self {
73            event_id: event_id.into(),
74            occurred_at: Self::now_iso8601(),
75            tenant_id: None,
76            correlation_id: None,
77            event,
78        }
79    }
80
81    /// Add a tenant scope.
82    #[must_use]
83    pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
84        self.tenant_id = Some(tenant_id.into());
85        self
86    }
87
88    /// Add a correlation ID.
89    #[must_use]
90    pub fn with_correlation(mut self, correlation_id: impl Into<CorrelationId>) -> Self {
91        self.correlation_id = Some(correlation_id.into());
92        self
93    }
94
95    /// Set explicit timestamp (for replay/testing).
96    #[must_use]
97    pub fn with_timestamp(mut self, occurred_at: impl Into<Timestamp>) -> Self {
98        self.occurred_at = occurred_at.into();
99        self
100    }
101
102    /// Generate ISO 8601 timestamp.
103    ///
104    /// Note: This returns a placeholder. Production systems should use
105    /// `with_timestamp()` to inject a timestamp from a trusted source.
106    fn now_iso8601() -> Timestamp {
107        Timestamp::epoch()
108    }
109}
110
111// ============================================================================
112// Experience Events
113// ============================================================================
114
115/// High-level event kinds for query filtering.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
117pub enum ExperienceEventKind {
118    ProposalCreated,
119    ProposalValidated,
120    FactPromoted,
121    RecallExecuted,
122    ReplayTraceRecorded,
123    ReplayabilityDowngraded,
124    ArtifactStateTransitioned,
125    ArtifactRollbackRecorded,
126    BackendInvoked,
127    OutcomeRecorded,
128    BudgetExceeded,
129    PolicySnapshotCaptured,
130    HypothesisResolved,
131    GateDecisionRecorded,
132}
133
134/// Append-only experience event payloads.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(tag = "type", content = "data")]
137pub enum ExperienceEvent {
138    /// Kernel proposal was created.
139    ProposalCreated {
140        proposal: KernelProposal,
141        chain_id: ChainId,
142        step: DecisionStep,
143        policy_snapshot_hash: Option<ContentHash>,
144    },
145    /// Proposal was validated (contracts/truths evaluated).
146    ProposalValidated {
147        proposal_id: ProposalId,
148        chain_id: ChainId,
149        step: DecisionStep,
150        contract_results: Vec<ContractResultSnapshot>,
151        all_passed: bool,
152        validator: ActorId,
153    },
154    /// Proposal was promoted into a fact.
155    FactPromoted {
156        proposal_id: ProposalId,
157        fact_id: FactId,
158        promoted_by: ActorId,
159        reason: String,
160        requires_human: bool,
161    },
162    /// Recall operation executed with full provenance.
163    RecallExecuted {
164        query: RecallQuery,
165        provenance: RecallProvenanceEnvelope,
166        trace_link_id: Option<TraceLinkId>,
167    },
168    /// Trace link recorded as a first-class object.
169    ReplayTraceRecorded {
170        trace_link_id: TraceLinkId,
171        trace_link: ReplayTrace,
172    },
173    /// Replayability downgraded for a trace.
174    ReplayabilityDowngraded {
175        trace_link_id: TraceLinkId,
176        from: Replayability,
177        to: Replayability,
178        reason: ReplayabilityDowngradeReason,
179    },
180    /// Governed artifact state transition recorded.
181    ArtifactStateTransitioned {
182        artifact_id: ArtifactId,
183        artifact_kind: ArtifactKind,
184        event: LifecycleEvent,
185    },
186    /// Governed artifact rollback recorded.
187    ArtifactRollbackRecorded { rollback: RollbackRecord },
188    /// Backend invocation occurred (useful for audit/latency analysis).
189    BackendInvoked {
190        backend_name: BackendId,
191        adapter_id: Option<BackendId>,
192        trace_link_id: TraceLinkId,
193        step: DecisionStep,
194        policy_snapshot_hash: Option<ContentHash>,
195    },
196    /// Outcome recorded for a chain step.
197    OutcomeRecorded {
198        chain_id: ChainId,
199        step: DecisionStep,
200        passed: bool,
201        stop_reason: Option<EngineStopReason>,
202        latency_ms: Option<u64>,
203        tokens: Option<u64>,
204        cost_microdollars: Option<u64>,
205        backend: Option<BackendId>,
206        /// Provider/gateway metadata (Kong headers, OpenRouter cost, etc.).
207        #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
208        metadata: std::collections::HashMap<String, String>,
209    },
210    /// Budget exceeded event for a chain/run.
211    BudgetExceeded {
212        chain_id: ChainId,
213        resource: BudgetResource,
214        limit: String,
215        observed: Option<String>,
216    },
217    /// Policy snapshot captured for provenance.
218    PolicySnapshotCaptured {
219        policy_id: PolicyId,
220        policy: PolicySnapshot,
221        snapshot_hash: ContentHash,
222        captured_by: ActorId,
223    },
224    /// A tracked hypothesis reached a terminal outcome.
225    HypothesisResolved {
226        chain_id: ChainId,
227        fact_id: FactId,
228        domain: DomainId,
229        claim: String,
230        confidence: converge_pack::UnitInterval,
231        outcome: HypothesisOutcome,
232        #[serde(default, skip_serializing_if = "Option::is_none")]
233        contradiction_id: Option<TensionId>,
234        formed_cycle: u32,
235        resolved_cycle: u32,
236    },
237    /// Human decision on a HITL gate recorded for audit and later policy mining.
238    GateDecisionRecorded {
239        request: GateRequest,
240        decision: GateDecision,
241    },
242}
243
244impl ExperienceEvent {
245    /// Get the event kind for filtering.
246    #[must_use]
247    pub fn kind(&self) -> ExperienceEventKind {
248        match self {
249            Self::ProposalCreated { .. } => ExperienceEventKind::ProposalCreated,
250            Self::ProposalValidated { .. } => ExperienceEventKind::ProposalValidated,
251            Self::FactPromoted { .. } => ExperienceEventKind::FactPromoted,
252            Self::RecallExecuted { .. } => ExperienceEventKind::RecallExecuted,
253            Self::ReplayTraceRecorded { .. } => ExperienceEventKind::ReplayTraceRecorded,
254            Self::ReplayabilityDowngraded { .. } => ExperienceEventKind::ReplayabilityDowngraded,
255            Self::ArtifactStateTransitioned { .. } => {
256                ExperienceEventKind::ArtifactStateTransitioned
257            }
258            Self::ArtifactRollbackRecorded { .. } => ExperienceEventKind::ArtifactRollbackRecorded,
259            Self::BackendInvoked { .. } => ExperienceEventKind::BackendInvoked,
260            Self::OutcomeRecorded { .. } => ExperienceEventKind::OutcomeRecorded,
261            Self::BudgetExceeded { .. } => ExperienceEventKind::BudgetExceeded,
262            Self::PolicySnapshotCaptured { .. } => ExperienceEventKind::PolicySnapshotCaptured,
263            Self::HypothesisResolved { .. } => ExperienceEventKind::HypothesisResolved,
264            Self::GateDecisionRecorded { .. } => ExperienceEventKind::GateDecisionRecorded,
265        }
266    }
267}
268
269// ============================================================================
270// Supporting Types
271// ============================================================================
272
273/// Snapshot of a contract result for validation events.
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct ContractResultSnapshot {
276    pub name: String,
277    pub passed: bool,
278    pub failure_reason: Option<String>,
279}
280
281/// Budget dimension that was exhausted.
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub enum BudgetResource {
284    EngineBudget,
285    Tokens,
286    Facts,
287    Cycles,
288    Time,
289    Cost,
290    Other(String),
291}
292
293/// Terminal outcome for a tracked hypothesis.
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
295pub enum HypothesisOutcome {
296    Confirmed,
297    Falsified,
298    Superseded,
299    Unresolved,
300}
301
302impl From<crate::kernel_boundary::ContractResult> for ContractResultSnapshot {
303    fn from(result: crate::kernel_boundary::ContractResult) -> Self {
304        Self {
305            name: result.name,
306            passed: result.passed,
307            failure_reason: result.failure_reason,
308        }
309    }
310}
311
312/// Kind of governed artifact.
313#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
314pub enum ArtifactKind {
315    Adapter,
316    Pack,
317    Policy,
318    TruthFile,
319    EvalSuite,
320    Other(String),
321}
322
323/// Policy snapshot payload.
324#[derive(Debug, Clone, Serialize, Deserialize)]
325#[serde(tag = "type", content = "policy")]
326pub enum PolicySnapshot {
327    Kernel(KernelPolicy),
328    Routing(RoutingPolicy),
329    Recall(RecallPolicy),
330}
331
332/// Query for experience events.
333#[derive(Debug, Clone, Serialize, Deserialize, Default)]
334pub struct EventQuery {
335    pub tenant_id: Option<TenantId>,
336    pub time_range: Option<TimeRange>,
337    pub kinds: Vec<ExperienceEventKind>,
338    pub correlation_id: Option<CorrelationId>,
339    pub chain_id: Option<ChainId>,
340    pub limit: Option<usize>,
341}
342
343/// Query for governed artifacts.
344#[derive(Debug, Clone, Serialize, Deserialize, Default)]
345pub struct ArtifactQuery {
346    pub tenant_id: Option<TenantId>,
347    pub artifact_id: Option<ArtifactId>,
348    pub kind: Option<ArtifactKind>,
349    pub state: Option<GovernedArtifactState>,
350    pub limit: Option<usize>,
351}
352
353/// Inclusive time range filter (ISO 8601 strings).
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct TimeRange {
356    pub start: Option<Timestamp>,
357    pub end: Option<Timestamp>,
358}
359
360// ============================================================================
361// Experience Store Trait
362// ============================================================================
363
364/// Experience store trait (append-only ledger boundary).
365///
366/// This is the canonical audit trail interface. Implementations provide
367/// append-only event storage and query access for governance, debugging,
368/// and downstream analytics.
369///
370/// See [`converge_experience`] for in-memory test support and `manifold` for
371/// database-backed adapters.
372pub trait ExperienceStore: Send + Sync {
373    /// Append a single event.
374    fn append_event(&self, event: ExperienceEventEnvelope) -> ExperienceStoreResult<()>;
375
376    /// Append multiple events (best-effort atomicity per implementation).
377    fn append_events(&self, events: &[ExperienceEventEnvelope]) -> ExperienceStoreResult<()> {
378        for event in events {
379            self.append_event(event.clone())?;
380        }
381        Ok(())
382    }
383
384    /// Query events by tenant/time/kind/etc.
385    fn query_events(
386        &self,
387        query: &EventQuery,
388    ) -> ExperienceStoreResult<Vec<ExperienceEventEnvelope>>;
389
390    /// Write an artifact lifecycle transition event.
391    fn write_artifact_state_transition(
392        &self,
393        artifact_id: &ArtifactId,
394        artifact_kind: ArtifactKind,
395        event: LifecycleEvent,
396    ) -> ExperienceStoreResult<()>;
397
398    /// Fetch a trace link by id.
399    fn get_trace_link(
400        &self,
401        trace_link_id: &TraceLinkId,
402    ) -> ExperienceStoreResult<Option<ReplayTrace>>;
403
404    /// Append a single user-side experience event.
405    ///
406    /// Default implementation returns `Unsupported`. In-process backends
407    /// override this to record the event in the same ledger as engine events.
408    fn append_user_event(&self, _event: UserExperienceEventEnvelope) -> ExperienceStoreResult<()> {
409        Err(ExperienceStoreError::StorageError {
410            message: "user-side events are not supported by this backend".to_string(),
411        })
412    }
413
414    /// Query both engine-side and user-side records, ordered by occurrence.
415    ///
416    /// Default implementation lifts engine events through the
417    /// [`ExperienceRecord::Engine`] variant. Backends that store user events
418    /// override this to interleave both record kinds.
419    fn query_records(&self, query: &EventQuery) -> ExperienceStoreResult<Vec<ExperienceRecord>> {
420        Ok(self
421            .query_events(query)?
422            .into_iter()
423            .map(ExperienceRecord::Engine)
424            .collect())
425    }
426}
427
428/// Experience store error type.
429#[derive(Debug, Clone, PartialEq, Eq)]
430pub enum ExperienceStoreError {
431    /// Storage layer error with message
432    StorageError { message: String },
433    /// Query was invalid or unsupported
434    InvalidQuery { message: String },
435    /// Record not found
436    NotFound { message: String },
437}
438
439impl std::fmt::Display for ExperienceStoreError {
440    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441        match self {
442            Self::StorageError { message } => write!(f, "Storage error: {}", message),
443            Self::InvalidQuery { message } => write!(f, "Invalid query: {}", message),
444            Self::NotFound { message } => write!(f, "Not found: {}", message),
445        }
446    }
447}
448
449impl std::error::Error for ExperienceStoreError {}
450
451/// Result type for experience store operations.
452pub type ExperienceStoreResult<T> = Result<T, ExperienceStoreError>;
453
454// ============================================================================
455// User-side Experience Events (sibling type to ExperienceEvent)
456// ============================================================================
457
458/// What a user override applies to.
459#[derive(Debug, Clone, Serialize, Deserialize)]
460#[serde(tag = "kind", content = "id")]
461pub enum OverrideTarget {
462    Fact(FactId),
463    Proposal(ProposalId),
464    Constraint(ConstraintName),
465}
466
467/// What a user correction applies to.
468#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
469#[serde(tag = "kind", rename_all = "snake_case")]
470pub enum CorrectionTarget {
471    Fact { fact_id: FactId },
472    Proposal { proposal_id: ProposalId },
473}
474
475impl CorrectionTarget {
476    /// Stable short label for recall summaries and logs.
477    #[must_use]
478    pub fn kind_label(&self) -> &'static str {
479        match self {
480            Self::Fact { .. } => "fact",
481            Self::Proposal { .. } => "proposal",
482        }
483    }
484}
485
486/// Primitive boundary a human adjusted.
487#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
488#[serde(rename_all = "snake_case")]
489pub enum BoundaryKind {
490    Authority,
491    Forbidden,
492    Expiry,
493    Reversibility,
494}
495
496/// Scope for a boundary adjustment.
497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
498#[serde(tag = "scope", rename_all = "snake_case")]
499pub enum BoundaryTarget {
500    Pack { pack_id: PackId },
501    Intent { intent_id: IntentId },
502    Global,
503}
504
505/// User-side experience event.
506///
507/// This is the trust-transfer counterpart to [`ExperienceEvent`]: every variant
508/// records a deliberate human act that adjusts engine state — approval,
509/// rejection, override, correction, or boundary change. Operator surfaces such
510/// as Helms emit these events; planning consumes them through recall to weight
511/// future priors.
512///
513/// Kept as a sibling enum (not a variant of `ExperienceEvent`) so additions on
514/// either side stay non-breaking for downstream crates.io consumers.
515#[derive(Debug, Clone, Serialize, Deserialize)]
516#[serde(tag = "type", content = "data")]
517pub enum UserExperienceEvent {
518    /// A human approved a paused gate request.
519    UserApprovalGranted {
520        gate_request_id: GateId,
521        actor: ActorId,
522        policy_snapshot_hash: Option<ContentHash>,
523        reason: Option<String>,
524    },
525    /// A human rejected a paused gate request.
526    UserApprovalRejected {
527        gate_request_id: GateId,
528        actor: ActorId,
529        policy_snapshot_hash: Option<ContentHash>,
530        reason: Option<String>,
531    },
532    /// A human issued an override against a fact, proposal, or constraint.
533    UserOverrideIssued {
534        target: OverrideTarget,
535        actor: ActorId,
536        policy_snapshot_hash: Option<ContentHash>,
537        reason: String,
538    },
539    /// A human corrected a fact or proposal without rewriting history.
540    ///
541    /// This event ledgers a correction relationship. It does not itself admit
542    /// or promote the corrected content; callers must run corrected content
543    /// through the normal admission and promotion path first.
544    UserCorrection {
545        target: CorrectionTarget,
546        actor: ActorId,
547        policy_snapshot_hash: Option<ContentHash>,
548        original_content: ContentHash,
549        corrected_content: FactContent,
550        reason: String,
551    },
552    /// A human adjusted an authority, forbidden-action, expiry, or reversibility boundary.
553    ///
554    /// This event records what changed; it is not the policy-update channel.
555    /// Callers must update the active policy surface first, then append this
556    /// ledger event. Recall consumers must filter by `target` so pack-scoped
557    /// or intent-scoped boundary changes do not spill into unrelated planning
558    /// runs.
559    UserBoundaryAdjusted {
560        boundary: BoundaryKind,
561        target: BoundaryTarget,
562        actor: ActorId,
563        policy_snapshot_hash: Option<ContentHash>,
564        previous_value: serde_json::Value,
565        new_value: serde_json::Value,
566        reason: String,
567    },
568}
569
570/// Envelope for a [`UserExperienceEvent`] — mirrors [`ExperienceEventEnvelope`]
571/// for the user-side ledger.
572#[derive(Debug, Clone, Serialize, Deserialize)]
573pub struct UserExperienceEventEnvelope {
574    pub event_id: EventId,
575    pub occurred_at: Timestamp,
576    pub tenant_id: Option<TenantId>,
577    pub correlation_id: Option<CorrelationId>,
578    pub event: UserExperienceEvent,
579}
580
581impl UserExperienceEventEnvelope {
582    #[must_use]
583    pub fn new(event_id: impl Into<EventId>, event: UserExperienceEvent) -> Self {
584        Self {
585            event_id: event_id.into(),
586            occurred_at: Timestamp::epoch(),
587            tenant_id: None,
588            correlation_id: None,
589            event,
590        }
591    }
592
593    #[must_use]
594    pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
595        self.tenant_id = Some(tenant_id.into());
596        self
597    }
598
599    #[must_use]
600    pub fn with_correlation(mut self, correlation_id: impl Into<CorrelationId>) -> Self {
601        self.correlation_id = Some(correlation_id.into());
602        self
603    }
604
605    #[must_use]
606    pub fn with_timestamp(mut self, occurred_at: impl Into<Timestamp>) -> Self {
607        self.occurred_at = occurred_at.into();
608        self
609    }
610}
611
612/// Unified query result spanning both ledger sides.
613///
614/// Recall and audit consumers iterate `ExperienceRecord` rather than the two
615/// envelope types directly, so a UserOverrideIssued and an OutcomeRecorded can
616/// both feed the same prior calibration without the consumer needing to call
617/// two stores.
618#[derive(Debug, Clone, Serialize, Deserialize)]
619#[serde(tag = "kind", content = "value")]
620pub enum ExperienceRecord {
621    Engine(ExperienceEventEnvelope),
622    User(UserExperienceEventEnvelope),
623}
624
625impl ExperienceRecord {
626    #[must_use]
627    pub fn correlation_id(&self) -> Option<&CorrelationId> {
628        match self {
629            Self::Engine(env) => env.correlation_id.as_ref(),
630            Self::User(env) => env.correlation_id.as_ref(),
631        }
632    }
633
634    #[must_use]
635    pub fn tenant_id(&self) -> Option<&TenantId> {
636        match self {
637            Self::Engine(env) => env.tenant_id.as_ref(),
638            Self::User(env) => env.tenant_id.as_ref(),
639        }
640    }
641
642    #[must_use]
643    pub fn occurred_at(&self) -> &Timestamp {
644        match self {
645            Self::Engine(env) => &env.occurred_at,
646            Self::User(env) => &env.occurred_at,
647        }
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654
655    #[test]
656    fn event_kind_mapping() {
657        let event = ExperienceEvent::BudgetExceeded {
658            chain_id: "chain-1".into(),
659            resource: BudgetResource::Tokens,
660            limit: "1024".to_string(),
661            observed: Some("2048".to_string()),
662        };
663        assert_eq!(event.kind(), ExperienceEventKind::BudgetExceeded);
664    }
665
666    #[test]
667    fn envelope_builder_sets_fields() {
668        let event = ExperienceEvent::OutcomeRecorded {
669            chain_id: "chain-1".into(),
670            step: DecisionStep::Planning,
671            passed: true,
672            stop_reason: None,
673            latency_ms: Some(12),
674            tokens: Some(42),
675            cost_microdollars: None,
676            backend: Some("local".into()),
677            metadata: Default::default(),
678        };
679        let envelope = ExperienceEventEnvelope::new("evt-1", event)
680            .with_tenant("tenant-a")
681            .with_correlation("corr-1")
682            .with_timestamp("2026-01-21T12:00:00Z");
683
684        assert_eq!(envelope.event_id, "evt-1");
685        assert_eq!(envelope.tenant_id.as_deref(), Some("tenant-a"));
686        assert_eq!(envelope.correlation_id.as_deref(), Some("corr-1"));
687        assert_eq!(envelope.occurred_at, "2026-01-21T12:00:00Z");
688    }
689
690    // ── ExperienceEvent::kind() exhaustive coverage ──────────────────────────
691
692    #[test]
693    fn event_kind_proposal_created() {
694        let event = ExperienceEvent::ProposalCreated {
695            proposal: crate::kernel_boundary::KernelProposal {
696                id: "p-1".into(),
697                kind: crate::kernel_boundary::ProposalKind::Claims,
698                payload: "test".into(),
699                structured_payload: None,
700                trace_link: crate::kernel_boundary::ReplayTrace::Local(
701                    crate::kernel_boundary::LocalReplayTrace {
702                        base_model_hash: "abc".into(),
703                        adapter: None,
704                        tokenizer_hash: "tok".into(),
705                        seed: 42,
706                        sampler: crate::kernel_boundary::SamplerParams::default(),
707                        prompt_version: "v1".into(),
708                        recall: None,
709                        weights_mutated: false,
710                        execution_env: crate::kernel_boundary::ExecutionEnv::default(),
711                    },
712                ),
713                contract_results: vec![crate::kernel_boundary::ContractResult::passed(
714                    "grounded-answering",
715                )],
716                requires_human: false,
717                confidence: Some(0.9),
718            },
719            chain_id: "c-1".into(),
720            step: DecisionStep::Planning,
721            policy_snapshot_hash: None,
722        };
723        assert_eq!(event.kind(), ExperienceEventKind::ProposalCreated);
724    }
725
726    #[test]
727    fn event_kind_fact_promoted() {
728        let event = ExperienceEvent::FactPromoted {
729            proposal_id: "p-1".into(),
730            fact_id: "f-1".into(),
731            promoted_by: "engine".into(),
732            reason: "validated".into(),
733            requires_human: false,
734        };
735        assert_eq!(event.kind(), ExperienceEventKind::FactPromoted);
736    }
737
738    #[test]
739    fn event_kind_hypothesis_resolved() {
740        let event = ExperienceEvent::HypothesisResolved {
741            chain_id: "c-1".into(),
742            fact_id: "f-1".into(),
743            domain: "market".into(),
744            claim: "price will increase".into(),
745            confidence: converge_pack::UnitInterval::clamped(0.85),
746            outcome: HypothesisOutcome::Confirmed,
747            contradiction_id: None,
748            formed_cycle: 1,
749            resolved_cycle: 3,
750        };
751        assert_eq!(event.kind(), ExperienceEventKind::HypothesisResolved);
752    }
753
754    #[test]
755    fn event_kind_policy_snapshot_captured() {
756        let event = ExperienceEvent::PolicySnapshotCaptured {
757            policy_id: "pol-1".into(),
758            policy: PolicySnapshot::Routing(crate::kernel_boundary::RoutingPolicy::default()),
759            snapshot_hash: ContentHash::zero(),
760            captured_by: "engine".into(),
761        };
762        assert_eq!(event.kind(), ExperienceEventKind::PolicySnapshotCaptured);
763    }
764
765    #[test]
766    fn user_experience_events_roundtrip_new_bidirectional_variants() {
767        let events = [
768            UserExperienceEvent::UserApprovalRejected {
769                gate_request_id: "gate-1".into(),
770                actor: "operator-1".into(),
771                policy_snapshot_hash: None,
772                reason: Some("not enough evidence".into()),
773            },
774            UserExperienceEvent::UserCorrection {
775                target: CorrectionTarget::Fact {
776                    fact_id: FactId::new("fact-1"),
777                },
778                actor: "operator-1".into(),
779                policy_snapshot_hash: None,
780                original_content: ContentHash::zero(),
781                corrected_content: FactContent::new(
782                    crate::FactContentKind::Claim,
783                    "corrected claim",
784                ),
785                reason: "source was stale".into(),
786            },
787            UserExperienceEvent::UserBoundaryAdjusted {
788                boundary: BoundaryKind::Authority,
789                target: BoundaryTarget::Pack {
790                    pack_id: PackId::new("pack-1"),
791                },
792                actor: "operator-1".into(),
793                policy_snapshot_hash: None,
794                previous_value: serde_json::json!({"limit": 100}),
795                new_value: serde_json::json!({"limit": 50}),
796                reason: "reduce autonomy".into(),
797            },
798        ];
799
800        for event in events {
801            let json = serde_json::to_string(&event).unwrap();
802            let back: UserExperienceEvent = serde_json::from_str(&json).unwrap();
803            assert!(matches!(
804                back,
805                UserExperienceEvent::UserApprovalRejected { .. }
806                    | UserExperienceEvent::UserCorrection { .. }
807                    | UserExperienceEvent::UserBoundaryAdjusted { .. }
808            ));
809        }
810    }
811
812    // ── ExperienceStoreError ─────────────────────────────────────────────────
813
814    #[test]
815    fn store_error_display_storage() {
816        let e = ExperienceStoreError::StorageError {
817            message: "disk full".into(),
818        };
819        assert!(e.to_string().contains("disk full"));
820    }
821
822    #[test]
823    fn store_error_display_invalid_query() {
824        let e = ExperienceStoreError::InvalidQuery {
825            message: "bad filter".into(),
826        };
827        assert!(e.to_string().contains("bad filter"));
828    }
829
830    #[test]
831    fn store_error_display_not_found() {
832        let e = ExperienceStoreError::NotFound {
833            message: "trace-99".into(),
834        };
835        assert!(e.to_string().contains("trace-99"));
836    }
837
838    #[test]
839    fn store_error_is_std_error() {
840        let e: Box<dyn std::error::Error> = Box::new(ExperienceStoreError::StorageError {
841            message: "test".into(),
842        });
843        assert!(!e.to_string().is_empty());
844    }
845
846    // ── ArtifactKind equality ────────────────────────────────────────────────
847
848    #[test]
849    fn artifact_kind_equality_named() {
850        assert_eq!(ArtifactKind::Adapter, ArtifactKind::Adapter);
851        assert_ne!(ArtifactKind::Pack, ArtifactKind::Policy);
852    }
853
854    #[test]
855    fn artifact_kind_other_variant() {
856        let a = ArtifactKind::Other("custom".into());
857        let b = ArtifactKind::Other("custom".into());
858        assert_eq!(a, b);
859        assert_ne!(
860            ArtifactKind::Other("x".into()),
861            ArtifactKind::Other("y".into())
862        );
863    }
864
865    // ── ContractResultSnapshot From conversion ───────────────────────────────
866
867    #[test]
868    fn contract_result_snapshot_from_contract_result() {
869        let cr = crate::kernel_boundary::ContractResult {
870            name: "schema-check".into(),
871            passed: false,
872            failure_reason: Some("missing field".into()),
873        };
874        let snap: ContractResultSnapshot = cr.into();
875        assert_eq!(snap.name, "schema-check");
876        assert!(!snap.passed);
877        assert_eq!(snap.failure_reason.as_deref(), Some("missing field"));
878    }
879
880    // ── EventQuery defaults ──────────────────────────────────────────────────
881
882    #[test]
883    fn event_query_default_is_empty() {
884        let q = EventQuery::default();
885        assert!(q.tenant_id.is_none());
886        assert!(q.kinds.is_empty());
887        assert!(q.correlation_id.is_none());
888        assert!(q.chain_id.is_none());
889        assert!(q.limit.is_none());
890    }
891
892    // ── Envelope without optional fields ─────────────────────────────────────
893
894    #[test]
895    fn envelope_minimal_no_optional_fields() {
896        let event = ExperienceEvent::BudgetExceeded {
897            chain_id: "c".into(),
898            resource: BudgetResource::Cycles,
899            limit: "10".into(),
900            observed: None,
901        };
902        let env = ExperienceEventEnvelope::new("e-1", event);
903        assert!(env.tenant_id.is_none());
904        assert!(env.correlation_id.is_none());
905        assert_eq!(env.occurred_at, "1970-01-01T00:00:00Z");
906    }
907
908    // ── Serde roundtrips ─────────────────────────────────────────────────────
909
910    #[test]
911    fn experience_event_kind_serde_roundtrip() {
912        let kinds = [
913            ExperienceEventKind::ProposalCreated,
914            ExperienceEventKind::ProposalValidated,
915            ExperienceEventKind::FactPromoted,
916            ExperienceEventKind::RecallExecuted,
917            ExperienceEventKind::ReplayTraceRecorded,
918            ExperienceEventKind::ReplayabilityDowngraded,
919            ExperienceEventKind::ArtifactStateTransitioned,
920            ExperienceEventKind::ArtifactRollbackRecorded,
921            ExperienceEventKind::BackendInvoked,
922            ExperienceEventKind::OutcomeRecorded,
923            ExperienceEventKind::BudgetExceeded,
924            ExperienceEventKind::PolicySnapshotCaptured,
925            ExperienceEventKind::HypothesisResolved,
926            ExperienceEventKind::GateDecisionRecorded,
927        ];
928        for kind in kinds {
929            let json = serde_json::to_string(&kind).unwrap();
930            let back: ExperienceEventKind = serde_json::from_str(&json).unwrap();
931            assert_eq!(back, kind);
932        }
933    }
934
935    #[test]
936    fn artifact_kind_serde_roundtrip() {
937        let kinds = [
938            ArtifactKind::Adapter,
939            ArtifactKind::Pack,
940            ArtifactKind::Policy,
941            ArtifactKind::TruthFile,
942            ArtifactKind::EvalSuite,
943            ArtifactKind::Other("custom".into()),
944        ];
945        for kind in kinds {
946            let json = serde_json::to_string(&kind).unwrap();
947            let back: ArtifactKind = serde_json::from_str(&json).unwrap();
948            assert_eq!(back, kind);
949        }
950    }
951
952    // ── Envelope serialization (pack) ────────────────────────────────────────
953
954    #[test]
955    fn envelope_pack_with_all_fields() {
956        let event = ExperienceEvent::BudgetExceeded {
957            chain_id: "c-1".into(),
958            resource: BudgetResource::Tokens,
959            limit: "1024".to_string(),
960            observed: Some("2048".to_string()),
961        };
962        let env = ExperienceEventEnvelope::new("evt-abc123", event)
963            .with_tenant("tenant-prod")
964            .with_correlation("corr-xyz789")
965            .with_timestamp("2026-04-28T15:30:45Z");
966
967        let json = serde_json::to_string(&env).unwrap();
968        assert!(json.contains("evt-abc123"));
969        assert!(json.contains("tenant-prod"));
970        assert!(json.contains("corr-xyz789"));
971        assert!(json.contains("2026-04-28T15:30:45Z"));
972    }
973
974    #[test]
975    fn envelope_pack_minimal() {
976        let event = ExperienceEvent::BudgetExceeded {
977            chain_id: "c".into(),
978            resource: BudgetResource::Cycles,
979            limit: "5".to_string(),
980            observed: None,
981        };
982        let env = ExperienceEventEnvelope::new("e1", event);
983
984        let json = serde_json::to_string(&env).unwrap();
985        assert!(json.contains("e1"));
986        assert!(json.contains("1970-01-01T00:00:00Z"));
987        // Optional fields should still serialize as null
988        assert!(json.contains("tenant_id"));
989        assert!(json.contains("correlation_id"));
990    }
991
992    // ── Envelope deserialization (unpack) ────────────────────────────────────
993
994    #[test]
995    fn envelope_unpack_with_all_fields() {
996        let json = r#"{
997            "event_id": "evt-1",
998            "occurred_at": "2026-04-28T12:00:00Z",
999            "tenant_id": "tenant-x",
1000            "correlation_id": "corr-1",
1001            "event": {
1002                "type": "BudgetExceeded",
1003                "data": {
1004                    "chain_id": "c-1",
1005                    "resource": "Tokens",
1006                    "limit": "999",
1007                    "observed": "500"
1008                }
1009            }
1010        }"#;
1011
1012        let env: ExperienceEventEnvelope = serde_json::from_str(json).unwrap();
1013        assert_eq!(env.event_id, "evt-1");
1014        assert_eq!(env.occurred_at, "2026-04-28T12:00:00Z");
1015        assert_eq!(env.tenant_id.as_deref(), Some("tenant-x"));
1016        assert_eq!(env.correlation_id.as_deref(), Some("corr-1"));
1017    }
1018
1019    #[test]
1020    fn envelope_unpack_missing_optional_fields() {
1021        let json = r#"{
1022            "event_id": "evt-minimal",
1023            "occurred_at": "2026-01-01T00:00:00Z",
1024            "tenant_id": null,
1025            "correlation_id": null,
1026            "event": {
1027                "type": "BudgetExceeded",
1028                "data": {
1029                    "chain_id": "c",
1030                    "resource": "Cycles",
1031                    "limit": "1",
1032                    "observed": null
1033                }
1034            }
1035        }"#;
1036
1037        let env: ExperienceEventEnvelope = serde_json::from_str(json).unwrap();
1038        assert!(env.tenant_id.is_none());
1039        assert!(env.correlation_id.is_none());
1040    }
1041
1042    #[test]
1043    fn envelope_unpack_missing_optional_keys_entirely() {
1044        let json = r#"{
1045            "event_id": "evt-sparse",
1046            "occurred_at": "2026-01-01T00:00:00Z",
1047            "event": {
1048                "type": "BudgetExceeded",
1049                "data": {
1050                    "chain_id": "c",
1051                    "resource": "Facts",
1052                    "limit": "10",
1053                    "observed": null
1054                }
1055            }
1056        }"#;
1057
1058        let env: ExperienceEventEnvelope = serde_json::from_str(json).unwrap();
1059        assert_eq!(env.event_id, "evt-sparse");
1060        assert!(env.tenant_id.is_none());
1061        assert!(env.correlation_id.is_none());
1062    }
1063
1064    // ── Roundtrip: pack then unpack ──────────────────────────────────────────
1065
1066    #[test]
1067    fn envelope_roundtrip_complete() {
1068        let event = ExperienceEvent::BudgetExceeded {
1069            chain_id: "chain-rt".into(),
1070            resource: BudgetResource::Tokens,
1071            limit: "777".to_string(),
1072            observed: Some("333".to_string()),
1073        };
1074        let original = ExperienceEventEnvelope::new("evt-rt", event)
1075            .with_tenant("tenant-rt")
1076            .with_correlation("corr-rt")
1077            .with_timestamp("2026-04-28T10:15:30Z");
1078
1079        let json = serde_json::to_string(&original).unwrap();
1080        let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1081
1082        assert_eq!(restored.event_id, original.event_id);
1083        assert_eq!(restored.occurred_at, original.occurred_at);
1084        assert_eq!(restored.tenant_id, original.tenant_id);
1085        assert_eq!(restored.correlation_id, original.correlation_id);
1086    }
1087
1088    #[test]
1089    fn envelope_roundtrip_minimal() {
1090        let event = ExperienceEvent::BudgetExceeded {
1091            chain_id: "c".into(),
1092            resource: BudgetResource::Cycles,
1093            limit: "2".to_string(),
1094            observed: None,
1095        };
1096        let original = ExperienceEventEnvelope::new("evt-min", event);
1097
1098        let json = serde_json::to_string(&original).unwrap();
1099        let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1100
1101        assert_eq!(restored.event_id, original.event_id);
1102        assert!(restored.tenant_id.is_none());
1103        assert!(restored.correlation_id.is_none());
1104    }
1105
1106    // ── Edge cases: empty/special/long strings ───────────────────────────────
1107
1108    #[test]
1109    fn envelope_edge_case_empty_event_id() {
1110        let event = ExperienceEvent::BudgetExceeded {
1111            chain_id: "c".into(),
1112            resource: BudgetResource::Cycles,
1113            limit: "1".to_string(),
1114            observed: None,
1115        };
1116        let env = ExperienceEventEnvelope::new("", event);
1117        assert_eq!(env.event_id, "");
1118
1119        let json = serde_json::to_string(&env).unwrap();
1120        let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1121        assert_eq!(restored.event_id, "");
1122    }
1123
1124    #[test]
1125    fn envelope_edge_case_special_chars_in_ids() {
1126        let event = ExperienceEvent::BudgetExceeded {
1127            chain_id: "c".into(),
1128            resource: BudgetResource::Cycles,
1129            limit: "1".to_string(),
1130            observed: None,
1131        };
1132        let special_id = "evt-🚀-/\\\"'";
1133        let env = ExperienceEventEnvelope::new(special_id, event)
1134            .with_tenant("tenant-@#$%^&*()")
1135            .with_correlation("corr-\n\t\r");
1136
1137        let json = serde_json::to_string(&env).unwrap();
1138        let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1139
1140        assert_eq!(restored.event_id, special_id);
1141        assert_eq!(restored.tenant_id.as_deref(), Some("tenant-@#$%^&*()"));
1142        assert_eq!(restored.correlation_id.as_deref(), Some("corr-\n\t\r"));
1143    }
1144
1145    #[test]
1146    fn envelope_edge_case_very_long_strings() {
1147        let long_id = "x".repeat(10_000);
1148        let event = ExperienceEvent::BudgetExceeded {
1149            chain_id: "c".into(),
1150            resource: BudgetResource::Cycles,
1151            limit: "1".to_string(),
1152            observed: None,
1153        };
1154        let env = ExperienceEventEnvelope::new(long_id.clone(), event)
1155            .with_tenant("y".repeat(5_000))
1156            .with_correlation("z".repeat(3_000));
1157
1158        let json = serde_json::to_string(&env).unwrap();
1159        let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1160
1161        assert_eq!(restored.event_id, long_id);
1162        assert_eq!(restored.tenant_id.as_ref().map(|s| s.len()), Some(5_000));
1163        assert_eq!(
1164            restored.correlation_id.as_ref().map(|s| s.len()),
1165            Some(3_000)
1166        );
1167    }
1168
1169    #[test]
1170    fn envelope_edge_case_unicode_in_ids() {
1171        let event = ExperienceEvent::BudgetExceeded {
1172            chain_id: "c".into(),
1173            resource: BudgetResource::Cycles,
1174            limit: "1".to_string(),
1175            observed: None,
1176        };
1177        let env = ExperienceEventEnvelope::new("evt-中文-العربية-русский", event)
1178            .with_tenant("テナント-यन्त्र")
1179            .with_correlation("相関-συσχέτιση");
1180
1181        let json = serde_json::to_string(&env).unwrap();
1182        let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1183
1184        assert_eq!(restored.event_id, "evt-中文-العربية-русский");
1185        assert_eq!(restored.tenant_id.as_deref(), Some("テナント-यन्त्र"));
1186        assert_eq!(restored.correlation_id.as_deref(), Some("相関-συσχέτιση"));
1187    }
1188
1189    // ── Malformed JSON / deserialization errors ──────────────────────────────
1190
1191    #[test]
1192    fn envelope_unpack_missing_required_event_id() {
1193        let json = r#"{
1194            "occurred_at": "2026-01-01T00:00:00Z",
1195            "tenant_id": null,
1196            "correlation_id": null,
1197            "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}}
1198        }"#;
1199
1200        let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1201        assert!(result.is_err());
1202    }
1203
1204    #[test]
1205    fn envelope_unpack_missing_required_occurred_at() {
1206        let json = r#"{
1207            "event_id": "evt-1",
1208            "tenant_id": null,
1209            "correlation_id": null,
1210            "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}}
1211        }"#;
1212
1213        let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1214        assert!(result.is_err());
1215    }
1216
1217    #[test]
1218    fn envelope_unpack_missing_required_event() {
1219        let json = r#"{
1220            "event_id": "evt-1",
1221            "occurred_at": "2026-01-01T00:00:00Z",
1222            "tenant_id": null,
1223            "correlation_id": null
1224        }"#;
1225
1226        let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1227        assert!(result.is_err());
1228    }
1229
1230    #[test]
1231    fn envelope_unpack_wrong_type_for_field() {
1232        let json = r#"{
1233            "event_id": 12345,
1234            "occurred_at": "2026-01-01T00:00:00Z",
1235            "tenant_id": null,
1236            "correlation_id": null,
1237            "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}}
1238        }"#;
1239
1240        let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1241        assert!(result.is_err());
1242    }
1243
1244    #[test]
1245    fn envelope_unpack_invalid_json_syntax() {
1246        let invalid = r#"{"event_id": "evt-1", "occurred_at": "2026-01-01"#;
1247        let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(invalid);
1248        assert!(result.is_err());
1249    }
1250
1251    #[test]
1252    fn envelope_unpack_extra_unknown_fields_ignored() {
1253        let json = r#"{
1254            "event_id": "evt-1",
1255            "occurred_at": "2026-01-01T00:00:00Z",
1256            "tenant_id": null,
1257            "correlation_id": null,
1258            "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}},
1259            "unknown_field": "ignored",
1260            "another_extra": 42
1261        }"#;
1262
1263        let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1264        // Extra fields are ignored by default serde behavior
1265        assert!(result.is_ok());
1266        assert_eq!(result.unwrap().event_id, "evt-1");
1267    }
1268
1269    #[test]
1270    fn envelope_unpack_null_for_event_id_invalid() {
1271        let json = r#"{
1272            "event_id": null,
1273            "occurred_at": "2026-01-01T00:00:00Z",
1274            "tenant_id": null,
1275            "correlation_id": null,
1276            "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}}
1277        }"#;
1278
1279        let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1280        assert!(result.is_err());
1281    }
1282
1283    // ── Builder chaining and override behavior ────────────────────────────────
1284
1285    #[test]
1286    fn envelope_builder_chaining_consistency() {
1287        let event = ExperienceEvent::BudgetExceeded {
1288            chain_id: "c".into(),
1289            resource: BudgetResource::Cycles,
1290            limit: "1".to_string(),
1291            observed: None,
1292        };
1293        let env = ExperienceEventEnvelope::new("evt-1", event)
1294            .with_tenant("t1")
1295            .with_tenant("t2")
1296            .with_correlation("corr1")
1297            .with_correlation("corr2")
1298            .with_timestamp("2026-01-01T00:00:00Z")
1299            .with_timestamp("2026-12-31T23:59:59Z");
1300
1301        assert_eq!(env.tenant_id.as_deref(), Some("t2"));
1302        assert_eq!(env.correlation_id.as_deref(), Some("corr2"));
1303        assert_eq!(env.occurred_at, "2026-12-31T23:59:59Z");
1304    }
1305
1306    #[test]
1307    fn envelope_builder_override_to_last_value() {
1308        let event = ExperienceEvent::BudgetExceeded {
1309            chain_id: "c".into(),
1310            resource: BudgetResource::Cycles,
1311            limit: "1".to_string(),
1312            observed: None,
1313        };
1314        let final_tenant = "final-tenant";
1315        let final_corr = "final-corr";
1316        let final_ts = "2099-01-01T00:00:00Z";
1317
1318        let env = ExperienceEventEnvelope::new("evt", event)
1319            .with_tenant("ignored1")
1320            .with_tenant("ignored2")
1321            .with_tenant(final_tenant)
1322            .with_correlation("ignored1")
1323            .with_correlation(final_corr)
1324            .with_timestamp("ignored1")
1325            .with_timestamp(final_ts);
1326
1327        assert_eq!(env.tenant_id.as_deref(), Some(final_tenant));
1328        assert_eq!(env.correlation_id.as_deref(), Some(final_corr));
1329        assert_eq!(env.occurred_at, final_ts);
1330    }
1331}