1use std::{any::Any, collections::HashMap, fmt, sync::Arc};
11
12use serde::{Deserialize, Serialize, de::DeserializeOwned};
13use thiserror::Error;
14
15use crate::context::ContextKey;
16use crate::types::{
17 ActorId, ApprovalId, ArtifactId, ContentHash, FactId, GateId, ObservationId, ProposalId,
18 SpanId, SubjectRef, Timestamp, TraceId, TraceReference, TraceSystemId, UnitInterval,
19 ValidationCheckId,
20};
21
22#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
24pub struct FactFamilyId(String);
25
26impl FactFamilyId {
27 #[must_use]
29 pub fn new(value: impl Into<String>) -> Self {
30 Self(value.into())
31 }
32
33 #[must_use]
35 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38}
39
40impl fmt::Display for FactFamilyId {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 f.write_str(&self.0)
43 }
44}
45
46impl From<&'static str> for FactFamilyId {
47 fn from(value: &'static str) -> Self {
48 Self::new(value)
49 }
50}
51
52impl From<String> for FactFamilyId {
53 fn from(value: String) -> Self {
54 Self::new(value)
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
60pub struct PayloadVersion(u16);
61
62impl PayloadVersion {
63 #[must_use]
65 pub const fn new(value: u16) -> Self {
66 Self(value)
67 }
68
69 #[must_use]
71 pub const fn get(self) -> u16 {
72 self.0
73 }
74}
75
76impl fmt::Display for PayloadVersion {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 write!(f, "{}", self.0)
79 }
80}
81
82impl From<u16> for PayloadVersion {
83 fn from(value: u16) -> Self {
84 Self::new(value)
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
90pub struct Provenance(String);
91
92impl Provenance {
93 #[must_use]
95 pub fn new(value: impl Into<String>) -> Self {
96 Self(value.into())
97 }
98
99 #[must_use]
101 pub fn as_str(&self) -> &str {
102 &self.0
103 }
104}
105
106impl fmt::Display for Provenance {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 f.write_str(&self.0)
109 }
110}
111
112impl From<&str> for Provenance {
113 fn from(value: &str) -> Self {
114 Self::new(value)
115 }
116}
117
118impl From<String> for Provenance {
119 fn from(value: String) -> Self {
120 Self::new(value)
121 }
122}
123
124pub trait ProvenanceSource: Copy + Send + Sync + 'static {
161 fn as_str(&self) -> &'static str;
165
166 #[must_use]
168 fn provenance(self) -> Provenance {
169 Provenance::from(self.as_str())
170 }
171
172 #[must_use]
175 fn proposed_fact<T>(
176 self,
177 key: ContextKey,
178 id: impl Into<ProposalId>,
179 payload: T,
180 ) -> ProposedFact
181 where
182 T: FactPayload + PartialEq,
183 {
184 ProposedFact::new(key, id, payload, self.provenance())
185 }
186
187 #[must_use]
191 fn proposed_fact_for<T>(
192 self,
193 source: &ContextFact,
194 key: ContextKey,
195 id: impl Into<ProposalId>,
196 payload: T,
197 ) -> ProposedFact
198 where
199 T: FactPayload + PartialEq,
200 {
201 self.proposed_fact(key, id, payload)
202 .with_subject_from(source)
203 }
204}
205
206pub trait FactPayload: fmt::Debug + Clone + Serialize + Send + Sync + 'static {
211 const FAMILY: &'static str;
213 const VERSION: u16;
215
216 fn validate(&self) -> Result<(), PayloadError> {
219 Ok(())
220 }
221}
222
223trait ErasedFactPayload: fmt::Debug + Send + Sync {
224 fn family(&self) -> FactFamilyId;
225 fn version(&self) -> PayloadVersion;
226 fn validate(&self) -> Result<(), PayloadError>;
227 fn as_any(&self) -> &dyn Any;
228 fn to_json_value(&self) -> Result<serde_json::Value, PayloadError>;
229 fn equivalent(&self, other: &dyn ErasedFactPayload) -> bool;
230}
231
232impl<T> ErasedFactPayload for T
233where
234 T: FactPayload + PartialEq,
235{
236 fn family(&self) -> FactFamilyId {
237 FactFamilyId::from(T::FAMILY)
238 }
239
240 fn version(&self) -> PayloadVersion {
241 PayloadVersion::new(T::VERSION)
242 }
243
244 fn validate(&self) -> Result<(), PayloadError> {
245 FactPayload::validate(self)
246 }
247
248 fn as_any(&self) -> &dyn Any {
249 self
250 }
251
252 fn to_json_value(&self) -> Result<serde_json::Value, PayloadError> {
253 serde_json::to_value(self).map_err(|err| PayloadError::Serialize {
254 family: T::FAMILY.into(),
255 version: T::VERSION.into(),
256 reason: err.to_string(),
257 })
258 }
259
260 fn equivalent(&self, other: &dyn ErasedFactPayload) -> bool {
261 other.as_any().downcast_ref::<T>() == Some(self)
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(deny_unknown_fields)]
269pub struct TextPayload {
270 text: String,
271}
272
273impl TextPayload {
274 #[must_use]
276 pub fn new(text: impl Into<String>) -> Self {
277 Self { text: text.into() }
278 }
279
280 #[must_use]
282 pub fn as_str(&self) -> &str {
283 &self.text
284 }
285}
286
287impl FactPayload for TextPayload {
288 const FAMILY: &'static str = "converge.text";
289 const VERSION: u16 = 1;
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(deny_unknown_fields)]
295pub struct DiagnosticPayload {
296 source: String,
297 message: String,
298}
299
300impl DiagnosticPayload {
301 #[must_use]
303 pub fn new(source: impl Into<String>, message: impl Into<String>) -> Self {
304 Self {
305 source: source.into(),
306 message: message.into(),
307 }
308 }
309
310 #[must_use]
312 pub fn source(&self) -> &str {
313 &self.source
314 }
315
316 #[must_use]
318 pub fn message(&self) -> &str {
319 &self.message
320 }
321}
322
323impl FactPayload for DiagnosticPayload {
324 const FAMILY: &'static str = "converge.diagnostic";
325 const VERSION: u16 = 1;
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct ExecutionProducerIdentity {
332 pub name: String,
334 pub version: String,
336}
337
338impl ExecutionProducerIdentity {
339 #[must_use]
341 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
342 Self {
343 name: name.into(),
344 version: version.into(),
345 }
346 }
347
348 fn validate(&self) -> Result<(), String> {
349 validate_non_empty("producer.name", &self.name)?;
350 validate_non_empty("producer.version", &self.version)
351 }
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356#[serde(deny_unknown_fields)]
357pub struct NativeExecutionIdentity {
358 pub backend: String,
360 pub version: String,
362 pub source_url: String,
364 pub expected_commit: String,
366 pub actual_commit: String,
368 pub source_mode: String,
370}
371
372impl NativeExecutionIdentity {
373 #[must_use]
375 pub fn new(
376 backend: impl Into<String>,
377 version: impl Into<String>,
378 source_url: impl Into<String>,
379 expected_commit: impl Into<String>,
380 actual_commit: impl Into<String>,
381 source_mode: impl Into<String>,
382 ) -> Self {
383 Self {
384 backend: backend.into(),
385 version: version.into(),
386 source_url: source_url.into(),
387 expected_commit: expected_commit.into(),
388 actual_commit: actual_commit.into(),
389 source_mode: source_mode.into(),
390 }
391 }
392
393 fn validate(&self) -> Result<(), String> {
394 validate_non_empty("native_identity.backend", &self.backend)?;
395 validate_non_empty("native_identity.version", &self.version)?;
396 validate_non_empty("native_identity.source_url", &self.source_url)?;
397 validate_non_empty("native_identity.expected_commit", &self.expected_commit)?;
398 validate_non_empty("native_identity.actual_commit", &self.actual_commit)?;
399 validate_non_empty("native_identity.source_mode", &self.source_mode)
400 }
401}
402
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(deny_unknown_fields)]
411pub struct ExecutionIdentity {
412 pub producer: ExecutionProducerIdentity,
414 pub backend: String,
416 pub backend_version: String,
418 pub build_identity: String,
420 pub runtime_config: String,
422 pub native_identity: Option<NativeExecutionIdentity>,
424}
425
426impl ExecutionIdentity {
427 #[must_use]
429 pub fn new(
430 producer: ExecutionProducerIdentity,
431 backend: impl Into<String>,
432 backend_version: impl Into<String>,
433 build_identity: impl Into<String>,
434 runtime_config: impl Into<String>,
435 native_identity: Option<NativeExecutionIdentity>,
436 ) -> Self {
437 Self {
438 producer,
439 backend: backend.into(),
440 backend_version: backend_version.into(),
441 build_identity: build_identity.into(),
442 runtime_config: runtime_config.into(),
443 native_identity,
444 }
445 }
446
447 #[must_use]
449 pub fn non_native(
450 producer_name: impl Into<String>,
451 producer_version: impl Into<String>,
452 backend: impl Into<String>,
453 runtime_config: impl Into<String>,
454 ) -> Self {
455 Self::new(
456 ExecutionProducerIdentity::new(producer_name, producer_version),
457 backend,
458 "not_applicable",
459 "not_applicable",
460 runtime_config,
461 None,
462 )
463 }
464
465 #[must_use]
467 pub fn unspecified(
468 producer_name: impl Into<String>,
469 producer_version: impl Into<String>,
470 ) -> Self {
471 Self::new(
472 ExecutionProducerIdentity::new(producer_name, producer_version),
473 "unknown",
474 "unknown",
475 "unknown",
476 "unknown",
477 None,
478 )
479 }
480
481 #[must_use]
499 pub fn runtime_config_from_typed<T: Serialize>(value: &T) -> String {
500 serde_json::to_string(value)
501 .expect("typed runtime_config must serialize to JSON; check Serialize impl")
502 }
503
504 #[must_use]
508 pub fn with_runtime_config_typed<T: Serialize>(mut self, value: &T) -> Self {
509 self.runtime_config = Self::runtime_config_from_typed(value);
510 self
511 }
512
513 fn validate(&self) -> Result<(), String> {
514 self.producer.validate()?;
515 validate_non_empty("backend", &self.backend)?;
516 validate_non_empty("backend_version", &self.backend_version)?;
517 validate_non_empty("build_identity", &self.build_identity)?;
518 validate_non_empty("runtime_config", &self.runtime_config)?;
519 if let Some(native_identity) = &self.native_identity {
520 native_identity.validate()?;
521 }
522 Ok(())
523 }
524}
525
526#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
528#[serde(deny_unknown_fields)]
529pub struct ExecutionIdentityEvidence {
530 pub subject_key: ContextKey,
532 pub subject_id: String,
534 pub subject_family: FactFamilyId,
536 pub subject_version: PayloadVersion,
538 pub identity: ExecutionIdentity,
540}
541
542impl ExecutionIdentityEvidence {
543 #[must_use]
545 pub fn new(
546 subject_key: ContextKey,
547 subject_id: impl Into<String>,
548 subject_family: impl Into<FactFamilyId>,
549 subject_version: impl Into<PayloadVersion>,
550 identity: ExecutionIdentity,
551 ) -> Self {
552 Self {
553 subject_key,
554 subject_id: subject_id.into(),
555 subject_family: subject_family.into(),
556 subject_version: subject_version.into(),
557 identity,
558 }
559 }
560
561 #[must_use]
563 pub fn for_payload<T: FactPayload>(
564 subject_key: ContextKey,
565 subject_id: impl Into<String>,
566 identity: ExecutionIdentity,
567 ) -> Self {
568 Self::new(subject_key, subject_id, T::FAMILY, T::VERSION, identity)
569 }
570}
571
572impl FactPayload for ExecutionIdentityEvidence {
573 const FAMILY: &'static str = "converge.execution_identity.evidence";
574 const VERSION: u16 = 1;
575
576 fn validate(&self) -> Result<(), PayloadError> {
577 validate_non_empty("subject_id", &self.subject_id).map_err(|reason| {
578 PayloadError::Invalid {
579 family: Self::FAMILY.into(),
580 version: Self::VERSION.into(),
581 reason,
582 }
583 })?;
584 self.identity
585 .validate()
586 .map_err(|reason| PayloadError::Invalid {
587 family: Self::FAMILY.into(),
588 version: Self::VERSION.into(),
589 reason,
590 })
591 }
592}
593
594fn validate_non_empty(field: &str, value: &str) -> Result<(), String> {
595 if value.trim().is_empty() {
596 Err(format!("{field} must not be empty"))
597 } else {
598 Ok(())
599 }
600}
601
602#[derive(Debug, Clone, PartialEq, Eq, Error)]
604pub enum PayloadError {
605 #[error("invalid payload for {family} v{version}: {reason}")]
607 Invalid {
608 family: FactFamilyId,
610 version: PayloadVersion,
612 reason: String,
614 },
615 #[error("failed to serialize payload {family} v{version}: {reason}")]
617 Serialize {
618 family: FactFamilyId,
620 version: PayloadVersion,
622 reason: String,
624 },
625 #[error("failed to deserialize payload {family} v{version}: {reason}")]
627 Deserialize {
628 family: FactFamilyId,
630 version: PayloadVersion,
632 reason: String,
634 },
635 #[error("unknown payload family/version: {family} v{version}")]
637 UnknownFamilyVersion {
638 family: FactFamilyId,
640 version: PayloadVersion,
642 },
643 #[error(
645 "payload type mismatch: expected {expected} v{expected_version}, got {actual} v{actual_version}"
646 )]
647 TypeMismatch {
648 expected: FactFamilyId,
650 expected_version: PayloadVersion,
652 actual: FactFamilyId,
654 actual_version: PayloadVersion,
656 },
657}
658
659#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
662#[serde(deny_unknown_fields)]
663pub struct WireFactPayload {
664 pub family: FactFamilyId,
666 pub version: PayloadVersion,
668 pub payload: serde_json::Value,
670}
671
672impl WireFactPayload {
673 fn from_erased(payload: &dyn ErasedFactPayload) -> Result<Self, PayloadError> {
674 Ok(Self {
675 family: payload.family(),
676 version: payload.version(),
677 payload: payload.to_json_value()?,
678 })
679 }
680}
681
682#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
685#[serde(deny_unknown_fields)]
686pub struct WireProposedFact {
687 pub key: ContextKey,
689 pub id: ProposalId,
691 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub subject: Option<SubjectRef>,
694 pub payload: WireFactPayload,
696 pub confidence: UnitInterval,
698 pub provenance: Provenance,
700}
701
702#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
704#[serde(deny_unknown_fields)]
705pub struct WireContextFact {
706 pub key: ContextKey,
708 pub id: FactId,
710 #[serde(default, skip_serializing_if = "Option::is_none")]
712 pub subject: Option<SubjectRef>,
713 pub payload: WireFactPayload,
715 pub promotion_record: FactPromotionRecord,
717 pub created_at: Timestamp,
719}
720
721type PayloadDecoder = Box<
722 dyn Fn(serde_json::Value) -> Result<Arc<dyn ErasedFactPayload>, PayloadError> + Send + Sync,
723>;
724
725#[derive(Default)]
728pub struct PayloadRegistry {
729 decoders: HashMap<(FactFamilyId, PayloadVersion), PayloadDecoder>,
730}
731
732impl PayloadRegistry {
733 #[must_use]
735 pub fn new() -> Self {
736 Self::default()
737 }
738
739 #[must_use]
741 pub fn with_pack_payloads() -> Self {
742 let mut registry = Self::new();
743 registry.register::<TextPayload>();
744 registry.register::<DiagnosticPayload>();
745 registry.register::<ExecutionIdentityEvidence>();
746 registry.register::<crate::governance::Vote>();
747 registry.register::<crate::governance::Disagreement>();
748 registry.register::<crate::governance::ConsensusOutcome>();
749 registry
750 }
751
752 pub fn register<T>(&mut self)
754 where
755 T: FactPayload + PartialEq + DeserializeOwned,
756 {
757 self.decoders.insert(
758 (
759 FactFamilyId::from(T::FAMILY),
760 PayloadVersion::new(T::VERSION),
761 ),
762 Box::new(|value| {
763 let payload: T =
764 serde_json::from_value(value).map_err(|err| PayloadError::Deserialize {
765 family: T::FAMILY.into(),
766 version: T::VERSION.into(),
767 reason: err.to_string(),
768 })?;
769 payload.validate()?;
770 Ok(Arc::new(payload))
771 }),
772 );
773 }
774
775 fn decode(
776 &self,
777 family: &FactFamilyId,
778 version: PayloadVersion,
779 payload: serde_json::Value,
780 ) -> Result<Arc<dyn ErasedFactPayload>, PayloadError> {
781 let decoder = self
782 .decoders
783 .get(&(family.clone(), version))
784 .ok_or_else(|| PayloadError::UnknownFamilyVersion {
785 family: family.clone(),
786 version,
787 })?;
788 decoder(payload)
789 }
790}
791
792#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
794pub enum FactActorKind {
795 Human,
797 Suggestor,
799 System,
801}
802
803#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
805pub struct FactActor {
806 id: ActorId,
807 kind: FactActorKind,
808}
809
810impl FactActor {
811 #[must_use]
813 pub fn id(&self) -> &ActorId {
814 &self.id
815 }
816
817 #[must_use]
819 pub fn kind(&self) -> FactActorKind {
820 self.kind
821 }
822
823 #[doc(hidden)]
824 pub fn new_projection(id: impl Into<ActorId>, kind: FactActorKind) -> Self {
825 Self {
826 id: id.into(),
827 kind,
828 }
829 }
830}
831
832#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
834pub struct FactValidationSummary {
835 checks_passed: Vec<ValidationCheckId>,
836 checks_skipped: Vec<ValidationCheckId>,
837 warnings: Vec<String>,
838}
839
840impl FactValidationSummary {
841 #[must_use]
843 pub fn checks_passed(&self) -> &[ValidationCheckId] {
844 &self.checks_passed
845 }
846
847 #[must_use]
849 pub fn checks_skipped(&self) -> &[ValidationCheckId] {
850 &self.checks_skipped
851 }
852
853 #[must_use]
855 pub fn warnings(&self) -> &[String] {
856 &self.warnings
857 }
858
859 #[doc(hidden)]
860 pub fn new_projection(
861 checks_passed: Vec<ValidationCheckId>,
862 checks_skipped: Vec<ValidationCheckId>,
863 warnings: Vec<String>,
864 ) -> Self {
865 Self {
866 checks_passed,
867 checks_skipped,
868 warnings,
869 }
870 }
871}
872
873#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
875#[serde(tag = "type", content = "id")]
876pub enum FactEvidenceRef {
877 Observation(ObservationId),
879 HumanApproval(ApprovalId),
881 Derived(ArtifactId),
883}
884
885#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
887pub struct FactLocalTrace {
888 trace_id: TraceId,
889 span_id: SpanId,
890 parent_span_id: Option<SpanId>,
891 sampled: bool,
892}
893
894impl FactLocalTrace {
895 #[must_use]
897 pub fn trace_id(&self) -> &TraceId {
898 &self.trace_id
899 }
900
901 #[must_use]
903 pub fn span_id(&self) -> &SpanId {
904 &self.span_id
905 }
906
907 #[must_use]
909 pub fn parent_span_id(&self) -> Option<&SpanId> {
910 self.parent_span_id.as_ref()
911 }
912
913 #[must_use]
915 pub fn sampled(&self) -> bool {
916 self.sampled
917 }
918
919 #[doc(hidden)]
920 pub fn new_projection(
921 trace_id: impl Into<TraceId>,
922 span_id: impl Into<SpanId>,
923 parent_span_id: Option<SpanId>,
924 sampled: bool,
925 ) -> Self {
926 Self {
927 trace_id: trace_id.into(),
928 span_id: span_id.into(),
929 parent_span_id,
930 sampled,
931 }
932 }
933}
934
935#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
937pub struct FactRemoteTrace {
938 system: TraceSystemId,
939 reference: TraceReference,
940 retrieval_auth: Option<String>,
941 retention_hint: Option<String>,
942}
943
944impl FactRemoteTrace {
945 #[must_use]
947 pub fn system(&self) -> &TraceSystemId {
948 &self.system
949 }
950
951 #[must_use]
953 pub fn reference(&self) -> &TraceReference {
954 &self.reference
955 }
956
957 #[must_use]
959 pub fn retrieval_auth(&self) -> Option<&str> {
960 self.retrieval_auth.as_deref()
961 }
962
963 #[must_use]
965 pub fn retention_hint(&self) -> Option<&str> {
966 self.retention_hint.as_deref()
967 }
968
969 #[doc(hidden)]
970 pub fn new_projection(
971 system: impl Into<TraceSystemId>,
972 reference: impl Into<TraceReference>,
973 retrieval_auth: Option<String>,
974 retention_hint: Option<String>,
975 ) -> Self {
976 Self {
977 system: system.into(),
978 reference: reference.into(),
979 retrieval_auth,
980 retention_hint,
981 }
982 }
983}
984
985#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
987#[serde(tag = "type")]
988pub enum FactTraceLink {
989 Local(FactLocalTrace),
991 Remote(FactRemoteTrace),
993}
994
995impl FactTraceLink {
996 #[must_use]
998 pub fn is_replay_eligible(&self) -> bool {
999 matches!(self, Self::Local(_))
1000 }
1001}
1002
1003#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1005pub struct FactPromotionRecord {
1006 gate_id: GateId,
1007 policy_version_hash: ContentHash,
1008 approver: FactActor,
1009 validation_summary: FactValidationSummary,
1010 evidence_refs: Vec<FactEvidenceRef>,
1011 trace_link: FactTraceLink,
1012 promoted_at: Timestamp,
1013}
1014
1015impl FactPromotionRecord {
1016 #[must_use]
1018 pub fn gate_id(&self) -> &GateId {
1019 &self.gate_id
1020 }
1021
1022 #[must_use]
1024 pub fn policy_version_hash(&self) -> &ContentHash {
1025 &self.policy_version_hash
1026 }
1027
1028 #[must_use]
1030 pub fn approver(&self) -> &FactActor {
1031 &self.approver
1032 }
1033
1034 #[must_use]
1036 pub fn validation_summary(&self) -> &FactValidationSummary {
1037 &self.validation_summary
1038 }
1039
1040 #[must_use]
1042 pub fn evidence_refs(&self) -> &[FactEvidenceRef] {
1043 &self.evidence_refs
1044 }
1045
1046 #[must_use]
1048 pub fn trace_link(&self) -> &FactTraceLink {
1049 &self.trace_link
1050 }
1051
1052 #[must_use]
1054 pub fn promoted_at(&self) -> &Timestamp {
1055 &self.promoted_at
1056 }
1057
1058 #[must_use]
1060 pub fn is_replay_eligible(&self) -> bool {
1061 self.trace_link.is_replay_eligible()
1062 }
1063
1064 #[doc(hidden)]
1065 pub fn new_projection(
1066 gate_id: impl Into<GateId>,
1067 policy_version_hash: ContentHash,
1068 approver: FactActor,
1069 validation_summary: FactValidationSummary,
1070 evidence_refs: Vec<FactEvidenceRef>,
1071 trace_link: FactTraceLink,
1072 promoted_at: impl Into<Timestamp>,
1073 ) -> Self {
1074 Self {
1075 gate_id: gate_id.into(),
1076 policy_version_hash,
1077 approver,
1078 validation_summary,
1079 evidence_refs,
1080 trace_link,
1081 promoted_at: promoted_at.into(),
1082 }
1083 }
1084}
1085
1086#[derive(Clone)]
1093pub struct ContextFact {
1094 key: ContextKey,
1096 id: FactId,
1098 subject: Option<SubjectRef>,
1100 payload: Arc<dyn ErasedFactPayload>,
1102 promotion_record: FactPromotionRecord,
1104 created_at: Timestamp,
1106}
1107
1108impl fmt::Debug for ContextFact {
1109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1110 f.debug_struct("ContextFact")
1111 .field("key", &self.key)
1112 .field("id", &self.id)
1113 .field("subject", &self.subject)
1114 .field("payload_family", &self.payload_family())
1115 .field("payload_version", &self.payload_version())
1116 .field("promotion_record", &self.promotion_record)
1117 .field("created_at", &self.created_at)
1118 .finish()
1119 }
1120}
1121
1122impl PartialEq for ContextFact {
1123 fn eq(&self, other: &Self) -> bool {
1124 self.key == other.key
1125 && self.id == other.id
1126 && self.subject == other.subject
1127 && self.payload_family() == other.payload_family()
1128 && self.payload_version() == other.payload_version()
1129 && self.payload.equivalent(other.payload.as_ref())
1130 && self.promotion_record == other.promotion_record
1131 && self.created_at == other.created_at
1132 }
1133}
1134
1135impl Eq for ContextFact {}
1136
1137impl Serialize for ContextFact {
1138 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1139 where
1140 S: serde::Serializer,
1141 {
1142 self.to_wire()
1143 .map_err(serde::ser::Error::custom)?
1144 .serialize(serializer)
1145 }
1146}
1147
1148impl ContextFact {
1149 #[must_use]
1155 pub fn new_projection<T>(
1156 key: ContextKey,
1157 id: impl Into<FactId>,
1158 payload: T,
1159 promotion_record: FactPromotionRecord,
1160 created_at: impl Into<Timestamp>,
1161 ) -> Self
1162 where
1163 T: FactPayload + PartialEq,
1164 {
1165 Self {
1166 key,
1167 id: id.into(),
1168 subject: None,
1169 payload: Arc::new(payload),
1170 promotion_record,
1171 created_at: created_at.into(),
1172 }
1173 }
1174
1175 pub fn from_wire(
1178 wire: WireContextFact,
1179 registry: &PayloadRegistry,
1180 ) -> Result<Self, PayloadError> {
1181 let payload = registry.decode(
1182 &wire.payload.family,
1183 wire.payload.version,
1184 wire.payload.payload,
1185 )?;
1186 Ok(Self {
1187 key: wire.key,
1188 id: wire.id,
1189 subject: wire.subject,
1190 payload,
1191 promotion_record: wire.promotion_record,
1192 created_at: wire.created_at,
1193 })
1194 }
1195
1196 pub fn to_wire(&self) -> Result<WireContextFact, PayloadError> {
1198 Ok(WireContextFact {
1199 key: self.key,
1200 id: self.id.clone(),
1201 subject: self.subject.clone(),
1202 payload: WireFactPayload::from_erased(self.payload.as_ref())?,
1203 promotion_record: self.promotion_record.clone(),
1204 created_at: self.created_at.clone(),
1205 })
1206 }
1207
1208 #[must_use]
1210 pub fn with_subject(mut self, subject: SubjectRef) -> Self {
1211 self.subject = Some(subject);
1212 self
1213 }
1214
1215 #[must_use]
1217 pub fn key(&self) -> ContextKey {
1218 self.key
1219 }
1220
1221 #[must_use]
1223 pub fn id(&self) -> &FactId {
1224 &self.id
1225 }
1226
1227 #[must_use]
1229 pub fn subject(&self) -> Option<&SubjectRef> {
1230 self.subject.as_ref()
1231 }
1232
1233 #[must_use]
1236 pub fn payload<T: FactPayload>(&self) -> Option<&T> {
1237 self.payload.as_any().downcast_ref::<T>()
1238 }
1239
1240 pub fn require_payload<T: FactPayload>(&self) -> Result<&T, PayloadError> {
1242 self.payload::<T>()
1243 .ok_or_else(|| PayloadError::TypeMismatch {
1244 expected: T::FAMILY.into(),
1245 expected_version: T::VERSION.into(),
1246 actual: self.payload_family(),
1247 actual_version: self.payload_version(),
1248 })
1249 }
1250
1251 #[must_use]
1253 pub fn payload_family(&self) -> FactFamilyId {
1254 self.payload.family()
1255 }
1256
1257 #[must_use]
1259 pub fn payload_version(&self) -> PayloadVersion {
1260 self.payload.version()
1261 }
1262
1263 #[must_use]
1265 pub fn text(&self) -> Option<&str> {
1266 self.payload::<TextPayload>().map(TextPayload::as_str)
1267 }
1268
1269 pub fn validate_payload(&self) -> Result<(), PayloadError> {
1271 self.payload.validate()
1272 }
1273
1274 #[must_use]
1276 pub fn promotion_record(&self) -> &FactPromotionRecord {
1277 &self.promotion_record
1278 }
1279
1280 #[must_use]
1282 pub fn created_at(&self) -> &Timestamp {
1283 &self.created_at
1284 }
1285
1286 #[must_use]
1288 pub fn is_replay_eligible(&self) -> bool {
1289 self.promotion_record.is_replay_eligible()
1290 }
1291}
1292
1293#[derive(Clone)]
1298pub struct ProposedFact {
1299 pub key: ContextKey,
1301 pub id: ProposalId,
1303 subject: Option<SubjectRef>,
1305 payload: Arc<dyn ErasedFactPayload>,
1307 confidence: UnitInterval,
1309 pub provenance: Provenance,
1311}
1312
1313impl fmt::Debug for ProposedFact {
1314 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1315 f.debug_struct("ProposedFact")
1316 .field("key", &self.key)
1317 .field("id", &self.id)
1318 .field("subject", &self.subject)
1319 .field("payload_family", &self.payload_family())
1320 .field("payload_version", &self.payload_version())
1321 .field("confidence", &self.confidence)
1322 .field("provenance", &self.provenance)
1323 .finish()
1324 }
1325}
1326
1327impl PartialEq for ProposedFact {
1328 fn eq(&self, other: &Self) -> bool {
1329 self.key == other.key
1330 && self.id == other.id
1331 && self.subject == other.subject
1332 && self.payload_family() == other.payload_family()
1333 && self.payload_version() == other.payload_version()
1334 && self.payload.equivalent(other.payload.as_ref())
1335 && self.confidence == other.confidence
1336 && self.provenance == other.provenance
1337 }
1338}
1339
1340impl Serialize for ProposedFact {
1341 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1342 where
1343 S: serde::Serializer,
1344 {
1345 self.to_wire()
1346 .map_err(serde::ser::Error::custom)?
1347 .serialize(serializer)
1348 }
1349}
1350
1351impl ProposedFact {
1352 #[must_use]
1356 pub fn new<T>(
1357 key: ContextKey,
1358 id: impl Into<ProposalId>,
1359 payload: T,
1360 provenance: impl Into<Provenance>,
1361 ) -> Self
1362 where
1363 T: FactPayload + PartialEq,
1364 {
1365 Self {
1366 key,
1367 id: id.into(),
1368 subject: None,
1369 payload: Arc::new(payload),
1370 confidence: UnitInterval::ONE,
1371 provenance: provenance.into(),
1372 }
1373 }
1374
1375 pub fn from_wire(
1378 wire: WireProposedFact,
1379 registry: &PayloadRegistry,
1380 ) -> Result<Self, PayloadError> {
1381 let payload = registry.decode(
1382 &wire.payload.family,
1383 wire.payload.version,
1384 wire.payload.payload,
1385 )?;
1386 Ok(Self {
1387 key: wire.key,
1388 id: wire.id,
1389 subject: wire.subject,
1390 payload,
1391 confidence: wire.confidence,
1392 provenance: wire.provenance,
1393 })
1394 }
1395
1396 pub fn to_wire(&self) -> Result<WireProposedFact, PayloadError> {
1398 Ok(WireProposedFact {
1399 key: self.key,
1400 id: self.id.clone(),
1401 subject: self.subject.clone(),
1402 payload: WireFactPayload::from_erased(self.payload.as_ref())?,
1403 confidence: self.confidence,
1404 provenance: self.provenance.clone(),
1405 })
1406 }
1407
1408 #[must_use]
1415 pub fn to_context_fact(
1416 &self,
1417 id: impl Into<FactId>,
1418 promotion_record: FactPromotionRecord,
1419 created_at: impl Into<Timestamp>,
1420 ) -> ContextFact {
1421 ContextFact {
1422 key: self.key,
1423 id: id.into(),
1424 subject: self.subject.clone(),
1425 payload: Arc::clone(&self.payload),
1426 promotion_record,
1427 created_at: created_at.into(),
1428 }
1429 }
1430
1431 #[must_use]
1433 pub fn with_subject(mut self, subject: SubjectRef) -> Self {
1434 self.subject = Some(subject);
1435 self
1436 }
1437
1438 #[must_use]
1443 pub fn with_subject_from(mut self, source: &ContextFact) -> Self {
1444 self.subject = source.subject().cloned();
1445 self
1446 }
1447
1448 #[must_use]
1450 pub fn key(&self) -> ContextKey {
1451 self.key
1452 }
1453
1454 #[must_use]
1456 pub fn id(&self) -> &ProposalId {
1457 &self.id
1458 }
1459
1460 #[must_use]
1462 pub fn subject(&self) -> Option<&SubjectRef> {
1463 self.subject.as_ref()
1464 }
1465
1466 #[must_use]
1469 pub fn payload<T: FactPayload>(&self) -> Option<&T> {
1470 self.payload.as_any().downcast_ref::<T>()
1471 }
1472
1473 pub fn require_payload<T: FactPayload>(&self) -> Result<&T, PayloadError> {
1475 self.payload::<T>()
1476 .ok_or_else(|| PayloadError::TypeMismatch {
1477 expected: T::FAMILY.into(),
1478 expected_version: T::VERSION.into(),
1479 actual: self.payload_family(),
1480 actual_version: self.payload_version(),
1481 })
1482 }
1483
1484 #[must_use]
1486 pub fn payload_family(&self) -> FactFamilyId {
1487 self.payload.family()
1488 }
1489
1490 #[must_use]
1492 pub fn payload_version(&self) -> PayloadVersion {
1493 self.payload.version()
1494 }
1495
1496 #[must_use]
1498 pub fn text(&self) -> Option<&str> {
1499 self.payload::<TextPayload>().map(TextPayload::as_str)
1500 }
1501
1502 pub fn validate_payload(&self) -> Result<(), PayloadError> {
1504 self.payload.validate()
1505 }
1506
1507 #[must_use]
1509 pub fn provenance_ref(&self) -> &Provenance {
1510 &self.provenance
1511 }
1512
1513 #[must_use]
1515 pub fn provenance(&self) -> &str {
1516 self.provenance.as_str()
1517 }
1518
1519 #[must_use]
1521 pub fn confidence(&self) -> f64 {
1522 self.confidence.as_f64()
1523 }
1524
1525 #[must_use]
1533 pub fn with_confidence(mut self, confidence: f64) -> Self {
1534 self.confidence = UnitInterval::clamped(confidence);
1535 self
1536 }
1537
1538 #[must_use]
1556 pub fn adjust_confidence(mut self, delta: f64) -> Self {
1557 self.confidence = self.confidence.saturating_add(delta);
1558 self
1559 }
1560}
1561
1562pub const CONFIDENCE_STEP_TINY: f64 = 0.05;
1564
1565pub const CONFIDENCE_STEP_MINOR: f64 = 0.1;
1567
1568pub const CONFIDENCE_STEP_MEDIUM: f64 = 0.15;
1570
1571pub const CONFIDENCE_STEP_MAJOR: f64 = 0.2;
1573
1574pub const CONFIDENCE_STEP_PRIMARY: f64 = 0.25;
1576
1577#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1579pub struct ValidationError {
1580 pub reason: String,
1582}
1583
1584impl std::fmt::Display for ValidationError {
1585 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1586 write!(f, "validation failed: {}", self.reason)
1587 }
1588}
1589
1590impl std::error::Error for ValidationError {}
1591
1592#[cfg(test)]
1593mod tests {
1594 use super::*;
1595
1596 #[derive(Clone, Copy, Debug)]
1597 struct TestProvenance;
1598
1599 impl ProvenanceSource for TestProvenance {
1600 fn as_str(&self) -> &'static str {
1601 "test-provenance"
1602 }
1603 }
1604
1605 fn projection_record() -> FactPromotionRecord {
1606 FactPromotionRecord::new_projection(
1607 "projection-test",
1608 ContentHash::from_hex(
1609 "1111111111111111111111111111111111111111111111111111111111111111",
1610 ),
1611 FactActor::new_projection("actor-1", FactActorKind::System),
1612 FactValidationSummary::default(),
1613 Vec::new(),
1614 FactTraceLink::Local(FactLocalTrace::new_projection(
1615 "trace-1", "span-1", None, true,
1616 )),
1617 Timestamp::epoch(),
1618 )
1619 }
1620
1621 fn projection_fact(
1622 key: ContextKey,
1623 id: impl Into<FactId>,
1624 content: impl Into<String>,
1625 ) -> ContextFact {
1626 ContextFact::new_projection(
1627 key,
1628 id,
1629 TextPayload::new(content),
1630 projection_record(),
1631 Timestamp::epoch(),
1632 )
1633 }
1634
1635 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1636 #[serde(deny_unknown_fields)]
1637 struct TestPayload {
1638 kind: String,
1639 score: f64,
1640 }
1641
1642 impl FactPayload for TestPayload {
1643 const FAMILY: &'static str = "test.payload";
1644 const VERSION: u16 = 1;
1645 }
1646
1647 fn native_identity() -> NativeExecutionIdentity {
1648 NativeExecutionIdentity::new(
1649 "CVC5",
1650 "1.3.3",
1651 "https://github.com/cvc5/cvc5",
1652 "expected",
1653 "actual",
1654 "vendored",
1655 )
1656 }
1657
1658 #[test]
1659 fn execution_identity_evidence_targets_typed_payload() {
1660 let identity = ExecutionIdentity::new(
1661 ExecutionProducerIdentity::new("soter", "0.1.0"),
1662 "cvc5",
1663 "1.3.3",
1664 "configure_flags=--no-poly",
1665 "timeout_ms=5000",
1666 Some(native_identity()),
1667 );
1668 let evidence = ExecutionIdentityEvidence::for_payload::<TestPayload>(
1669 ContextKey::Evaluations,
1670 "smt-report-q1",
1671 identity,
1672 );
1673
1674 assert_eq!(evidence.subject_key, ContextKey::Evaluations);
1675 assert_eq!(evidence.subject_id, "smt-report-q1");
1676 assert_eq!(evidence.subject_family, FactFamilyId::from("test.payload"));
1677 assert_eq!(evidence.subject_version, PayloadVersion::new(1));
1678 assert_eq!(evidence.identity.backend, "cvc5");
1679 assert!(FactPayload::validate(&evidence).is_ok());
1680 }
1681
1682 #[test]
1683 fn execution_identity_evidence_rejects_empty_subject_id() {
1684 let evidence = ExecutionIdentityEvidence::for_payload::<TestPayload>(
1685 ContextKey::Strategies,
1686 "",
1687 ExecutionIdentity::non_native("ferrox", "0.5.1", "greedy", "tasks=3"),
1688 );
1689
1690 assert!(matches!(
1691 FactPayload::validate(&evidence),
1692 Err(PayloadError::Invalid { .. })
1693 ));
1694 }
1695
1696 #[test]
1697 fn trace_link_local_is_replay_eligible() {
1698 let local = FactTraceLink::Local(FactLocalTrace {
1699 trace_id: "t1".into(),
1700 span_id: "s1".into(),
1701 parent_span_id: None,
1702 sampled: true,
1703 });
1704 assert!(local.is_replay_eligible());
1705 }
1706
1707 #[test]
1708 fn trace_link_remote_is_not_replay_eligible() {
1709 let remote = FactTraceLink::Remote(FactRemoteTrace {
1710 system: "datadog".into(),
1711 reference: "ref-1".into(),
1712 retrieval_auth: None,
1713 retention_hint: None,
1714 });
1715 assert!(!remote.is_replay_eligible());
1716 }
1717
1718 #[test]
1719 fn promotion_record_delegates_replay_eligibility() {
1720 let local_record = FactPromotionRecord::new_projection(
1721 "gate-1",
1722 ContentHash::from_hex(
1723 "1111111111111111111111111111111111111111111111111111111111111111",
1724 ),
1725 FactActor::new_projection("actor-1", FactActorKind::Human),
1726 FactValidationSummary::default(),
1727 Vec::new(),
1728 FactTraceLink::Local(FactLocalTrace::new_projection("t1", "s1", None, true)),
1729 "2026-01-01T00:00:00Z",
1730 );
1731 assert!(local_record.is_replay_eligible());
1732
1733 let remote_record = FactPromotionRecord::new_projection(
1734 "gate-2",
1735 ContentHash::from_hex(
1736 "2222222222222222222222222222222222222222222222222222222222222222",
1737 ),
1738 FactActor::new_projection("actor-2", FactActorKind::System),
1739 FactValidationSummary::default(),
1740 Vec::new(),
1741 FactTraceLink::Remote(FactRemoteTrace::new_projection("dd", "ref-1", None, None)),
1742 "2026-01-01T00:00:00Z",
1743 );
1744 assert!(!remote_record.is_replay_eligible());
1745 }
1746
1747 #[test]
1748 fn fact_delegates_replay_eligibility() {
1749 let fact = projection_fact(ContextKey::Seeds, "f1", "content");
1750 assert!(fact.is_replay_eligible());
1751 }
1752
1753 #[test]
1754 fn proposed_fact_new_sets_fields() {
1755 let pf = ProposedFact::new(
1756 ContextKey::Hypotheses,
1757 "p1",
1758 TextPayload::new("my content"),
1759 TestProvenance.provenance(),
1760 );
1761 assert_eq!(pf.key, ContextKey::Hypotheses);
1762 assert_eq!(pf.id, "p1");
1763 assert_eq!(pf.text(), Some("my content"));
1764 assert_eq!(pf.confidence(), 1.0);
1765 assert_eq!(pf.provenance(), "test-provenance");
1766 }
1767
1768 #[test]
1769 fn proposed_fact_with_confidence() {
1770 let pf = ProposedFact::new(
1771 ContextKey::Signals,
1772 "p2",
1773 TextPayload::new("c"),
1774 TestProvenance.provenance(),
1775 )
1776 .with_confidence(0.42);
1777 assert!((pf.confidence() - 0.42).abs() < f64::EPSILON);
1778 }
1779
1780 #[test]
1781 fn adjust_confidence_accumulates() {
1782 let pf = ProposedFact::new(
1783 ContextKey::Seeds,
1784 "p",
1785 TextPayload::new("c"),
1786 TestProvenance.provenance(),
1787 )
1788 .with_confidence(0.5)
1789 .adjust_confidence(CONFIDENCE_STEP_MINOR)
1790 .adjust_confidence(CONFIDENCE_STEP_MAJOR);
1791 assert!((pf.confidence() - 0.8).abs() < f64::EPSILON);
1792 }
1793
1794 #[test]
1795 fn adjust_confidence_clamps_at_one() {
1796 let pf = ProposedFact::new(
1797 ContextKey::Seeds,
1798 "p",
1799 TextPayload::new("c"),
1800 TestProvenance.provenance(),
1801 )
1802 .with_confidence(0.9)
1803 .adjust_confidence(CONFIDENCE_STEP_MAJOR);
1804 assert_eq!(pf.confidence(), 1.0);
1805 }
1806
1807 #[test]
1808 fn adjust_confidence_clamps_at_zero() {
1809 let pf = ProposedFact::new(
1810 ContextKey::Seeds,
1811 "p",
1812 TextPayload::new("c"),
1813 TestProvenance.provenance(),
1814 )
1815 .with_confidence(0.1)
1816 .adjust_confidence(-0.5);
1817 assert_eq!(pf.confidence(), 0.0);
1818 }
1819
1820 #[test]
1821 fn with_confidence_clamps_high() {
1822 let pf = ProposedFact::new(
1823 ContextKey::Seeds,
1824 "p",
1825 TextPayload::new("c"),
1826 TestProvenance.provenance(),
1827 )
1828 .with_confidence(1.5);
1829 assert_eq!(pf.confidence(), 1.0);
1830 }
1831
1832 #[test]
1833 fn with_confidence_clamps_negative() {
1834 let pf = ProposedFact::new(
1835 ContextKey::Seeds,
1836 "p",
1837 TextPayload::new("c"),
1838 TestProvenance.provenance(),
1839 )
1840 .with_confidence(-0.1);
1841 assert_eq!(pf.confidence(), 0.0);
1842 }
1843
1844 #[test]
1845 fn with_confidence_normalizes_nan() {
1846 let pf = ProposedFact::new(
1847 ContextKey::Seeds,
1848 "p",
1849 TextPayload::new("c"),
1850 TestProvenance.provenance(),
1851 )
1852 .with_confidence(f64::NAN);
1853 assert_eq!(pf.confidence(), 0.0);
1854 }
1855
1856 #[test]
1857 fn with_confidence_normalizes_infinity() {
1858 let pf = ProposedFact::new(
1859 ContextKey::Seeds,
1860 "p",
1861 TextPayload::new("c"),
1862 TestProvenance.provenance(),
1863 )
1864 .with_confidence(f64::INFINITY);
1865 assert_eq!(pf.confidence(), 0.0);
1866 }
1867
1868 #[test]
1869 fn wire_proposed_fact_deserialization_rejects_out_of_range_confidence() {
1870 let json = r#"{
1871 "key":"Seeds",
1872 "id":"p",
1873 "payload":{
1874 "family":"converge.text",
1875 "version":1,
1876 "payload":{"text":"c"}
1877 },
1878 "confidence":1.5,
1879 "provenance":"test"
1880 }"#;
1881 let result = serde_json::from_str::<WireProposedFact>(json);
1882 assert!(result.is_err());
1883 }
1884
1885 #[test]
1886 fn proposed_fact_wire_round_trips_through_registry() {
1887 let payload = TestPayload {
1888 kind: "vote".into(),
1889 score: 0.7,
1890 };
1891 let subject = SubjectRef::parse("atlas://acquisition-assets/shared-identity-core")
1892 .expect("valid subject");
1893 let pf = ProposedFact::new(
1894 ContextKey::Hypotheses,
1895 "p",
1896 payload.clone(),
1897 TestProvenance.provenance(),
1898 )
1899 .with_subject(subject.clone());
1900 let wire = pf.to_wire().unwrap();
1901 let mut registry = PayloadRegistry::new();
1902 registry.register::<TestPayload>();
1903
1904 let decoded = ProposedFact::from_wire(wire, ®istry).unwrap();
1905
1906 assert_eq!(decoded.key, ContextKey::Hypotheses);
1907 assert_eq!(decoded.id, "p");
1908 assert_eq!(decoded.subject(), Some(&subject));
1909 assert_eq!(decoded.provenance(), "test-provenance");
1910 assert_eq!(decoded.require_payload::<TestPayload>().unwrap(), &payload);
1911 }
1912
1913 #[test]
1914 fn proposed_fact_from_wire_fails_closed_for_unknown_family_version() {
1915 let wire = WireProposedFact {
1916 key: ContextKey::Hypotheses,
1917 id: "p".into(),
1918 subject: None,
1919 payload: WireFactPayload {
1920 family: FactFamilyId::new("unknown.payload"),
1921 version: PayloadVersion::new(1),
1922 payload: serde_json::json!({"kind":"vote"}),
1923 },
1924 confidence: UnitInterval::ONE,
1925 provenance: TestProvenance.provenance(),
1926 };
1927
1928 let registry = PayloadRegistry::new();
1929 let result = ProposedFact::from_wire(wire, ®istry);
1930
1931 assert!(matches!(
1932 result,
1933 Err(PayloadError::UnknownFamilyVersion { .. })
1934 ));
1935 }
1936
1937 #[test]
1938 fn context_fact_wire_round_trips_through_registry() {
1939 let payload = TestPayload {
1940 kind: "fact".into(),
1941 score: 0.9,
1942 };
1943 let subject = SubjectRef::parse("quorum://unresolved-questions/identity-owner-coverage")
1944 .expect("valid subject");
1945 let fact = ContextFact::new_projection(
1946 ContextKey::Seeds,
1947 "f",
1948 payload.clone(),
1949 projection_record(),
1950 Timestamp::epoch(),
1951 )
1952 .with_subject(subject.clone());
1953 let wire = fact.to_wire().unwrap();
1954 let mut registry = PayloadRegistry::new();
1955 registry.register::<TestPayload>();
1956
1957 let decoded = ContextFact::from_wire(wire, ®istry).unwrap();
1958
1959 assert_eq!(decoded.key(), ContextKey::Seeds);
1960 assert_eq!(decoded.id(), "f");
1961 assert_eq!(decoded.subject(), Some(&subject));
1962 assert_eq!(decoded.require_payload::<TestPayload>().unwrap(), &payload);
1963 }
1964
1965 #[test]
1966 fn proposed_fact_to_context_fact_preserves_typed_payload() {
1967 let payload = TestPayload {
1968 kind: "proposal".into(),
1969 score: 0.8,
1970 };
1971 let proposal = ProposedFact::new(
1972 ContextKey::Strategies,
1973 "p",
1974 payload.clone(),
1975 TestProvenance.provenance(),
1976 )
1977 .with_subject(
1978 SubjectRef::parse("warden://dd-gates/dd-evidence.identity-data-residency")
1979 .expect("valid subject"),
1980 );
1981
1982 let fact = proposal.to_context_fact("f", projection_record(), Timestamp::epoch());
1983
1984 assert_eq!(fact.key(), ContextKey::Strategies);
1985 assert_eq!(fact.subject(), proposal.subject());
1986 assert_eq!(fact.require_payload::<TestPayload>().unwrap(), &payload);
1987 }
1988
1989 #[test]
1990 fn proposed_fact_with_subject_from_copies_source_subject() {
1991 let subject = SubjectRef::parse("atlas://acquisition-assets/shared-identity-core")
1992 .expect("valid subject");
1993 let source = projection_fact(ContextKey::Seeds, "seed-1", "source").with_subject(subject);
1994
1995 let pf = ProposedFact::new(
1996 ContextKey::Hypotheses,
1997 "p1",
1998 TextPayload::new("derived"),
1999 TestProvenance.provenance(),
2000 )
2001 .with_subject_from(&source);
2002
2003 assert_eq!(pf.subject(), source.subject());
2004 }
2005
2006 #[test]
2007 fn provenance_source_proposed_fact_for_copies_source_subject() {
2008 let subject = SubjectRef::parse("atlas://acquisition-assets/shared-identity-core")
2009 .expect("valid subject");
2010 let source = projection_fact(ContextKey::Seeds, "seed-1", "source").with_subject(subject);
2011
2012 let pf = TestProvenance.proposed_fact_for(
2013 &source,
2014 ContextKey::Hypotheses,
2015 "p1",
2016 TextPayload::new("derived"),
2017 );
2018
2019 assert_eq!(pf.subject(), source.subject());
2020 assert_eq!(pf.provenance(), "test-provenance");
2021 }
2022
2023 #[test]
2024 fn validation_error_display() {
2025 let err = ValidationError {
2026 reason: "bad input".into(),
2027 };
2028 assert_eq!(err.to_string(), "validation failed: bad input");
2029 }
2030
2031 #[test]
2032 fn validation_error_is_std_error() {
2033 let err = ValidationError {
2034 reason: "test".into(),
2035 };
2036 let _: &dyn std::error::Error = &err;
2037 }
2038
2039 #[test]
2040 fn fact_accessors() {
2041 let fact = projection_fact(ContextKey::Constraints, "f2", "body");
2042 assert_eq!(fact.key(), ContextKey::Constraints);
2043 assert_eq!(fact.id(), "f2");
2044 assert_eq!(fact.text(), Some("body"));
2045 assert_eq!(fact.created_at(), "1970-01-01T00:00:00Z");
2046 assert_eq!(fact.promotion_record().gate_id(), "projection-test");
2047 }
2048
2049 #[test]
2050 fn fact_actor_accessors() {
2051 let actor = FactActor::new_projection("agent-x", FactActorKind::Suggestor);
2052 assert_eq!(actor.id(), "agent-x");
2053 assert_eq!(actor.kind(), FactActorKind::Suggestor);
2054 }
2055
2056 #[test]
2057 fn validation_summary_accessors() {
2058 let vs = FactValidationSummary::new_projection(
2059 vec!["check-a".into()],
2060 vec!["check-b".into()],
2061 vec!["warn-c".into()],
2062 );
2063 assert_eq!(vs.checks_passed(), &["check-a"]);
2064 assert_eq!(vs.checks_skipped(), &["check-b"]);
2065 assert_eq!(vs.warnings(), &["warn-c"]);
2066 }
2067
2068 #[test]
2069 fn local_trace_accessors() {
2070 let lt =
2071 FactLocalTrace::new_projection("trace-1", "span-1", Some("parent-1".into()), false);
2072 assert_eq!(lt.trace_id(), "trace-1");
2073 assert_eq!(lt.span_id(), "span-1");
2074 assert_eq!(lt.parent_span_id().map(SpanId::as_str), Some("parent-1"));
2075 assert!(!lt.sampled());
2076 }
2077
2078 #[test]
2079 fn remote_trace_accessors() {
2080 let rt =
2081 FactRemoteTrace::new_projection("sys", "ref", Some("auth".into()), Some("30d".into()));
2082 assert_eq!(rt.system(), "sys");
2083 assert_eq!(rt.reference(), "ref");
2084 assert_eq!(rt.retrieval_auth(), Some("auth"));
2085 assert_eq!(rt.retention_hint(), Some("30d"));
2086 }
2087
2088 mod prop {
2089 use super::*;
2090 use proptest::prelude::*;
2091
2092 fn arb_context_key() -> impl Strategy<Value = ContextKey> {
2093 prop_oneof![
2094 Just(ContextKey::Seeds),
2095 Just(ContextKey::Hypotheses),
2096 Just(ContextKey::Strategies),
2097 Just(ContextKey::Constraints),
2098 Just(ContextKey::Signals),
2099 Just(ContextKey::Competitors),
2100 Just(ContextKey::Evaluations),
2101 Just(ContextKey::Proposals),
2102 Just(ContextKey::Diagnostic),
2103 Just(ContextKey::Votes),
2104 Just(ContextKey::Disagreements),
2105 Just(ContextKey::ConsensusOutcomes),
2106 ]
2107 }
2108
2109 proptest! {
2110 #[test]
2111 fn proposed_fact_always_constructible(
2112 key in arb_context_key(),
2113 id in "[a-z]{1,20}",
2114 content in ".*",
2115 prov in "[a-z0-9-]{1,30}",
2116 ) {
2117 let pf = ProposedFact::new(
2118 key,
2119 id.clone(),
2120 TextPayload::new(content.clone()),
2121 Provenance::new(prov.clone()),
2122 );
2123 prop_assert_eq!(pf.key, key);
2124 prop_assert_eq!(&pf.id, &id);
2125 prop_assert_eq!(pf.text(), Some(content.as_str()));
2126 prop_assert_eq!(pf.provenance(), prov.as_str());
2127 prop_assert!((pf.confidence() - 1.0).abs() < f64::EPSILON);
2128 }
2129 }
2130 }
2131}