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, Timestamp, TraceId, TraceReference, TraceSystemId, UnitInterval, ValidationCheckId,
19};
20
21/// Stable payload-family identifier used by typed facts and wire adapters.
22#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
23pub struct FactFamilyId(String);
24
25impl FactFamilyId {
26    /// Creates a payload-family identifier.
27    #[must_use]
28    pub fn new(value: impl Into<String>) -> Self {
29        Self(value.into())
30    }
31
32    /// Returns the raw identifier string.
33    #[must_use]
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37}
38
39impl fmt::Display for FactFamilyId {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        f.write_str(&self.0)
42    }
43}
44
45impl From<&'static str> for FactFamilyId {
46    fn from(value: &'static str) -> Self {
47        Self::new(value)
48    }
49}
50
51impl From<String> for FactFamilyId {
52    fn from(value: String) -> Self {
53        Self::new(value)
54    }
55}
56
57/// Frozen payload schema version.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
59pub struct PayloadVersion(u16);
60
61impl PayloadVersion {
62    /// Creates a payload version.
63    #[must_use]
64    pub const fn new(value: u16) -> Self {
65        Self(value)
66    }
67
68    /// Returns the numeric payload version.
69    #[must_use]
70    pub const fn get(self) -> u16 {
71        self.0
72    }
73}
74
75impl fmt::Display for PayloadVersion {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        write!(f, "{}", self.0)
78    }
79}
80
81impl From<u16> for PayloadVersion {
82    fn from(value: u16) -> Self {
83        Self::new(value)
84    }
85}
86
87/// Uniform proposal provenance metadata.
88#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
89pub struct Provenance(String);
90
91impl Provenance {
92    /// Creates provenance metadata.
93    #[must_use]
94    pub fn new(value: impl Into<String>) -> Self {
95        Self(value.into())
96    }
97
98    /// Returns the provenance identifier.
99    #[must_use]
100    pub fn as_str(&self) -> &str {
101        &self.0
102    }
103}
104
105impl fmt::Display for Provenance {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        f.write_str(&self.0)
108    }
109}
110
111impl From<&str> for Provenance {
112    fn from(value: &str) -> Self {
113        Self::new(value)
114    }
115}
116
117impl From<String> for Provenance {
118    fn from(value: String) -> Self {
119        Self::new(value)
120    }
121}
122
123/// Stable, audit-friendly identifier for an extension that emits
124/// facts into the convergence loop.
125///
126/// Implementors are typically zero-sized marker types declared by
127/// each fact-emitting crate. The trait gives them a single canonical
128/// [`as_str`](ProvenanceSource::as_str) plus a default
129/// [`proposed_fact`](ProvenanceSource::proposed_fact) constructor
130/// that stamps the resulting [`ProposedFact`] with the right
131/// [`Provenance`] string.
132///
133/// # Migration from the per-crate `ProvenanceSource` enum
134///
135/// Earlier fact-emitting extensions each duplicated an 8-variant
136/// `ProvenanceSource` enum and a `*_PROVENANCE` constant. This trait
137/// replaces that pattern. Each crate now declares only its own marker
138/// and canonical provenance constant:
139///
140/// ```ignore
141/// use converge_pack::{ProvenanceSource, ContextKey, TextPayload};
142///
143/// pub struct Arbiter;
144/// impl ProvenanceSource for Arbiter {
145///     fn as_str(&self) -> &'static str { "arbiter" }
146/// }
147/// pub const ARBITER_PROVENANCE: Arbiter = Arbiter;
148///
149/// let provenance = ARBITER_PROVENANCE.provenance();
150/// let fact = ARBITER_PROVENANCE.proposed_fact(
151///     ContextKey::Diagnostic,
152///     "decision-001",
153///     TextPayload::new("hello"),
154/// );
155/// assert_eq!(fact.provenance_ref(), &provenance);
156/// ```
157///
158/// Extensions no longer need to enumerate every sibling extension.
159pub trait ProvenanceSource: Copy + Send + Sync + 'static {
160    /// Canonical lowercase identifier carried on
161    /// [`ProposedFact`]`.provenance`. Stable across the extension's
162    /// public API.
163    fn as_str(&self) -> &'static str;
164
165    /// Construct typed [`Provenance`] from this marker.
166    #[must_use]
167    fn provenance(self) -> Provenance {
168        Provenance::from(self.as_str())
169    }
170
171    /// Construct a [`ProposedFact`] stamped with this provenance and
172    /// a typed payload.
173    #[must_use]
174    fn proposed_fact<T>(
175        self,
176        key: ContextKey,
177        id: impl Into<ProposalId>,
178        payload: T,
179    ) -> ProposedFact
180    where
181        T: FactPayload + PartialEq,
182    {
183        ProposedFact::new(key, id, payload, self.provenance())
184    }
185}
186
187/// Typed payload carried by proposed and promoted facts.
188///
189/// Implementors own a frozen `(FAMILY, VERSION)` tuple. A shape change is a new
190/// Rust type and a new `VERSION`, never an implicit registry upgrade.
191pub trait FactPayload: fmt::Debug + Clone + Serialize + Send + Sync + 'static {
192    /// Stable payload-family identifier.
193    const FAMILY: &'static str;
194    /// Frozen schema version for this payload type.
195    const VERSION: u16;
196
197    /// Validate domain invariants that the Rust type cannot make
198    /// unrepresentable.
199    fn validate(&self) -> Result<(), PayloadError> {
200        Ok(())
201    }
202}
203
204trait ErasedFactPayload: fmt::Debug + Send + Sync {
205    fn family(&self) -> FactFamilyId;
206    fn version(&self) -> PayloadVersion;
207    fn validate(&self) -> Result<(), PayloadError>;
208    fn as_any(&self) -> &dyn Any;
209    fn to_json_value(&self) -> Result<serde_json::Value, PayloadError>;
210    fn equivalent(&self, other: &dyn ErasedFactPayload) -> bool;
211}
212
213impl<T> ErasedFactPayload for T
214where
215    T: FactPayload + PartialEq,
216{
217    fn family(&self) -> FactFamilyId {
218        FactFamilyId::from(T::FAMILY)
219    }
220
221    fn version(&self) -> PayloadVersion {
222        PayloadVersion::new(T::VERSION)
223    }
224
225    fn validate(&self) -> Result<(), PayloadError> {
226        FactPayload::validate(self)
227    }
228
229    fn as_any(&self) -> &dyn Any {
230        self
231    }
232
233    fn to_json_value(&self) -> Result<serde_json::Value, PayloadError> {
234        serde_json::to_value(self).map_err(|err| PayloadError::Serialize {
235            family: T::FAMILY.into(),
236            version: T::VERSION.into(),
237            reason: err.to_string(),
238        })
239    }
240
241    fn equivalent(&self, other: &dyn ErasedFactPayload) -> bool {
242        other.as_any().downcast_ref::<T>() == Some(self)
243    }
244}
245
246/// Human-readable text payload. This is explicit text, not a generic semantic
247/// escape hatch.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct TextPayload {
251    text: String,
252}
253
254impl TextPayload {
255    /// Creates a text payload.
256    #[must_use]
257    pub fn new(text: impl Into<String>) -> Self {
258        Self { text: text.into() }
259    }
260
261    /// Returns the text.
262    #[must_use]
263    pub fn as_str(&self) -> &str {
264        &self.text
265    }
266}
267
268impl FactPayload for TextPayload {
269    const FAMILY: &'static str = "converge.text";
270    const VERSION: u16 = 1;
271}
272
273/// Structured diagnostic payload.
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(deny_unknown_fields)]
276pub struct DiagnosticPayload {
277    source: String,
278    message: String,
279}
280
281impl DiagnosticPayload {
282    /// Creates a diagnostic payload.
283    #[must_use]
284    pub fn new(source: impl Into<String>, message: impl Into<String>) -> Self {
285        Self {
286            source: source.into(),
287            message: message.into(),
288        }
289    }
290
291    /// Returns the diagnostic source.
292    #[must_use]
293    pub fn source(&self) -> &str {
294        &self.source
295    }
296
297    /// Returns the diagnostic message.
298    #[must_use]
299    pub fn message(&self) -> &str {
300        &self.message
301    }
302}
303
304impl FactPayload for DiagnosticPayload {
305    const FAMILY: &'static str = "converge.diagnostic";
306    const VERSION: u16 = 1;
307}
308
309/// Crate, extension, or service that produced an execution result.
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(deny_unknown_fields)]
312pub struct ExecutionProducerIdentity {
313    /// Producer crate, extension, or service name.
314    pub name: String,
315    /// Producer version.
316    pub version: String,
317}
318
319impl ExecutionProducerIdentity {
320    /// Creates producer identity metadata.
321    #[must_use]
322    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
323        Self {
324            name: name.into(),
325            version: version.into(),
326        }
327    }
328
329    fn validate(&self) -> Result<(), String> {
330        validate_non_empty("producer.name", &self.name)?;
331        validate_non_empty("producer.version", &self.version)
332    }
333}
334
335/// Native backend details for solver, policy, analytics, or model execution.
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(deny_unknown_fields)]
338pub struct NativeExecutionIdentity {
339    /// Native backend name, for example `CVC5`, `OR-Tools:cp_sat`, or `HiGHS`.
340    pub backend: String,
341    /// Native backend version as reported by the linked library.
342    pub version: String,
343    /// Source URL for the native dependency when known.
344    pub source_url: String,
345    /// Expected pinned source commit or release identifier.
346    pub expected_commit: String,
347    /// Actual source commit or release identifier used at build time.
348    pub actual_commit: String,
349    /// How the native backend was sourced, for example vendored or external.
350    pub source_mode: String,
351}
352
353impl NativeExecutionIdentity {
354    /// Creates native backend identity metadata.
355    #[must_use]
356    pub fn new(
357        backend: impl Into<String>,
358        version: impl Into<String>,
359        source_url: impl Into<String>,
360        expected_commit: impl Into<String>,
361        actual_commit: impl Into<String>,
362        source_mode: impl Into<String>,
363    ) -> Self {
364        Self {
365            backend: backend.into(),
366            version: version.into(),
367            source_url: source_url.into(),
368            expected_commit: expected_commit.into(),
369            actual_commit: actual_commit.into(),
370            source_mode: source_mode.into(),
371        }
372    }
373
374    fn validate(&self) -> Result<(), String> {
375        validate_non_empty("native_identity.backend", &self.backend)?;
376        validate_non_empty("native_identity.version", &self.version)?;
377        validate_non_empty("native_identity.source_url", &self.source_url)?;
378        validate_non_empty("native_identity.expected_commit", &self.expected_commit)?;
379        validate_non_empty("native_identity.actual_commit", &self.actual_commit)?;
380        validate_non_empty("native_identity.source_mode", &self.source_mode)
381    }
382}
383
384/// Runtime execution identity shared by evidence-producing extensions.
385///
386/// This records what executed a result. It is intentionally generic: CVC5,
387/// Cedar analysis, OR-Tools, HiGHS, model inference, and deterministic fake
388/// backends can all populate the same contract without leaking implementation
389/// fields into domain payloads.
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391#[serde(deny_unknown_fields)]
392pub struct ExecutionIdentity {
393    /// Producer crate, extension, or service.
394    pub producer: ExecutionProducerIdentity,
395    /// Logical backend or engine name, for example `cvc5` or `cp-sat-v9.15`.
396    pub backend: String,
397    /// Backend version visible at the safe execution boundary.
398    pub backend_version: String,
399    /// Build/source/config identity for replay and audit.
400    pub build_identity: String,
401    /// Runtime options that affect the result.
402    pub runtime_config: String,
403    /// Native backend details when the execution crossed an FFI/native boundary.
404    pub native_identity: Option<NativeExecutionIdentity>,
405}
406
407impl ExecutionIdentity {
408    /// Creates execution identity metadata.
409    #[must_use]
410    pub fn new(
411        producer: ExecutionProducerIdentity,
412        backend: impl Into<String>,
413        backend_version: impl Into<String>,
414        build_identity: impl Into<String>,
415        runtime_config: impl Into<String>,
416        native_identity: Option<NativeExecutionIdentity>,
417    ) -> Self {
418        Self {
419            producer,
420            backend: backend.into(),
421            backend_version: backend_version.into(),
422            build_identity: build_identity.into(),
423            runtime_config: runtime_config.into(),
424            native_identity,
425        }
426    }
427
428    /// Creates non-native execution identity metadata.
429    #[must_use]
430    pub fn non_native(
431        producer_name: impl Into<String>,
432        producer_version: impl Into<String>,
433        backend: impl Into<String>,
434        runtime_config: impl Into<String>,
435    ) -> Self {
436        Self::new(
437            ExecutionProducerIdentity::new(producer_name, producer_version),
438            backend,
439            "not_applicable",
440            "not_applicable",
441            runtime_config,
442            None,
443        )
444    }
445
446    /// Creates unknown execution identity metadata for placeholders and tests.
447    #[must_use]
448    pub fn unspecified(
449        producer_name: impl Into<String>,
450        producer_version: impl Into<String>,
451    ) -> Self {
452        Self::new(
453            ExecutionProducerIdentity::new(producer_name, producer_version),
454            "unknown",
455            "unknown",
456            "unknown",
457            "unknown",
458            None,
459        )
460    }
461
462    /// Serializes a typed configuration struct to the canonical
463    /// `runtime_config` JSON string.
464    ///
465    /// This is the workspace-standard encoding for `runtime_config` per
466    /// `kb/Standards/Runtime Config Encoding.md`: a JSON object whose keys
467    /// are the struct field names and whose values are field values. Empty
468    /// configs serialize as `{}`.
469    ///
470    /// Panics if `T`'s `Serialize` impl is malformed (e.g., non-finite
471    /// floats, non-string map keys). For all practical workspace config
472    /// structs this is unreachable; a panic here means the caller's config
473    /// struct is broken.
474    ///
475    /// ```ignore
476    /// let rc = ExecutionIdentity::runtime_config_from_typed(&my_cfg);
477    /// let identity = ExecutionIdentity::non_native("crate", "1.0", "backend", rc);
478    /// ```
479    #[must_use]
480    pub fn runtime_config_from_typed<T: Serialize>(value: &T) -> String {
481        serde_json::to_string(value)
482            .expect("typed runtime_config must serialize to JSON; check Serialize impl")
483    }
484
485    /// Replaces this identity's `runtime_config` with the JSON encoding of a
486    /// typed config struct. Builder-style sibling of
487    /// [`Self::runtime_config_from_typed`].
488    #[must_use]
489    pub fn with_runtime_config_typed<T: Serialize>(mut self, value: &T) -> Self {
490        self.runtime_config = Self::runtime_config_from_typed(value);
491        self
492    }
493
494    fn validate(&self) -> Result<(), String> {
495        self.producer.validate()?;
496        validate_non_empty("backend", &self.backend)?;
497        validate_non_empty("backend_version", &self.backend_version)?;
498        validate_non_empty("build_identity", &self.build_identity)?;
499        validate_non_empty("runtime_config", &self.runtime_config)?;
500        if let Some(native_identity) = &self.native_identity {
501            native_identity.validate()?;
502        }
503        Ok(())
504    }
505}
506
507/// Companion evidence that links a produced fact to its execution identity.
508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
509#[serde(deny_unknown_fields)]
510pub struct ExecutionIdentityEvidence {
511    /// Context key containing the produced subject.
512    pub subject_key: ContextKey,
513    /// Subject fact/proposal id.
514    pub subject_id: String,
515    /// Subject payload family.
516    pub subject_family: FactFamilyId,
517    /// Subject payload version.
518    pub subject_version: PayloadVersion,
519    /// Execution identity that produced the subject.
520    pub identity: ExecutionIdentity,
521}
522
523impl ExecutionIdentityEvidence {
524    /// Creates execution identity evidence for a known payload family.
525    #[must_use]
526    pub fn new(
527        subject_key: ContextKey,
528        subject_id: impl Into<String>,
529        subject_family: impl Into<FactFamilyId>,
530        subject_version: impl Into<PayloadVersion>,
531        identity: ExecutionIdentity,
532    ) -> Self {
533        Self {
534            subject_key,
535            subject_id: subject_id.into(),
536            subject_family: subject_family.into(),
537            subject_version: subject_version.into(),
538            identity,
539        }
540    }
541
542    /// Creates execution identity evidence for a typed fact payload.
543    #[must_use]
544    pub fn for_payload<T: FactPayload>(
545        subject_key: ContextKey,
546        subject_id: impl Into<String>,
547        identity: ExecutionIdentity,
548    ) -> Self {
549        Self::new(subject_key, subject_id, T::FAMILY, T::VERSION, identity)
550    }
551}
552
553impl FactPayload for ExecutionIdentityEvidence {
554    const FAMILY: &'static str = "converge.execution_identity.evidence";
555    const VERSION: u16 = 1;
556
557    fn validate(&self) -> Result<(), PayloadError> {
558        validate_non_empty("subject_id", &self.subject_id).map_err(|reason| {
559            PayloadError::Invalid {
560                family: Self::FAMILY.into(),
561                version: Self::VERSION.into(),
562                reason,
563            }
564        })?;
565        self.identity
566            .validate()
567            .map_err(|reason| PayloadError::Invalid {
568                family: Self::FAMILY.into(),
569                version: Self::VERSION.into(),
570                reason,
571            })
572    }
573}
574
575fn validate_non_empty(field: &str, value: &str) -> Result<(), String> {
576    if value.trim().is_empty() {
577        Err(format!("{field} must not be empty"))
578    } else {
579        Ok(())
580    }
581}
582
583/// Errors at the typed payload and wire materialization boundary.
584#[derive(Debug, Clone, PartialEq, Eq, Error)]
585pub enum PayloadError {
586    /// Payload failed validation.
587    #[error("invalid payload for {family} v{version}: {reason}")]
588    Invalid {
589        /// Payload family.
590        family: FactFamilyId,
591        /// Payload version.
592        version: PayloadVersion,
593        /// Human-readable reason.
594        reason: String,
595    },
596    /// Payload failed JSON serialization at a border.
597    #[error("failed to serialize payload {family} v{version}: {reason}")]
598    Serialize {
599        /// Payload family.
600        family: FactFamilyId,
601        /// Payload version.
602        version: PayloadVersion,
603        /// Human-readable reason.
604        reason: String,
605    },
606    /// Payload failed JSON deserialization at a border.
607    #[error("failed to deserialize payload {family} v{version}: {reason}")]
608    Deserialize {
609        /// Payload family.
610        family: FactFamilyId,
611        /// Payload version.
612        version: PayloadVersion,
613        /// Human-readable reason.
614        reason: String,
615    },
616    /// No decoder is registered for the wire family and version.
617    #[error("unknown payload family/version: {family} v{version}")]
618    UnknownFamilyVersion {
619        /// Payload family.
620        family: FactFamilyId,
621        /// Payload version.
622        version: PayloadVersion,
623    },
624    /// A typed access request used the wrong payload type.
625    #[error(
626        "payload type mismatch: expected {expected} v{expected_version}, got {actual} v{actual_version}"
627    )]
628    TypeMismatch {
629        /// Expected family.
630        expected: FactFamilyId,
631        /// Expected version.
632        expected_version: PayloadVersion,
633        /// Actual family.
634        actual: FactFamilyId,
635        /// Actual version.
636        actual_version: PayloadVersion,
637    },
638}
639
640/// Stable wire payload shape for HTTP, gRPC, NATS, storage/replay, CLI
641/// fixtures, non-Rust clients, and audit export.
642#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
643#[serde(deny_unknown_fields)]
644pub struct WireFactPayload {
645    /// Payload family.
646    pub family: FactFamilyId,
647    /// Payload schema version.
648    pub version: PayloadVersion,
649    /// JSON payload bytes decoded into a value at the border.
650    pub payload: serde_json::Value,
651}
652
653impl WireFactPayload {
654    fn from_erased(payload: &dyn ErasedFactPayload) -> Result<Self, PayloadError> {
655        Ok(Self {
656            family: payload.family(),
657            version: payload.version(),
658            payload: payload.to_json_value()?,
659        })
660    }
661}
662
663/// Wire shape for proposed facts. This is the only sanctioned way for borders
664/// to materialize proposals without already holding a typed Rust payload.
665#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
666#[serde(deny_unknown_fields)]
667pub struct WireProposedFact {
668    /// Destination context key.
669    pub key: ContextKey,
670    /// Proposal identifier.
671    pub id: ProposalId,
672    /// Proposed payload.
673    pub payload: WireFactPayload,
674    /// Confidence hint.
675    pub confidence: UnitInterval,
676    /// Uniform provenance metadata.
677    pub provenance: Provenance,
678}
679
680/// Wire shape for promoted context facts.
681#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
682#[serde(deny_unknown_fields)]
683pub struct WireContextFact {
684    /// Context key.
685    pub key: ContextKey,
686    /// Fact identifier.
687    pub id: FactId,
688    /// Fact payload.
689    pub payload: WireFactPayload,
690    /// Promotion record.
691    pub promotion_record: FactPromotionRecord,
692    /// Creation timestamp.
693    pub created_at: Timestamp,
694}
695
696type PayloadDecoder = Box<
697    dyn Fn(serde_json::Value) -> Result<Arc<dyn ErasedFactPayload>, PayloadError> + Send + Sync,
698>;
699
700/// Registry used at serialization borders to materialize typed payloads from
701/// `(family, version, payload)` tuples.
702#[derive(Default)]
703pub struct PayloadRegistry {
704    decoders: HashMap<(FactFamilyId, PayloadVersion), PayloadDecoder>,
705}
706
707impl PayloadRegistry {
708    /// Creates an empty registry.
709    #[must_use]
710    pub fn new() -> Self {
711        Self::default()
712    }
713
714    /// Creates a registry with payloads owned by `converge-pack`.
715    #[must_use]
716    pub fn with_pack_payloads() -> Self {
717        let mut registry = Self::new();
718        registry.register::<TextPayload>();
719        registry.register::<DiagnosticPayload>();
720        registry.register::<ExecutionIdentityEvidence>();
721        registry
722    }
723
724    /// Registers one frozen payload type.
725    pub fn register<T>(&mut self)
726    where
727        T: FactPayload + PartialEq + DeserializeOwned,
728    {
729        self.decoders.insert(
730            (
731                FactFamilyId::from(T::FAMILY),
732                PayloadVersion::new(T::VERSION),
733            ),
734            Box::new(|value| {
735                let payload: T =
736                    serde_json::from_value(value).map_err(|err| PayloadError::Deserialize {
737                        family: T::FAMILY.into(),
738                        version: T::VERSION.into(),
739                        reason: err.to_string(),
740                    })?;
741                payload.validate()?;
742                Ok(Arc::new(payload))
743            }),
744        );
745    }
746
747    fn decode(
748        &self,
749        family: &FactFamilyId,
750        version: PayloadVersion,
751        payload: serde_json::Value,
752    ) -> Result<Arc<dyn ErasedFactPayload>, PayloadError> {
753        let decoder = self
754            .decoders
755            .get(&(family.clone(), version))
756            .ok_or_else(|| PayloadError::UnknownFamilyVersion {
757                family: family.clone(),
758                version,
759            })?;
760        decoder(payload)
761    }
762}
763
764/// Actor kind recorded on a promoted fact.
765#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
766pub enum FactActorKind {
767    /// Human approver.
768    Human,
769    /// Suggestor or automated domain actor.
770    Suggestor,
771    /// Kernel or system component.
772    System,
773}
774
775/// Read-only actor record attached to authoritative facts.
776#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
777pub struct FactActor {
778    id: ActorId,
779    kind: FactActorKind,
780}
781
782impl FactActor {
783    /// Returns the actor identifier.
784    #[must_use]
785    pub fn id(&self) -> &ActorId {
786        &self.id
787    }
788
789    /// Returns the actor kind.
790    #[must_use]
791    pub fn kind(&self) -> FactActorKind {
792        self.kind
793    }
794
795    #[doc(hidden)]
796    pub fn new_projection(id: impl Into<ActorId>, kind: FactActorKind) -> Self {
797        Self {
798            id: id.into(),
799            kind,
800        }
801    }
802}
803
804/// Summary of validation checks attached to an authoritative fact.
805#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
806pub struct FactValidationSummary {
807    checks_passed: Vec<ValidationCheckId>,
808    checks_skipped: Vec<ValidationCheckId>,
809    warnings: Vec<String>,
810}
811
812impl FactValidationSummary {
813    /// Returns validation checks that passed.
814    #[must_use]
815    pub fn checks_passed(&self) -> &[ValidationCheckId] {
816        &self.checks_passed
817    }
818
819    /// Returns validation checks that were skipped.
820    #[must_use]
821    pub fn checks_skipped(&self) -> &[ValidationCheckId] {
822        &self.checks_skipped
823    }
824
825    /// Returns validation warnings.
826    #[must_use]
827    pub fn warnings(&self) -> &[String] {
828        &self.warnings
829    }
830
831    #[doc(hidden)]
832    pub fn new_projection(
833        checks_passed: Vec<ValidationCheckId>,
834        checks_skipped: Vec<ValidationCheckId>,
835        warnings: Vec<String>,
836    ) -> Self {
837        Self {
838            checks_passed,
839            checks_skipped,
840            warnings,
841        }
842    }
843}
844
845/// Typed evidence references attached to an authoritative fact.
846#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
847#[serde(tag = "type", content = "id")]
848pub enum FactEvidenceRef {
849    /// Observation used as evidence.
850    Observation(ObservationId),
851    /// Human approval used as evidence.
852    HumanApproval(ApprovalId),
853    /// Derived artifact used as evidence.
854    Derived(ArtifactId),
855}
856
857/// Local replayable trace reference.
858#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
859pub struct FactLocalTrace {
860    trace_id: TraceId,
861    span_id: SpanId,
862    parent_span_id: Option<SpanId>,
863    sampled: bool,
864}
865
866impl FactLocalTrace {
867    /// Returns the trace identifier.
868    #[must_use]
869    pub fn trace_id(&self) -> &TraceId {
870        &self.trace_id
871    }
872
873    /// Returns the span identifier.
874    #[must_use]
875    pub fn span_id(&self) -> &SpanId {
876        &self.span_id
877    }
878
879    /// Returns the parent span identifier.
880    #[must_use]
881    pub fn parent_span_id(&self) -> Option<&SpanId> {
882        self.parent_span_id.as_ref()
883    }
884
885    /// Returns whether the trace was sampled.
886    #[must_use]
887    pub fn sampled(&self) -> bool {
888        self.sampled
889    }
890
891    #[doc(hidden)]
892    pub fn new_projection(
893        trace_id: impl Into<TraceId>,
894        span_id: impl Into<SpanId>,
895        parent_span_id: Option<SpanId>,
896        sampled: bool,
897    ) -> Self {
898        Self {
899            trace_id: trace_id.into(),
900            span_id: span_id.into(),
901            parent_span_id,
902            sampled,
903        }
904    }
905}
906
907/// Remote audit-only trace reference.
908#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
909pub struct FactRemoteTrace {
910    system: TraceSystemId,
911    reference: TraceReference,
912    retrieval_auth: Option<String>,
913    retention_hint: Option<String>,
914}
915
916impl FactRemoteTrace {
917    /// Returns the remote system identifier.
918    #[must_use]
919    pub fn system(&self) -> &TraceSystemId {
920        &self.system
921    }
922
923    /// Returns the remote trace reference.
924    #[must_use]
925    pub fn reference(&self) -> &TraceReference {
926        &self.reference
927    }
928
929    /// Returns the retrieval auth hint.
930    #[must_use]
931    pub fn retrieval_auth(&self) -> Option<&str> {
932        self.retrieval_auth.as_deref()
933    }
934
935    /// Returns the retention hint.
936    #[must_use]
937    pub fn retention_hint(&self) -> Option<&str> {
938        self.retention_hint.as_deref()
939    }
940
941    #[doc(hidden)]
942    pub fn new_projection(
943        system: impl Into<TraceSystemId>,
944        reference: impl Into<TraceReference>,
945        retrieval_auth: Option<String>,
946        retention_hint: Option<String>,
947    ) -> Self {
948        Self {
949            system: system.into(),
950            reference: reference.into(),
951            retrieval_auth,
952            retention_hint,
953        }
954    }
955}
956
957/// Trace record attached to an authoritative fact.
958#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
959#[serde(tag = "type")]
960pub enum FactTraceLink {
961    /// Local replayable trace.
962    Local(FactLocalTrace),
963    /// Remote audit-only trace.
964    Remote(FactRemoteTrace),
965}
966
967impl FactTraceLink {
968    /// Returns whether the trace is replay-eligible.
969    #[must_use]
970    pub fn is_replay_eligible(&self) -> bool {
971        matches!(self, Self::Local(_))
972    }
973}
974
975/// Read-only promotion record attached to an authoritative fact.
976#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
977pub struct FactPromotionRecord {
978    gate_id: GateId,
979    policy_version_hash: ContentHash,
980    approver: FactActor,
981    validation_summary: FactValidationSummary,
982    evidence_refs: Vec<FactEvidenceRef>,
983    trace_link: FactTraceLink,
984    promoted_at: Timestamp,
985}
986
987impl FactPromotionRecord {
988    /// Returns the gate identifier that promoted the fact.
989    #[must_use]
990    pub fn gate_id(&self) -> &GateId {
991        &self.gate_id
992    }
993
994    /// Returns the policy hash used during promotion.
995    #[must_use]
996    pub fn policy_version_hash(&self) -> &ContentHash {
997        &self.policy_version_hash
998    }
999
1000    /// Returns the approving actor.
1001    #[must_use]
1002    pub fn approver(&self) -> &FactActor {
1003        &self.approver
1004    }
1005
1006    /// Returns the validation summary.
1007    #[must_use]
1008    pub fn validation_summary(&self) -> &FactValidationSummary {
1009        &self.validation_summary
1010    }
1011
1012    /// Returns the evidence references used during promotion.
1013    #[must_use]
1014    pub fn evidence_refs(&self) -> &[FactEvidenceRef] {
1015        &self.evidence_refs
1016    }
1017
1018    /// Returns the trace link for audit or replay.
1019    #[must_use]
1020    pub fn trace_link(&self) -> &FactTraceLink {
1021        &self.trace_link
1022    }
1023
1024    /// Returns the promotion timestamp.
1025    #[must_use]
1026    pub fn promoted_at(&self) -> &Timestamp {
1027        &self.promoted_at
1028    }
1029
1030    /// Returns whether the promotion is replay-eligible.
1031    #[must_use]
1032    pub fn is_replay_eligible(&self) -> bool {
1033        self.trace_link.is_replay_eligible()
1034    }
1035
1036    #[doc(hidden)]
1037    pub fn new_projection(
1038        gate_id: impl Into<GateId>,
1039        policy_version_hash: ContentHash,
1040        approver: FactActor,
1041        validation_summary: FactValidationSummary,
1042        evidence_refs: Vec<FactEvidenceRef>,
1043        trace_link: FactTraceLink,
1044        promoted_at: impl Into<Timestamp>,
1045    ) -> Self {
1046        Self {
1047            gate_id: gate_id.into(),
1048            policy_version_hash,
1049            approver,
1050            validation_summary,
1051            evidence_refs,
1052            trace_link,
1053            promoted_at: promoted_at.into(),
1054        }
1055    }
1056}
1057
1058/// Read-only projection of a validated assertion in the context.
1059///
1060/// This type is not promotion authority. It is the value suggestors and
1061/// pack authors can read from context after the engine has promoted a
1062/// proposal. Constructing one locally does not admit it into Converge; there is
1063/// no public API that accepts a `ContextFact` as promoted truth.
1064#[derive(Clone)]
1065pub struct ContextFact {
1066    /// Which context key this fact belongs to.
1067    key: ContextKey,
1068    /// Unique identifier within the context key namespace.
1069    id: FactId,
1070    /// Typed fact payload.
1071    payload: Arc<dyn ErasedFactPayload>,
1072    /// The immutable promotion record that made this fact authoritative.
1073    promotion_record: FactPromotionRecord,
1074    /// When the authoritative fact entered context.
1075    created_at: Timestamp,
1076}
1077
1078impl fmt::Debug for ContextFact {
1079    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1080        f.debug_struct("ContextFact")
1081            .field("key", &self.key)
1082            .field("id", &self.id)
1083            .field("payload_family", &self.payload_family())
1084            .field("payload_version", &self.payload_version())
1085            .field("promotion_record", &self.promotion_record)
1086            .field("created_at", &self.created_at)
1087            .finish()
1088    }
1089}
1090
1091impl PartialEq for ContextFact {
1092    fn eq(&self, other: &Self) -> bool {
1093        self.key == other.key
1094            && self.id == other.id
1095            && self.payload_family() == other.payload_family()
1096            && self.payload_version() == other.payload_version()
1097            && self.payload.equivalent(other.payload.as_ref())
1098            && self.promotion_record == other.promotion_record
1099            && self.created_at == other.created_at
1100    }
1101}
1102
1103impl Eq for ContextFact {}
1104
1105impl Serialize for ContextFact {
1106    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1107    where
1108        S: serde::Serializer,
1109    {
1110        self.to_wire()
1111            .map_err(serde::ser::Error::custom)?
1112            .serialize(serializer)
1113    }
1114}
1115
1116impl ContextFact {
1117    /// Creates a read-only context projection.
1118    ///
1119    /// This constructor does not promote anything and is intentionally named as
1120    /// a projection constructor. The engine is still the only component that can
1121    /// add context facts to a live `ContextState`.
1122    #[must_use]
1123    pub fn new_projection<T>(
1124        key: ContextKey,
1125        id: impl Into<FactId>,
1126        payload: T,
1127        promotion_record: FactPromotionRecord,
1128        created_at: impl Into<Timestamp>,
1129    ) -> Self
1130    where
1131        T: FactPayload + PartialEq,
1132    {
1133        Self {
1134            key,
1135            id: id.into(),
1136            payload: Arc::new(payload),
1137            promotion_record,
1138            created_at: created_at.into(),
1139        }
1140    }
1141
1142    /// Creates a context fact from a wire representation at a serialization
1143    /// border.
1144    pub fn from_wire(
1145        wire: WireContextFact,
1146        registry: &PayloadRegistry,
1147    ) -> Result<Self, PayloadError> {
1148        let payload = registry.decode(
1149            &wire.payload.family,
1150            wire.payload.version,
1151            wire.payload.payload,
1152        )?;
1153        Ok(Self {
1154            key: wire.key,
1155            id: wire.id,
1156            payload,
1157            promotion_record: wire.promotion_record,
1158            created_at: wire.created_at,
1159        })
1160    }
1161
1162    /// Converts this fact to the stable wire shape.
1163    pub fn to_wire(&self) -> Result<WireContextFact, PayloadError> {
1164        Ok(WireContextFact {
1165            key: self.key,
1166            id: self.id.clone(),
1167            payload: WireFactPayload::from_erased(self.payload.as_ref())?,
1168            promotion_record: self.promotion_record.clone(),
1169            created_at: self.created_at.clone(),
1170        })
1171    }
1172
1173    /// Returns the context key this fact belongs to.
1174    #[must_use]
1175    pub fn key(&self) -> ContextKey {
1176        self.key
1177    }
1178
1179    /// Returns the fact identifier.
1180    #[must_use]
1181    pub fn id(&self) -> &FactId {
1182        &self.id
1183    }
1184
1185    /// Returns the typed payload when the requested type matches the stored
1186    /// payload family/version.
1187    #[must_use]
1188    pub fn payload<T: FactPayload>(&self) -> Option<&T> {
1189        self.payload.as_any().downcast_ref::<T>()
1190    }
1191
1192    /// Returns the typed payload or a mismatch error.
1193    pub fn require_payload<T: FactPayload>(&self) -> Result<&T, PayloadError> {
1194        self.payload::<T>()
1195            .ok_or_else(|| PayloadError::TypeMismatch {
1196                expected: T::FAMILY.into(),
1197                expected_version: T::VERSION.into(),
1198                actual: self.payload_family(),
1199                actual_version: self.payload_version(),
1200            })
1201    }
1202
1203    /// Returns the payload family.
1204    #[must_use]
1205    pub fn payload_family(&self) -> FactFamilyId {
1206        self.payload.family()
1207    }
1208
1209    /// Returns the payload version.
1210    #[must_use]
1211    pub fn payload_version(&self) -> PayloadVersion {
1212        self.payload.version()
1213    }
1214
1215    /// Returns the payload as text when this is a [`TextPayload`].
1216    #[must_use]
1217    pub fn text(&self) -> Option<&str> {
1218        self.payload::<TextPayload>().map(TextPayload::as_str)
1219    }
1220
1221    /// Validates the stored payload.
1222    pub fn validate_payload(&self) -> Result<(), PayloadError> {
1223        self.payload.validate()
1224    }
1225
1226    /// Returns the immutable promotion record for this fact.
1227    #[must_use]
1228    pub fn promotion_record(&self) -> &FactPromotionRecord {
1229        &self.promotion_record
1230    }
1231
1232    /// Returns the fact creation timestamp.
1233    #[must_use]
1234    pub fn created_at(&self) -> &Timestamp {
1235        &self.created_at
1236    }
1237
1238    /// Returns whether the fact is replay-eligible.
1239    #[must_use]
1240    pub fn is_replay_eligible(&self) -> bool {
1241        self.promotion_record.is_replay_eligible()
1242    }
1243}
1244
1245/// An unvalidated suggestion from a non-authoritative source.
1246///
1247/// Proposed facts live in `ContextKey::Proposals` until a `ValidationAgent`
1248/// promotes them to `Fact`. The proposal tracks its origin for audit trail.
1249#[derive(Clone)]
1250pub struct ProposedFact {
1251    /// The context key this proposal targets.
1252    pub key: ContextKey,
1253    /// Unique identifier encoding origin and target.
1254    pub id: ProposalId,
1255    /// Typed proposed payload.
1256    payload: Arc<dyn ErasedFactPayload>,
1257    /// Confidence hint from the source. Always in [0.0, 1.0].
1258    confidence: UnitInterval,
1259    /// Provenance information (e.g., model ID, prompt hash).
1260    pub provenance: Provenance,
1261}
1262
1263impl fmt::Debug for ProposedFact {
1264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1265        f.debug_struct("ProposedFact")
1266            .field("key", &self.key)
1267            .field("id", &self.id)
1268            .field("payload_family", &self.payload_family())
1269            .field("payload_version", &self.payload_version())
1270            .field("confidence", &self.confidence)
1271            .field("provenance", &self.provenance)
1272            .finish()
1273    }
1274}
1275
1276impl PartialEq for ProposedFact {
1277    fn eq(&self, other: &Self) -> bool {
1278        self.key == other.key
1279            && self.id == other.id
1280            && self.payload_family() == other.payload_family()
1281            && self.payload_version() == other.payload_version()
1282            && self.payload.equivalent(other.payload.as_ref())
1283            && self.confidence == other.confidence
1284            && self.provenance == other.provenance
1285    }
1286}
1287
1288impl Serialize for ProposedFact {
1289    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1290    where
1291        S: serde::Serializer,
1292    {
1293        self.to_wire()
1294            .map_err(serde::ser::Error::custom)?
1295            .serialize(serializer)
1296    }
1297}
1298
1299impl ProposedFact {
1300    /// Create a new draft proposal with explicit provenance.
1301    ///
1302    /// Confidence defaults to 1.0. Override with [`with_confidence`][Self::with_confidence].
1303    #[must_use]
1304    pub fn new<T>(
1305        key: ContextKey,
1306        id: impl Into<ProposalId>,
1307        payload: T,
1308        provenance: impl Into<Provenance>,
1309    ) -> Self
1310    where
1311        T: FactPayload + PartialEq,
1312    {
1313        Self {
1314            key,
1315            id: id.into(),
1316            payload: Arc::new(payload),
1317            confidence: UnitInterval::ONE,
1318            provenance: provenance.into(),
1319        }
1320    }
1321
1322    /// Materializes a proposal from the stable wire shape at an external
1323    /// border.
1324    pub fn from_wire(
1325        wire: WireProposedFact,
1326        registry: &PayloadRegistry,
1327    ) -> Result<Self, PayloadError> {
1328        let payload = registry.decode(
1329            &wire.payload.family,
1330            wire.payload.version,
1331            wire.payload.payload,
1332        )?;
1333        Ok(Self {
1334            key: wire.key,
1335            id: wire.id,
1336            payload,
1337            confidence: wire.confidence,
1338            provenance: wire.provenance,
1339        })
1340    }
1341
1342    /// Converts this proposal to the stable wire shape.
1343    pub fn to_wire(&self) -> Result<WireProposedFact, PayloadError> {
1344        Ok(WireProposedFact {
1345            key: self.key,
1346            id: self.id.clone(),
1347            payload: WireFactPayload::from_erased(self.payload.as_ref())?,
1348            confidence: self.confidence,
1349            provenance: self.provenance.clone(),
1350        })
1351    }
1352
1353    /// Creates a promoted context projection that preserves this proposal's
1354    /// exact typed payload.
1355    ///
1356    /// Promotion authority still belongs to the engine/gate. This method only
1357    /// avoids re-serializing or textifying a payload after that authority has
1358    /// already accepted the proposal.
1359    #[must_use]
1360    pub fn to_context_fact(
1361        &self,
1362        id: impl Into<FactId>,
1363        promotion_record: FactPromotionRecord,
1364        created_at: impl Into<Timestamp>,
1365    ) -> ContextFact {
1366        ContextFact {
1367            key: self.key,
1368            id: id.into(),
1369            payload: Arc::clone(&self.payload),
1370            promotion_record,
1371            created_at: created_at.into(),
1372        }
1373    }
1374
1375    /// Returns the context key this proposal targets.
1376    #[must_use]
1377    pub fn key(&self) -> ContextKey {
1378        self.key
1379    }
1380
1381    /// Returns the proposal identifier.
1382    #[must_use]
1383    pub fn id(&self) -> &ProposalId {
1384        &self.id
1385    }
1386
1387    /// Returns the typed payload when the requested type matches the stored
1388    /// payload family/version.
1389    #[must_use]
1390    pub fn payload<T: FactPayload>(&self) -> Option<&T> {
1391        self.payload.as_any().downcast_ref::<T>()
1392    }
1393
1394    /// Returns the typed payload or a mismatch error.
1395    pub fn require_payload<T: FactPayload>(&self) -> Result<&T, PayloadError> {
1396        self.payload::<T>()
1397            .ok_or_else(|| PayloadError::TypeMismatch {
1398                expected: T::FAMILY.into(),
1399                expected_version: T::VERSION.into(),
1400                actual: self.payload_family(),
1401                actual_version: self.payload_version(),
1402            })
1403    }
1404
1405    /// Returns the payload family.
1406    #[must_use]
1407    pub fn payload_family(&self) -> FactFamilyId {
1408        self.payload.family()
1409    }
1410
1411    /// Returns the payload version.
1412    #[must_use]
1413    pub fn payload_version(&self) -> PayloadVersion {
1414        self.payload.version()
1415    }
1416
1417    /// Returns the payload as text when this is a [`TextPayload`].
1418    #[must_use]
1419    pub fn text(&self) -> Option<&str> {
1420        self.payload::<TextPayload>().map(TextPayload::as_str)
1421    }
1422
1423    /// Validates the stored payload.
1424    pub fn validate_payload(&self) -> Result<(), PayloadError> {
1425        self.payload.validate()
1426    }
1427
1428    /// Returns the proposal provenance value.
1429    #[must_use]
1430    pub fn provenance_ref(&self) -> &Provenance {
1431        &self.provenance
1432    }
1433
1434    /// Returns the proposal provenance string for wire/logging boundaries.
1435    #[must_use]
1436    pub fn provenance(&self) -> &str {
1437        self.provenance.as_str()
1438    }
1439
1440    /// Returns the confidence value, always in [0.0, 1.0].
1441    #[must_use]
1442    pub fn confidence(&self) -> f64 {
1443        self.confidence.as_f64()
1444    }
1445
1446    /// Set an explicit confidence baseline for this proposal.
1447    ///
1448    /// Use this to establish a starting point, then accumulate criteria with
1449    /// [`adjust_confidence`][Self::adjust_confidence]. The value is clamped to
1450    /// [0.0, 1.0]; non-finite values (NaN, infinity) are treated as 0.0.
1451    ///
1452    /// For computed confidence (e.g. from a solver), pass the result directly.
1453    #[must_use]
1454    pub fn with_confidence(mut self, confidence: f64) -> Self {
1455        self.confidence = UnitInterval::clamped(confidence);
1456        self
1457    }
1458
1459    /// Adjust confidence by a named step, clamped to [0.0, 1.0].
1460    ///
1461    /// This is the recommended way to express confidence in suggestors and pack
1462    /// solvers. Use the `CONFIDENCE_STEP_*` constants as the vocabulary:
1463    ///
1464    /// ```rust,ignore
1465    /// use converge_pack::{CONFIDENCE_STEP_MAJOR, CONFIDENCE_STEP_MINOR, CONFIDENCE_STEP_TINY, TextPayload};
1466    ///
1467    /// let proposal = EXAMPLE_PROVENANCE.proposed_fact(key, id, TextPayload::new(content))
1468    ///     .with_confidence(0.5)                        // baseline
1469    ///     .adjust_confidence(CONFIDENCE_STEP_MAJOR)    // primary criterion met
1470    ///     .adjust_confidence(CONFIDENCE_STEP_MINOR)    // supporting criterion met
1471    ///     .adjust_confidence(CONFIDENCE_STEP_TINY);    // tiebreaker bonus
1472    /// ```
1473    ///
1474    /// Prefer this over accumulating a local `f64` and calling `with_confidence`
1475    /// at the end — the clamping is automatic and the intent is explicit at each step.
1476    #[must_use]
1477    pub fn adjust_confidence(mut self, delta: f64) -> Self {
1478        self.confidence = self.confidence.saturating_add(delta);
1479        self
1480    }
1481}
1482
1483/// Tiny confidence step — use for marginal or tiebreaker criteria (0.05).
1484pub const CONFIDENCE_STEP_TINY: f64 = 0.05;
1485
1486/// Minor confidence step — use for supporting criteria (0.1).
1487pub const CONFIDENCE_STEP_MINOR: f64 = 0.1;
1488
1489/// Medium confidence step — use for moderately significant criteria (0.15).
1490pub const CONFIDENCE_STEP_MEDIUM: f64 = 0.15;
1491
1492/// Major confidence step — use for significant criteria (0.2).
1493pub const CONFIDENCE_STEP_MAJOR: f64 = 0.2;
1494
1495/// Primary confidence step — use for decisive or high-weight criteria (0.25).
1496pub const CONFIDENCE_STEP_PRIMARY: f64 = 0.25;
1497
1498/// Error when a `ProposedFact` fails validation.
1499#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1500pub struct ValidationError {
1501    /// Reason the proposal was rejected.
1502    pub reason: String,
1503}
1504
1505impl std::fmt::Display for ValidationError {
1506    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1507        write!(f, "validation failed: {}", self.reason)
1508    }
1509}
1510
1511impl std::error::Error for ValidationError {}
1512
1513#[cfg(test)]
1514mod tests {
1515    use super::*;
1516
1517    #[derive(Clone, Copy, Debug)]
1518    struct TestProvenance;
1519
1520    impl ProvenanceSource for TestProvenance {
1521        fn as_str(&self) -> &'static str {
1522            "test-provenance"
1523        }
1524    }
1525
1526    fn projection_record() -> FactPromotionRecord {
1527        FactPromotionRecord::new_projection(
1528            "projection-test",
1529            ContentHash::from_hex(
1530                "1111111111111111111111111111111111111111111111111111111111111111",
1531            ),
1532            FactActor::new_projection("actor-1", FactActorKind::System),
1533            FactValidationSummary::default(),
1534            Vec::new(),
1535            FactTraceLink::Local(FactLocalTrace::new_projection(
1536                "trace-1", "span-1", None, true,
1537            )),
1538            Timestamp::epoch(),
1539        )
1540    }
1541
1542    fn projection_fact(
1543        key: ContextKey,
1544        id: impl Into<FactId>,
1545        content: impl Into<String>,
1546    ) -> ContextFact {
1547        ContextFact::new_projection(
1548            key,
1549            id,
1550            TextPayload::new(content),
1551            projection_record(),
1552            Timestamp::epoch(),
1553        )
1554    }
1555
1556    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1557    #[serde(deny_unknown_fields)]
1558    struct TestPayload {
1559        kind: String,
1560        score: f64,
1561    }
1562
1563    impl FactPayload for TestPayload {
1564        const FAMILY: &'static str = "test.payload";
1565        const VERSION: u16 = 1;
1566    }
1567
1568    fn native_identity() -> NativeExecutionIdentity {
1569        NativeExecutionIdentity::new(
1570            "CVC5",
1571            "1.3.3",
1572            "https://github.com/cvc5/cvc5",
1573            "expected",
1574            "actual",
1575            "vendored",
1576        )
1577    }
1578
1579    #[test]
1580    fn execution_identity_evidence_targets_typed_payload() {
1581        let identity = ExecutionIdentity::new(
1582            ExecutionProducerIdentity::new("soter", "0.1.0"),
1583            "cvc5",
1584            "1.3.3",
1585            "configure_flags=--no-poly",
1586            "timeout_ms=5000",
1587            Some(native_identity()),
1588        );
1589        let evidence = ExecutionIdentityEvidence::for_payload::<TestPayload>(
1590            ContextKey::Evaluations,
1591            "smt-report-q1",
1592            identity,
1593        );
1594
1595        assert_eq!(evidence.subject_key, ContextKey::Evaluations);
1596        assert_eq!(evidence.subject_id, "smt-report-q1");
1597        assert_eq!(evidence.subject_family, FactFamilyId::from("test.payload"));
1598        assert_eq!(evidence.subject_version, PayloadVersion::new(1));
1599        assert_eq!(evidence.identity.backend, "cvc5");
1600        assert!(FactPayload::validate(&evidence).is_ok());
1601    }
1602
1603    #[test]
1604    fn execution_identity_evidence_rejects_empty_subject_id() {
1605        let evidence = ExecutionIdentityEvidence::for_payload::<TestPayload>(
1606            ContextKey::Strategies,
1607            "",
1608            ExecutionIdentity::non_native("ferrox", "0.5.1", "greedy", "tasks=3"),
1609        );
1610
1611        assert!(matches!(
1612            FactPayload::validate(&evidence),
1613            Err(PayloadError::Invalid { .. })
1614        ));
1615    }
1616
1617    #[test]
1618    fn trace_link_local_is_replay_eligible() {
1619        let local = FactTraceLink::Local(FactLocalTrace {
1620            trace_id: "t1".into(),
1621            span_id: "s1".into(),
1622            parent_span_id: None,
1623            sampled: true,
1624        });
1625        assert!(local.is_replay_eligible());
1626    }
1627
1628    #[test]
1629    fn trace_link_remote_is_not_replay_eligible() {
1630        let remote = FactTraceLink::Remote(FactRemoteTrace {
1631            system: "datadog".into(),
1632            reference: "ref-1".into(),
1633            retrieval_auth: None,
1634            retention_hint: None,
1635        });
1636        assert!(!remote.is_replay_eligible());
1637    }
1638
1639    #[test]
1640    fn promotion_record_delegates_replay_eligibility() {
1641        let local_record = FactPromotionRecord::new_projection(
1642            "gate-1",
1643            ContentHash::from_hex(
1644                "1111111111111111111111111111111111111111111111111111111111111111",
1645            ),
1646            FactActor::new_projection("actor-1", FactActorKind::Human),
1647            FactValidationSummary::default(),
1648            Vec::new(),
1649            FactTraceLink::Local(FactLocalTrace::new_projection("t1", "s1", None, true)),
1650            "2026-01-01T00:00:00Z",
1651        );
1652        assert!(local_record.is_replay_eligible());
1653
1654        let remote_record = FactPromotionRecord::new_projection(
1655            "gate-2",
1656            ContentHash::from_hex(
1657                "2222222222222222222222222222222222222222222222222222222222222222",
1658            ),
1659            FactActor::new_projection("actor-2", FactActorKind::System),
1660            FactValidationSummary::default(),
1661            Vec::new(),
1662            FactTraceLink::Remote(FactRemoteTrace::new_projection("dd", "ref-1", None, None)),
1663            "2026-01-01T00:00:00Z",
1664        );
1665        assert!(!remote_record.is_replay_eligible());
1666    }
1667
1668    #[test]
1669    fn fact_delegates_replay_eligibility() {
1670        let fact = projection_fact(ContextKey::Seeds, "f1", "content");
1671        assert!(fact.is_replay_eligible());
1672    }
1673
1674    #[test]
1675    fn proposed_fact_new_sets_fields() {
1676        let pf = ProposedFact::new(
1677            ContextKey::Hypotheses,
1678            "p1",
1679            TextPayload::new("my content"),
1680            TestProvenance.provenance(),
1681        );
1682        assert_eq!(pf.key, ContextKey::Hypotheses);
1683        assert_eq!(pf.id, "p1");
1684        assert_eq!(pf.text(), Some("my content"));
1685        assert_eq!(pf.confidence(), 1.0);
1686        assert_eq!(pf.provenance(), "test-provenance");
1687    }
1688
1689    #[test]
1690    fn proposed_fact_with_confidence() {
1691        let pf = ProposedFact::new(
1692            ContextKey::Signals,
1693            "p2",
1694            TextPayload::new("c"),
1695            TestProvenance.provenance(),
1696        )
1697        .with_confidence(0.42);
1698        assert!((pf.confidence() - 0.42).abs() < f64::EPSILON);
1699    }
1700
1701    #[test]
1702    fn adjust_confidence_accumulates() {
1703        let pf = ProposedFact::new(
1704            ContextKey::Seeds,
1705            "p",
1706            TextPayload::new("c"),
1707            TestProvenance.provenance(),
1708        )
1709        .with_confidence(0.5)
1710        .adjust_confidence(CONFIDENCE_STEP_MINOR)
1711        .adjust_confidence(CONFIDENCE_STEP_MAJOR);
1712        assert!((pf.confidence() - 0.8).abs() < f64::EPSILON);
1713    }
1714
1715    #[test]
1716    fn adjust_confidence_clamps_at_one() {
1717        let pf = ProposedFact::new(
1718            ContextKey::Seeds,
1719            "p",
1720            TextPayload::new("c"),
1721            TestProvenance.provenance(),
1722        )
1723        .with_confidence(0.9)
1724        .adjust_confidence(CONFIDENCE_STEP_MAJOR);
1725        assert_eq!(pf.confidence(), 1.0);
1726    }
1727
1728    #[test]
1729    fn adjust_confidence_clamps_at_zero() {
1730        let pf = ProposedFact::new(
1731            ContextKey::Seeds,
1732            "p",
1733            TextPayload::new("c"),
1734            TestProvenance.provenance(),
1735        )
1736        .with_confidence(0.1)
1737        .adjust_confidence(-0.5);
1738        assert_eq!(pf.confidence(), 0.0);
1739    }
1740
1741    #[test]
1742    fn with_confidence_clamps_high() {
1743        let pf = ProposedFact::new(
1744            ContextKey::Seeds,
1745            "p",
1746            TextPayload::new("c"),
1747            TestProvenance.provenance(),
1748        )
1749        .with_confidence(1.5);
1750        assert_eq!(pf.confidence(), 1.0);
1751    }
1752
1753    #[test]
1754    fn with_confidence_clamps_negative() {
1755        let pf = ProposedFact::new(
1756            ContextKey::Seeds,
1757            "p",
1758            TextPayload::new("c"),
1759            TestProvenance.provenance(),
1760        )
1761        .with_confidence(-0.1);
1762        assert_eq!(pf.confidence(), 0.0);
1763    }
1764
1765    #[test]
1766    fn with_confidence_normalizes_nan() {
1767        let pf = ProposedFact::new(
1768            ContextKey::Seeds,
1769            "p",
1770            TextPayload::new("c"),
1771            TestProvenance.provenance(),
1772        )
1773        .with_confidence(f64::NAN);
1774        assert_eq!(pf.confidence(), 0.0);
1775    }
1776
1777    #[test]
1778    fn with_confidence_normalizes_infinity() {
1779        let pf = ProposedFact::new(
1780            ContextKey::Seeds,
1781            "p",
1782            TextPayload::new("c"),
1783            TestProvenance.provenance(),
1784        )
1785        .with_confidence(f64::INFINITY);
1786        assert_eq!(pf.confidence(), 0.0);
1787    }
1788
1789    #[test]
1790    fn wire_proposed_fact_deserialization_rejects_out_of_range_confidence() {
1791        let json = r#"{
1792            "key":"Seeds",
1793            "id":"p",
1794            "payload":{
1795                "family":"converge.text",
1796                "version":1,
1797                "payload":{"text":"c"}
1798            },
1799            "confidence":1.5,
1800            "provenance":"test"
1801        }"#;
1802        let result = serde_json::from_str::<WireProposedFact>(json);
1803        assert!(result.is_err());
1804    }
1805
1806    #[test]
1807    fn proposed_fact_wire_round_trips_through_registry() {
1808        let payload = TestPayload {
1809            kind: "vote".into(),
1810            score: 0.7,
1811        };
1812        let pf = ProposedFact::new(
1813            ContextKey::Hypotheses,
1814            "p",
1815            payload.clone(),
1816            TestProvenance.provenance(),
1817        );
1818        let wire = pf.to_wire().unwrap();
1819        let mut registry = PayloadRegistry::new();
1820        registry.register::<TestPayload>();
1821
1822        let decoded = ProposedFact::from_wire(wire, &registry).unwrap();
1823
1824        assert_eq!(decoded.key, ContextKey::Hypotheses);
1825        assert_eq!(decoded.id, "p");
1826        assert_eq!(decoded.provenance(), "test-provenance");
1827        assert_eq!(decoded.require_payload::<TestPayload>().unwrap(), &payload);
1828    }
1829
1830    #[test]
1831    fn proposed_fact_from_wire_fails_closed_for_unknown_family_version() {
1832        let wire = WireProposedFact {
1833            key: ContextKey::Hypotheses,
1834            id: "p".into(),
1835            payload: WireFactPayload {
1836                family: FactFamilyId::new("unknown.payload"),
1837                version: PayloadVersion::new(1),
1838                payload: serde_json::json!({"kind":"vote"}),
1839            },
1840            confidence: UnitInterval::ONE,
1841            provenance: TestProvenance.provenance(),
1842        };
1843
1844        let registry = PayloadRegistry::new();
1845        let result = ProposedFact::from_wire(wire, &registry);
1846
1847        assert!(matches!(
1848            result,
1849            Err(PayloadError::UnknownFamilyVersion { .. })
1850        ));
1851    }
1852
1853    #[test]
1854    fn context_fact_wire_round_trips_through_registry() {
1855        let payload = TestPayload {
1856            kind: "fact".into(),
1857            score: 0.9,
1858        };
1859        let fact = ContextFact::new_projection(
1860            ContextKey::Seeds,
1861            "f",
1862            payload.clone(),
1863            projection_record(),
1864            Timestamp::epoch(),
1865        );
1866        let wire = fact.to_wire().unwrap();
1867        let mut registry = PayloadRegistry::new();
1868        registry.register::<TestPayload>();
1869
1870        let decoded = ContextFact::from_wire(wire, &registry).unwrap();
1871
1872        assert_eq!(decoded.key(), ContextKey::Seeds);
1873        assert_eq!(decoded.id(), "f");
1874        assert_eq!(decoded.require_payload::<TestPayload>().unwrap(), &payload);
1875    }
1876
1877    #[test]
1878    fn proposed_fact_to_context_fact_preserves_typed_payload() {
1879        let payload = TestPayload {
1880            kind: "proposal".into(),
1881            score: 0.8,
1882        };
1883        let proposal = ProposedFact::new(
1884            ContextKey::Strategies,
1885            "p",
1886            payload.clone(),
1887            TestProvenance.provenance(),
1888        );
1889
1890        let fact = proposal.to_context_fact("f", projection_record(), Timestamp::epoch());
1891
1892        assert_eq!(fact.key(), ContextKey::Strategies);
1893        assert_eq!(fact.require_payload::<TestPayload>().unwrap(), &payload);
1894    }
1895
1896    #[test]
1897    fn validation_error_display() {
1898        let err = ValidationError {
1899            reason: "bad input".into(),
1900        };
1901        assert_eq!(err.to_string(), "validation failed: bad input");
1902    }
1903
1904    #[test]
1905    fn validation_error_is_std_error() {
1906        let err = ValidationError {
1907            reason: "test".into(),
1908        };
1909        let _: &dyn std::error::Error = &err;
1910    }
1911
1912    #[test]
1913    fn fact_accessors() {
1914        let fact = projection_fact(ContextKey::Constraints, "f2", "body");
1915        assert_eq!(fact.key(), ContextKey::Constraints);
1916        assert_eq!(fact.id(), "f2");
1917        assert_eq!(fact.text(), Some("body"));
1918        assert_eq!(fact.created_at(), "1970-01-01T00:00:00Z");
1919        assert_eq!(fact.promotion_record().gate_id(), "projection-test");
1920    }
1921
1922    #[test]
1923    fn fact_actor_accessors() {
1924        let actor = FactActor::new_projection("agent-x", FactActorKind::Suggestor);
1925        assert_eq!(actor.id(), "agent-x");
1926        assert_eq!(actor.kind(), FactActorKind::Suggestor);
1927    }
1928
1929    #[test]
1930    fn validation_summary_accessors() {
1931        let vs = FactValidationSummary::new_projection(
1932            vec!["check-a".into()],
1933            vec!["check-b".into()],
1934            vec!["warn-c".into()],
1935        );
1936        assert_eq!(vs.checks_passed(), &["check-a"]);
1937        assert_eq!(vs.checks_skipped(), &["check-b"]);
1938        assert_eq!(vs.warnings(), &["warn-c"]);
1939    }
1940
1941    #[test]
1942    fn local_trace_accessors() {
1943        let lt =
1944            FactLocalTrace::new_projection("trace-1", "span-1", Some("parent-1".into()), false);
1945        assert_eq!(lt.trace_id(), "trace-1");
1946        assert_eq!(lt.span_id(), "span-1");
1947        assert_eq!(lt.parent_span_id().map(SpanId::as_str), Some("parent-1"));
1948        assert!(!lt.sampled());
1949    }
1950
1951    #[test]
1952    fn remote_trace_accessors() {
1953        let rt =
1954            FactRemoteTrace::new_projection("sys", "ref", Some("auth".into()), Some("30d".into()));
1955        assert_eq!(rt.system(), "sys");
1956        assert_eq!(rt.reference(), "ref");
1957        assert_eq!(rt.retrieval_auth(), Some("auth"));
1958        assert_eq!(rt.retention_hint(), Some("30d"));
1959    }
1960
1961    mod prop {
1962        use super::*;
1963        use proptest::prelude::*;
1964
1965        fn arb_context_key() -> impl Strategy<Value = ContextKey> {
1966            prop_oneof![
1967                Just(ContextKey::Seeds),
1968                Just(ContextKey::Hypotheses),
1969                Just(ContextKey::Strategies),
1970                Just(ContextKey::Constraints),
1971                Just(ContextKey::Signals),
1972                Just(ContextKey::Competitors),
1973                Just(ContextKey::Evaluations),
1974                Just(ContextKey::Proposals),
1975                Just(ContextKey::Diagnostic),
1976                Just(ContextKey::Votes),
1977                Just(ContextKey::Disagreements),
1978                Just(ContextKey::ConsensusOutcomes),
1979            ]
1980        }
1981
1982        proptest! {
1983            #[test]
1984            fn proposed_fact_always_constructible(
1985                key in arb_context_key(),
1986                id in "[a-z]{1,20}",
1987                content in ".*",
1988                prov in "[a-z0-9-]{1,30}",
1989            ) {
1990                let pf = ProposedFact::new(
1991                    key,
1992                    id.clone(),
1993                    TextPayload::new(content.clone()),
1994                    Provenance::new(prov.clone()),
1995                );
1996                prop_assert_eq!(pf.key, key);
1997                prop_assert_eq!(&pf.id, &id);
1998                prop_assert_eq!(pf.text(), Some(content.as_str()));
1999                prop_assert_eq!(pf.provenance(), prov.as_str());
2000                prop_assert!((pf.confidence() - 1.0).abs() < f64::EPSILON);
2001            }
2002        }
2003    }
2004}