1#![allow(missing_docs)]
2use std::sync::Arc;
29
30use chrono::{DateTime, Utc};
31use mempill_types::{
32 AgentId, AdjudicationOutcome, AdjudicationVerdict, AssertionKind, Disposition,
33 LedgerEntry, LedgerEventKind, TransactionTime, ValidityAssertion,
34};
35
36use crate::{
37 engine_handle::ErasedPendingStore,
38 error::MemError,
39 ports::PersistencePort,
40};
41
42pub struct SubmitAdjudicationUseCase<P>
46where
47 P: PersistencePort + Send + Sync + 'static,
48{
49 persistence: Arc<P>,
50 pending_store: Arc<dyn ErasedPendingStore>,
51}
52
53impl<P> SubmitAdjudicationUseCase<P>
54where
55 P: PersistencePort + Send + Sync + 'static,
56{
57 pub fn new(persistence: Arc<P>, pending_store: Arc<dyn ErasedPendingStore>) -> Self {
58 Self { persistence, pending_store }
59 }
60
61 pub fn execute(
65 &self,
66 handle_id: uuid::Uuid,
67 response: mempill_types::AdjudicationResponse,
68 now: DateTime<Utc>,
69 ) -> Result<AdjudicationOutcome, MemError> {
70 let tx_time = TransactionTime(now);
71
72 let row = self.pending_store
74 .get_pending_erased(handle_id)
75 .map_err(|e| MemError::PendingStore { source: e })?
76 .ok_or(MemError::AdjudicationHandleNotFound { handle_id })?;
77
78 if let Some(expires_at) = row.expires_at {
82 if expires_at <= now {
83 let agent_id_exp: AgentId = row.agent_id.clone();
85 let challenger_ref_exp = row.challenger_claim_ref.clone();
86 let handle_id_exp = handle_id;
87
88 let ledger_check = self.persistence
90 .load_ledger(&agent_id_exp, None, 10_000)
91 .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
92 let challenger_disp = latest_disposition_from_ledger(&ledger_check, &challenger_ref_exp);
93
94 if challenger_disp == Some(Disposition::QueuedForAdjudication) {
95 let mut txn = self.persistence
97 .begin_atomic(&agent_id_exp)
98 .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
99
100 let expired_entry = mempill_types::LedgerEntry {
101 entry_id: uuid::Uuid::new_v4(),
102 agent_id: agent_id_exp.clone(),
103 claim_ref: challenger_ref_exp.clone(),
104 event_kind: LedgerEventKind::AdjudicationExpired,
105 disposition: Disposition::Contested,
106 rationale: Some(serde_json::json!({
107 "event": "adjudication_ttl_expired_lazy",
108 "handle_id": handle_id_exp.to_string(),
109 "expired_at": expires_at.to_rfc3339(),
110 "incumbent_claim_ref": row.incumbent_claim_ref.0.to_string(),
111 })),
112 recorded_at: tx_time.clone(),
113 };
114
115 match self.persistence.append_ledger_entry(&mut txn, &expired_entry) {
116 Ok(()) => {
117 if let Err(e) = self.persistence.commit(txn) {
118 return Err(MemError::Persistence { source: Box::new(e) });
119 }
120 let _ = self.pending_store.mark_expired_erased(handle_id_exp);
122 }
123 Err(e) => {
124 let _ = self.persistence.rollback(txn);
125 return Err(MemError::Persistence { source: Box::new(e) });
126 }
127 }
128 }
129
130 return Err(MemError::AdjudicationHandleNotFound { handle_id });
131 }
132 }
133
134 let agent_id: AgentId = row.agent_id.clone();
135 let challenger_ref = row.challenger_claim_ref.clone();
136 let incumbent_ref = row.incumbent_claim_ref.clone();
137
138 let ledger = self.persistence
142 .load_ledger(&agent_id, None, 10_000)
143 .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
144
145 let challenger_disp = latest_disposition_from_ledger(&ledger, &challenger_ref);
146
147 if challenger_disp != Some(Disposition::QueuedForAdjudication) {
153 return Err(MemError::AdjudicationHandleNotFound { handle_id });
154 }
155
156 let incumbent_edges = self.persistence
160 .load_edges_for(&agent_id, &incumbent_ref)
161 .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
162 let challenger_edges = self.persistence
163 .load_edges_for(&agent_id, &challenger_ref)
164 .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
165
166 let mut txn = self.persistence
168 .begin_atomic(&agent_id)
169 .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
170
171 let result = self.apply_verdict_within_txn(
172 &response.verdict,
173 &response.evidence_provenance,
174 &agent_id,
175 &challenger_ref,
176 &incumbent_ref,
177 tx_time.clone(),
178 &incumbent_edges,
179 &challenger_edges,
180 &mut txn,
181 );
182
183 match result {
184 Ok(()) => {
185 self.persistence
186 .commit(txn)
187 .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
188
189 self.pending_store
191 .mark_resolved_erased(handle_id)
192 .map_err(|e| MemError::PendingStore { source: e })?;
193
194 let (outcome_claim_ref, final_disposition) = match &response.verdict {
196 AdjudicationVerdict::Affirm => {
197 (challenger_ref, Disposition::CommittedCheap)
198 }
199 AdjudicationVerdict::Deny => {
200 (challenger_ref, Disposition::Superseded)
201 }
202 AdjudicationVerdict::Unknown => {
203 (challenger_ref, Disposition::Contested)
205 }
206 _ => (challenger_ref, Disposition::Contested),
208 };
209
210 Ok(AdjudicationOutcome {
211 handle_id,
212 disposition: final_disposition,
213 claim_ref: outcome_claim_ref,
214 })
215 }
216 Err(e) => {
217 let _ = self.persistence.rollback(txn);
218 Err(e)
219 }
220 }
221 }
222
223 #[allow(clippy::too_many_arguments)]
227 fn apply_verdict_within_txn(
228 &self,
229 verdict: &AdjudicationVerdict,
230 evidence_provenance: &mempill_types::ProvenanceLabel,
231 agent_id: &AgentId,
232 challenger_ref: &mempill_types::ClaimRef,
233 incumbent_ref: &mempill_types::ClaimRef,
234 tx_time: TransactionTime,
235 incumbent_edges: &[mempill_types::ClaimEdge],
236 challenger_edges: &[mempill_types::ClaimEdge],
237 txn: &mut P::Transaction,
238 ) -> Result<(), MemError> {
239 match verdict {
240 AdjudicationVerdict::Affirm => {
241 self.bound_claim(
244 agent_id,
245 incumbent_ref,
246 challenger_ref,
247 tx_time.clone(),
248 incumbent_edges,
249 txn,
250 )?;
251 let affirm_entry = LedgerEntry {
253 entry_id: uuid::Uuid::new_v4(),
254 agent_id: agent_id.clone(),
255 claim_ref: challenger_ref.clone(),
256 event_kind: LedgerEventKind::AdjudicationResolved,
257 disposition: Disposition::CommittedCheap,
258 rationale: Some(serde_json::json!({
259 "event": "oracle_affirm",
260 "verdict": "Affirm",
261 "evidence_provenance": serde_json::to_value(evidence_provenance).ok(),
262 })),
263 recorded_at: tx_time.clone(),
264 };
265 self.persistence
266 .append_ledger_entry(txn, &affirm_entry)
267 .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
268 Ok(())
269 }
270
271 AdjudicationVerdict::Deny => {
272 self.bound_claim(
275 agent_id,
276 challenger_ref,
277 incumbent_ref,
278 tx_time.clone(),
279 challenger_edges,
280 txn,
281 )?;
282 Ok(())
284 }
285
286 AdjudicationVerdict::Unknown => {
287 let rationale = serde_json::json!({
291 "event": "oracle_abstain",
292 "verdict": "Unknown",
293 });
294 let challenger_entry = LedgerEntry {
295 entry_id: uuid::Uuid::new_v4(),
296 agent_id: agent_id.clone(),
297 claim_ref: challenger_ref.clone(),
298 event_kind: LedgerEventKind::AdjudicationResolved,
299 disposition: Disposition::Contested,
300 rationale: Some(rationale.clone()),
301 recorded_at: tx_time.clone(),
302 };
303 self.persistence
304 .append_ledger_entry(txn, &challenger_entry)
305 .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
306
307 let incumbent_entry = LedgerEntry {
308 entry_id: uuid::Uuid::new_v4(),
309 agent_id: agent_id.clone(),
310 claim_ref: incumbent_ref.clone(),
311 event_kind: LedgerEventKind::AdjudicationResolved,
312 disposition: Disposition::Contested,
313 rationale: Some(rationale),
314 recorded_at: tx_time.clone(),
315 };
316 self.persistence
317 .append_ledger_entry(txn, &incumbent_entry)
318 .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
319 Ok(())
320 }
321 _ => Ok(()),
323 }
324 }
325
326 fn bound_claim(
332 &self,
333 agent_id: &AgentId,
334 target_ref: &mempill_types::ClaimRef,
335 overturning_ref: &mempill_types::ClaimRef,
336 tx_time: TransactionTime,
337 preloaded_edges: &[mempill_types::ClaimEdge],
338 txn: &mut P::Transaction,
339 ) -> Result<(), MemError> {
340 use mempill_types::{EdgeKind, ExternalKind, Confidence};
341
342 let assertion = ValidityAssertion {
344 assertion_ref: uuid::Uuid::new_v4(),
345 agent_id: agent_id.clone(),
346 target_claim: target_ref.clone(),
347 kind: AssertionKind::Bound { bound_at: tx_time.0 },
348 provenance: mempill_types::ProvenanceLabel::External(
349 ExternalKind::ExternalFirstHand,
350 ),
351 confidence: Confidence {
352 value_confidence: 1.0,
353 valid_time_confidence: 1.0,
354 },
355 asserted_at: tx_time.clone(),
356 };
357 self.persistence
358 .append_validity_assertion(txn, &assertion)
359 .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
360
361 let ledger_entry = LedgerEntry {
363 entry_id: uuid::Uuid::new_v4(),
364 agent_id: agent_id.clone(),
365 claim_ref: target_ref.clone(),
366 event_kind: LedgerEventKind::ValidityAsserted,
367 disposition: Disposition::Superseded,
368 rationale: Some(serde_json::json!({
369 "event": "oracle_supersession",
370 "overturning_claim": overturning_ref.0.to_string(),
371 "bound_at": tx_time.0.to_rfc3339(),
372 })),
373 recorded_at: tx_time.clone(),
374 };
375 self.persistence
376 .append_ledger_entry(txn, &ledger_entry)
377 .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
378
379 use std::collections::HashSet;
383 let mut seen: HashSet<mempill_types::ClaimRef> = HashSet::new();
384 for edge in preloaded_edges {
385 if edge.kind == EdgeKind::DependsOn && edge.to_claim == *target_ref {
386 if !seen.insert(edge.from_claim.clone()) {
387 continue;
388 }
389 let flag_entry = LedgerEntry {
390 entry_id: uuid::Uuid::new_v4(),
391 agent_id: agent_id.clone(),
392 claim_ref: edge.from_claim.clone(),
393 event_kind: LedgerEventKind::DependentFlaggedPendingReview,
394 disposition: Disposition::PendingReview,
395 rationale: Some(serde_json::json!({
396 "event": "depends_on_cascade",
397 "superseded_parent": target_ref.0.to_string(),
398 "overturning_claim": overturning_ref.0.to_string(),
399 })),
400 recorded_at: tx_time.clone(),
401 };
402 self.persistence
403 .append_ledger_entry(txn, &flag_entry)
404 .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
405 }
406 }
407 Ok(())
408 }
409}
410
411fn latest_disposition_from_ledger(
414 ledger: &[LedgerEntry],
415 target: &mempill_types::ClaimRef,
416) -> Option<Disposition> {
417 ledger
418 .iter()
419 .filter(|e| &e.claim_ref == target)
420 .max_by_key(|e| e.recorded_at.0)
421 .map(|e| e.disposition.clone())
422}
423
424#[cfg(test)]
427mod tests {
428 use super::*;
429 use crate::engine_handle::{ErasedPendingStore, ErasedPendingStoreAdapter};
430 use crate::ports::{
431 PendingAdjudicationPort, PendingAdjudicationRow, PersistencePort, Txn as TxnTrait,
432 };
433 use mempill_types::{
434 AgentId, AdjudicationRequest, AdjudicationResponse, AdjudicationVerdict,
435 Cardinality, Claim, ClaimEdge, ClaimRef, Confidence, Criticality, CurrencySignal,
436 CurrencyState, Disposition, ExternalAnchor, ExternalKind, Fact, LedgerEntry,
437 ProvenanceLabel, TransactionTime, ValidTime, ValidityAssertion,
438 };
439 use std::sync::Mutex;
440
441 struct MockTxn(AgentId);
444 impl TxnTrait for MockTxn {
445 fn agent_id(&self) -> &AgentId { &self.0 }
446 }
447
448 #[derive(Debug, thiserror::Error)]
451 #[error("mock error")]
452 struct MockErr;
453
454 #[derive(Default)]
457 struct MockStore {
458 claims: Mutex<Vec<Claim>>,
459 ledger: Mutex<Vec<LedgerEntry>>,
460 validity_assertions: Mutex<Vec<ValidityAssertion>>,
461 fail_on_ledger_write: Mutex<Option<usize>>,
463 ledger_write_count: Mutex<usize>,
464 rollback_called: Mutex<bool>,
465 }
466
467 impl PersistencePort for MockStore {
468 type Transaction = MockTxn;
469 type Error = MockErr;
470
471 fn begin_atomic(&self, agent_id: &AgentId) -> Result<MockTxn, MockErr> {
472 Ok(MockTxn(agent_id.clone()))
473 }
474
475 fn append_claim(&self, _: &mut MockTxn, claim: &Claim) -> Result<ClaimRef, MockErr> {
476 self.claims.lock().unwrap().push(claim.clone());
477 Ok(claim.claim_ref().clone())
478 }
479
480 fn append_validity_assertion(
481 &self,
482 _: &mut MockTxn,
483 a: &ValidityAssertion,
484 ) -> Result<(), MockErr> {
485 self.validity_assertions.lock().unwrap().push(a.clone());
486 Ok(())
487 }
488
489 fn append_ledger_entry(
490 &self,
491 _: &mut MockTxn,
492 e: &LedgerEntry,
493 ) -> Result<(), MockErr> {
494 let mut count = self.ledger_write_count.lock().unwrap();
495 *count += 1;
496 let fail_on = *self.fail_on_ledger_write.lock().unwrap();
497 if fail_on == Some(*count) {
498 return Err(MockErr);
499 }
500 self.ledger.lock().unwrap().push(e.clone());
501 Ok(())
502 }
503
504 fn append_claim_edge(&self, _: &mut MockTxn, _: &ClaimEdge) -> Result<(), MockErr> {
505 Ok(())
506 }
507
508 fn commit(&self, _: MockTxn) -> Result<(), MockErr> { Ok(()) }
509
510 fn rollback(&self, _: MockTxn) -> Result<(), MockErr> {
511 *self.rollback_called.lock().unwrap() = true;
512 Ok(())
513 }
514
515 fn load_subject_line(&self, _: &AgentId, _: &str, _: &str, _as_of_tx_time: Option<chrono::DateTime<chrono::Utc>>) -> Result<Vec<Claim>, MockErr> {
516 Ok(self.claims.lock().unwrap().clone())
517 }
518
519 fn load_claim(&self, _: &AgentId, r: &ClaimRef) -> Result<Option<Claim>, MockErr> {
520 Ok(self.claims.lock().unwrap().iter().find(|c| c.claim_ref() == r).cloned())
521 }
522
523 fn load_validity_assertions_for(&self, _: &AgentId, _: &ClaimRef) -> Result<Vec<ValidityAssertion>, MockErr> {
524 Ok(vec![])
525 }
526
527 fn load_ledger(&self, _: &AgentId, _: Option<&TransactionTime>, _: usize) -> Result<Vec<LedgerEntry>, MockErr> {
528 Ok(self.ledger.lock().unwrap().clone())
529 }
530
531 fn load_ledger_for_claims(&self, _: &AgentId, _refs: &[ClaimRef], _as_of: Option<chrono::DateTime<chrono::Utc>>) -> Result<Vec<LedgerEntry>, MockErr> {
532 Ok(vec![])
533 }
534
535 fn load_edges_for(&self, _: &AgentId, _: &ClaimRef) -> Result<Vec<ClaimEdge>, MockErr> {
536 Ok(vec![])
537 }
538
539 fn load_injected_claims(&self, _: &AgentId) -> Result<Vec<ClaimRef>, MockErr> { Ok(vec![]) }
540
541 fn load_lineage(&self, _: &AgentId, _: &ClaimRef) -> Result<Vec<ClaimEdge>, MockErr> { Ok(vec![]) }
542
543 fn list_predicates_for_subject(&self, _: &AgentId, _: &str, _: Option<chrono::DateTime<chrono::Utc>>) -> Result<Vec<String>, MockErr> { Ok(vec![]) }
544 }
545
546 #[derive(Default)]
549 struct MockPendingStore {
550 rows: Mutex<Vec<PendingAdjudicationRow>>,
551 }
552
553 impl MockPendingStore {
554 fn seed(&self, row: PendingAdjudicationRow) {
555 self.rows.lock().unwrap().push(row);
556 }
557
558 fn is_resolved(&self, handle_id: uuid::Uuid) -> bool {
559 self.rows.lock().unwrap().iter()
560 .any(|r| r.handle_id == handle_id && r.status == "resolved")
561 }
562 }
563
564 impl PendingAdjudicationPort for MockPendingStore {
565 type Error = MockErr;
566
567 fn insert_pending(&self, row: &PendingAdjudicationRow) -> Result<(), MockErr> {
568 self.rows.lock().unwrap().push(row.clone());
569 Ok(())
570 }
571
572 fn get_pending(&self, handle_id: uuid::Uuid) -> Result<Option<PendingAdjudicationRow>, MockErr> {
573 Ok(self.rows.lock().unwrap().iter().find(|r| r.handle_id == handle_id).cloned())
574 }
575
576 fn list_pending(&self, agent_id: Option<&AgentId>) -> Result<Vec<PendingAdjudicationRow>, MockErr> {
577 Ok(self.rows.lock().unwrap().iter()
578 .filter(|r| agent_id.is_none_or(|a| r.agent_id == *a) && r.status == "pending")
579 .cloned()
580 .collect())
581 }
582
583 fn list_expired(&self, now: chrono::DateTime<Utc>) -> Result<Vec<PendingAdjudicationRow>, MockErr> {
584 Ok(self.rows.lock().unwrap().iter()
585 .filter(|r| r.status == "pending" && r.expires_at.is_some_and(|e| e <= now))
586 .cloned()
587 .collect())
588 }
589
590 fn mark_resolved(&self, handle_id: uuid::Uuid) -> Result<(), MockErr> {
591 for r in self.rows.lock().unwrap().iter_mut() {
592 if r.handle_id == handle_id {
593 r.status = "resolved".to_string();
594 }
595 }
596 Ok(())
597 }
598
599 fn mark_expired(&self, handle_id: uuid::Uuid) -> Result<(), MockErr> {
600 for r in self.rows.lock().unwrap().iter_mut() {
601 if r.handle_id == handle_id {
602 r.status = "expired".to_string();
603 }
604 }
605 Ok(())
606 }
607
608 fn list_queued_orphan_claims(&self) -> Result<Vec<crate::ports::pending_adjudication::OrphanedQueuedClaim>, MockErr> {
609 Ok(vec![])
610 }
611 }
612
613 fn make_agent() -> AgentId { AgentId("test-agent".into()) }
616
617 fn make_claim(agent: &AgentId) -> Claim {
618 Claim::new(
619 ClaimRef::new_random(),
620 agent.clone(),
621 Fact { subject: "user".into(), predicate: "city".into(), value: serde_json::json!("Berlin") },
622 Cardinality::Functional,
623 ProvenanceLabel::External(ExternalKind::UserAsserted),
624 ExternalAnchor { nearest_external_anchor: None, derivation_depth: 0 },
625 TransactionTime(Utc::now()),
626 ValidTime { start: None, end: None, valid_time_confidence: 0.0 , start_granularity: None, end_granularity: None},
627 Confidence { value_confidence: 0.9, valid_time_confidence: 0.0 },
628 Criticality::Medium,
629 vec![],
630 None,
631 None,
632 )
633 }
634
635 fn make_dummy_adj_request(agent: &AgentId) -> AdjudicationRequest {
636 AdjudicationRequest {
637 subject_line: mempill_types::SubjectLineRef {
638 agent_id: agent.clone(),
639 subject: "user".into(),
640 predicate: "city".into(),
641 },
642 incumbent: mempill_types::Belief {
643 claim_ref: ClaimRef::new_random(),
644 fact: Fact { subject: "user".into(), predicate: "city".into(), value: serde_json::json!("Berlin") },
645 provenance: ProvenanceLabel::External(ExternalKind::UserAsserted),
646 valid_time: ValidTime { start: None, end: None, valid_time_confidence: 0.0 , start_granularity: None, end_granularity: None},
647 transaction_time: TransactionTime(Utc::now()),
648 confidence: Confidence { value_confidence: 0.9, valid_time_confidence: 0.0 },
649 currency_signal: CurrencySignal {
650 last_refreshed_at: TransactionTime(Utc::now()),
651 state: CurrencyState::Fresh,
652 corroboration_count: 0,
653 },
654 criticality: Criticality::Medium,
655 },
656 challenger: make_claim(agent),
657 criticality: Criticality::Medium,
658 reason: mempill_types::OverturnReason::ExternalContradiction,
659 }
660 }
661
662 fn setup_queued_scenario(
664 store: &MockStore,
665 pending: &MockPendingStore,
666 handle_id: uuid::Uuid,
667 ) -> (ClaimRef, ClaimRef) {
668 let agent = make_agent();
669 let challenger = make_claim(&agent);
670 let incumbent = make_claim(&agent);
671 let now = Utc::now();
672
673 store.ledger.lock().unwrap().push(LedgerEntry {
676 entry_id: uuid::Uuid::new_v4(),
677 agent_id: agent.clone(),
678 claim_ref: challenger.claim_ref().clone(),
679 event_kind: LedgerEventKind::ClaimCommitted,
680 disposition: Disposition::QueuedForAdjudication,
681 rationale: None,
682 recorded_at: TransactionTime(now - chrono::Duration::seconds(5)),
683 });
684 store.ledger.lock().unwrap().push(LedgerEntry {
685 entry_id: uuid::Uuid::new_v4(),
686 agent_id: agent.clone(),
687 claim_ref: incumbent.claim_ref().clone(),
688 event_kind: LedgerEventKind::ClaimCommitted,
689 disposition: Disposition::CommittedCheap, rationale: None,
691 recorded_at: TransactionTime(now - chrono::Duration::seconds(10)),
692 });
693
694 pending.seed(PendingAdjudicationRow {
696 handle_id,
697 agent_id: agent.clone(),
698 subject: "user".into(),
699 predicate: "city".into(),
700 challenger_claim_ref: challenger.claim_ref().clone(),
701 incumbent_claim_ref: incumbent.claim_ref().clone(),
702 request_payload: make_dummy_adj_request(&agent),
703 queued_at: now - chrono::Duration::seconds(10),
704 expires_at: None,
705 status: "pending".to_string(),
706 });
707
708 (challenger.claim_ref().clone(), incumbent.claim_ref().clone())
709 }
710
711 fn build_use_case(
712 store: Arc<MockStore>,
713 pending: Arc<MockPendingStore>,
714 ) -> SubmitAdjudicationUseCase<MockStore> {
715 let erased: Arc<dyn ErasedPendingStore> =
716 Arc::new(ErasedPendingStoreAdapter::new({
717 struct Delegate(Arc<MockPendingStore>);
718 impl PendingAdjudicationPort for Delegate {
719 type Error = MockErr;
720 fn insert_pending(&self, r: &PendingAdjudicationRow) -> Result<(), MockErr> { self.0.insert_pending(r) }
721 fn get_pending(&self, h: uuid::Uuid) -> Result<Option<PendingAdjudicationRow>, MockErr> { self.0.get_pending(h) }
722 fn list_pending(&self, a: Option<&AgentId>) -> Result<Vec<PendingAdjudicationRow>, MockErr> { self.0.list_pending(a) }
723 fn list_expired(&self, n: chrono::DateTime<Utc>) -> Result<Vec<PendingAdjudicationRow>, MockErr> { self.0.list_expired(n) }
724 fn mark_resolved(&self, h: uuid::Uuid) -> Result<(), MockErr> { self.0.mark_resolved(h) }
725 fn mark_expired(&self, h: uuid::Uuid) -> Result<(), MockErr> { self.0.mark_expired(h) }
726 fn list_queued_orphan_claims(&self) -> Result<Vec<crate::ports::pending_adjudication::OrphanedQueuedClaim>, MockErr> { self.0.list_queued_orphan_claims() }
727 }
728 Delegate(Arc::clone(&pending))
729 }));
730 SubmitAdjudicationUseCase::new(store, erased)
731 }
732
733 #[test]
736 fn unknown_handle_returns_handle_not_found() {
737 let store = Arc::new(MockStore::default());
738 let pending = Arc::new(MockPendingStore::default());
739 let uc = build_use_case(Arc::clone(&store), Arc::clone(&pending));
740
741 let response = AdjudicationResponse {
742 handle_id: uuid::Uuid::new_v4(),
743 verdict: AdjudicationVerdict::Affirm,
744 evidence_provenance: ProvenanceLabel::External(ExternalKind::ExternalFirstHand),
745 };
746 let result = uc.execute(response.handle_id, response, Utc::now());
747 assert!(matches!(result, Err(MemError::AdjudicationHandleNotFound { .. })));
748 }
749
750 #[test]
753 fn affirm_challenger_committed_cheap_incumbent_superseded_two_ledger_entries() {
754 let store = Arc::new(MockStore::default());
755 let pending = Arc::new(MockPendingStore::default());
756 let handle_id = uuid::Uuid::new_v4();
757 let (challenger_ref, incumbent_ref) =
758 setup_queued_scenario(&store, &pending, handle_id);
759
760 let uc = build_use_case(Arc::clone(&store), Arc::clone(&pending));
761 let response = AdjudicationResponse {
762 handle_id,
763 verdict: AdjudicationVerdict::Affirm,
764 evidence_provenance: ProvenanceLabel::External(ExternalKind::ExternalFirstHand),
765 };
766 let outcome = uc.execute(handle_id, response, Utc::now()).unwrap();
767
768 assert_eq!(outcome.handle_id, handle_id);
769 assert_eq!(outcome.disposition, Disposition::CommittedCheap);
770 assert_eq!(outcome.claim_ref, challenger_ref);
771
772 let ledger = store.ledger.lock().unwrap();
773 let resolution_entries: Vec<_> = ledger.iter()
775 .filter(|e| e.event_kind == LedgerEventKind::AdjudicationResolved
776 || e.event_kind == LedgerEventKind::ValidityAsserted)
777 .collect();
778 assert_eq!(resolution_entries.len(), 2, "Affirm must write exactly 2 ledger entries");
779
780 let challenger_entry = resolution_entries.iter()
782 .find(|e| e.claim_ref == challenger_ref && e.event_kind == LedgerEventKind::AdjudicationResolved)
783 .expect("challenger AdjudicationResolved entry must exist");
784 assert_eq!(challenger_entry.disposition, Disposition::CommittedCheap);
785
786 let incumbent_entry = resolution_entries.iter()
788 .find(|e| e.claim_ref == incumbent_ref)
789 .expect("incumbent ValidityAsserted entry must exist");
790 assert_eq!(incumbent_entry.disposition, Disposition::Superseded);
791
792 let assertions = store.validity_assertions.lock().unwrap();
794 assert_eq!(assertions.len(), 1, "one Bound assertion for incumbent");
795 assert_eq!(assertions[0].target_claim, incumbent_ref);
796
797 assert!(pending.is_resolved(handle_id), "pending row must be resolved");
799 }
800
801 #[test]
804 fn affirm_challenger_entry_has_external_provenance_in_rationale() {
805 let store = Arc::new(MockStore::default());
806 let pending = Arc::new(MockPendingStore::default());
807 let handle_id = uuid::Uuid::new_v4();
808 setup_queued_scenario(&store, &pending, handle_id);
809
810 let uc = build_use_case(Arc::clone(&store), Arc::clone(&pending));
811 let evidence = ProvenanceLabel::External(ExternalKind::ExternalFirstHand);
812 let response = AdjudicationResponse {
813 handle_id,
814 verdict: AdjudicationVerdict::Affirm,
815 evidence_provenance: evidence.clone(),
816 };
817 uc.execute(handle_id, response, Utc::now()).unwrap();
818
819 let ledger = store.ledger.lock().unwrap();
820 let affirm_entry = ledger.iter()
821 .find(|e| e.event_kind == LedgerEventKind::AdjudicationResolved
822 && e.disposition == Disposition::CommittedCheap)
823 .expect("affirm ledger entry must exist");
824 let rationale = affirm_entry.rationale.as_ref().expect("rationale must be present");
825 let rationale_str = rationale.to_string();
826 assert!(rationale_str.contains("Affirm"), "rationale must mention Affirm verdict");
827 assert!(rationale_str.contains("ExternalFirstHand"), "rationale must include evidence provenance");
828 }
829
830 #[test]
833 fn deny_challenger_superseded_one_ledger_entry() {
834 let store = Arc::new(MockStore::default());
835 let pending = Arc::new(MockPendingStore::default());
836 let handle_id = uuid::Uuid::new_v4();
837 let (challenger_ref, incumbent_ref) =
838 setup_queued_scenario(&store, &pending, handle_id);
839
840 let uc = build_use_case(Arc::clone(&store), Arc::clone(&pending));
841 let response = AdjudicationResponse {
842 handle_id,
843 verdict: AdjudicationVerdict::Deny,
844 evidence_provenance: ProvenanceLabel::External(ExternalKind::ExternalFirstHand),
845 };
846 let outcome = uc.execute(handle_id, response, Utc::now()).unwrap();
847
848 assert_eq!(outcome.disposition, Disposition::Superseded);
849 assert_eq!(outcome.claim_ref, challenger_ref);
850
851 let ledger = store.ledger.lock().unwrap();
852 let resolution_entries: Vec<_> = ledger.iter()
853 .filter(|e| e.event_kind == LedgerEventKind::ValidityAsserted)
854 .collect();
855 assert_eq!(resolution_entries.len(), 1, "Deny must write exactly 1 ValidityAsserted entry");
856 assert_eq!(resolution_entries[0].claim_ref, challenger_ref);
857 assert_eq!(resolution_entries[0].disposition, Disposition::Superseded);
858
859 let assertions = store.validity_assertions.lock().unwrap();
861 assert_eq!(assertions.len(), 1, "one Bound assertion for challenger");
862 assert_eq!(assertions[0].target_claim, challenger_ref);
863
864 let incumbent_resolution = ledger.iter()
866 .filter(|e| e.claim_ref == incumbent_ref
867 && (e.event_kind == LedgerEventKind::AdjudicationResolved
868 || e.event_kind == LedgerEventKind::ValidityAsserted))
869 .count();
870 assert_eq!(incumbent_resolution, 0, "Deny must not touch the incumbent");
871
872 assert!(pending.is_resolved(handle_id));
873 }
874
875 #[test]
878 fn unknown_both_contested_two_ledger_entries_no_bound_assertion() {
879 let store = Arc::new(MockStore::default());
880 let pending = Arc::new(MockPendingStore::default());
881 let handle_id = uuid::Uuid::new_v4();
882 let (challenger_ref, incumbent_ref) =
883 setup_queued_scenario(&store, &pending, handle_id);
884
885 let uc = build_use_case(Arc::clone(&store), Arc::clone(&pending));
886 let response = AdjudicationResponse {
887 handle_id,
888 verdict: AdjudicationVerdict::Unknown,
889 evidence_provenance: ProvenanceLabel::External(ExternalKind::ExternalFirstHand),
890 };
891 let outcome = uc.execute(handle_id, response, Utc::now()).unwrap();
892
893 assert_eq!(outcome.disposition, Disposition::Contested);
894 assert_eq!(outcome.claim_ref, challenger_ref);
895
896 let ledger = store.ledger.lock().unwrap();
897 let abstain_entries: Vec<_> = ledger.iter()
898 .filter(|e| e.event_kind == LedgerEventKind::AdjudicationResolved)
899 .collect();
900 assert_eq!(abstain_entries.len(), 2, "Unknown must write 2 AdjudicationResolved entries (one per claim)");
901
902 let ch_entry = abstain_entries.iter().find(|e| e.claim_ref == challenger_ref).unwrap();
903 let inc_entry = abstain_entries.iter().find(|e| e.claim_ref == incumbent_ref).unwrap();
904 assert_eq!(ch_entry.disposition, Disposition::Contested);
905 assert_eq!(inc_entry.disposition, Disposition::Contested);
906
907 let assertions = store.validity_assertions.lock().unwrap();
909 assert_eq!(assertions.len(), 0, "Unknown must not write any Bound assertions");
910
911 assert!(pending.is_resolved(handle_id));
912 }
913
914 #[test]
917 fn duplicate_submit_returns_handle_not_found() {
918 let store = Arc::new(MockStore::default());
919 let pending = Arc::new(MockPendingStore::default());
920 let handle_id = uuid::Uuid::new_v4();
921 setup_queued_scenario(&store, &pending, handle_id);
922
923 let uc = build_use_case(Arc::clone(&store), Arc::clone(&pending));
924 let mk_response = || AdjudicationResponse {
925 handle_id,
926 verdict: AdjudicationVerdict::Deny,
927 evidence_provenance: ProvenanceLabel::External(ExternalKind::ExternalFirstHand),
928 };
929
930 uc.execute(handle_id, mk_response(), Utc::now()).unwrap();
932
933 let result = uc.execute(handle_id, mk_response(), Utc::now());
936 assert!(
937 matches!(result, Err(MemError::AdjudicationHandleNotFound { .. })),
938 "duplicate submit must return AdjudicationHandleNotFound"
939 );
940 }
941
942 #[test]
949 fn stale_challenger_not_queued_returns_handle_not_found() {
950 let store = Arc::new(MockStore::default());
951 let pending = Arc::new(MockPendingStore::default());
952 let handle_id = uuid::Uuid::new_v4();
953 let agent = make_agent();
954 let challenger = make_claim(&agent);
955 let incumbent = make_claim(&agent);
956 let now = Utc::now();
957
958 store.ledger.lock().unwrap().push(LedgerEntry {
960 entry_id: uuid::Uuid::new_v4(),
961 agent_id: agent.clone(),
962 claim_ref: challenger.claim_ref().clone(),
963 event_kind: LedgerEventKind::ClaimCommitted,
964 disposition: Disposition::CommittedCheap, rationale: None,
966 recorded_at: TransactionTime(now),
967 });
968 store.ledger.lock().unwrap().push(LedgerEntry {
970 entry_id: uuid::Uuid::new_v4(),
971 agent_id: agent.clone(),
972 claim_ref: incumbent.claim_ref().clone(),
973 event_kind: LedgerEventKind::ClaimCommitted,
974 disposition: Disposition::CommittedCheap,
975 rationale: None,
976 recorded_at: TransactionTime(now),
977 });
978
979 pending.seed(PendingAdjudicationRow {
980 handle_id,
981 agent_id: agent.clone(),
982 subject: "user".into(),
983 predicate: "city".into(),
984 challenger_claim_ref: challenger.claim_ref().clone(),
985 incumbent_claim_ref: incumbent.claim_ref().clone(),
986 request_payload: make_dummy_adj_request(&agent),
987 queued_at: now,
988 expires_at: None,
989 status: "pending".to_string(),
990 });
991
992 let uc = build_use_case(Arc::clone(&store), Arc::clone(&pending));
993 let response = AdjudicationResponse {
994 handle_id,
995 verdict: AdjudicationVerdict::Affirm,
996 evidence_provenance: ProvenanceLabel::External(ExternalKind::ExternalFirstHand),
997 };
998 let result = uc.execute(handle_id, response, Utc::now());
999 assert!(
1000 matches!(result, Err(MemError::AdjudicationHandleNotFound { .. })),
1001 "stale challenger (not QueuedForAdjudication) must return AdjudicationHandleNotFound"
1002 );
1003 }
1004
1005 #[test]
1008 fn expired_handle_returns_handle_not_found() {
1009 let store = Arc::new(MockStore::default());
1010 let pending = Arc::new(MockPendingStore::default());
1011 let handle_id = uuid::Uuid::new_v4();
1012 let agent = make_agent();
1013 let challenger = make_claim(&agent);
1014 let incumbent = make_claim(&agent);
1015 let past = Utc::now() - chrono::Duration::hours(2);
1016
1017 pending.seed(PendingAdjudicationRow {
1019 handle_id,
1020 agent_id: agent.clone(),
1021 subject: "user".into(),
1022 predicate: "city".into(),
1023 challenger_claim_ref: challenger.claim_ref().clone(),
1024 incumbent_claim_ref: incumbent.claim_ref().clone(),
1025 request_payload: make_dummy_adj_request(&agent),
1026 queued_at: past - chrono::Duration::hours(1),
1027 expires_at: Some(past), status: "pending".to_string(),
1029 });
1030
1031 let uc = build_use_case(Arc::clone(&store), Arc::clone(&pending));
1032 let response = AdjudicationResponse {
1033 handle_id,
1034 verdict: AdjudicationVerdict::Affirm,
1035 evidence_provenance: ProvenanceLabel::External(ExternalKind::ExternalFirstHand),
1036 };
1037 let result = uc.execute(handle_id, response, Utc::now());
1038 assert!(
1039 matches!(result, Err(MemError::AdjudicationHandleNotFound { .. })),
1040 "expired handle must return AdjudicationHandleNotFound"
1041 );
1042 }
1043
1044 #[test]
1047 fn atomicity_failure_mid_apply_no_partial_state() {
1048 let store = Arc::new(MockStore::default());
1049 let pending = Arc::new(MockPendingStore::default());
1050 let handle_id = uuid::Uuid::new_v4();
1051 setup_queued_scenario(&store, &pending, handle_id);
1052
1053 *store.fail_on_ledger_write.lock().unwrap() = Some(1);
1055
1056 let uc = build_use_case(Arc::clone(&store), Arc::clone(&pending));
1057 let response = AdjudicationResponse {
1058 handle_id,
1059 verdict: AdjudicationVerdict::Affirm,
1060 evidence_provenance: ProvenanceLabel::External(ExternalKind::ExternalFirstHand),
1061 };
1062 let result = uc.execute(handle_id, response, Utc::now());
1063 assert!(result.is_err(), "must propagate the injected failure");
1064
1065 let ledger = store.ledger.lock().unwrap();
1067 let resolution_entries: Vec<_> = ledger.iter()
1068 .filter(|e| e.event_kind == LedgerEventKind::AdjudicationResolved
1069 || e.event_kind == LedgerEventKind::ValidityAsserted)
1070 .collect();
1071 assert_eq!(
1072 resolution_entries.len(), 0,
1073 "no resolution ledger entries must remain after mid-apply failure"
1074 );
1075 assert!(*store.rollback_called.lock().unwrap(), "rollback must be called");
1076 }
1077}