Skip to main content

offline_protocol/protocol/
custodian.rs

1//! The engine's custody seams (`docs/spec/custody.md`).
2//!
3//! The store in `custody.rs` decides; this file is where the engine feeds it:
4//! the drop point at the end of the forwarding queue, redelivery on neighbour
5//! discovery, the receipt in both directions, the sweep, the durable records
6//! and the erase. Each seam is small on purpose, so the invariants the chapter
7//! states can be read off the call sites: nothing here touches the suppression
8//! cache, settles an outbox entry, or requests an acknowledgement.
9
10use std::time::{Duration, Instant};
11
12use chrono::Utc;
13use offline_protocol_core::{Message, MessageId, MessagePriority};
14use offline_protocol_router::RelayPriority;
15use tracing::{debug, info, warn};
16
17use super::custody::{
18    decode_receipt, encode_receipt, CustodyCandidate, CustodyRefusal, CustodyStats, HeldFrame,
19    MAX_CUSTODY_RECEIPTS_PER_MESSAGE,
20};
21use super::mesh_relay::{PendingRelay, RelayRejection};
22use super::prefixes::internal_prefixes;
23use super::storage::PruneAllowance;
24use super::types::{storage_keys, CustodyRecord, CUSTODY_RECORD_VERSION};
25use super::OfflineProtocol;
26use crate::config::CustodyConfig;
27use crate::{Error, ProtocolStateError, Result};
28
29/// How often held records are swept for expiry, and receipt suppressions for
30/// their end. Holds are hours, so a minute between sweeps costs nothing
31/// observable and keeps the tick free of a walk over the store.
32pub(crate) const CUSTODY_SWEEP_INTERVAL: Duration = Duration::from_secs(60);
33
34impl OfflineProtocol {
35    /// What this device has done as a custodian and as a depositor, and what
36    /// it is holding right now. See [`CustodyStats`].
37    pub fn custody_stats(&self) -> CustodyStats {
38        self.custody.stats()
39    }
40
41    /// The custody configuration in force.
42    pub fn custody_config(&self) -> &CustodyConfig {
43        self.custody.config()
44    }
45
46    /// Deletes every held frame and resets the custody counters.
47    ///
48    /// Callable on its own, and called by the data-layer wipe: a custody
49    /// store that survived a logout would hold other people's traffic past
50    /// the point the user asked for erasure. Attempts every record and
51    /// reports the first failure, like the data wipe: answering `Ok` for an
52    /// erase that left records behind is the worst shape this call can take.
53    pub fn erase_custody(&mut self) -> Result<()> {
54        let ids = self.custody.erase();
55        // A redelivery queued seconds ago must not go out after the erase.
56        self.mesh_relay.drop_held();
57        self.custody_receipts.clear();
58        let Some(storage) = self.protocol_state_storage.clone() else {
59            return Ok(());
60        };
61
62        let mut first_error: Option<String> = None;
63        let mut record_error = |err: ProtocolStateError| {
64            if first_error.is_none() {
65                first_error = Some(err.to_string());
66            }
67        };
68
69        for id in ids {
70            if let Err(err) = storage.delete(storage_keys::CUSTODY, &id.as_str()) {
71                record_error(err);
72            }
73        }
74        // Records the store never held this launch go too: a tail a restore
75        // left in place for lack of room, or a record written by a build
76        // whose version this one dropped.
77        match storage.list_keys(storage_keys::CUSTODY) {
78            Ok(keys) => {
79                for key in keys {
80                    if let Err(err) = storage.delete(storage_keys::CUSTODY, &key) {
81                        record_error(err);
82                    }
83                }
84            }
85            Err(ProtocolStateError::NotFound(_)) => {}
86            Err(err) => record_error(err),
87        }
88
89        match first_error {
90            Some(detail) => Err(Error::Other(format!(
91                "failed to delete every custody record: {detail}"
92            ))),
93            None => Ok(()),
94        }
95    }
96
97    /// The drop point: a forward that waited past the overdue cut-off without
98    /// reaching a link.
99    ///
100    /// An ordinary forward is judged by the acceptance table; its id was
101    /// released from the suppression cache when the governor abandoned it,
102    /// which is what makes "a custodian never blanks the route it is holding"
103    /// a consequence of existing code. A marked held forward is returned to
104    /// the store: not a deposit, not a refusal, not counted.
105    pub(super) fn judge_at_drop_point(&mut self, relay: PendingRelay) {
106        if relay.custody_target.is_some() {
107            self.return_held_to_store(relay);
108            return;
109        }
110
111        let PendingRelay {
112            message,
113            arrival_peer,
114            custody_request,
115            ..
116        } = relay;
117
118        let session_tier = arrival_peer
119            .as_deref()
120            .is_some_and(|peer| self.confirmed_sessions.contains(peer));
121        // The battery snapshot locks and allocates across every transport;
122        // taken only for a frame the table can reach that row for, which is
123        // one that asked for custody on a device that has it on.
124        let battery_ok = !self.custody.is_enabled()
125            || custody_request.is_none()
126            || self.battery_allows_relaying();
127        let now_ms = Utc::now().timestamp_millis();
128
129        let message_id = message.id.clone();
130        let verdict = self.custody.judge(CustodyCandidate {
131            message,
132            request: custody_request.as_deref(),
133            arrival_peer: arrival_peer.as_deref(),
134            session_tier,
135            battery_ok,
136            now_ms,
137        });
138
139        match verdict {
140            Ok(accepted) => {
141                for evicted in &accepted.evicted {
142                    self.delete_custody_record(evicted);
143                }
144                if let Some(held) = self.custody.get(&accepted.id) {
145                    self.persist_custody_record(held);
146                }
147                debug!(
148                    message_id = %message_id,
149                    depositor = %accepted.depositor,
150                    class = %accepted.class,
151                    evicted = accepted.evicted.len(),
152                    "Took a frame into custody"
153                );
154                self.send_custody_receipt(&accepted.depositor, &accepted.id);
155            }
156            Err(CustodyRefusal::Duplicate) => {
157                debug!(message_id = %message_id, "Deposit of a frame already held; absorbed");
158            }
159            Err(refusal) => {
160                debug!(
161                    message_id = %message_id,
162                    reason = refusal.as_str(),
163                    "Frame abandoned at the drop point and not taken into custody"
164                );
165            }
166        }
167    }
168
169    /// A marked held forward reached no link: back to the store, unjudged.
170    pub(super) fn return_held_to_store(&mut self, relay: PendingRelay) {
171        self.custody.returned(&relay.message.id);
172    }
173
174    /// A marked held forward went out. To its recipient, the frame leaves
175    /// custody; to anyone else, it stays held until its hold ends.
176    pub(super) fn record_custody_transmission(
177        &mut self,
178        relay: &PendingRelay,
179        reached_recipient: bool,
180    ) {
181        let id = &relay.message.id;
182        if reached_recipient {
183            if self.custody.record_delivered(id).is_some() {
184                self.delete_custody_record(id);
185            }
186        } else {
187            self.custody.record_re_originated(id);
188        }
189    }
190
191    /// Queues every eligible held frame toward a neighbour that just
192    /// appeared on a mesh link, through the governor's held-frame intake.
193    ///
194    /// The discovery hook also fires for a peer a carrier with no link
195    /// reported present (a gateway presence edge, a relay's presence
196    /// answer); nothing is queued for those, because a marked forward
197    /// toward a peer no mesh link reaches would only sit out the overdue
198    /// window and come back. Obeys `allow_relay` and the battery floors like
199    /// any forward: a custodian is a forwarder for the frames it holds. A
200    /// refusal for rate or room, and a forward that comes back untransmitted,
201    /// both leave the frame held and the neighbour untried, so the next
202    /// discovery of the same neighbour tries again; the store itself bounds
203    /// how many distinct neighbours a frame is offered to during its hold.
204    pub(super) fn redeliver_custody_to(&mut self, peer: &str) {
205        if !self.custody.is_enabled() || self.custody.is_empty() {
206            return;
207        }
208        if !self.config.relay.allow_relay
209            || matches!(self.config.relay.relay_priority, RelayPriority::Never)
210        {
211            return;
212        }
213        if !self
214            .transport_manager
215            .mesh_neighbors()
216            .iter()
217            .any(|neighbor| neighbor.peer_id == peer)
218        {
219            return;
220        }
221        if !self.battery_allows_relaying() {
222            return;
223        }
224
225        let now = Instant::now();
226        for id in self.custody.candidates_for(peer) {
227            let Some(message) = self.custody.get(&id).map(|held| held.message.clone()) else {
228                continue;
229            };
230            match self.mesh_relay.admit_held(message, peer, now) {
231                Ok(()) => {
232                    self.custody.mark_queued(&id, peer);
233                    debug!(
234                        message_id = %id,
235                        neighbor = %peer,
236                        cause = "custody_redelivery",
237                        "Queued a held frame toward a neighbor"
238                    );
239                }
240                Err(RelayRejection::QueueFull) => break,
241                Err(_) => continue,
242            }
243        }
244    }
245
246    /// Drops held records past the hold in force and receipt suppressions
247    /// past their end. Throttled to [`CUSTODY_SWEEP_INTERVAL`].
248    pub(super) fn sweep_custody(&mut self) {
249        let now = Instant::now();
250        if now.duration_since(self.custody_last_sweep) < CUSTODY_SWEEP_INTERVAL {
251            return;
252        }
253        self.custody_last_sweep = now;
254        self.sweep_custody_now(now, Utc::now().timestamp_millis());
255    }
256
257    /// [`Self::sweep_custody`] without the throttle, at the given clocks.
258    pub(super) fn sweep_custody_now(&mut self, _now: Instant, now_ms: i64) {
259        self.custody_receipts.retain(|_, custodians| {
260            custodians.retain(|_, until_ms| *until_ms > now_ms);
261            !custodians.is_empty()
262        });
263
264        if self.custody.is_empty() {
265            return;
266        }
267        let hold_ms = self.custody.config().hold_ms;
268        let expired = self.custody.expire(now_ms, hold_ms);
269        for id in &expired {
270            self.delete_custody_record(id);
271        }
272        if !expired.is_empty() {
273            debug!(
274                count = expired.len(),
275                "Expired held frames at the end of their hold"
276            );
277        }
278    }
279
280    /// Answers a deposit with the receipt, once, over the arrival link.
281    ///
282    /// Never through the outbox, the mesh offer, a relay or a gateway: it
283    /// names a neighbour that was on a link a moment ago, and a receipt that
284    /// cannot be put on that link is dropped and counted. Never requests an
285    /// acknowledgement. Sent only to a depositor whose key package advertised
286    /// the custody entry, because a peer without it does not know the prefix
287    /// as a control frame.
288    fn send_custody_receipt(&mut self, depositor: &str, held_id: &MessageId) {
289        if !self.peer_data_custody.contains(depositor) {
290            debug!(
291                depositor = %depositor,
292                "Receipt withheld: the depositor does not advertise the custody entry"
293            );
294            self.custody.record_receipt_dropped();
295            return;
296        }
297        if self.mls_manager.is_none() {
298            // An unsigned receipt is refused by the depositor's control gate
299            // before it is read, so there is nothing to send.
300            self.custody.record_receipt_dropped();
301            return;
302        }
303
304        let content = encode_receipt(&held_id.as_str(), self.custody.config().hold_ms);
305        let mut message = match self.create_message(
306            depositor,
307            content,
308            Some(MessagePriority::Low),
309            None,
310        ) {
311            Ok(message) => message,
312            Err(err) => {
313                warn!(depositor = %depositor, error = %err, "Could not build a custody receipt");
314                self.custody.record_receipt_dropped();
315                return;
316            }
317        };
318        message.requires_ack = false;
319        if let Err(err) = self.sign_control_message(&mut message) {
320            warn!(depositor = %depositor, error = %err, "Could not sign a custody receipt");
321            self.custody.record_receipt_dropped();
322            return;
323        }
324
325        // Ours: a copy circling back through the mesh is recognised rather
326        // than carried, exactly as an offered frame is.
327        self.deduplicator.mark_seen_local(message.id.clone());
328        self.mesh_relay.mark_handled(&message.id.as_str());
329
330        match self.transport_manager.send_to_neighbor(depositor, &message) {
331            Ok(transport) => {
332                debug!(
333                    depositor = %depositor,
334                    held = %held_id,
335                    transport = ?transport,
336                    "Sent a custody receipt"
337                );
338                self.custody.record_receipt_sent();
339            }
340            Err(err) => {
341                debug!(
342                    depositor = %depositor,
343                    held = %held_id,
344                    error = %err,
345                    "Dropped a custody receipt: the arrival link is gone"
346                );
347                self.custody.record_receipt_dropped();
348            }
349        }
350    }
351
352    /// A receipt from a custodian, past the control gate.
353    ///
354    /// Suppresses further deposit requests for that identifier toward that
355    /// custodian until the hold elapses, and nothing else: the outbox entry,
356    /// the acknowledgement timer, the retry entry and any park are untouched.
357    /// A receipt naming nothing in the outbox is ignored and counted; that is
358    /// the ordinary shape of one arriving after the recipient's
359    /// acknowledgement settled the message, and the shape a forged receipt
360    /// for a never-sent identifier would take.
361    ///
362    /// `minted_at_ms` is the receipt frame's own timestamp: the hold is
363    /// relative to it, as the chapter says, so the suppression ends when the
364    /// custodian's does, however long the receipt took to arrive and whatever
365    /// this device's monotonic clock did in the meantime.
366    pub(super) fn handle_custody_receipt(
367        &mut self,
368        custodian: &str,
369        body: &str,
370        minted_at_ms: i64,
371    ) {
372        let receipt = match decode_receipt(body) {
373            Ok(receipt) => receipt,
374            Err(err) => {
375                debug!(custodian = %custodian, error = ?err, "Refused a malformed custody receipt");
376                self.custody.record_receipt_ignored();
377                return;
378            }
379        };
380        let Ok(id) = MessageId::from_str(&receipt.id) else {
381            self.custody.record_receipt_ignored();
382            return;
383        };
384        if !self.outbox.contains_key(&id) {
385            debug!(custodian = %custodian, message_id = %id, "Custody receipt names nothing in the outbox");
386            self.custody.record_receipt_ignored();
387            return;
388        }
389
390        // A hold longer than this device's own outbox window buys nothing:
391        // the entry is gone before the suppression would end. Wall clock,
392        // from the receipt's own timestamp: a monotonic clock pauses in
393        // suspend and knows nothing of transit, and either would keep asking
394        // nothing of a custodian that had already let the frame go.
395        let hold_ms = receipt
396            .hold_ms
397            .min(self.config.reliability.retry.outbox_max_lifetime_ms);
398        // Never from a timestamp ahead of this device's clock: the control
399        // gate admits one up to two days ahead, and never checks it on an
400        // unbound signature, so a fast-clock custodian would otherwise
401        // suppress re-deposit toward itself past its own hold.
402        let until_ms = minted_at_ms
403            .min(Utc::now().timestamp_millis())
404            .saturating_add(i64::try_from(hold_ms).unwrap_or(i64::MAX));
405
406        let custodians = self.custody_receipts.entry(id.clone()).or_default();
407        if !custodians.contains_key(custodian)
408            && custodians.len() >= MAX_CUSTODY_RECEIPTS_PER_MESSAGE
409        {
410            // Bounded against a neighbourhood that mints addresses: the
411            // suppression that ends soonest is the one worth least.
412            if let Some(oldest) = custodians
413                .iter()
414                .min_by_key(|(_, until_ms)| **until_ms)
415                .map(|(peer, _)| peer.clone())
416            {
417                custodians.remove(&oldest);
418            }
419        }
420        custodians.insert(custodian.to_string(), until_ms);
421        debug!(custodian = %custodian, message_id = %id, hold_ms, "Recorded a custody receipt");
422        self.custody.record_receipt_received();
423    }
424
425    /// The class token to write on this device's own frame when it is
426    /// offered to neighbours, or `None` when the frame is not a deposit.
427    ///
428    /// Judged from the plaintext retained for re-sealing on resend: a frame
429    /// whose plaintext this device no longer holds, after a restart, is
430    /// offered without the key, exactly as the chapter says. Only a sealed
431    /// 1:1 frame qualifies, so a group frame, a plaintext frame and a media
432    /// chunk never carry one.
433    pub(super) fn custody_class_for(&self, message: &Message) -> Option<&'static str> {
434        if !message.content.starts_with(internal_prefixes::ENCRYPTED) {
435            return None;
436        }
437        let plaintext = self
438            .outbox
439            .get(&message.id)
440            .and_then(|entry| entry.reseal.as_ref())
441            .map(|reseal| reseal.content.as_str())
442            .or_else(|| {
443                self.pending_reseal
444                    .get(&message.id)
445                    .map(|reseal| reseal.content.as_str())
446            })?;
447        #[cfg(feature = "data")]
448        {
449            super::data_sync::custody_class_of_plaintext(plaintext)
450        }
451        #[cfg(not(feature = "data"))]
452        {
453            // Without the data layer there are no replication frames to
454            // deposit, so nothing this device sends is Class A.
455            let _ = plaintext;
456            None
457        }
458    }
459
460    /// Whether a receipt from `custodian` still suppresses a deposit request
461    /// for `id` toward it.
462    pub(super) fn custody_suppressed_toward(&self, id: &MessageId, custodian: &str) -> bool {
463        self.custody_receipts
464            .get(id)
465            .and_then(|custodians| custodians.get(custodian))
466            .is_some_and(|until_ms| *until_ms > Utc::now().timestamp_millis())
467    }
468
469    /// Writes one held frame under its own message id. Best-effort, like the
470    /// pending-decrypt records: a failed write is logged and the frame still
471    /// lives in the store, it just will not survive a restart.
472    fn persist_custody_record(&self, held: &HeldFrame) {
473        let Some(storage) = &self.protocol_state_storage else {
474            return;
475        };
476        let record = CustodyRecord {
477            version: CUSTODY_RECORD_VERSION,
478            depositor: held.depositor.clone(),
479            message: held.message.clone(),
480            accepted_at_ms: held.accepted_at_ms,
481            class: held.class.clone(),
482        };
483        let data = match serde_json::to_vec(&record) {
484            Ok(data) => data,
485            Err(err) => {
486                warn!(message_id = %held.message.id, error = %err, "Failed to serialize a custody record");
487                return;
488            }
489        };
490        if let Err(err) = self.write_state_record(
491            storage.as_ref(),
492            storage_keys::CUSTODY,
493            &held.message.id.as_str(),
494            &data,
495        ) {
496            warn!(message_id = %held.message.id, error = %err, "Failed to persist a custody record");
497        }
498    }
499
500    /// Removes one held frame's record. Logged rather than swallowed: a
501    /// delete that silently failed is a record the next launch restores and
502    /// redelivers, past the point the store had given it up.
503    fn delete_custody_record(&self, id: &MessageId) {
504        if let Some(storage) = &self.protocol_state_storage {
505            if let Err(err) = storage.delete(storage_keys::CUSTODY, &id.as_str()) {
506                warn!(message_id = %id, error = %err, "Failed to delete a custody record");
507            }
508        }
509    }
510
511    /// Restores held frames at launch, under a budget.
512    ///
513    /// With custody disabled, every record is erased rather than kept:
514    /// nothing would ever deliver it. With custody enabled, each record is
515    /// dropped when its acceptance time is older than the hold in force and
516    /// kept exactly as stored otherwise, oldest first so the budgets in force
517    /// admit the frames that have waited longest. Restore never sends
518    /// anything. Deletes draw on `allowance` and may be refused, leaving the
519    /// record for a later launch; a record the store has no room for is
520    /// deleted rather than left, because nothing would ever restore it.
521    pub(super) fn restore_custody(&mut self, allowance: &mut PruneAllowance) {
522        let Some(storage) = self.protocol_state_storage.clone() else {
523            return;
524        };
525        let keys = match storage.list_keys(storage_keys::CUSTODY) {
526            Ok(keys) => keys,
527            Err(ProtocolStateError::NotFound(_)) => return,
528            Err(err) => {
529                warn!(error = %err, "Failed to list custody records");
530                return;
531            }
532        };
533        if keys.is_empty() {
534            return;
535        }
536
537        let cap = self.config.custody.max_entries.saturating_mul(4).max(1);
538        let enabled = self.custody.is_enabled();
539        let hold = i64::try_from(self.custody.config().hold_ms).unwrap_or(i64::MAX);
540        let now_ms = Utc::now().timestamp_millis();
541        // A refusing budget on the inbound pool, which no advisory walk
542        // shares (see `PruneAllowance::refusing_private`).
543        let mut budget = allowance.refusing_private();
544        let mut erased = 0usize;
545        let mut expired = 0usize;
546        let mut deferred = 0usize;
547
548        let mut records: Vec<CustodyRecord> = Vec::new();
549        for key in keys.into_iter().take(cap) {
550            if !enabled {
551                if budget.claim() {
552                    self.delete_custody_record_by_key(&key);
553                    erased += 1;
554                } else {
555                    deferred += 1;
556                }
557                continue;
558            }
559            let data = match self.read_state_record(storage.as_ref(), storage_keys::CUSTODY, &key) {
560                Ok(Some(data)) => data,
561                Ok(None) => continue,
562                Err(err) => {
563                    debug!(key = %key, error = %err, "Custody record could not be read; leaving it");
564                    continue;
565                }
566            };
567            let record = match serde_json::from_slice::<CustodyRecord>(&data) {
568                Ok(record) if record.version == CUSTODY_RECORD_VERSION => record,
569                Ok(record) => {
570                    warn!(key = %key, version = record.version, "Dropping a custody record of an unknown version");
571                    if budget.claim() {
572                        self.delete_custody_record_by_key(&key);
573                    }
574                    continue;
575                }
576                Err(err) => {
577                    warn!(key = %key, error = %err, "Dropping a corrupted custody record");
578                    if budget.claim() {
579                        self.delete_custody_record_by_key(&key);
580                    }
581                    continue;
582                }
583            };
584            if record.message.id.as_str() != key {
585                warn!(key = %key, message_id = %record.message.id, "Dropping a custody record filed under another id");
586                if budget.claim() {
587                    self.delete_custody_record_by_key(&key);
588                }
589                continue;
590            }
591            if now_ms.saturating_sub(record.accepted_at_ms) > hold {
592                if budget.claim() {
593                    self.delete_custody_record_by_key(&key);
594                    expired += 1;
595                } else {
596                    deferred += 1;
597                }
598                continue;
599            }
600            records.push(record);
601        }
602
603        records.sort_by(|left, right| {
604            left.accepted_at_ms
605                .cmp(&right.accepted_at_ms)
606                .then_with(|| left.message.id.as_str().cmp(&right.message.id.as_str()))
607        });
608
609        let mut restored = 0usize;
610        for record in records {
611            let key = record.message.id.as_str();
612            if self.custody.admit_restored(
613                record.message,
614                record.depositor,
615                record.accepted_at_ms,
616                record.class,
617            ) {
618                restored += 1;
619            } else if budget.claim() {
620                self.delete_custody_record_by_key(&key);
621            } else {
622                deferred += 1;
623            }
624        }
625
626        if enabled {
627            info!(restored, expired, deferred, "Restored custody records");
628        } else if erased > 0 || deferred > 0 {
629            info!(
630                erased,
631                deferred, "Erased custody records left by a launch with custody enabled"
632            );
633        }
634    }
635
636    fn delete_custody_record_by_key(&self, key: &str) {
637        if let Some(storage) = &self.protocol_state_storage {
638            if let Err(err) = storage.delete(storage_keys::CUSTODY, key) {
639                warn!(key = %key, error = %err, "Failed to delete a custody record");
640            }
641        }
642    }
643}