mempill_core/ports/persistence.rs
1#![allow(missing_docs)]
2//! PersistencePort — INSERT-only, agent_id-first persistence abstraction.
3//!
4//! All methods take `agent_id` as the primary parameter (not a filter).
5//! Must enforce: single-writer per agent_id; append-only; atomic commit unit.
6
7use mempill_types::{
8 AgentId, Claim, ClaimEdge, ClaimRef, LedgerEntry, TransactionTime, ValidityAssertion,
9};
10
11/// An opaque transaction handle scoped to exactly one agent_id.
12/// No cross-agent transaction is possible (atomic commit unit is per-agent_id).
13pub trait Txn: Send + 'static {
14 fn agent_id(&self) -> &AgentId;
15}
16
17/// The persistence port — INSERT-only, agent_id-first.
18/// All methods take `agent_id` as the primary parameter (not a filter).
19/// Must enforce: single-writer per agent_id; append-only; atomic commit unit.
20pub trait PersistencePort: Send + Sync + 'static {
21 type Transaction: Txn;
22 type Error: std::error::Error + Send + Sync + 'static;
23
24 /// Begin an atomic unit scoped to one agent_id. No cross-agent transaction allowed.
25 fn begin_atomic(&self, agent_id: &AgentId) -> Result<Self::Transaction, Self::Error>;
26
27 fn append_claim(
28 &self,
29 txn: &mut Self::Transaction,
30 claim: &Claim,
31 ) -> Result<ClaimRef, Self::Error>;
32
33 fn append_validity_assertion(
34 &self,
35 txn: &mut Self::Transaction,
36 assertion: &ValidityAssertion,
37 ) -> Result<(), Self::Error>;
38
39 fn append_ledger_entry(
40 &self,
41 txn: &mut Self::Transaction,
42 entry: &LedgerEntry,
43 ) -> Result<(), Self::Error>;
44
45 fn append_claim_edge(
46 &self,
47 txn: &mut Self::Transaction,
48 edge: &ClaimEdge,
49 ) -> Result<(), Self::Error>;
50
51 fn commit(&self, txn: Self::Transaction) -> Result<(), Self::Error>;
52 fn rollback(&self, txn: Self::Transaction) -> Result<(), Self::Error>;
53
54 // ── Read operations (non-mutating w.r.t. belief and history — I1, I3) ──
55
56 /// Load all claims on the given (agent_id, subject, predicate) subject-line,
57 /// ordered by `tx_time ASC` (oldest first — callers fold in tx_time order).
58 ///
59 /// # Transaction-time cutoff (`as_of_tx_time`)
60 ///
61 /// When `as_of_tx_time` is `Some(T)`, only claims with `transaction_time <= T`
62 /// are returned. This enforces correct bi-temporal tx-time semantics: a claim
63 /// ingested after the query's as-of point did not exist at that point and must
64 /// not be visible to the fold.
65 ///
66 /// When `as_of_tx_time` is `None`, all claims are returned (current view). Use
67 /// `None` on all **write-path** callers (ingest, reconcile, adjudication) so
68 /// that incumbent detection always sees the full current state; passing `Some`
69 /// there would break conflict detection and succession.
70 fn load_subject_line(
71 &self,
72 agent_id: &AgentId,
73 subject: &str,
74 predicate: &str,
75 as_of_tx_time: Option<chrono::DateTime<chrono::Utc>>,
76 ) -> Result<Vec<Claim>, Self::Error>;
77
78 fn load_claim(
79 &self,
80 agent_id: &AgentId,
81 claim_ref: &ClaimRef,
82 ) -> Result<Option<Claim>, Self::Error>;
83
84 fn load_validity_assertions_for(
85 &self,
86 agent_id: &AgentId,
87 claim_ref: &ClaimRef,
88 ) -> Result<Vec<ValidityAssertion>, Self::Error>;
89
90 fn load_ledger(
91 &self,
92 agent_id: &AgentId,
93 from: Option<&TransactionTime>,
94 limit: usize,
95 ) -> Result<Vec<LedgerEntry>, Self::Error>;
96
97 /// Load ALL ledger entries for the given claim refs, with no row cap.
98 ///
99 /// Intended for the read path (query_memory / query_history): builds the
100 /// disposition map scoped to exactly the claims on a subject-line, avoiding
101 /// the agent-wide capped scan that caused silent wrong-belief at scale.
102 ///
103 /// # Empty input
104 ///
105 /// When `claim_refs` is empty this method MUST return `Ok(vec![])` immediately
106 /// without issuing any SQL (an empty `IN ()` clause is a syntax error on most
107 /// backends).
108 ///
109 /// # No row cap
110 ///
111 /// Unlike `load_ledger`, this method applies no `LIMIT`. Subject-lines are
112 /// small (typically 1–100 claims), so the result set is bounded naturally.
113 ///
114 /// # Transaction-time cutoff (`as_of_tx_time`)
115 ///
116 /// When `as_of_tx_time` is `Some(T)`, only ledger entries with
117 /// `recorded_at <= T` are returned. This is required for correct bi-temporal
118 /// tx-time travel: the disposition map must not see entries recorded after the
119 /// query's as-of point, otherwise a post-T supersession would incorrectly exclude
120 /// a claim that was still live at T.
121 ///
122 /// When `as_of_tx_time` is `None`, all entries are returned (preserves the
123 /// current behaviour for callers that want the full history or the latest view).
124 fn load_ledger_for_claims(
125 &self,
126 agent_id: &AgentId,
127 claim_refs: &[ClaimRef],
128 as_of_tx_time: Option<chrono::DateTime<chrono::Utc>>,
129 ) -> Result<Vec<LedgerEntry>, Self::Error>;
130
131 fn load_edges_for(
132 &self,
133 agent_id: &AgentId,
134 claim_ref: &ClaimRef,
135 ) -> Result<Vec<ClaimEdge>, Self::Error>;
136
137 /// Load the set of claims this agent served as injected context in the current session (for Amplification Guard entailment check).
138 fn load_injected_claims(
139 &self,
140 agent_id: &AgentId,
141 ) -> Result<Vec<ClaimRef>, Self::Error>;
142
143 /// Recursive CTE lineage traversal — returns the full `DerivedFrom` ancestry for a claim.
144 fn load_lineage(
145 &self,
146 agent_id: &AgentId,
147 claim_ref: &ClaimRef,
148 ) -> Result<Vec<ClaimEdge>, Self::Error>;
149
150 /// Return all distinct predicates stored for `(agent_id, subject)`.
151 ///
152 /// When `as_of_tx_time` is `Some(T)`, only predicates that have at least one claim
153 /// with `tx_time <= T` are returned (bi-temporal tx-time cutoff). When `None`, all
154 /// predicates are returned (current view).
155 ///
156 /// The returned `Vec<String>` is NOT guaranteed to be sorted — the caller sorts if needed.
157 /// The result set is small (bounded by the number of distinct predicates per subject),
158 /// so no pagination is provided.
159 fn list_predicates_for_subject(
160 &self,
161 agent_id: &AgentId,
162 subject: &str,
163 as_of_tx_time: Option<chrono::DateTime<chrono::Utc>>,
164 ) -> Result<Vec<String>, Self::Error>;
165
166 /// Whether the store requires a global write serialization lock across all agent_ids.
167 ///
168 /// SQLite: true (single connection, no concurrent transactions possible).
169 /// Postgres: false (pool provides concurrent transactions; advisory lock per agent_id).
170 ///
171 /// EngineHandle consults this at write-path entry to decide whether to acquire
172 /// `store_write_lock`. Default = true (safe fallback for unknown adapters).
173 fn requires_global_write_serialization(&self) -> bool {
174 true
175 }
176}