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