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