rag_rat_sync/store.rs
1//! The op-log-backed [`SyncStore`] (phase D, #406).
2//!
3//! Adapts the phase-C ingest and read seams to the transport's [`SyncStore`] trait: entries offered
4//! to a peer come from [`account_entries_for_sync`], and entries received from a peer go straight
5//! into [`account_ingest`], which re-verifies signature, canonicity, and chain continuity. The
6//! transport therefore adds no trust — a synced entry passes exactly the checks a local write does.
7
8use rag_rat_oplog::{
9 AccountId, ContentIngestOutcome, IngestOutcome, account_entries_for_sync, account_entry_ref,
10 account_ingest, account_signed_entry_exists, account_signed_hash, content_entries_for_sync,
11 content_entry_ref, content_ingest, content_signed_entry_exists, content_signed_hash,
12 sign_local_node_binding, verify_node_binding,
13};
14use rusqlite::Connection;
15
16use crate::auth::NodeAuth;
17use crate::session::{Ingested, SyncStore};
18
19/// Mint this account's signed node binding for `local_node`. A store with no local device yet (a
20/// fresh peer being onboarded) has nothing to prove, so it returns an EMPTY binding rather than
21/// failing: an `Open` peer ignores it, while a `Closed` peer fails to verify it (an empty binding
22/// decodes to nothing) and correctly refuses — onboarding a not-yet-roster device is the deferred
23/// invite-token flow, not something `Closed` admits. Shared by both op-log stores — the binding is
24/// account-level, identical whether the session moves account entries or content.
25fn sign_binding(
26 conn: &Connection,
27 account_id: AccountId,
28 local_node: &[u8; 32],
29 now_ms: i64,
30) -> anyhow::Result<Vec<u8>> {
31 match sign_local_node_binding(conn, account_id, local_node, now_ms)? {
32 Ok(bytes) => Ok(bytes),
33 // No local device to sign with — send an anonymous (empty) binding. Never authorizes under
34 // `Closed`; harmless under `Open`.
35 Err(_no_local_device) => Ok(Vec::new()),
36 }
37}
38
39/// Whether a peer's binding authorizes it for `account_id`, given its authenticated `remote_node`.
40/// Collapses the internal failure taxonomy to a bool so the transport's wire refusal stays uniform;
41/// a `?`-propagated error here is a real DB fault, not a rejected peer.
42fn authorize_binding(
43 conn: &Connection,
44 account_id: AccountId,
45 binding: &[u8],
46 remote_node: &[u8; 32],
47 now_ms: i64,
48) -> anyhow::Result<bool> {
49 Ok(verify_node_binding(conn, account_id, binding, remote_node, now_ms)?.is_ok())
50}
51
52/// A [`SyncStore`] over one account's op log on a live connection. Scoped to a single account: a
53/// session syncs one account, and the hello handshake refuses a peer naming a different one.
54pub struct OplogSyncStore<'a> {
55 conn: &'a Connection,
56 account_id: AccountId,
57 now_ms: i64,
58}
59
60impl<'a> OplogSyncStore<'a> {
61 pub fn new(conn: &'a Connection, account_id: AccountId, now_ms: i64) -> Self {
62 Self { conn, account_id, now_ms }
63 }
64}
65
66impl SyncStore for OplogSyncStore<'_> {
67 fn account_id(&self) -> [u8; 32] {
68 self.account_id.to_bytes()
69 }
70
71 fn snapshot(&self) -> anyhow::Result<Vec<([u8; 32], Vec<u8>)>> {
72 // Key by the SIGNED-envelope hash, not `entry_hash`: two envelopes can share an entry_hash
73 // but differ in signature (pre-verify keeps competing signatures for exactly this reason),
74 // and diffing by entry_hash would let a peer holding the valid signature suppress it.
75 Ok(account_entries_for_sync(self.conn, self.account_id)?
76 .into_iter()
77 .map(|e| (account_signed_hash(&e.signed_bytes), e.signed_bytes))
78 .collect())
79 }
80
81 fn ingest(&mut self, signed_bytes: &[u8]) -> anyhow::Result<Ingested> {
82 // Refuse an entry for a DIFFERENT account before it reaches `account_ingest`. This session
83 // is scoped to one account; `account_ingest` would happily store a valid entry for any
84 // account (it is not account-scoped), so a peer could otherwise inject and grow other
85 // accounts through a session that never named them. A structurally undecodable entry is a
86 // peer to distrust, not a session-fatal error — drop it as NoChange.
87 let Ok((entry_account, _entry_hash)) = account_entry_ref(signed_bytes) else {
88 return Ok(Ingested::NoChange);
89 };
90 if entry_account != self.account_id {
91 return Ok(Ingested::NoChange);
92 }
93 // Skip an entry already held — matched by the EXACT signed envelope, not entry_hash: a
94 // distinct signature of the same body is a different entry the peer may need, so it must
95 // still ingest. `account_ingest`'s fast path re-reports `Ingested` for an exact replay, so
96 // without this an idempotent redelivery would inflate "newly stored".
97 if account_signed_entry_exists(self.conn, self.account_id, signed_bytes)? {
98 return Ok(Ingested::NoChange);
99 }
100 // `account_ingest` is the SAME entry point a local write uses: it re-verifies from scratch,
101 // so a forged or malformed frame is rejected here exactly as a bad local write would be.
102 // A structurally rejected entry is NOT an error — a peer may legitimately offer something
103 // this binary refuses (e.g. over a future cap) — so map it to NoChange, not a failure that
104 // would abort the whole session.
105 match account_ingest(self.conn, signed_bytes, self.now_ms)? {
106 // Newly durable: stored, or durably parked pending its signer. Every `Ingested*`
107 // variant added state (the `RejectedPromotions` suffixes report collateral pre-verify
108 // eviction of OTHER parked rows, not a failure of THIS entry).
109 IngestOutcome::PreVerify
110 | IngestOutcome::PreVerifyWithEviction { .. }
111 | IngestOutcome::Ingested { .. }
112 | IngestOutcome::IngestedWithRejectedPromotions { .. }
113 | IngestOutcome::IngestedWithRejectedContentPromotions { .. }
114 | IngestOutcome::IngestedWithRejectedAccountAndContentPromotions { .. } =>
115 Ok(Ingested::Stored),
116 // Already held / structurally refused / capacity-blocked: nothing new landed. A refusal
117 // is not a session error — a peer may legitimately offer what this binary declines.
118 IngestOutcome::Rejected(_) | IngestOutcome::CapacityReached { .. } =>
119 Ok(Ingested::NoChange),
120 }
121 }
122}
123
124/// A [`SyncStore`] over one account's OWN `/3` content on a live connection (phase D, #406) — the
125/// memories themselves, where [`OplogSyncStore`] moves the account log that authorizes them.
126///
127/// Scoped to a single account, like the account-log store: a session restores the account's own
128/// memories onto a fresh sibling. Content authored by OTHER accounts (shared streams) is a later
129/// slice (#407) and is refused here. Received bytes go through [`content_ingest`], which
130/// re-resolves the roster key and re-verifies the signature from scratch — the transport adds no
131/// trust.
132///
133/// Run this AFTER an account-log session in the same restore: `content_ingest` needs the roster and
134/// grant material the account log carries to ACCEPT (rather than park) a candidate, so the account
135/// authority must be in place first. A content session run before authority lands still transfers
136/// every byte — it just parks candidates that a later settle promotes once authority arrives.
137pub struct OplogContentSyncStore<'a> {
138 conn: &'a Connection,
139 account_id: AccountId,
140 now_ms: i64,
141}
142
143impl<'a> OplogContentSyncStore<'a> {
144 pub fn new(conn: &'a Connection, account_id: AccountId, now_ms: i64) -> Self {
145 Self { conn, account_id, now_ms }
146 }
147}
148
149impl SyncStore for OplogContentSyncStore<'_> {
150 fn account_id(&self) -> [u8; 32] {
151 self.account_id.to_bytes()
152 }
153
154 fn snapshot(&self) -> anyhow::Result<Vec<([u8; 32], Vec<u8>)>> {
155 // Key by the SIGNED-envelope hash, not `entry_hash`: two content envelopes can share an
156 // entry_hash but differ in signature (content_pre_verify keeps competing signatures for
157 // exactly this reason), and diffing by entry_hash would let a peer holding the valid
158 // signature suppress it against one holding only an invalid variant.
159 Ok(content_entries_for_sync(self.conn, self.account_id)?
160 .into_iter()
161 .map(|e| (content_signed_hash(&e.signed_bytes), e.signed_bytes))
162 .collect())
163 }
164
165 fn ingest(&mut self, signed_bytes: &[u8]) -> anyhow::Result<Ingested> {
166 // Refuse content whose CLAIMED author is a DIFFERENT account than this session before it
167 // reaches `content_ingest`. This is a session-scope PRE-FILTER, not a trust boundary: the
168 // claimed author is attacker-settable, so `content_ingest` re-resolves the roster key and
169 // rejects anything not signed by a device in the account's roster regardless. The
170 // pre-filter keeps an honestly-labeled foreign entry from parking in this account's
171 // pre-verify table through a session that never named the other account. An
172 // undecodable entry is a peer to distrust — dropped as NoChange, not a
173 // session-fatal error.
174 let Ok((_stream, entry_account, _entry_hash)) = content_entry_ref(signed_bytes) else {
175 return Ok(Ingested::NoChange);
176 };
177 if entry_account != self.account_id {
178 return Ok(Ingested::NoChange);
179 }
180 // Skip content already held — matched by the EXACT signed envelope, not entry_hash: a
181 // distinct signature of the same body is a different entry the peer may need, so it must
182 // still ingest. `content_ingest` re-reports `Ingested` for an exact replay, so without this
183 // an idempotent redelivery would inflate "newly stored".
184 if content_signed_entry_exists(self.conn, self.account_id, signed_bytes)? {
185 return Ok(Ingested::NoChange);
186 }
187 // `content_ingest` is the SAME entry point untrusted content takes: it re-resolves the
188 // roster key, re-verifies the signature, and stores the candidate under the §18b anti-abuse
189 // budgets. A structural refusal (Rejected) or a capacity block is NOT a session error — a
190 // peer may legitimately offer what this binary declines (e.g. content over the remote-flood
191 // cap) — so map both to NoChange rather than aborting the whole session.
192 match content_ingest(self.conn, signed_bytes, self.now_ms)? {
193 // Newly durable: stored as a candidate, or durably parked pending its roster key. The
194 // `Eviction` suffix reports collateral pre-verify eviction of OTHER parked rows, not a
195 // failure of THIS entry.
196 ContentIngestOutcome::PreVerify
197 | ContentIngestOutcome::PreVerifyWithEviction { .. }
198 | ContentIngestOutcome::Ingested { .. } => Ok(Ingested::Stored),
199 // Already held / structurally refused / capacity-blocked: nothing new landed.
200 ContentIngestOutcome::Rejected(_) | ContentIngestOutcome::CapacityReached { .. } =>
201 Ok(Ingested::NoChange),
202 }
203 }
204}
205
206// Both op-log stores carry the same account-level node-authorization capability (the binding is
207// about the account + transport node, independent of whether the session moves account entries or
208// content), so both delegate to the shared helpers above.
209// Both auth methods take `now_ms` per HANDSHAKE (not the store's construction-time `now_ms`, which
210// stays the ingest timestamp for received entries): binding freshness must track the live clock, or
211// a reused store would mint stale bindings and never advance the replay window.
212impl NodeAuth for OplogSyncStore<'_> {
213 fn local_binding(&self, local_node: &[u8; 32], now_ms: i64) -> anyhow::Result<Vec<u8>> {
214 sign_binding(self.conn, self.account_id, local_node, now_ms)
215 }
216
217 fn authorize(
218 &self,
219 binding: &[u8],
220 remote_node: &[u8; 32],
221 now_ms: i64,
222 ) -> anyhow::Result<bool> {
223 authorize_binding(self.conn, self.account_id, binding, remote_node, now_ms)
224 }
225}
226
227impl NodeAuth for OplogContentSyncStore<'_> {
228 fn local_binding(&self, local_node: &[u8; 32], now_ms: i64) -> anyhow::Result<Vec<u8>> {
229 sign_binding(self.conn, self.account_id, local_node, now_ms)
230 }
231
232 fn authorize(
233 &self,
234 binding: &[u8],
235 remote_node: &[u8; 32],
236 now_ms: i64,
237 ) -> anyhow::Result<bool> {
238 authorize_binding(self.conn, self.account_id, binding, remote_node, now_ms)
239 }
240}