Skip to main content

converge_pack/
fact.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Facts and proposed facts — the type boundary.
5//!
6//! This is the most important design decision in Converge: LLMs suggest,
7//! the engine validates. `ProposedFact` is not `Fact`. There is no implicit
8//! conversion between them.
9
10use 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/// Stable payload-family identifier used by typed facts and wire adapters.
23#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
24pub struct FactFamilyId(String);
25
26impl FactFamilyId {
27    /// Creates a payload-family identifier.
28    #[must_use]
29    pub fn new(value: impl Into<String>) -> Self {
30        Self(value.into())
31    }
32
33    /// Returns the raw identifier string.
34    #[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/// Frozen payload schema version.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
60pub struct PayloadVersion(u16);
61
62impl PayloadVersion {
63    /// Creates a payload version.
64    #[must_use]
65    pub const fn new(value: u16) -> Self {
66        Self(value)
67    }
68
69    /// Returns the numeric payload version.
70    #[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/// Uniform proposal provenance metadata.
89#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
90pub struct Provenance(String);
91
92impl Provenance {
93    /// Creates provenance metadata.
94    #[must_use]
95    pub fn new(value: impl Into<String>) -> Self {
96        Self(value.into())
97    }
98
99    /// Returns the provenance identifier.
100    #[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
124/// Stable, audit-friendly identifier for an extension that emits
125/// facts into the convergence loop.
126///
127/// Implementors are typically zero-sized marker types declared by
128/// each fact-emitting crate. The trait gives them a single canonical
129/// [`as_str`](ProvenanceSource::as_str) plus a default
130/// [`proposed_fact`](ProvenanceSource::proposed_fact) constructor
131/// that stamps the resulting [`ProposedFact`] with the right
132/// [`Provenance`] string.
133///
134/// # Migration from the per-crate `ProvenanceSource` enum
135///
136/// Earlier fact-emitting extensions each duplicated an 8-variant
137/// `ProvenanceSource` enum and a `*_PROVENANCE` constant. This trait
138/// replaces that pattern. Each crate now declares only its own marker
139/// and canonical provenance constant:
140///
141/// ```ignore
142/// use converge_pack::{ProvenanceSource, ContextKey, TextPayload};
143///
144/// pub struct Arbiter;
145/// impl ProvenanceSource for Arbiter {
146///     fn as_str(&self) -> &'static str { "arbiter" }
147/// }
148/// pub const ARBITER_PROVENANCE: Arbiter = Arbiter;
149///
150/// let provenance = ARBITER_PROVENANCE.provenance();
151/// let fact = ARBITER_PROVENANCE.proposed_fact(
152///     ContextKey::Diagnostic,
153///     "decision-001",
154///     TextPayload::new("hello"),
155/// );
156/// assert_eq!(fact.provenance_ref(), &provenance);
157/// ```
158///
159/// Extensions no longer need to enumerate every sibling extension.
160pub trait ProvenanceSource: Copy + Send + Sync + 'static {
161    /// Canonical lowercase identifier carried on
162    /// [`ProposedFact`]`.provenance`. Stable across the extension's
163    /// public API.
164    fn as_str(&self) -> &'static str;
165
166    /// Construct typed [`Provenance`] from this marker.
167    #[must_use]
168    fn provenance(self) -> Provenance {
169        Provenance::from(self.as_str())
170    }
171
172    /// Construct a [`ProposedFact`] stamped with this provenance and
173    /// a typed payload.
174    #[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    /// Construct a [`ProposedFact`] derived from an existing
188    /// [`ContextFact`], preserving the source fact's app-owned subject when
189    /// one is present.
190    #[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
206/// Typed payload carried by proposed and promoted facts.
207///
208/// Implementors own a frozen `(FAMILY, VERSION)` tuple. A shape change is a new
209/// Rust type and a new `VERSION`, never an implicit registry upgrade.
210pub trait FactPayload: fmt::Debug + Clone + Serialize + Send + Sync + 'static {
211    /// Stable payload-family identifier.
212    const FAMILY: &'static str;
213    /// Frozen schema version for this payload type.
214    const VERSION: u16;
215
216    /// Validate domain invariants that the Rust type cannot make
217    /// unrepresentable.
218    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/// Human-readable text payload. This is explicit text, not a generic semantic
266/// escape hatch.
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(deny_unknown_fields)]
269pub struct TextPayload {
270    text: String,
271}
272
273impl TextPayload {
274    /// Creates a text payload.
275    #[must_use]
276    pub fn new(text: impl Into<String>) -> Self {
277        Self { text: text.into() }
278    }
279
280    /// Returns the text.
281    #[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/// Structured diagnostic payload.
293#[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    /// Creates a diagnostic payload.
302    #[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    /// Returns the diagnostic source.
311    #[must_use]
312    pub fn source(&self) -> &str {
313        &self.source
314    }
315
316    /// Returns the diagnostic message.
317    #[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/// Crate, extension, or service that produced an execution result.
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct ExecutionProducerIdentity {
332    /// Producer crate, extension, or service name.
333    pub name: String,
334    /// Producer version.
335    pub version: String,
336}
337
338impl ExecutionProducerIdentity {
339    /// Creates producer identity metadata.
340    #[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/// Native backend details for solver, policy, analytics, or model execution.
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356#[serde(deny_unknown_fields)]
357pub struct NativeExecutionIdentity {
358    /// Native backend name, for example `CVC5`, `OR-Tools:cp_sat`, or `HiGHS`.
359    pub backend: String,
360    /// Native backend version as reported by the linked library.
361    pub version: String,
362    /// Source URL for the native dependency when known.
363    pub source_url: String,
364    /// Expected pinned source commit or release identifier.
365    pub expected_commit: String,
366    /// Actual source commit or release identifier used at build time.
367    pub actual_commit: String,
368    /// How the native backend was sourced, for example vendored or external.
369    pub source_mode: String,
370}
371
372impl NativeExecutionIdentity {
373    /// Creates native backend identity metadata.
374    #[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/// Runtime execution identity shared by evidence-producing extensions.
404///
405/// This records what executed a result. It is intentionally generic: CVC5,
406/// Cedar analysis, OR-Tools, HiGHS, model inference, and deterministic fake
407/// backends can all populate the same contract without leaking implementation
408/// fields into domain payloads.
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(deny_unknown_fields)]
411pub struct ExecutionIdentity {
412    /// Producer crate, extension, or service.
413    pub producer: ExecutionProducerIdentity,
414    /// Logical backend or engine name, for example `cvc5` or `cp-sat-v9.15`.
415    pub backend: String,
416    /// Backend version visible at the safe execution boundary.
417    pub backend_version: String,
418    /// Build/source/config identity for replay and audit.
419    pub build_identity: String,
420    /// Runtime options that affect the result.
421    pub runtime_config: String,
422    /// Native backend details when the execution crossed an FFI/native boundary.
423    pub native_identity: Option<NativeExecutionIdentity>,
424}
425
426impl ExecutionIdentity {
427    /// Creates execution identity metadata.
428    #[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    /// Creates non-native execution identity metadata.
448    #[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    /// Creates unknown execution identity metadata for placeholders and tests.
466    #[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    /// Serializes a typed configuration struct to the canonical
482    /// `runtime_config` JSON string.
483    ///
484    /// This is the workspace-standard encoding for `runtime_config` per
485    /// `kb/Standards/Runtime Config Encoding.md`: a JSON object whose keys
486    /// are the struct field names and whose values are field values. Empty
487    /// configs serialize as `{}`.
488    ///
489    /// Panics if `T`'s `Serialize` impl is malformed (e.g., non-finite
490    /// floats, non-string map keys). For all practical workspace config
491    /// structs this is unreachable; a panic here means the caller's config
492    /// struct is broken.
493    ///
494    /// ```ignore
495    /// let rc = ExecutionIdentity::runtime_config_from_typed(&my_cfg);
496    /// let identity = ExecutionIdentity::non_native("crate", "1.0", "backend", rc);
497    /// ```
498    #[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    /// Replaces this identity's `runtime_config` with the JSON encoding of a
505    /// typed config struct. Builder-style sibling of
506    /// [`Self::runtime_config_from_typed`].
507    #[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/// Companion evidence that links a produced fact to its execution identity.
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
528#[serde(deny_unknown_fields)]
529pub struct ExecutionIdentityEvidence {
530    /// Context key containing the produced subject.
531    pub subject_key: ContextKey,
532    /// Subject fact/proposal id.
533    pub subject_id: String,
534    /// Subject payload family.
535    pub subject_family: FactFamilyId,
536    /// Subject payload version.
537    pub subject_version: PayloadVersion,
538    /// Execution identity that produced the subject.
539    pub identity: ExecutionIdentity,
540}
541
542impl ExecutionIdentityEvidence {
543    /// Creates execution identity evidence for a known payload family.
544    #[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    /// Creates execution identity evidence for a typed fact payload.
562    #[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/// Errors at the typed payload and wire materialization boundary.
603#[derive(Debug, Clone, PartialEq, Eq, Error)]
604pub enum PayloadError {
605    /// Payload failed validation.
606    #[error("invalid payload for {family} v{version}: {reason}")]
607    Invalid {
608        /// Payload family.
609        family: FactFamilyId,
610        /// Payload version.
611        version: PayloadVersion,
612        /// Human-readable reason.
613        reason: String,
614    },
615    /// Payload failed JSON serialization at a border.
616    #[error("failed to serialize payload {family} v{version}: {reason}")]
617    Serialize {
618        /// Payload family.
619        family: FactFamilyId,
620        /// Payload version.
621        version: PayloadVersion,
622        /// Human-readable reason.
623        reason: String,
624    },
625    /// Payload failed JSON deserialization at a border.
626    #[error("failed to deserialize payload {family} v{version}: {reason}")]
627    Deserialize {
628        /// Payload family.
629        family: FactFamilyId,
630        /// Payload version.
631        version: PayloadVersion,
632        /// Human-readable reason.
633        reason: String,
634    },
635    /// No decoder is registered for the wire family and version.
636    #[error("unknown payload family/version: {family} v{version}")]
637    UnknownFamilyVersion {
638        /// Payload family.
639        family: FactFamilyId,
640        /// Payload version.
641        version: PayloadVersion,
642    },
643    /// A typed access request used the wrong payload type.
644    #[error(
645        "payload type mismatch: expected {expected} v{expected_version}, got {actual} v{actual_version}"
646    )]
647    TypeMismatch {
648        /// Expected family.
649        expected: FactFamilyId,
650        /// Expected version.
651        expected_version: PayloadVersion,
652        /// Actual family.
653        actual: FactFamilyId,
654        /// Actual version.
655        actual_version: PayloadVersion,
656    },
657}
658
659/// Stable wire payload shape for HTTP, gRPC, NATS, storage/replay, CLI
660/// fixtures, non-Rust clients, and audit export.
661#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
662#[serde(deny_unknown_fields)]
663pub struct WireFactPayload {
664    /// Payload family.
665    pub family: FactFamilyId,
666    /// Payload schema version.
667    pub version: PayloadVersion,
668    /// JSON payload bytes decoded into a value at the border.
669    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/// Wire shape for proposed facts. This is the only sanctioned way for borders
683/// to materialize proposals without already holding a typed Rust payload.
684#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
685#[serde(deny_unknown_fields)]
686pub struct WireProposedFact {
687    /// Destination context key.
688    pub key: ContextKey,
689    /// Proposal identifier.
690    pub id: ProposalId,
691    /// Optional app-owned subject this proposal is about.
692    #[serde(default, skip_serializing_if = "Option::is_none")]
693    pub subject: Option<SubjectRef>,
694    /// Proposed payload.
695    pub payload: WireFactPayload,
696    /// Confidence hint.
697    pub confidence: UnitInterval,
698    /// Uniform provenance metadata.
699    pub provenance: Provenance,
700}
701
702/// Wire shape for promoted context facts.
703#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
704#[serde(deny_unknown_fields)]
705pub struct WireContextFact {
706    /// Context key.
707    pub key: ContextKey,
708    /// Fact identifier.
709    pub id: FactId,
710    /// Optional app-owned subject this fact is about.
711    #[serde(default, skip_serializing_if = "Option::is_none")]
712    pub subject: Option<SubjectRef>,
713    /// Fact payload.
714    pub payload: WireFactPayload,
715    /// Promotion record.
716    pub promotion_record: FactPromotionRecord,
717    /// Creation timestamp.
718    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/// Registry used at serialization borders to materialize typed payloads from
726/// `(family, version, payload)` tuples.
727#[derive(Default)]
728pub struct PayloadRegistry {
729    decoders: HashMap<(FactFamilyId, PayloadVersion), PayloadDecoder>,
730}
731
732impl PayloadRegistry {
733    /// Creates an empty registry.
734    #[must_use]
735    pub fn new() -> Self {
736        Self::default()
737    }
738
739    /// Creates a registry with payloads owned by `converge-pack`.
740    #[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    /// Registers one frozen payload type.
753    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/// Actor kind recorded on a promoted fact.
793#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
794pub enum FactActorKind {
795    /// Human approver.
796    Human,
797    /// Suggestor or automated domain actor.
798    Suggestor,
799    /// Kernel or system component.
800    System,
801}
802
803/// Read-only actor record attached to authoritative facts.
804#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
805pub struct FactActor {
806    id: ActorId,
807    kind: FactActorKind,
808}
809
810impl FactActor {
811    /// Returns the actor identifier.
812    #[must_use]
813    pub fn id(&self) -> &ActorId {
814        &self.id
815    }
816
817    /// Returns the actor kind.
818    #[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/// Summary of validation checks attached to an authoritative fact.
833#[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    /// Returns validation checks that passed.
842    #[must_use]
843    pub fn checks_passed(&self) -> &[ValidationCheckId] {
844        &self.checks_passed
845    }
846
847    /// Returns validation checks that were skipped.
848    #[must_use]
849    pub fn checks_skipped(&self) -> &[ValidationCheckId] {
850        &self.checks_skipped
851    }
852
853    /// Returns validation warnings.
854    #[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/// Typed evidence references attached to an authoritative fact.
874#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
875#[serde(tag = "type", content = "id")]
876pub enum FactEvidenceRef {
877    /// Observation used as evidence.
878    Observation(ObservationId),
879    /// Human approval used as evidence.
880    HumanApproval(ApprovalId),
881    /// Derived artifact used as evidence.
882    Derived(ArtifactId),
883}
884
885/// Local replayable trace reference.
886#[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    /// Returns the trace identifier.
896    #[must_use]
897    pub fn trace_id(&self) -> &TraceId {
898        &self.trace_id
899    }
900
901    /// Returns the span identifier.
902    #[must_use]
903    pub fn span_id(&self) -> &SpanId {
904        &self.span_id
905    }
906
907    /// Returns the parent span identifier.
908    #[must_use]
909    pub fn parent_span_id(&self) -> Option<&SpanId> {
910        self.parent_span_id.as_ref()
911    }
912
913    /// Returns whether the trace was sampled.
914    #[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/// Remote audit-only trace reference.
936#[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    /// Returns the remote system identifier.
946    #[must_use]
947    pub fn system(&self) -> &TraceSystemId {
948        &self.system
949    }
950
951    /// Returns the remote trace reference.
952    #[must_use]
953    pub fn reference(&self) -> &TraceReference {
954        &self.reference
955    }
956
957    /// Returns the retrieval auth hint.
958    #[must_use]
959    pub fn retrieval_auth(&self) -> Option<&str> {
960        self.retrieval_auth.as_deref()
961    }
962
963    /// Returns the retention hint.
964    #[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/// Trace record attached to an authoritative fact.
986#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
987#[serde(tag = "type")]
988pub enum FactTraceLink {
989    /// Local replayable trace.
990    Local(FactLocalTrace),
991    /// Remote audit-only trace.
992    Remote(FactRemoteTrace),
993}
994
995impl FactTraceLink {
996    /// Returns whether the trace is replay-eligible.
997    #[must_use]
998    pub fn is_replay_eligible(&self) -> bool {
999        matches!(self, Self::Local(_))
1000    }
1001}
1002
1003/// Read-only promotion record attached to an authoritative fact.
1004#[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    /// Returns the gate identifier that promoted the fact.
1017    #[must_use]
1018    pub fn gate_id(&self) -> &GateId {
1019        &self.gate_id
1020    }
1021
1022    /// Returns the policy hash used during promotion.
1023    #[must_use]
1024    pub fn policy_version_hash(&self) -> &ContentHash {
1025        &self.policy_version_hash
1026    }
1027
1028    /// Returns the approving actor.
1029    #[must_use]
1030    pub fn approver(&self) -> &FactActor {
1031        &self.approver
1032    }
1033
1034    /// Returns the validation summary.
1035    #[must_use]
1036    pub fn validation_summary(&self) -> &FactValidationSummary {
1037        &self.validation_summary
1038    }
1039
1040    /// Returns the evidence references used during promotion.
1041    #[must_use]
1042    pub fn evidence_refs(&self) -> &[FactEvidenceRef] {
1043        &self.evidence_refs
1044    }
1045
1046    /// Returns the trace link for audit or replay.
1047    #[must_use]
1048    pub fn trace_link(&self) -> &FactTraceLink {
1049        &self.trace_link
1050    }
1051
1052    /// Returns the promotion timestamp.
1053    #[must_use]
1054    pub fn promoted_at(&self) -> &Timestamp {
1055        &self.promoted_at
1056    }
1057
1058    /// Returns whether the promotion is replay-eligible.
1059    #[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/// Read-only projection of a validated assertion in the context.
1087///
1088/// This type is not promotion authority. It is the value suggestors and
1089/// pack authors can read from context after the engine has promoted a
1090/// proposal. Constructing one locally does not admit it into Converge; there is
1091/// no public API that accepts a `ContextFact` as promoted truth.
1092#[derive(Clone)]
1093pub struct ContextFact {
1094    /// Which context key this fact belongs to.
1095    key: ContextKey,
1096    /// Unique identifier within the context key namespace.
1097    id: FactId,
1098    /// App-owned subject this fact is about.
1099    subject: Option<SubjectRef>,
1100    /// Typed fact payload.
1101    payload: Arc<dyn ErasedFactPayload>,
1102    /// The immutable promotion record that made this fact authoritative.
1103    promotion_record: FactPromotionRecord,
1104    /// When the authoritative fact entered context.
1105    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    /// Creates a read-only context projection.
1150    ///
1151    /// This constructor does not promote anything and is intentionally named as
1152    /// a projection constructor. The engine is still the only component that can
1153    /// add context facts to a live `ContextState`.
1154    #[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    /// Creates a context fact from a wire representation at a serialization
1176    /// border.
1177    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    /// Converts this fact to the stable wire shape.
1197    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    /// Attaches the app-owned subject this fact is about.
1209    #[must_use]
1210    pub fn with_subject(mut self, subject: SubjectRef) -> Self {
1211        self.subject = Some(subject);
1212        self
1213    }
1214
1215    /// Returns the context key this fact belongs to.
1216    #[must_use]
1217    pub fn key(&self) -> ContextKey {
1218        self.key
1219    }
1220
1221    /// Returns the fact identifier.
1222    #[must_use]
1223    pub fn id(&self) -> &FactId {
1224        &self.id
1225    }
1226
1227    /// Returns the app-owned subject this fact is about, when tagged.
1228    #[must_use]
1229    pub fn subject(&self) -> Option<&SubjectRef> {
1230        self.subject.as_ref()
1231    }
1232
1233    /// Returns the typed payload when the requested type matches the stored
1234    /// payload family/version.
1235    #[must_use]
1236    pub fn payload<T: FactPayload>(&self) -> Option<&T> {
1237        self.payload.as_any().downcast_ref::<T>()
1238    }
1239
1240    /// Returns the typed payload or a mismatch error.
1241    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    /// Returns the payload family.
1252    #[must_use]
1253    pub fn payload_family(&self) -> FactFamilyId {
1254        self.payload.family()
1255    }
1256
1257    /// Returns the payload version.
1258    #[must_use]
1259    pub fn payload_version(&self) -> PayloadVersion {
1260        self.payload.version()
1261    }
1262
1263    /// Returns the payload as text when this is a [`TextPayload`].
1264    #[must_use]
1265    pub fn text(&self) -> Option<&str> {
1266        self.payload::<TextPayload>().map(TextPayload::as_str)
1267    }
1268
1269    /// Validates the stored payload.
1270    pub fn validate_payload(&self) -> Result<(), PayloadError> {
1271        self.payload.validate()
1272    }
1273
1274    /// Returns the immutable promotion record for this fact.
1275    #[must_use]
1276    pub fn promotion_record(&self) -> &FactPromotionRecord {
1277        &self.promotion_record
1278    }
1279
1280    /// Returns the fact creation timestamp.
1281    #[must_use]
1282    pub fn created_at(&self) -> &Timestamp {
1283        &self.created_at
1284    }
1285
1286    /// Returns whether the fact is replay-eligible.
1287    #[must_use]
1288    pub fn is_replay_eligible(&self) -> bool {
1289        self.promotion_record.is_replay_eligible()
1290    }
1291}
1292
1293/// An unvalidated suggestion from a non-authoritative source.
1294///
1295/// Proposed facts live in `ContextKey::Proposals` until a `ValidationAgent`
1296/// promotes them to `Fact`. The proposal tracks its origin for audit trail.
1297#[derive(Clone)]
1298pub struct ProposedFact {
1299    /// The context key this proposal targets.
1300    pub key: ContextKey,
1301    /// Unique identifier encoding origin and target.
1302    pub id: ProposalId,
1303    /// App-owned subject this proposal is about.
1304    subject: Option<SubjectRef>,
1305    /// Typed proposed payload.
1306    payload: Arc<dyn ErasedFactPayload>,
1307    /// Confidence hint from the source. Always in [0.0, 1.0].
1308    confidence: UnitInterval,
1309    /// Provenance information (e.g., model ID, prompt hash).
1310    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    /// Create a new draft proposal with explicit provenance.
1353    ///
1354    /// Confidence defaults to 1.0. Override with [`with_confidence`][Self::with_confidence].
1355    #[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    /// Materializes a proposal from the stable wire shape at an external
1376    /// border.
1377    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    /// Converts this proposal to the stable wire shape.
1397    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    /// Creates a promoted context projection that preserves this proposal's
1409    /// exact typed payload.
1410    ///
1411    /// Promotion authority still belongs to the engine/gate. This method only
1412    /// avoids re-serializing or textifying a payload after that authority has
1413    /// already accepted the proposal.
1414    #[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    /// Attaches the app-owned subject this proposal is about.
1432    #[must_use]
1433    pub fn with_subject(mut self, subject: SubjectRef) -> Self {
1434        self.subject = Some(subject);
1435        self
1436    }
1437
1438    /// Copies the app-owned subject from a promoted source fact when present.
1439    ///
1440    /// This is the common Suggestor pattern: a specialist derives a proposal
1441    /// from an input fact without interpreting the subject vocabulary itself.
1442    #[must_use]
1443    pub fn with_subject_from(mut self, source: &ContextFact) -> Self {
1444        self.subject = source.subject().cloned();
1445        self
1446    }
1447
1448    /// Returns the context key this proposal targets.
1449    #[must_use]
1450    pub fn key(&self) -> ContextKey {
1451        self.key
1452    }
1453
1454    /// Returns the proposal identifier.
1455    #[must_use]
1456    pub fn id(&self) -> &ProposalId {
1457        &self.id
1458    }
1459
1460    /// Returns the app-owned subject this proposal is about, when tagged.
1461    #[must_use]
1462    pub fn subject(&self) -> Option<&SubjectRef> {
1463        self.subject.as_ref()
1464    }
1465
1466    /// Returns the typed payload when the requested type matches the stored
1467    /// payload family/version.
1468    #[must_use]
1469    pub fn payload<T: FactPayload>(&self) -> Option<&T> {
1470        self.payload.as_any().downcast_ref::<T>()
1471    }
1472
1473    /// Returns the typed payload or a mismatch error.
1474    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    /// Returns the payload family.
1485    #[must_use]
1486    pub fn payload_family(&self) -> FactFamilyId {
1487        self.payload.family()
1488    }
1489
1490    /// Returns the payload version.
1491    #[must_use]
1492    pub fn payload_version(&self) -> PayloadVersion {
1493        self.payload.version()
1494    }
1495
1496    /// Returns the payload as text when this is a [`TextPayload`].
1497    #[must_use]
1498    pub fn text(&self) -> Option<&str> {
1499        self.payload::<TextPayload>().map(TextPayload::as_str)
1500    }
1501
1502    /// Validates the stored payload.
1503    pub fn validate_payload(&self) -> Result<(), PayloadError> {
1504        self.payload.validate()
1505    }
1506
1507    /// Returns the proposal provenance value.
1508    #[must_use]
1509    pub fn provenance_ref(&self) -> &Provenance {
1510        &self.provenance
1511    }
1512
1513    /// Returns the proposal provenance string for wire/logging boundaries.
1514    #[must_use]
1515    pub fn provenance(&self) -> &str {
1516        self.provenance.as_str()
1517    }
1518
1519    /// Returns the confidence value, always in [0.0, 1.0].
1520    #[must_use]
1521    pub fn confidence(&self) -> f64 {
1522        self.confidence.as_f64()
1523    }
1524
1525    /// Set an explicit confidence baseline for this proposal.
1526    ///
1527    /// Use this to establish a starting point, then accumulate criteria with
1528    /// [`adjust_confidence`][Self::adjust_confidence]. The value is clamped to
1529    /// [0.0, 1.0]; non-finite values (NaN, infinity) are treated as 0.0.
1530    ///
1531    /// For computed confidence (e.g. from a solver), pass the result directly.
1532    #[must_use]
1533    pub fn with_confidence(mut self, confidence: f64) -> Self {
1534        self.confidence = UnitInterval::clamped(confidence);
1535        self
1536    }
1537
1538    /// Adjust confidence by a named step, clamped to [0.0, 1.0].
1539    ///
1540    /// This is the recommended way to express confidence in suggestors and pack
1541    /// solvers. Use the `CONFIDENCE_STEP_*` constants as the vocabulary:
1542    ///
1543    /// ```rust,ignore
1544    /// use converge_pack::{CONFIDENCE_STEP_MAJOR, CONFIDENCE_STEP_MINOR, CONFIDENCE_STEP_TINY, TextPayload};
1545    ///
1546    /// let proposal = EXAMPLE_PROVENANCE.proposed_fact(key, id, TextPayload::new(content))
1547    ///     .with_confidence(0.5)                        // baseline
1548    ///     .adjust_confidence(CONFIDENCE_STEP_MAJOR)    // primary criterion met
1549    ///     .adjust_confidence(CONFIDENCE_STEP_MINOR)    // supporting criterion met
1550    ///     .adjust_confidence(CONFIDENCE_STEP_TINY);    // tiebreaker bonus
1551    /// ```
1552    ///
1553    /// Prefer this over accumulating a local `f64` and calling `with_confidence`
1554    /// at the end — the clamping is automatic and the intent is explicit at each step.
1555    #[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
1562/// Tiny confidence step — use for marginal or tiebreaker criteria (0.05).
1563pub const CONFIDENCE_STEP_TINY: f64 = 0.05;
1564
1565/// Minor confidence step — use for supporting criteria (0.1).
1566pub const CONFIDENCE_STEP_MINOR: f64 = 0.1;
1567
1568/// Medium confidence step — use for moderately significant criteria (0.15).
1569pub const CONFIDENCE_STEP_MEDIUM: f64 = 0.15;
1570
1571/// Major confidence step — use for significant criteria (0.2).
1572pub const CONFIDENCE_STEP_MAJOR: f64 = 0.2;
1573
1574/// Primary confidence step — use for decisive or high-weight criteria (0.25).
1575pub const CONFIDENCE_STEP_PRIMARY: f64 = 0.25;
1576
1577/// Error when a `ProposedFact` fails validation.
1578#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1579pub struct ValidationError {
1580    /// Reason the proposal was rejected.
1581    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, &registry).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, &registry);
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, &registry).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}