1use serde::{Deserialize, Serialize};
11
12use crate::context::ContextKey;
13use crate::types::{
14 ActorId, ApprovalId, ArtifactId, ContentHash, FactId, GateId, ObservationId, ProposalId,
15 SpanId, Timestamp, TraceId, TraceReference, TraceSystemId, ValidationCheckId,
16};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20pub enum FactActorKind {
21 Human,
23 Suggestor,
25 System,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
31pub struct FactActor {
32 id: ActorId,
33 kind: FactActorKind,
34}
35
36impl FactActor {
37 #[must_use]
39 pub fn id(&self) -> &ActorId {
40 &self.id
41 }
42
43 #[must_use]
45 pub fn kind(&self) -> FactActorKind {
46 self.kind
47 }
48
49 #[cfg(feature = "kernel-authority")]
50 #[doc(hidden)]
51 pub fn new(id: impl Into<ActorId>, kind: FactActorKind) -> Self {
52 Self {
53 id: id.into(),
54 kind,
55 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
61pub struct FactValidationSummary {
62 checks_passed: Vec<ValidationCheckId>,
63 checks_skipped: Vec<ValidationCheckId>,
64 warnings: Vec<String>,
65}
66
67impl FactValidationSummary {
68 #[must_use]
70 pub fn checks_passed(&self) -> &[ValidationCheckId] {
71 &self.checks_passed
72 }
73
74 #[must_use]
76 pub fn checks_skipped(&self) -> &[ValidationCheckId] {
77 &self.checks_skipped
78 }
79
80 #[must_use]
82 pub fn warnings(&self) -> &[String] {
83 &self.warnings
84 }
85
86 #[cfg(feature = "kernel-authority")]
87 #[doc(hidden)]
88 pub fn new(
89 checks_passed: Vec<ValidationCheckId>,
90 checks_skipped: Vec<ValidationCheckId>,
91 warnings: Vec<String>,
92 ) -> Self {
93 Self {
94 checks_passed,
95 checks_skipped,
96 warnings,
97 }
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103#[serde(tag = "type", content = "id")]
104pub enum FactEvidenceRef {
105 Observation(ObservationId),
107 HumanApproval(ApprovalId),
109 Derived(ArtifactId),
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115pub struct FactLocalTrace {
116 trace_id: TraceId,
117 span_id: SpanId,
118 parent_span_id: Option<SpanId>,
119 sampled: bool,
120}
121
122impl FactLocalTrace {
123 #[must_use]
125 pub fn trace_id(&self) -> &TraceId {
126 &self.trace_id
127 }
128
129 #[must_use]
131 pub fn span_id(&self) -> &SpanId {
132 &self.span_id
133 }
134
135 #[must_use]
137 pub fn parent_span_id(&self) -> Option<&SpanId> {
138 self.parent_span_id.as_ref()
139 }
140
141 #[must_use]
143 pub fn sampled(&self) -> bool {
144 self.sampled
145 }
146
147 #[cfg(feature = "kernel-authority")]
148 #[doc(hidden)]
149 pub fn new(
150 trace_id: impl Into<TraceId>,
151 span_id: impl Into<SpanId>,
152 parent_span_id: Option<SpanId>,
153 sampled: bool,
154 ) -> Self {
155 Self {
156 trace_id: trace_id.into(),
157 span_id: span_id.into(),
158 parent_span_id,
159 sampled,
160 }
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
166pub struct FactRemoteTrace {
167 system: TraceSystemId,
168 reference: TraceReference,
169 retrieval_auth: Option<String>,
170 retention_hint: Option<String>,
171}
172
173impl FactRemoteTrace {
174 #[must_use]
176 pub fn system(&self) -> &TraceSystemId {
177 &self.system
178 }
179
180 #[must_use]
182 pub fn reference(&self) -> &TraceReference {
183 &self.reference
184 }
185
186 #[must_use]
188 pub fn retrieval_auth(&self) -> Option<&str> {
189 self.retrieval_auth.as_deref()
190 }
191
192 #[must_use]
194 pub fn retention_hint(&self) -> Option<&str> {
195 self.retention_hint.as_deref()
196 }
197
198 #[cfg(feature = "kernel-authority")]
199 #[doc(hidden)]
200 pub fn new(
201 system: impl Into<TraceSystemId>,
202 reference: impl Into<TraceReference>,
203 retrieval_auth: Option<String>,
204 retention_hint: Option<String>,
205 ) -> Self {
206 Self {
207 system: system.into(),
208 reference: reference.into(),
209 retrieval_auth,
210 retention_hint,
211 }
212 }
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
217#[serde(tag = "type")]
218pub enum FactTraceLink {
219 Local(FactLocalTrace),
221 Remote(FactRemoteTrace),
223}
224
225impl FactTraceLink {
226 #[must_use]
228 pub fn is_replay_eligible(&self) -> bool {
229 matches!(self, Self::Local(_))
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
235pub struct FactPromotionRecord {
236 gate_id: GateId,
237 policy_version_hash: ContentHash,
238 approver: FactActor,
239 validation_summary: FactValidationSummary,
240 evidence_refs: Vec<FactEvidenceRef>,
241 trace_link: FactTraceLink,
242 promoted_at: Timestamp,
243}
244
245impl FactPromotionRecord {
246 #[must_use]
248 pub fn gate_id(&self) -> &GateId {
249 &self.gate_id
250 }
251
252 #[must_use]
254 pub fn policy_version_hash(&self) -> &ContentHash {
255 &self.policy_version_hash
256 }
257
258 #[must_use]
260 pub fn approver(&self) -> &FactActor {
261 &self.approver
262 }
263
264 #[must_use]
266 pub fn validation_summary(&self) -> &FactValidationSummary {
267 &self.validation_summary
268 }
269
270 #[must_use]
272 pub fn evidence_refs(&self) -> &[FactEvidenceRef] {
273 &self.evidence_refs
274 }
275
276 #[must_use]
278 pub fn trace_link(&self) -> &FactTraceLink {
279 &self.trace_link
280 }
281
282 #[must_use]
284 pub fn promoted_at(&self) -> &Timestamp {
285 &self.promoted_at
286 }
287
288 #[must_use]
290 pub fn is_replay_eligible(&self) -> bool {
291 self.trace_link.is_replay_eligible()
292 }
293
294 #[cfg(feature = "kernel-authority")]
295 #[doc(hidden)]
296 pub fn new(
297 gate_id: impl Into<GateId>,
298 policy_version_hash: ContentHash,
299 approver: FactActor,
300 validation_summary: FactValidationSummary,
301 evidence_refs: Vec<FactEvidenceRef>,
302 trace_link: FactTraceLink,
303 promoted_at: impl Into<Timestamp>,
304 ) -> Self {
305 Self {
306 gate_id: gate_id.into(),
307 policy_version_hash,
308 approver,
309 validation_summary,
310 evidence_refs,
311 trace_link,
312 promoted_at: promoted_at.into(),
313 }
314 }
315}
316
317#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
322pub struct Fact {
323 key: ContextKey,
325 pub id: FactId,
327 pub content: String,
329 promotion_record: FactPromotionRecord,
331 created_at: Timestamp,
333}
334
335impl Fact {
336 #[must_use]
338 pub fn key(&self) -> ContextKey {
339 self.key
340 }
341
342 #[must_use]
344 pub fn promotion_record(&self) -> &FactPromotionRecord {
345 &self.promotion_record
346 }
347
348 #[must_use]
350 pub fn created_at(&self) -> &Timestamp {
351 &self.created_at
352 }
353
354 #[must_use]
356 pub fn is_replay_eligible(&self) -> bool {
357 self.promotion_record.is_replay_eligible()
358 }
359}
360
361#[cfg(feature = "kernel-authority")]
363#[doc(hidden)]
364pub mod kernel_authority {
365 use super::*;
366
367 #[must_use]
369 pub fn new_fact(key: ContextKey, id: impl Into<FactId>, content: impl Into<String>) -> Fact {
370 new_fact_with_promotion(
371 key,
372 id,
373 content,
374 FactPromotionRecord::new(
375 "kernel-authority",
376 ContentHash::zero(),
377 FactActor::new("converge-kernel", FactActorKind::System),
378 FactValidationSummary::default(),
379 Vec::new(),
380 FactTraceLink::Local(FactLocalTrace::new("kernel-authority", "seed", None, true)),
381 Timestamp::epoch(),
382 ),
383 Timestamp::epoch(),
384 )
385 }
386
387 #[must_use]
389 pub fn new_fact_with_promotion(
390 key: ContextKey,
391 id: impl Into<FactId>,
392 content: impl Into<String>,
393 promotion_record: FactPromotionRecord,
394 created_at: impl Into<Timestamp>,
395 ) -> Fact {
396 Fact {
397 key,
398 id: id.into(),
399 content: content.into(),
400 promotion_record,
401 created_at: created_at.into(),
402 }
403 }
404}
405
406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
411pub struct ProposedFact {
412 pub key: ContextKey,
414 pub id: ProposalId,
416 pub content: String,
418 confidence: f64,
420 pub provenance: String,
422}
423
424impl ProposedFact {
425 #[must_use]
429 pub fn new(
430 key: ContextKey,
431 id: impl Into<ProposalId>,
432 content: impl Into<String>,
433 provenance: impl Into<String>,
434 ) -> Self {
435 Self {
436 key,
437 id: id.into(),
438 content: content.into(),
439 confidence: 1.0,
440 provenance: provenance.into(),
441 }
442 }
443
444 #[must_use]
446 pub fn confidence(&self) -> f64 {
447 self.confidence
448 }
449
450 #[must_use]
458 pub fn with_confidence(mut self, confidence: f64) -> Self {
459 self.confidence = if confidence.is_finite() {
460 confidence.clamp(0.0, 1.0)
461 } else {
462 0.0
463 };
464 self
465 }
466
467 #[must_use]
485 pub fn adjust_confidence(mut self, delta: f64) -> Self {
486 self.confidence = (self.confidence + delta).clamp(0.0, 1.0);
487 self
488 }
489}
490
491pub const CONFIDENCE_STEP_TINY: f64 = 0.05;
493
494pub const CONFIDENCE_STEP_MINOR: f64 = 0.1;
496
497pub const CONFIDENCE_STEP_MEDIUM: f64 = 0.15;
499
500pub const CONFIDENCE_STEP_MAJOR: f64 = 0.2;
502
503pub const CONFIDENCE_STEP_PRIMARY: f64 = 0.25;
505
506#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
508pub struct ValidationError {
509 pub reason: String,
511}
512
513impl std::fmt::Display for ValidationError {
514 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
515 write!(f, "validation failed: {}", self.reason)
516 }
517}
518
519impl std::error::Error for ValidationError {}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn trace_link_local_is_replay_eligible() {
527 let local = FactTraceLink::Local(FactLocalTrace {
528 trace_id: "t1".into(),
529 span_id: "s1".into(),
530 parent_span_id: None,
531 sampled: true,
532 });
533 assert!(local.is_replay_eligible());
534 }
535
536 #[test]
537 fn trace_link_remote_is_not_replay_eligible() {
538 let remote = FactTraceLink::Remote(FactRemoteTrace {
539 system: "datadog".into(),
540 reference: "ref-1".into(),
541 retrieval_auth: None,
542 retention_hint: None,
543 });
544 assert!(!remote.is_replay_eligible());
545 }
546
547 #[cfg(feature = "kernel-authority")]
548 #[test]
549 fn promotion_record_delegates_replay_eligibility() {
550 let local_record = FactPromotionRecord::new(
551 "gate-1",
552 ContentHash::from_hex(
553 "1111111111111111111111111111111111111111111111111111111111111111",
554 ),
555 FactActor::new("actor-1", FactActorKind::Human),
556 FactValidationSummary::default(),
557 Vec::new(),
558 FactTraceLink::Local(FactLocalTrace::new("t1", "s1", None, true)),
559 "2026-01-01T00:00:00Z",
560 );
561 assert!(local_record.is_replay_eligible());
562
563 let remote_record = FactPromotionRecord::new(
564 "gate-2",
565 ContentHash::from_hex(
566 "2222222222222222222222222222222222222222222222222222222222222222",
567 ),
568 FactActor::new("actor-2", FactActorKind::System),
569 FactValidationSummary::default(),
570 Vec::new(),
571 FactTraceLink::Remote(FactRemoteTrace::new("dd", "ref-1", None, None)),
572 "2026-01-01T00:00:00Z",
573 );
574 assert!(!remote_record.is_replay_eligible());
575 }
576
577 #[cfg(feature = "kernel-authority")]
578 #[test]
579 fn fact_delegates_replay_eligibility() {
580 let fact = kernel_authority::new_fact(ContextKey::Seeds, "f1", "content");
581 assert!(fact.is_replay_eligible());
582 }
583
584 #[test]
585 fn proposed_fact_new_sets_fields() {
586 let pf = ProposedFact::new(ContextKey::Hypotheses, "p1", "my content", "gpt-4");
587 assert_eq!(pf.key, ContextKey::Hypotheses);
588 assert_eq!(pf.id, "p1");
589 assert_eq!(pf.content, "my content");
590 assert_eq!(pf.confidence(), 1.0);
591 assert_eq!(pf.provenance, "gpt-4");
592 }
593
594 #[test]
595 fn proposed_fact_with_confidence() {
596 let pf = ProposedFact::new(ContextKey::Signals, "p2", "c", "prov").with_confidence(0.42);
597 assert!((pf.confidence() - 0.42).abs() < f64::EPSILON);
598 }
599
600 #[test]
601 fn adjust_confidence_accumulates() {
602 let pf = ProposedFact::new(ContextKey::Seeds, "p", "c", "x")
603 .with_confidence(0.5)
604 .adjust_confidence(CONFIDENCE_STEP_MINOR)
605 .adjust_confidence(CONFIDENCE_STEP_MAJOR);
606 assert!((pf.confidence() - 0.8).abs() < f64::EPSILON);
607 }
608
609 #[test]
610 fn adjust_confidence_clamps_at_one() {
611 let pf = ProposedFact::new(ContextKey::Seeds, "p", "c", "x")
612 .with_confidence(0.9)
613 .adjust_confidence(CONFIDENCE_STEP_MAJOR);
614 assert_eq!(pf.confidence(), 1.0);
615 }
616
617 #[test]
618 fn adjust_confidence_clamps_at_zero() {
619 let pf = ProposedFact::new(ContextKey::Seeds, "p", "c", "x")
620 .with_confidence(0.1)
621 .adjust_confidence(-0.5);
622 assert_eq!(pf.confidence(), 0.0);
623 }
624
625 #[test]
626 fn with_confidence_clamps_high() {
627 let pf = ProposedFact::new(ContextKey::Seeds, "p", "c", "x").with_confidence(1.5);
628 assert_eq!(pf.confidence(), 1.0);
629 }
630
631 #[test]
632 fn with_confidence_clamps_negative() {
633 let pf = ProposedFact::new(ContextKey::Seeds, "p", "c", "x").with_confidence(-0.1);
634 assert_eq!(pf.confidence(), 0.0);
635 }
636
637 #[test]
638 fn with_confidence_normalizes_nan() {
639 let pf = ProposedFact::new(ContextKey::Seeds, "p", "c", "x").with_confidence(f64::NAN);
640 assert_eq!(pf.confidence(), 0.0);
641 }
642
643 #[test]
644 fn with_confidence_normalizes_infinity() {
645 let pf = ProposedFact::new(ContextKey::Seeds, "p", "c", "x").with_confidence(f64::INFINITY);
646 assert_eq!(pf.confidence(), 0.0);
647 }
648
649 #[test]
650 fn validation_error_display() {
651 let err = ValidationError {
652 reason: "bad input".into(),
653 };
654 assert_eq!(err.to_string(), "validation failed: bad input");
655 }
656
657 #[test]
658 fn validation_error_is_std_error() {
659 let err = ValidationError {
660 reason: "test".into(),
661 };
662 let _: &dyn std::error::Error = &err;
663 }
664
665 #[cfg(feature = "kernel-authority")]
666 #[test]
667 fn fact_accessors() {
668 let fact = kernel_authority::new_fact(ContextKey::Constraints, "f2", "body");
669 assert_eq!(fact.key(), ContextKey::Constraints);
670 assert_eq!(fact.id, "f2");
671 assert_eq!(fact.content, "body");
672 assert_eq!(fact.created_at(), "1970-01-01T00:00:00Z");
673 assert_eq!(fact.promotion_record().gate_id(), "kernel-authority");
674 }
675
676 #[cfg(feature = "kernel-authority")]
677 #[test]
678 fn fact_actor_accessors() {
679 let actor = FactActor::new("agent-x", FactActorKind::Suggestor);
680 assert_eq!(actor.id(), "agent-x");
681 assert_eq!(actor.kind(), FactActorKind::Suggestor);
682 }
683
684 #[cfg(feature = "kernel-authority")]
685 #[test]
686 fn validation_summary_accessors() {
687 let vs = FactValidationSummary::new(
688 vec!["check-a".into()],
689 vec!["check-b".into()],
690 vec!["warn-c".into()],
691 );
692 assert_eq!(vs.checks_passed(), &["check-a"]);
693 assert_eq!(vs.checks_skipped(), &["check-b"]);
694 assert_eq!(vs.warnings(), &["warn-c"]);
695 }
696
697 #[cfg(feature = "kernel-authority")]
698 #[test]
699 fn local_trace_accessors() {
700 let lt = FactLocalTrace::new("trace-1", "span-1", Some("parent-1".into()), false);
701 assert_eq!(lt.trace_id(), "trace-1");
702 assert_eq!(lt.span_id(), "span-1");
703 assert_eq!(lt.parent_span_id().map(SpanId::as_str), Some("parent-1"));
704 assert!(!lt.sampled());
705 }
706
707 #[cfg(feature = "kernel-authority")]
708 #[test]
709 fn remote_trace_accessors() {
710 let rt = FactRemoteTrace::new("sys", "ref", Some("auth".into()), Some("30d".into()));
711 assert_eq!(rt.system(), "sys");
712 assert_eq!(rt.reference(), "ref");
713 assert_eq!(rt.retrieval_auth(), Some("auth"));
714 assert_eq!(rt.retention_hint(), Some("30d"));
715 }
716
717 mod prop {
718 use super::*;
719 use proptest::prelude::*;
720
721 fn arb_context_key() -> impl Strategy<Value = ContextKey> {
722 prop_oneof![
723 Just(ContextKey::Seeds),
724 Just(ContextKey::Hypotheses),
725 Just(ContextKey::Strategies),
726 Just(ContextKey::Constraints),
727 Just(ContextKey::Signals),
728 Just(ContextKey::Competitors),
729 Just(ContextKey::Evaluations),
730 Just(ContextKey::Proposals),
731 Just(ContextKey::Diagnostic),
732 ]
733 }
734
735 proptest! {
736 #[test]
737 fn proposed_fact_always_constructible(
738 key in arb_context_key(),
739 id in "[a-z]{1,20}",
740 content in ".*",
741 prov in "[a-z0-9-]{1,30}",
742 ) {
743 let pf = ProposedFact::new(key, id.clone(), content.clone(), prov.clone());
744 prop_assert_eq!(pf.key, key);
745 prop_assert_eq!(&pf.id, &id);
746 prop_assert_eq!(&pf.content, &content);
747 prop_assert_eq!(&pf.provenance, &prov);
748 prop_assert!((pf.confidence() - 1.0).abs() < f64::EPSILON);
749 }
750 }
751 }
752}