Skip to main content

cratefield_module_webhooks/
deliver.rs

1//! The drain: lease due outbox rows within the invocation's
2//! [`ScheduledBudget`] (ADR 0023), sign and POST each one, and file the
3//! outcome — complete, retry with backoff, or dead-letter. This is the
4//! whole delivery policy of the module, in one place.
5
6use bytes::Bytes;
7use cratefield_core::{
8    Clock, DbError, DrainOptions, HttpClient, HttpError, IdGen, ModuleConfig, ModuleContext,
9    Outbox, OutboxRecord, Processed, ScheduledBudget, UlidIdGen,
10};
11use hmac::{Hmac, KeyInit, Mac};
12use http::header::CONTENT_TYPE;
13use http::{Request, StatusCode};
14use serde::Serialize;
15use sha2::Sha256;
16use std::fmt::Write as _;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicUsize, Ordering};
19use std::time::Duration;
20use time::OffsetDateTime;
21
22use crate::clock::iso;
23use crate::store::{self, DeadLetterReason, DeliveryJob, GiveUp};
24
25/// The one header a delivery is verified by, Stripe-style:
26/// `t=<unix seconds>,v1=<lowercase hex>`. The same scheme
27/// `cratefield-adapter-webhook-tracker` signs with, so one receiver-side
28/// verifier covers both.
29pub const SIGNATURE_HEADER: &str = "Cratefield-Signature";
30/// The event's identity, so a receiver can dedupe the at-least-once
31/// redeliveries the outbox contract promises.
32pub const EVENT_ID_HEADER: &str = "Cratefield-Event-Id";
33/// What happened, in the caller's own vocabulary.
34pub const EVENT_TYPE_HEADER: &str = "Cratefield-Event-Type";
35
36/// The first retry delay; each further attempt doubles it.
37const BACKOFF_BASE_SECS: u64 = 30;
38/// The ceiling on the doubling.
39const BACKOFF_MAX_SECS: u64 = 3_600;
40/// How long a claimed row stays leased to one drainer.
41const LEASE_SECS: i64 = 300;
42/// Rows claimed per pass, before the budget caps it further. A pass
43/// delivers sequentially — webhook receivers answer well within the
44/// [`LEASE_SECS`] the claim holds, and a sequential pass keeps the
45/// delivery log in attempt order.
46const DRAIN_BATCH: u64 = 16;
47/// What the [`ScheduledBudget`] charges per row processed: one subrequest
48/// per delivery attempt — at most one outbound POST — a conservative count
49/// (ADR 0023 counts the unit of work, not the database writes around it).
50const SUBREQUESTS_PER_DELIVERY: u32 = 1;
51
52/// What one drain pass did.
53///
54/// A per-row failure never aborts the pass; it is counted in `failed` and
55/// the row is due again when its five-minute lease would have lapsed,
56/// exactly the posture `module-notifications`' drain takes. The pass does
57/// stop early — releasing the rest, still due, for the next tick — when
58/// the invocation's [`ScheduledBudget`] runs out, which `deferred` counts.
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
60pub struct DrainReport {
61    /// Rows this pass processed — one subrequest charged each; the budget
62    /// allowed this many.
63    pub claimed: usize,
64    /// Answered 2xx: the row is completed.
65    pub delivered: usize,
66    /// Failed and scheduled for another attempt.
67    pub retried: usize,
68    /// Given up on, in `webhooks_dead_letters`, replayable.
69    pub dead_lettered: usize,
70    /// Dropped without an attempt: the endpoint was deleted between the
71    /// enqueue and the drain.
72    pub dropped: usize,
73    /// Rows whose delivery errored in a way this pass could not file —
74    /// the log write itself failed. The row is due again when its lease
75    /// would have lapsed.
76    pub failed: usize,
77    /// Claimed rows released untouched because the budget ran out: still
78    /// queued, due immediately, for the next tick.
79    pub deferred: u64,
80}
81
82/// The filing of one claimed row: what the report counts, and what the
83/// drain maps to a [`Processed`] for the outbox.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85enum Filed {
86    /// 2xx: the row completes.
87    Delivered,
88    /// Transient failure: retry at this instant, counting one attempt.
89    Retried(OffsetDateTime),
90    /// Terminal: dead-lettered, the row already moved out of the outbox.
91    DeadLettered,
92    /// The endpoint is gone: the row completes — nobody is subscribed.
93    Dropped,
94}
95
96/// The handler's outcome counters.
97///
98/// Atomics rather than a `Cell`/`RefCell`: the `scheduled` future is `Send`
99/// (core's `BoxFuture` carries `+ Send`) and a `&RefCell<T>` is not, so the
100/// handler cannot hold one across an await. `&Counters` is `Send + Sync`,
101/// and the handler runs one row at a time, so nothing contends.
102#[derive(Debug, Default)]
103struct Counters {
104    delivered: AtomicUsize,
105    retried: AtomicUsize,
106    dead_lettered: AtomicUsize,
107    dropped: AtomicUsize,
108    failed: AtomicUsize,
109}
110
111/// The delivery as the receiver sees it: the event's identity, the
112/// subject it is about, when it was enqueued, and the caller's payload
113/// under `data`.
114#[derive(Serialize)]
115struct Envelope<'a> {
116    id: &'a str,
117    #[serde(rename = "type")]
118    event_type: &'a str,
119    subject: &'a str,
120    created_at: &'a str,
121    data: &'a serde_json::Value,
122}
123
124/// Signs `body` at `signed_at` and renders the [`SIGNATURE_HEADER`] value:
125/// `t=<unix seconds>,v1=<lowercase hex>`, both parts in one header.
126///
127/// The MAC is HMAC-SHA256 over the bytes `{signed_at}.{body}` — the
128/// timestamp is bound INTO the signature, so a captured delivery cannot be
129/// replayed with a fresh timestamp: the re-stamp invalidates the MAC. That
130/// is the reason for this scheme over a separate, unsigned timestamp
131/// header, which a replay would carry untouched.
132pub(crate) fn signature_header(secret: &str, signed_at: i64, body: &[u8]) -> Option<String> {
133    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
134    mac.update(signed_at.to_string().as_bytes());
135    mac.update(b".");
136    mac.update(body);
137    Some(format!(
138        "t={signed_at},v1={}",
139        lower_hex(&mac.finalize().into_bytes())
140    ))
141}
142
143/// Lowercase hex, the case the Stripe-style verifiers expect.
144fn lower_hex(bytes: &[u8]) -> String {
145    let mut out = String::with_capacity(bytes.len() * 2);
146    for byte in bytes {
147        // Writing to a String cannot fail.
148        let _ = write!(out, "{byte:02x}");
149    }
150    out
151}
152
153/// The retry delay after `attempts_made` failed attempts: 30s doubling to
154/// a 1h ceiling.
155pub(crate) fn backoff(attempts_made: i64) -> Duration {
156    let doublings = u32::try_from(attempts_made.saturating_sub(1).clamp(0, 63)).unwrap_or(63);
157    Duration::from_secs(
158        BACKOFF_BASE_SECS
159            .saturating_mul(1_u64 << doublings)
160            .min(BACKOFF_MAX_SECS),
161    )
162}
163
164/// The clock reading one pass works in: the stored RFC 3339 spelling for
165/// every database timestamp, Unix seconds for the signature, and the
166/// `OffsetDateTime` itself for the timestamps the outbox applies.
167struct Timestamps {
168    at: OffsetDateTime,
169    iso: String,
170    unix: i64,
171}
172
173/// Now, through the deployment's `Clock` port — which [`drain`] has
174/// already hard-required, so there is no silent wall-clock fallback to
175/// mask a mis-wired context (tests would otherwise sign and stamp with a
176/// clock they never pinned).
177fn now_timestamps(clock: &Arc<dyn Clock>) -> Timestamps {
178    let at = clock.now();
179    Timestamps {
180        at,
181        iso: iso(at),
182        unix: at.unix_timestamp(),
183    }
184}
185
186/// Delivers as many due outbox rows as this pass can lease **within the
187/// invocation's [`ScheduledBudget`]** (ADR 0023), through the ports of
188/// `ctx` — the entry point the venture's scheduled invocation must use
189/// (a cron invocation builds no router, so a context parked at
190/// router-build time is exactly what this must not depend on).
191///
192/// Each row is signed and delivered once per pass: 2xx completes it,
193/// anything else retries with exponential backoff (410 Gone and
194/// SSRF-blocked destinations dead-letter immediately), and `MAX_ATTEMPTS`
195/// failures dead-letter it for [`Webhooks::replay`]. One delivery attempt
196/// spends one subrequest; when the budget runs out the rows it never
197/// reached are released, still due, for the next tick and counted in
198/// [`DrainReport::deferred`].
199///
200/// # Errors
201///
202/// [`DeliveryError`] when a port is missing, the claim fails, or the
203/// outbox refuses a completion write. A per-row *delivery* failure never
204/// aborts the pass: it is counted in [`DrainReport::failed`] and the row
205/// is due again when its lease would have lapsed.
206pub(crate) async fn drain(
207    ctx: &ModuleContext,
208    max_attempts: u32,
209) -> Result<DrainReport, DeliveryError> {
210    let db = ctx
211        .ports
212        .db
213        .clone()
214        .ok_or(DeliveryError::MissingPort("Database"))?;
215    let http = ctx
216        .ports
217        .http
218        .clone()
219        .ok_or(DeliveryError::MissingPort("HttpClient"))?;
220    // `requires()` declares this port, so its absence is a wiring bug —
221    // refused here, not papered over with the wall clock.
222    let clock = ctx
223        .ports
224        .clock
225        .clone()
226        .ok_or(DeliveryError::MissingPort("Clock"))?;
227    let max_attempts = i64::from(
228        ModuleConfig::new(crate::MODULE_NAME, &*ctx.config).get_u32("MAX_ATTEMPTS", max_attempts),
229    );
230
231    let counters = Counters::default();
232    let budget: &ScheduledBudget = &ctx.scheduled;
233    let core_report = Outbox::new(store::OUTBOX)
234        .drain_within(
235            &*db,
236            &*clock,
237            budget,
238            DrainOptions {
239                limit: DRAIN_BATCH,
240                lease: time::Duration::seconds(LEASE_SECS),
241                subrequests_per_item: SUBREQUESTS_PER_DELIVERY,
242            },
243            |record| {
244                // Clone the ports into the future: a `FnMut` handler cannot
245                // hand out borrows of its captures across an await.
246                let db = Arc::clone(&db);
247                let http = Arc::clone(&http);
248                let clock = Arc::clone(&clock);
249                let counters = &counters;
250                async move {
251                    let now = now_timestamps(&clock);
252                    match deliver_one(&*db, &*http, &record, max_attempts, &now).await {
253                        Ok(Filed::Delivered) => {
254                            counters.delivered.fetch_add(1, Ordering::Relaxed);
255                            Processed::Done
256                        }
257                        Ok(Filed::Retried(at)) => {
258                            counters.retried.fetch_add(1, Ordering::Relaxed);
259                            Processed::RetryAt(at)
260                        }
261                        Ok(Filed::DeadLettered) => {
262                            counters.dead_lettered.fetch_add(1, Ordering::Relaxed);
263                            // The dead letter already dropped the outbox row.
264                            Processed::Done
265                        }
266                        Ok(Filed::Dropped) => {
267                            counters.dropped.fetch_add(1, Ordering::Relaxed);
268                            Processed::Done
269                        }
270                        // The row keeps its lease: released due when that
271                        // lapses, without counting an attempt. Abandoning
272                        // the rest of the pass over one error would leave
273                        // every row behind it leased and untouched for the
274                        // whole lease.
275                        Err(error) => {
276                            counters.failed.fetch_add(1, Ordering::Relaxed);
277                            tracing::error!(row = %record.id, %error, "delivering a webhook failed");
278                            Processed::NextAt(
279                                now.at.saturating_add(time::Duration::seconds(LEASE_SECS)),
280                            )
281                        }
282                    }
283                }
284            },
285        )
286        .await?;
287
288    Ok(DrainReport {
289        claimed: usize::try_from(core_report.processed).unwrap_or(usize::MAX),
290        delivered: counters.delivered.load(Ordering::Relaxed),
291        retried: counters.retried.load(Ordering::Relaxed),
292        dead_lettered: counters.dead_lettered.load(Ordering::Relaxed),
293        dropped: counters.dropped.load(Ordering::Relaxed),
294        failed: counters.failed.load(Ordering::Relaxed),
295        deferred: core_report.released,
296    })
297}
298
299/// One row: parse, resolve the endpoint, sign, send, and report how it was
300/// filed. The outbox row itself is left to [`drain_within`], which applies
301/// the [`Processed`] the caller maps this [`Filed`] to — except the
302/// dead-letter and drop paths, which move or remove the row here (a dead
303/// letter must be atomic with its row's removal).
304///
305/// [`drain_within`]: cratefield_core::Outbox::drain_within
306async fn deliver_one(
307    db: &dyn cratefield_core::Database,
308    http: &dyn HttpClient,
309    record: &OutboxRecord,
310    max_attempts: i64,
311    now: &Timestamps,
312) -> Result<Filed, DeliveryError> {
313    let Ok(job) = serde_json::from_str::<DeliveryJob>(&record.payload) else {
314        return dead_letter_malformed(db, record, now).await;
315    };
316
317    // One read for URL and secret: the listable endpoint type carries no
318    // secret, so this is the only query that ever selects one. `None` is
319    // an endpoint deleted between the enqueue and the drain: nobody is
320    // subscribed at the other end any more. Dropped, not dead-lettered —
321    // a replay would have nothing to deliver to either.
322    let Some(target) = store::delivery_target(db, &job.subject, &job.endpoint_id).await? else {
323        return Ok(Filed::Dropped);
324    };
325
326    let body = serde_json::to_vec(&Envelope {
327        id: &job.event_id,
328        event_type: &job.event_type,
329        subject: &job.subject,
330        created_at: &job.created_at,
331        data: &job.data,
332    })
333    .map_err(|error| DeliveryError::Encode(error.to_string()))?;
334
335    let attempt = record.attempts + 1;
336    match post(http, &target.url, &target.secret, &job, &body, now.unix).await {
337        Ok(status) if status.is_success() => {
338            log_attempt(
339                db,
340                &job,
341                attempt,
342                Some(i64::from(status.as_u16())),
343                None,
344                now,
345            )
346            .await?;
347            // Completed by the drain, off this outcome.
348            Ok(Filed::Delivered)
349        }
350        // The endpoint said never again: a permanent refusal no retry
351        // fixes, so it does not burn the remaining attempts.
352        Ok(StatusCode::GONE) => {
353            reject_now(
354                db,
355                &job,
356                record,
357                attempt,
358                "endpoint answered 410 Gone",
359                Some(i64::from(StatusCode::GONE.as_u16())),
360                now,
361            )
362            .await
363        }
364        Ok(status) => {
365            let why = format!("endpoint answered {status}");
366            retry_or_give_up(
367                db,
368                &job,
369                record,
370                now,
371                max_attempts,
372                Failure {
373                    attempt,
374                    why: &why,
375                    status_code: Some(i64::from(status.as_u16())),
376                },
377            )
378            .await
379        }
380        // A blocked destination is a permanent configuration problem (the
381        // `HttpClient` port's contract: loopback, private and metadata
382        // addresses are refused) — every retry would be blocked too.
383        Err(HttpError::BlockedDestination(why)) => {
384            let why = format!("destination refused by outbound policy: {why}");
385            reject_now(db, &job, record, attempt, &why, None, now).await
386        }
387        // Transport trouble, a deadline, an oversized reply: weather.
388        // Retry with backoff to a bound.
389        Err(error) => {
390            let why = error.to_string();
391            retry_or_give_up(
392                db,
393                &job,
394                record,
395                now,
396                max_attempts,
397                Failure {
398                    attempt,
399                    why: &why,
400                    status_code: None,
401                },
402            )
403            .await
404        }
405    }
406}
407
408/// The filing when the outbox payload is not a delivery job at all: log
409/// it, dead-letter it as `malformed` — a bug or a hand-edited database,
410/// not weather — and keep the pass moving.
411async fn dead_letter_malformed(
412    db: &dyn cratefield_core::Database,
413    record: &OutboxRecord,
414    now: &Timestamps,
415) -> Result<Filed, DeliveryError> {
416    tracing::error!(row = %record.id, "outbox row is not a webhooks delivery job");
417    let why = "outbox payload is not a webhooks delivery job";
418    let broken = broken_job(&record.payload, &record.topic);
419    log_attempt(db, &broken, 1, None, Some(why), now).await?;
420    store::dead_letter(
421        db,
422        &UlidIdGen.ulid(),
423        &broken,
424        &GiveUp {
425            outbox_row_id: &record.id,
426            attempts: record.attempts + 1,
427            reason: DeadLetterReason::Malformed,
428            last_error: why,
429            status_code: None,
430            created_at: &broken.created_at,
431            failed_at: &now.iso,
432        },
433    )
434    .await?;
435    Ok(Filed::DeadLettered)
436}
437
438/// A failure no retry fixes (`410 Gone`, or the `HttpClient` port
439/// refusing the destination outright): log the attempt and dead-letter
440/// immediately as `rejected`, without burning the remaining attempts.
441async fn reject_now(
442    db: &dyn cratefield_core::Database,
443    job: &DeliveryJob,
444    record: &OutboxRecord,
445    attempt: i64,
446    why: &str,
447    status_code: Option<i64>,
448    now: &Timestamps,
449) -> Result<Filed, DeliveryError> {
450    log_attempt(db, job, attempt, status_code, Some(why), now).await?;
451    store::dead_letter(
452        db,
453        &UlidIdGen.ulid(),
454        job,
455        &GiveUp {
456            outbox_row_id: &record.id,
457            attempts: attempt,
458            reason: DeadLetterReason::Rejected,
459            last_error: why,
460            status_code,
461            created_at: &job.created_at,
462            failed_at: &now.iso,
463        },
464    )
465    .await?;
466    Ok(Filed::DeadLettered)
467}
468
469/// A payload that failed to parse, for the dead letter's own columns. The
470/// topic is all the identity that survives; `data` keeps the raw payload
471/// so nothing a debugger needs is silently dropped. It names no endpoint
472/// and no subject, because none could be read.
473fn broken_job(payload: &str, topic: &str) -> DeliveryJob {
474    DeliveryJob {
475        endpoint_id: String::new(),
476        event_id: String::new(),
477        event_type: topic.to_owned(),
478        subject: String::new(),
479        created_at: String::new(),
480        data: serde_json::Value::String(payload.to_owned()),
481    }
482}
483
484/// POSTs the signed envelope. Returns the status of any answered request;
485/// transport failures are [`HttpError`]s.
486async fn post(
487    http: &dyn HttpClient,
488    url: &str,
489    secret: &str,
490    job: &DeliveryJob,
491    body: &[u8],
492    unix_now: i64,
493) -> Result<StatusCode, HttpError> {
494    let signature = signature_header(secret, unix_now, body)
495        .ok_or_else(|| HttpError::Transport("signing failed".to_owned()))?;
496    let request = Request::builder()
497        .method(http::Method::POST)
498        .uri(url)
499        .header(CONTENT_TYPE, "application/json")
500        .header(SIGNATURE_HEADER, signature)
501        .header(EVENT_ID_HEADER, job.event_id.as_str())
502        .header(EVENT_TYPE_HEADER, job.event_type.as_str())
503        .body(Bytes::copy_from_slice(body))
504        .map_err(|error| HttpError::Transport(error.to_string()))?;
505    Ok(http.send(request).await?.status())
506}
507
508/// Logs one attempt, whatever its outcome.
509async fn log_attempt(
510    db: &dyn cratefield_core::Database,
511    job: &DeliveryJob,
512    attempt: i64,
513    status_code: Option<i64>,
514    error: Option<&str>,
515    now: &Timestamps,
516) -> Result<(), DbError> {
517    store::record_delivery(
518        db,
519        &UlidIdGen.ulid(),
520        job,
521        attempt,
522        status_code,
523        error,
524        &now.iso,
525    )
526    .await
527}
528
529/// What a transient failure looked like to the attempt that saw it.
530struct Failure<'a> {
531    attempt: i64,
532    why: &'a str,
533    status_code: Option<i64>,
534}
535
536/// The transient-failure policy: back off to a bound, then dead-letter.
537/// Never a dead letter before the log row is written — the delivery log
538/// is how an operator finds out what the last attempt saw.
539async fn retry_or_give_up(
540    db: &dyn cratefield_core::Database,
541    job: &DeliveryJob,
542    record: &OutboxRecord,
543    now: &Timestamps,
544    max_attempts: i64,
545    failure: Failure<'_>,
546) -> Result<Filed, DeliveryError> {
547    let (attempt, why, status_code) = (failure.attempt, failure.why, failure.status_code);
548    log_attempt(db, job, attempt, status_code, Some(why), now).await?;
549    if attempt >= max_attempts {
550        store::dead_letter(
551            db,
552            &UlidIdGen.ulid(),
553            job,
554            &GiveUp {
555                outbox_row_id: &record.id,
556                attempts: attempt,
557                reason: DeadLetterReason::AttemptsExhausted,
558                last_error: &format!("gave up after {attempt} attempts: {why}"),
559                status_code,
560                created_at: &job.created_at,
561                failed_at: &now.iso,
562            },
563        )
564        .await?;
565        return Ok(Filed::DeadLettered);
566    }
567    // The outbox counts this attempt when it applies `Processed::RetryAt`.
568    let delay = backoff(attempt);
569    let seconds = i64::try_from(delay.as_secs()).unwrap_or(i64::from(u32::MAX));
570    Ok(Filed::Retried(
571        now.at.saturating_add(time::Duration::seconds(seconds)),
572    ))
573}
574
575/// Why a whole drain pass cannot start.
576#[derive(Debug, Clone, PartialEq, Eq)]
577pub enum DeliveryError {
578    /// A port the module requires is absent from this deployment.
579    MissingPort(&'static str),
580    /// The database refused a read or a write.
581    Database(DbError),
582    /// The envelope could not be serialised. Cannot happen for a payload
583    /// that parsed on the way in, but a serde error is not a panic.
584    Encode(String),
585}
586
587impl std::fmt::Display for DeliveryError {
588    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
589        match self {
590            DeliveryError::MissingPort(port) => write!(f, "webhooks: missing port {port}"),
591            DeliveryError::Database(error) => write!(f, "webhooks: {error}"),
592            DeliveryError::Encode(error) => write!(f, "webhooks: {error}"),
593        }
594    }
595}
596
597impl std::error::Error for DeliveryError {}
598
599impl From<DbError> for DeliveryError {
600    fn from(error: DbError) -> Self {
601        DeliveryError::Database(error)
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn the_signature_header_carries_a_bound_timestamp_and_lowercase_hex() {
611        let header =
612            signature_header("a test secret", 1_234, b"body bytes").expect("hmac accepts any key");
613        assert!(header.starts_with("t=1234,v1="), "{header}");
614        let hex = header.trim_start_matches("t=1234,v1=");
615        assert_eq!(hex.len(), 64, "SHA-256 in hex: {header}");
616        assert!(
617            hex.chars()
618                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
619            "the hex is lowercase: {header}"
620        );
621    }
622
623    #[test]
624    fn the_signature_binds_the_body_so_a_restamp_invalidates_it() {
625        let a = signature_header("secret", 1_234, b"one body").expect("signs");
626        let b = signature_header("secret", 1_234, b"another body").expect("signs");
627        let c = signature_header("secret", 1_235, b"one body").expect("signs");
628        assert_ne!(a, b, "a different body signs differently");
629        assert_ne!(a, c, "a different timestamp signs differently");
630    }
631
632    #[test]
633    fn backoff_doubles_from_thirty_seconds_to_the_one_hour_ceiling() {
634        assert_eq!(backoff(1), Duration::from_secs(30));
635        assert_eq!(backoff(2), Duration::from_secs(60));
636        assert_eq!(backoff(3), Duration::from_secs(120));
637        assert_eq!(backoff(8), Duration::from_secs(3_600));
638        assert_eq!(backoff(20), Duration::from_secs(3_600), "capped");
639        assert_eq!(backoff(0), Duration::from_secs(30), "floor at one attempt");
640    }
641
642    #[test]
643    fn the_envelope_names_the_event_its_subject_and_its_data() {
644        let job = store::test_job();
645        let body = serde_json::to_vec(&Envelope {
646            id: &job.event_id,
647            event_type: &job.event_type,
648            subject: &job.subject,
649            created_at: "2026-09-27T00:00:00Z",
650            data: &job.data,
651        })
652        .expect("serialises");
653        let parsed: serde_json::Value = serde_json::from_slice(&body).expect("valid JSON");
654        assert_eq!(parsed["id"], "ev");
655        assert_eq!(parsed["type"], "order.paid");
656        assert_eq!(parsed["subject"], "acct");
657        assert_eq!(parsed["created_at"], "2026-09-27T00:00:00Z");
658        assert_eq!(parsed["data"]["amount"], 1);
659    }
660
661    /// The receiver's side of the contract, exactly as a customer would
662    /// implement it: recompute the MAC over `{t}.{body}` and compare.
663    #[test]
664    fn a_receiver_recomputing_the_mac_verifies_the_delivery() {
665        let secret = "whsec_generated_by_create_endpoint";
666        let body = b"{\"id\":\"ev\",\"type\":\"order.paid\"}";
667        let signed_at = 1_792_000_000;
668        let header = signature_header(secret, signed_at, body).expect("signs");
669
670        let (t, v1) = header
671            .split_once(",v1=")
672            .map(|(t, v1)| (t.trim_start_matches("t="), v1))
673            .expect("the header shape");
674        assert_eq!(t, "1792000000");
675
676        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("any key");
677        mac.update(t.as_bytes());
678        mac.update(b".");
679        mac.update(body);
680        assert_eq!(lower_hex(&mac.finalize().into_bytes()), v1);
681    }
682
683    #[test]
684    fn dead_letter_reasons_round_trip_their_column_values() {
685        for (reason, stored) in [
686            (DeadLetterReason::AttemptsExhausted, "attempts_exhausted"),
687            (DeadLetterReason::Rejected, "rejected"),
688            (DeadLetterReason::Malformed, "malformed"),
689        ] {
690            assert_eq!(reason.as_str(), stored);
691        }
692    }
693}