1use serde::{Deserialize, Serialize};
28
29use crate::StopReason as EngineStopReason;
30use crate::governed_artifact::{GovernedArtifactState, LifecycleEvent, RollbackRecord};
31use crate::kernel_boundary::{
32 DecisionStep, KernelPolicy, KernelProposal, ReplayTrace, Replayability,
33 ReplayabilityDowngradeReason, RoutingPolicy,
34};
35use crate::recall::{RecallPolicy, RecallProvenanceEnvelope, RecallQuery};
36use crate::types::{
37 ActorId, ArtifactId, BackendId, ChainId, ContentHash, CorrelationId, DomainId, EventId, FactId,
38 PolicyId, ProposalId, TenantId, TensionId, Timestamp, TraceLinkId,
39};
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ExperienceEventEnvelope {
52 pub event_id: EventId,
54 pub occurred_at: Timestamp,
56 pub tenant_id: Option<TenantId>,
58 pub correlation_id: Option<CorrelationId>,
60 pub event: ExperienceEvent,
62}
63
64impl ExperienceEventEnvelope {
65 #[must_use]
69 pub fn new(event_id: impl Into<EventId>, event: ExperienceEvent) -> Self {
70 Self {
71 event_id: event_id.into(),
72 occurred_at: Self::now_iso8601(),
73 tenant_id: None,
74 correlation_id: None,
75 event,
76 }
77 }
78
79 #[must_use]
81 pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
82 self.tenant_id = Some(tenant_id.into());
83 self
84 }
85
86 #[must_use]
88 pub fn with_correlation(mut self, correlation_id: impl Into<CorrelationId>) -> Self {
89 self.correlation_id = Some(correlation_id.into());
90 self
91 }
92
93 #[must_use]
95 pub fn with_timestamp(mut self, occurred_at: impl Into<Timestamp>) -> Self {
96 self.occurred_at = occurred_at.into();
97 self
98 }
99
100 fn now_iso8601() -> Timestamp {
105 Timestamp::epoch()
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
115pub enum ExperienceEventKind {
116 ProposalCreated,
117 ProposalValidated,
118 FactPromoted,
119 RecallExecuted,
120 ReplayTraceRecorded,
121 ReplayabilityDowngraded,
122 ArtifactStateTransitioned,
123 ArtifactRollbackRecorded,
124 BackendInvoked,
125 OutcomeRecorded,
126 BudgetExceeded,
127 PolicySnapshotCaptured,
128 HypothesisResolved,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(tag = "type", content = "data")]
134pub enum ExperienceEvent {
135 ProposalCreated {
137 proposal: KernelProposal,
138 chain_id: ChainId,
139 step: DecisionStep,
140 policy_snapshot_hash: Option<ContentHash>,
141 },
142 ProposalValidated {
144 proposal_id: ProposalId,
145 chain_id: ChainId,
146 step: DecisionStep,
147 contract_results: Vec<ContractResultSnapshot>,
148 all_passed: bool,
149 validator: ActorId,
150 },
151 FactPromoted {
153 proposal_id: ProposalId,
154 fact_id: FactId,
155 promoted_by: ActorId,
156 reason: String,
157 requires_human: bool,
158 },
159 RecallExecuted {
161 query: RecallQuery,
162 provenance: RecallProvenanceEnvelope,
163 trace_link_id: Option<TraceLinkId>,
164 },
165 ReplayTraceRecorded {
167 trace_link_id: TraceLinkId,
168 trace_link: ReplayTrace,
169 },
170 ReplayabilityDowngraded {
172 trace_link_id: TraceLinkId,
173 from: Replayability,
174 to: Replayability,
175 reason: ReplayabilityDowngradeReason,
176 },
177 ArtifactStateTransitioned {
179 artifact_id: ArtifactId,
180 artifact_kind: ArtifactKind,
181 event: LifecycleEvent,
182 },
183 ArtifactRollbackRecorded { rollback: RollbackRecord },
185 BackendInvoked {
187 backend_name: BackendId,
188 adapter_id: Option<BackendId>,
189 trace_link_id: TraceLinkId,
190 step: DecisionStep,
191 policy_snapshot_hash: Option<ContentHash>,
192 },
193 OutcomeRecorded {
195 chain_id: ChainId,
196 step: DecisionStep,
197 passed: bool,
198 stop_reason: Option<EngineStopReason>,
199 latency_ms: Option<u64>,
200 tokens: Option<u64>,
201 cost_microdollars: Option<u64>,
202 backend: Option<BackendId>,
203 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
205 metadata: std::collections::HashMap<String, String>,
206 },
207 BudgetExceeded {
209 chain_id: ChainId,
210 resource: BudgetResource,
211 limit: String,
212 observed: Option<String>,
213 },
214 PolicySnapshotCaptured {
216 policy_id: PolicyId,
217 policy: PolicySnapshot,
218 snapshot_hash: ContentHash,
219 captured_by: ActorId,
220 },
221 HypothesisResolved {
223 chain_id: ChainId,
224 fact_id: FactId,
225 domain: DomainId,
226 claim: String,
227 confidence: f64,
228 outcome: HypothesisOutcome,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 contradiction_id: Option<TensionId>,
231 formed_cycle: u32,
232 resolved_cycle: u32,
233 },
234}
235
236impl ExperienceEvent {
237 #[must_use]
239 pub fn kind(&self) -> ExperienceEventKind {
240 match self {
241 Self::ProposalCreated { .. } => ExperienceEventKind::ProposalCreated,
242 Self::ProposalValidated { .. } => ExperienceEventKind::ProposalValidated,
243 Self::FactPromoted { .. } => ExperienceEventKind::FactPromoted,
244 Self::RecallExecuted { .. } => ExperienceEventKind::RecallExecuted,
245 Self::ReplayTraceRecorded { .. } => ExperienceEventKind::ReplayTraceRecorded,
246 Self::ReplayabilityDowngraded { .. } => ExperienceEventKind::ReplayabilityDowngraded,
247 Self::ArtifactStateTransitioned { .. } => {
248 ExperienceEventKind::ArtifactStateTransitioned
249 }
250 Self::ArtifactRollbackRecorded { .. } => ExperienceEventKind::ArtifactRollbackRecorded,
251 Self::BackendInvoked { .. } => ExperienceEventKind::BackendInvoked,
252 Self::OutcomeRecorded { .. } => ExperienceEventKind::OutcomeRecorded,
253 Self::BudgetExceeded { .. } => ExperienceEventKind::BudgetExceeded,
254 Self::PolicySnapshotCaptured { .. } => ExperienceEventKind::PolicySnapshotCaptured,
255 Self::HypothesisResolved { .. } => ExperienceEventKind::HypothesisResolved,
256 }
257 }
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct ContractResultSnapshot {
267 pub name: String,
268 pub passed: bool,
269 pub failure_reason: Option<String>,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274pub enum BudgetResource {
275 EngineBudget,
276 Tokens,
277 Facts,
278 Cycles,
279 Time,
280 Cost,
281 Other(String),
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
286pub enum HypothesisOutcome {
287 Confirmed,
288 Falsified,
289 Superseded,
290 Unresolved,
291}
292
293impl From<crate::kernel_boundary::ContractResult> for ContractResultSnapshot {
294 fn from(result: crate::kernel_boundary::ContractResult) -> Self {
295 Self {
296 name: result.name,
297 passed: result.passed,
298 failure_reason: result.failure_reason,
299 }
300 }
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
305pub enum ArtifactKind {
306 Adapter,
307 Pack,
308 Policy,
309 TruthFile,
310 EvalSuite,
311 Other(String),
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
316#[serde(tag = "type", content = "policy")]
317pub enum PolicySnapshot {
318 Kernel(KernelPolicy),
319 Routing(RoutingPolicy),
320 Recall(RecallPolicy),
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize, Default)]
325pub struct EventQuery {
326 pub tenant_id: Option<TenantId>,
327 pub time_range: Option<TimeRange>,
328 pub kinds: Vec<ExperienceEventKind>,
329 pub correlation_id: Option<CorrelationId>,
330 pub chain_id: Option<ChainId>,
331 pub limit: Option<usize>,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize, Default)]
336pub struct ArtifactQuery {
337 pub tenant_id: Option<TenantId>,
338 pub artifact_id: Option<ArtifactId>,
339 pub kind: Option<ArtifactKind>,
340 pub state: Option<GovernedArtifactState>,
341 pub limit: Option<usize>,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct TimeRange {
347 pub start: Option<Timestamp>,
348 pub end: Option<Timestamp>,
349}
350
351pub trait ExperienceStore: Send + Sync {
364 fn append_event(&self, event: ExperienceEventEnvelope) -> ExperienceStoreResult<()>;
366
367 fn append_events(&self, events: &[ExperienceEventEnvelope]) -> ExperienceStoreResult<()> {
369 for event in events {
370 self.append_event(event.clone())?;
371 }
372 Ok(())
373 }
374
375 fn query_events(
377 &self,
378 query: &EventQuery,
379 ) -> ExperienceStoreResult<Vec<ExperienceEventEnvelope>>;
380
381 fn write_artifact_state_transition(
383 &self,
384 artifact_id: &ArtifactId,
385 artifact_kind: ArtifactKind,
386 event: LifecycleEvent,
387 ) -> ExperienceStoreResult<()>;
388
389 fn get_trace_link(
391 &self,
392 trace_link_id: &TraceLinkId,
393 ) -> ExperienceStoreResult<Option<ReplayTrace>>;
394}
395
396#[derive(Debug, Clone, PartialEq, Eq)]
398pub enum ExperienceStoreError {
399 StorageError { message: String },
401 InvalidQuery { message: String },
403 NotFound { message: String },
405}
406
407impl std::fmt::Display for ExperienceStoreError {
408 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409 match self {
410 Self::StorageError { message } => write!(f, "Storage error: {}", message),
411 Self::InvalidQuery { message } => write!(f, "Invalid query: {}", message),
412 Self::NotFound { message } => write!(f, "Not found: {}", message),
413 }
414 }
415}
416
417impl std::error::Error for ExperienceStoreError {}
418
419pub type ExperienceStoreResult<T> = Result<T, ExperienceStoreError>;
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn event_kind_mapping() {
428 let event = ExperienceEvent::BudgetExceeded {
429 chain_id: "chain-1".into(),
430 resource: BudgetResource::Tokens,
431 limit: "1024".to_string(),
432 observed: Some("2048".to_string()),
433 };
434 assert_eq!(event.kind(), ExperienceEventKind::BudgetExceeded);
435 }
436
437 #[test]
438 fn envelope_builder_sets_fields() {
439 let event = ExperienceEvent::OutcomeRecorded {
440 chain_id: "chain-1".into(),
441 step: DecisionStep::Planning,
442 passed: true,
443 stop_reason: None,
444 latency_ms: Some(12),
445 tokens: Some(42),
446 cost_microdollars: None,
447 backend: Some("local".into()),
448 metadata: Default::default(),
449 };
450 let envelope = ExperienceEventEnvelope::new("evt-1", event)
451 .with_tenant("tenant-a")
452 .with_correlation("corr-1")
453 .with_timestamp("2026-01-21T12:00:00Z");
454
455 assert_eq!(envelope.event_id, "evt-1");
456 assert_eq!(envelope.tenant_id.as_deref(), Some("tenant-a"));
457 assert_eq!(envelope.correlation_id.as_deref(), Some("corr-1"));
458 assert_eq!(envelope.occurred_at, "2026-01-21T12:00:00Z");
459 }
460
461 #[test]
464 fn event_kind_proposal_created() {
465 let event = ExperienceEvent::ProposalCreated {
466 proposal: crate::kernel_boundary::KernelProposal {
467 id: "p-1".into(),
468 kind: crate::kernel_boundary::ProposalKind::Claims,
469 payload: "test".into(),
470 structured_payload: None,
471 trace_link: crate::kernel_boundary::ReplayTrace::Local(
472 crate::kernel_boundary::LocalReplayTrace {
473 base_model_hash: "abc".into(),
474 adapter: None,
475 tokenizer_hash: "tok".into(),
476 seed: 42,
477 sampler: crate::kernel_boundary::SamplerParams::default(),
478 prompt_version: "v1".into(),
479 recall: None,
480 weights_mutated: false,
481 execution_env: crate::kernel_boundary::ExecutionEnv::default(),
482 },
483 ),
484 contract_results: vec![crate::kernel_boundary::ContractResult::passed(
485 "grounded-answering",
486 )],
487 requires_human: false,
488 confidence: Some(0.9),
489 },
490 chain_id: "c-1".into(),
491 step: DecisionStep::Planning,
492 policy_snapshot_hash: None,
493 };
494 assert_eq!(event.kind(), ExperienceEventKind::ProposalCreated);
495 }
496
497 #[test]
498 fn event_kind_fact_promoted() {
499 let event = ExperienceEvent::FactPromoted {
500 proposal_id: "p-1".into(),
501 fact_id: "f-1".into(),
502 promoted_by: "engine".into(),
503 reason: "validated".into(),
504 requires_human: false,
505 };
506 assert_eq!(event.kind(), ExperienceEventKind::FactPromoted);
507 }
508
509 #[test]
510 fn event_kind_hypothesis_resolved() {
511 let event = ExperienceEvent::HypothesisResolved {
512 chain_id: "c-1".into(),
513 fact_id: "f-1".into(),
514 domain: "market".into(),
515 claim: "price will increase".into(),
516 confidence: 0.85,
517 outcome: HypothesisOutcome::Confirmed,
518 contradiction_id: None,
519 formed_cycle: 1,
520 resolved_cycle: 3,
521 };
522 assert_eq!(event.kind(), ExperienceEventKind::HypothesisResolved);
523 }
524
525 #[test]
526 fn event_kind_policy_snapshot_captured() {
527 let event = ExperienceEvent::PolicySnapshotCaptured {
528 policy_id: "pol-1".into(),
529 policy: PolicySnapshot::Routing(crate::kernel_boundary::RoutingPolicy::default()),
530 snapshot_hash: ContentHash::zero(),
531 captured_by: "engine".into(),
532 };
533 assert_eq!(event.kind(), ExperienceEventKind::PolicySnapshotCaptured);
534 }
535
536 #[test]
539 fn store_error_display_storage() {
540 let e = ExperienceStoreError::StorageError {
541 message: "disk full".into(),
542 };
543 assert!(e.to_string().contains("disk full"));
544 }
545
546 #[test]
547 fn store_error_display_invalid_query() {
548 let e = ExperienceStoreError::InvalidQuery {
549 message: "bad filter".into(),
550 };
551 assert!(e.to_string().contains("bad filter"));
552 }
553
554 #[test]
555 fn store_error_display_not_found() {
556 let e = ExperienceStoreError::NotFound {
557 message: "trace-99".into(),
558 };
559 assert!(e.to_string().contains("trace-99"));
560 }
561
562 #[test]
563 fn store_error_is_std_error() {
564 let e: Box<dyn std::error::Error> = Box::new(ExperienceStoreError::StorageError {
565 message: "test".into(),
566 });
567 assert!(!e.to_string().is_empty());
568 }
569
570 #[test]
573 fn artifact_kind_equality_named() {
574 assert_eq!(ArtifactKind::Adapter, ArtifactKind::Adapter);
575 assert_ne!(ArtifactKind::Pack, ArtifactKind::Policy);
576 }
577
578 #[test]
579 fn artifact_kind_other_variant() {
580 let a = ArtifactKind::Other("custom".into());
581 let b = ArtifactKind::Other("custom".into());
582 assert_eq!(a, b);
583 assert_ne!(
584 ArtifactKind::Other("x".into()),
585 ArtifactKind::Other("y".into())
586 );
587 }
588
589 #[test]
592 fn contract_result_snapshot_from_contract_result() {
593 let cr = crate::kernel_boundary::ContractResult {
594 name: "schema-check".into(),
595 passed: false,
596 failure_reason: Some("missing field".into()),
597 };
598 let snap: ContractResultSnapshot = cr.into();
599 assert_eq!(snap.name, "schema-check");
600 assert!(!snap.passed);
601 assert_eq!(snap.failure_reason.as_deref(), Some("missing field"));
602 }
603
604 #[test]
607 fn event_query_default_is_empty() {
608 let q = EventQuery::default();
609 assert!(q.tenant_id.is_none());
610 assert!(q.kinds.is_empty());
611 assert!(q.correlation_id.is_none());
612 assert!(q.chain_id.is_none());
613 assert!(q.limit.is_none());
614 }
615
616 #[test]
619 fn envelope_minimal_no_optional_fields() {
620 let event = ExperienceEvent::BudgetExceeded {
621 chain_id: "c".into(),
622 resource: BudgetResource::Cycles,
623 limit: "10".into(),
624 observed: None,
625 };
626 let env = ExperienceEventEnvelope::new("e-1", event);
627 assert!(env.tenant_id.is_none());
628 assert!(env.correlation_id.is_none());
629 assert_eq!(env.occurred_at, "1970-01-01T00:00:00Z");
630 }
631
632 #[test]
635 fn experience_event_kind_serde_roundtrip() {
636 let kinds = [
637 ExperienceEventKind::ProposalCreated,
638 ExperienceEventKind::ProposalValidated,
639 ExperienceEventKind::FactPromoted,
640 ExperienceEventKind::RecallExecuted,
641 ExperienceEventKind::ReplayTraceRecorded,
642 ExperienceEventKind::ReplayabilityDowngraded,
643 ExperienceEventKind::ArtifactStateTransitioned,
644 ExperienceEventKind::ArtifactRollbackRecorded,
645 ExperienceEventKind::BackendInvoked,
646 ExperienceEventKind::OutcomeRecorded,
647 ExperienceEventKind::BudgetExceeded,
648 ExperienceEventKind::PolicySnapshotCaptured,
649 ExperienceEventKind::HypothesisResolved,
650 ];
651 for kind in kinds {
652 let json = serde_json::to_string(&kind).unwrap();
653 let back: ExperienceEventKind = serde_json::from_str(&json).unwrap();
654 assert_eq!(back, kind);
655 }
656 }
657
658 #[test]
659 fn artifact_kind_serde_roundtrip() {
660 let kinds = [
661 ArtifactKind::Adapter,
662 ArtifactKind::Pack,
663 ArtifactKind::Policy,
664 ArtifactKind::TruthFile,
665 ArtifactKind::EvalSuite,
666 ArtifactKind::Other("custom".into()),
667 ];
668 for kind in kinds {
669 let json = serde_json::to_string(&kind).unwrap();
670 let back: ArtifactKind = serde_json::from_str(&json).unwrap();
671 assert_eq!(back, kind);
672 }
673 }
674}