1use 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#[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#[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 PolicyDenied {
118 mode: PolicyMode,
119 revision: u64,
120 },
121}
122
123#[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
380pub 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 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 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 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 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 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 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;