1use serde::{Deserialize, Serialize};
28
29use crate::governed_artifact::{GovernedArtifactState, LifecycleEvent, RollbackRecord};
30use crate::kernel_boundary::{
31 DecisionStep, KernelPolicy, KernelProposal, ReplayTrace, Replayability,
32 ReplayabilityDowngradeReason, RoutingPolicy,
33};
34use crate::recall::{RecallPolicy, RecallProvenanceEnvelope, RecallQuery};
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ExperienceEventEnvelope {
47 pub event_id: String,
49 pub occurred_at: String,
51 pub tenant_id: Option<String>,
53 pub correlation_id: Option<String>,
55 pub event: ExperienceEvent,
57}
58
59impl ExperienceEventEnvelope {
60 #[must_use]
64 pub fn new(event_id: impl Into<String>, event: ExperienceEvent) -> Self {
65 Self {
66 event_id: event_id.into(),
67 occurred_at: Self::now_iso8601(),
68 tenant_id: None,
69 correlation_id: None,
70 event,
71 }
72 }
73
74 #[must_use]
76 pub fn with_tenant(mut self, tenant_id: impl Into<String>) -> Self {
77 self.tenant_id = Some(tenant_id.into());
78 self
79 }
80
81 #[must_use]
83 pub fn with_correlation(mut self, correlation_id: impl Into<String>) -> Self {
84 self.correlation_id = Some(correlation_id.into());
85 self
86 }
87
88 #[must_use]
90 pub fn with_timestamp(mut self, occurred_at: impl Into<String>) -> Self {
91 self.occurred_at = occurred_at.into();
92 self
93 }
94
95 fn now_iso8601() -> String {
100 "1970-01-01T00:00:00Z".to_string()
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
110pub enum ExperienceEventKind {
111 ProposalCreated,
112 ProposalValidated,
113 FactPromoted,
114 RecallExecuted,
115 ReplayTraceRecorded,
116 ReplayabilityDowngraded,
117 ArtifactStateTransitioned,
118 ArtifactRollbackRecorded,
119 BackendInvoked,
120 OutcomeRecorded,
121 BudgetExceeded,
122 PolicySnapshotCaptured,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127#[serde(tag = "type", content = "data")]
128pub enum ExperienceEvent {
129 ProposalCreated {
131 proposal: KernelProposal,
132 chain_id: String,
133 step: DecisionStep,
134 policy_snapshot_hash: Option<String>,
135 },
136 ProposalValidated {
138 proposal_id: String,
139 chain_id: String,
140 step: DecisionStep,
141 contract_results: Vec<ContractResultSnapshot>,
142 all_passed: bool,
143 validator: String,
144 },
145 FactPromoted {
147 proposal_id: String,
148 fact_id: String,
149 promoted_by: String,
150 reason: String,
151 requires_human: bool,
152 },
153 RecallExecuted {
155 query: RecallQuery,
156 provenance: RecallProvenanceEnvelope,
157 trace_link_id: Option<String>,
158 },
159 ReplayTraceRecorded {
161 trace_link_id: String,
162 trace_link: ReplayTrace,
163 },
164 ReplayabilityDowngraded {
166 trace_link_id: String,
167 from: Replayability,
168 to: Replayability,
169 reason: ReplayabilityDowngradeReason,
170 },
171 ArtifactStateTransitioned {
173 artifact_id: String,
174 artifact_kind: ArtifactKind,
175 event: LifecycleEvent,
176 },
177 ArtifactRollbackRecorded { rollback: RollbackRecord },
179 BackendInvoked {
181 backend_name: String,
182 adapter_id: Option<String>,
183 trace_link_id: String,
184 step: DecisionStep,
185 policy_snapshot_hash: Option<String>,
186 },
187 OutcomeRecorded {
189 chain_id: String,
190 step: DecisionStep,
191 passed: bool,
192 stop_reason: Option<String>,
193 latency_ms: Option<u64>,
194 tokens: Option<u64>,
195 cost_microdollars: Option<u64>,
196 backend: Option<String>,
197 },
198 BudgetExceeded {
200 chain_id: String,
201 resource: String,
202 limit: String,
203 observed: Option<String>,
204 },
205 PolicySnapshotCaptured {
207 policy_id: String,
208 policy: PolicySnapshot,
209 snapshot_hash: String,
210 captured_by: String,
211 },
212}
213
214impl ExperienceEvent {
215 #[must_use]
217 pub fn kind(&self) -> ExperienceEventKind {
218 match self {
219 Self::ProposalCreated { .. } => ExperienceEventKind::ProposalCreated,
220 Self::ProposalValidated { .. } => ExperienceEventKind::ProposalValidated,
221 Self::FactPromoted { .. } => ExperienceEventKind::FactPromoted,
222 Self::RecallExecuted { .. } => ExperienceEventKind::RecallExecuted,
223 Self::ReplayTraceRecorded { .. } => ExperienceEventKind::ReplayTraceRecorded,
224 Self::ReplayabilityDowngraded { .. } => ExperienceEventKind::ReplayabilityDowngraded,
225 Self::ArtifactStateTransitioned { .. } => {
226 ExperienceEventKind::ArtifactStateTransitioned
227 }
228 Self::ArtifactRollbackRecorded { .. } => ExperienceEventKind::ArtifactRollbackRecorded,
229 Self::BackendInvoked { .. } => ExperienceEventKind::BackendInvoked,
230 Self::OutcomeRecorded { .. } => ExperienceEventKind::OutcomeRecorded,
231 Self::BudgetExceeded { .. } => ExperienceEventKind::BudgetExceeded,
232 Self::PolicySnapshotCaptured { .. } => ExperienceEventKind::PolicySnapshotCaptured,
233 }
234 }
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct ContractResultSnapshot {
244 pub name: String,
245 pub passed: bool,
246 pub failure_reason: Option<String>,
247}
248
249impl From<crate::kernel_boundary::ContractResult> for ContractResultSnapshot {
250 fn from(result: crate::kernel_boundary::ContractResult) -> Self {
251 Self {
252 name: result.name,
253 passed: result.passed,
254 failure_reason: result.failure_reason,
255 }
256 }
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
261pub enum ArtifactKind {
262 Adapter,
263 Pack,
264 Policy,
265 TruthFile,
266 EvalSuite,
267 Other(String),
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(tag = "type", content = "policy")]
273pub enum PolicySnapshot {
274 Kernel(KernelPolicy),
275 Routing(RoutingPolicy),
276 Recall(RecallPolicy),
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize, Default)]
281pub struct EventQuery {
282 pub tenant_id: Option<String>,
283 pub time_range: Option<TimeRange>,
284 pub kinds: Vec<ExperienceEventKind>,
285 pub correlation_id: Option<String>,
286 pub chain_id: Option<String>,
287 pub limit: Option<usize>,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize, Default)]
292pub struct ArtifactQuery {
293 pub tenant_id: Option<String>,
294 pub artifact_id: Option<String>,
295 pub kind: Option<ArtifactKind>,
296 pub state: Option<GovernedArtifactState>,
297 pub limit: Option<usize>,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct TimeRange {
303 pub start: Option<String>,
304 pub end: Option<String>,
305}
306
307pub trait ExperienceStore: Send + Sync {
320 fn append_event(&self, event: ExperienceEventEnvelope) -> ExperienceStoreResult<()>;
322
323 fn append_events(&self, events: &[ExperienceEventEnvelope]) -> ExperienceStoreResult<()> {
325 for event in events {
326 self.append_event(event.clone())?;
327 }
328 Ok(())
329 }
330
331 fn query_events(
333 &self,
334 query: &EventQuery,
335 ) -> ExperienceStoreResult<Vec<ExperienceEventEnvelope>>;
336
337 fn write_artifact_state_transition(
339 &self,
340 artifact_id: &str,
341 artifact_kind: ArtifactKind,
342 event: LifecycleEvent,
343 ) -> ExperienceStoreResult<()>;
344
345 fn get_trace_link(&self, trace_link_id: &str) -> ExperienceStoreResult<Option<ReplayTrace>>;
347}
348
349#[derive(Debug, Clone, PartialEq, Eq)]
351pub enum ExperienceStoreError {
352 StorageError { message: String },
354 InvalidQuery { message: String },
356 NotFound { message: String },
358}
359
360impl std::fmt::Display for ExperienceStoreError {
361 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362 match self {
363 Self::StorageError { message } => write!(f, "Storage error: {}", message),
364 Self::InvalidQuery { message } => write!(f, "Invalid query: {}", message),
365 Self::NotFound { message } => write!(f, "Not found: {}", message),
366 }
367 }
368}
369
370impl std::error::Error for ExperienceStoreError {}
371
372pub type ExperienceStoreResult<T> = Result<T, ExperienceStoreError>;
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn event_kind_mapping() {
381 let event = ExperienceEvent::BudgetExceeded {
382 chain_id: "chain-1".to_string(),
383 resource: "tokens".to_string(),
384 limit: "1024".to_string(),
385 observed: Some("2048".to_string()),
386 };
387 assert_eq!(event.kind(), ExperienceEventKind::BudgetExceeded);
388 }
389
390 #[test]
391 fn envelope_builder_sets_fields() {
392 let event = ExperienceEvent::OutcomeRecorded {
393 chain_id: "chain-1".to_string(),
394 step: DecisionStep::Planning,
395 passed: true,
396 stop_reason: None,
397 latency_ms: Some(12),
398 tokens: Some(42),
399 cost_microdollars: None,
400 backend: Some("local".to_string()),
401 };
402 let envelope = ExperienceEventEnvelope::new("evt-1", event)
403 .with_tenant("tenant-a")
404 .with_correlation("corr-1")
405 .with_timestamp("2026-01-21T12:00:00Z");
406
407 assert_eq!(envelope.event_id, "evt-1");
408 assert_eq!(envelope.tenant_id.as_deref(), Some("tenant-a"));
409 assert_eq!(envelope.correlation_id.as_deref(), Some("corr-1"));
410 assert_eq!(envelope.occurred_at, "2026-01-21T12:00:00Z");
411 }
412}