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}