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 GateId, 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 fn append_user_event(&self, _event: UserExperienceEventEnvelope) -> ExperienceStoreResult<()> {
408 Err(ExperienceStoreError::StorageError {
409 message: "user-side events are not supported by this backend".to_string(),
410 })
411 }
412
413 fn query_records(&self, query: &EventQuery) -> ExperienceStoreResult<Vec<ExperienceRecord>> {
419 Ok(self
420 .query_events(query)?
421 .into_iter()
422 .map(ExperienceRecord::Engine)
423 .collect())
424 }
425}
426
427#[derive(Debug, Clone, PartialEq, Eq)]
429pub enum ExperienceStoreError {
430 StorageError { message: String },
432 InvalidQuery { message: String },
434 NotFound { message: String },
436}
437
438impl std::fmt::Display for ExperienceStoreError {
439 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
440 match self {
441 Self::StorageError { message } => write!(f, "Storage error: {}", message),
442 Self::InvalidQuery { message } => write!(f, "Invalid query: {}", message),
443 Self::NotFound { message } => write!(f, "Not found: {}", message),
444 }
445 }
446}
447
448impl std::error::Error for ExperienceStoreError {}
449
450pub type ExperienceStoreResult<T> = Result<T, ExperienceStoreError>;
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
459#[serde(tag = "kind", content = "id")]
460pub enum OverrideTarget {
461 Fact(FactId),
462 Proposal(ProposalId),
463 Constraint(String),
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize)]
477#[serde(tag = "type", content = "data")]
478pub enum UserExperienceEvent {
479 UserApprovalGranted {
481 gate_request_id: GateId,
482 actor: ActorId,
483 policy_snapshot_hash: Option<ContentHash>,
484 reason: Option<String>,
485 },
486 UserOverrideIssued {
488 target: OverrideTarget,
489 actor: ActorId,
490 policy_snapshot_hash: Option<ContentHash>,
491 reason: String,
492 },
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct UserExperienceEventEnvelope {
499 pub event_id: EventId,
500 pub occurred_at: Timestamp,
501 pub tenant_id: Option<TenantId>,
502 pub correlation_id: Option<CorrelationId>,
503 pub event: UserExperienceEvent,
504}
505
506impl UserExperienceEventEnvelope {
507 #[must_use]
508 pub fn new(event_id: impl Into<EventId>, event: UserExperienceEvent) -> Self {
509 Self {
510 event_id: event_id.into(),
511 occurred_at: Timestamp::epoch(),
512 tenant_id: None,
513 correlation_id: None,
514 event,
515 }
516 }
517
518 #[must_use]
519 pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
520 self.tenant_id = Some(tenant_id.into());
521 self
522 }
523
524 #[must_use]
525 pub fn with_correlation(mut self, correlation_id: impl Into<CorrelationId>) -> Self {
526 self.correlation_id = Some(correlation_id.into());
527 self
528 }
529
530 #[must_use]
531 pub fn with_timestamp(mut self, occurred_at: impl Into<Timestamp>) -> Self {
532 self.occurred_at = occurred_at.into();
533 self
534 }
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize)]
544#[serde(tag = "kind", content = "value")]
545pub enum ExperienceRecord {
546 Engine(ExperienceEventEnvelope),
547 User(UserExperienceEventEnvelope),
548}
549
550impl ExperienceRecord {
551 #[must_use]
552 pub fn correlation_id(&self) -> Option<&CorrelationId> {
553 match self {
554 Self::Engine(env) => env.correlation_id.as_ref(),
555 Self::User(env) => env.correlation_id.as_ref(),
556 }
557 }
558
559 #[must_use]
560 pub fn tenant_id(&self) -> Option<&TenantId> {
561 match self {
562 Self::Engine(env) => env.tenant_id.as_ref(),
563 Self::User(env) => env.tenant_id.as_ref(),
564 }
565 }
566
567 #[must_use]
568 pub fn occurred_at(&self) -> &Timestamp {
569 match self {
570 Self::Engine(env) => &env.occurred_at,
571 Self::User(env) => &env.occurred_at,
572 }
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 #[test]
581 fn event_kind_mapping() {
582 let event = ExperienceEvent::BudgetExceeded {
583 chain_id: "chain-1".into(),
584 resource: BudgetResource::Tokens,
585 limit: "1024".to_string(),
586 observed: Some("2048".to_string()),
587 };
588 assert_eq!(event.kind(), ExperienceEventKind::BudgetExceeded);
589 }
590
591 #[test]
592 fn envelope_builder_sets_fields() {
593 let event = ExperienceEvent::OutcomeRecorded {
594 chain_id: "chain-1".into(),
595 step: DecisionStep::Planning,
596 passed: true,
597 stop_reason: None,
598 latency_ms: Some(12),
599 tokens: Some(42),
600 cost_microdollars: None,
601 backend: Some("local".into()),
602 metadata: Default::default(),
603 };
604 let envelope = ExperienceEventEnvelope::new("evt-1", event)
605 .with_tenant("tenant-a")
606 .with_correlation("corr-1")
607 .with_timestamp("2026-01-21T12:00:00Z");
608
609 assert_eq!(envelope.event_id, "evt-1");
610 assert_eq!(envelope.tenant_id.as_deref(), Some("tenant-a"));
611 assert_eq!(envelope.correlation_id.as_deref(), Some("corr-1"));
612 assert_eq!(envelope.occurred_at, "2026-01-21T12:00:00Z");
613 }
614
615 #[test]
618 fn event_kind_proposal_created() {
619 let event = ExperienceEvent::ProposalCreated {
620 proposal: crate::kernel_boundary::KernelProposal {
621 id: "p-1".into(),
622 kind: crate::kernel_boundary::ProposalKind::Claims,
623 payload: "test".into(),
624 structured_payload: None,
625 trace_link: crate::kernel_boundary::ReplayTrace::Local(
626 crate::kernel_boundary::LocalReplayTrace {
627 base_model_hash: "abc".into(),
628 adapter: None,
629 tokenizer_hash: "tok".into(),
630 seed: 42,
631 sampler: crate::kernel_boundary::SamplerParams::default(),
632 prompt_version: "v1".into(),
633 recall: None,
634 weights_mutated: false,
635 execution_env: crate::kernel_boundary::ExecutionEnv::default(),
636 },
637 ),
638 contract_results: vec![crate::kernel_boundary::ContractResult::passed(
639 "grounded-answering",
640 )],
641 requires_human: false,
642 confidence: Some(0.9),
643 },
644 chain_id: "c-1".into(),
645 step: DecisionStep::Planning,
646 policy_snapshot_hash: None,
647 };
648 assert_eq!(event.kind(), ExperienceEventKind::ProposalCreated);
649 }
650
651 #[test]
652 fn event_kind_fact_promoted() {
653 let event = ExperienceEvent::FactPromoted {
654 proposal_id: "p-1".into(),
655 fact_id: "f-1".into(),
656 promoted_by: "engine".into(),
657 reason: "validated".into(),
658 requires_human: false,
659 };
660 assert_eq!(event.kind(), ExperienceEventKind::FactPromoted);
661 }
662
663 #[test]
664 fn event_kind_hypothesis_resolved() {
665 let event = ExperienceEvent::HypothesisResolved {
666 chain_id: "c-1".into(),
667 fact_id: "f-1".into(),
668 domain: "market".into(),
669 claim: "price will increase".into(),
670 confidence: 0.85,
671 outcome: HypothesisOutcome::Confirmed,
672 contradiction_id: None,
673 formed_cycle: 1,
674 resolved_cycle: 3,
675 };
676 assert_eq!(event.kind(), ExperienceEventKind::HypothesisResolved);
677 }
678
679 #[test]
680 fn event_kind_policy_snapshot_captured() {
681 let event = ExperienceEvent::PolicySnapshotCaptured {
682 policy_id: "pol-1".into(),
683 policy: PolicySnapshot::Routing(crate::kernel_boundary::RoutingPolicy::default()),
684 snapshot_hash: ContentHash::zero(),
685 captured_by: "engine".into(),
686 };
687 assert_eq!(event.kind(), ExperienceEventKind::PolicySnapshotCaptured);
688 }
689
690 #[test]
693 fn store_error_display_storage() {
694 let e = ExperienceStoreError::StorageError {
695 message: "disk full".into(),
696 };
697 assert!(e.to_string().contains("disk full"));
698 }
699
700 #[test]
701 fn store_error_display_invalid_query() {
702 let e = ExperienceStoreError::InvalidQuery {
703 message: "bad filter".into(),
704 };
705 assert!(e.to_string().contains("bad filter"));
706 }
707
708 #[test]
709 fn store_error_display_not_found() {
710 let e = ExperienceStoreError::NotFound {
711 message: "trace-99".into(),
712 };
713 assert!(e.to_string().contains("trace-99"));
714 }
715
716 #[test]
717 fn store_error_is_std_error() {
718 let e: Box<dyn std::error::Error> = Box::new(ExperienceStoreError::StorageError {
719 message: "test".into(),
720 });
721 assert!(!e.to_string().is_empty());
722 }
723
724 #[test]
727 fn artifact_kind_equality_named() {
728 assert_eq!(ArtifactKind::Adapter, ArtifactKind::Adapter);
729 assert_ne!(ArtifactKind::Pack, ArtifactKind::Policy);
730 }
731
732 #[test]
733 fn artifact_kind_other_variant() {
734 let a = ArtifactKind::Other("custom".into());
735 let b = ArtifactKind::Other("custom".into());
736 assert_eq!(a, b);
737 assert_ne!(
738 ArtifactKind::Other("x".into()),
739 ArtifactKind::Other("y".into())
740 );
741 }
742
743 #[test]
746 fn contract_result_snapshot_from_contract_result() {
747 let cr = crate::kernel_boundary::ContractResult {
748 name: "schema-check".into(),
749 passed: false,
750 failure_reason: Some("missing field".into()),
751 };
752 let snap: ContractResultSnapshot = cr.into();
753 assert_eq!(snap.name, "schema-check");
754 assert!(!snap.passed);
755 assert_eq!(snap.failure_reason.as_deref(), Some("missing field"));
756 }
757
758 #[test]
761 fn event_query_default_is_empty() {
762 let q = EventQuery::default();
763 assert!(q.tenant_id.is_none());
764 assert!(q.kinds.is_empty());
765 assert!(q.correlation_id.is_none());
766 assert!(q.chain_id.is_none());
767 assert!(q.limit.is_none());
768 }
769
770 #[test]
773 fn envelope_minimal_no_optional_fields() {
774 let event = ExperienceEvent::BudgetExceeded {
775 chain_id: "c".into(),
776 resource: BudgetResource::Cycles,
777 limit: "10".into(),
778 observed: None,
779 };
780 let env = ExperienceEventEnvelope::new("e-1", event);
781 assert!(env.tenant_id.is_none());
782 assert!(env.correlation_id.is_none());
783 assert_eq!(env.occurred_at, "1970-01-01T00:00:00Z");
784 }
785
786 #[test]
789 fn experience_event_kind_serde_roundtrip() {
790 let kinds = [
791 ExperienceEventKind::ProposalCreated,
792 ExperienceEventKind::ProposalValidated,
793 ExperienceEventKind::FactPromoted,
794 ExperienceEventKind::RecallExecuted,
795 ExperienceEventKind::ReplayTraceRecorded,
796 ExperienceEventKind::ReplayabilityDowngraded,
797 ExperienceEventKind::ArtifactStateTransitioned,
798 ExperienceEventKind::ArtifactRollbackRecorded,
799 ExperienceEventKind::BackendInvoked,
800 ExperienceEventKind::OutcomeRecorded,
801 ExperienceEventKind::BudgetExceeded,
802 ExperienceEventKind::PolicySnapshotCaptured,
803 ExperienceEventKind::HypothesisResolved,
804 ];
805 for kind in kinds {
806 let json = serde_json::to_string(&kind).unwrap();
807 let back: ExperienceEventKind = serde_json::from_str(&json).unwrap();
808 assert_eq!(back, kind);
809 }
810 }
811
812 #[test]
813 fn artifact_kind_serde_roundtrip() {
814 let kinds = [
815 ArtifactKind::Adapter,
816 ArtifactKind::Pack,
817 ArtifactKind::Policy,
818 ArtifactKind::TruthFile,
819 ArtifactKind::EvalSuite,
820 ArtifactKind::Other("custom".into()),
821 ];
822 for kind in kinds {
823 let json = serde_json::to_string(&kind).unwrap();
824 let back: ArtifactKind = serde_json::from_str(&json).unwrap();
825 assert_eq!(back, kind);
826 }
827 }
828
829 #[test]
832 fn envelope_pack_with_all_fields() {
833 let event = ExperienceEvent::BudgetExceeded {
834 chain_id: "c-1".into(),
835 resource: BudgetResource::Tokens,
836 limit: "1024".to_string(),
837 observed: Some("2048".to_string()),
838 };
839 let env = ExperienceEventEnvelope::new("evt-abc123", event)
840 .with_tenant("tenant-prod")
841 .with_correlation("corr-xyz789")
842 .with_timestamp("2026-04-28T15:30:45Z");
843
844 let json = serde_json::to_string(&env).unwrap();
845 assert!(json.contains("evt-abc123"));
846 assert!(json.contains("tenant-prod"));
847 assert!(json.contains("corr-xyz789"));
848 assert!(json.contains("2026-04-28T15:30:45Z"));
849 }
850
851 #[test]
852 fn envelope_pack_minimal() {
853 let event = ExperienceEvent::BudgetExceeded {
854 chain_id: "c".into(),
855 resource: BudgetResource::Cycles,
856 limit: "5".to_string(),
857 observed: None,
858 };
859 let env = ExperienceEventEnvelope::new("e1", event);
860
861 let json = serde_json::to_string(&env).unwrap();
862 assert!(json.contains("e1"));
863 assert!(json.contains("1970-01-01T00:00:00Z"));
864 assert!(json.contains("tenant_id"));
866 assert!(json.contains("correlation_id"));
867 }
868
869 #[test]
872 fn envelope_unpack_with_all_fields() {
873 let json = r#"{
874 "event_id": "evt-1",
875 "occurred_at": "2026-04-28T12:00:00Z",
876 "tenant_id": "tenant-x",
877 "correlation_id": "corr-1",
878 "event": {
879 "type": "BudgetExceeded",
880 "data": {
881 "chain_id": "c-1",
882 "resource": "Tokens",
883 "limit": "999",
884 "observed": "500"
885 }
886 }
887 }"#;
888
889 let env: ExperienceEventEnvelope = serde_json::from_str(json).unwrap();
890 assert_eq!(env.event_id, "evt-1");
891 assert_eq!(env.occurred_at, "2026-04-28T12:00:00Z");
892 assert_eq!(env.tenant_id.as_deref(), Some("tenant-x"));
893 assert_eq!(env.correlation_id.as_deref(), Some("corr-1"));
894 }
895
896 #[test]
897 fn envelope_unpack_missing_optional_fields() {
898 let json = r#"{
899 "event_id": "evt-minimal",
900 "occurred_at": "2026-01-01T00:00:00Z",
901 "tenant_id": null,
902 "correlation_id": null,
903 "event": {
904 "type": "BudgetExceeded",
905 "data": {
906 "chain_id": "c",
907 "resource": "Cycles",
908 "limit": "1",
909 "observed": null
910 }
911 }
912 }"#;
913
914 let env: ExperienceEventEnvelope = serde_json::from_str(json).unwrap();
915 assert!(env.tenant_id.is_none());
916 assert!(env.correlation_id.is_none());
917 }
918
919 #[test]
920 fn envelope_unpack_missing_optional_keys_entirely() {
921 let json = r#"{
922 "event_id": "evt-sparse",
923 "occurred_at": "2026-01-01T00:00:00Z",
924 "event": {
925 "type": "BudgetExceeded",
926 "data": {
927 "chain_id": "c",
928 "resource": "Facts",
929 "limit": "10",
930 "observed": null
931 }
932 }
933 }"#;
934
935 let env: ExperienceEventEnvelope = serde_json::from_str(json).unwrap();
936 assert_eq!(env.event_id, "evt-sparse");
937 assert!(env.tenant_id.is_none());
938 assert!(env.correlation_id.is_none());
939 }
940
941 #[test]
944 fn envelope_roundtrip_complete() {
945 let event = ExperienceEvent::BudgetExceeded {
946 chain_id: "chain-rt".into(),
947 resource: BudgetResource::Tokens,
948 limit: "777".to_string(),
949 observed: Some("333".to_string()),
950 };
951 let original = ExperienceEventEnvelope::new("evt-rt", event)
952 .with_tenant("tenant-rt")
953 .with_correlation("corr-rt")
954 .with_timestamp("2026-04-28T10:15:30Z");
955
956 let json = serde_json::to_string(&original).unwrap();
957 let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
958
959 assert_eq!(restored.event_id, original.event_id);
960 assert_eq!(restored.occurred_at, original.occurred_at);
961 assert_eq!(restored.tenant_id, original.tenant_id);
962 assert_eq!(restored.correlation_id, original.correlation_id);
963 }
964
965 #[test]
966 fn envelope_roundtrip_minimal() {
967 let event = ExperienceEvent::BudgetExceeded {
968 chain_id: "c".into(),
969 resource: BudgetResource::Cycles,
970 limit: "2".to_string(),
971 observed: None,
972 };
973 let original = ExperienceEventEnvelope::new("evt-min", event);
974
975 let json = serde_json::to_string(&original).unwrap();
976 let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
977
978 assert_eq!(restored.event_id, original.event_id);
979 assert!(restored.tenant_id.is_none());
980 assert!(restored.correlation_id.is_none());
981 }
982
983 #[test]
986 fn envelope_edge_case_empty_event_id() {
987 let event = ExperienceEvent::BudgetExceeded {
988 chain_id: "c".into(),
989 resource: BudgetResource::Cycles,
990 limit: "1".to_string(),
991 observed: None,
992 };
993 let env = ExperienceEventEnvelope::new("", event);
994 assert_eq!(env.event_id, "");
995
996 let json = serde_json::to_string(&env).unwrap();
997 let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
998 assert_eq!(restored.event_id, "");
999 }
1000
1001 #[test]
1002 fn envelope_edge_case_special_chars_in_ids() {
1003 let event = ExperienceEvent::BudgetExceeded {
1004 chain_id: "c".into(),
1005 resource: BudgetResource::Cycles,
1006 limit: "1".to_string(),
1007 observed: None,
1008 };
1009 let special_id = "evt-🚀-/\\\"'";
1010 let env = ExperienceEventEnvelope::new(special_id, event)
1011 .with_tenant("tenant-@#$%^&*()")
1012 .with_correlation("corr-\n\t\r");
1013
1014 let json = serde_json::to_string(&env).unwrap();
1015 let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1016
1017 assert_eq!(restored.event_id, special_id);
1018 assert_eq!(restored.tenant_id.as_deref(), Some("tenant-@#$%^&*()"));
1019 assert_eq!(restored.correlation_id.as_deref(), Some("corr-\n\t\r"));
1020 }
1021
1022 #[test]
1023 fn envelope_edge_case_very_long_strings() {
1024 let long_id = "x".repeat(10_000);
1025 let event = ExperienceEvent::BudgetExceeded {
1026 chain_id: "c".into(),
1027 resource: BudgetResource::Cycles,
1028 limit: "1".to_string(),
1029 observed: None,
1030 };
1031 let env = ExperienceEventEnvelope::new(long_id.clone(), event)
1032 .with_tenant("y".repeat(5_000))
1033 .with_correlation("z".repeat(3_000));
1034
1035 let json = serde_json::to_string(&env).unwrap();
1036 let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1037
1038 assert_eq!(restored.event_id, long_id);
1039 assert_eq!(restored.tenant_id.as_ref().map(|s| s.len()), Some(5_000));
1040 assert_eq!(
1041 restored.correlation_id.as_ref().map(|s| s.len()),
1042 Some(3_000)
1043 );
1044 }
1045
1046 #[test]
1047 fn envelope_edge_case_unicode_in_ids() {
1048 let event = ExperienceEvent::BudgetExceeded {
1049 chain_id: "c".into(),
1050 resource: BudgetResource::Cycles,
1051 limit: "1".to_string(),
1052 observed: None,
1053 };
1054 let env = ExperienceEventEnvelope::new("evt-中文-العربية-русский", event)
1055 .with_tenant("テナント-यन्त्र")
1056 .with_correlation("相関-συσχέτιση");
1057
1058 let json = serde_json::to_string(&env).unwrap();
1059 let restored: ExperienceEventEnvelope = serde_json::from_str(&json).unwrap();
1060
1061 assert_eq!(restored.event_id, "evt-中文-العربية-русский");
1062 assert_eq!(restored.tenant_id.as_deref(), Some("テナント-यन्त्र"));
1063 assert_eq!(restored.correlation_id.as_deref(), Some("相関-συσχέτιση"));
1064 }
1065
1066 #[test]
1069 fn envelope_unpack_missing_required_event_id() {
1070 let json = r#"{
1071 "occurred_at": "2026-01-01T00:00:00Z",
1072 "tenant_id": null,
1073 "correlation_id": null,
1074 "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}}
1075 }"#;
1076
1077 let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1078 assert!(result.is_err());
1079 }
1080
1081 #[test]
1082 fn envelope_unpack_missing_required_occurred_at() {
1083 let json = r#"{
1084 "event_id": "evt-1",
1085 "tenant_id": null,
1086 "correlation_id": null,
1087 "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}}
1088 }"#;
1089
1090 let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1091 assert!(result.is_err());
1092 }
1093
1094 #[test]
1095 fn envelope_unpack_missing_required_event() {
1096 let json = r#"{
1097 "event_id": "evt-1",
1098 "occurred_at": "2026-01-01T00:00:00Z",
1099 "tenant_id": null,
1100 "correlation_id": null
1101 }"#;
1102
1103 let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1104 assert!(result.is_err());
1105 }
1106
1107 #[test]
1108 fn envelope_unpack_wrong_type_for_field() {
1109 let json = r#"{
1110 "event_id": 12345,
1111 "occurred_at": "2026-01-01T00:00:00Z",
1112 "tenant_id": null,
1113 "correlation_id": null,
1114 "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}}
1115 }"#;
1116
1117 let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1118 assert!(result.is_err());
1119 }
1120
1121 #[test]
1122 fn envelope_unpack_invalid_json_syntax() {
1123 let invalid = r#"{"event_id": "evt-1", "occurred_at": "2026-01-01"#;
1124 let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(invalid);
1125 assert!(result.is_err());
1126 }
1127
1128 #[test]
1129 fn envelope_unpack_extra_unknown_fields_ignored() {
1130 let json = r#"{
1131 "event_id": "evt-1",
1132 "occurred_at": "2026-01-01T00:00:00Z",
1133 "tenant_id": null,
1134 "correlation_id": null,
1135 "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}},
1136 "unknown_field": "ignored",
1137 "another_extra": 42
1138 }"#;
1139
1140 let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1141 assert!(result.is_ok());
1143 assert_eq!(result.unwrap().event_id, "evt-1");
1144 }
1145
1146 #[test]
1147 fn envelope_unpack_null_for_event_id_invalid() {
1148 let json = r#"{
1149 "event_id": null,
1150 "occurred_at": "2026-01-01T00:00:00Z",
1151 "tenant_id": null,
1152 "correlation_id": null,
1153 "event": {"type": "BudgetExceeded", "data": {"chain_id": "c", "resource": "Cycles", "limit": "1", "observed": null}}
1154 }"#;
1155
1156 let result: Result<ExperienceEventEnvelope, _> = serde_json::from_str(json);
1157 assert!(result.is_err());
1158 }
1159
1160 #[test]
1163 fn envelope_builder_chaining_consistency() {
1164 let event = ExperienceEvent::BudgetExceeded {
1165 chain_id: "c".into(),
1166 resource: BudgetResource::Cycles,
1167 limit: "1".to_string(),
1168 observed: None,
1169 };
1170 let env = ExperienceEventEnvelope::new("evt-1", event)
1171 .with_tenant("t1")
1172 .with_tenant("t2")
1173 .with_correlation("corr1")
1174 .with_correlation("corr2")
1175 .with_timestamp("2026-01-01T00:00:00Z")
1176 .with_timestamp("2026-12-31T23:59:59Z");
1177
1178 assert_eq!(env.tenant_id.as_deref(), Some("t2"));
1179 assert_eq!(env.correlation_id.as_deref(), Some("corr2"));
1180 assert_eq!(env.occurred_at, "2026-12-31T23:59:59Z");
1181 }
1182
1183 #[test]
1184 fn envelope_builder_override_to_last_value() {
1185 let event = ExperienceEvent::BudgetExceeded {
1186 chain_id: "c".into(),
1187 resource: BudgetResource::Cycles,
1188 limit: "1".to_string(),
1189 observed: None,
1190 };
1191 let final_tenant = "final-tenant";
1192 let final_corr = "final-corr";
1193 let final_ts = "2099-01-01T00:00:00Z";
1194
1195 let env = ExperienceEventEnvelope::new("evt", event)
1196 .with_tenant("ignored1")
1197 .with_tenant("ignored2")
1198 .with_tenant(final_tenant)
1199 .with_correlation("ignored1")
1200 .with_correlation(final_corr)
1201 .with_timestamp("ignored1")
1202 .with_timestamp(final_ts);
1203
1204 assert_eq!(env.tenant_id.as_deref(), Some(final_tenant));
1205 assert_eq!(env.correlation_id.as_deref(), Some(final_corr));
1206 assert_eq!(env.occurred_at, final_ts);
1207 }
1208}