Skip to main content

mempill_core/testing/
oracle_conformance.rs

1//! Generic oracle-resolution conformance suite.
2//!
3//! `run_oracle_conformance` exercises every observable oracle-resolution behavior
4//! and panics on any deviation from the expected contract.
5//!
6//! Both `mempill-sqlite` and `mempill-postgres` activate `mempill-core/test-support`
7//! in dev-dependencies and call the exported scenario functions to verify that the
8//! SAME assertions pass on SQLite (in-memory + file-backed) and PG 16 + PG 18.
9//!
10//! # Design
11//!
12//! The harness is built from standalone async scenario functions.  Each function
13//! receives either:
14//! - A reference to an already-constructed `EngineHandle` (for most scenarios), OR
15//! - A pair of factory closures (for the reopen / durability scenario which drops the
16//!   first engine and opens a second one over the same durable backing store).
17//!
18//! Scenarios use a dedicated `AgentId` per-scenario so they are safe to run
19//! sequentially against a shared store without cross-contamination.
20//!
21//! # Scenario catalogue
22//!
23//! | Sub-test | Function |
24//! |----------|----------|
25//! | 1  | `scenario_affirm_challenger_wins` |
26//! | 2  | `scenario_deny_incumbent_stands` |
27//! | 3  | `scenario_unknown_stays_contested` |
28//! | 4  | `scenario_queued_surfaces_contested` |
29//! | 5  | `scenario_stale_handle_not_found` |
30//! | 6  | `scenario_duplicate_submit_not_found` |
31//! | 7  | `scenario_ttl_expiry_reverts_contested` |
32//! | 8a | `scenario_sweep_reverts_expired` |
33//! | 8b | `scenario_sweep_recovers_orphan` |
34//! | 9  | `scenario_durable_store_survives_reopen` |
35//! | 10 | `scenario_atomicity_no_torn_write` |
36//! | 11 | `scenario_ledger_entry_expectations` |
37//! | 12 | `scenario_b11_oracle_absent_contested` |
38
39#[cfg(any(test, feature = "test-support"))]
40use std::time::Duration;
41
42#[cfg(any(test, feature = "test-support"))]
43use mempill_types::{
44    AdjudicationResponse, AdjudicationVerdict, AgentId, BeliefStatus, Cardinality, Confidence,
45    Criticality, Disposition, ExternalKind, LedgerEventKind, ProvenanceLabel,
46};
47
48#[cfg(any(test, feature = "test-support"))]
49use crate::{
50    application::{AuditQueryRequest, IngestClaimRequest, QueryMemoryRequest},
51    ports::OraclePort,
52    EngineConfig, EngineHandle,
53};
54
55// ── Internal TestOracle ───────────────────────────────────────────────────────
56
57/// Deterministic oracle that always returns the caller-supplied `fixed_uuid` as the
58/// adjudication handle.  `handle_to_uuid` is the identity function on `uuid::Uuid`.
59///
60/// Both adapter test files use this type (via the re-exported `build_oracle_engine` helper)
61/// so that both adapters exercise **identical oracle behavior**.
62#[cfg(any(test, feature = "test-support"))]
63pub struct TestOracle {
64    /// The UUID returned by every `request_adjudication` call, allowing callers to predict
65    /// the handle before submitting an adjudication response.
66    pub fixed_uuid: uuid::Uuid,
67}
68
69#[cfg(any(test, feature = "test-support"))]
70impl OraclePort for TestOracle {
71    type Error = crate::noop::NoOpError;
72    type Handle = uuid::Uuid;
73
74    fn request_adjudication(
75        &self,
76        _agent_id: &AgentId,
77        _request: mempill_types::AdjudicationRequest,
78    ) -> Result<Self::Handle, Self::Error> {
79        Ok(self.fixed_uuid)
80    }
81
82    fn handle_to_uuid(handle: &Self::Handle) -> uuid::Uuid {
83        *handle
84    }
85}
86
87// ── Common request builders ───────────────────────────────────────────────────
88
89/// Builds a minimal `IngestClaimRequest` for `agent` with the given `value`.
90/// All other fields are set to conformance-stable defaults (External/UserAsserted, Functional, High).
91#[cfg(any(test, feature = "test-support"))]
92pub fn ingest_req(agent: &AgentId, value: &str) -> IngestClaimRequest {
93    IngestClaimRequest {
94        agent_id: agent.clone(),
95        subject: "subject".into(),
96        predicate: "predicate".into(),
97        value: serde_json::json!(value),
98        provenance: ProvenanceLabel::External(ExternalKind::UserAsserted),
99        cardinality: Cardinality::Functional,
100        valid_time: None,
101        confidence: Confidence { value_confidence: 0.95, valid_time_confidence: 0.0 },
102        criticality: Criticality::High,
103        derived_from: vec![],
104    }
105}
106
107/// Builds a `QueryMemoryRequest` targeting the fixed `(subject, predicate)` pair used by all
108/// conformance scenarios, scoped to `agent`.
109#[cfg(any(test, feature = "test-support"))]
110pub fn query_req(agent: &AgentId) -> QueryMemoryRequest {
111    QueryMemoryRequest {
112        agent_id: agent.clone(),
113        subject: "subject".into(),
114        predicate: "predicate".into(),
115        as_of_tx_time: None,
116        valid_at: None,
117    }
118}
119
120#[cfg(any(test, feature = "test-support"))]
121fn adj_response(
122    handle_id: uuid::Uuid,
123    verdict: AdjudicationVerdict,
124) -> AdjudicationResponse {
125    AdjudicationResponse {
126        handle_id,
127        verdict,
128        evidence_provenance: ProvenanceLabel::External(ExternalKind::ExternalFirstHand),
129    }
130}
131
132// ── Scenario 1: Affirm — challenger wins ─────────────────────────────────────
133
134/// Scenario 1 (Affirm): challenger CommittedCheap, incumbent Superseded,
135/// ledger entry has External provenance, query_memory surfaces challenger.
136///
137/// Callers pass `handle_id` matching the UUID used when building the engine's `TestOracle`.
138#[cfg(any(test, feature = "test-support"))]
139#[cfg(any(test, feature = "test-support"))]
140pub async fn scenario_affirm_challenger_wins_with_handle<P, O, V>(
141    engine: &EngineHandle<P, O, V>,
142    handle_id: uuid::Uuid,
143) where
144    P: crate::ports::PersistencePort + Send + Sync + 'static,
145    P::Error: std::fmt::Debug,
146    O: OraclePort + Send + Sync + 'static,
147    V: crate::ports::VectorPort + Send + Sync + 'static,
148{
149    let agent = AgentId("conformance-affirm-agent".into());
150
151    let resp_inc = engine.ingest_claim(ingest_req(&agent, "incumbent-value")).await
152        .expect("conformance[affirm]: ingest incumbent must succeed");
153    assert_eq!(resp_inc.disposition, Disposition::CommittedCheap,
154        "conformance[affirm]: incumbent must be CommittedCheap");
155
156    let resp_ch = engine.ingest_claim(ingest_req(&agent, "challenger-value")).await
157        .expect("conformance[affirm]: ingest challenger must succeed");
158    assert_eq!(resp_ch.disposition, Disposition::QueuedForAdjudication,
159        "conformance[affirm]: challenger with oracle present must be QueuedForAdjudication");
160
161    let challenger_ref = resp_ch.claim_ref.clone();
162    let incumbent_ref = resp_inc.claim_ref.clone();
163
164    // Submit Affirm.
165    let outcome = engine.submit_adjudication(
166        handle_id,
167        adj_response(handle_id, AdjudicationVerdict::Affirm),
168    ).await.expect("conformance[affirm]: Affirm submit must succeed");
169
170    assert_eq!(outcome.disposition, Disposition::CommittedCheap,
171        "conformance[affirm]: challenger must be CommittedCheap after Affirm");
172    assert_eq!(outcome.claim_ref, challenger_ref,
173        "conformance[affirm]: outcome.claim_ref must be challenger");
174
175    // query_memory must surface challenger.
176    let qr = engine.query_memory(query_req(&agent)).await
177        .expect("conformance[affirm]: query must succeed");
178    let primary_val = qr.belief.primary.as_ref().map(|b| b.fact.value.clone());
179    assert_ne!(qr.belief.status, BeliefStatus::Contested,
180        "conformance[affirm]: must NOT be Contested after Affirm");
181    assert_ne!(qr.belief.status, BeliefStatus::NoBelief,
182        "conformance[affirm]: must NOT be NoBelief after Affirm");
183    assert_eq!(primary_val, Some(serde_json::json!("challenger-value")),
184        "conformance[affirm]: challenger must be surfaced as primary belief");
185
186    // Ledger must have AdjudicationResolved + External provenance for challenger.
187    let audit = engine.query_audit(AuditQueryRequest {
188        agent_id: agent.clone(),
189        claim_ref: None,
190        from_tx_time: None,
191        limit: 100,
192    }).await.expect("conformance[affirm]: audit must succeed");
193
194    let ch_entry = audit.entries.iter()
195        .find(|e| e.claim_ref == challenger_ref && e.event_kind == LedgerEventKind::AdjudicationResolved)
196        .expect("conformance[affirm]: AdjudicationResolved entry for challenger must exist");
197    assert_eq!(ch_entry.disposition, Disposition::CommittedCheap,
198        "conformance[affirm]: ledger entry disposition must be CommittedCheap");
199    let rationale = ch_entry.rationale.as_ref().map(|r| r.to_string()).unwrap_or_default();
200    assert!(rationale.contains("ExternalFirstHand"),
201        "conformance[affirm]: Affirm rationale must contain ExternalFirstHand provenance");
202
203    // Incumbent must have a Superseded entry (written during ingest heavy-path).
204    let inc_entry = audit.entries.iter()
205        .find(|e| e.claim_ref == incumbent_ref && e.disposition == Disposition::Superseded)
206        .expect("conformance[affirm]: incumbent Superseded entry must exist");
207    assert_eq!(inc_entry.disposition, Disposition::Superseded,
208        "conformance[affirm]: incumbent must be Superseded");
209}
210
211// ── Scenario 2: Deny — incumbent stands ──────────────────────────────────────
212
213/// Scenario 2 (Deny): after `AdjudicationVerdict::Deny` the incumbent remains the primary belief
214/// and the challenger is Superseded.  Verifies that `query_memory` surfaces the incumbent value.
215#[cfg(any(test, feature = "test-support"))]
216pub async fn scenario_deny_incumbent_stands<P, O, V>(
217    engine: &EngineHandle<P, O, V>,
218    handle_id: uuid::Uuid,
219) where
220    P: crate::ports::PersistencePort + Send + Sync + 'static,
221    P::Error: std::fmt::Debug,
222    O: OraclePort + Send + Sync + 'static,
223    V: crate::ports::VectorPort + Send + Sync + 'static,
224{
225    let agent = AgentId("conformance-deny-agent".into());
226
227    let resp_inc = engine.ingest_claim(ingest_req(&agent, "incumbent-deny")).await
228        .expect("conformance[deny]: ingest incumbent");
229    assert_eq!(resp_inc.disposition, Disposition::CommittedCheap);
230
231    let resp_ch = engine.ingest_claim(ingest_req(&agent, "challenger-deny")).await
232        .expect("conformance[deny]: ingest challenger");
233    assert_eq!(resp_ch.disposition, Disposition::QueuedForAdjudication);
234
235    let challenger_ref = resp_ch.claim_ref.clone();
236
237    let outcome = engine.submit_adjudication(
238        handle_id,
239        adj_response(handle_id, AdjudicationVerdict::Deny),
240    ).await.expect("conformance[deny]: Deny submit must succeed");
241
242    assert_eq!(outcome.disposition, Disposition::Superseded,
243        "conformance[deny]: challenger must be Superseded after Deny");
244    assert_eq!(outcome.claim_ref, challenger_ref,
245        "conformance[deny]: outcome.claim_ref must be challenger");
246
247    // query_memory must surface incumbent.
248    let qr = engine.query_memory(query_req(&agent)).await
249        .expect("conformance[deny]: query must succeed");
250    let primary_val = qr.belief.primary.as_ref().map(|b| b.fact.value.clone());
251    assert_ne!(qr.belief.status, BeliefStatus::Contested,
252        "conformance[deny]: must NOT be Contested after Deny");
253    assert_ne!(qr.belief.status, BeliefStatus::NoBelief,
254        "conformance[deny]: must NOT be NoBelief after Deny");
255    assert_eq!(primary_val, Some(serde_json::json!("incumbent-deny")),
256        "conformance[deny]: incumbent must be surfaced after Deny");
257}
258
259// ── Scenario 3: Unknown — stays Contested ────────────────────────────────────
260
261/// Scenario 3 (Unknown): `AdjudicationVerdict::Unknown` leaves both claims in `Contested` state.
262/// Verifies that a second submit on the now-consumed handle returns `AdjudicationHandleNotFound`.
263#[cfg(any(test, feature = "test-support"))]
264pub async fn scenario_unknown_stays_contested<P, O, V>(
265    engine: &EngineHandle<P, O, V>,
266    handle_id: uuid::Uuid,
267) where
268    P: crate::ports::PersistencePort + Send + Sync + 'static,
269    P::Error: std::fmt::Debug,
270    O: OraclePort + Send + Sync + 'static,
271    V: crate::ports::VectorPort + Send + Sync + 'static,
272{
273    let agent = AgentId("conformance-unknown-agent".into());
274
275    let resp_inc = engine.ingest_claim(ingest_req(&agent, "incumbent-unknown")).await
276        .expect("conformance[unknown]: ingest incumbent");
277    let incumbent_ref = resp_inc.claim_ref.clone();
278    assert_eq!(resp_inc.disposition, Disposition::CommittedCheap);
279
280    let resp_ch = engine.ingest_claim(ingest_req(&agent, "challenger-unknown")).await
281        .expect("conformance[unknown]: ingest challenger");
282    let challenger_ref = resp_ch.claim_ref.clone();
283    assert_eq!(resp_ch.disposition, Disposition::QueuedForAdjudication);
284
285    let outcome = engine.submit_adjudication(
286        handle_id,
287        adj_response(handle_id, AdjudicationVerdict::Unknown),
288    ).await.expect("conformance[unknown]: Unknown submit must succeed");
289
290    assert_eq!(outcome.disposition, Disposition::Contested,
291        "conformance[unknown]: outcome must be Contested after Unknown");
292    assert_eq!(outcome.claim_ref, challenger_ref);
293
294    // query_memory must surface Contested[both].
295    let qr = engine.query_memory(query_req(&agent)).await
296        .expect("conformance[unknown]: query must succeed");
297    assert_eq!(qr.belief.status, BeliefStatus::Contested,
298        "conformance[unknown]: must be Contested after Unknown");
299    let all_vals: Vec<_> = qr.belief.primary.iter()
300        .map(|b| b.fact.value.clone())
301        .chain(qr.belief.alternatives.iter().map(|b| b.fact.value.clone()))
302        .collect();
303    assert!(all_vals.contains(&serde_json::json!("incumbent-unknown")),
304        "conformance[unknown]: incumbent must be visible in Contested");
305    assert!(all_vals.contains(&serde_json::json!("challenger-unknown")),
306        "conformance[unknown]: challenger must be visible in Contested");
307
308    // Handle must be consumed — second submit must fail.
309    let second = engine.submit_adjudication(
310        handle_id,
311        adj_response(handle_id, AdjudicationVerdict::Unknown),
312    ).await;
313    assert!(
314        matches!(second, Err(crate::error::MemError::AdjudicationHandleNotFound { .. })),
315        "conformance[unknown]: second submit on consumed handle must be AdjudicationHandleNotFound; got {second:?}"
316    );
317
318    // Audit: 2 AdjudicationResolved entries (one per claim).
319    let audit = engine.query_audit(AuditQueryRequest {
320        agent_id: agent.clone(),
321        claim_ref: None,
322        from_tx_time: None,
323        limit: 100,
324    }).await.expect("conformance[unknown]: audit must succeed");
325    let resolved: Vec<_> = audit.entries.iter()
326        .filter(|e| e.event_kind == LedgerEventKind::AdjudicationResolved)
327        .collect();
328    assert_eq!(resolved.len(), 2,
329        "conformance[unknown]: Unknown must produce 2 AdjudicationResolved entries");
330    let has_inc = resolved.iter().any(|e| e.claim_ref == incumbent_ref && e.disposition == Disposition::Contested);
331    let has_ch  = resolved.iter().any(|e| e.claim_ref == challenger_ref && e.disposition == Disposition::Contested);
332    assert!(has_inc, "conformance[unknown]: incumbent AdjudicationResolved/Contested must exist");
333    assert!(has_ch,  "conformance[unknown]: challenger AdjudicationResolved/Contested must exist");
334}
335
336// ── Scenario 4: Queued — BEFORE submit surfaces Contested ─────────────────────
337
338/// Scenario 4 (Queued): before any adjudication is submitted, `query_memory` must surface
339/// `BeliefStatus::Contested` for both the incumbent and the queued challenger (invariant I7).
340#[cfg(any(test, feature = "test-support"))]
341pub async fn scenario_queued_surfaces_contested<P, O, V>(
342    engine: &EngineHandle<P, O, V>,
343) where
344    P: crate::ports::PersistencePort + Send + Sync + 'static,
345    P::Error: std::fmt::Debug,
346    O: OraclePort + Send + Sync + 'static,
347    V: crate::ports::VectorPort + Send + Sync + 'static,
348{
349    let agent = AgentId("conformance-queued-agent".into());
350
351    let resp_inc = engine.ingest_claim(ingest_req(&agent, "queued-incumbent")).await
352        .expect("conformance[queued]: ingest incumbent");
353    assert_eq!(resp_inc.disposition, Disposition::CommittedCheap);
354
355    let resp_ch = engine.ingest_claim(ingest_req(&agent, "queued-challenger")).await
356        .expect("conformance[queued]: ingest challenger");
357    assert_eq!(resp_ch.disposition, Disposition::QueuedForAdjudication,
358        "conformance[queued]: challenger with oracle must be QueuedForAdjudication");
359
360    // BEFORE submit: query_memory must surface Contested.
361    let qr = engine.query_memory(query_req(&agent)).await
362        .expect("conformance[queued]: query must succeed");
363    assert_eq!(qr.belief.status, BeliefStatus::Contested,
364        "conformance[queued]: BEFORE any submit, belief must be Contested (I7)");
365    let all_vals: Vec<_> = qr.belief.primary.iter()
366        .map(|b| b.fact.value.clone())
367        .chain(qr.belief.alternatives.iter().map(|b| b.fact.value.clone()))
368        .collect();
369    assert!(all_vals.contains(&serde_json::json!("queued-incumbent")),
370        "conformance[queued]: incumbent must be visible in pre-submit Contested");
371    assert!(all_vals.contains(&serde_json::json!("queued-challenger")),
372        "conformance[queued]: challenger must be visible in pre-submit Contested");
373}
374
375// ── Scenario 5: Stale handle → AdjudicationHandleNotFound ────────────────────
376
377/// Scenario 5 (Stale handle): submitting adjudication with a random/unknown UUID must return
378/// `MemError::AdjudicationHandleNotFound`, proving the engine rejects phantom handles.
379#[cfg(any(test, feature = "test-support"))]
380pub async fn scenario_stale_handle_not_found<P, O, V>(
381    engine: &EngineHandle<P, O, V>,
382) where
383    P: crate::ports::PersistencePort + Send + Sync + 'static,
384    P::Error: std::fmt::Debug,
385    O: OraclePort + Send + Sync + 'static,
386    V: crate::ports::VectorPort + Send + Sync + 'static,
387{
388    let random_handle = uuid::Uuid::new_v4();
389    let result = engine.submit_adjudication(
390        random_handle,
391        adj_response(random_handle, AdjudicationVerdict::Affirm),
392    ).await;
393    assert!(
394        matches!(result, Err(crate::error::MemError::AdjudicationHandleNotFound { .. })),
395        "conformance[stale-handle]: random/unknown handle must return AdjudicationHandleNotFound; got {result:?}"
396    );
397}
398
399// ── Scenario 6: Duplicate submit → AdjudicationHandleNotFound ─────────────────
400
401/// Scenario 6 (Duplicate submit): after a successful first submit the handle is consumed;
402/// a second submit with the same `handle_id` must return `MemError::AdjudicationHandleNotFound`.
403#[cfg(any(test, feature = "test-support"))]
404pub async fn scenario_duplicate_submit_not_found<P, O, V>(
405    engine: &EngineHandle<P, O, V>,
406    handle_id: uuid::Uuid,
407) where
408    P: crate::ports::PersistencePort + Send + Sync + 'static,
409    P::Error: std::fmt::Debug,
410    O: OraclePort + Send + Sync + 'static,
411    V: crate::ports::VectorPort + Send + Sync + 'static,
412{
413    let agent = AgentId("conformance-dup-agent".into());
414
415    engine.ingest_claim(ingest_req(&agent, "dup-incumbent")).await
416        .expect("conformance[dup]: ingest incumbent");
417    engine.ingest_claim(ingest_req(&agent, "dup-challenger")).await
418        .expect("conformance[dup]: ingest challenger");
419
420    // First submit succeeds.
421    engine.submit_adjudication(handle_id, adj_response(handle_id, AdjudicationVerdict::Affirm)).await
422        .expect("conformance[dup]: first submit must succeed");
423
424    // Second submit must fail.
425    let second = engine.submit_adjudication(handle_id, adj_response(handle_id, AdjudicationVerdict::Affirm)).await;
426    assert!(
427        matches!(second, Err(crate::error::MemError::AdjudicationHandleNotFound { .. })),
428        "conformance[dup]: duplicate submit must return AdjudicationHandleNotFound; got {second:?}"
429    );
430}
431
432// ── Scenario 7: TTL expiry → AdjudicationHandleNotFound + Contested ──────────
433
434/// TTL expiry via a 1-ns TTL so the row expires immediately.
435/// Caller must supply an engine built with `EngineConfig { default_adjudication_ttl: Some(1ns), .. }`.
436#[cfg(any(test, feature = "test-support"))]
437pub async fn scenario_ttl_expiry_reverts_contested<P, O, V>(
438    engine: &EngineHandle<P, O, V>,
439    handle_id: uuid::Uuid,
440) where
441    P: crate::ports::PersistencePort + Send + Sync + 'static,
442    P::Error: std::fmt::Debug,
443    O: OraclePort + Send + Sync + 'static,
444    V: crate::ports::VectorPort + Send + Sync + 'static,
445{
446    let agent = AgentId("conformance-ttl-agent".into());
447
448    let resp_inc = engine.ingest_claim(ingest_req(&agent, "ttl-incumbent")).await
449        .expect("conformance[ttl]: ingest incumbent");
450    assert_eq!(resp_inc.disposition, Disposition::CommittedCheap);
451
452    let resp_ch = engine.ingest_claim(ingest_req(&agent, "ttl-challenger")).await
453        .expect("conformance[ttl]: ingest challenger");
454    assert_eq!(resp_ch.disposition, Disposition::QueuedForAdjudication);
455
456    // Sleep a tiny bit to ensure the 1-ns TTL has elapsed.
457    tokio::time::sleep(tokio::time::Duration::from_millis(5)).await;
458
459    // submit on the expired handle → AdjudicationHandleNotFound.
460    let result = engine.submit_adjudication(
461        handle_id,
462        adj_response(handle_id, AdjudicationVerdict::Affirm),
463    ).await;
464    assert!(
465        matches!(result, Err(crate::error::MemError::AdjudicationHandleNotFound { .. })),
466        "conformance[ttl]: expired handle must return AdjudicationHandleNotFound; got {result:?}"
467    );
468
469    // query_memory must surface Contested[both] after lazy expiry.
470    let qr = engine.query_memory(query_req(&agent)).await
471        .expect("conformance[ttl]: query must succeed");
472    assert_eq!(qr.belief.status, BeliefStatus::Contested,
473        "conformance[ttl]: after TTL expiry, must be Contested");
474    let all_vals: Vec<_> = qr.belief.primary.iter()
475        .map(|b| b.fact.value.clone())
476        .chain(qr.belief.alternatives.iter().map(|b| b.fact.value.clone()))
477        .collect();
478    assert!(all_vals.contains(&serde_json::json!("ttl-incumbent")),
479        "conformance[ttl]: incumbent must be visible in Contested after expiry");
480    assert!(all_vals.contains(&serde_json::json!("ttl-challenger")),
481        "conformance[ttl]: challenger must be visible in Contested after expiry");
482
483    // Audit must contain a TTL/expiry-related ledger entry for the challenger.
484    let audit = engine.query_audit(AuditQueryRequest {
485        agent_id: agent.clone(),
486        claim_ref: None,
487        from_tx_time: None,
488        limit: 100,
489    }).await.expect("conformance[ttl]: audit must succeed");
490    // The engine writes an AdjudicationExpired or AdjudicationResolved entry on expiry.
491    let has_expiry_entry = audit.entries.iter().any(|e| {
492        e.claim_ref == resp_ch.claim_ref
493            && (e.event_kind == LedgerEventKind::AdjudicationExpired
494                || e.disposition == Disposition::Contested)
495    });
496    assert!(has_expiry_entry,
497        "conformance[ttl]: ledger must have an expiry entry for the challenger; entries={:?}",
498        audit.entries.iter().map(|e| (&e.claim_ref, &e.event_kind, &e.disposition)).collect::<Vec<_>>()
499    );
500}
501
502// ── Scenario 8a: Sweep reverts expired ────────────────────────────────────────
503
504/// Sweep test: an already-past TTL row is reverted to Contested by sweep.
505/// Caller must supply an engine built with `default_adjudication_ttl: Some(1ns)`.
506#[cfg(any(test, feature = "test-support"))]
507pub async fn scenario_sweep_reverts_expired<P, O, V>(
508    engine: &EngineHandle<P, O, V>,
509) where
510    P: crate::ports::PersistencePort + Send + Sync + 'static,
511    P::Error: std::fmt::Debug,
512    O: OraclePort + Send + Sync + 'static,
513    V: crate::ports::VectorPort + Send + Sync + 'static,
514{
515    let agent = AgentId("conformance-sweep-exp-agent".into());
516
517    engine.ingest_claim(ingest_req(&agent, "sweep-exp-incumbent")).await
518        .expect("conformance[sweep-exp]: ingest incumbent");
519    let resp_ch = engine.ingest_claim(ingest_req(&agent, "sweep-exp-challenger")).await
520        .expect("conformance[sweep-exp]: ingest challenger");
521    assert_eq!(resp_ch.disposition, Disposition::QueuedForAdjudication);
522
523    tokio::time::sleep(tokio::time::Duration::from_millis(5)).await;
524
525    let swept = engine.sweep_expired_adjudications().await
526        .expect("conformance[sweep-exp]: sweep must succeed");
527    assert!(swept >= 1,
528        "conformance[sweep-exp]: sweep must revert at least 1 expired row; got {swept}");
529
530    let qr = engine.query_memory(query_req(&agent)).await
531        .expect("conformance[sweep-exp]: query must succeed");
532    assert_eq!(qr.belief.status, BeliefStatus::Contested,
533        "conformance[sweep-exp]: after sweep, must be Contested");
534    let all_vals: Vec<_> = qr.belief.primary.iter()
535        .map(|b| b.fact.value.clone())
536        .chain(qr.belief.alternatives.iter().map(|b| b.fact.value.clone()))
537        .collect();
538    assert!(all_vals.contains(&serde_json::json!("sweep-exp-incumbent")),
539        "conformance[sweep-exp]: incumbent must be visible after sweep");
540    assert!(all_vals.contains(&serde_json::json!("sweep-exp-challenger")),
541        "conformance[sweep-exp]: challenger must be visible after sweep");
542}
543
544// ── Scenario 8b: Sweep recovers orphan ────────────────────────────────────────
545
546/// Orphan recovery: a QueuedForAdjudication claim with no pending row is reverted by sweep.
547///
548/// The orphan is seeded directly via the persistence port, bypassing the engine ingest path.
549/// The engine passed to this function must have an accessible persistence store.
550/// Because `EngineHandle` does not expose the store, callers must seed the orphan externally
551/// and then call this function.  The scenario verifies the post-sweep state.
552///
553/// This function takes two lambdas:
554/// - `seed_orphan`: inserts the orphan claim + ledger entry directly, returns
555///   `(incumbent_agent_id, challenger_value, incumbent_value)`.
556/// - The engine reference (the EngineHandle built on the same store as seed_orphan touches).
557#[cfg(any(test, feature = "test-support"))]
558pub async fn scenario_sweep_recovers_orphan<P, O, V>(
559    engine: &EngineHandle<P, O, V>,
560    agent_name: &str,
561) where
562    P: crate::ports::PersistencePort + Send + Sync + 'static,
563    P::Error: std::fmt::Debug,
564    O: OraclePort + Send + Sync + 'static,
565    V: crate::ports::VectorPort + Send + Sync + 'static,
566{
567    // After the adapter test has seeded the orphan, we simply call sweep and verify.
568    let agent = AgentId(agent_name.into());
569
570    let swept = engine.sweep_expired_adjudications().await
571        .expect("conformance[sweep-orphan]: sweep must succeed");
572    assert!(swept >= 1,
573        "conformance[sweep-orphan]: sweep must recover at least 1 orphaned claim; got {swept}");
574
575    let qr = engine.query_memory(query_req(&agent)).await
576        .expect("conformance[sweep-orphan]: query must succeed");
577    assert_eq!(qr.belief.status, BeliefStatus::Contested,
578        "conformance[sweep-orphan]: after orphan recovery, must be Contested");
579    let all_vals: Vec<_> = qr.belief.primary.iter()
580        .map(|b| b.fact.value.clone())
581        .chain(qr.belief.alternatives.iter().map(|b| b.fact.value.clone()))
582        .collect();
583    assert!(all_vals.contains(&serde_json::json!("orphan-incumbent")),
584        "conformance[sweep-orphan]: incumbent must be visible; got {all_vals:?}");
585    assert!(all_vals.contains(&serde_json::json!("orphan-challenger")),
586        "conformance[sweep-orphan]: orphaned challenger must be visible; got {all_vals:?}");
587}
588
589// ── Scenario 9: Durable store survives reopen ─────────────────────────────────
590
591/// After queuing a conflict on engine-1, drop it and open engine-2 on the SAME backing
592/// store, then submit Affirm on the pre-restart handle.  This proves the pending row
593/// (Amendment-1) survives engine restart.
594///
595/// Callers supply two engines built over the same durable backing store and the
596/// handle_id used by the oracle.
597#[cfg(any(test, feature = "test-support"))]
598pub async fn scenario_durable_store_survives_reopen<P, O, V>(
599    engine1: EngineHandle<P, O, V>,
600    build_engine2: impl FnOnce() -> EngineHandle<P, O, V>,
601    handle_id: uuid::Uuid,
602) where
603    P: crate::ports::PersistencePort + Send + Sync + 'static,
604    P::Error: std::fmt::Debug,
605    O: OraclePort + Send + Sync + 'static,
606    V: crate::ports::VectorPort + Send + Sync + 'static,
607{
608    let agent = AgentId("conformance-reopen-agent".into());
609
610    // Engine 1: ingest conflict.
611    let resp_inc = engine1.ingest_claim(ingest_req(&agent, "reopen-incumbent")).await
612        .expect("conformance[reopen]: ingest incumbent on engine-1");
613    assert_eq!(resp_inc.disposition, Disposition::CommittedCheap);
614
615    let resp_ch = engine1.ingest_claim(ingest_req(&agent, "reopen-challenger")).await
616        .expect("conformance[reopen]: ingest challenger on engine-1");
617    assert_eq!(resp_ch.disposition, Disposition::QueuedForAdjudication);
618
619    let challenger_ref = resp_ch.claim_ref.clone();
620
621    // Drop engine 1, simulating restart.
622    drop(engine1);
623
624    // Engine 2: open on same backing store.
625    let engine2 = build_engine2();
626
627    // Submit Affirm on the pre-restart handle — must resolve (proves pending row durability).
628    let outcome = engine2.submit_adjudication(
629        handle_id,
630        adj_response(handle_id, AdjudicationVerdict::Affirm),
631    ).await.expect("conformance[reopen]: Affirm on pre-restart handle must succeed");
632    assert_eq!(outcome.disposition, Disposition::CommittedCheap,
633        "conformance[reopen]: challenger must be CommittedCheap after cross-restart Affirm");
634    assert_eq!(outcome.claim_ref, challenger_ref,
635        "conformance[reopen]: outcome.claim_ref must be challenger");
636
637    // Query on engine 2 must surface challenger.
638    let qr = engine2.query_memory(query_req(&agent)).await
639        .expect("conformance[reopen]: query on engine-2 must succeed");
640    let primary_val = qr.belief.primary.as_ref().map(|b| b.fact.value.clone());
641    assert_eq!(primary_val, Some(serde_json::json!("reopen-challenger")),
642        "conformance[reopen]: challenger must be surfaced after cross-restart Affirm");
643}
644
645// ── Scenario 10: Atomicity — no torn write ────────────────────────────────────
646
647/// After a successful Affirm submit, the ledger + disposition + pending-row-resolved
648/// are all consistent (no partial state).
649///
650/// Full mid-apply failure injection is not feasible without engine-level hooks; we
651/// verify the observable post-success consistency guarantee instead.
652#[cfg(any(test, feature = "test-support"))]
653pub async fn scenario_atomicity_no_torn_write<P, O, V>(
654    engine: &EngineHandle<P, O, V>,
655    handle_id: uuid::Uuid,
656) where
657    P: crate::ports::PersistencePort + Send + Sync + 'static,
658    P::Error: std::fmt::Debug,
659    O: OraclePort + Send + Sync + 'static,
660    V: crate::ports::VectorPort + Send + Sync + 'static,
661{
662    let agent = AgentId("conformance-atomicity-agent".into());
663
664    engine.ingest_claim(ingest_req(&agent, "atom-incumbent")).await
665        .expect("conformance[atomicity]: ingest incumbent");
666    let resp_ch = engine.ingest_claim(ingest_req(&agent, "atom-challenger")).await
667        .expect("conformance[atomicity]: ingest challenger");
668    let challenger_ref = resp_ch.claim_ref.clone();
669
670    let outcome = engine.submit_adjudication(
671        handle_id,
672        adj_response(handle_id, AdjudicationVerdict::Affirm),
673    ).await.expect("conformance[atomicity]: Affirm submit must succeed");
674
675    // Disposition check (ledger).
676    assert_eq!(outcome.disposition, Disposition::CommittedCheap,
677        "conformance[atomicity]: challenger disposition must be CommittedCheap");
678    assert_eq!(outcome.claim_ref, challenger_ref);
679
680    // Pending row must be consumed (handle gone).
681    let second = engine.submit_adjudication(
682        handle_id,
683        adj_response(handle_id, AdjudicationVerdict::Affirm),
684    ).await;
685    assert!(
686        matches!(second, Err(crate::error::MemError::AdjudicationHandleNotFound { .. })),
687        "conformance[atomicity]: pending row must be consumed (not found on second submit)"
688    );
689
690    // query_memory consistent: challenger surfaced, not Contested.
691    let qr = engine.query_memory(query_req(&agent)).await
692        .expect("conformance[atomicity]: query must succeed");
693    assert_ne!(qr.belief.status, BeliefStatus::Contested,
694        "conformance[atomicity]: after Affirm, must NOT be Contested");
695    assert_ne!(qr.belief.status, BeliefStatus::NoBelief,
696        "conformance[atomicity]: after Affirm, must NOT be NoBelief");
697
698    // Ledger consistent: AdjudicationResolved entry present.
699    let audit = engine.query_audit(AuditQueryRequest {
700        agent_id: agent.clone(),
701        claim_ref: None,
702        from_tx_time: None,
703        limit: 100,
704    }).await.expect("conformance[atomicity]: audit must succeed");
705    let resolved = audit.entries.iter()
706        .find(|e| e.claim_ref == challenger_ref && e.event_kind == LedgerEventKind::AdjudicationResolved)
707        .expect("conformance[atomicity]: AdjudicationResolved ledger entry must exist");
708    assert_eq!(resolved.disposition, Disposition::CommittedCheap,
709        "conformance[atomicity]: ledger entry must be CommittedCheap");
710}
711
712// ── Scenario 11: Ledger entry expectations consistent across adapters ──────────
713
714/// Verify that the ledger entry kinds and dispositions for each verdict
715/// are consistent (same invariants) across adapters.
716/// This is an aggregated check — sub-assertions from scenarios 1, 2, 3 are reused
717/// here as an explicit cross-check.
718#[cfg(any(test, feature = "test-support"))]
719pub async fn scenario_ledger_entry_expectations<P, O, V>(
720    engine: &EngineHandle<P, O, V>,
721    handle_id: uuid::Uuid,
722    verdict: AdjudicationVerdict,
723    expected_ch_disposition: Disposition,
724    expected_ch_event_kind: LedgerEventKind,
725) where
726    P: crate::ports::PersistencePort + Send + Sync + 'static,
727    P::Error: std::fmt::Debug,
728    O: OraclePort + Send + Sync + 'static,
729    V: crate::ports::VectorPort + Send + Sync + 'static,
730{
731    let label = format!("{verdict:?}");
732    let agent = AgentId(format!("conformance-ledger-{label}-agent"));
733
734    engine.ingest_claim(ingest_req(&agent, "ledger-incumbent")).await
735        .expect("conformance[ledger]: ingest incumbent");
736    let resp_ch = engine.ingest_claim(ingest_req(&agent, "ledger-challenger")).await
737        .expect("conformance[ledger]: ingest challenger");
738    let challenger_ref = resp_ch.claim_ref.clone();
739
740    engine.submit_adjudication(handle_id, adj_response(handle_id, verdict)).await
741        .expect("conformance[ledger]: submit must succeed");
742
743    let audit = engine.query_audit(AuditQueryRequest {
744        agent_id: agent.clone(),
745        claim_ref: None,
746        from_tx_time: None,
747        limit: 100,
748    }).await.expect("conformance[ledger]: audit must succeed");
749
750    // Find the resolution entry for the challenger.
751    let ch_entry = audit.entries.iter()
752        .find(|e| e.claim_ref == challenger_ref && e.event_kind == expected_ch_event_kind)
753        .unwrap_or_else(|| panic!(
754            "conformance[ledger/{label}]: expected {:?} event kind for challenger; entries={:?}",
755            expected_ch_event_kind,
756            audit.entries.iter().map(|e| (&e.event_kind, &e.disposition)).collect::<Vec<_>>()
757        ));
758    assert_eq!(ch_entry.disposition, expected_ch_disposition,
759        "conformance[ledger/{label}]: challenger disposition must be {expected_ch_disposition:?}");
760}
761
762// ── Scenario 12: B11 oracle-absent → Contested ────────────────────────────────
763
764/// With no oracle, conflicting External claims must immediately surface as Contested.
765/// Caller must pass a no-oracle engine (DefaultEngine / `open_default_in_memory` variant).
766#[cfg(any(test, feature = "test-support"))]
767pub async fn scenario_b11_oracle_absent_contested<P, O, V>(
768    engine: &EngineHandle<P, O, V>,
769) where
770    P: crate::ports::PersistencePort + Send + Sync + 'static,
771    P::Error: std::fmt::Debug,
772    O: OraclePort + Send + Sync + 'static,
773    V: crate::ports::VectorPort + Send + Sync + 'static,
774{
775    let agent = AgentId("conformance-b11-agent".into());
776
777    let resp_inc = engine.ingest_claim(ingest_req(&agent, "b11-incumbent")).await
778        .expect("conformance[b11]: ingest incumbent");
779    assert_eq!(resp_inc.disposition, Disposition::CommittedCheap);
780
781    let resp_ch = engine.ingest_claim(ingest_req(&agent, "b11-challenger")).await
782        .expect("conformance[b11]: ingest challenger");
783    assert_eq!(resp_ch.disposition, Disposition::Contested,
784        "conformance[b11]: oracle-absent External conflict MUST be Contested immediately");
785
786    let qr = engine.query_memory(query_req(&agent)).await
787        .expect("conformance[b11]: query must succeed");
788    assert_eq!(qr.belief.status, BeliefStatus::Contested,
789        "conformance[b11]: query_memory after oracle-absent conflict must be Contested");
790    let all_vals: Vec<_> = qr.belief.primary.iter()
791        .map(|b| b.fact.value.clone())
792        .chain(qr.belief.alternatives.iter().map(|b| b.fact.value.clone()))
793        .collect();
794    assert!(all_vals.contains(&serde_json::json!("b11-incumbent")),
795        "conformance[b11]: incumbent must be visible in Contested");
796    assert!(all_vals.contains(&serde_json::json!("b11-challenger")),
797        "conformance[b11]: challenger must be visible in Contested");
798}
799
800// ── Public entry-point helpers ─────────────────────────────────────────────────
801
802/// Build a fresh `EngineConfig` with a 1-nanosecond adjudication TTL.
803/// Used by adapter tests for TTL/sweep scenarios.
804#[cfg(any(test, feature = "test-support"))]
805pub fn tiny_ttl_config() -> EngineConfig {
806    EngineConfig {
807        default_adjudication_ttl: Some(Duration::from_nanos(1)),
808        ..EngineConfig::default()
809    }
810}