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, ConstraintName, ContentHash, CorrelationId, DomainId,
39 EventId, FactContent, FactId, GateId, IntentId, PackId, PolicyId, ProposalId, TenantId,
40 TensionId, Timestamp, TraceLinkId,
41};
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ExperienceEventEnvelope {
54 pub event_id: EventId,
56 pub occurred_at: Timestamp,
58 pub tenant_id: Option<TenantId>,
60 pub correlation_id: Option<CorrelationId>,
62 pub event: ExperienceEvent,
64}
65
66impl ExperienceEventEnvelope {
67 #[must_use]
71 pub fn new(event_id: impl Into<EventId>, event: ExperienceEvent) -> Self {
72 Self {
73 event_id: event_id.into(),
74 occurred_at: Self::now_iso8601(),
75 tenant_id: None,
76 correlation_id: None,
77 event,
78 }
79 }
80
81 #[must_use]
83 pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
84 self.tenant_id = Some(tenant_id.into());
85 self
86 }
87
88 #[must_use]
90 pub fn with_correlation(mut self, correlation_id: impl Into<CorrelationId>) -> Self {
91 self.correlation_id = Some(correlation_id.into());
92 self
93 }
94
95 #[must_use]
97 pub fn with_timestamp(mut self, occurred_at: impl Into<Timestamp>) -> Self {
98 self.occurred_at = occurred_at.into();
99 self
100 }
101
102 fn now_iso8601() -> Timestamp {
107 Timestamp::epoch()
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
117pub enum ExperienceEventKind {
118 ProposalCreated,
119 ProposalValidated,
120 FactPromoted,
121 RecallExecuted,
122 ReplayTraceRecorded,
123 ReplayabilityDowngraded,
124 ArtifactStateTransitioned,
125 ArtifactRollbackRecorded,
126 BackendInvoked,
127 OutcomeRecorded,
128 BudgetExceeded,
129 PolicySnapshotCaptured,
130 HypothesisResolved,
131 GateDecisionRecorded,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(tag = "type", content = "data")]
137pub enum ExperienceEvent {
138 ProposalCreated {
140 proposal: KernelProposal,
141 chain_id: ChainId,
142 step: DecisionStep,
143 policy_snapshot_hash: Option<ContentHash>,
144 },
145 ProposalValidated {
147 proposal_id: ProposalId,
148 chain_id: ChainId,
149 step: DecisionStep,
150 contract_results: Vec<ContractResultSnapshot>,
151 all_passed: bool,
152 validator: ActorId,
153 },
154 FactPromoted {
156 proposal_id: ProposalId,
157 fact_id: FactId,
158 promoted_by: ActorId,
159 reason: String,
160 requires_human: bool,
161 },
162 RecallExecuted {
164 query: RecallQuery,
165 provenance: RecallProvenanceEnvelope,
166 trace_link_id: Option<TraceLinkId>,
167 },
168 ReplayTraceRecorded {
170 trace_link_id: TraceLinkId,
171 trace_link: ReplayTrace,
172 },
173 ReplayabilityDowngraded {
175 trace_link_id: TraceLinkId,
176 from: Replayability,
177 to: Replayability,
178 reason: ReplayabilityDowngradeReason,
179 },
180 ArtifactStateTransitioned {
182 artifact_id: ArtifactId,
183 artifact_kind: ArtifactKind,
184 event: LifecycleEvent,
185 },
186 ArtifactRollbackRecorded { rollback: RollbackRecord },
188 BackendInvoked {
190 backend_name: BackendId,
191 adapter_id: Option<BackendId>,
192 trace_link_id: TraceLinkId,
193 step: DecisionStep,
194 policy_snapshot_hash: Option<ContentHash>,
195 },
196 OutcomeRecorded {
198 chain_id: ChainId,
199 step: DecisionStep,
200 passed: bool,
201 stop_reason: Option<EngineStopReason>,
202 latency_ms: Option<u64>,
203 tokens: Option<u64>,
204 cost_microdollars: Option<u64>,
205 backend: Option<BackendId>,
206 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
208 metadata: std::collections::HashMap<String, String>,
209 },
210 BudgetExceeded {
212 chain_id: ChainId,
213 resource: BudgetResource,
214 limit: String,
215 observed: Option<String>,
216 },
217 PolicySnapshotCaptured {
219 policy_id: PolicyId,
220 policy: PolicySnapshot,
221 snapshot_hash: ContentHash,
222 captured_by: ActorId,
223 },
224 HypothesisResolved {
226 chain_id: ChainId,
227 fact_id: FactId,
228 domain: DomainId,
229 claim: String,
230 confidence: converge_pack::UnitInterval,
231 outcome: HypothesisOutcome,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 contradiction_id: Option<TensionId>,
234 formed_cycle: u32,
235 resolved_cycle: u32,
236 },
237 GateDecisionRecorded {
239 request: GateRequest,
240 decision: GateDecision,
241 },
242}
243
244impl ExperienceEvent {
245 #[must_use]
247 pub fn kind(&self) -> ExperienceEventKind {
248 match self {
249 Self::ProposalCreated { .. } => ExperienceEventKind::ProposalCreated,
250 Self::ProposalValidated { .. } => ExperienceEventKind::ProposalValidated,
251 Self::FactPromoted { .. } => ExperienceEventKind::FactPromoted,
252 Self::RecallExecuted { .. } => ExperienceEventKind::RecallExecuted,
253 Self::ReplayTraceRecorded { .. } => ExperienceEventKind::ReplayTraceRecorded,
254 Self::ReplayabilityDowngraded { .. } => ExperienceEventKind::ReplayabilityDowngraded,
255 Self::ArtifactStateTransitioned { .. } => {
256 ExperienceEventKind::ArtifactStateTransitioned
257 }
258 Self::ArtifactRollbackRecorded { .. } => ExperienceEventKind::ArtifactRollbackRecorded,
259 Self::BackendInvoked { .. } => ExperienceEventKind::BackendInvoked,
260 Self::OutcomeRecorded { .. } => ExperienceEventKind::OutcomeRecorded,
261 Self::BudgetExceeded { .. } => ExperienceEventKind::BudgetExceeded,
262 Self::PolicySnapshotCaptured { .. } => ExperienceEventKind::PolicySnapshotCaptured,
263 Self::HypothesisResolved { .. } => ExperienceEventKind::HypothesisResolved,
264 Self::GateDecisionRecorded { .. } => ExperienceEventKind::GateDecisionRecorded,
265 }
266 }
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct ContractResultSnapshot {
276 pub name: String,
277 pub passed: bool,
278 pub failure_reason: Option<String>,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub enum BudgetResource {
284 EngineBudget,
285 Tokens,
286 Facts,
287 Cycles,
288 Time,
289 Cost,
290 Other(String),
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
295pub enum HypothesisOutcome {
296 Confirmed,
297 Falsified,
298 Superseded,
299 Unresolved,
300}
301
302impl From<crate::kernel_boundary::ContractResult> for ContractResultSnapshot {
303 fn from(result: crate::kernel_boundary::ContractResult) -> Self {
304 Self {
305 name: result.name,
306 passed: result.passed,
307 failure_reason: result.failure_reason,
308 }
309 }
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
314pub enum ArtifactKind {
315 Adapter,
316 Pack,
317 Policy,
318 TruthFile,
319 EvalSuite,
320 Other(String),
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
325#[serde(tag = "type", content = "policy")]
326pub enum PolicySnapshot {
327 Kernel(KernelPolicy),
328 Routing(RoutingPolicy),
329 Recall(RecallPolicy),
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize, Default)]
334pub struct EventQuery {
335 pub tenant_id: Option<TenantId>,
336 pub time_range: Option<TimeRange>,
337 pub kinds: Vec<ExperienceEventKind>,
338 pub correlation_id: Option<CorrelationId>,
339 pub chain_id: Option<ChainId>,
340 pub limit: Option<usize>,
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize, Default)]
345pub struct ArtifactQuery {
346 pub tenant_id: Option<TenantId>,
347 pub artifact_id: Option<ArtifactId>,
348 pub kind: Option<ArtifactKind>,
349 pub state: Option<GovernedArtifactState>,
350 pub limit: Option<usize>,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct TimeRange {
356 pub start: Option<Timestamp>,
357 pub end: Option<Timestamp>,
358}
359
360pub trait ExperienceStore: Send + Sync {
373 fn append_event(&self, event: ExperienceEventEnvelope) -> ExperienceStoreResult<()>;
375
376 fn append_events(&self, events: &[ExperienceEventEnvelope]) -> ExperienceStoreResult<()> {
378 for event in events {
379 self.append_event(event.clone())?;
380 }
381 Ok(())
382 }
383
384 fn query_events(
386 &self,
387 query: &EventQuery,
388 ) -> ExperienceStoreResult<Vec<ExperienceEventEnvelope>>;
389
390 fn write_artifact_state_transition(
392 &self,
393 artifact_id: &ArtifactId,
394 artifact_kind: ArtifactKind,
395 event: LifecycleEvent,
396 ) -> ExperienceStoreResult<()>;
397
398 fn get_trace_link(
400 &self,
401 trace_link_id: &TraceLinkId,
402 ) -> ExperienceStoreResult<Option<ReplayTrace>>;
403
404 fn append_user_event(&self, _event: UserExperienceEventEnvelope) -> ExperienceStoreResult<()> {
409 Err(ExperienceStoreError::StorageError {
410 message: "user-side events are not supported by this backend".to_string(),
411 })
412 }
413
414 fn query_records(&self, query: &EventQuery) -> ExperienceStoreResult<Vec<ExperienceRecord>> {
420 Ok(self
421 .query_events(query)?
422 .into_iter()
423 .map(ExperienceRecord::Engine)
424 .collect())
425 }
426}
427
428#[derive(Debug, Clone, PartialEq, Eq)]
430pub enum ExperienceStoreError {
431 StorageError { message: String },
433 InvalidQuery { message: String },
435 NotFound { message: String },
437}
438
439impl std::fmt::Display for ExperienceStoreError {
440 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441 match self {
442 Self::StorageError { message } => write!(f, "Storage error: {}", message),
443 Self::InvalidQuery { message } => write!(f, "Invalid query: {}", message),
444 Self::NotFound { message } => write!(f, "Not found: {}", message),
445 }
446 }
447}
448
449impl std::error::Error for ExperienceStoreError {}
450
451pub type ExperienceStoreResult<T> = Result<T, ExperienceStoreError>;
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
460#[serde(tag = "kind", content = "id")]
461pub enum OverrideTarget {
462 Fact(FactId),
463 Proposal(ProposalId),
464 Constraint(ConstraintName),
465}
466
467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
469#[serde(tag = "kind", rename_all = "snake_case")]
470pub enum CorrectionTarget {
471 Fact { fact_id: FactId },
472 Proposal { proposal_id: ProposalId },
473}
474
475impl CorrectionTarget {
476 #[must_use]
478 pub fn kind_label(&self) -> &'static str {
479 match self {
480 Self::Fact { .. } => "fact",
481 Self::Proposal { .. } => "proposal",
482 }
483 }
484}
485
486#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
488#[serde(rename_all = "snake_case")]
489pub enum BoundaryKind {
490 Authority,
491 Forbidden,
492 Expiry,
493 Reversibility,
494}
495
496#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
498#[serde(tag = "scope", rename_all = "snake_case")]
499pub enum BoundaryTarget {
500 Pack { pack_id: PackId },
501 Intent { intent_id: IntentId },
502 Global,
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize)]
516#[serde(tag = "type", content = "data")]
517pub enum UserExperienceEvent {
518 UserApprovalGranted {
520 gate_request_id: GateId,
521 actor: ActorId,
522 policy_snapshot_hash: Option<ContentHash>,
523 reason: Option<String>,
524 },
525 UserApprovalRejected {
527 gate_request_id: GateId,
528 actor: ActorId,
529 policy_snapshot_hash: Option<ContentHash>,
530 reason: Option<String>,
531 },
532 UserOverrideIssued {
534 target: OverrideTarget,
535 actor: ActorId,
536 policy_snapshot_hash: Option<ContentHash>,
537 reason: String,
538 },
539 UserCorrection {
545 target: CorrectionTarget,
546 actor: ActorId,
547 policy_snapshot_hash: Option<ContentHash>,
548 original_content: ContentHash,
549 corrected_content: FactContent,
550 reason: String,
551 },
552 UserBoundaryAdjusted {
560 boundary: BoundaryKind,
561 target: BoundaryTarget,
562 actor: ActorId,
563 policy_snapshot_hash: Option<ContentHash>,
564 previous_value: serde_json::Value,
565 new_value: serde_json::Value,
566 reason: String,
567 },
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize)]
573pub struct UserExperienceEventEnvelope {
574 pub event_id: EventId,
575 pub occurred_at: Timestamp,
576 pub tenant_id: Option<TenantId>,
577 pub correlation_id: Option<CorrelationId>,
578 pub event: UserExperienceEvent,
579}
580
581impl UserExperienceEventEnvelope {
582 #[must_use]
583 pub fn new(event_id: impl Into<EventId>, event: UserExperienceEvent) -> Self {
584 Self {
585 event_id: event_id.into(),
586 occurred_at: Timestamp::epoch(),
587 tenant_id: None,
588 correlation_id: None,
589 event,
590 }
591 }
592
593 #[must_use]
594 pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
595 self.tenant_id = Some(tenant_id.into());
596 self
597 }
598
599 #[must_use]
600 pub fn with_correlation(mut self, correlation_id: impl Into<CorrelationId>) -> Self {
601 self.correlation_id = Some(correlation_id.into());
602 self
603 }
604
605 #[must_use]
606 pub fn with_timestamp(mut self, occurred_at: impl Into<Timestamp>) -> Self {
607 self.occurred_at = occurred_at.into();
608 self
609 }
610}
611
612#[derive(Debug, Clone, Serialize, Deserialize)]
619#[serde(tag = "kind", content = "value")]
620pub enum ExperienceRecord {
621 Engine(ExperienceEventEnvelope),
622 User(UserExperienceEventEnvelope),
623}
624
625impl ExperienceRecord {
626 #[must_use]
627 pub fn correlation_id(&self) -> Option<&CorrelationId> {
628 match self {
629 Self::Engine(env) => env.correlation_id.as_ref(),
630 Self::User(env) => env.correlation_id.as_ref(),
631 }
632 }
633
634 #[must_use]
635 pub fn tenant_id(&self) -> Option<&TenantId> {
636 match self {
637 Self::Engine(env) => env.tenant_id.as_ref(),
638 Self::User(env) => env.tenant_id.as_ref(),
639 }
640 }
641
642 #[must_use]
643 pub fn occurred_at(&self) -> &Timestamp {
644 match self {
645 Self::Engine(env) => &env.occurred_at,
646 Self::User(env) => &env.occurred_at,
647 }
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654
655 #[test]
656 fn event_kind_mapping() {
657 let event = ExperienceEvent::BudgetExceeded {
658 chain_id: "chain-1".into(),
659 resource: BudgetResource::Tokens,
660 limit: "1024".to_string(),
661 observed: Some("2048".to_string()),
662 };
663 assert_eq!(event.kind(), ExperienceEventKind::BudgetExceeded);
664 }
665
666 #[test]
667 fn envelope_builder_sets_fields() {
668 let event = ExperienceEvent::OutcomeRecorded {
669 chain_id: "chain-1".into(),
670 step: DecisionStep::Planning,
671 passed: true,
672 stop_reason: None,
673 latency_ms: Some(12),
674 tokens: Some(42),
675 cost_microdollars: None,
676 backend: Some("local".into()),
677 metadata: Default::default(),
678 };
679 let envelope = ExperienceEventEnvelope::new("evt-1", event)
680 .with_tenant("tenant-a")
681 .with_correlation("corr-1")
682 .with_timestamp("2026-01-21T12:00:00Z");
683
684 assert_eq!(envelope.event_id, "evt-1");
685 assert_eq!(envelope.tenant_id.as_deref(), Some("tenant-a"));
686 assert_eq!(envelope.correlation_id.as_deref(), Some("corr-1"));
687 assert_eq!(envelope.occurred_at, "2026-01-21T12:00:00Z");
688 }
689
690 #[test]
693 fn event_kind_proposal_created() {
694 let event = ExperienceEvent::ProposalCreated {
695 proposal: crate::kernel_boundary::KernelProposal {
696 id: "p-1".into(),
697 kind: crate::kernel_boundary::ProposalKind::Claims,
698 payload: "test".into(),
699 structured_payload: None,
700 trace_link: crate::kernel_boundary::ReplayTrace::Local(
701 crate::kernel_boundary::LocalReplayTrace {
702 base_model_hash: "abc".into(),
703 adapter: None,
704 tokenizer_hash: "tok".into(),
705 seed: 42,
706 sampler: crate::kernel_boundary::SamplerParams::default(),
707 prompt_version: "v1".into(),
708 recall: None,
709 weights_mutated: false,
710 execution_env: crate::kernel_boundary::ExecutionEnv::default(),
711 },
712 ),
713 contract_results: vec![crate::kernel_boundary::ContractResult::passed(
714 "grounded-answering",
715 )],
716 requires_human: false,
717 confidence: Some(0.9),
718 },
719 chain_id: "c-1".into(),
720 step: DecisionStep::Planning,
721 policy_snapshot_hash: None,
722 };
723 assert_eq!(event.kind(), ExperienceEventKind::ProposalCreated);
724 }
725
726 #[test]
727 fn event_kind_fact_promoted() {
728 let event = ExperienceEvent::FactPromoted {
729 proposal_id: "p-1".into(),
730 fact_id: "f-1".into(),
731 promoted_by: "engine".into(),
732 reason: "validated".into(),
733 requires_human: false,
734 };
735 assert_eq!(event.kind(), ExperienceEventKind::FactPromoted);
736 }
737
738 #[test]
739 fn event_kind_hypothesis_resolved() {
740 let event = ExperienceEvent::HypothesisResolved {
741 chain_id: "c-1".into(),
742 fact_id: "f-1".into(),
743 domain: "market".into(),
744 claim: "price will increase".into(),
745 confidence: converge_pack::UnitInterval::clamped(0.85),
746 outcome: HypothesisOutcome::Confirmed,
747 contradiction_id: None,
748 formed_cycle: 1,
749 resolved_cycle: 3,
750 };
751 assert_eq!(event.kind(), ExperienceEventKind::HypothesisResolved);
752 }
753
754 #[test]
755 fn event_kind_policy_snapshot_captured() {
756 let event = ExperienceEvent::PolicySnapshotCaptured {
757 policy_id: "pol-1".into(),
758 policy: PolicySnapshot::Routing(crate::kernel_boundary::RoutingPolicy::default()),
759 snapshot_hash: ContentHash::zero(),
760 captured_by: "engine".into(),
761 };
762 assert_eq!(event.kind(), ExperienceEventKind::PolicySnapshotCaptured);
763 }
764
765 #[test]
766 fn user_experience_events_roundtrip_new_bidirectional_variants() {
767 let events = [
768 UserExperienceEvent::UserApprovalRejected {
769 gate_request_id: "gate-1".into(),
770 actor: "operator-1".into(),
771 policy_snapshot_hash: None,
772 reason: Some("not enough evidence".into()),
773 },
774 UserExperienceEvent::UserCorrection {
775 target: CorrectionTarget::Fact {
776 fact_id: FactId::new("fact-1"),
777 },
778 actor: "operator-1".into(),
779 policy_snapshot_hash: None,
780 original_content: ContentHash::zero(),
781 corrected_content: FactContent::new(
782 crate::FactContentKind::Claim,
783 "corrected claim",
784 ),
785 reason: "source was stale".into(),
786 },
787 UserExperienceEvent::UserBoundaryAdjusted {
788 boundary: BoundaryKind::Authority,
789 target: BoundaryTarget::Pack {
790 pack_id: PackId::new("pack-1"),
791 },
792 actor: "operator-1".into(),
793 policy_snapshot_hash: None,
794 previous_value: serde_json::json!({"limit": 100}),
795 new_value: serde_json::json!({"limit": 50}),
796 reason: "reduce autonomy".into(),
797 },
798 ];
799
800 for event in events {
801 let json = serde_json::to_string(&event).unwrap();
802 let back: UserExperienceEvent = serde_json::from_str(&json).unwrap();
803 assert!(matches!(
804 back,
805 UserExperienceEvent::UserApprovalRejected { .. }
806 | UserExperienceEvent::UserCorrection { .. }
807 | UserExperienceEvent::UserBoundaryAdjusted { .. }
808 ));
809 }
810 }
811
812 #[test]
815 fn store_error_display_storage() {
816 let e = ExperienceStoreError::StorageError {
817 message: "disk full".into(),
818 };
819 assert!(e.to_string().contains("disk full"));
820 }
821
822 #[test]
823 fn store_error_display_invalid_query() {
824 let e = ExperienceStoreError::InvalidQuery {
825 message: "bad filter".into(),
826 };
827 assert!(e.to_string().contains("bad filter"));
828 }
829
830 #[test]
831 fn store_error_display_not_found() {
832 let e = ExperienceStoreError::NotFound {
833 message: "trace-99".into(),
834 };
835 assert!(e.to_string().contains("trace-99"));
836 }
837
838 #[test]
839 fn store_error_is_std_error() {
840 let e: Box<dyn std::error::Error> = Box::new(ExperienceStoreError::StorageError {
841 message: "test".into(),
842 });
843 assert!(!e.to_string().is_empty());
844 }
845
846 #[test]
849 fn artifact_kind_equality_named() {
850 assert_eq!(ArtifactKind::Adapter, ArtifactKind::Adapter);
851 assert_ne!(ArtifactKind::Pack, ArtifactKind::Policy);
852 }
853
854 #[test]
855 fn artifact_kind_other_variant() {
856 let a = ArtifactKind::Other("custom".into());
857 let b = ArtifactKind::Other("custom".into());
858 assert_eq!(a, b);
859 assert_ne!(
860 ArtifactKind::Other("x".into()),
861 ArtifactKind::Other("y".into())
862 );
863 }
864
865 #[test]
868 fn contract_result_snapshot_from_contract_result() {
869 let cr = crate::kernel_boundary::ContractResult {
870 name: "schema-check".into(),
871 passed: false,
872 failure_reason: Some("missing field".into()),
873 };
874 let snap: ContractResultSnapshot = cr.into();
875 assert_eq!(snap.name, "schema-check");
876 assert!(!snap.passed);
877 assert_eq!(snap.failure_reason.as_deref(), Some("missing field"));
878 }
879
880 #[test]
883 fn event_query_default_is_empty() {
884 let q = EventQuery::default();
885 assert!(q.tenant_id.is_none());
886 assert!(q.kinds.is_empty());
887 assert!(q.correlation_id.is_none());
888 assert!(q.chain_id.is_none());
889 assert!(q.limit.is_none());
890 }
891
892 #[test]
895 fn envelope_minimal_no_optional_fields() {
896 let event = ExperienceEvent::BudgetExceeded {
897 chain_id: "c".into(),
898 resource: BudgetResource::Cycles,
899 limit: "10".into(),
900 observed: None,
901 };
902 let env = ExperienceEventEnvelope::new("e-1", event);
903 assert!(env.tenant_id.is_none());
904 assert!(env.correlation_id.is_none());
905 assert_eq!(env.occurred_at, "1970-01-01T00:00:00Z");
906 }
907
908 #[test]
911 fn experience_event_kind_serde_roundtrip() {
912 let kinds = [
913 ExperienceEventKind::ProposalCreated,
914 ExperienceEventKind::ProposalValidated,
915 ExperienceEventKind::FactPromoted,
916 ExperienceEventKind::RecallExecuted,
917 ExperienceEventKind::ReplayTraceRecorded,
918 ExperienceEventKind::ReplayabilityDowngraded,
919 ExperienceEventKind::ArtifactStateTransitioned,
920 ExperienceEventKind::ArtifactRollbackRecorded,
921 ExperienceEventKind::BackendInvoked,
922 ExperienceEventKind::OutcomeRecorded,
923 ExperienceEventKind::BudgetExceeded,
924 ExperienceEventKind::PolicySnapshotCaptured,
925 ExperienceEventKind::HypothesisResolved,
926 ExperienceEventKind::GateDecisionRecorded,
927 ];
928 for kind in kinds {
929 let json = serde_json::to_string(&kind).unwrap();
930 let back: ExperienceEventKind = serde_json::from_str(&json).unwrap();
931 assert_eq!(back, kind);
932 }
933 }
934
935 #[test]
936 fn artifact_kind_serde_roundtrip() {
937 let kinds = [
938 ArtifactKind::Adapter,
939 ArtifactKind::Pack,
940 ArtifactKind::Policy,
941 ArtifactKind::TruthFile,
942 ArtifactKind::EvalSuite,
943 ArtifactKind::Other("custom".into()),
944 ];
945 for kind in kinds {
946 let json = serde_json::to_string(&kind).unwrap();
947 let back: ArtifactKind = serde_json::from_str(&json).unwrap();
948 assert_eq!(back, kind);
949 }
950 }
951
952 #[test]
955 fn envelope_pack_with_all_fields() {
956 let event = ExperienceEvent::BudgetExceeded {
957 chain_id: "c-1".into(),
958 resource: BudgetResource::Tokens,
959 limit: "1024".to_string(),
960 observed: Some("2048".to_string()),
961 };
962 let env = ExperienceEventEnvelope::new("evt-abc123", event)
963 .with_tenant("tenant-prod")
964 .with_correlation("corr-xyz789")
965 .with_timestamp("2026-04-28T15:30:45Z");
966
967 let json = serde_json::to_string(&env).unwrap();
968 assert!(json.contains("evt-abc123"));
969 assert!(json.contains("tenant-prod"));
970 assert!(json.contains("corr-xyz789"));
971 assert!(json.contains("2026-04-28T15:30:45Z"));
972 }
973
974 #[test]
975 fn envelope_pack_minimal() {
976 let event = ExperienceEvent::BudgetExceeded {
977 chain_id: "c".into(),
978 resource: BudgetResource::Cycles,
979 limit: "5".to_string(),
980 observed: None,
981 };
982 let env = ExperienceEventEnvelope::new("e1", event);
983
984 let json = serde_json::to_string(&env).unwrap();
985 assert!(json.contains("e1"));
986 assert!(json.contains("1970-01-01T00:00:00Z"));
987 assert!(json.contains("tenant_id"));
989 assert!(json.contains("correlation_id"));
990 }
991
992 #[test]
995 fn envelope_unpack_with_all_fields() {
996 let json = r#"{
997 "event_id": "evt-1",
998 "occurred_at": "2026-04-28T12:00:00Z",
999 "tenant_id": "tenant-x",
1000 "correlation_id": "corr-1",
1001 "event": {
1002 "type": "BudgetExceeded",
1003 "data": {
1004 "chain_id": "c-1",
1005 "resource": "Tokens",
1006 "limit": "999",
1007 "observed": "500"
1008 }
1009 }
1010 }"#;
1011
1012 let env: ExperienceEventEnvelope = serde_json::from_str(json).unwrap();
1013 assert_eq!(env.event_id, "evt-1");
1014 assert_eq!(env.occurred_at, "2026-04-28T12:00:00Z");
1015 assert_eq!(env.tenant_id.as_deref(), Some("tenant-x"));
1016 assert_eq!(env.correlation_id.as_deref(), Some("corr-1"));
1017 }
1018
1019 #[test]
1020 fn envelope_unpack_missing_optional_fields() {
1021 let json = r#"{
1022 "event_id": "evt-minimal",
1023 "occurred_at": "2026-01-01T00:00:00Z",
1024 "tenant_id": null,
1025 "correlation_id": null,
1026 "event": {
1027 "type": "BudgetExceeded",
1028 "data": {
1029 "chain_id": "c",
1030 "resource": "Cycles",
1031 "limit": "1",
1032 "observed": null
1033 }
1034 }
1035 }"#;
1036
1037 let env: ExperienceEventEnvelope = serde_json::from_str(json).unwrap();
1038 assert!(env.tenant_id.is_none());
1039 assert!(env.correlation_id.is_none());
1040 }
1041
1042 #[test]
1043 fn envelope_unpack_missing_optional_keys_entirely() {
1044 let json = r#"{
1045 "event_id": "evt-sparse",
1046 "occurred_at": "2026-01-01T00:00:00Z",
1047 "event": {
1048 "type": "BudgetExceeded",
1049 "data": {
1050 "chain_id": "c",
1051 "resource": "Facts",
1052 "limit": "10",
1053 "observed": null
1054 }
1055 }
1056 }"#;
1057
1058 let env: ExperienceEventEnvelope = serde_json::from_str(json).unwrap();
1059 assert_eq!(env.event_id, "evt-sparse");
1060 assert!(env.tenant_id.is_none());
1061 assert!(env.correlation_id.is_none());
1062 }
1063
1064 #[test]
1067 fn envelope_roundtrip_complete() {
1068 let event = ExperienceEvent::BudgetExceeded {
1069 chain_id: "chain-rt".into(),
1070 resource: BudgetResource::Tokens,
1071 limit: "777".to_string(),
1072 observed: Some("333".to_string()),
1073 };
1074 let original = ExperienceEventEnvelope::new("evt-rt", event)
1075 .with_tenant("tenant-rt")
1076 .with_correlation("corr-rt")
1077 .with_timestamp("2026-04-28T10:15:30Z");
1078
1079 let json = serde_json::to_string(&original).unwrap();
1080 let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1081
1082 assert_eq!(restored.event_id, original.event_id);
1083 assert_eq!(restored.occurred_at, original.occurred_at);
1084 assert_eq!(restored.tenant_id, original.tenant_id);
1085 assert_eq!(restored.correlation_id, original.correlation_id);
1086 }
1087
1088 #[test]
1089 fn envelope_roundtrip_minimal() {
1090 let event = ExperienceEvent::BudgetExceeded {
1091 chain_id: "c".into(),
1092 resource: BudgetResource::Cycles,
1093 limit: "2".to_string(),
1094 observed: None,
1095 };
1096 let original = ExperienceEventEnvelope::new("evt-min", event);
1097
1098 let json = serde_json::to_string(&original).unwrap();
1099 let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1100
1101 assert_eq!(restored.event_id, original.event_id);
1102 assert!(restored.tenant_id.is_none());
1103 assert!(restored.correlation_id.is_none());
1104 }
1105
1106 #[test]
1109 fn envelope_edge_case_empty_event_id() {
1110 let event = ExperienceEvent::BudgetExceeded {
1111 chain_id: "c".into(),
1112 resource: BudgetResource::Cycles,
1113 limit: "1".to_string(),
1114 observed: None,
1115 };
1116 let env = ExperienceEventEnvelope::new("", event);
1117 assert_eq!(env.event_id, "");
1118
1119 let json = serde_json::to_string(&env).unwrap();
1120 let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1121 assert_eq!(restored.event_id, "");
1122 }
1123
1124 #[test]
1125 fn envelope_edge_case_special_chars_in_ids() {
1126 let event = ExperienceEvent::BudgetExceeded {
1127 chain_id: "c".into(),
1128 resource: BudgetResource::Cycles,
1129 limit: "1".to_string(),
1130 observed: None,
1131 };
1132 let special_id = "evt-🚀-/\\\"'";
1133 let env = ExperienceEventEnvelope::new(special_id, event)
1134 .with_tenant("tenant-@#$%^&*()")
1135 .with_correlation("corr-\n\t\r");
1136
1137 let json = serde_json::to_string(&env).unwrap();
1138 let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1139
1140 assert_eq!(restored.event_id, special_id);
1141 assert_eq!(restored.tenant_id.as_deref(), Some("tenant-@#$%^&*()"));
1142 assert_eq!(restored.correlation_id.as_deref(), Some("corr-\n\t\r"));
1143 }
1144
1145 #[test]
1146 fn envelope_edge_case_very_long_strings() {
1147 let long_id = "x".repeat(10_000);
1148 let event = ExperienceEvent::BudgetExceeded {
1149 chain_id: "c".into(),
1150 resource: BudgetResource::Cycles,
1151 limit: "1".to_string(),
1152 observed: None,
1153 };
1154 let env = ExperienceEventEnvelope::new(long_id.clone(), event)
1155 .with_tenant("y".repeat(5_000))
1156 .with_correlation("z".repeat(3_000));
1157
1158 let json = serde_json::to_string(&env).unwrap();
1159 let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1160
1161 assert_eq!(restored.event_id, long_id);
1162 assert_eq!(restored.tenant_id.as_ref().map(|s| s.len()), Some(5_000));
1163 assert_eq!(
1164 restored.correlation_id.as_ref().map(|s| s.len()),
1165 Some(3_000)
1166 );
1167 }
1168
1169 #[test]
1170 fn envelope_edge_case_unicode_in_ids() {
1171 let event = ExperienceEvent::BudgetExceeded {
1172 chain_id: "c".into(),
1173 resource: BudgetResource::Cycles,
1174 limit: "1".to_string(),
1175 observed: None,
1176 };
1177 let env = ExperienceEventEnvelope::new("evt-中文-العربية-русский", event)
1178 .with_tenant("テナント-यन्त्र")
1179 .with_correlation("相関-συσχέτιση");
1180
1181 let json = serde_json::to_string(&env).unwrap();
1182 let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1183
1184 assert_eq!(restored.event_id, "evt-中文-العربية-русский");
1185 assert_eq!(restored.tenant_id.as_deref(), Some("テナント-यन्त्र"));
1186 assert_eq!(restored.correlation_id.as_deref(), Some("相関-συσχέτιση"));
1187 }
1188
1189 #[test]
1192 fn envelope_unpack_missing_required_event_id() {
1193 let json = r#"{
1194 "occurred_at": "2026-01-01T00:00:00Z",
1195 "tenant_id": null,
1196 "correlation_id": null,
1197 "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}}
1198 }"#;
1199
1200 let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1201 assert!(result.is_err());
1202 }
1203
1204 #[test]
1205 fn envelope_unpack_missing_required_occurred_at() {
1206 let json = r#"{
1207 "event_id": "evt-1",
1208 "tenant_id": null,
1209 "correlation_id": null,
1210 "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}}
1211 }"#;
1212
1213 let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1214 assert!(result.is_err());
1215 }
1216
1217 #[test]
1218 fn envelope_unpack_missing_required_event() {
1219 let json = r#"{
1220 "event_id": "evt-1",
1221 "occurred_at": "2026-01-01T00:00:00Z",
1222 "tenant_id": null,
1223 "correlation_id": null
1224 }"#;
1225
1226 let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1227 assert!(result.is_err());
1228 }
1229
1230 #[test]
1231 fn envelope_unpack_wrong_type_for_field() {
1232 let json = r#"{
1233 "event_id": 12345,
1234 "occurred_at": "2026-01-01T00:00:00Z",
1235 "tenant_id": null,
1236 "correlation_id": null,
1237 "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}}
1238 }"#;
1239
1240 let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1241 assert!(result.is_err());
1242 }
1243
1244 #[test]
1245 fn envelope_unpack_invalid_json_syntax() {
1246 let invalid = r#"{"event_id": "evt-1", "occurred_at": "2026-01-01"#;
1247 let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(invalid);
1248 assert!(result.is_err());
1249 }
1250
1251 #[test]
1252 fn envelope_unpack_extra_unknown_fields_ignored() {
1253 let json = r#"{
1254 "event_id": "evt-1",
1255 "occurred_at": "2026-01-01T00:00:00Z",
1256 "tenant_id": null,
1257 "correlation_id": null,
1258 "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}},
1259 "unknown_field": "ignored",
1260 "another_extra": 42
1261 }"#;
1262
1263 let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1264 assert!(result.is_ok());
1266 assert_eq!(result.unwrap().event_id, "evt-1");
1267 }
1268
1269 #[test]
1270 fn envelope_unpack_null_for_event_id_invalid() {
1271 let json = r#"{
1272 "event_id": null,
1273 "occurred_at": "2026-01-01T00:00:00Z",
1274 "tenant_id": null,
1275 "correlation_id": null,
1276 "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}}
1277 }"#;
1278
1279 let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1280 assert!(result.is_err());
1281 }
1282
1283 #[test]
1286 fn envelope_builder_chaining_consistency() {
1287 let event = ExperienceEvent::BudgetExceeded {
1288 chain_id: "c".into(),
1289 resource: BudgetResource::Cycles,
1290 limit: "1".to_string(),
1291 observed: None,
1292 };
1293 let env = ExperienceEventEnvelope::new("evt-1", event)
1294 .with_tenant("t1")
1295 .with_tenant("t2")
1296 .with_correlation("corr1")
1297 .with_correlation("corr2")
1298 .with_timestamp("2026-01-01T00:00:00Z")
1299 .with_timestamp("2026-12-31T23:59:59Z");
1300
1301 assert_eq!(env.tenant_id.as_deref(), Some("t2"));
1302 assert_eq!(env.correlation_id.as_deref(), Some("corr2"));
1303 assert_eq!(env.occurred_at, "2026-12-31T23:59:59Z");
1304 }
1305
1306 #[test]
1307 fn envelope_builder_override_to_last_value() {
1308 let event = ExperienceEvent::BudgetExceeded {
1309 chain_id: "c".into(),
1310 resource: BudgetResource::Cycles,
1311 limit: "1".to_string(),
1312 observed: None,
1313 };
1314 let final_tenant = "final-tenant";
1315 let final_corr = "final-corr";
1316 let final_ts = "2099-01-01T00:00:00Z";
1317
1318 let env = ExperienceEventEnvelope::new("evt", event)
1319 .with_tenant("ignored1")
1320 .with_tenant("ignored2")
1321 .with_tenant(final_tenant)
1322 .with_correlation("ignored1")
1323 .with_correlation(final_corr)
1324 .with_timestamp("ignored1")
1325 .with_timestamp(final_ts);
1326
1327 assert_eq!(env.tenant_id.as_deref(), Some(final_tenant));
1328 assert_eq!(env.correlation_id.as_deref(), Some(final_corr));
1329 assert_eq!(env.occurred_at, final_ts);
1330 }
1331}