Skip to main content

lunaris_verify/
types.rs

1//! Verifier DTOs — [`VerifyDecision`] (worker output) + [`VerifierBackend`]
2//! (audit-event tag).
3//!
4//! Per D-11 the worker's `apply_supersede` consumes a [`VerifyDecision`] and
5//! issues a single `StoragePort::atomic_write` with two `KvPut` ops (winner +
6//! loser-with-new-sys-to). Per D-22 the same decision is serialized into an
7//! `AuditEvent::VerifierArbitration { winner_id, loser_id, reason, backend,
8//! decided_at_iso }` and published to `__lunaris_audit__` fire-and-forget.
9
10use serde::{Deserialize, Serialize};
11use ulid::Ulid;
12
13/// One verifier arbitration decision. `winner_id` / `loser_id` are `Option`
14/// because a "deferred" decision (NoopVerifier short-circuit, or cloud-api
15/// D-21 transient-after-retry) carries no arbitration pair — the worker
16/// treats such decisions as "skip this item".
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub struct VerifyDecision {
19    pub winner_id: Option<Ulid>,
20    pub loser_id: Option<Ulid>,
21    pub reason: String,
22    pub backend: VerifierBackend,
23    pub decided_at_iso: String,
24    /// Optional machine-readable cause for deferred decisions. Set by the
25    /// cloud-api wrapper after D-21 retry exhaustion so the D-22 audit
26    /// pipeline can distinguish "transient backend failure" from "NoopVerifier
27    /// passthrough". Omitted from serialized audit records when `None`
28    /// (additive, backwards-compatible — existing records round-trip fine).
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub cause: Option<String>,
31}
32
33impl VerifyDecision {
34    /// Abstain — no arbitration produced. NoopVerifier returns this
35    /// unconditionally; cloud-api returns this after D-21 retry exhaust.
36    pub fn deferred() -> Self {
37        Self {
38            winner_id: None,
39            loser_id: None,
40            reason: "deferred (NoopVerifier or backend abstain)".to_string(),
41            backend: VerifierBackend::Noop,
42            decided_at_iso: chrono_iso_now(),
43            cause: None,
44        }
45    }
46
47    /// Abstain with a machine-readable cause and a specific backend tag. Used
48    /// by the cloud-api wrapper (R3) to preserve the D-21 audit signal after
49    /// retry exhaustion: `cause = "transient_after_retry: <err>"` and the
50    /// correct provider `backend` tag flow through to the D-22 audit event.
51    pub fn deferred_with_cause(cause: impl Into<String>, backend: VerifierBackend) -> Self {
52        Self {
53            winner_id: None,
54            loser_id: None,
55            reason: "deferred (transient backend failure after retry)".to_string(),
56            backend,
57            decided_at_iso: chrono_iso_now(),
58            cause: Some(cause.into()),
59        }
60    }
61
62    /// Real arbitration — winner + loser identified. The worker will issue
63    /// ONE `atomic_write` superseding the loser and one audit publish.
64    pub fn arbitrate(
65        winner: Ulid,
66        loser: Ulid,
67        reason: impl Into<String>,
68        backend: VerifierBackend,
69    ) -> Self {
70        Self {
71            winner_id: Some(winner),
72            loser_id: Some(loser),
73            reason: reason.into(),
74            backend,
75            decided_at_iso: chrono_iso_now(),
76            cause: None,
77        }
78    }
79
80    /// `true` iff both `winner_id` and `loser_id` are set. The worker checks
81    /// this before invoking `apply_supersede` so deferred decisions never
82    /// trigger an MVCC write.
83    pub fn applies(&self) -> bool {
84        self.winner_id.is_some() && self.loser_id.is_some()
85    }
86}
87
88/// Deterministic arbitration for a cross-episode contradiction
89/// ([`lunaris_extract::NeedsReviewReason::CrossEpisodeContradiction`]).
90///
91/// The memory-update convergence policy is **latest-assertion-wins**: the
92/// newly-ingested fact (`new_fact_id`) is the winner and the prior in-scope
93/// fact (`existing_fact_id`) is the loser to be superseded (its bi-temporal
94/// interval closed by `apply_supersede`). Every verifier backend's
95/// `CrossEpisodeContradiction` arm routes through this single helper so the
96/// rule is one deterministic, testable place rather than N model-dependent
97/// judgments. (Full LLM-semantic arbitration of the fact *texts* is a flagged
98/// follow-up — the reason carries fact ids + object ids, not the fact texts.)
99#[must_use]
100pub fn cross_episode_decision(
101    existing_fact_id: Ulid,
102    new_fact_id: Ulid,
103    backend: VerifierBackend,
104) -> VerifyDecision {
105    VerifyDecision::arbitrate(
106        new_fact_id,
107        existing_fact_id,
108        "cross-episode contradiction: latest assertion supersedes prior",
109        backend,
110    )
111}
112
113/// Provider tag for audit records. Serialized with default enum tagging
114/// (externally tagged → `"Ollama"` / `"CloudAnthropic"` / etc.) so the audit
115/// schema is grep-friendly in ops tooling.
116///
117/// llama.cpp-only cutover (2026-07, Phase C): the `Candle` variant was
118/// removed with the in-process backends — historical audit records carrying
119/// `"Candle"` predate the cutover and are not deserialized by Lunaris.
120#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
121pub enum VerifierBackend {
122    Ollama,
123    CloudAnthropic,
124    CloudOpenAI,
125    CloudGemini,
126    CloudMiniMax,
127    /// Generic OpenAI-compatible URL server (llama.cpp-only cutover).
128    CloudOpenAiCompat,
129    Noop,
130}
131
132/// W-4 fix: use chrono workspace dep instead of a hand-rolled date
133/// decomposer. `chrono::Utc::now().to_rfc3339()` produces strings like
134/// `"2026-04-20T15:00:00.123456789+00:00"` which deserialize losslessly
135/// into any downstream consumer that expects RFC-3339.
136fn chrono_iso_now() -> String {
137    chrono::Utc::now().to_rfc3339()
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn verify_decision_round_trips_serde() {
146        let d = VerifyDecision::arbitrate(
147            Ulid::new(),
148            Ulid::new(),
149            "winner has higher confidence (0.9 > 0.6)",
150            VerifierBackend::CloudAnthropic,
151        );
152        let bytes = serde_json::to_vec(&d).expect("serialize");
153        let back: VerifyDecision = serde_json::from_slice(&bytes).expect("deserialize");
154        assert_eq!(d, back);
155        assert!(back.applies());
156    }
157
158    #[test]
159    fn deferred_decision_does_not_apply() {
160        let d = VerifyDecision::deferred();
161        assert!(!d.applies());
162        assert_eq!(d.backend, VerifierBackend::Noop);
163        assert_eq!(d.winner_id, None);
164        assert_eq!(d.loser_id, None);
165    }
166
167    #[test]
168    fn verifier_backend_serializes_to_default_tags() {
169        let json = serde_json::to_string(&VerifierBackend::Noop).unwrap();
170        assert_eq!(json, "\"Noop\"");
171        let json = serde_json::to_string(&VerifierBackend::CloudAnthropic).unwrap();
172        assert_eq!(json, "\"CloudAnthropic\"");
173    }
174
175    #[test]
176    fn iso_now_is_rfc3339() {
177        let s = chrono_iso_now();
178        // chrono::Utc::now().to_rfc3339() produces a string like
179        // "2026-04-20T15:00:00.000+00:00" — verify the basic shape.
180        assert!(s.len() >= 20, "rfc3339 len: {s}");
181        assert!(s.contains('T'), "rfc3339 contains T separator: {s}");
182    }
183}