Skip to main content

mempill_core/application/
query_memory.rs

1#![allow(missing_docs)]
2//! QueryMemoryUseCase — application layer read path.
3//!
4//! Read-only: no Txn opened, no writes. Delegates to TruthEngine (fold)
5//! then Projection (project). `now` is injected by the EngineHandle boundary.
6
7use std::sync::Arc;
8
9use chrono::{DateTime, Utc};
10
11use crate::{
12    application::ingest_claim::build_latest_disposition_map,
13    config::EngineConfig,
14    engine::{projection, truth_engine},
15    error::MemError,
16    ports::{PersistencePort, VectorPort},
17};
18
19use super::dto::{QueryMemoryRequest, QueryMemoryResponse};
20
21/// Use-case: query the canonical belief for a (subject, predicate) line.
22/// Generic over persistence and vector ports.
23/// Vector is optional: None = structural-only mode (v0.1 default).
24pub struct QueryMemoryUseCase<P, V>
25where
26    P: PersistencePort + Send + Sync + 'static,
27    V: VectorPort + Send + Sync + 'static,
28{
29    persistence: Arc<P>,
30    #[allow(dead_code)]
31    vector: Option<Arc<V>>, // v0.1: unused; structural-only query
32    config: EngineConfig,
33}
34
35impl<P, V> QueryMemoryUseCase<P, V>
36where
37    P: PersistencePort + Send + Sync + 'static,
38    V: VectorPort + Send + Sync + 'static,
39{
40    pub fn new(persistence: Arc<P>, vector: Option<Arc<V>>, config: EngineConfig) -> Self {
41        Self { persistence, vector, config }
42    }
43
44    /// Read path: no Txn (read-only). TruthEngine fold → Projection → DTO.
45    ///
46    /// `now` is injected by the EngineHandle (DETERMINISM — no clock reads here).
47    pub fn execute_with_time(
48        &self,
49        req: QueryMemoryRequest,
50        now: DateTime<Utc>,
51    ) -> Result<QueryMemoryResponse, MemError> {
52        // Determine the bi-temporal as-of point: use the request's as_of_tx_time if supplied,
53        // otherwise use the injected `now`.
54        let as_of = req.as_of_tx_time.unwrap_or(now);
55
56        // Load claims for the subject-line scoped to the tx-time as-of point.
57        // Passing `Some(as_of)` enforces the claim-level tx-time cutoff: claims ingested
58        // after `as_of` are excluded at the DB layer. This is the correct bi-temporal
59        // behavior — a claim that did not exist at `as_of` must not be visible to the fold.
60        let claims = self.persistence
61            .load_subject_line(&req.agent_id, &req.subject, &req.predicate, Some(as_of))
62            .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
63
64        // Load ledger for the disposition-based liveness filter — scoped to exactly the
65        // claims on this subject-line (no agent-wide cap; always complete regardless of
66        // total agent ledger size — fixes the silent-wrong-belief-at-scale bug).
67        let claim_refs: Vec<_> = claims.iter().map(|c| c.claim_ref().clone()).collect();
68        // Pass the same as_of cutoff to the ledger load so that post-T supersession
69        // entries are excluded, preserving correct bi-temporal tx-time travel.
70        let all_ledger = self.persistence
71            .load_ledger_for_claims(&req.agent_id, &claim_refs, Some(as_of))
72            .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
73        let latest_disposition = build_latest_disposition_map(&all_ledger);
74
75        // C2: canonical valid-time fold (with disposition filter).
76        //
77        // D2 independence: `as_of` drives transaction-time visibility (which claims/assertions
78        // are visible). `req.valid_at` is the independent valid-time axis — it narrows the
79        // live set to the single claim whose valid-time window contains the instant, AFTER
80        // the tx-time filter. When `req.valid_at` is `None`, backward-compatible behaviour
81        // is preserved: the fold uses `as_of` for both axes.
82        let fold = truth_engine::fold(
83            claims.clone(),
84            |cref| {
85                self.persistence
86                    .load_validity_assertions_for(&req.agent_id, cref)
87                    .unwrap_or_default()
88            },
89            as_of,
90            req.valid_at, // D2: independent valid-time axis; None = backward-compatible
91            &self.config,
92            &latest_disposition,
93        );
94
95        // Build ledger entries per claim (for A26 PendingReview detection).
96        // Reuse the already-loaded all_ledger from above (no second load needed).
97        let ledger_entries: Vec<_> = claims.iter().flat_map(|c| {
98            all_ledger.iter()
99                .filter(|e| &e.claim_ref == c.claim_ref())
100                .cloned()
101        }).collect();
102
103        // Determine contested state from ledger (Contested disposition in live claims).
104        let contested = fold.live_claims.iter().any(|cs| {
105            cs.last_disposition
106                .as_ref()
107                .map(|d| *d == mempill_types::Disposition::Contested)
108                .unwrap_or(false)
109        });
110
111        // C5: projection.
112        let belief = projection::project(&fold, &ledger_entries, now, &self.config, contested);
113
114        Ok(QueryMemoryResponse { belief })
115    }
116
117    /// Convenience wrapper that stamps now internally (for direct calls outside EngineHandle).
118    pub fn execute(&self, req: QueryMemoryRequest) -> Result<QueryMemoryResponse, MemError> {
119        self.execute_with_time(req, Utc::now())
120    }
121}
122
123// ── Tests ──────────────────────────────────────────────────────────────────────
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::noop::NoOpVector;
129    use crate::ports::persistence::Txn;
130    use chrono::TimeZone;
131    use mempill_types::{
132        AgentId, BeliefStatus, Cardinality, Claim, ClaimEdge, ClaimRef, Confidence, Criticality,
133        ExternalAnchor, ExternalKind, Fact, LedgerEntry, ProvenanceLabel, TransactionTime,
134        ValidTime, ValidityAssertion,
135    };
136    use std::sync::Mutex;
137
138    struct MockTxn(AgentId);
139    impl Txn for MockTxn {
140        fn agent_id(&self) -> &AgentId { &self.0 }
141    }
142
143    #[derive(Debug, thiserror::Error)]
144    #[error("mock")]
145    struct MockErr;
146
147    #[derive(Default)]
148    struct MockStore {
149        claims: Mutex<Vec<Claim>>,
150    }
151
152    impl PersistencePort for MockStore {
153        type Transaction = MockTxn;
154        type Error = MockErr;
155        fn begin_atomic(&self, aid: &AgentId) -> Result<MockTxn, MockErr> { Ok(MockTxn(aid.clone())) }
156        fn append_claim(&self, _t: &mut MockTxn, c: &Claim) -> Result<ClaimRef, MockErr> {
157            self.claims.lock().unwrap().push(c.clone());
158            Ok(c.claim_ref().clone())
159        }
160        fn append_validity_assertion(&self, _t: &mut MockTxn, _a: &ValidityAssertion) -> Result<(), MockErr> { Ok(()) }
161        fn append_ledger_entry(&self, _t: &mut MockTxn, _e: &LedgerEntry) -> Result<(), MockErr> { Ok(()) }
162        fn append_claim_edge(&self, _t: &mut MockTxn, _e: &ClaimEdge) -> Result<(), MockErr> { Ok(()) }
163        fn commit(&self, _t: MockTxn) -> Result<(), MockErr> { Ok(()) }
164        fn rollback(&self, _t: MockTxn) -> Result<(), MockErr> { Ok(()) }
165        fn load_subject_line(&self, _aid: &AgentId, subject: &str, predicate: &str, _as_of_tx_time: Option<chrono::DateTime<chrono::Utc>>) -> Result<Vec<Claim>, MockErr> {
166            let claims = self.claims.lock().unwrap();
167            Ok(claims.iter()
168                .filter(|c| c.fact().subject == subject && c.fact().predicate == predicate)
169                .cloned()
170                .collect())
171        }
172        fn load_claim(&self, _aid: &AgentId, _r: &ClaimRef) -> Result<Option<Claim>, MockErr> { Ok(None) }
173        fn load_validity_assertions_for(&self, _aid: &AgentId, _r: &ClaimRef) -> Result<Vec<ValidityAssertion>, MockErr> { Ok(vec![]) }
174        fn load_ledger(&self, _aid: &AgentId, _from: Option<&mempill_types::TransactionTime>, _lim: usize) -> Result<Vec<LedgerEntry>, MockErr> { Ok(vec![]) }
175        fn load_ledger_for_claims(&self, _aid: &AgentId, _refs: &[ClaimRef], _as_of: Option<chrono::DateTime<chrono::Utc>>) -> Result<Vec<LedgerEntry>, MockErr> { Ok(vec![]) }
176        fn load_edges_for(&self, _aid: &AgentId, _r: &ClaimRef) -> Result<Vec<ClaimEdge>, MockErr> { Ok(vec![]) }
177        fn load_injected_claims(&self, _aid: &AgentId) -> Result<Vec<ClaimRef>, MockErr> { Ok(vec![]) }
178        fn load_lineage(&self, _aid: &AgentId, _r: &ClaimRef) -> Result<Vec<ClaimEdge>, MockErr> { Ok(vec![]) }
179        fn list_predicates_for_subject(&self, _aid: &AgentId, subject: &str, _as_of: Option<chrono::DateTime<chrono::Utc>>) -> Result<Vec<String>, MockErr> {
180            let claims = self.claims.lock().unwrap();
181            let mut preds: Vec<String> = claims.iter()
182                .filter(|c| c.fact().subject == subject)
183                .map(|c| c.fact().predicate.clone())
184                .collect::<std::collections::HashSet<_>>()
185                .into_iter()
186                .collect();
187            preds.sort();
188            Ok(preds)
189        }
190    }
191
192    fn make_claim(subject: &str, predicate: &str, value: serde_json::Value, tx: DateTime<Utc>) -> Claim {
193        Claim::new(
194            ClaimRef::new_random(),
195            AgentId("agent".into()),
196            Fact { subject: subject.into(), predicate: predicate.into(), value },
197            Cardinality::Functional,
198            ProvenanceLabel::External(ExternalKind::UserAsserted),
199            ExternalAnchor { nearest_external_anchor: None, derivation_depth: 0 },
200            TransactionTime(tx),
201            ValidTime { start: None, end: None, valid_time_confidence: 0.0 , start_granularity: None, end_granularity: None},
202            Confidence { value_confidence: 0.9, valid_time_confidence: 0.0 },
203            Criticality::Medium,
204            vec![],
205            None,
206            None,
207        )
208    }
209
210    #[test]
211    fn query_no_claims_returns_no_belief() {
212        let store = Arc::new(MockStore::default());
213        let uc = QueryMemoryUseCase::new(
214            Arc::clone(&store),
215            None::<Arc<NoOpVector>>,
216            EngineConfig::default(),
217        );
218        let now = Utc::now();
219        let req = QueryMemoryRequest {
220            agent_id: AgentId("agent".into()),
221            subject: "user".into(),
222            predicate: "city".into(),
223            as_of_tx_time: None,
224        valid_at: None,
225        };
226        let resp = uc.execute_with_time(req, now).unwrap();
227        assert_eq!(resp.belief.status, BeliefStatus::NoBelief);
228    }
229
230    #[test]
231    fn query_with_one_claim_returns_resolved() {
232        let store = Arc::new(MockStore::default());
233        let tx = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
234        let claim = make_claim("user", "city", serde_json::json!("Paris"), tx);
235        store.claims.lock().unwrap().push(claim);
236
237        let uc = QueryMemoryUseCase::new(
238            Arc::clone(&store),
239            None::<Arc<NoOpVector>>,
240            EngineConfig::default(),
241        );
242        let now = Utc.with_ymd_and_hms(2026, 1, 2, 0, 0, 0).unwrap();
243        let req = QueryMemoryRequest {
244            agent_id: AgentId("agent".into()),
245            subject: "user".into(),
246            predicate: "city".into(),
247            as_of_tx_time: None,
248        valid_at: None,
249        };
250        let resp = uc.execute_with_time(req, now).unwrap();
251        // Single live claim with unknown valid_time → TimingUncertain (valid_time is None).
252        assert!(
253            matches!(resp.belief.status, BeliefStatus::TimingUncertain | BeliefStatus::Resolved),
254            "expected Resolved or TimingUncertain, got {:?}",
255            resp.belief.status
256        );
257        assert!(resp.belief.primary.is_some(), "primary belief must be present");
258    }
259
260    // ── Regression: valid_at point-in-time selection works end-to-end via public ingest API ──
261    //
262    // Bi-temporal property guarded: querying valid_at = T returns the claim whose valid-time
263    // window contains T, even when multiple non-overlapping claims exist on the same subject-line.
264    //
265    // WHY this matters: the succession fold (truth_engine Step 4) selects the single claim
266    // whose half-open [start, end) window covers the query instant. This test proves the full
267    // chain — public IngestClaimUseCase → QueryMemoryUseCase — correctly surfaces alice for
268    // valid_at=2021 and bob for valid_at=2023, without ingest-time supersession collapsing
269    // alice's claim before the query even runs.
270    //
271    // Scenario: alice=CEO [2020,2022), bob=CEO [2022,2024) — non-overlapping, Succession route.
272    // alice stays CommittedCheap (ingest-time supersession was removed per ingest_claim.rs L362).
273    #[test]
274    fn valid_at_succession_two_windows_selects_correct_ceo_via_public_api() {
275        use crate::application::ingest_claim::IngestClaimUseCase;
276        use crate::noop::NoOpOracle;
277        use chrono::TimeZone;
278        use mempill_types::BeliefStatus;
279
280        // A shared mock store that tracks claims AND ledger entries for both use-cases.
281        // This is required because QueryMemoryUseCase.load_ledger_for_claims must return
282        // the real ledger entries committed by IngestClaimUseCase.
283        #[derive(Default)]
284        struct FullMockStore {
285            claims: Mutex<Vec<Claim>>,
286            ledger: Mutex<Vec<LedgerEntry>>,
287            assertions: Mutex<Vec<ValidityAssertion>>,
288        }
289
290        impl PersistencePort for FullMockStore {
291            type Transaction = MockTxn;
292            type Error = MockErr;
293
294            fn begin_atomic(&self, aid: &AgentId) -> Result<MockTxn, MockErr> {
295                Ok(MockTxn(aid.clone()))
296            }
297            fn append_claim(&self, _t: &mut MockTxn, c: &Claim) -> Result<ClaimRef, MockErr> {
298                self.claims.lock().unwrap().push(c.clone());
299                Ok(c.claim_ref().clone())
300            }
301            fn append_validity_assertion(&self, _t: &mut MockTxn, a: &ValidityAssertion) -> Result<(), MockErr> {
302                self.assertions.lock().unwrap().push(a.clone());
303                Ok(())
304            }
305            fn append_ledger_entry(&self, _t: &mut MockTxn, e: &LedgerEntry) -> Result<(), MockErr> {
306                self.ledger.lock().unwrap().push(e.clone());
307                Ok(())
308            }
309            fn append_claim_edge(&self, _t: &mut MockTxn, _e: &ClaimEdge) -> Result<(), MockErr> { Ok(()) }
310            fn commit(&self, _t: MockTxn) -> Result<(), MockErr> { Ok(()) }
311            fn rollback(&self, _t: MockTxn) -> Result<(), MockErr> { Ok(()) }
312            fn load_subject_line(&self, _aid: &AgentId, subject: &str, predicate: &str, _as_of_tx_time: Option<chrono::DateTime<chrono::Utc>>) -> Result<Vec<Claim>, MockErr> {
313                let claims = self.claims.lock().unwrap();
314                Ok(claims.iter()
315                    .filter(|c| c.fact().subject == subject && c.fact().predicate == predicate)
316                    .cloned()
317                    .collect())
318            }
319            fn load_claim(&self, _aid: &AgentId, r: &ClaimRef) -> Result<Option<Claim>, MockErr> {
320                let claims = self.claims.lock().unwrap();
321                Ok(claims.iter().find(|c| c.claim_ref() == r).cloned())
322            }
323            fn load_validity_assertions_for(&self, _aid: &AgentId, r: &ClaimRef) -> Result<Vec<ValidityAssertion>, MockErr> {
324                let assertions = self.assertions.lock().unwrap();
325                Ok(assertions.iter().filter(|a| &a.target_claim == r).cloned().collect())
326            }
327            fn load_ledger(&self, _aid: &AgentId, _from: Option<&mempill_types::TransactionTime>, _lim: usize) -> Result<Vec<LedgerEntry>, MockErr> {
328                Ok(self.ledger.lock().unwrap().clone())
329            }
330            fn load_ledger_for_claims(&self, _aid: &AgentId, refs: &[ClaimRef], _as_of: Option<chrono::DateTime<chrono::Utc>>) -> Result<Vec<LedgerEntry>, MockErr> {
331                let ledger = self.ledger.lock().unwrap();
332                Ok(ledger.iter().filter(|e| refs.contains(&e.claim_ref)).cloned().collect())
333            }
334            fn load_edges_for(&self, _aid: &AgentId, _r: &ClaimRef) -> Result<Vec<ClaimEdge>, MockErr> { Ok(vec![]) }
335            fn load_injected_claims(&self, _aid: &AgentId) -> Result<Vec<ClaimRef>, MockErr> { Ok(vec![]) }
336            fn load_lineage(&self, _aid: &AgentId, _r: &ClaimRef) -> Result<Vec<ClaimEdge>, MockErr> { Ok(vec![]) }
337            fn list_predicates_for_subject(&self, _aid: &AgentId, subject: &str, _as_of: Option<chrono::DateTime<chrono::Utc>>) -> Result<Vec<String>, MockErr> {
338                let claims = self.claims.lock().unwrap();
339                let mut preds: Vec<String> = claims.iter()
340                    .filter(|c| c.fact().subject == subject)
341                    .map(|c| c.fact().predicate.clone())
342                    .collect::<std::collections::HashSet<_>>()
343                    .into_iter()
344                    .collect();
345                preds.sort();
346                Ok(preds)
347            }
348        }
349
350        let store = Arc::new(FullMockStore::default());
351
352        // tx_time for both ingests must be >= valid_time_start to pass B7 gate check.
353        // Use a tx_time well after both valid windows end (2025-01-01 > 2024-01-01).
354        let tx_now = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
355
356        let agent = AgentId("diag-agent".into());
357
358        let ingest_uc = IngestClaimUseCase::new(
359            Arc::clone(&store),
360            None::<Arc<NoOpOracle>>,
361            None,
362            EngineConfig::default(),
363        );
364
365        // Ingest alice: CEO [2020-01-01, 2022-01-01), confident.
366        let alice_req = crate::application::dto::IngestClaimRequest {
367            agent_id: agent.clone(),
368            subject: "acme".into(),
369            predicate: "ceo".into(),
370            value: serde_json::json!("alice"),
371            provenance: mempill_types::ProvenanceLabel::External(mempill_types::ExternalKind::UserAsserted),
372            cardinality: Cardinality::Functional,
373            valid_time: Some(ValidTime {
374                start: Some(Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()),
375                end: Some(Utc.with_ymd_and_hms(2022, 1, 1, 0, 0, 0).unwrap()),
376                valid_time_confidence: 0.9,
377                start_granularity: None, end_granularity: None,
378            }),
379            confidence: Confidence { value_confidence: 0.9, valid_time_confidence: 0.9 },
380            criticality: Criticality::Medium,
381            derived_from: vec![],
382        };
383        let alice_resp = ingest_uc.execute_with_time(alice_req, tx_now).unwrap();
384
385        // ASSERT: alice must be CommittedCheap (first write, no incumbent).
386        assert_eq!(
387            alice_resp.disposition,
388            mempill_types::Disposition::CommittedCheap,
389            "alice (first write) must be CommittedCheap"
390        );
391
392        // Ingest bob: CEO [2022-01-01, 2024-01-01), confident. Non-overlapping → Succession.
393        let bob_req = crate::application::dto::IngestClaimRequest {
394            agent_id: agent.clone(),
395            subject: "acme".into(),
396            predicate: "ceo".into(),
397            value: serde_json::json!("bob"),
398            provenance: mempill_types::ProvenanceLabel::External(mempill_types::ExternalKind::UserAsserted),
399            cardinality: Cardinality::Functional,
400            valid_time: Some(ValidTime {
401                start: Some(Utc.with_ymd_and_hms(2022, 1, 1, 0, 0, 0).unwrap()),
402                end: Some(Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap()),
403                valid_time_confidence: 0.9,
404                start_granularity: None, end_granularity: None,
405            }),
406            confidence: Confidence { value_confidence: 0.9, valid_time_confidence: 0.9 },
407            criticality: Criticality::Medium,
408            derived_from: vec![],
409        };
410        let bob_resp = ingest_uc.execute_with_time(bob_req, tx_now).unwrap();
411
412        // ASSERT: bob must be CommittedCheap via the Succession route (NOT Contested).
413        // If bob is Contested here, the reconciler did NOT detect the succession.
414        assert_eq!(
415            bob_resp.disposition,
416            mempill_types::Disposition::CommittedCheap,
417            "bob (successor, non-overlapping windows) must route to CommittedCheap via Succession gate (not Contested)"
418        );
419
420        // Verify alice's disposition in the ledger: it should still be CommittedCheap.
421        // Ingest-time supersession was removed — alice must NOT be Superseded.
422        {
423            let ledger = store.ledger.lock().unwrap();
424            let alice_dispositions: Vec<_> = ledger.iter()
425                .filter(|e| e.claim_ref == alice_resp.claim_ref)
426                .map(|e| e.disposition.clone())
427                .collect();
428            // alice should have exactly one ledger entry: ClaimCommitted/CommittedCheap.
429            // If it has a second entry with Superseded, ingest-time supersession crept back in.
430            let latest_alice = alice_dispositions.last().cloned();
431            assert_eq!(
432                latest_alice,
433                Some(mempill_types::Disposition::CommittedCheap),
434                "alice's latest ledger disposition must be CommittedCheap (not Superseded) — \
435                 ingest-time supersession was removed; predecessor stays live until oracle affirms"
436            );
437        }
438
439        // Now query with valid_at = 2021-06-01 (in alice's window [2020, 2022)).
440        // Expected: alice is returned (Resolved, primary.value = "alice").
441        let query_uc = QueryMemoryUseCase::new(
442            Arc::clone(&store),
443            None::<Arc<NoOpVector>>,
444            EngineConfig::default(),
445        );
446
447        let query_now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
448
449        let q_alice = QueryMemoryRequest {
450            agent_id: agent.clone(),
451            subject: "acme".into(),
452            predicate: "ceo".into(),
453            as_of_tx_time: None,
454            valid_at: Some(Utc.with_ymd_and_hms(2021, 6, 1, 0, 0, 0).unwrap()),
455        };
456        let resp_alice = query_uc.execute_with_time(q_alice, query_now).unwrap();
457
458        // valid_at=2021-06-01 is in alice's window [2020, 2022) → must return alice.
459        let alice_primary = resp_alice.belief.primary.as_ref();
460        assert_eq!(
461            resp_alice.belief.status,
462            BeliefStatus::Resolved,
463            "valid_at=2021-06-01 (alice's window [2020,2022)) must be Resolved, got {:?}; primary={:?}",
464            resp_alice.belief.status,
465            alice_primary.map(|b| &b.fact.value)
466        );
467        assert_eq!(
468            alice_primary.map(|b| b.fact.value.clone()),
469            Some(serde_json::json!("alice")),
470            "valid_at=2021-06-01 must return alice, got {:?}",
471            alice_primary.map(|b| &b.fact.value)
472        );
473
474        // valid_at=2023-06-01 is in bob's window [2022, 2024) → must return bob.
475        let q_bob = QueryMemoryRequest {
476            agent_id: agent.clone(),
477            subject: "acme".into(),
478            predicate: "ceo".into(),
479            as_of_tx_time: None,
480            valid_at: Some(Utc.with_ymd_and_hms(2023, 6, 1, 0, 0, 0).unwrap()),
481        };
482        let resp_bob = query_uc.execute_with_time(q_bob, query_now).unwrap();
483
484        let bob_primary = resp_bob.belief.primary.as_ref();
485        assert_eq!(
486            resp_bob.belief.status,
487            BeliefStatus::Resolved,
488            "valid_at=2023-06-01 (bob's window [2022,2024)) must be Resolved, got {:?}; primary={:?}",
489            resp_bob.belief.status,
490            bob_primary.map(|b| &b.fact.value)
491        );
492        assert_eq!(
493            bob_primary.map(|b| b.fact.value.clone()),
494            Some(serde_json::json!("bob")),
495            "valid_at=2023-06-01 must return bob, got {:?}",
496            bob_primary.map(|b| &b.fact.value)
497        );
498    }
499
500    // ── Regression: oracle adjudication → Affirm → correct belief surfaced via public API ──
501    //
502    // Bi-temporal property guarded: after a conflict is routed to the oracle and resolved
503    // with an Affirm verdict, the winner (bob) is surfaced as the canonical belief and
504    // the loser (alice) is Superseded. This tests the full oracle composition path:
505    // IngestClaimUseCase (oracle present) → QueuedForAdjudication pending row →
506    // SubmitAdjudicationUseCase(Affirm) → QueryMemoryUseCase.
507    //
508    // NOTE — bi-temporal tx-time rewind limitation (not tested here):
509    // Querying with as_of_tx_time set to a point BEFORE the Affirm was issued would
510    // ideally return alice (the tx-time axis rewinds past the supersession). However,
511    // QueryMemoryUseCase.load_ledger_for_claims does NOT accept an as_of_tx_time
512    // parameter and returns ALL ledger entries regardless of recorded_at. As a result,
513    // build_latest_disposition_map sees alice's Superseded entry (written at affirm_time)
514    // even when querying before that time. The tx-time axis IS correctly applied to
515    // ValidityAssertion::Bound (truth_engine.rs ~L123), but the disposition-based
516    // liveness filter is not tx-time filtered. This means alice is incorrectly excluded
517    // from the live set even at pre-affirm as_of_tx_time. See BLOCKER in task output.
518    #[test]
519    fn oracle_affirm_surfaces_winner_and_excludes_loser_via_public_api() {
520        use crate::application::ingest_claim::IngestClaimUseCase;
521        use crate::application::submit_adjudication::SubmitAdjudicationUseCase;
522        use crate::engine_handle::{ErasedPendingStore, ErasedPendingStoreAdapter};
523        use crate::ports::{PendingAdjudicationPort, PendingAdjudicationRow};
524        use chrono::TimeZone;
525        use mempill_types::{BeliefStatus, Disposition};
526
527        // ── Oracle that returns a deterministic handle UUID ───────────────────
528        struct TestOracle {
529            fixed_uuid: uuid::Uuid,
530        }
531        impl crate::ports::OraclePort for TestOracle {
532            type Error = crate::noop::NoOpError;
533            type Handle = uuid::Uuid;
534            fn request_adjudication(
535                &self,
536                _aid: &AgentId,
537                _req: mempill_types::AdjudicationRequest,
538            ) -> Result<uuid::Uuid, crate::noop::NoOpError> {
539                Ok(self.fixed_uuid)
540            }
541            fn handle_to_uuid(h: &uuid::Uuid) -> uuid::Uuid { *h }
542        }
543
544        // ── Mock pending-adjudication store ───────────────────────────────────
545        #[derive(Default)]
546        struct MockPending {
547            rows: Mutex<Vec<PendingAdjudicationRow>>,
548        }
549        impl PendingAdjudicationPort for MockPending {
550            type Error = MockErr;
551            fn insert_pending(&self, r: &PendingAdjudicationRow) -> Result<(), MockErr> {
552                self.rows.lock().unwrap().push(r.clone()); Ok(())
553            }
554            fn get_pending(&self, id: uuid::Uuid) -> Result<Option<PendingAdjudicationRow>, MockErr> {
555                Ok(self.rows.lock().unwrap().iter().find(|r| r.handle_id == id).cloned())
556            }
557            fn list_pending(&self, _: Option<&AgentId>) -> Result<Vec<PendingAdjudicationRow>, MockErr> {
558                Ok(self.rows.lock().unwrap().clone())
559            }
560            fn list_expired(&self, _: chrono::DateTime<Utc>) -> Result<Vec<PendingAdjudicationRow>, MockErr> {
561                Ok(vec![])
562            }
563            fn mark_resolved(&self, id: uuid::Uuid) -> Result<(), MockErr> {
564                for r in self.rows.lock().unwrap().iter_mut() {
565                    if r.handle_id == id { r.status = "resolved".to_string(); }
566                }
567                Ok(())
568            }
569            fn mark_expired(&self, _: uuid::Uuid) -> Result<(), MockErr> { Ok(()) }
570            fn list_queued_orphan_claims(&self) -> Result<Vec<crate::ports::pending_adjudication::OrphanedQueuedClaim>, MockErr> {
571                Ok(vec![])
572            }
573        }
574
575        // ── Shared mock persistence that tracks all written state ─────────────
576        // FullMockStore2 tracks claims, ledger, and assertions for all three use-cases.
577        #[derive(Default)]
578        struct FullMockStore2 {
579            claims: Mutex<Vec<Claim>>,
580            ledger: Mutex<Vec<LedgerEntry>>,
581            assertions: Mutex<Vec<ValidityAssertion>>,
582        }
583        impl PersistencePort for FullMockStore2 {
584            type Transaction = MockTxn;
585            type Error = MockErr;
586            fn begin_atomic(&self, aid: &AgentId) -> Result<MockTxn, MockErr> { Ok(MockTxn(aid.clone())) }
587            fn append_claim(&self, _t: &mut MockTxn, c: &Claim) -> Result<ClaimRef, MockErr> {
588                self.claims.lock().unwrap().push(c.clone());
589                Ok(c.claim_ref().clone())
590            }
591            fn append_validity_assertion(&self, _t: &mut MockTxn, a: &ValidityAssertion) -> Result<(), MockErr> {
592                self.assertions.lock().unwrap().push(a.clone()); Ok(())
593            }
594            fn append_ledger_entry(&self, _t: &mut MockTxn, e: &LedgerEntry) -> Result<(), MockErr> {
595                self.ledger.lock().unwrap().push(e.clone()); Ok(())
596            }
597            fn append_claim_edge(&self, _t: &mut MockTxn, _e: &ClaimEdge) -> Result<(), MockErr> { Ok(()) }
598            fn commit(&self, _t: MockTxn) -> Result<(), MockErr> { Ok(()) }
599            fn rollback(&self, _t: MockTxn) -> Result<(), MockErr> { Ok(()) }
600            fn load_subject_line(&self, _aid: &AgentId, subject: &str, predicate: &str, _as_of_tx_time: Option<chrono::DateTime<chrono::Utc>>) -> Result<Vec<Claim>, MockErr> {
601                Ok(self.claims.lock().unwrap().iter()
602                    .filter(|c| c.fact().subject == subject && c.fact().predicate == predicate)
603                    .cloned().collect())
604            }
605            fn load_claim(&self, _aid: &AgentId, r: &ClaimRef) -> Result<Option<Claim>, MockErr> {
606                Ok(self.claims.lock().unwrap().iter().find(|c| c.claim_ref() == r).cloned())
607            }
608            fn load_validity_assertions_for(&self, _aid: &AgentId, r: &ClaimRef) -> Result<Vec<ValidityAssertion>, MockErr> {
609                Ok(self.assertions.lock().unwrap().iter().filter(|a| &a.target_claim == r).cloned().collect())
610            }
611            fn load_ledger(&self, _aid: &AgentId, _from: Option<&mempill_types::TransactionTime>, _lim: usize) -> Result<Vec<LedgerEntry>, MockErr> {
612                Ok(self.ledger.lock().unwrap().clone())
613            }
614            fn load_ledger_for_claims(&self, _aid: &AgentId, refs: &[ClaimRef], as_of: Option<chrono::DateTime<chrono::Utc>>) -> Result<Vec<LedgerEntry>, MockErr> {
615                Ok(self.ledger.lock().unwrap().iter()
616                    .filter(|e| refs.contains(&e.claim_ref))
617                    .filter(|e| as_of.is_none_or(|t| e.recorded_at.0 <= t))
618                    .cloned()
619                    .collect())
620            }
621            fn load_edges_for(&self, _aid: &AgentId, _r: &ClaimRef) -> Result<Vec<ClaimEdge>, MockErr> { Ok(vec![]) }
622            fn load_injected_claims(&self, _aid: &AgentId) -> Result<Vec<ClaimRef>, MockErr> { Ok(vec![]) }
623            fn load_lineage(&self, _aid: &AgentId, _r: &ClaimRef) -> Result<Vec<ClaimEdge>, MockErr> { Ok(vec![]) }
624            fn list_predicates_for_subject(&self, _aid: &AgentId, subject: &str, as_of: Option<chrono::DateTime<chrono::Utc>>) -> Result<Vec<String>, MockErr> {
625                let claims = self.claims.lock().unwrap();
626                let mut preds: Vec<String> = claims.iter()
627                    .filter(|c| c.fact().subject == subject)
628                    .filter(|c| as_of.is_none_or(|t| c.transaction_time().0 <= t))
629                    .map(|c| c.fact().predicate.clone())
630                    .collect::<std::collections::HashSet<_>>()
631                    .into_iter()
632                    .collect();
633                preds.sort();
634                Ok(preds)
635            }
636        }
637
638        let store = Arc::new(FullMockStore2::default());
639        let pending = Arc::new(MockPending::default());
640        let handle_uuid = uuid::Uuid::new_v4();
641        let oracle = Arc::new(TestOracle { fixed_uuid: handle_uuid });
642
643        // Erase pending store type for IngestClaimUseCase + SubmitAdjudicationUseCase.
644        let erased_pending: Arc<dyn ErasedPendingStore> = {
645            struct Delegate(Arc<MockPending>);
646            impl PendingAdjudicationPort for Delegate {
647                type Error = MockErr;
648                fn insert_pending(&self, r: &PendingAdjudicationRow) -> Result<(), MockErr> { self.0.insert_pending(r) }
649                fn get_pending(&self, id: uuid::Uuid) -> Result<Option<PendingAdjudicationRow>, MockErr> { self.0.get_pending(id) }
650                fn list_pending(&self, a: Option<&AgentId>) -> Result<Vec<PendingAdjudicationRow>, MockErr> { self.0.list_pending(a) }
651                fn list_expired(&self, n: chrono::DateTime<Utc>) -> Result<Vec<PendingAdjudicationRow>, MockErr> { self.0.list_expired(n) }
652                fn mark_resolved(&self, id: uuid::Uuid) -> Result<(), MockErr> { self.0.mark_resolved(id) }
653                fn mark_expired(&self, id: uuid::Uuid) -> Result<(), MockErr> { self.0.mark_expired(id) }
654                fn list_queued_orphan_claims(&self) -> Result<Vec<crate::ports::pending_adjudication::OrphanedQueuedClaim>, MockErr> { self.0.list_queued_orphan_claims() }
655            }
656            Arc::new(ErasedPendingStoreAdapter::new(Delegate(Arc::clone(&pending))))
657        };
658
659        let agent = AgentId("oracle-vt-agent".into());
660        let ingest_now = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
661
662        let ingest_uc = IngestClaimUseCase::new(
663            Arc::clone(&store),
664            Some(Arc::clone(&oracle)),
665            Some(Arc::clone(&erased_pending)),
666            EngineConfig::default(),
667        );
668
669        // ── Step 1: Ingest alice — first claim, no conflict, CommittedCheap ──
670        let alice_req = crate::application::dto::IngestClaimRequest {
671            agent_id: agent.clone(),
672            subject: "acme".into(),
673            predicate: "ceo".into(),
674            value: serde_json::json!("alice"),
675            provenance: mempill_types::ProvenanceLabel::External(mempill_types::ExternalKind::UserAsserted),
676            cardinality: Cardinality::Functional,
677            valid_time: None, // no valid_time → conflict on second write
678            confidence: Confidence { value_confidence: 0.9, valid_time_confidence: 0.0 },
679            criticality: Criticality::Medium,
680            derived_from: vec![],
681        };
682        let alice_resp = ingest_uc.execute_with_time(alice_req, ingest_now).unwrap();
683        assert_eq!(alice_resp.disposition, Disposition::CommittedCheap,
684            "alice (first write, no conflict) must be CommittedCheap");
685        let alice_ref = alice_resp.claim_ref.clone();
686
687        // ── Step 2: Ingest bob — conflicts with alice, oracle routes to QueuedForAdjudication ──
688        let bob_req = crate::application::dto::IngestClaimRequest {
689            agent_id: agent.clone(),
690            subject: "acme".into(),
691            predicate: "ceo".into(),
692            value: serde_json::json!("bob"),
693            provenance: mempill_types::ProvenanceLabel::External(mempill_types::ExternalKind::UserAsserted),
694            cardinality: Cardinality::Functional,
695            valid_time: None,
696            confidence: Confidence { value_confidence: 0.9, valid_time_confidence: 0.0 },
697            criticality: Criticality::Medium,
698            derived_from: vec![],
699        };
700        let bob_resp = ingest_uc.execute_with_time(bob_req, ingest_now).unwrap();
701        assert_eq!(bob_resp.disposition, Disposition::QueuedForAdjudication,
702            "bob (conflict, oracle present) must be QueuedForAdjudication");
703        let bob_ref = bob_resp.claim_ref.clone();
704
705        // ── Step 3: Submit Affirm → alice Superseded, bob CommittedCheap ─────
706        // The affirm_time is distinct from ingest_now so tx-time axis is separable.
707        let affirm_now = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
708        let submit_uc = SubmitAdjudicationUseCase::new(
709            Arc::clone(&store),
710            Arc::clone(&erased_pending),
711        );
712        let adj_response = mempill_types::AdjudicationResponse {
713            handle_id: handle_uuid,
714            verdict: mempill_types::AdjudicationVerdict::Affirm,
715            evidence_provenance: mempill_types::ProvenanceLabel::External(
716                mempill_types::ExternalKind::ExternalFirstHand,
717            ),
718        };
719        let outcome = submit_uc.execute(handle_uuid, adj_response, affirm_now).unwrap();
720        assert_eq!(outcome.disposition, Disposition::CommittedCheap,
721            "Affirm outcome must be CommittedCheap for the winner (bob)");
722        assert_eq!(outcome.claim_ref, bob_ref,
723            "Affirm outcome claim_ref must be bob (the challenger/winner)");
724
725        // Verify alice's ledger shows Superseded after Affirm.
726        {
727            let ledger = store.ledger.lock().unwrap();
728            let alice_latest = ledger.iter()
729                .filter(|e| e.claim_ref == alice_ref)
730                .max_by_key(|e| e.recorded_at.0)
731                .map(|e| e.disposition.clone());
732            assert_eq!(alice_latest, Some(Disposition::Superseded),
733                "alice's latest ledger entry must be Superseded after Affirm");
734        }
735
736        // ── Step 4: Query as_of=None (current view) → bob is the canonical belief ──
737        //
738        // With alice Superseded and bob CommittedCheap, bob is the single live claim.
739        // The correct bi-temporal answer for as_of=now is bob (Resolved).
740        let query_uc = QueryMemoryUseCase::new(
741            Arc::clone(&store),
742            None::<Arc<crate::noop::NoOpVector>>,
743            EngineConfig::default(),
744        );
745        let query_now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
746        let q_now = crate::application::dto::QueryMemoryRequest {
747            agent_id: agent.clone(),
748            subject: "acme".into(),
749            predicate: "ceo".into(),
750            as_of_tx_time: None,
751            valid_at: None,
752        };
753        let resp_now = query_uc.execute_with_time(q_now, query_now).unwrap();
754        let now_primary = resp_now.belief.primary.as_ref()
755            .map(|b| b.fact.value.clone());
756        // Bob has no valid_time → TimingUncertain (single live claim whose valid_time.is_unknown()).
757        // The claim IS surfaced as the primary belief — TimingUncertain means "we have a belief
758        // but don't know the exact valid-time window", which is correct for a no-valid_time claim.
759        assert!(
760            matches!(resp_now.belief.status, BeliefStatus::TimingUncertain | BeliefStatus::Resolved),
761            "as_of=now after Affirm must surface bob (TimingUncertain or Resolved); got {:?}",
762            resp_now.belief.status
763        );
764        assert_eq!(
765            now_primary,
766            Some(serde_json::json!("bob")),
767            "as_of=now after Affirm must return bob (CommittedCheap winner); got {now_primary:?}"
768        );
769
770        // ── Step 5: Query as_of=before_affirm — bi-temporal tx-time rewind ───
771        //
772        // Correct bi-temporal behavior (D2 independence rule):
773        //   At as_of_tx_time before affirm_now, alice's Superseded ledger entry (recorded_at=affirm_now)
774        //   and any Bound ValidityAssertion from the Affirm are INVISIBLE (recorded_at > as_of).
775        //   Alice is live (CommittedCheap as of that tx-time) and bob is QueuedForAdjudication.
776        //   The fold sees two non-superseded claims → Contested.
777        //
778        // Implementation: load_ledger_for_claims now accepts as_of_tx_time and filters
779        //   recorded_at <= T, so the disposition map correctly excludes the post-affirm
780        //   Superseded entry for alice.
781        let before_affirm = Utc.with_ymd_and_hms(2025, 3, 1, 0, 0, 0).unwrap(); // between ingest_now and affirm_now
782        let q_before_affirm = crate::application::dto::QueryMemoryRequest {
783            agent_id: agent.clone(),
784            subject: "acme".into(),
785            predicate: "ceo".into(),
786            as_of_tx_time: Some(before_affirm),
787            valid_at: None,
788        };
789        let resp_before = query_uc.execute_with_time(q_before_affirm, query_now).unwrap();
790        // At as_of=before_affirm both alice (CommittedCheap) and bob (QueuedForAdjudication) are
791        // visible and neither is superseded → Contested is the correct bi-temporal answer.
792        assert_eq!(
793            resp_before.belief.status,
794            BeliefStatus::Contested,
795            "as_of=before_affirm: bi-temporal tx-time rewind must return Contested \
796             (both alice and bob live before affirm). Got {:?}",
797            resp_before.belief.status
798        );
799    }
800
801    #[test]
802    fn query_now_injected_not_read_from_clock() {
803        // Verify that the injected `now` flows into projection (currency decay) rather than
804        // the system clock. Two queries with different injected 'now' values on the same
805        // claim yield different CurrencyState in the result.
806        let store = Arc::new(MockStore::default());
807        // A claim from 200 days ago.
808        let old_tx = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
809        let claim = make_claim("user", "job", serde_json::json!("Engineer"), old_tx);
810        store.claims.lock().unwrap().push(claim);
811
812        let uc = QueryMemoryUseCase::new(
813            Arc::clone(&store),
814            None::<Arc<NoOpVector>>,
815            EngineConfig::default(),
816        );
817
818        // Query with 'now' very close to the claim's tx_time → should be Fresh.
819        let near_now = Utc.with_ymd_and_hms(2020, 1, 2, 0, 0, 0).unwrap(); // 1 day later
820        let req = QueryMemoryRequest {
821            agent_id: AgentId("agent".into()),
822            subject: "user".into(),
823            predicate: "job".into(),
824            as_of_tx_time: None,
825        valid_at: None,
826        };
827        let resp_near = uc.execute_with_time(req.clone(), near_now).unwrap();
828        assert!(resp_near.belief.primary.is_some());
829        assert_eq!(
830            resp_near.belief.primary.unwrap().currency_signal.state,
831            mempill_types::CurrencyState::Fresh,
832            "1 day after claim, currency must be Fresh (injected now, not system clock)"
833        );
834
835        // Query with 'now' 200 days after claim → should be Decayed (decayed_threshold_days=90).
836        let far_now = Utc.with_ymd_and_hms(2020, 7, 20, 0, 0, 0).unwrap(); // ~200 days later
837        let resp_far = uc.execute_with_time(req, far_now).unwrap();
838        assert!(resp_far.belief.primary.is_some());
839        assert_eq!(
840            resp_far.belief.primary.unwrap().currency_signal.state,
841            mempill_types::CurrencyState::Decayed,
842            "200 days after claim, currency must be Decayed (injected now, not system clock)"
843        );
844    }
845}