Skip to main content

khive_db/stores/note/transport/
mod.rs

1//! Durable sender-side records. No transport adapter performs SQL.
2use super::{map_err, SqlNoteStore};
3use crate::pool::ConnectionPool;
4use khive_storage::{StorageCapability, StorageError, StorageResult};
5use rusqlite::{params, OptionalExtension};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::sync::Arc;
9use uuid::Uuid;
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub struct EnvelopeKey {
13    pub logical_message_id: Uuid,
14    pub recipient_device_id: Uuid,
15    pub recipient_key_epoch: u64,
16}
17
18/// Immutable submission data. Credential references name a key-facility entry; never supply key
19/// material.
20#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
21pub struct SenderEnvelope {
22    pub namespace: String,
23    pub logical_message_id: Uuid,
24    pub outbound_note_id: Uuid,
25    pub kind: String,
26    pub slug: String,
27    pub credential_ref: String,
28    pub recipient_address: String,
29    pub protocol_version: u32,
30    pub sender_agent_id: String,
31    pub sender_assurance: SenderAssurance,
32    pub recipient_agent_id: String,
33    pub recipient_device_id: Uuid,
34    pub recipient_key_epoch: u64,
35    pub contact_generation: u64,
36    pub sender_key_epoch: u64,
37    pub recipient_key_fingerprint: String,
38    pub enc: Vec<u8>,
39    pub ciphertext: Vec<u8>,
40}
41impl SenderEnvelope {
42    pub fn key(&self) -> EnvelopeKey {
43        EnvelopeKey {
44            logical_message_id: self.logical_message_id,
45            recipient_device_id: self.recipient_device_id,
46            recipient_key_epoch: self.recipient_key_epoch,
47        }
48    }
49    pub fn validate(&self) -> StorageResult<()> {
50        for id in [&self.sender_agent_id, &self.recipient_agent_id] {
51            if Uuid::parse_str(id).ok().map(|id| id.to_string()).as_deref() != Some(id.as_str()) {
52                return Err(invalid("agent id must be a canonical UUID"));
53            }
54        }
55        if self.enc.len() != 32 || self.ciphertext.len() > 65_536 {
56            return Err(invalid("invalid envelope byte lengths"));
57        }
58        if [
59            self.recipient_key_epoch,
60            self.sender_key_epoch,
61            self.contact_generation,
62        ]
63        .iter()
64        .any(|n| *n == 0 || *n > u32::MAX as u64)
65        {
66            return Err(invalid("epoch/generation outside supported range"));
67        }
68        if self.protocol_version != 1
69            || self.kind.is_empty()
70            || self.slug.is_empty()
71            || self.credential_ref.is_empty()
72        {
73            return Err(invalid("invalid transport identity"));
74        }
75        if self.recipient_key_fingerprint.len() != 64
76            || !self
77                .recipient_key_fingerprint
78                .bytes()
79                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
80        {
81            return Err(invalid("fingerprint must be 32 lowercase hex bytes"));
82        }
83        Ok(())
84    }
85}
86
87/// Sender identity assurance recorded at send time and preserved across transport attempts.
88#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum SenderAssurance {
91    Claimed,
92    DaemonBearer,
93    ActorSignature,
94}
95
96#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum TransportState {
99    Pending,
100    RecipientStored,
101    RecipientQuarantined,
102    Failed,
103}
104#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum FailureClass {
107    Transient,
108    Authentication,
109    Permanent,
110}
111#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum HoldReason {
114    InsufficientCredit,
115    RecipientKeyChanged,
116    /// Policy state evaluated at the refused transport attempt.
117    PolicyDenied {
118        mode: PolicyMode,
119        revision: u64,
120    },
121}
122
123/// Recorded evaluation mode; this does not enable or change runtime policy.
124#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "snake_case")]
126pub enum PolicyMode {
127    Off,
128    Shadow,
129    Enforce,
130}
131#[derive(Clone, Debug, PartialEq, Eq)]
132pub struct SenderRecord {
133    pub envelope: SenderEnvelope,
134    pub state: TransportState,
135    pub attempt_count: u64,
136    pub envelope_seq: u64,
137    pub next_retry_at: Option<i64>,
138    pub last_failure_class: Option<FailureClass>,
139    pub hold_reason: Option<HoldReason>,
140    pub receipt: Option<Value>,
141    pub created_at: i64,
142    pub updated_at: i64,
143    pub admitted_at: Option<i64>,
144}
145
146fn invalid(message: &str) -> StorageError {
147    StorageError::InvalidInput {
148        capability: StorageCapability::Notes,
149        operation: "sender_transport".into(),
150        message: message.into(),
151    }
152}
153trait StorageSpelling {
154    fn storage_spelling(&self) -> &'static str;
155}
156impl StorageSpelling for SenderAssurance {
157    fn storage_spelling(&self) -> &'static str {
158        match self {
159            Self::Claimed => "claimed",
160            Self::DaemonBearer => "daemon_bearer",
161            Self::ActorSignature => "actor_signature",
162        }
163    }
164}
165impl StorageSpelling for TransportState {
166    fn storage_spelling(&self) -> &'static str {
167        match self {
168            Self::Pending => "pending",
169            Self::RecipientStored => "recipient_stored",
170            Self::RecipientQuarantined => "recipient_quarantined",
171            Self::Failed => "failed",
172        }
173    }
174}
175impl StorageSpelling for FailureClass {
176    fn storage_spelling(&self) -> &'static str {
177        match self {
178            Self::Transient => "transient",
179            Self::Authentication => "authentication",
180            Self::Permanent => "permanent",
181        }
182    }
183}
184impl StorageSpelling for HoldReason {
185    fn storage_spelling(&self) -> &'static str {
186        match self {
187            Self::InsufficientCredit => "insufficient_credit",
188            Self::RecipientKeyChanged => "recipient_key_changed",
189            Self::PolicyDenied { .. } => "policy_denied",
190        }
191    }
192}
193impl StorageSpelling for PolicyMode {
194    fn storage_spelling(&self) -> &'static str {
195        match self {
196            Self::Off => "off",
197            Self::Shadow => "shadow",
198            Self::Enforce => "enforce",
199        }
200    }
201}
202fn encode(value: &impl StorageSpelling) -> &'static str {
203    value.storage_spelling()
204}
205fn decode<T: serde::de::DeserializeOwned>(value: String) -> rusqlite::Result<T> {
206    serde_json::from_value(Value::String(value)).map_err(|e| {
207        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
208    })
209}
210fn uuid(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<Uuid> {
211    Uuid::parse_str(&row.get::<_, String>(index)?).map_err(|e| {
212        rusqlite::Error::FromSqlConversionFailure(index, rusqlite::types::Type::Text, Box::new(e))
213    })
214}
215const COLUMNS: &str = concat!(
216    "namespace, logical_message_id, outbound_note_id, kind, slug, credential_ref, ",
217    "recipient_address, protocol_version, sender_agent_id, recipient_agent_id, ",
218    "recipient_device_id, recipient_key_epoch, contact_generation, sender_key_epoch, ",
219    "recipient_key_fingerprint, enc, ciphertext, state, attempt_count, next_retry_at, ",
220    "last_failure_class, hold_reason, receipt, created_at, updated_at, envelope_seq, ",
221    "policy_mode, policy_revision, sender_assurance, admitted_at",
222);
223fn unsigned_column(value: i64, index: usize) -> rusqlite::Result<u64> {
224    u64::try_from(value).map_err(|error| {
225        rusqlite::Error::FromSqlConversionFailure(
226            index,
227            rusqlite::types::Type::Integer,
228            Box::new(error),
229        )
230    })
231}
232fn read_unsigned(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<u64> {
233    unsigned_column(row.get(index)?, index)
234}
235fn read_optional_unsigned(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<Option<u64>> {
236    row.get::<_, Option<i64>>(index)?
237        .map(|value| unsigned_column(value, index))
238        .transpose()
239}
240fn sql_integer(value: u64) -> StorageResult<i64> {
241    i64::try_from(value)
242        .map_err(|_| invalid("unsigned transport value exceeds SQLite integer range"))
243}
244fn read_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SenderRecord> {
245    Ok(SenderRecord {
246        envelope: SenderEnvelope {
247            namespace: row.get(0)?,
248            logical_message_id: uuid(row, 1)?,
249            outbound_note_id: uuid(row, 2)?,
250            kind: row.get(3)?,
251            slug: row.get(4)?,
252            credential_ref: row.get(5)?,
253            recipient_address: row.get(6)?,
254            protocol_version: row.get(7)?,
255            sender_agent_id: row.get(8)?,
256            sender_assurance: decode(row.get(28)?)?,
257            recipient_agent_id: row.get(9)?,
258            recipient_device_id: uuid(row, 10)?,
259            recipient_key_epoch: read_unsigned(row, 11)?,
260            contact_generation: read_unsigned(row, 12)?,
261            sender_key_epoch: read_unsigned(row, 13)?,
262            recipient_key_fingerprint: row.get(14)?,
263            enc: row.get(15)?,
264            ciphertext: row.get(16)?,
265        },
266        state: decode(row.get(17)?)?,
267        attempt_count: read_unsigned(row, 18)?,
268        next_retry_at: row.get(19)?,
269        last_failure_class: row.get::<_, Option<String>>(20)?.map(decode).transpose()?,
270        hold_reason: match row.get::<_, Option<String>>(21)?.as_deref() {
271            Some("policy_denied") => Some(HoldReason::PolicyDenied {
272                mode: decode(row.get(26)?)?,
273                revision: read_unsigned(row, 27)?,
274            }),
275            reason => reason.map(|r| decode(r.to_owned())).transpose()?,
276        },
277        receipt: row
278            .get::<_, Option<String>>(22)?
279            .map(|s| {
280                serde_json::from_str(&s).map_err(|e| {
281                    rusqlite::Error::FromSqlConversionFailure(
282                        22,
283                        rusqlite::types::Type::Text,
284                        Box::new(e),
285                    )
286                })
287            })
288            .transpose()?,
289        envelope_seq: read_unsigned(row, 25)?,
290        created_at: row.get(23)?,
291        updated_at: row.get(24)?,
292        admitted_at: row.get(29)?,
293    })
294}
295fn load(conn: &rusqlite::Connection, key: EnvelopeKey) -> rusqlite::Result<Option<SenderRecord>> {
296    let epoch = i64::try_from(key.recipient_key_epoch)
297        .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
298    conn.query_row(
299        &LOAD_SQL.replace("{COLUMNS}", COLUMNS),
300        params![
301            key.logical_message_id.to_string(),
302            key.recipient_device_id.to_string(),
303            epoch
304        ],
305        read_row,
306    )
307    .optional()
308}
309
310const LOAD_SQL: &str = concat!(
311    "SELECT {COLUMNS} FROM comm_sender_transport WHERE logical_message_id=?1 AND ",
312    "recipient_device_id=?2 AND recipient_key_epoch=?3",
313);
314
315const TERMINAL_SQL: &str = concat!(
316    "SELECT EXISTS(SELECT 1 FROM comm_sender_transport WHERE logical_message_id=?1 ",
317    "AND receipt IS NOT NULL)",
318);
319
320const PRIOR_SQL: &str = concat!(
321    "SELECT {COLUMNS} FROM comm_sender_transport WHERE logical_message_id=?1 ORDER BY ",
322    "envelope_seq DESC LIMIT 1",
323);
324
325const DEVICE_EPOCH_SQL: &str = concat!(
326    "SELECT MAX(recipient_key_epoch) FROM comm_sender_transport WHERE ",
327    "logical_message_id=?1 AND recipient_device_id=?2",
328);
329
330const INSERT_SQL: &str = concat!(
331    "INSERT INTO comm_sender_transport (namespace, logical_message_id, ",
332    "outbound_note_id, kind, slug, credential_ref, recipient_address, ",
333    "protocol_version, sender_agent_id, recipient_agent_id, recipient_device_id, ",
334    "recipient_key_epoch, contact_generation, sender_key_epoch, ",
335    "recipient_key_fingerprint, enc, ",
336    "ciphertext,state,created_at,updated_at,envelope_seq,sender_assurance) VALUES ",
337    "(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17, ",
338    "'pending',?18,?18,?19,?20)",
339);
340
341const PENDING_SQL: &str = concat!(
342    "SELECT {COLUMNS} FROM comm_sender_transport AS t WHERE namespace=?1 AND kind=?2 ",
343    "AND slug=?3 AND state='pending' AND hold_reason IS NULL AND (next_retry_at IS ",
344    "NULL OR next_retry_at<=?4) AND NOT EXISTS(SELECT 1 FROM comm_sender_transport AS ",
345    "done WHERE done.logical_message_id=t.logical_message_id AND done.receipt IS NOT ",
346    "NULL) AND envelope_seq=(SELECT MAX(envelope_seq) FROM comm_sender_transport ",
347    "WHERE logical_message_id=t.logical_message_id) ORDER BY ",
348    "created_at,logical_message_id LIMIT ?5",
349);
350
351const FAILURE_SQL: &str = concat!(
352    "UPDATE comm_sender_transport SET ",
353    "state=?4,attempt_count=?5,next_retry_at=?6,last_failure_class=?7,updated_at=?8,",
354    "hold_reason=CASE WHEN ?4='pending' THEN hold_reason ELSE NULL END,",
355    "policy_mode=CASE WHEN ?4='pending' THEN policy_mode ELSE NULL END,",
356    "policy_revision=CASE WHEN ?4='pending' THEN policy_revision ELSE NULL END ",
357    "WHERE logical_message_id=?1 AND recipient_device_id=?2 AND ",
358    "recipient_key_epoch=?3",
359);
360
361const ADMISSION_SQL: &str = concat!(
362    "UPDATE comm_sender_transport SET admitted_at=?4,next_retry_at=?5,updated_at=?6 ",
363    "WHERE logical_message_id=?1 AND recipient_device_id=?2 AND recipient_key_epoch=?3 ",
364    "AND state='pending' AND hold_reason IS NULL",
365);
366
367const HOLD_SQL: &str = concat!(
368    "UPDATE comm_sender_transport SET hold_reason=?4,updated_at=?5,",
369    "policy_mode=?6,policy_revision=?7 WHERE ",
370    "logical_message_id=?1 AND recipient_device_id=?2 AND recipient_key_epoch=?3",
371);
372
373const RECEIPT_SQL: &str = concat!(
374    "UPDATE comm_sender_transport SET ",
375    "state=?4,receipt=?5,next_retry_at=NULL,hold_reason=NULL,",
376    "policy_mode=NULL,policy_revision=NULL,updated_at=?6 WHERE ",
377    "logical_message_id=?1 AND recipient_device_id=?2 AND recipient_key_epoch=?3",
378);
379
380/// Store uses the note writer's transaction/queue routing and bounded pooled readers.
381pub struct SenderTransportStore {
382    notes: SqlNoteStore,
383}
384impl SenderTransportStore {
385    pub fn new(pool: Arc<ConnectionPool>) -> Self {
386        Self {
387            notes: SqlNoteStore::new(pool, false),
388        }
389    }
390    pub async fn get(&self, key: EnvelopeKey) -> StorageResult<Option<SenderRecord>> {
391        self.notes
392            .with_reader("sender_transport_get", move |conn| load(conn, key))
393            .await
394    }
395    /// Exact retries reuse the record. Only confirmed key-change operations
396    /// may create another envelope.
397    pub async fn create(
398        &self,
399        envelope: SenderEnvelope,
400        confirmed_key_change: bool,
401    ) -> StorageResult<SenderRecord> {
402        envelope.validate()?;
403        self.notes
404            .with_writer_tx_storage("sender_transport_create", move |conn| {
405                let op = "sender_transport_create";
406                if let Some(existing) = load(conn, envelope.key()).map_err(|e| map_err(e, op))? {
407                    if confirmed_key_change {
408                        return Err(invalid(
409                            "confirmed re-encryption requires a new key identity",
410                        ));
411                    }
412                    if existing.envelope != envelope {
413                        return Err(invalid("envelope_conflict"));
414                    }
415                    return Ok(existing);
416                }
417                let terminal: bool = conn
418                    .query_row(
419                        TERMINAL_SQL,
420                        [envelope.logical_message_id.to_string()],
421                        |row| row.get(0),
422                    )
423                    .map_err(|e| map_err(e, op))?;
424                if terminal {
425                    return Err(invalid("logical message already has a recipient receipt"));
426                }
427                let prior = conn
428                    .query_row(
429                        &PRIOR_SQL.replace("{COLUMNS}", COLUMNS),
430                        [envelope.logical_message_id.to_string()],
431                        read_row,
432                    )
433                    .optional()
434                    .map_err(|e| map_err(e, op))?;
435                let envelope_seq = if let Some(prior) = prior {
436                    if !confirmed_key_change
437                        || prior.state != TransportState::Pending
438                        || prior.hold_reason != Some(HoldReason::RecipientKeyChanged)
439                    {
440                        return Err(invalid(
441                            "new envelope requires confirmed recipient key change",
442                        ));
443                    }
444                    let previous_epoch: Option<u64> = conn
445                        .query_row(
446                            DEVICE_EPOCH_SQL,
447                            params![
448                                envelope.logical_message_id.to_string(),
449                                envelope.recipient_device_id.to_string()
450                            ],
451                            |row| read_optional_unsigned(row, 0),
452                        )
453                        .map_err(|e| map_err(e, op))?;
454                    if previous_epoch.is_some_and(|epoch| envelope.recipient_key_epoch <= epoch) {
455                        return Err(invalid("same device key epoch must increase"));
456                    }
457                    let a = &prior.envelope;
458                    let b = &envelope;
459                    if a.sender_assurance != b.sender_assurance {
460                        return Err(invalid("sender_assurance_conflict"));
461                    }
462                    if a.namespace != b.namespace
463                        || a.outbound_note_id != b.outbound_note_id
464                        || a.kind != b.kind
465                        || a.slug != b.slug
466                        || a.sender_agent_id != b.sender_agent_id
467                        || a.sender_key_epoch != b.sender_key_epoch
468                        || a.recipient_agent_id != b.recipient_agent_id
469                        || a.recipient_address != b.recipient_address
470                    {
471                        return Err(invalid("logical message identity cannot change"));
472                    }
473                    prior
474                        .envelope_seq
475                        .checked_add(1)
476                        .filter(|seq| *seq <= i64::MAX as u64)
477                        .ok_or_else(|| invalid("envelope sequence exhausted"))?
478                } else {
479                    if confirmed_key_change {
480                        return Err(invalid("no prior envelope to re-encrypt"));
481                    }
482                    1
483                };
484                let now = chrono::Utc::now().timestamp_micros();
485                conn.execute(
486                    INSERT_SQL,
487                    params![
488                        envelope.namespace,
489                        envelope.logical_message_id.to_string(),
490                        envelope.outbound_note_id.to_string(),
491                        envelope.kind,
492                        envelope.slug,
493                        envelope.credential_ref,
494                        envelope.recipient_address,
495                        envelope.protocol_version,
496                        envelope.sender_agent_id,
497                        envelope.recipient_agent_id,
498                        envelope.recipient_device_id.to_string(),
499                        sql_integer(envelope.recipient_key_epoch)?,
500                        sql_integer(envelope.contact_generation)?,
501                        sql_integer(envelope.sender_key_epoch)?,
502                        envelope.recipient_key_fingerprint,
503                        envelope.enc,
504                        envelope.ciphertext,
505                        now,
506                        sql_integer(envelope_seq)?,
507                        encode(&envelope.sender_assurance)
508                    ],
509                )
510                .map_err(|e| map_err(e, op))?;
511                load(conn, envelope.key())
512                    .map_err(|e| map_err(e, op))?
513                    .ok_or_else(|| invalid("inserted record disappeared"))
514            })
515            .await
516    }
517    /// Only due, unheld rows of the exact route and highest local envelope
518    /// sequence are submitted automatically.
519    pub async fn list_pending(
520        &self,
521        namespace: &str,
522        kind: &str,
523        slug: &str,
524        now: i64,
525        limit: u32,
526    ) -> StorageResult<Vec<SenderRecord>> {
527        let (namespace, kind, slug) = (namespace.to_owned(), kind.to_owned(), slug.to_owned());
528        self.notes
529            .with_reader("sender_transport_pending", move |conn| {
530                let mut stmt = conn.prepare(&PENDING_SQL.replace("{COLUMNS}", COLUMNS))?;
531                let rows = stmt
532                    .query_map(
533                        params![namespace, kind, slug, now, limit.min(1000)],
534                        read_row,
535                    )?
536                    .collect();
537                rows
538            })
539            .await
540    }
541    /// Record a failure only for an unheld pending envelope. A hold suspends failure updates as
542    /// well as automatic retry until the hold is resolved.
543    pub async fn record_failure(
544        &self,
545        key: EnvelopeKey,
546        class: FailureClass,
547        next_retry_at: Option<i64>,
548    ) -> StorageResult<()> {
549        self.notes
550            .with_writer_tx_storage("sender_transport_failure", move |conn| {
551                let op = "sender_transport_failure";
552                let row = load(conn, key)
553                    .map_err(|e| map_err(e, op))?
554                    .ok_or_else(|| invalid("unknown sender record"))?;
555                if row.state != TransportState::Pending {
556                    return Err(invalid("sender record is not pending"));
557                }
558                if row.hold_reason.is_some() {
559                    return Err(invalid("sender record is held"));
560                }
561                let state = if class == FailureClass::Permanent {
562                    TransportState::Failed
563                } else {
564                    TransportState::Pending
565                };
566                let attempts = if class == FailureClass::Authentication {
567                    row.attempt_count
568                } else {
569                    row.attempt_count.saturating_add(1).min(i64::MAX as u64)
570                };
571                let retry = if class == FailureClass::Transient {
572                    next_retry_at
573                } else {
574                    None
575                };
576                conn.execute(
577                    FAILURE_SQL,
578                    params![
579                        key.logical_message_id.to_string(),
580                        key.recipient_device_id.to_string(),
581                        sql_integer(key.recipient_key_epoch)?,
582                        encode(&state),
583                        sql_integer(attempts)?,
584                        retry,
585                        encode(&class),
586                        chrono::Utc::now().timestamp_micros()
587                    ],
588                )
589                .map_err(|e| map_err(e, op))?;
590                Ok(())
591            })
592            .await
593    }
594    /// Record a successful service admission and schedule resubmission 600 seconds later.
595    /// Re-admission of the same pending envelope replaces both timestamps.
596    pub async fn record_admission(&self, key: EnvelopeKey, admitted_at: i64) -> StorageResult<()> {
597        self.notes
598            .with_writer_tx_storage("sender_transport_admission", move |conn| {
599                let op = "sender_transport_admission";
600                let row = load(conn, key)
601                    .map_err(|e| map_err(e, op))?
602                    .ok_or_else(|| invalid("unknown sender record"))?;
603                if row.state != TransportState::Pending {
604                    return Err(invalid("sender record is not pending"));
605                }
606                if row.hold_reason.is_some() {
607                    return Err(invalid("sender record is held"));
608                }
609                let next_retry_at = admitted_at
610                    .checked_add(600_000_000)
611                    .ok_or_else(|| invalid("admission deadline exceeds SQLite integer range"))?;
612                conn.execute(
613                    ADMISSION_SQL,
614                    params![
615                        key.logical_message_id.to_string(),
616                        key.recipient_device_id.to_string(),
617                        sql_integer(key.recipient_key_epoch)?,
618                        admitted_at,
619                        next_retry_at,
620                        chrono::Utc::now().timestamp_micros()
621                    ],
622                )
623                .map_err(|e| map_err(e, op))?;
624                Ok(())
625            })
626            .await
627    }
628    /// Release credit or policy holds explicitly. Key-change holds release by creating a confirmed
629    /// new envelope. Policy holds require the evaluated mode and revision in the reason;
630    /// every other reason, including release (`None`), clears both policy columns.
631    pub async fn hold(&self, key: EnvelopeKey, reason: Option<HoldReason>) -> StorageResult<()> {
632        self.notes
633            .with_writer_tx_storage("sender_transport_hold", move |conn| {
634                let op = "sender_transport_hold";
635                let row = load(conn, key)
636                    .map_err(|e| map_err(e, op))?
637                    .ok_or_else(|| invalid("unknown sender record"))?;
638                if row.state != TransportState::Pending {
639                    return Err(invalid("sender record is not pending"));
640                }
641                if row.hold_reason == Some(HoldReason::RecipientKeyChanged)
642                    && reason != Some(HoldReason::RecipientKeyChanged)
643                {
644                    return Err(invalid("key change requires confirmed re-encryption"));
645                }
646                let (policy_mode, policy_revision) = match reason {
647                    Some(HoldReason::PolicyDenied { mode, revision }) => {
648                        (Some(encode(&mode)), Some(sql_integer(revision)?))
649                    }
650                    _ => (None, None),
651                };
652                conn.execute(
653                    HOLD_SQL,
654                    params![
655                        key.logical_message_id.to_string(),
656                        key.recipient_device_id.to_string(),
657                        sql_integer(key.recipient_key_epoch)?,
658                        reason.map(|r| encode(&r)),
659                        chrono::Utc::now().timestamp_micros(),
660                        policy_mode,
661                        policy_revision
662                    ],
663                )
664                .map_err(|e| map_err(e, op))?;
665                Ok(())
666            })
667            .await
668    }
669    /// Caller verifies the signature before reaching this store. Binding checks repeat inside
670    /// the transaction.
671    pub async fn accept_receipt(
672        &self,
673        key: EnvelopeKey,
674        state: TransportState,
675        receipt: Value,
676    ) -> StorageResult<()> {
677        self.notes
678            .with_writer_tx_storage("sender_transport_receipt", move |conn| {
679                let op = "sender_transport_receipt";
680                let row = load(conn, key)
681                    .map_err(|e| map_err(e, op))?
682                    .ok_or_else(|| invalid("unknown sender record"))?;
683                let disposition = match state {
684                    TransportState::RecipientStored => "stored",
685                    TransportState::RecipientQuarantined => "quarantined",
686                    _ => return Err(invalid("receipt target is not a recipient outcome")),
687                };
688                if receipt.get("disposition").and_then(Value::as_str) != Some(disposition) {
689                    return Err(invalid("receipt disposition mismatch"));
690                }
691                let binding = receipt
692                    .get("binding")
693                    .ok_or_else(|| invalid("missing receipt binding"))?;
694                let e = &row.envelope;
695                let expected = [
696                    ("protocol_version", serde_json::json!(e.protocol_version)),
697                    (
698                        "logical_message_id",
699                        serde_json::json!(e.logical_message_id),
700                    ),
701                    ("sender_agent_id", serde_json::json!(e.sender_agent_id)),
702                    (
703                        "recipient_agent_id",
704                        serde_json::json!(e.recipient_agent_id),
705                    ),
706                    (
707                        "recipient_device_id",
708                        serde_json::json!(e.recipient_device_id),
709                    ),
710                    (
711                        "recipient_key_epoch",
712                        serde_json::json!(e.recipient_key_epoch),
713                    ),
714                    (
715                        "contact_generation",
716                        serde_json::json!(e.contact_generation),
717                    ),
718                ];
719                for (field, value) in expected {
720                    if binding.get(field) != Some(&value) {
721                        return Err(invalid("receipt binding mismatch"));
722                    }
723                }
724                let attempt = binding
725                    .get("delivery_attempt_id")
726                    .and_then(Value::as_str)
727                    .ok_or_else(|| invalid("missing delivery attempt"))?;
728                if Uuid::parse_str(attempt)
729                    .ok()
730                    .map(|id| id.to_string())
731                    .as_deref()
732                    != Some(attempt)
733                {
734                    return Err(invalid("invalid delivery attempt"));
735                }
736                if let Some(accepted) = row.receipt {
737                    if accepted != receipt || row.state != state {
738                        return Err(invalid("receipt_conflict"));
739                    }
740                    return Ok(());
741                }
742                conn.execute(
743                    RECEIPT_SQL,
744                    params![
745                        key.logical_message_id.to_string(),
746                        key.recipient_device_id.to_string(),
747                        sql_integer(key.recipient_key_epoch)?,
748                        encode(&state),
749                        receipt.to_string(),
750                        chrono::Utc::now().timestamp_micros()
751                    ],
752                )
753                .map_err(|e| map_err(e, op))?;
754                Ok(())
755            })
756            .await
757    }
758}
759#[cfg(test)]
760mod tests;