use serde::{Deserialize, Serialize};
use crate::governed_artifact::{GovernedArtifactState, LifecycleEvent, RollbackRecord};
use crate::kernel_boundary::{
DecisionStep, KernelPolicy, KernelProposal, Replayability, ReplayabilityDowngradeReason,
RoutingPolicy, TraceLink,
};
use crate::recall::{RecallPolicy, RecallProvenanceEnvelope, RecallQuery};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExperienceEventEnvelope {
pub event_id: String,
pub occurred_at: String,
pub tenant_id: Option<String>,
pub correlation_id: Option<String>,
pub event: ExperienceEvent,
}
impl ExperienceEventEnvelope {
#[must_use]
pub fn new(event_id: impl Into<String>, event: ExperienceEvent) -> Self {
Self {
event_id: event_id.into(),
occurred_at: Self::now_iso8601(),
tenant_id: None,
correlation_id: None,
event,
}
}
#[must_use]
pub fn with_tenant(mut self, tenant_id: impl Into<String>) -> Self {
self.tenant_id = Some(tenant_id.into());
self
}
#[must_use]
pub fn with_correlation(mut self, correlation_id: impl Into<String>) -> Self {
self.correlation_id = Some(correlation_id.into());
self
}
#[must_use]
pub fn with_timestamp(mut self, occurred_at: impl Into<String>) -> Self {
self.occurred_at = occurred_at.into();
self
}
fn now_iso8601() -> String {
"1970-01-01T00:00:00Z".to_string()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ExperienceEventKind {
ProposalCreated,
ProposalValidated,
FactPromoted,
RecallExecuted,
TraceLinkRecorded,
ReplayabilityDowngraded,
ArtifactStateTransitioned,
ArtifactRollbackRecorded,
BackendInvoked,
OutcomeRecorded,
BudgetExceeded,
PolicySnapshotCaptured,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum ExperienceEvent {
ProposalCreated {
proposal: KernelProposal,
chain_id: String,
step: DecisionStep,
policy_snapshot_hash: Option<String>,
},
ProposalValidated {
proposal_id: String,
chain_id: String,
step: DecisionStep,
contract_results: Vec<ContractResultSnapshot>,
all_passed: bool,
validator: String,
},
FactPromoted {
proposal_id: String,
fact_id: String,
promoted_by: String,
reason: String,
requires_human: bool,
},
RecallExecuted {
query: RecallQuery,
provenance: RecallProvenanceEnvelope,
trace_link_id: Option<String>,
},
TraceLinkRecorded {
trace_link_id: String,
trace_link: TraceLink,
},
ReplayabilityDowngraded {
trace_link_id: String,
from: Replayability,
to: Replayability,
reason: ReplayabilityDowngradeReason,
},
ArtifactStateTransitioned {
artifact_id: String,
artifact_kind: ArtifactKind,
event: LifecycleEvent,
},
ArtifactRollbackRecorded { rollback: RollbackRecord },
BackendInvoked {
backend_name: String,
adapter_id: Option<String>,
trace_link_id: String,
step: DecisionStep,
policy_snapshot_hash: Option<String>,
},
OutcomeRecorded {
chain_id: String,
step: DecisionStep,
passed: bool,
stop_reason: Option<String>,
latency_ms: Option<u64>,
tokens: Option<u64>,
cost_microdollars: Option<u64>,
backend: Option<String>,
},
BudgetExceeded {
chain_id: String,
resource: String,
limit: String,
observed: Option<String>,
},
PolicySnapshotCaptured {
policy_id: String,
policy: PolicySnapshot,
snapshot_hash: String,
captured_by: String,
},
}
impl ExperienceEvent {
#[must_use]
pub fn kind(&self) -> ExperienceEventKind {
match self {
Self::ProposalCreated { .. } => ExperienceEventKind::ProposalCreated,
Self::ProposalValidated { .. } => ExperienceEventKind::ProposalValidated,
Self::FactPromoted { .. } => ExperienceEventKind::FactPromoted,
Self::RecallExecuted { .. } => ExperienceEventKind::RecallExecuted,
Self::TraceLinkRecorded { .. } => ExperienceEventKind::TraceLinkRecorded,
Self::ReplayabilityDowngraded { .. } => ExperienceEventKind::ReplayabilityDowngraded,
Self::ArtifactStateTransitioned { .. } => {
ExperienceEventKind::ArtifactStateTransitioned
}
Self::ArtifactRollbackRecorded { .. } => ExperienceEventKind::ArtifactRollbackRecorded,
Self::BackendInvoked { .. } => ExperienceEventKind::BackendInvoked,
Self::OutcomeRecorded { .. } => ExperienceEventKind::OutcomeRecorded,
Self::BudgetExceeded { .. } => ExperienceEventKind::BudgetExceeded,
Self::PolicySnapshotCaptured { .. } => ExperienceEventKind::PolicySnapshotCaptured,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractResultSnapshot {
pub name: String,
pub passed: bool,
pub failure_reason: Option<String>,
}
impl From<crate::kernel_boundary::ContractResult> for ContractResultSnapshot {
fn from(result: crate::kernel_boundary::ContractResult) -> Self {
Self {
name: result.name,
passed: result.passed,
failure_reason: result.failure_reason,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ArtifactKind {
Adapter,
Pack,
Policy,
TruthFile,
EvalSuite,
Other(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "policy")]
pub enum PolicySnapshot {
Kernel(KernelPolicy),
Routing(RoutingPolicy),
Recall(RecallPolicy),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EventQuery {
pub tenant_id: Option<String>,
pub time_range: Option<TimeRange>,
pub kinds: Vec<ExperienceEventKind>,
pub correlation_id: Option<String>,
pub chain_id: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ArtifactQuery {
pub tenant_id: Option<String>,
pub artifact_id: Option<String>,
pub kind: Option<ArtifactKind>,
pub state: Option<GovernedArtifactState>,
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeRange {
pub start: Option<String>,
pub end: Option<String>,
}
#[deprecated(
since = "0.2.0",
note = "Use converge_core::traits::{ExperienceAppender, ExperienceReplayer} instead. See BOUNDARY.md for migration."
)]
pub trait ExperienceStore: Send + Sync {
fn append_event(&self, event: ExperienceEventEnvelope) -> ExperienceStoreResult<()>;
fn append_events(&self, events: &[ExperienceEventEnvelope]) -> ExperienceStoreResult<()> {
for event in events {
self.append_event(event.clone())?;
}
Ok(())
}
fn query_events(
&self,
query: &EventQuery,
) -> ExperienceStoreResult<Vec<ExperienceEventEnvelope>>;
fn write_artifact_state_transition(
&self,
artifact_id: &str,
artifact_kind: ArtifactKind,
event: LifecycleEvent,
) -> ExperienceStoreResult<()>;
fn get_trace_link(&self, trace_link_id: &str) -> ExperienceStoreResult<Option<TraceLink>>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExperienceStoreError {
StorageError { message: String },
InvalidQuery { message: String },
NotFound { message: String },
}
impl std::fmt::Display for ExperienceStoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::StorageError { message } => write!(f, "Storage error: {}", message),
Self::InvalidQuery { message } => write!(f, "Invalid query: {}", message),
Self::NotFound { message } => write!(f, "Not found: {}", message),
}
}
}
impl std::error::Error for ExperienceStoreError {}
pub type ExperienceStoreResult<T> = Result<T, ExperienceStoreError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_kind_mapping() {
let event = ExperienceEvent::BudgetExceeded {
chain_id: "chain-1".to_string(),
resource: "tokens".to_string(),
limit: "1024".to_string(),
observed: Some("2048".to_string()),
};
assert_eq!(event.kind(), ExperienceEventKind::BudgetExceeded);
}
#[test]
fn envelope_builder_sets_fields() {
let event = ExperienceEvent::OutcomeRecorded {
chain_id: "chain-1".to_string(),
step: DecisionStep::Planning,
passed: true,
stop_reason: None,
latency_ms: Some(12),
tokens: Some(42),
cost_microdollars: None,
backend: Some("local".to_string()),
};
let envelope = ExperienceEventEnvelope::new("evt-1", event)
.with_tenant("tenant-a")
.with_correlation("corr-1")
.with_timestamp("2026-01-21T12:00:00Z");
assert_eq!(envelope.event_id, "evt-1");
assert_eq!(envelope.tenant_id.as_deref(), Some("tenant-a"));
assert_eq!(envelope.correlation_id.as_deref(), Some("corr-1"));
assert_eq!(envelope.occurred_at, "2026-01-21T12:00:00Z");
}
}