1use serde::{Deserialize, Serialize};
31
32use crate::governed_artifact::{GovernedArtifactState, LifecycleEvent, RollbackRecord};
33use crate::kernel_boundary::{
34 DecisionStep, KernelPolicy, KernelProposal, Replayability, ReplayabilityDowngradeReason,
35 RoutingPolicy, TraceLink,
36};
37use crate::recall::{RecallPolicy, RecallProvenanceEnvelope, RecallQuery};
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ExperienceEventEnvelope {
50 pub event_id: String,
52 pub occurred_at: String,
54 pub tenant_id: Option<String>,
56 pub correlation_id: Option<String>,
58 pub event: ExperienceEvent,
60}
61
62impl ExperienceEventEnvelope {
63 #[must_use]
67 pub fn new(event_id: impl Into<String>, event: ExperienceEvent) -> Self {
68 Self {
69 event_id: event_id.into(),
70 occurred_at: Self::now_iso8601(),
71 tenant_id: None,
72 correlation_id: None,
73 event,
74 }
75 }
76
77 #[must_use]
79 pub fn with_tenant(mut self, tenant_id: impl Into<String>) -> Self {
80 self.tenant_id = Some(tenant_id.into());
81 self
82 }
83
84 #[must_use]
86 pub fn with_correlation(mut self, correlation_id: impl Into<String>) -> Self {
87 self.correlation_id = Some(correlation_id.into());
88 self
89 }
90
91 #[must_use]
93 pub fn with_timestamp(mut self, occurred_at: impl Into<String>) -> Self {
94 self.occurred_at = occurred_at.into();
95 self
96 }
97
98 fn now_iso8601() -> String {
103 "1970-01-01T00:00:00Z".to_string()
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
113pub enum ExperienceEventKind {
114 ProposalCreated,
115 ProposalValidated,
116 FactPromoted,
117 RecallExecuted,
118 TraceLinkRecorded,
119 ReplayabilityDowngraded,
120 ArtifactStateTransitioned,
121 ArtifactRollbackRecorded,
122 BackendInvoked,
123 OutcomeRecorded,
124 BudgetExceeded,
125 PolicySnapshotCaptured,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(tag = "type", content = "data")]
131pub enum ExperienceEvent {
132 ProposalCreated {
134 proposal: KernelProposal,
135 chain_id: String,
136 step: DecisionStep,
137 policy_snapshot_hash: Option<String>,
138 },
139 ProposalValidated {
141 proposal_id: String,
142 chain_id: String,
143 step: DecisionStep,
144 contract_results: Vec<ContractResultSnapshot>,
145 all_passed: bool,
146 validator: String,
147 },
148 FactPromoted {
150 proposal_id: String,
151 fact_id: String,
152 promoted_by: String,
153 reason: String,
154 requires_human: bool,
155 },
156 RecallExecuted {
158 query: RecallQuery,
159 provenance: RecallProvenanceEnvelope,
160 trace_link_id: Option<String>,
161 },
162 TraceLinkRecorded {
164 trace_link_id: String,
165 trace_link: TraceLink,
166 },
167 ReplayabilityDowngraded {
169 trace_link_id: String,
170 from: Replayability,
171 to: Replayability,
172 reason: ReplayabilityDowngradeReason,
173 },
174 ArtifactStateTransitioned {
176 artifact_id: String,
177 artifact_kind: ArtifactKind,
178 event: LifecycleEvent,
179 },
180 ArtifactRollbackRecorded {
182 rollback: RollbackRecord,
183 },
184 BackendInvoked {
186 backend_name: String,
187 adapter_id: Option<String>,
188 trace_link_id: String,
189 step: DecisionStep,
190 policy_snapshot_hash: Option<String>,
191 },
192 OutcomeRecorded {
194 chain_id: String,
195 step: DecisionStep,
196 passed: bool,
197 stop_reason: Option<String>,
198 latency_ms: Option<u64>,
199 tokens: Option<u64>,
200 cost_microdollars: Option<u64>,
201 backend: Option<String>,
202 },
203 BudgetExceeded {
205 chain_id: String,
206 resource: String,
207 limit: String,
208 observed: Option<String>,
209 },
210 PolicySnapshotCaptured {
212 policy_id: String,
213 policy: PolicySnapshot,
214 snapshot_hash: String,
215 captured_by: String,
216 },
217}
218
219impl ExperienceEvent {
220 #[must_use]
222 pub fn kind(&self) -> ExperienceEventKind {
223 match self {
224 Self::ProposalCreated { .. } => ExperienceEventKind::ProposalCreated,
225 Self::ProposalValidated { .. } => ExperienceEventKind::ProposalValidated,
226 Self::FactPromoted { .. } => ExperienceEventKind::FactPromoted,
227 Self::RecallExecuted { .. } => ExperienceEventKind::RecallExecuted,
228 Self::TraceLinkRecorded { .. } => ExperienceEventKind::TraceLinkRecorded,
229 Self::ReplayabilityDowngraded { .. } => ExperienceEventKind::ReplayabilityDowngraded,
230 Self::ArtifactStateTransitioned { .. } => {
231 ExperienceEventKind::ArtifactStateTransitioned
232 }
233 Self::ArtifactRollbackRecorded { .. } => {
234 ExperienceEventKind::ArtifactRollbackRecorded
235 }
236 Self::BackendInvoked { .. } => ExperienceEventKind::BackendInvoked,
237 Self::OutcomeRecorded { .. } => ExperienceEventKind::OutcomeRecorded,
238 Self::BudgetExceeded { .. } => ExperienceEventKind::BudgetExceeded,
239 Self::PolicySnapshotCaptured { .. } => ExperienceEventKind::PolicySnapshotCaptured,
240 }
241 }
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ContractResultSnapshot {
251 pub name: String,
252 pub passed: bool,
253 pub failure_reason: Option<String>,
254}
255
256impl From<crate::kernel_boundary::ContractResult> for ContractResultSnapshot {
257 fn from(result: crate::kernel_boundary::ContractResult) -> Self {
258 Self {
259 name: result.name,
260 passed: result.passed,
261 failure_reason: result.failure_reason,
262 }
263 }
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
268pub enum ArtifactKind {
269 Adapter,
270 Pack,
271 Policy,
272 TruthFile,
273 EvalSuite,
274 Other(String),
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
279#[serde(tag = "type", content = "policy")]
280pub enum PolicySnapshot {
281 Kernel(KernelPolicy),
282 Routing(RoutingPolicy),
283 Recall(RecallPolicy),
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize, Default)]
288pub struct EventQuery {
289 pub tenant_id: Option<String>,
290 pub time_range: Option<TimeRange>,
291 pub kinds: Vec<ExperienceEventKind>,
292 pub correlation_id: Option<String>,
293 pub chain_id: Option<String>,
294 pub limit: Option<usize>,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize, Default)]
299pub struct ArtifactQuery {
300 pub tenant_id: Option<String>,
301 pub artifact_id: Option<String>,
302 pub kind: Option<ArtifactKind>,
303 pub state: Option<GovernedArtifactState>,
304 pub limit: Option<usize>,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct TimeRange {
310 pub start: Option<String>,
311 pub end: Option<String>,
312}
313
314#[deprecated(
332 since = "0.2.0",
333 note = "Use converge_core::traits::{ExperienceAppender, ExperienceReplayer} instead. See BOUNDARY.md for migration."
334)]
335pub trait ExperienceStore: Send + Sync {
336 fn append_event(&self, event: ExperienceEventEnvelope) -> ExperienceStoreResult<()>;
338
339 fn append_events(&self, events: &[ExperienceEventEnvelope]) -> ExperienceStoreResult<()> {
341 for event in events {
342 self.append_event(event.clone())?;
343 }
344 Ok(())
345 }
346
347 fn query_events(
349 &self,
350 query: &EventQuery,
351 ) -> ExperienceStoreResult<Vec<ExperienceEventEnvelope>>;
352
353 fn write_artifact_state_transition(
355 &self,
356 artifact_id: &str,
357 artifact_kind: ArtifactKind,
358 event: LifecycleEvent,
359 ) -> ExperienceStoreResult<()>;
360
361 fn get_trace_link(&self, trace_link_id: &str)
363 -> ExperienceStoreResult<Option<TraceLink>>;
364}
365
366#[derive(Debug, Clone, PartialEq, Eq)]
368pub enum ExperienceStoreError {
369 StorageError { message: String },
371 InvalidQuery { message: String },
373 NotFound { message: String },
375}
376
377impl std::fmt::Display for ExperienceStoreError {
378 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379 match self {
380 Self::StorageError { message } => write!(f, "Storage error: {}", message),
381 Self::InvalidQuery { message } => write!(f, "Invalid query: {}", message),
382 Self::NotFound { message } => write!(f, "Not found: {}", message),
383 }
384 }
385}
386
387impl std::error::Error for ExperienceStoreError {}
388
389pub type ExperienceStoreResult<T> = Result<T, ExperienceStoreError>;
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn event_kind_mapping() {
398 let event = ExperienceEvent::BudgetExceeded {
399 chain_id: "chain-1".to_string(),
400 resource: "tokens".to_string(),
401 limit: "1024".to_string(),
402 observed: Some("2048".to_string()),
403 };
404 assert_eq!(event.kind(), ExperienceEventKind::BudgetExceeded);
405 }
406
407 #[test]
408 fn envelope_builder_sets_fields() {
409 let event = ExperienceEvent::OutcomeRecorded {
410 chain_id: "chain-1".to_string(),
411 step: DecisionStep::Planning,
412 passed: true,
413 stop_reason: None,
414 latency_ms: Some(12),
415 tokens: Some(42),
416 cost_microdollars: None,
417 backend: Some("local".to_string()),
418 };
419 let envelope = ExperienceEventEnvelope::new("evt-1", event)
420 .with_tenant("tenant-a")
421 .with_correlation("corr-1")
422 .with_timestamp("2026-01-21T12:00:00Z");
423
424 assert_eq!(envelope.event_id, "evt-1");
425 assert_eq!(envelope.tenant_id.as_deref(), Some("tenant-a"));
426 assert_eq!(envelope.correlation_id.as_deref(), Some("corr-1"));
427 assert_eq!(envelope.occurred_at, "2026-01-21T12:00:00Z");
428 }
429}