Skip to main content

lifeloop/router/
receipts.rs

1//! Receipt synthesis, idempotency, and run-scoped sequencing (issue #14).
2//!
3//! Fills the [`ReceiptEmitter`] seam declared in `src/router/seams.rs`
4//! (issue #7) and implements the status-mapping handoff documented on
5//! [`super::NegotiatedPlan`] (issue #13).
6//!
7//! # Boundary
8//!
9//! Owns:
10//! * [`ReceiptContext`] — caller-supplied identifiers Lifeloop cannot
11//!   synthesize (client id, harness ids, root receipt id, parent
12//!   receipt id, at-epoch clock value);
13//! * [`SequenceGenerator`] — a per-`lifeloop_run_id` monotonic counter
14//!   that scopes [`crate::LifecycleReceipt::sequence`] within a run.
15//!   Reset semantics are defined in the type docs;
16//! * [`IdempotencyStore`] (trait + [`InMemoryIdempotencyStore`]) — the
17//!   replay-boundary store keyed by
18//!   `(client_id, adapter_id, idempotency_key)`. Persistence is the
19//!   consumer's concern; the trait exists so a real consumer can plug
20//!   in a database without changing this module;
21//! * [`LifeloopReceiptEmitter`] — the concrete emitter that consumes a
22//!   [`super::NegotiatedPlan`] + [`crate::CallbackResponse`] and
23//!   produces a validated, idempotent [`crate::LifecycleReceipt`];
24//! * [`ReceiptError`] — typed failure variants for emission
25//!   (validation failures, idempotency conflicts, the
26//!   `receipt.emitted` rejection).
27//!
28//! Does **not** own:
29//! * failure-class mapping for arbitrary
30//!   [`super::validation::RouteError`]s — that is issue #15's
31//!   [`super::FailureMapper`] seam. This module reads
32//!   [`crate::FailureClass`] / [`crate::RetryClass`] directly when
33//!   building a `status=failed` receipt;
34//! * receipt persistence beyond the in-memory idempotency cache.
35//!   A consumer's durable receipt ledger plugs in via the
36//!   [`IdempotencyStore`] trait;
37//! * payload body inspection.
38//!
39//! # Status mapping (mirrors the `NegotiatedPlan` doc and seeds #15)
40//!
41//! | Source                                                         | Receipt status      | Failure class                                           |
42//! |----------------------------------------------------------------|---------------------|---------------------------------------------------------|
43//! | `event == receipt.emitted`                                     | (rejected — error)  | n/a                                                     |
44//! | `outcome=Unsupported`                                          | `failed`            | `negotiated.failure_class` (≥ `capability_unsupported`) |
45//! | `outcome=RequiresOperator`                                     | `failed`            | `operator_required`                                     |
46//! | `outcome=Degraded`                                             | `degraded`          | none                                                    |
47//! | `outcome=Satisfied` + any `placement_decisions[].Failed`       | `failed`            | first failed decision's `failure_class`                 |
48//! | `outcome=Satisfied` + `response.status=Failed`                 | `failed`            | `response.failure_class` (REQUIRED by validation)       |
49//! | `outcome=Satisfied` + `response.status=Skipped`                | `skipped`           | none                                                    |
50//! | `outcome=Satisfied` + `response.status=Observed`               | `observed`          | none                                                    |
51//! | `outcome=Satisfied` + `response.status=Degraded`               | `degraded`          | none                                                    |
52//! | `outcome=Satisfied` + `response.status=Delivered`              | `delivered`         | none                                                    |
53//!
54//! `retry_class` on a `failed` receipt is seeded via
55//! [`crate::FailureClass::default_retry`] unless the response or
56//! negotiation already provided one.
57//!
58//! NOTE: The spec status vocabulary is `observed | delivered | skipped
59//! | degraded | failed`. The issue brief's "blocked" shorthand for a
60//! halted dispatch maps to `failed` per the spec — there is no
61//! separate `blocked` variant on the wire.
62
63use std::collections::HashMap;
64use std::sync::Mutex;
65
66use crate::{
67    CallbackResponse, FailureClass, LifecycleEventKind, LifecycleReceipt, PayloadReceipt,
68    PlacementOutcome, ReceiptStatus, RetryClass, SCHEMA_VERSION, ValidationError,
69};
70
71use super::negotiation::{NegotiatedPlan, PayloadPlacementDecision};
72use super::seams::ReceiptEmitter;
73
74// ===========================================================================
75// ReceiptError
76// ===========================================================================
77
78/// Failure variants from receipt emission.
79///
80/// `Conflict` is the spec-named `duplicate_id_conflict`: the same
81/// `(client_id, adapter_id, idempotency_key)` tuple was reused with a
82/// receipt body that does not match the prior content.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum ReceiptError {
85    /// `receipt.emitted` is a notification event and must not itself
86    /// produce a receipt. Rejected at emit time so a misuse cannot
87    /// land in the idempotency store.
88    ReceiptEmittedNotEmittable,
89    /// Same `idempotency_key` reused with different content. Maps
90    /// onto the spec's `duplicate_id_conflict` failure class.
91    Conflict { idempotency_key: String },
92    /// The synthesized receipt failed [`LifecycleReceipt::validate`].
93    Invalid(ValidationError),
94}
95
96impl std::fmt::Display for ReceiptError {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        match self {
99            Self::ReceiptEmittedNotEmittable => f.write_str(
100                "receipt.emitted is a notification event and does not itself \
101                 produce a receipt",
102            ),
103            Self::Conflict { idempotency_key } => write!(
104                f,
105                "idempotency_key `{idempotency_key}` was reused with \
106                 different receipt content (duplicate_id_conflict)"
107            ),
108            Self::Invalid(e) => write!(f, "synthesized receipt failed validation: {e}"),
109        }
110    }
111}
112
113impl std::error::Error for ReceiptError {}
114
115impl From<ValidationError> for ReceiptError {
116    fn from(e: ValidationError) -> Self {
117        Self::Invalid(e)
118    }
119}
120
121// ===========================================================================
122// ReceiptContext
123// ===========================================================================
124
125/// Caller-supplied identifiers and clock value Lifeloop cannot
126/// synthesize on its own. Every field except the harness ids is
127/// required.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct ReceiptContext {
130    /// Client-declared stable label scoping idempotency and replay.
131    /// Each lifecycle client picks its own opaque identifier; see the
132    /// spec's "Receipt Schema" section in
133    /// `docs/specs/lifecycle-contract/body.md` for the illustrative
134    /// client-label list. Required-non-empty.
135    pub client_id: String,
136    /// Opaque identifier for *this* emitted receipt. Required-non-empty.
137    pub receipt_id: String,
138    /// Optional parent receipt id for nested or causally linked
139    /// lifecycle receipts. `None` for root receipts.
140    pub parent_receipt_id: Option<String>,
141    /// Wall-clock value to stamp on the receipt. Required.
142    pub at_epoch_s: u64,
143    /// Optional harness session id correlation.
144    pub harness_session_id: Option<String>,
145    /// Optional harness run id correlation. Receipts in the same
146    /// `lifeloop_run_id` (= `harness_run_id` when present) share a
147    /// monotonic [`SequenceGenerator`] counter; absent run id means
148    /// no in-run sequence is synthesized (`sequence` = `None`).
149    pub harness_run_id: Option<String>,
150    /// Optional harness task id correlation.
151    pub harness_task_id: Option<String>,
152}
153
154// ===========================================================================
155// SequenceGenerator
156// ===========================================================================
157
158/// Per-`lifeloop_run_id` monotonic sequence counter.
159///
160/// The spec ties `sequence` to "the strongest available durable
161/// session scope". For a Lifeloop-synthesized receipt, that scope is
162/// the `lifeloop_run_id` (= caller's `harness_run_id`). The generator
163/// keeps one counter per run id and hands out 1, 2, 3, ... within a
164/// run.
165///
166/// Sequence is per run, *not* global: emitting under run A then run
167/// B then run A again produces A=1, B=1, A=2.
168///
169/// When the caller supplies no `harness_run_id`, the emitter sets
170/// `sequence=None` rather than inventing a misleading cross-run order
171/// — see the spec's "required and nullable" rule for `sequence`.
172#[derive(Debug, Default)]
173pub struct SequenceGenerator {
174    counters: Mutex<HashMap<String, u64>>,
175}
176
177impl SequenceGenerator {
178    pub fn new() -> Self {
179        Self::default()
180    }
181
182    /// Allocate the next sequence value for `run_id` and return it.
183    /// Counters start at 1 within a run.
184    pub fn next(&self, run_id: &str) -> u64 {
185        let mut guard = self.counters.lock().expect("sequence mutex poisoned");
186        let slot = guard.entry(run_id.to_string()).or_insert(0);
187        *slot += 1;
188        *slot
189    }
190}
191
192// ===========================================================================
193// IdempotencyStore
194// ===========================================================================
195
196/// Composite key the idempotency store uses internally. The spec
197/// scopes idempotency by `(client_id, adapter_id, idempotency_key)`
198/// — the key type pins that triple so a consumer's persistent store
199/// cannot accidentally collapse the scope.
200#[derive(Debug, Clone, Hash, PartialEq, Eq)]
201pub struct IdempotencyKey {
202    pub client_id: String,
203    pub adapter_id: String,
204    pub idempotency_key: String,
205}
206
207/// Replay-boundary store. Distinct trait so a consumer can plug in a
208/// real database without this module knowing.
209///
210/// "Receipt content" for the replay comparison is the set of
211/// idempotency-invariant fields: every field *except* `receipt_id`,
212/// `at_epoch_s`, and `sequence`, which are minted fresh on each
213/// emission. Implementations MUST compare on that content (the
214/// in-memory implementation below is the reference shape) so a
215/// spec-compliant retry of the same `idempotency_key` replays rather
216/// than conflicting.
217pub trait IdempotencyStore {
218    /// Look up the prior receipt for `key`, if any.
219    fn get(&self, key: &IdempotencyKey) -> Option<LifecycleReceipt>;
220
221    /// Insert or replay-confirm `receipt` under `key`. Implementations
222    /// must enforce the spec's idempotency rule (comparing the
223    /// idempotency-invariant content defined above, not the
224    /// per-emission `receipt_id`/`at_epoch_s`/`sequence`):
225    /// * if no prior entry exists → insert and return
226    ///   `Ok(StoreOutcome::Inserted)`;
227    /// * if a prior entry exists with content equal to `receipt` →
228    ///   return `Ok(StoreOutcome::Replayed(prior))` *without*
229    ///   overwriting;
230    /// * if a prior entry exists with different content → return
231    ///   `Err(ReceiptError::Conflict { .. })`.
232    fn put(
233        &self,
234        key: &IdempotencyKey,
235        receipt: &LifecycleReceipt,
236    ) -> Result<StoreOutcome, ReceiptError>;
237}
238
239/// Outcome of a successful [`IdempotencyStore::put`].
240///
241/// `Replayed` carries a full [`LifecycleReceipt`]. The size disparity
242/// between `Inserted` (zero-sized) and `Replayed` triggers
243/// `clippy::large_enum_variant`; we accept it rather than boxing
244/// because every `put()` returns a stack-local `StoreOutcome` and
245/// boxing would force every `IdempotencyStore` impl through an extra
246/// indirection for no measurable benefit (mirrors the choice for
247/// `AdapterResolution::Found` in `validation.rs`).
248#[derive(Debug, Clone, PartialEq, Eq)]
249#[allow(clippy::large_enum_variant)]
250pub enum StoreOutcome {
251    /// First write under this key. The supplied receipt is now the
252    /// canonical value.
253    Inserted,
254    /// Replay of an existing receipt; the prior value is returned
255    /// unchanged.
256    Replayed(LifecycleReceipt),
257}
258
259/// In-memory reference [`IdempotencyStore`] backed by a `HashMap`
260/// behind a `Mutex` for interior mutability through `&self`.
261///
262/// Suitable for tests and for single-process consumers who do not
263/// need durable replay protection.
264#[derive(Debug, Default)]
265pub struct InMemoryIdempotencyStore {
266    inner: Mutex<HashMap<IdempotencyKey, LifecycleReceipt>>,
267}
268
269impl InMemoryIdempotencyStore {
270    pub fn new() -> Self {
271        Self::default()
272    }
273}
274
275impl IdempotencyStore for InMemoryIdempotencyStore {
276    fn get(&self, key: &IdempotencyKey) -> Option<LifecycleReceipt> {
277        self.inner
278            .lock()
279            .expect("idem mutex poisoned")
280            .get(key)
281            .cloned()
282    }
283
284    fn put(
285        &self,
286        key: &IdempotencyKey,
287        receipt: &LifecycleReceipt,
288    ) -> Result<StoreOutcome, ReceiptError> {
289        let mut guard = self.inner.lock().expect("idem mutex poisoned");
290        if let Some(prior) = guard.get(key) {
291            if replay_content_eq(prior, receipt) {
292                return Ok(StoreOutcome::Replayed(prior.clone()));
293            }
294            return Err(ReceiptError::Conflict {
295                idempotency_key: key.idempotency_key.clone(),
296            });
297        }
298        guard.insert(key.clone(), receipt.clone());
299        Ok(StoreOutcome::Inserted)
300    }
301}
302
303/// Replay content-equality over idempotency-invariant fields.
304///
305/// The spec's idempotency rule keys a replay on identical *receipt
306/// content*, but `receipt_id`, `at_epoch_s`, and `sequence` are
307/// minted fresh on every emission (a new opaque id, a new wall-clock
308/// stamp, and a per-run monotonic counter). Comparing the whole
309/// receipt would therefore turn a spec-compliant retry of the same
310/// `idempotency_key` into a false `duplicate_id_conflict`. We exclude
311/// exactly those per-emission fields and compare the rest, so a retry
312/// that produces the same logical receipt replays cleanly.
313fn replay_content_eq(a: &LifecycleReceipt, b: &LifecycleReceipt) -> bool {
314    a.schema_version == b.schema_version
315        && a.idempotency_key == b.idempotency_key
316        && a.client_id == b.client_id
317        && a.adapter_id == b.adapter_id
318        && a.invocation_id == b.invocation_id
319        && a.event == b.event
320        && a.event_id == b.event_id
321        && a.parent_receipt_id == b.parent_receipt_id
322        && a.integration_mode == b.integration_mode
323        && a.status == b.status
324        && a.harness_session_id == b.harness_session_id
325        && a.harness_run_id == b.harness_run_id
326        && a.harness_task_id == b.harness_task_id
327        && a.payload_receipts == b.payload_receipts
328        && a.telemetry_summary == b.telemetry_summary
329        && a.capability_degradations == b.capability_degradations
330        && a.failure_class == b.failure_class
331        && a.retry_class == b.retry_class
332        && a.warnings == b.warnings
333}
334
335// ===========================================================================
336// LifeloopReceiptEmitter
337// ===========================================================================
338
339/// Concrete [`ReceiptEmitter`] for issue #14.
340///
341/// Holds a sequence generator and an idempotency store. The
342/// [`ReceiptEmitter::emit`] trait method takes a fully-built
343/// [`LifecycleReceipt`] (the seam from issue #7) and validates +
344/// idempotency-checks it. The richer
345/// [`Self::synthesize_and_emit`] entry point consumes a
346/// [`NegotiatedPlan`] + [`CallbackResponse`] + [`ReceiptContext`] and
347/// builds the receipt before storing it — that is the path issue #14
348/// is delivering.
349#[derive(Debug)]
350pub struct LifeloopReceiptEmitter<S: IdempotencyStore = InMemoryIdempotencyStore> {
351    sequencer: SequenceGenerator,
352    store: S,
353}
354
355impl LifeloopReceiptEmitter<InMemoryIdempotencyStore> {
356    /// Construct an emitter backed by an in-memory idempotency store.
357    pub fn in_memory() -> Self {
358        Self {
359            sequencer: SequenceGenerator::new(),
360            store: InMemoryIdempotencyStore::new(),
361        }
362    }
363}
364
365impl<S: IdempotencyStore> LifeloopReceiptEmitter<S> {
366    /// Construct an emitter with a caller-supplied store (e.g. a
367    /// database-backed implementation in production).
368    pub fn with_store(store: S) -> Self {
369        Self {
370            sequencer: SequenceGenerator::new(),
371            store,
372        }
373    }
374
375    pub fn store(&self) -> &S {
376        &self.store
377    }
378
379    pub fn sequencer(&self) -> &SequenceGenerator {
380        &self.sequencer
381    }
382
383    /// Build, validate, and idempotency-check a receipt from a
384    /// negotiation result + the client's response.
385    ///
386    /// Returns the canonical receipt (newly inserted, or the prior
387    /// replay value when the same idempotency key is reused with the
388    /// same content).
389    pub fn synthesize_and_emit(
390        &self,
391        negotiated: &NegotiatedPlan,
392        response: &CallbackResponse,
393        ctx: &ReceiptContext,
394    ) -> Result<LifecycleReceipt, ReceiptError> {
395        // `receipt.emitted` is a notification event and never itself
396        // produces a receipt. Reject at emit time so the
397        // idempotency store cannot record an illegal receipt even
398        // before validate() runs.
399        if matches!(negotiated.plan.event, LifecycleEventKind::ReceiptEmitted) {
400            return Err(ReceiptError::ReceiptEmittedNotEmittable);
401        }
402
403        let (status, failure_class, retry_class) = derive_status(negotiated, response);
404
405        // Replay check BEFORE sequence allocation: if this idempotency
406        // key has already been recorded, reuse the prior sequence so a
407        // content-equality replay short-circuits cleanly. Otherwise a
408        // replay would synthesize seq=N+1, fail the content-equality
409        // check, and surface as a false Conflict.
410        let prior_sequence = negotiated.plan.idempotency_key.as_deref().and_then(|idem| {
411            let key = IdempotencyKey {
412                client_id: ctx.client_id.clone(),
413                adapter_id: negotiated.plan.adapter.adapter_id.clone(),
414                idempotency_key: idem.to_string(),
415            };
416            self.store.get(&key).map(|prior| prior.sequence)
417        });
418
419        // Sequence is scoped to lifeloop_run_id (= harness_run_id
420        // when supplied). Without a run id, leave sequence null.
421        let sequence = match prior_sequence {
422            Some(reused) => reused,
423            None => ctx
424                .harness_run_id
425                .as_deref()
426                .map(|run| self.sequencer.next(run)),
427        };
428
429        let receipt = LifecycleReceipt {
430            schema_version: SCHEMA_VERSION.to_string(),
431            receipt_id: ctx.receipt_id.clone(),
432            idempotency_key: negotiated.plan.idempotency_key.clone(),
433            client_id: ctx.client_id.clone(),
434            adapter_id: negotiated.plan.adapter.adapter_id.clone(),
435            invocation_id: negotiated.plan.invocation_id.clone(),
436            event: negotiated.plan.event,
437            event_id: negotiated.plan.event_id.clone(),
438            sequence,
439            parent_receipt_id: ctx.parent_receipt_id.clone(),
440            integration_mode: negotiated.plan.integration_mode,
441            status,
442            at_epoch_s: ctx.at_epoch_s,
443            harness_session_id: ctx.harness_session_id.clone(),
444            harness_run_id: ctx.harness_run_id.clone(),
445            harness_task_id: ctx.harness_task_id.clone(),
446            payload_receipts: payload_receipts_from(negotiated),
447            telemetry_summary: serde_json::Map::new(),
448            capability_degradations: negotiated.capability_degradations.clone(),
449            failure_class,
450            retry_class,
451            warnings: merged_warnings(negotiated, response),
452        };
453
454        receipt.validate()?;
455
456        match negotiated.plan.idempotency_key.as_deref() {
457            Some(idem) => {
458                let key = IdempotencyKey {
459                    client_id: ctx.client_id.clone(),
460                    adapter_id: negotiated.plan.adapter.adapter_id.clone(),
461                    idempotency_key: idem.to_string(),
462                };
463                match self.store.put(&key, &receipt)? {
464                    StoreOutcome::Inserted => Ok(receipt),
465                    StoreOutcome::Replayed(prior) => Ok(prior),
466                }
467            }
468            // No idempotency key — the receipt itself is the replay
469            // boundary; we hand it back unstored. A future durable
470            // ledger may still record it under `receipt_id`, but
471            // that is the consumer's concern.
472            None => Ok(receipt),
473        }
474    }
475}
476
477impl<S: IdempotencyStore> ReceiptEmitter for LifeloopReceiptEmitter<S> {
478    type Error = ReceiptError;
479
480    /// Validate + idempotency-check an externally-built
481    /// [`LifecycleReceipt`]. The richer
482    /// [`Self::synthesize_and_emit`] is the path issue #14 is
483    /// delivering; this trait method exists to satisfy the issue #7
484    /// seam contract for callers that already hold a built receipt.
485    fn emit(&self, receipt: &LifecycleReceipt) -> Result<(), Self::Error> {
486        receipt.validate()?;
487        if let Some(idem) = receipt.idempotency_key.as_deref() {
488            let key = IdempotencyKey {
489                client_id: receipt.client_id.clone(),
490                adapter_id: receipt.adapter_id.clone(),
491                idempotency_key: idem.to_string(),
492            };
493            self.store.put(&key, receipt)?;
494        }
495        Ok(())
496    }
497}
498
499// ===========================================================================
500// Status derivation (the public mapping table mirrored at the top of file)
501// ===========================================================================
502
503fn derive_status(
504    negotiated: &NegotiatedPlan,
505    response: &CallbackResponse,
506) -> (ReceiptStatus, Option<FailureClass>, Option<RetryClass>) {
507    use crate::NegotiationOutcome as NO;
508
509    // Capability outcomes that block dispatch always win.
510    match negotiated.outcome {
511        NO::Unsupported => {
512            let fc = negotiated
513                .failure_class
514                .unwrap_or(FailureClass::CapabilityUnsupported);
515            return (ReceiptStatus::Failed, Some(fc), Some(fc.default_retry()));
516        }
517        NO::RequiresOperator => {
518            let fc = FailureClass::OperatorRequired;
519            return (ReceiptStatus::Failed, Some(fc), Some(fc.default_retry()));
520        }
521        NO::Degraded => {
522            // Fall through to client-status-aware refinement below
523            // unless the client itself reported Failed.
524            if matches!(response.status, ReceiptStatus::Failed) {
525                let fc = response
526                    .failure_class
527                    .unwrap_or(FailureClass::InternalError);
528                let rc = response.retry_class.unwrap_or(fc.default_retry());
529                return (ReceiptStatus::Failed, Some(fc), Some(rc));
530            }
531            return (ReceiptStatus::Degraded, None, None);
532        }
533        NO::Satisfied => {}
534    }
535
536    // Satisfied negotiation: a placement decision may still have
537    // failed (e.g. payload_too_large on a non-blocking outcome —
538    // negotiation already escalates to Unsupported when this
539    // happens, but be defensive).
540    if let Some(failed) = negotiated.placement_decisions.iter().find_map(|d| match d {
541        PayloadPlacementDecision::Failed { failure_class, .. } => Some(*failure_class),
542        _ => None,
543    }) {
544        return (
545            ReceiptStatus::Failed,
546            Some(failed),
547            Some(failed.default_retry()),
548        );
549    }
550
551    // Honor the client's reported status.
552    match response.status {
553        ReceiptStatus::Failed => {
554            let fc = response
555                .failure_class
556                .unwrap_or(FailureClass::InternalError);
557            let rc = response.retry_class.unwrap_or(fc.default_retry());
558            (ReceiptStatus::Failed, Some(fc), Some(rc))
559        }
560        ReceiptStatus::Skipped => (ReceiptStatus::Skipped, None, None),
561        ReceiptStatus::Observed => (ReceiptStatus::Observed, None, None),
562        ReceiptStatus::Degraded => (ReceiptStatus::Degraded, None, None),
563        ReceiptStatus::Delivered => (ReceiptStatus::Delivered, None, None),
564    }
565}
566
567fn payload_receipts_from(negotiated: &NegotiatedPlan) -> Vec<PayloadReceipt> {
568    // Map placement decisions onto wire PayloadReceipts. Provenance
569    // comes from the payload envelope that negotiation evaluated, not
570    // from CallbackResponse::client_payloads; response payloads are
571    // client output and may be sparse or derived.
572    negotiated
573        .placement_decisions
574        .iter()
575        .map(|d| match d {
576            PayloadPlacementDecision::Chosen {
577                payload_id,
578                payload_kind,
579                byte_size,
580                content_digest,
581                chosen,
582                ..
583            } => PayloadReceipt {
584                payload_id: payload_id.clone(),
585                payload_kind: payload_kind.clone(),
586                placement: *chosen,
587                status: PlacementOutcome::Delivered,
588                byte_size: *byte_size,
589                content_digest: content_digest.clone(),
590            },
591            PayloadPlacementDecision::Failed {
592                payload_id,
593                payload_kind,
594                byte_size,
595                content_digest,
596                rejected,
597                ..
598            } => PayloadReceipt {
599                payload_id: payload_id.clone(),
600                payload_kind: payload_kind.clone(),
601                // No placement won; surface the first attempted one
602                // for diagnostics. Falls back to ReceiptOnly when
603                // there were no attempts (unusual).
604                placement: rejected
605                    .first()
606                    .map(|r| r.placement())
607                    .unwrap_or(crate::PlacementClass::ReceiptOnly),
608                status: PlacementOutcome::Failed,
609                byte_size: *byte_size,
610                content_digest: content_digest.clone(),
611            },
612            PayloadPlacementDecision::Skipped {
613                payload_id,
614                payload_kind,
615                byte_size,
616                content_digest,
617                rejected,
618            } => PayloadReceipt {
619                payload_id: payload_id.clone(),
620                payload_kind: payload_kind.clone(),
621                // No placement won, but only preferred/optional
622                // placements were unavailable; surface the first
623                // attempted placement for diagnostics.
624                placement: rejected
625                    .first()
626                    .map(|r| r.placement())
627                    .unwrap_or(crate::PlacementClass::ReceiptOnly),
628                status: PlacementOutcome::Skipped,
629                byte_size: *byte_size,
630                content_digest: content_digest.clone(),
631            },
632        })
633        .collect()
634}
635
636fn merged_warnings(
637    negotiated: &NegotiatedPlan,
638    response: &CallbackResponse,
639) -> Vec<crate::Warning> {
640    let mut out = negotiated.warnings.clone();
641    out.extend(response.warnings.iter().cloned());
642    out
643}