Skip to main content

car_server_core/
feedback_drain.rs

1//! The feedback-spool drain — the background loop that uploads durable
2//! feedback submissions when connectivity and server capability allow.
3//!
4//! PR A, U5 of `docs/plans/2026-08-31-car-feedback-system.md` (Architecture
5//! DAEMON "Spool" drain; binding requirements 3/12/13/30/31); outcomes
6//! SPOOL-1/2/5/6/7, DEG-1/3, PRIV-1, IDEM-1.
7//!
8//! # Event-driven with a local-only park tick (PRIV-1 / SPOOL-5)
9//!
10//! Unlike the daemon's other background loops (`registry_reaper`,
11//! `command_scheduler` — interval tickers), this loop makes NO network on a
12//! schedule. It parks on a [`tokio::sync::Notify`] until `feedback.submit`
13//! wakes it ([`wake_feedback_drain`]), runs drain rounds while the outbox has
14//! work, and goes back to sleep. With an empty outbox there is no network
15//! call, nothing: [`run_drain_round`] returns before constructing a single
16//! request — before even consulting the transport — when no Queued entry
17//! exists. The distinguishing test
18//! `empty_outbox_produces_no_transport_calls` pins that with a counting mock,
19//! and `spawned_drain_parks_idle_and_drains_on_wake` pins the park phase.
20//!
21//! The in-process `Notify` cannot see a CLI's direct spool enqueue (the CLI
22//! writes to the spool with the daemon idle — codex finding #11), so the park
23//! additionally wakes every [`DrainConfig::park_check_interval`] for a
24//! **LOCAL-ONLY** disk check: does the outbox hold a PENDING (Queued/Sending)
25//! entry? When empty — the overwhelmingly common case — the tick reads the
26//! directory listing only; when entries exist it asks the spool which are
27//! still pending, so settled entries waiting out their TTL never break the
28//! park. Either way the tick touches the filesystem only and NEVER the
29//! transport, so PRIV-1 holds; when a pending entry appeared, the loop runs a
30//! normal drain round (which itself consults the transport only for Queued
31//! entries). The distinguishing test
32//! `cli_direct_enqueue_while_parked_drains_within_one_tick` enqueues through
33//! a second `Spool` handle without calling the drain handle.
34//!
35//! The one boot-time action is a single local spool read (plus one round IF
36//! entries were spooled while the daemon was down — SPOOL-2's restart half);
37//! on an idle install that read finds nothing and the loop parks.
38//!
39//! # Per-entry protocol (requirement 31 / SPOOL-6, checklist #1)
40//!
41//! `mark_sending` → transport → apply the returned
42//! [`DrainAction`] to the spool: `Acknowledge → mark_acknowledged`,
43//! `Requeue/RequeueAfter → mark_queued`, terminal → `mark_terminal`. Every
44//! transition is a PERSISTED `state.json` rewrite in
45//! `car_feedback_core::spool`, and the tests below re-open the spool from
46//! disk to assert the durable state, not an in-memory mirror. An entry found
47//! `Sending` at round start is a crashed drain's leftover: it is recovered to
48//! Queued and re-sent under the same immutable `client_submission_id` — safe
49//! by IDEM-1 (the server's idempotent 200 replay) and never a duplicate mint.
50//!
51//! # Pacing and backoff (requirement 12 / SPOOL-7)
52//!
53//! At most one upload per [`DrainConfig::min_upload_interval`] (15s),
54//! `Retry-After` honored up to [`DrainConfig::max_backoff`] (the transport
55//! already clamps the header to its `MAX_RETRY_AFTER_SECS`; the loop clamps
56//! again so no single header can park the drain until restart) and waited out
57//! through the same interruptible `wait_or_wake` as every other wait — a
58//! fresh submit's wake runs a round, whose upload the 15s pacing still
59//! spaces, and a repeat 429 re-arms the wait. This pacing is strictly PER-DEVICE: N
60//! reconnecting devices still submit N/15s in aggregate, so it cannot keep a
61//! CAR fleet inside the server's per-org `car-feedback` sliding window on its
62//! own — the fleet-wide no-429 guarantee is delivered server-side by that
63//! dedicated rate-limit policy (CAR's OWN bucket on `CarFeedbackController`,
64//! never the shared bug-report policy).
65//! Retriable failures back off exponentially up to
66//! [`DrainConfig::max_backoff`]. Held entries (no session / the capability
67//! probe not advertising the lane) re-check on
68//! [`DrainConfig::held_recheck_interval`] — that is what lets DEG-3's
69//! capability-flip drain happen without a user action, and it runs only
70//! while the outbox is non-empty, so PRIV-1 holds.
71//!
72//! # Retriable-attempt cap (finding F13) — per process, never terminal
73//!
74//! 5xx / 408 / 3xx / network failures are retriable by taxonomy, and without
75//! a ceiling one entry the server keeps failing would be re-posted every
76//! `max_backoff` for the life of the daemon. The spool's `state.json` carries
77//! no attempt counter (its record is immutable after enqueue except
78//! `state`/`settled_at`), and no HONEST terminal state exists for "gave up on
79//! a retriable failure": `TerminalRejected` means the server rejected the
80//! report and `TerminalActionable` names a user action. So the cap is an
81//! in-memory [`RetryLedger`] owned by the loop: after
82//! [`DrainConfig::max_retriable_attempts`] consecutive retriable failures an
83//! entry is skipped by this process's further rounds — BEFORE the eligibility
84//! probe, so it costs no network — logged once, and left `Queued` on disk
85//! (visible through SPOOL-3's staleness notice, exportable, never silently
86//! converted into a report nobody sees — requirement 13). The budget renews
87//! on daemon restart. A durable dead-letter needs a spool schema change
88//! (attempt counter + an honest terminal variant) and is a recorded follow-up.
89//! 429 (`Retry-After`) and holds are not failures of the entry and never
90//! consume budget.
91
92use std::collections::HashMap;
93use std::path::{Path, PathBuf};
94use std::sync::{Arc, OnceLock};
95use std::time::Duration;
96
97use async_trait::async_trait;
98use tokio::sync::Notify;
99use tokio::time::Instant;
100
101use car_feedback_core::spool::{
102    IdentityLane, Spool, SpoolEntryId, SpoolEntrySummary, SpoolState, TerminalReason,
103};
104use car_parslee::feedback_transport::{
105    DrainAction, DrainEligibility, FeedbackTransport, FeedbackTransportError, SubmitOutcome,
106    TransportActionableReason,
107};
108
109use crate::feedback::FEEDBACK_OUTBOX_DIR;
110use crate::session::ServerState;
111
112/// Tuning for one drain loop. Production uses [`DrainConfig::default`]; tests
113/// construct short intervals (a code-level parameter, not a knob — there is
114/// no env var and the default is always right, per house rule #1a).
115#[derive(Debug, Clone)]
116pub struct DrainConfig {
117    /// SPOOL-7: minimum gap between uploads (shared org rate-limit budget).
118    pub min_upload_interval: Duration,
119    /// First retry delay for a retriable failure; doubles per failed round.
120    pub initial_backoff: Duration,
121    /// Ceiling for the exponential schedule.
122    pub max_backoff: Duration,
123    /// How often held entries (capability not advertised / no session /
124    /// anonymous) re-check —
125    /// only while the outbox is non-empty (DEG-3 without violating PRIV-1).
126    pub held_recheck_interval: Duration,
127    /// While PARKED (no pending entries), how often the loop does the
128    /// LOCAL-ONLY disk check for pending entries that arrived without a
129    /// `Notify` — a CLI's direct spool enqueue (finding #11). The check reads
130    /// the outbox (the directory listing, then the spool's own list when
131    /// entries exist) and nothing else: no transport call is ever made from
132    /// the park tick itself.
133    pub park_check_interval: Duration,
134    /// Finding F13: consecutive retriable failures (5xx/408/3xx/network) an
135    /// entry may accumulate in THIS process before the drain stops re-posting
136    /// it — the entry stays `Queued` on disk and gets a fresh budget on the
137    /// next daemon start (see the module docs). At the default schedule the
138    /// default of 24 is roughly five hours of retrying (30s doubling to the
139    /// 15-minute ceiling, then 18 more 15-minute rounds).
140    pub max_retriable_attempts: u32,
141}
142
143impl Default for DrainConfig {
144    fn default() -> Self {
145        DrainConfig {
146            min_upload_interval: Duration::from_secs(15),
147            initial_backoff: Duration::from_secs(30),
148            max_backoff: Duration::from_secs(15 * 60),
149            held_recheck_interval: Duration::from_secs(15 * 60),
150            park_check_interval: Duration::from_secs(60),
151            max_retriable_attempts: 24,
152        }
153    }
154}
155
156/// The per-process retry ledger (finding F13): consecutive retriable failures
157/// per queued entry, owned by one drain loop and threaded through every
158/// [`run_drain_round`]. Deliberately NOT durable and NOT a terminal
159/// transition — see the module docs for why. Rows for entries that settled
160/// or were pruned are dropped each round, so the map never outgrows the live
161/// outbox.
162#[derive(Debug, Default)]
163pub struct RetryLedger {
164    failures: HashMap<SpoolEntryId, u32>,
165}
166
167impl RetryLedger {
168    /// One more retriable failure for `id`; logs ONCE, at the moment the cap
169    /// is reached, with the user-actionable recovery.
170    fn record_failure(&mut self, id: &SpoolEntryId, cap: u32) {
171        let count = self.failures.entry(id.clone()).or_insert(0);
172        *count = count.saturating_add(1);
173        if *count == cap {
174            tracing::warn!(
175                target: "car::feedback",
176                entry = %id, attempts = cap,
177                "feedback entry hit this daemon's retriable-attempt cap; it stays queued \
178                 (export it with `car feedback --export`) and retries again after the \
179                 daemon restarts"
180            );
181        }
182    }
183
184    /// Has `id` used up this process's budget?
185    fn is_exhausted(&self, id: &SpoolEntryId, cap: u32) -> bool {
186        self.failures.get(id).is_some_and(|count| *count >= cap)
187    }
188
189    /// Keep only the rows for entries still queued — settled/pruned entries
190    /// leave no residue.
191    fn retain_queued(&mut self, queued: &[SpoolEntrySummary]) {
192        self.failures
193            .retain(|id, _| queued.iter().any(|entry| &entry.id == id));
194    }
195}
196
197/// The drain's view of the upstream transport — exactly the two calls U5's
198/// transport exposes, as a trait so tests count and script them.
199#[async_trait]
200pub trait DrainTransport: Send + Sync {
201    async fn drain_eligible(&self, lane: &IdentityLane) -> DrainEligibility;
202    async fn submit(
203        &self,
204        bundle: &car_feedback_core::bundle::RedactedBundle,
205        lane: &IdentityLane,
206        client_submission_id: &str,
207    ) -> Result<SubmitOutcome, FeedbackTransportError>;
208}
209
210#[async_trait]
211impl DrainTransport for FeedbackTransport {
212    async fn drain_eligible(&self, lane: &IdentityLane) -> DrainEligibility {
213        FeedbackTransport::drain_eligible(self, lane).await
214    }
215    async fn submit(
216        &self,
217        bundle: &car_feedback_core::bundle::RedactedBundle,
218        lane: &IdentityLane,
219        client_submission_id: &str,
220    ) -> Result<SubmitOutcome, FeedbackTransportError> {
221        FeedbackTransport::submit_report(self, bundle, lane, client_submission_id).await
222    }
223}
224
225/// Boot-passive live transport (demand-driven credentials, car#661):
226/// constructing [`FeedbackTransport`] resolves the API base, which falls
227/// through to the published auth state — a SECRET-STORE read a cold daemon
228/// must never perform (`cold_daemon_handshake_is_passive_but_explicit_status_reads`
229/// pins the zero-read boot). The empty-spool round never consults the
230/// transport (PRIV-1), so deferring construction to the first call keeps
231/// boot, handshake, and idle parking credential-free; a construction failure
232/// surfaces as a per-entry Hold with the reason instead of a never-started
233/// drain.
234struct LazyLiveTransport {
235    inner: tokio::sync::OnceCell<FeedbackTransport>,
236    #[cfg(test)]
237    constructor_error: Option<String>,
238}
239
240impl LazyLiveTransport {
241    fn new() -> Self {
242        Self {
243            inner: tokio::sync::OnceCell::new(),
244            #[cfg(test)]
245            constructor_error: None,
246        }
247    }
248
249    #[cfg(test)]
250    fn failing(error: &str) -> Self {
251        Self {
252            inner: tokio::sync::OnceCell::new(),
253            constructor_error: Some(error.to_string()),
254        }
255    }
256
257    async fn get(&self) -> Result<&FeedbackTransport, String> {
258        #[cfg(test)]
259        if let Some(error) = &self.constructor_error {
260            return Err(error.clone());
261        }
262        self.inner
263            .get_or_try_init(|| async { FeedbackTransport::live() })
264            .await
265    }
266}
267
268#[async_trait]
269impl DrainTransport for LazyLiveTransport {
270    async fn drain_eligible(&self, lane: &IdentityLane) -> DrainEligibility {
271        match self.get().await {
272            Ok(transport) => DrainTransport::drain_eligible(transport, lane).await,
273            Err(error) => DrainEligibility::Hold {
274                reason: format!("feedback transport unavailable: {error}"),
275            },
276        }
277    }
278    async fn submit(
279        &self,
280        bundle: &car_feedback_core::bundle::RedactedBundle,
281        lane: &IdentityLane,
282        client_submission_id: &str,
283    ) -> Result<SubmitOutcome, FeedbackTransportError> {
284        match self.get().await {
285            Ok(transport) => {
286                DrainTransport::submit(transport, bundle, lane, client_submission_id).await
287            }
288            // Unreachable in practice: every round consults drain_eligible
289            // first, which Holds on a construction failure — but a sane
290            // retriable error keeps the seam total.
291            Err(error) => Err(FeedbackTransportError::FetchFailed(format!(
292                "feedback transport unavailable: {error}"
293            ))),
294        }
295    }
296}
297
298/// What one pass over the outbox concluded — drives the loop's next wait.
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub enum RoundOutcome {
301    /// No Queued entries — park on the Notify (no timer, PRIV-1).
302    Idle,
303    /// Queued entries exist but every one is held (no session / capability
304    /// not advertised /
305    /// anonymous while `anonymousIntake` is false — or past this process's
306    /// retriable-attempt cap, finding F13) — re-check on the held
307    /// cadence (DEG-3).
308    AllHeld,
309    /// At least one entry settled (acknowledged or terminal) — run again
310    /// immediately in case more remain.
311    Progressed,
312    /// Retriable failure(s) — wait out the exponential backoff, then retry.
313    Backoff { min_delay: Duration },
314    /// The server said 429 — no upload before this delay (Retry-After).
315    RetryAfter(Duration),
316}
317
318/// Handle to a spawned drain: `wake()` after every `feedback.submit` enqueue.
319#[derive(Clone)]
320pub struct FeedbackDrainHandle {
321    notify: Arc<Notify>,
322}
323
324impl FeedbackDrainHandle {
325    pub fn wake(&self) {
326        self.notify.notify_one();
327    }
328}
329
330/// The process-wide drain handle, set by the first spawn. `feedback.submit`
331/// wakes through [`wake_feedback_drain`] without holding a reference.
332static DRAIN_HANDLE: OnceLock<FeedbackDrainHandle> = OnceLock::new();
333
334/// Wake the spawned drain (a no-op when none is running — e.g. under tests
335/// or an embedder that never spawned one). Called by `feedback.submit` after
336/// each durable enqueue, so a submission starts draining without any poll.
337pub fn wake_feedback_drain() {
338    if let Some(handle) = DRAIN_HANDLE.get() {
339        handle.wake();
340    }
341}
342
343/// Spawn the drain for this daemon: derives the CAR state root the same way
344/// the `feedback.*` surface does, builds the live Parslee transport, and
345/// parks until the first `feedback.submit` (after one boot pass that uploads
346/// anything spooled while the daemon was down). Call once at boot, like
347/// `spawn_stale_registry_reaper`; the task dies with the runtime.
348pub fn spawn_feedback_drain(state: &ServerState) {
349    let car_home = match crate::feedback::car_home_dir(state) {
350        Ok(home) => home,
351        Err(e) => {
352            tracing::warn!(target: "car::feedback", error = %e, "feedback drain not started");
353            return;
354        }
355    };
356    // LAZY on purpose: building the live transport reads the published auth
357    // state through the secret store, and a cold daemon must boot with ZERO
358    // credential reads (car#661). Construction happens on the first round
359    // that actually has queued work.
360    let transport: Arc<dyn DrainTransport> = Arc::new(LazyLiveTransport::new());
361    let handle = spawn_feedback_drain_with(car_home, transport, DrainConfig::default());
362    // First spawn wins; a second daemon-in-process (tests) keeps its own handle.
363    let _ = DRAIN_HANDLE.set(handle);
364}
365
366/// Spawn a drain loop over an explicit spool root + transport + config — the
367/// seam production wiring and tests share.
368pub fn spawn_feedback_drain_with(
369    car_home: PathBuf,
370    transport: Arc<dyn DrainTransport>,
371    config: DrainConfig,
372) -> FeedbackDrainHandle {
373    let notify = Arc::new(Notify::new());
374    let handle = FeedbackDrainHandle {
375        notify: notify.clone(),
376    };
377    let spool_root = car_home.join(FEEDBACK_OUTBOX_DIR);
378    tokio::spawn(async move {
379        let mut last_upload: Option<Instant> = None;
380        // The F13 attempt ledger lives exactly as long as this loop: one
381        // daemon lifetime, one budget per entry.
382        let mut ledger = RetryLedger::default();
383        loop {
384            // Drain until the outbox is empty or everything holds.
385            let mut backoff = config.initial_backoff;
386            loop {
387                let outcome = run_drain_round(
388                    &spool_root,
389                    transport.as_ref(),
390                    &config,
391                    &mut last_upload,
392                    &mut ledger,
393                )
394                .await;
395                match outcome {
396                    RoundOutcome::Idle => break,
397                    RoundOutcome::Progressed => {
398                        backoff = config.initial_backoff;
399                    }
400                    RoundOutcome::AllHeld => {
401                        // Re-check held entries later — or immediately on a
402                        // new submit (which may also change the session).
403                        wait_or_wake(&notify, config.held_recheck_interval).await;
404                    }
405                    RoundOutcome::Backoff { min_delay } => {
406                        let delay = backoff.max(min_delay).min(config.max_backoff);
407                        wait_or_wake(&notify, delay).await;
408                        backoff = (backoff * 2).min(config.max_backoff);
409                    }
410                    RoundOutcome::RetryAfter(delay) => {
411                        // Retry-After is honored (requirement 12) — up to the
412                        // backoff ceiling: the header is advisory, and an
413                        // unclamped value on this single sequential task would
414                        // park every queued report until restart. The wait is
415                        // interruptible like every other wait in this loop; a
416                        // fresh submit's wake runs a round whose upload is
417                        // still paced by `min_upload_interval`, and a repeat
418                        // 429 simply re-arms this arm.
419                        let delay = delay.min(config.max_backoff);
420                        wait_or_wake(&notify, delay).await;
421                    }
422                }
423            }
424            // Outbox has no pending work: park until the next submit's Notify
425            // — or until the LOCAL-ONLY park tick spots a PENDING entry that
426            // arrived without one (a CLI direct enqueue, finding #11). The
427            // tick reads the outbox and nothing else; it makes NO transport
428            // call, so PRIV-1 holds while parked.
429            loop {
430                tokio::select! {
431                    _ = notify.notified() => break,
432                    _ = tokio::time::sleep(config.park_check_interval) => {
433                        if spool_has_pending_entries(&spool_root) {
434                            break;
435                        }
436                    }
437                }
438            }
439        }
440    });
441    handle
442}
443
444/// LOCAL-ONLY park-tick probe: does the outbox hold a PENDING (Queued or
445/// Sending) entry? Two-stage on purpose. The directory listing alone answers
446/// the overwhelmingly common case — an empty outbox — without opening the
447/// spool. Only when published entry directories exist does the probe ask the
448/// spool's own list surface which of them are still pending: settled entries
449/// (acknowledged/terminal) linger for the 14-day TTL and a stray hand-made
450/// directory is not an entry, and neither may break the park (grace r2 — the
451/// old any-directory answer made an idle-but-non-empty outbox run a spurious
452/// drain round every tick). No transport is touched on any path. Entries
453/// still in `.tmp-*` staging don't count; their publishing rename lands
454/// before the enqueuer returns. An unreadable outbox reads as empty, the same
455/// convention the round would report as a warning on the next wake.
456fn spool_has_pending_entries(spool_root: &Path) -> bool {
457    let Ok(dirents) = std::fs::read_dir(spool_root) else {
458        // Missing/unreadable outbox dir == empty.
459        return false;
460    };
461    let has_published_dir = dirents.flatten().any(|dirent| {
462        !dirent.file_name().to_string_lossy().starts_with(".tmp-")
463            && dirent.file_type().map(|t| t.is_dir()).unwrap_or(false)
464    });
465    if !has_published_dir {
466        return false;
467    }
468    let Ok(spool) = Spool::open(spool_root) else {
469        return false;
470    };
471    spool
472        .list()
473        .map(|rows| {
474            rows.iter()
475                .any(|row| matches!(row.state, SpoolState::Queued | SpoolState::Sending))
476        })
477        .unwrap_or(false)
478}
479
480async fn wait_or_wake(notify: &Notify, delay: Duration) {
481    tokio::select! {
482        _ = tokio::time::sleep(delay) => {}
483        _ = notify.notified() => {}
484    }
485}
486
487/// One pass over the outbox. Reads the spool BEFORE touching the transport:
488/// an empty outbox returns [`RoundOutcome::Idle`] having made zero transport
489/// calls (PRIV-1 / SPOOL-5). Every state change is applied to the durable
490/// spool via its transition API. `ledger` is the loop-owned F13 attempt
491/// ledger; a caller running rounds in isolation passes a fresh one.
492pub async fn run_drain_round(
493    spool_root: &Path,
494    transport: &dyn DrainTransport,
495    config: &DrainConfig,
496    last_upload: &mut Option<Instant>,
497    ledger: &mut RetryLedger,
498) -> RoundOutcome {
499    // All spool I/O is small bounded filesystem work; run_blocking is not
500    // needed for correctness here, and the drain runs on its own task.
501    let spool = match Spool::open(spool_root) {
502        Ok(s) => s,
503        Err(e) => {
504            tracing::warn!(target: "car::feedback", error = %e, "feedback spool unavailable");
505            return RoundOutcome::Backoff {
506                min_delay: config.initial_backoff,
507            };
508        }
509    };
510    let entries = match spool.list() {
511        Ok(rows) => rows,
512        Err(e) => {
513            tracing::warn!(target: "car::feedback", error = %e, "feedback spool list failed");
514            return RoundOutcome::Backoff {
515                min_delay: config.initial_backoff,
516            };
517        }
518    };
519
520    // Crash recovery (checklist #1's restart scenario): a Sending entry at
521    // round start is a previous drain's in-flight leftover — hand it back to
522    // Queued; re-sending under the same client_submission_id is IDEM-1-safe.
523    let mut queued: Vec<_> = Vec::new();
524    for entry in entries {
525        match &entry.state {
526            SpoolState::Queued => queued.push(entry),
527            SpoolState::Sending => {
528                if let Err(e) = spool.mark_queued(&entry.id) {
529                    tracing::warn!(
530                        target: "car::feedback",
531                        entry = %entry.id, error = %e,
532                        "stale Sending entry could not be recovered"
533                    );
534                } else {
535                    queued.push(entry);
536                }
537            }
538            SpoolState::Acknowledged { .. }
539            | SpoolState::TerminalActionable { .. }
540            | SpoolState::TerminalRejected { .. } => {}
541        }
542    }
543
544    if queued.is_empty() {
545        // PRIV-1: no queued work ⇒ the transport is never consulted.
546        return RoundOutcome::Idle;
547    }
548
549    // Finding F13: drop ledger rows for entries no longer queued, then set
550    // aside every entry past this process's retriable-attempt budget BEFORE
551    // the eligibility probe — a capped entry stays Queued on disk (never
552    // terminal, never pruned: requirement 13) and costs no network this
553    // round. The cap-hit warning was logged once when the budget ran out;
554    // each skip is debug-level only.
555    ledger.retain_queued(&queued);
556    let (queued, capped): (Vec<_>, Vec<_>) = queued
557        .into_iter()
558        .partition(|entry| !ledger.is_exhausted(&entry.id, config.max_retriable_attempts));
559    for entry in &capped {
560        tracing::debug!(
561            target: "car::feedback",
562            entry = %entry.id,
563            "feedback entry past this daemon's retriable-attempt cap; skipped this round"
564        );
565    }
566    if queued.is_empty() {
567        // Only capped entries remain: held by this process, no transport
568        // call — the held-recheck cadence keeps the loop calm.
569        return RoundOutcome::AllHeld;
570    }
571
572    // Per-round eligibility cache: the verdict now comes from the anonymous
573    // capability probe (`GET /api/v1/car-feedback/capability`) plus session
574    // presence — both install-global, neither per-org — so it is keyed on the
575    // LANE KIND only and N queued entries across any number of orgs cost at
576    // most one probe per lane kind per round ("cache the probe briefly" —
577    // the round IS the cache lifetime; the transport holds no cache).
578    let mut eligibility: HashMap<&'static str, DrainEligibility> = HashMap::new();
579    let mut progressed = false;
580    let mut retriable: Option<Duration> = None;
581
582    for entry in queued {
583        // Anonymous intake is deliberately unavailable in v1. Do not spend a
584        // bearer refresh + capability request rediscovering that fixed local
585        // fact every held-recheck round.
586        if matches!(entry.lane, IdentityLane::Anonymous) {
587            tracing::debug!(
588                target: "car::feedback",
589                entry = %entry.id,
590                "anonymous feedback entry held locally; v1 has no anonymous drain"
591            );
592            continue;
593        }
594        let cache_key = "authenticated";
595        let verdict = match eligibility.get(&cache_key) {
596            Some(v) => v.clone(),
597            None => {
598                let v = transport.drain_eligible(&entry.lane).await;
599                eligibility.insert(cache_key, v.clone());
600                v
601            }
602        };
603        if let DrainEligibility::Hold { reason } = verdict {
604            tracing::debug!(
605                target: "car::feedback",
606                entry = %entry.id, reason = %reason,
607                "feedback entry held queued"
608            );
609            continue;
610        }
611
612        // SPOOL-7: pace uploads inside the shared org window.
613        if let Some(last) = *last_upload {
614            let since = last.elapsed();
615            if since < config.min_upload_interval {
616                tokio::time::sleep(config.min_upload_interval - since).await;
617            }
618        }
619
620        if let Err(e) = spool.mark_sending(&entry.id) {
621            tracing::warn!(
622                target: "car::feedback",
623                entry = %entry.id, error = %e,
624                "mark_sending failed; skipping entry this round"
625            );
626            continue;
627        }
628
629        let bundle = match spool.load_bundle(&entry.id) {
630            Ok(b) => b,
631            Err(e) => {
632                // A corrupt stored bundle can never upload — terminal with
633                // the reason, not an infinite retry.
634                let _ = spool.mark_terminal(
635                    &entry.id,
636                    TerminalReason::Rejected {
637                        message: format!("stored bundle unreadable: {e}"),
638                    },
639                );
640                progressed = true;
641                continue;
642            }
643        };
644
645        let attempt = transport
646            .submit(&bundle, &entry.lane, &entry.client_submission_id)
647            .await;
648        *last_upload = Some(Instant::now());
649
650        match attempt {
651            Ok(SubmitOutcome { action, omitted }) => {
652                report_omitted(&entry.id, &omitted);
653                match action {
654                    DrainAction::Acknowledge { server_id } => {
655                        if apply(&spool, &entry.id, |s| {
656                            s.mark_acknowledged(&entry.id, &server_id)
657                        }) {
658                            progressed = true;
659                        }
660                    }
661                    DrainAction::Requeue { backoff } => {
662                        apply(&spool, &entry.id, |s| s.mark_queued(&entry.id));
663                        ledger.record_failure(&entry.id, config.max_retriable_attempts);
664                        let delay = Duration::from_secs(backoff);
665                        retriable = Some(retriable.map_or(delay, |d| d.max(delay)));
666                    }
667                    DrainAction::RequeueAfter { secs } => {
668                        apply(&spool, &entry.id, |s| s.mark_queued(&entry.id));
669                        // Rate-limited: stop the round — every further upload
670                        // this round would burst the same window
671                        // (requirement 12).
672                        return RoundOutcome::RetryAfter(Duration::from_secs(secs));
673                    }
674                    DrainAction::TerminalActionable { reason } => {
675                        let reason = match reason {
676                            TransportActionableReason::AuthRequired => TerminalReason::AuthRequired,
677                            TransportActionableReason::ReconsentRequired => {
678                                TerminalReason::ReconsentRequired
679                            }
680                            TransportActionableReason::Forbidden => TerminalReason::Forbidden,
681                        };
682                        if apply(&spool, &entry.id, |s| s.mark_terminal(&entry.id, reason)) {
683                            progressed = true;
684                        }
685                    }
686                    DrainAction::TerminalRejected { message } => {
687                        // The omitted notes ride the durable rejection
688                        // message — the one settle path with a persisted
689                        // free-text slot today.
690                        let message = match omitted_suffix(&omitted) {
691                            Some(suffix) => format!("{message}{suffix}"),
692                            None => message,
693                        };
694                        if apply(&spool, &entry.id, |s| {
695                            s.mark_terminal(&entry.id, TerminalReason::Rejected { message })
696                        }) {
697                            progressed = true;
698                        }
699                    }
700                }
701            }
702            // Typed holds (anonymous intake not accepted, session lost between the
703            // eligibility check and the send): back to Queued, no terminal.
704            Err(FeedbackTransportError::AnonymousNotYetSupported)
705            | Err(FeedbackTransportError::NoSession) => {
706                apply(&spool, &entry.id, |s| s.mark_queued(&entry.id));
707            }
708            // Read-path taxonomy leaking into a submit is unexpected but must
709            // not orphan the entry: a rejected bearer holds Queued exactly
710            // like NoSession (the user may sign back in); a transport-level
711            // fetch failure is retriable on the backoff schedule.
712            Err(FeedbackTransportError::Unauthorized) => {
713                apply(&spool, &entry.id, |s| s.mark_queued(&entry.id));
714            }
715            Err(FeedbackTransportError::FetchFailed(e)) => {
716                tracing::warn!(
717                    target: "car::feedback",
718                    entry = %entry.id, error = %e,
719                    "feedback submit transport failure; will retry"
720                );
721                apply(&spool, &entry.id, |s| s.mark_queued(&entry.id));
722                ledger.record_failure(&entry.id, config.max_retriable_attempts);
723                let delay = config.initial_backoff;
724                retriable = Some(retriable.map_or(delay, |d| d.max(delay)));
725            }
726        }
727    }
728
729    if let Some(min_delay) = retriable {
730        RoundOutcome::Backoff { min_delay }
731    } else if progressed {
732        RoundOutcome::Progressed
733    } else {
734        RoundOutcome::AllHeld
735    }
736}
737
738/// One `"; omitted: a; b"` suffix for a settle message, `None` when the full
739/// bundle rode the wire.
740fn omitted_suffix(omitted: &[String]) -> Option<String> {
741    if omitted.is_empty() {
742        None
743    } else {
744        Some(format!(" (omitted: {})", omitted.join("; ")))
745    }
746}
747
748/// Surface the transport's omitted-item notes (the 413/preflight fallback
749/// ladder's honesty trail). For a rejected entry they are folded into the
750/// durable message at the call site; for an acknowledged entry the spool has
751/// no free-text slot yet, so they are logged — attaching them to the
752/// acknowledged `state.json` awaits a spool note API (recorded as an open
753/// item on the PR).
754fn report_omitted(id: &SpoolEntryId, omitted: &[String]) {
755    if let Some(suffix) = omitted_suffix(omitted) {
756        tracing::warn!(
757            target: "car::feedback",
758            entry = %id,
759            "feedback upload sent with omissions{suffix}"
760        );
761    }
762}
763
764/// Apply one spool transition, logging (never panicking) on failure — the
765/// drain must survive a hand-damaged entry and keep serving the rest.
766fn apply(
767    spool: &Spool,
768    id: &SpoolEntryId,
769    transition: impl FnOnce(&Spool) -> std::io::Result<()>,
770) -> bool {
771    match transition(spool) {
772        Ok(()) => true,
773        Err(e) => {
774            tracing::warn!(
775                target: "car::feedback",
776                entry = %id, error = %e,
777                "spool transition failed"
778            );
779            false
780        }
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use car_feedback_core::bundle::{collect, CollectInputs, RedactedBundle};
788    use std::sync::atomic::{AtomicUsize, Ordering};
789    use std::sync::Mutex;
790    use tempfile::TempDir;
791
792    /// Counting, scriptable mock transport. Every call — eligibility included
793    /// — counts as a transport call for the PRIV-1 distinguishing test.
794    struct MockTransport {
795        eligible_calls: AtomicUsize,
796        submit_calls: AtomicUsize,
797        eligibility: Mutex<DrainEligibility>,
798        /// Successive submit results; the last one repeats.
799        script: Mutex<Vec<Result<DrainAction, FeedbackTransportError>>>,
800        /// Omitted-item notes attached to every scripted Ok outcome (the
801        /// transport fallback-ladder honesty trail).
802        omitted: Mutex<Vec<String>>,
803        /// Virtual timestamps of each submit (paused-clock pacing asserts).
804        submit_at: Mutex<Vec<Instant>>,
805        submitted_ids: Mutex<Vec<String>>,
806    }
807
808    impl MockTransport {
809        fn new(action: DrainAction) -> Self {
810            MockTransport {
811                eligible_calls: AtomicUsize::new(0),
812                submit_calls: AtomicUsize::new(0),
813                eligibility: Mutex::new(DrainEligibility::Eligible),
814                script: Mutex::new(vec![Ok(action)]),
815                omitted: Mutex::new(Vec::new()),
816                submit_at: Mutex::new(Vec::new()),
817                submitted_ids: Mutex::new(Vec::new()),
818            }
819        }
820
821        fn holding(reason: &str) -> Self {
822            let t = Self::new(DrainAction::Acknowledge {
823                server_id: "unused".into(),
824            });
825            *t.eligibility.lock().unwrap() = DrainEligibility::Hold {
826                reason: reason.to_string(),
827            };
828            t
829        }
830
831        fn total_calls(&self) -> usize {
832            self.eligible_calls.load(Ordering::SeqCst) + self.submit_calls.load(Ordering::SeqCst)
833        }
834    }
835
836    #[async_trait]
837    impl DrainTransport for MockTransport {
838        async fn drain_eligible(&self, _lane: &IdentityLane) -> DrainEligibility {
839            self.eligible_calls.fetch_add(1, Ordering::SeqCst);
840            self.eligibility.lock().unwrap().clone()
841        }
842        async fn submit(
843            &self,
844            _bundle: &RedactedBundle,
845            _lane: &IdentityLane,
846            client_submission_id: &str,
847        ) -> Result<SubmitOutcome, FeedbackTransportError> {
848            self.submit_calls.fetch_add(1, Ordering::SeqCst);
849            self.submit_at.lock().unwrap().push(Instant::now());
850            self.submitted_ids
851                .lock()
852                .unwrap()
853                .push(client_submission_id.to_string());
854            let mut script = self.script.lock().unwrap();
855            let next = if script.len() > 1 {
856                script.remove(0)
857            } else {
858                script[0].clone()
859            };
860            next.map(|action| SubmitOutcome {
861                action,
862                omitted: self.omitted.lock().unwrap().clone(),
863            })
864        }
865    }
866
867    fn bundle() -> RedactedBundle {
868        let tmp = TempDir::new().unwrap();
869        collect(CollectInputs {
870            description: "the command deck window went blank".to_string(),
871            state_root: Some(tmp.path().to_path_buf()),
872            ..CollectInputs::default()
873        })
874        .unwrap()
875    }
876
877    fn auth_lane() -> IdentityLane {
878        IdentityLane::Authenticated {
879            org_id: "org_abc".to_string(),
880        }
881    }
882
883    fn fast_config() -> DrainConfig {
884        DrainConfig {
885            min_upload_interval: Duration::from_secs(15),
886            initial_backoff: Duration::from_secs(1),
887            max_backoff: Duration::from_secs(8),
888            held_recheck_interval: Duration::from_secs(60),
889            park_check_interval: Duration::from_secs(60),
890            max_retriable_attempts: DrainConfig::default().max_retriable_attempts,
891        }
892    }
893
894    /// A fresh per-call ledger — the isolation most rounds below want (no
895    /// cap can trip inside one round: an entry fails at most once per round).
896    fn ledger() -> RetryLedger {
897        RetryLedger::default()
898    }
899
900    fn enqueue(root: &Path, lane: IdentityLane) -> SpoolEntryId {
901        let spool = Spool::open(root).unwrap();
902        spool.enqueue(&bundle(), lane, "title").unwrap()
903    }
904
905    /// Re-open the spool FROM DISK and read one entry's persisted state —
906    /// the durable-state trace (checklist #1), never an in-memory mirror.
907    fn persisted_state(root: &Path, id: &SpoolEntryId) -> SpoolState {
908        Spool::open(root)
909            .unwrap()
910            .list()
911            .unwrap()
912            .into_iter()
913            .find(|e| &e.id == id)
914            .expect("entry on disk")
915            .state
916    }
917
918    // ---- PRIV-1 / SPOOL-5: the distinguishing test -------------------------
919
920    #[tokio::test]
921    async fn lazy_live_transport_construction_failure_holds_without_submitting() {
922        let transport = LazyLiveTransport::failing("fixture construction failure");
923        let verdict = transport.drain_eligible(&auth_lane()).await;
924        assert_eq!(
925            verdict,
926            DrainEligibility::Hold {
927                reason: "feedback transport unavailable: fixture construction failure".to_string()
928            }
929        );
930    }
931
932    #[tokio::test]
933    async fn empty_outbox_produces_no_transport_calls() {
934        // Distinguishing scenario: a ticker-style drain (like every other
935        // daemon loop) would consult the transport on a schedule; this drain
936        // must return Idle from an empty outbox having made ZERO transport
937        // calls — not even an eligibility/entitlements probe.
938        let tmp = TempDir::new().unwrap();
939        let spool_root = tmp.path().join("feedback-outbox");
940        let transport = MockTransport::new(DrainAction::Acknowledge {
941            server_id: "never".into(),
942        });
943        let mut last = None;
944        let outcome = run_drain_round(
945            &spool_root,
946            &transport,
947            &fast_config(),
948            &mut last,
949            &mut ledger(),
950        )
951        .await;
952        assert_eq!(outcome, RoundOutcome::Idle);
953        assert_eq!(
954            transport.total_calls(),
955            0,
956            "empty outbox must touch nothing"
957        );
958    }
959
960    // ---- happy path: persisted queued → sending → acknowledged -------------
961
962    #[tokio::test]
963    async fn acknowledged_entry_persists_the_server_id_on_disk() {
964        let tmp = TempDir::new().unwrap();
965        let root = tmp.path().join("feedback-outbox");
966        let id = enqueue(&root, auth_lane());
967        let transport = MockTransport::new(DrainAction::Acknowledge {
968            server_id: "row-7".into(),
969        });
970        let mut last = None;
971        let outcome =
972            run_drain_round(&root, &transport, &fast_config(), &mut last, &mut ledger()).await;
973        assert_eq!(outcome, RoundOutcome::Progressed);
974        assert_eq!(
975            persisted_state(&root, &id),
976            SpoolState::Acknowledged {
977                server_id: "row-7".to_string()
978            }
979        );
980        // The submit carried the entry's immutable idempotency key (IDEM-1).
981        let sent = transport.submitted_ids.lock().unwrap().clone();
982        assert_eq!(sent.len(), 1);
983        assert!(!sent[0].is_empty());
984    }
985
986    // ---- requirement 3 / DEG-1/DEG-3: the capability gate -------------------
987
988    #[tokio::test]
989    async fn hold_verdict_keeps_entries_queued_then_capability_flip_drains_them() {
990        // DEG-1: transport says Hold (the capability probe does not
991        // advertise authenticated intake) → the spool holds, nothing is
992        // submitted. DEG-3: the verdict flips (the capability appears) →
993        // the SAME entry drains unchanged.
994        let tmp = TempDir::new().unwrap();
995        let root = tmp.path().join("feedback-outbox");
996        let id = enqueue(&root, auth_lane());
997        let transport =
998            MockTransport::holding("capability does not advertise authenticated intake");
999        let mut last = None;
1000
1001        let outcome =
1002            run_drain_round(&root, &transport, &fast_config(), &mut last, &mut ledger()).await;
1003        assert_eq!(outcome, RoundOutcome::AllHeld);
1004        assert_eq!(transport.submit_calls.load(Ordering::SeqCst), 0);
1005        assert_eq!(persisted_state(&root, &id), SpoolState::Queued);
1006
1007        *transport.eligibility.lock().unwrap() = DrainEligibility::Eligible;
1008        *transport.script.lock().unwrap() = vec![Ok(DrainAction::Acknowledge {
1009            server_id: "row-1".into(),
1010        })];
1011        let outcome =
1012            run_drain_round(&root, &transport, &fast_config(), &mut last, &mut ledger()).await;
1013        assert_eq!(outcome, RoundOutcome::Progressed);
1014        assert_eq!(
1015            persisted_state(&root, &id),
1016            SpoolState::Acknowledged {
1017                server_id: "row-1".to_string()
1018            }
1019        );
1020    }
1021
1022    #[tokio::test]
1023    async fn capability_probe_is_cached_per_round_across_orgs() {
1024        // The eligibility verdict is install-global (capability probe +
1025        // session), never per-org: two queued entries for two DIFFERENT orgs
1026        // must cost exactly ONE drain_eligible call in a round. The old
1027        // per-org cache key made this two calls — this test fails there.
1028        let tmp = TempDir::new().unwrap();
1029        let root = tmp.path().join("feedback-outbox");
1030        enqueue(&root, auth_lane());
1031        enqueue(
1032            &root,
1033            IdentityLane::Authenticated {
1034                org_id: "org_other".to_string(),
1035            },
1036        );
1037        let transport = MockTransport::holding("capability unavailable");
1038        let mut last = None;
1039        let outcome =
1040            run_drain_round(&root, &transport, &fast_config(), &mut last, &mut ledger()).await;
1041        assert_eq!(outcome, RoundOutcome::AllHeld);
1042        assert_eq!(
1043            transport.eligible_calls.load(Ordering::SeqCst),
1044            1,
1045            "one capability-backed eligibility probe per lane kind per round"
1046        );
1047    }
1048
1049    #[tokio::test]
1050    async fn anonymous_entries_hold_queued_without_a_submit() {
1051        // v1 ruling (a): the server does not accept anonymous intake
1052        // (`anonymousIntake: false` on the capability probe); DEG-1 covers
1053        // these entries.
1054        let tmp = TempDir::new().unwrap();
1055        let root = tmp.path().join("feedback-outbox");
1056        let id = enqueue(&root, IdentityLane::Anonymous);
1057        let transport = MockTransport::holding("server does not accept anonymous feedback");
1058        let mut last = None;
1059        let outcome =
1060            run_drain_round(&root, &transport, &fast_config(), &mut last, &mut ledger()).await;
1061        assert_eq!(outcome, RoundOutcome::AllHeld);
1062        assert_eq!(transport.submit_calls.load(Ordering::SeqCst), 0);
1063        assert_eq!(
1064            transport.eligible_calls.load(Ordering::SeqCst),
1065            0,
1066            "anonymous v1 entries must not trigger a capability probe"
1067        );
1068        assert_eq!(persisted_state(&root, &id), SpoolState::Queued);
1069    }
1070
1071    // ---- SPOOL-6: taxonomy applied to durable state -------------------------
1072
1073    #[tokio::test]
1074    async fn corrupt_bundle_settles_terminal_instead_of_retrying_forever() {
1075        let tmp = TempDir::new().unwrap();
1076        let root = tmp.path().join("feedback-outbox");
1077        let id = enqueue(&root, auth_lane());
1078        std::fs::write(root.join(id.as_str()).join("bundle.json"), b"{corrupt").unwrap();
1079        let transport = MockTransport::new(DrainAction::Acknowledge {
1080            server_id: "must-not-submit".into(),
1081        });
1082        let mut last = None;
1083        let outcome =
1084            run_drain_round(&root, &transport, &fast_config(), &mut last, &mut ledger()).await;
1085        assert_eq!(outcome, RoundOutcome::Progressed);
1086        assert_eq!(transport.submit_calls.load(Ordering::SeqCst), 0);
1087        match persisted_state(&root, &id) {
1088            SpoolState::TerminalRejected { message } => {
1089                assert!(message.contains("stored bundle unreadable"), "{message}");
1090            }
1091            state => panic!("corrupt bundle must settle terminal, got {state:?}"),
1092        }
1093    }
1094
1095    #[tokio::test]
1096    async fn submit_unauthorized_returns_entry_to_queued_hold() {
1097        let tmp = TempDir::new().unwrap();
1098        let root = tmp.path().join("feedback-outbox");
1099        let id = enqueue(&root, auth_lane());
1100        let transport = MockTransport::new(DrainAction::Acknowledge {
1101            server_id: "unused".into(),
1102        });
1103        *transport.script.lock().unwrap() = vec![Err(FeedbackTransportError::Unauthorized)];
1104        let mut last = None;
1105        let outcome =
1106            run_drain_round(&root, &transport, &fast_config(), &mut last, &mut ledger()).await;
1107        assert_eq!(outcome, RoundOutcome::AllHeld);
1108        assert_eq!(persisted_state(&root, &id), SpoolState::Queued);
1109    }
1110
1111    #[tokio::test]
1112    async fn terminal_actionable_and_rejected_persist_and_never_retry() {
1113        let tmp = TempDir::new().unwrap();
1114        let root = tmp.path().join("feedback-outbox");
1115        let auth_id = enqueue(&root, auth_lane());
1116        let transport = MockTransport::new(DrainAction::TerminalActionable {
1117            reason: TransportActionableReason::ReconsentRequired,
1118        });
1119        let mut last = None;
1120        run_drain_round(&root, &transport, &fast_config(), &mut last, &mut ledger()).await;
1121        assert!(matches!(
1122            persisted_state(&root, &auth_id),
1123            SpoolState::TerminalActionable {
1124                reason: car_feedback_core::spool::ActionableReason::ReconsentRequired
1125            }
1126        ));
1127
1128        // SPOOL-6's "never retried": another round makes no further submit.
1129        let before = transport.submit_calls.load(Ordering::SeqCst);
1130        let outcome =
1131            run_drain_round(&root, &transport, &fast_config(), &mut last, &mut ledger()).await;
1132        assert_eq!(outcome, RoundOutcome::Idle);
1133        assert_eq!(transport.submit_calls.load(Ordering::SeqCst), before);
1134
1135        // 400-class: terminal with the server's message, persisted.
1136        // (Reset pacing so this unpaused test doesn't sleep a real interval.)
1137        last = None;
1138        let rejected_id = enqueue(&root, auth_lane());
1139        *transport.script.lock().unwrap() = vec![Ok(DrainAction::TerminalRejected {
1140            message: "HTTP 400: description invalid".into(),
1141        })];
1142        run_drain_round(&root, &transport, &fast_config(), &mut last, &mut ledger()).await;
1143        assert_eq!(
1144            persisted_state(&root, &rejected_id),
1145            SpoolState::TerminalRejected {
1146                message: "HTTP 400: description invalid".to_string()
1147            }
1148        );
1149    }
1150
1151    #[tokio::test]
1152    async fn retriable_failure_requeues_durably_and_reports_backoff() {
1153        let tmp = TempDir::new().unwrap();
1154        let root = tmp.path().join("feedback-outbox");
1155        let id = enqueue(&root, auth_lane());
1156        let transport = MockTransport::new(DrainAction::Requeue { backoff: 30 });
1157        let mut last = None;
1158        let outcome =
1159            run_drain_round(&root, &transport, &fast_config(), &mut last, &mut ledger()).await;
1160        assert_eq!(
1161            outcome,
1162            RoundOutcome::Backoff {
1163                min_delay: Duration::from_secs(30)
1164            }
1165        );
1166        assert_eq!(persisted_state(&root, &id), SpoolState::Queued);
1167    }
1168
1169    #[tokio::test(start_paused = true)]
1170    async fn retry_after_stops_the_round_and_is_honored_before_the_next_upload() {
1171        // SPOOL-7 against a mock 429: the round stops immediately (no burst
1172        // into the same window) and the next submit happens no sooner than
1173        // Retry-After.
1174        let tmp = TempDir::new().unwrap();
1175        let root = tmp.path().join("feedback-outbox");
1176        let first = enqueue(&root, auth_lane());
1177        let second = enqueue(&root, auth_lane());
1178        let transport = MockTransport::new(DrainAction::Acknowledge {
1179            server_id: "row".into(),
1180        });
1181        *transport.script.lock().unwrap() = vec![
1182            Ok(DrainAction::RequeueAfter { secs: 40 }),
1183            Ok(DrainAction::Acknowledge {
1184                server_id: "row-a".into(),
1185            }),
1186            Ok(DrainAction::Acknowledge {
1187                server_id: "row-b".into(),
1188            }),
1189        ];
1190        let mut last = None;
1191        let outcome =
1192            run_drain_round(&root, &transport, &fast_config(), &mut last, &mut ledger()).await;
1193        assert_eq!(outcome, RoundOutcome::RetryAfter(Duration::from_secs(40)));
1194        // Only ONE submit happened — the 429 stopped the round before the
1195        // second entry could burst the same window.
1196        assert_eq!(transport.submit_calls.load(Ordering::SeqCst), 1);
1197        assert_eq!(persisted_state(&root, &first), SpoolState::Queued);
1198        assert_eq!(persisted_state(&root, &second), SpoolState::Queued);
1199
1200        // Honor it, then drain: the next round's uploads are ≥40s later.
1201        tokio::time::sleep(Duration::from_secs(40)).await;
1202        let outcome =
1203            run_drain_round(&root, &transport, &fast_config(), &mut last, &mut ledger()).await;
1204        assert_eq!(outcome, RoundOutcome::Progressed);
1205        let stamps = transport.submit_at.lock().unwrap().clone();
1206        assert!(stamps.len() >= 2);
1207        assert!(
1208            stamps[1].duration_since(stamps[0]) >= Duration::from_secs(40),
1209            "second upload ran {:?} after the 429 — Retry-After not honored",
1210            stamps[1].duration_since(stamps[0])
1211        );
1212    }
1213
1214    #[tokio::test(start_paused = true)]
1215    async fn uploads_pace_at_most_one_per_min_interval() {
1216        // SPOOL-7: two queued entries drain ≥15s apart (virtual clock).
1217        let tmp = TempDir::new().unwrap();
1218        let root = tmp.path().join("feedback-outbox");
1219        enqueue(&root, auth_lane());
1220        enqueue(&root, auth_lane());
1221        let transport = MockTransport::new(DrainAction::Acknowledge {
1222            server_id: "row".into(),
1223        });
1224        let mut last = None;
1225        let outcome =
1226            run_drain_round(&root, &transport, &fast_config(), &mut last, &mut ledger()).await;
1227        assert_eq!(outcome, RoundOutcome::Progressed);
1228        let stamps = transport.submit_at.lock().unwrap().clone();
1229        assert_eq!(stamps.len(), 2);
1230        assert!(
1231            stamps[1].duration_since(stamps[0]) >= Duration::from_secs(15),
1232            "uploads {:?} apart — pacing not applied",
1233            stamps[1].duration_since(stamps[0])
1234        );
1235    }
1236
1237    // ---- crash recovery (checklist #1's restart scenario) -------------------
1238
1239    #[tokio::test]
1240    async fn stale_sending_entry_from_a_crashed_drain_recovers_and_resends_same_id() {
1241        // A daemon killed mid-upload leaves the entry Sending on disk. The
1242        // next round recovers it to Queued and re-sends it under the SAME
1243        // client_submission_id (IDEM-1 makes the replay safe server-side).
1244        let tmp = TempDir::new().unwrap();
1245        let root = tmp.path().join("feedback-outbox");
1246        let id = enqueue(&root, auth_lane());
1247        let spool = Spool::open(&root).unwrap();
1248        spool.mark_sending(&id).unwrap();
1249        let original_csid = spool
1250            .list()
1251            .unwrap()
1252            .into_iter()
1253            .find(|e| e.id == id)
1254            .unwrap()
1255            .client_submission_id;
1256        drop(spool); // "crash"
1257
1258        let transport = MockTransport::new(DrainAction::Acknowledge {
1259            server_id: "row-1".into(),
1260        });
1261        let mut last = None;
1262        let outcome =
1263            run_drain_round(&root, &transport, &fast_config(), &mut last, &mut ledger()).await;
1264        assert_eq!(outcome, RoundOutcome::Progressed);
1265        assert_eq!(
1266            persisted_state(&root, &id),
1267            SpoolState::Acknowledged {
1268                server_id: "row-1".to_string()
1269            }
1270        );
1271        assert_eq!(
1272            transport.submitted_ids.lock().unwrap().as_slice(),
1273            &[original_csid],
1274            "the recovered entry must re-send its ORIGINAL idempotency key"
1275        );
1276    }
1277
1278    // ---- the spawned loop wakes on submit, parks when idle ------------------
1279
1280    #[tokio::test(start_paused = true)]
1281    async fn spawned_drain_parks_idle_and_drains_on_wake() {
1282        let tmp = TempDir::new().unwrap();
1283        let car_home = tmp.path().to_path_buf();
1284        let root = car_home.join(FEEDBACK_OUTBOX_DIR);
1285        let transport = Arc::new(MockTransport::new(DrainAction::Acknowledge {
1286            server_id: "row-1".into(),
1287        }));
1288        let handle = spawn_feedback_drain_with(car_home, transport.clone(), fast_config());
1289
1290        // PARK-PHASE assertion (PRIV-1's distinguishing half): a long virtual
1291        // idle spans dozens of 60s park ticks — each tick may read the disk,
1292        // but with an EMPTY outbox not one transport call (not even an
1293        // eligibility probe) may happen.
1294        tokio::time::sleep(Duration::from_secs(3600)).await;
1295        assert_eq!(
1296            transport.total_calls(),
1297            0,
1298            "an empty-outbox park (incl. its local disk ticks) must never touch the transport"
1299        );
1300
1301        // Enqueue + wake (what feedback.submit does) → the entry drains.
1302        let id = enqueue(&root, auth_lane());
1303        handle.wake();
1304        // Let the loop run; paused clock auto-advances through its sleeps.
1305        for _ in 0..200 {
1306            tokio::task::yield_now().await;
1307            if transport.submit_calls.load(Ordering::SeqCst) > 0 {
1308                break;
1309            }
1310            tokio::time::sleep(Duration::from_millis(50)).await;
1311        }
1312        assert_eq!(transport.submit_calls.load(Ordering::SeqCst), 1);
1313        assert_eq!(
1314            persisted_state(&root, &id),
1315            SpoolState::Acknowledged {
1316                server_id: "row-1".to_string()
1317            }
1318        );
1319    }
1320
1321    // ---- finding #11: the park tick catches CLI direct enqueues -------------
1322
1323    #[tokio::test(start_paused = true)]
1324    async fn cli_direct_enqueue_while_parked_drains_within_one_tick() {
1325        // Distinguishing scenario: the CLI writes straight to the spool while
1326        // the daemon idles — NOTHING calls the drain handle. A Notify-only
1327        // drain parks forever; the park tick's local disk check must pick the
1328        // entry up within one park_check_interval.
1329        let tmp = TempDir::new().unwrap();
1330        let car_home = tmp.path().to_path_buf();
1331        let root = car_home.join(FEEDBACK_OUTBOX_DIR);
1332        let transport = Arc::new(MockTransport::new(DrainAction::Acknowledge {
1333            server_id: "row-cli".into(),
1334        }));
1335        let _handle = spawn_feedback_drain_with(car_home, transport.clone(), fast_config());
1336
1337        // Let the loop reach its park (boot round on an empty outbox).
1338        tokio::time::sleep(Duration::from_secs(1)).await;
1339        assert_eq!(transport.total_calls(), 0);
1340
1341        // The "CLI": a second Spool handle, no RPC, no wake().
1342        let id = enqueue(&root, auth_lane());
1343
1344        // Within one park tick (60s here) the entry must drain.
1345        for _ in 0..200 {
1346            tokio::task::yield_now().await;
1347            if transport.submit_calls.load(Ordering::SeqCst) > 0 {
1348                break;
1349            }
1350            tokio::time::sleep(Duration::from_secs(1)).await;
1351        }
1352        assert_eq!(
1353            transport.submit_calls.load(Ordering::SeqCst),
1354            1,
1355            "a parked drain must catch a direct spool enqueue via the local tick"
1356        );
1357        assert_eq!(
1358            persisted_state(&root, &id),
1359            SpoolState::Acknowledged {
1360                server_id: "row-cli".to_string()
1361            }
1362        );
1363    }
1364
1365    /// Distinguishing test (grace r2, park probe): the old probe answered
1366    /// "any directory", so a settled entry waiting out its 14-day TTL — or one
1367    /// stray hand-made directory — broke the park every tick forever. The
1368    /// probe must answer PENDING entries only: false for absence, staging,
1369    /// files, stray dirs, and settled entries; true for Queued and Sending.
1370    #[test]
1371    fn park_probe_sees_only_pending_entries() {
1372        let tmp = TempDir::new().unwrap();
1373        let root = tmp.path().join(FEEDBACK_OUTBOX_DIR);
1374        // Absent dir: empty (and no transport implications at all).
1375        assert!(!spool_has_pending_entries(&root));
1376        std::fs::create_dir_all(root.join(".tmp-half-written")).unwrap();
1377        std::fs::write(root.join("stray-file"), b"x").unwrap();
1378        assert!(
1379            !spool_has_pending_entries(&root),
1380            "staging dirs and files don't count"
1381        );
1382        // A stray directory that is not a spool entry (no state.json).
1383        std::fs::create_dir_all(root.join("00000000000000000000-not-an-entry")).unwrap();
1384        assert!(
1385            !spool_has_pending_entries(&root),
1386            "a stray directory is not a pending entry"
1387        );
1388        // A settled entry — acknowledged, lingering for the TTL.
1389        let settled = enqueue(&root, auth_lane());
1390        {
1391            let spool = Spool::open(&root).unwrap();
1392            spool.mark_sending(&settled).unwrap();
1393            spool.mark_acknowledged(&settled, "row-1").unwrap();
1394        }
1395        assert_eq!(
1396            persisted_state(&root, &settled),
1397            SpoolState::Acknowledged {
1398                server_id: "row-1".to_string()
1399            }
1400        );
1401        assert!(
1402            !spool_has_pending_entries(&root),
1403            "a settled entry must not break the park"
1404        );
1405        // A Queued entry: pending.
1406        let queued = enqueue(&root, auth_lane());
1407        assert!(spool_has_pending_entries(&root));
1408        // A Sending leftover (crashed drain): pending too — the round recovers it.
1409        Spool::open(&root).unwrap().mark_sending(&queued).unwrap();
1410        assert!(spool_has_pending_entries(&root));
1411    }
1412
1413    // ---- grace r2: Retry-After is bounded and interruptible -----------------
1414
1415    /// Distinguishing test: the loop used to `sleep(delay)` the classified
1416    /// Retry-After verbatim — a `999999999` header parked the single drain
1417    /// task for ~31 years (this test's virtual minute would find the entry
1418    /// still Queued after one submit). Clamped to `max_backoff` (8s here),
1419    /// the entry retries and settles within the minute.
1420    #[tokio::test(start_paused = true)]
1421    async fn absurd_retry_after_is_clamped_to_the_backoff_ceiling() {
1422        let tmp = TempDir::new().unwrap();
1423        let car_home = tmp.path().to_path_buf();
1424        let root = car_home.join(FEEDBACK_OUTBOX_DIR);
1425        let transport = Arc::new(MockTransport::new(DrainAction::Acknowledge {
1426            server_id: "row-1".into(),
1427        }));
1428        *transport.script.lock().unwrap() = vec![
1429            Ok(DrainAction::RequeueAfter { secs: 999_999_999 }),
1430            Ok(DrainAction::Acknowledge {
1431                server_id: "row-1".into(),
1432            }),
1433        ];
1434        let handle = spawn_feedback_drain_with(car_home, transport.clone(), fast_config());
1435        tokio::time::sleep(Duration::from_secs(1)).await;
1436        let id = enqueue(&root, auth_lane());
1437        handle.wake();
1438        // Let the first attempt (the 429) land.
1439        for _ in 0..200 {
1440            tokio::task::yield_now().await;
1441            if transport.submit_calls.load(Ordering::SeqCst) >= 1 {
1442                break;
1443            }
1444            tokio::time::sleep(Duration::from_millis(50)).await;
1445        }
1446        assert_eq!(transport.submit_calls.load(Ordering::SeqCst), 1);
1447        assert_eq!(persisted_state(&root, &id), SpoolState::Queued);
1448
1449        // One virtual minute covers the clamped wait (≤ max_backoff = 8s)
1450        // plus the 15s upload pacing; the unclamped loop is still asleep.
1451        tokio::time::sleep(Duration::from_secs(60)).await;
1452        assert_eq!(
1453            transport.submit_calls.load(Ordering::SeqCst),
1454            2,
1455            "the drain must retry within the backoff ceiling, not the header's decades"
1456        );
1457        assert_eq!(
1458            persisted_state(&root, &id),
1459            SpoolState::Acknowledged {
1460                server_id: "row-1".to_string()
1461            }
1462        );
1463    }
1464
1465    /// Distinguishing test: the Retry-After wait was a plain sleep, so a
1466    /// submit during it changed nothing until the delay ran out. Routed
1467    /// through `wait_or_wake`, the wake ends the wait; the next round's upload
1468    /// is still spaced by `min_upload_interval` (15s), so by t+30s the retry
1469    /// has happened — with the old non-interruptible 300s sleep it has not.
1470    #[tokio::test(start_paused = true)]
1471    async fn submit_wake_ends_a_retry_after_wait_early() {
1472        let tmp = TempDir::new().unwrap();
1473        let car_home = tmp.path().to_path_buf();
1474        let root = car_home.join(FEEDBACK_OUTBOX_DIR);
1475        let transport = Arc::new(MockTransport::new(DrainAction::Acknowledge {
1476            server_id: "row".into(),
1477        }));
1478        *transport.script.lock().unwrap() = vec![
1479            Ok(DrainAction::RequeueAfter { secs: 300 }),
1480            Ok(DrainAction::Acknowledge {
1481                server_id: "row-a".into(),
1482            }),
1483            Ok(DrainAction::Acknowledge {
1484                server_id: "row-b".into(),
1485            }),
1486        ];
1487        // A ceiling ABOVE the header, so the clamp alone cannot explain an
1488        // early retry — only the wake can.
1489        let mut config = fast_config();
1490        config.max_backoff = Duration::from_secs(600);
1491        let handle = spawn_feedback_drain_with(car_home, transport.clone(), config);
1492        tokio::time::sleep(Duration::from_secs(1)).await;
1493        let first = enqueue(&root, auth_lane());
1494        handle.wake();
1495        for _ in 0..200 {
1496            tokio::task::yield_now().await;
1497            if transport.submit_calls.load(Ordering::SeqCst) >= 1 {
1498                break;
1499            }
1500            tokio::time::sleep(Duration::from_millis(50)).await;
1501        }
1502        assert_eq!(
1503            transport.submit_calls.load(Ordering::SeqCst),
1504            1,
1505            "the 429 landed"
1506        );
1507        assert_eq!(persisted_state(&root, &first), SpoolState::Queued);
1508
1509        // The user submits again while the drain is waiting out the 429.
1510        let second = enqueue(&root, auth_lane());
1511        handle.wake();
1512        tokio::time::sleep(Duration::from_secs(30)).await;
1513        assert!(
1514            transport.submit_calls.load(Ordering::SeqCst) >= 2,
1515            "a submit wake must end the Retry-After wait (calls: {})",
1516            transport.submit_calls.load(Ordering::SeqCst)
1517        );
1518        assert_eq!(
1519            persisted_state(&root, &first),
1520            SpoolState::Acknowledged {
1521                server_id: "row-a".to_string()
1522            },
1523            "the oldest queued entry is retried first"
1524        );
1525        // Let the second entry drain too — pacing spaces it 15s after the first.
1526        tokio::time::sleep(Duration::from_secs(30)).await;
1527        assert_eq!(
1528            persisted_state(&root, &second),
1529            SpoolState::Acknowledged {
1530                server_id: "row-b".to_string()
1531            }
1532        );
1533    }
1534
1535    // ---- transport omitted-notes surface on settle (SubmitOutcome seam) -----
1536
1537    #[tokio::test]
1538    async fn rejected_upload_with_omissions_persists_the_omitted_note() {
1539        let tmp = TempDir::new().unwrap();
1540        let root = tmp.path().join("feedback-outbox");
1541        let id = enqueue(&root, auth_lane());
1542        let transport = MockTransport::new(DrainAction::TerminalRejected {
1543            message: "HTTP 400: description invalid".into(),
1544        });
1545        *transport.omitted.lock().unwrap() = vec!["screenshot dropped (413 fallback)".to_string()];
1546        let mut last = None;
1547        run_drain_round(&root, &transport, &fast_config(), &mut last, &mut ledger()).await;
1548        match persisted_state(&root, &id) {
1549            SpoolState::TerminalRejected { message } => {
1550                assert!(
1551                    message.contains("screenshot dropped (413 fallback)"),
1552                    "the omitted note must persist in the durable rejection message: {message}"
1553                );
1554                assert!(message.contains("HTTP 400"));
1555            }
1556            other => panic!("expected TerminalRejected, got {other:?}"),
1557        }
1558    }
1559
1560    // ---- finding F13: the per-process retriable-attempt cap ----------------
1561
1562    /// Distinguishing test: without a cap, a server that keeps answering 5xx
1563    /// (or a network that keeps dropping the POST) gets one attempt per round
1564    /// forever. With the cap the entry is attempted exactly
1565    /// `max_retriable_attempts` times — both retriable arms count — then every
1566    /// further round in this process skips it: no submit, not even an
1567    /// eligibility probe, while it stays `Queued` on disk (never terminal,
1568    /// never pruned — requirement 13). A fresh ledger (a daemon restart) tries
1569    /// again.
1570    #[tokio::test(start_paused = true)]
1571    async fn retriable_failures_stop_after_the_per_process_cap_and_the_entry_stays_queued() {
1572        let tmp = TempDir::new().unwrap();
1573        let root = tmp.path().join("feedback-outbox");
1574        let id = enqueue(&root, auth_lane());
1575        let transport = MockTransport::new(DrainAction::Requeue { backoff: 1 });
1576        *transport.script.lock().unwrap() = vec![
1577            Ok(DrainAction::Requeue { backoff: 1 }),
1578            Err(FeedbackTransportError::FetchFailed(
1579                "connection reset".into(),
1580            )),
1581            Ok(DrainAction::Requeue { backoff: 1 }),
1582        ];
1583        let mut config = fast_config();
1584        config.max_retriable_attempts = 3;
1585        let mut last = None;
1586        let mut ledger = RetryLedger::default();
1587
1588        for round in 1..=3 {
1589            let outcome = run_drain_round(&root, &transport, &config, &mut last, &mut ledger).await;
1590            assert!(
1591                matches!(outcome, RoundOutcome::Backoff { .. }),
1592                "round {round}: {outcome:?}"
1593            );
1594            assert_eq!(transport.submit_calls.load(Ordering::SeqCst), round);
1595            assert_eq!(persisted_state(&root, &id), SpoolState::Queued);
1596        }
1597
1598        // Budget spent: further rounds skip it without touching the transport.
1599        let probes_before = transport.eligible_calls.load(Ordering::SeqCst);
1600        for _ in 0..3 {
1601            let outcome = run_drain_round(&root, &transport, &config, &mut last, &mut ledger).await;
1602            assert_eq!(outcome, RoundOutcome::AllHeld);
1603        }
1604        assert_eq!(
1605            transport.submit_calls.load(Ordering::SeqCst),
1606            3,
1607            "no submit past the cap"
1608        );
1609        assert_eq!(
1610            transport.eligible_calls.load(Ordering::SeqCst),
1611            probes_before,
1612            "a capped entry costs no network — not even the eligibility probe"
1613        );
1614        assert_eq!(
1615            persisted_state(&root, &id),
1616            SpoolState::Queued,
1617            "capped ⇒ still Queued on disk: never a terminal state, never pruned"
1618        );
1619
1620        // A daemon restart == a fresh ledger: the entry gets a new budget.
1621        let mut restarted = RetryLedger::default();
1622        let outcome = run_drain_round(&root, &transport, &config, &mut last, &mut restarted).await;
1623        assert!(matches!(outcome, RoundOutcome::Backoff { .. }));
1624        assert_eq!(transport.submit_calls.load(Ordering::SeqCst), 4);
1625    }
1626
1627    /// 429 is pacing, not a failure of the entry: `Retry-After` rounds never
1628    /// consume the F13 budget — with a cap of ONE, two consecutive 429s still
1629    /// leave the entry sendable and the third round acknowledges it. (Were
1630    /// 429 counted, round two would already skip it as AllHeld.)
1631    #[tokio::test(start_paused = true)]
1632    async fn retry_after_does_not_consume_the_attempt_budget() {
1633        let tmp = TempDir::new().unwrap();
1634        let root = tmp.path().join("feedback-outbox");
1635        let id = enqueue(&root, auth_lane());
1636        let transport = MockTransport::new(DrainAction::Acknowledge {
1637            server_id: "row".into(),
1638        });
1639        *transport.script.lock().unwrap() = vec![
1640            Ok(DrainAction::RequeueAfter { secs: 1 }),
1641            Ok(DrainAction::RequeueAfter { secs: 1 }),
1642            Ok(DrainAction::Acknowledge {
1643                server_id: "row-1".into(),
1644            }),
1645        ];
1646        let mut config = fast_config();
1647        config.max_retriable_attempts = 1;
1648        let mut last = None;
1649        let mut ledger = RetryLedger::default();
1650
1651        for _ in 0..2 {
1652            let outcome = run_drain_round(&root, &transport, &config, &mut last, &mut ledger).await;
1653            assert_eq!(outcome, RoundOutcome::RetryAfter(Duration::from_secs(1)));
1654            tokio::time::sleep(Duration::from_secs(1)).await;
1655        }
1656        let outcome = run_drain_round(&root, &transport, &config, &mut last, &mut ledger).await;
1657        assert_eq!(outcome, RoundOutcome::Progressed);
1658        assert_eq!(
1659            persisted_state(&root, &id),
1660            SpoolState::Acknowledged {
1661                server_id: "row-1".to_string()
1662            }
1663        );
1664    }
1665}