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}