Skip to main content

pv_harness/
worker.rs

1//! Bounded asynchronous worker for loop analysis, event emission, and capture.
2
3use crate::session::{Session, Workflow};
4use pv_bridge::{EventBus, PublishAck};
5use pv_core::metrics::Registry;
6use pv_events::{EventClass, EventMetrics, OcsfEventBuilder, StatusId, StopReason};
7use pv_loopdetect::{BreakerVerdict, Embedder, NoopVectorSink, VectorSink};
8use serde_json::Value;
9use std::sync::atomic::Ordering;
10use std::sync::Arc;
11use tokio::sync::{mpsc, oneshot};
12use tracing::Instrument as _;
13
14/// Structured ATIF representation attached to an asynchronous event job.
15pub struct AtifCapture {
16    /// Step originator.
17    pub source: pv_atif::Source,
18    /// Required dialog message.
19    pub message: Value,
20    /// Optional explicit reasoning.
21    pub reasoning_content: Option<String>,
22    /// Model used for this step.
23    pub model_name: Option<String>,
24    /// Structured tool calls.
25    pub tool_calls: Option<Vec<pv_atif::ToolCall>>,
26    /// Structured environment feedback.
27    pub observation: Option<pv_atif::Observation>,
28    /// Number of represented LLM calls.
29    pub llm_call_count: Option<u64>,
30}
31
32/// Work copied from the hot path for asynchronous processing.
33pub struct WorkerJob {
34    /// Session receiving the resulting event and trajectory step.
35    pub session: Arc<Session>,
36    /// Identity validated for the request that created this job.
37    pub identity: pv_events::AgentIdentity,
38    /// Event class to emit when loop detection does not override it.
39    pub class: EventClass,
40    /// Class-specific event payload.
41    pub payload: Value,
42    /// Reasoning or response text used for loop detection and ATIF capture.
43    pub text: String,
44    /// Whether this job represents a reasoning step that should update the
45    /// semantic loop breaker.
46    pub analyze_loop: bool,
47    /// Event outcome.
48    pub status: StatusId,
49    /// Normalized stop reason for stop events.
50    pub stop_reason: Option<StopReason>,
51    /// Provider or source-native stop reason value.
52    pub native_stop_reason: Option<String>,
53    /// Token and compression metrics.
54    pub metrics: EventMetrics,
55    /// provider-vs-heuristic prompt-token correction
56    /// (see `ActiveJournalRecord::prompt_token_correction`). Zero for
57    /// every job class except terminal response captures whose
58    /// provider reported `usage.prompt_tokens`.
59    pub prompt_token_correction: i64,
60    /// Cost attributed to this step, in micro-USD.
61    pub cost_usd_micros: u64,
62    /// ATIF step representation for unsigned workflows.
63    pub atif: Option<AtifCapture>,
64    /// Durable response-attempt marker cleared only after journal and broker commit.
65    pub response_marker: Option<String>,
66    /// Chat response attempt correlated across request and terminal capture records.
67    pub response_attempt: Option<ResponseAttempt>,
68}
69
70/// Durable request/terminal marker embedded in the active event journal.
71#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
72pub struct ResponseAttempt {
73    /// Stable ID shared by request admission and its terminal response event.
74    pub id: String,
75    /// False on request admission, true on response completion or dispatch failure.
76    pub terminal: bool,
77}
78
79#[derive(serde::Serialize, serde::Deserialize)]
80struct InFlightResponse {
81    session_id: String,
82    attempt_id: String,
83    request_digest: String,
84}
85
86#[derive(Debug, serde::Serialize, serde::Deserialize)]
87pub(crate) struct ActiveJournalRecord {
88    pub(crate) event: Value,
89    pub(crate) identity: pv_events::AgentIdentity,
90    pub(crate) atif_step: Option<pv_atif::Step>,
91    pub(crate) tool_calls: u64,
92    pub(crate) tool_allowed: u64,
93    pub(crate) tool_blocked: u64,
94    pub(crate) prompt_tokens: u64,
95    pub(crate) completion_tokens: u64,
96    pub(crate) cached_tokens: u64,
97    pub(crate) cost_usd_micros: u64,
98    /// Signed reconciliation of the admission-time
99    /// prompt-token HEURISTIC against the provider's reported
100    /// `usage.prompt_tokens` (provider − heuristic; negative for the
101    /// common CJK 3-4× over-estimate). Carried on terminal response
102    /// records; `#[serde(default)]` keeps old journals readable and
103    /// lets a rolled-back binary ignore it (totals then fall back to
104    /// the heuristic — bounded degradation, never corruption).
105    #[serde(default)]
106    pub(crate) prompt_token_correction: i64,
107    pub(crate) stop_reason_id: Option<u8>,
108    pub(crate) response_attempt: Option<ResponseAttempt>,
109}
110
111/// Accounting totals folded from journal records during recovery.
112///
113/// The same
114/// accounting fold used to be implemented THREE times — the live path
115/// in `process_job`, signed recovery in `recover_signed_journals`, and
116/// unsigned consolidation in `consolidate_step_journals`. Any change to
117/// a counted dimension had to be made in three places or a
118/// crash-recovered receipt attested different numbers than a
119/// clean-close receipt for identical traffic — and no test caught it,
120/// because the paths were asserted separately. `ActiveJournalRecord`'s
121/// fields are now the single source: the live path applies them via
122/// [`ActiveJournalRecord::apply_to_totals`], recovery folds them via
123/// [`ActiveJournalRecord::fold_into`], and the proof-harness test
124/// (`unified_fold_matches_live_path`) pins the two applications to
125/// identical results over representative record shapes.
126#[derive(Debug, Default, PartialEq, Eq)]
127pub(crate) struct RecoveredTotals {
128    pub(crate) tool_calls: u64,
129    pub(crate) tool_allowed: u64,
130    pub(crate) tool_blocked: u64,
131    pub(crate) prompt_tokens: u64,
132    pub(crate) completion_tokens: u64,
133    pub(crate) cached_tokens: u64,
134    pub(crate) cost_usd_micros: u64,
135}
136
137impl RecoveredTotals {
138    /// The tool-accounting invariant every fold must check: classified
139    /// outcomes can never exceed observed calls.
140    pub(crate) fn validate_tool_accounting(&self) -> Result<(), String> {
141        if self
142            .tool_allowed
143            .checked_add(self.tool_blocked)
144            .is_none_or(|classified| classified > self.tool_calls)
145        {
146            return Err("journal has inconsistent tool accounting".to_owned());
147        }
148        Ok(())
149    }
150
151    /// Publish the folded totals onto a session (Release ordering, the
152    /// same discipline both recovery paths used inline).
153    pub(crate) fn store_on(&self, totals: &crate::session::Totals) {
154        use std::sync::atomic::Ordering;
155        totals.tool_calls.store(self.tool_calls, Ordering::Release);
156        totals.tool_allowed.store(self.tool_allowed, Ordering::Release);
157        totals.tool_blocked.store(self.tool_blocked, Ordering::Release);
158        totals.prompt_tokens.store(self.prompt_tokens, Ordering::Release);
159        totals
160            .completion_tokens
161            .store(self.completion_tokens, Ordering::Release);
162        totals.cached_tokens.store(self.cached_tokens, Ordering::Release);
163        totals
164            .cost_usd_micros
165            .store(self.cost_usd_micros, Ordering::Release);
166    }
167}
168
169impl ActiveJournalRecord {
170    /// Fold this record's counted dimensions into recovery totals with
171    /// overflow + JCS-bound checks. THE accounting rule — do not
172    /// re-implement per call site.
173    pub(crate) fn fold_into(&self, totals: &mut RecoveredTotals) -> Result<(), String> {
174        fn checked(current: u64, value: u64, field: &str) -> Result<u64, String> {
175            current
176                .checked_add(value)
177                .filter(|total| *total <= pv_core::error::JCS_SAFE_MAX)
178                .ok_or_else(|| format!("recovered {field} overflow"))
179        }
180        // R64 F3 (landed R69): the cost dimension has a TIGHTER
181        // ceiling than the JCS-safe integer bound. `pv_atif::validate::
182        // MAX_COST_USD` refuses any `total_cost_usd > 9e9` (== 9e15
183        // micros), while `JCS_SAFE_MAX = 2^53 = 9.007e15` micros —
184        // a $7.2M window in which the fold used to say "safe" while
185        // the next strict-write would refuse the trajectory, wedging
186        // recovery. Cap the cost fold at
187        // `pv_core::units::COST_MICROS_MAX` so any cost that folds
188        // cleanly also passes ATIF strict validation.
189        fn checked_cost(current: u64, value: u64) -> Result<u64, String> {
190            current
191                .checked_add(value)
192                .filter(|total| *total <= pv_core::units::COST_MICROS_MAX)
193                .ok_or_else(|| "recovered cost exceeds ATIF strict validator ceiling".to_owned())
194        }
195        totals.tool_calls = checked(totals.tool_calls, self.tool_calls, "tool calls")?;
196        totals.tool_allowed = checked(totals.tool_allowed, self.tool_allowed, "allowed tools")?;
197        totals.tool_blocked = checked(totals.tool_blocked, self.tool_blocked, "blocked tools")?;
198        totals.prompt_tokens = checked(totals.prompt_tokens, self.prompt_tokens, "prompt tokens")?;
199        if self.prompt_token_correction != 0 {
200            totals.prompt_tokens = totals
201                .prompt_tokens
202                .checked_add_signed(self.prompt_token_correction)
203                .filter(|total| *total <= pv_core::error::JCS_SAFE_MAX)
204                .ok_or_else(|| "recovered prompt token correction underflow/overflow".to_owned())?;
205        }
206        totals.completion_tokens = checked(
207            totals.completion_tokens,
208            self.completion_tokens,
209            "completion tokens",
210        )?;
211        totals.cached_tokens = checked(totals.cached_tokens, self.cached_tokens, "cached tokens")?;
212        totals.cost_usd_micros = checked_cost(totals.cost_usd_micros, self.cost_usd_micros)?;
213        Ok(())
214    }
215
216    /// Apply this record's counted dimensions to a live session's
217    /// atomic totals — the live-path twin of [`Self::fold_into`], with
218    /// the same JCS-bound discipline. The record's fields were
219    /// conditionalized ONCE at construction (compression carries
220    /// prompt tokens; terminal responses carry completion/cached/cost;
221    /// tool records carry call outcomes), so applying them
222    /// unconditionally here is exactly the old per-class branching,
223    /// without the second copy of the rules.
224    ///
225    /// All-or-nothing across fields (R64 F1). The previous shape was
226    /// a loop of `checked_atomic_add` + a trailing `fetch_update` —
227    /// each iteration COMMITTED its store before the next validated,
228    /// so a mid-record failure (JCS bound crossed on
229    /// `completion_tokens`; `prompt_token_correction` underflow) left
230    /// `session.totals` half-updated (e.g., `tool_calls` bumped but
231    /// `completion_tokens` not). The subsequent invariant
232    /// `allowed+blocked <= calls` could then fail spuriously, and a
233    /// clean-close receipt over the partial totals attested different
234    /// numbers than a `fold_into`-based recovery replay of the exact
235    /// same on-disk journal — reintroducing the pre-R59 "recovered
236    /// receipt differs from live receipt for identical traffic"
237    /// hazard the unified fold exists to prevent. Snapshot → fold →
238    /// store restores the discipline: fold_into runs on a local
239    /// `RecoveredTotals`, either all fields validate or the Err
240    /// bubbles up with no store; only on success do we publish.
241    ///
242    /// Safe under sequential-per-session semantics: the only
243    /// production caller is `finish_job` (worker.rs
244    /// below), which runs one step at a time per session inside the
245    /// worker loop. Metrics scrape may still observe a mid-publish
246    /// window across the seven per-field stores in `store_on`, but
247    /// that is inherent to per-field atomics and unchanged from the
248    /// prior shape.
249    pub(crate) fn apply_to_totals(&self, totals: &crate::session::Totals) -> Result<(), String> {
250        use std::sync::atomic::Ordering;
251        let mut snapshot = RecoveredTotals {
252            tool_calls: totals.tool_calls.load(Ordering::Acquire),
253            tool_allowed: totals.tool_allowed.load(Ordering::Acquire),
254            tool_blocked: totals.tool_blocked.load(Ordering::Acquire),
255            prompt_tokens: totals.prompt_tokens.load(Ordering::Acquire),
256            completion_tokens: totals.completion_tokens.load(Ordering::Acquire),
257            cached_tokens: totals.cached_tokens.load(Ordering::Acquire),
258            cost_usd_micros: totals.cost_usd_micros.load(Ordering::Acquire),
259        };
260        self.fold_into(&mut snapshot)?;
261        snapshot.store_on(totals);
262        Ok(())
263    }
264}
265
266#[derive(Debug, serde::Serialize, serde::Deserialize)]
267struct BrokerAckRecord {
268    session_id: String,
269    event_uid: String,
270    ack: PublishAck,
271}
272
273struct Envelope {
274    job: WorkerJob,
275    completion: Option<oneshot::Sender<Result<(), String>>>,
276    span: tracing::Span,
277    _capacity_permit: tokio::sync::OwnedSemaphorePermit,
278}
279
280/// Non-blocking submission error.
281#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
282#[non_exhaustive]
283pub enum SubmitError {
284    /// The bounded worker queue has no remaining capacity.
285    #[error("worker queue is full")]
286    Full,
287    /// The worker supervisor has stopped.
288    #[error("worker queue is closed")]
289    Closed,
290}
291
292/// Which admission stage a drop should be counted against. Each variant
293/// maps to a distinct `pv_events_dropped_total{stage="..."}` counter,
294/// so operators can PromQL-alert on the actual bottleneck class.
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296enum DropStage {
297    /// Initial worker-side admission. `try_reserve` / `try_reserve_pair`'s
298    /// worker half. Bumps `pv_events_dropped_total{stage="worker_queue"}`.
299    WorkerQueue,
300    /// Downstream response-capture submission (`ResponsePermit::submit`).
301    /// The permit itself only holds the response-capacity semaphore; the
302    /// mpsc slot is contested at submit time, so exhaustion at THIS
303    /// stage — which is a real observable class of failure — bumps
304    /// `pv_events_dropped_total{stage="response_slot"}`.
305    ResponseSlot,
306}
307
308impl DropStage {
309    fn full_counter_key(self) -> &'static str {
310        match self {
311            Self::WorkerQueue => "pv_events_dropped_total{stage=\"worker_queue\"}",
312            Self::ResponseSlot => "pv_events_dropped_total{stage=\"response_slot\"}",
313        }
314    }
315
316    fn label(self) -> &'static str {
317        match self {
318            Self::WorkerQueue => "worker_queue",
319            Self::ResponseSlot => "response_slot",
320        }
321    }
322}
323
324/// Cloneable handle used by request handlers to submit worker jobs.
325#[derive(Clone)]
326pub struct WorkerHandle {
327    senders: Arc<Vec<mpsc::Sender<Envelope>>>,
328    metrics: Arc<Registry>,
329    pending: Arc<std::sync::atomic::AtomicU64>,
330    drained: Arc<tokio::sync::Notify>,
331    capacity: Arc<tokio::sync::Semaphore>,
332    /// Separate admission budget for downstream response jobs. Every
333    /// chat request reserves a worker slot AND a response slot; giving
334    /// them distinct semaphores + distinct
335    /// `pv_events_dropped_total{stage=...}` counters lets operators see
336    /// which class of capacity ran out. Prior to this split both
337    /// reservations pulled from the same semaphore so admission was
338    /// silently halved and both failures aliased to `stage="worker_queue"`.
339    response_capacity: Arc<tokio::sync::Semaphore>,
340}
341
342/// Guaranteed slot in the bounded worker queue.
343pub struct WorkerPermit {
344    permit: mpsc::OwnedPermit<Envelope>,
345    pending: Arc<std::sync::atomic::AtomicU64>,
346    capacity_permit: tokio::sync::OwnedSemaphorePermit,
347}
348
349/// Reservation for the downstream response-capture worker job. Draws
350/// from a distinct capacity semaphore so operators can tell via
351/// `pv_events_dropped_total{stage="response_slot"}` vs
352/// `stage="worker_queue"` which class of admission is the bottleneck.
353///
354/// Unlike [`WorkerPermit`], the response permit only holds the
355/// capacity semaphore — the mpsc queue slot is re-acquired at submit
356/// time via [`Self::submit`]. This keeps the initial admission cheap
357/// (one semaphore acquire instead of two mpsc reservations that would
358/// otherwise compete for the same shard's slot with the worker
359/// permit) and lets the response job land whenever worker capacity
360/// and the shard have room, which is the common case since response
361/// capture happens
362/// tens-of-seconds after the initial worker job has drained.
363///
364/// Held by the streaming-response wrapper in `routes.rs`
365/// (`AbortFinalizingStream`) for the lifetime of the forwarded response;
366/// drops on stream completion or client abort.
367pub struct ResponsePermit {
368    _capacity_permit: tokio::sync::OwnedSemaphorePermit,
369}
370
371impl ResponsePermit {
372    /// Commit a response-capture job. The capacity slot reserved at
373    /// admission travels INTO the envelope: the submission never
374    /// contends on the main worker semaphore, so a backlog of new
375    /// admissions cannot starve the capture job of an
376    /// already-admitted stream — the guarantee `try_reserve_pair`'s
377    /// acquire-both-or-fail exists to provide. (A previous revision
378    /// dropped this permit and drew a fresh slot from the WORKER
379    /// semaphore, which both forfeited the reservation under load and
380    /// charged the resulting drop to `stage="response_slot"` when the
381    /// exhausted budget was actually worker capacity.) The only
382    /// remaining failure mode is the shard's bounded mpsc queue being
383    /// momentarily full, which returns `SubmitError::Full` and bumps
384    /// `pv_events_dropped_total{stage="response_slot"}`.
385    pub fn submit(self, worker: &WorkerHandle, job: WorkerJob) -> Result<(), SubmitError> {
386        worker.submit_with_permit(job, self._capacity_permit, DropStage::ResponseSlot)
387    }
388}
389
390/// Fused reservation covering a worker job AND its downstream response
391/// slot. Acquired atomically at request admission: on any failure the
392/// worker slot (if already held) is released via RAII before the error
393/// surfaces, so callers cannot end up with an orphaned half-permit.
394pub struct WorkerAndResponsePermit {
395    /// Permit for the initial worker job (dispatch / quota / receipt-sign).
396    pub worker: WorkerPermit,
397    /// Permit for the downstream response-capture job.
398    pub response: ResponsePermit,
399}
400
401impl WorkerPermit {
402    /// Commit a job into the previously reserved queue slot.
403    pub fn submit(self, job: WorkerJob) {
404        job.session.worker_job_started();
405        self.pending.fetch_add(1, Ordering::AcqRel);
406        let span = worker_span(&job);
407        self.permit.send(Envelope {
408            job,
409            completion: None,
410            span,
411            _capacity_permit: self.capacity_permit,
412        });
413    }
414}
415
416impl WorkerHandle {
417    /// Reserve queue capacity before committing quota or other state.
418    pub fn try_reserve(&self, session_id: &str) -> Result<WorkerPermit, SubmitError> {
419        let capacity_permit = self.try_capacity()?;
420        let Some(sender) = self.sender_for(session_id).cloned() else {
421            return Err(SubmitError::Closed);
422        };
423        match sender.try_reserve_owned() {
424            Ok(permit) => Ok(WorkerPermit {
425                permit,
426                pending: Arc::clone(&self.pending),
427                capacity_permit,
428            }),
429            Err(mpsc::error::TrySendError::Full(_sender)) => {
430                self.metrics
431                    .counter(
432                        "pv_events_dropped_total{stage=\"worker_queue\"}",
433                        "Worker jobs or response-slot reservations dropped, labeled by admission stage",
434                    )
435                    .inc();
436                Err(SubmitError::Full)
437            }
438            Err(mpsc::error::TrySendError::Closed(_sender)) => {
439                self.metrics
440                    .counter(
441                        "pv_events_dropped_total{stage=\"worker_closed\"}",
442                        "Worker jobs or response-slot reservations dropped, labeled by admission stage",
443                    )
444                    .inc();
445                Err(SubmitError::Closed)
446            }
447        }
448    }
449
450    /// Reserve the downstream response-capture slot. Distinct from
451    /// [`Self::try_reserve`] because response capture is a separate
452    /// stage with its own admission budget; keeping the counters
453    /// separate lets operators see which one ran out. The mpsc queue
454    /// slot is re-acquired at submit time via [`ResponsePermit::submit`].
455    fn try_reserve_response(&self) -> Result<ResponsePermit, SubmitError> {
456        Arc::clone(&self.response_capacity)
457            .try_acquire_owned()
458            .map(|p| ResponsePermit { _capacity_permit: p })
459            .map_err(|_| {
460                self.metrics
461                    .counter(
462                        "pv_events_dropped_total{stage=\"response_slot\"}",
463                        "Worker jobs or response-slot reservations dropped, labeled by admission stage",
464                    )
465                    .inc();
466                SubmitError::Full
467            })
468    }
469
470    /// Atomic acquire-both-or-fail for admission: obtains a worker
471    /// permit AND a response permit for the same request, distinct
472    /// counters on failure. On any error path (worker slot or response
473    /// slot exhaustion, closed channel), a partially-taken worker
474    /// permit drops via RAII before the error surfaces so callers
475    /// never observe an orphaned half-reservation.
476    pub fn try_reserve_pair(&self, session_id: &str) -> Result<WorkerAndResponsePermit, SubmitError> {
477        // Stage 1: worker slot. Bumps stage="worker_queue" on failure.
478        let worker = self.try_reserve(session_id)?;
479        // Stage 2: response slot. Bumps stage="response_slot" on
480        // failure. If this fails, `worker` drops via NLL — its
481        // OwnedSemaphorePermit + mpsc::OwnedPermit release cleanly
482        // and the counters agree with the caller's view (only the
483        // response-slot counter incremented).
484        let response = self.try_reserve_response()?;
485        Ok(WorkerAndResponsePermit { worker, response })
486    }
487
488    /// Submit without waiting. Used by rejection/failure paths; the hot path
489    /// reserves capacity up front via [`WorkerHandle::try_reserve`] and
490    /// submits through [`WorkerPermit::submit`].
491    pub fn try_submit(&self, job: WorkerJob) -> Result<(), SubmitError> {
492        self.try_submit_labeled(job, DropStage::WorkerQueue)
493    }
494
495    /// Same as [`Self::try_submit`], but the failure counters carry a
496    /// caller-supplied stage label so response-slot exhaustion mid-stream
497    /// can be distinguished from admission-side worker-queue exhaustion.
498    /// See [`ResponsePermit::submit`].
499    fn try_submit_labeled(&self, job: WorkerJob, stage: DropStage) -> Result<(), SubmitError> {
500        let capacity_permit = self.try_capacity_labeled(stage)?;
501        self.submit_with_permit(job, capacity_permit, stage)
502    }
503
504    /// Queue-admission tail shared by [`Self::try_submit_labeled`]
505    /// (which draws a fresh worker-capacity slot) and
506    /// [`ResponsePermit::submit`] (which carries the response-capacity
507    /// slot reserved at admission). The permit — whichever semaphore
508    /// it came from — rides inside the envelope and releases to its
509    /// origin when the worker finishes the job.
510    fn submit_with_permit(
511        &self,
512        job: WorkerJob,
513        capacity_permit: tokio::sync::OwnedSemaphorePermit,
514        stage: DropStage,
515    ) -> Result<(), SubmitError> {
516        let Some(sender) = self.sender_for(&job.session.id).cloned() else {
517            return Err(SubmitError::Closed);
518        };
519        job.session.worker_job_started();
520        self.pending.fetch_add(1, Ordering::AcqRel);
521        let span = worker_span(&job);
522        match sender.try_send(Envelope {
523            job,
524            completion: None,
525            span,
526            _capacity_permit: capacity_permit,
527        }) {
528            Ok(()) => Ok(()),
529            Err(mpsc::error::TrySendError::Full(envelope)) => {
530                let session_id = envelope.job.session.id.clone();
531                envelope.job.session.worker_job_finished();
532                self.worker_job_finished();
533                self.metrics
534                    .counter(
535                        stage.full_counter_key(),
536                        "Worker jobs or response-slot reservations dropped, labeled by admission stage",
537                    )
538                    .inc();
539                tracing::warn!(
540                    session = %session_id,
541                    stage = %stage.label(),
542                    "worker queue full; request must fail closed"
543                );
544                Err(SubmitError::Full)
545            }
546            Err(mpsc::error::TrySendError::Closed(envelope)) => {
547                let session_id = envelope.job.session.id.clone();
548                envelope.job.session.worker_job_finished();
549                self.worker_job_finished();
550                self.metrics
551                    .counter(
552                        "pv_events_dropped_total{stage=\"worker_closed\"}",
553                        "Worker jobs or response-slot reservations dropped, labeled by admission stage",
554                    )
555                    .inc();
556                tracing::warn!(
557                    session = %session_id,
558                    stage = %stage.label(),
559                    "worker queue closed; request must fail closed"
560                );
561                Err(SubmitError::Closed)
562            }
563        }
564    }
565
566    /// Submit and wait for completion. Intended for lifecycle operations and
567    /// deterministic integration tests, never streaming hot paths.
568    pub async fn submit_and_wait(&self, job: WorkerJob) -> Result<(), String> {
569        let sender_for_session = self
570            .sender_for(&job.session.id)
571            .cloned()
572            .ok_or_else(|| "worker queue is closed".to_owned())?;
573        let (sender, receiver) = oneshot::channel();
574        let capacity_permit = Arc::clone(&self.capacity)
575            .acquire_owned()
576            .await
577            .map_err(|_| "worker capacity semaphore is closed".to_owned())?;
578        job.session.worker_job_started();
579        self.pending.fetch_add(1, Ordering::AcqRel);
580        let span = worker_span(&job);
581        sender_for_session
582            .send(Envelope {
583                job,
584                completion: Some(sender),
585                span,
586                _capacity_permit: capacity_permit,
587            })
588            .await
589            .map_err(|error| {
590                error.0.job.session.worker_job_finished();
591                self.worker_job_finished();
592                "worker queue is closed".to_owned()
593            })?;
594        receiver
595            .await
596            .map_err(|_| "worker completion channel closed".to_owned())?
597    }
598
599    /// Wait until every accepted job has completed processing.
600    pub async fn wait_idle(&self) {
601        loop {
602            // Same pinned Notified must span enable() and .await; a fresh
603            // notified() after enable is dropped would miss a notify_waiters()
604            // firing in the interval.
605            let notified = self.drained.notified();
606            let mut notified = std::pin::pin!(notified);
607            notified.as_mut().enable();
608            if self.pending.load(Ordering::Acquire) == 0 {
609                return;
610            }
611            notified.await;
612        }
613    }
614
615    /// Number of accepted-but-not-yet-completed jobs. Sampled by the
616    /// `/metrics` scrape into the `pv_worker_queue_depth` gauge.
617    pub fn queue_depth(&self) -> u64 {
618        self.pending.load(Ordering::Acquire)
619    }
620
621    fn worker_job_finished(&self) {
622        if self.pending.fetch_sub(1, Ordering::AcqRel) == 1 {
623            self.drained.notify_waiters();
624        }
625    }
626
627    fn try_capacity(&self) -> Result<tokio::sync::OwnedSemaphorePermit, SubmitError> {
628        self.try_capacity_labeled(DropStage::WorkerQueue)
629    }
630
631    /// Same as [`Self::try_capacity`] but the failure counter carries a
632    /// caller-supplied stage label. `WorkerQueue` accounts to the main
633    /// worker admission semaphore; `ResponseSlot` accounts to the mid-
634    /// stream response-capture submission path — so operators can tell
635    /// which class of exhaustion is producing drops.
636    fn try_capacity_labeled(
637        &self,
638        stage: DropStage,
639    ) -> Result<tokio::sync::OwnedSemaphorePermit, SubmitError> {
640        Arc::clone(&self.capacity).try_acquire_owned().map_err(|_| {
641            self.metrics
642                .counter(
643                    stage.full_counter_key(),
644                    "Worker jobs or response-slot reservations dropped, labeled by admission stage",
645                )
646                .inc();
647            SubmitError::Full
648        })
649    }
650
651    fn sender_for(&self, session_id: &str) -> Option<&mpsc::Sender<Envelope>> {
652        let partitions = u32::try_from(self.senders.len()).ok()?;
653        let shard = pv_bridge::bus::partition_for(session_id, partitions) as usize;
654        self.senders.get(shard)
655    }
656}
657
658/// Start the session-sharded worker pool (16 shards, each behind a
659/// bounded channel). Ordering is per session via hash sharding.
660pub fn spawn_worker(
661    capacity: usize,
662    bridge: Arc<dyn EventBus>,
663    embedder: Arc<dyn Embedder>,
664    metrics: Arc<Registry>,
665) -> WorkerHandle {
666    spawn_worker_with_sink(capacity, bridge, embedder, Arc::new(NoopVectorSink), metrics)
667}
668
669/// Start the worker pool with an explicit off-path vector sink.
670pub fn spawn_worker_with_sink(
671    capacity: usize,
672    bridge: Arc<dyn EventBus>,
673    embedder: Arc<dyn Embedder>,
674    vector_sink: Arc<dyn VectorSink>,
675    metrics: Arc<Registry>,
676) -> WorkerHandle {
677    spawn_worker_with_spool(capacity, bridge, embedder, vector_sink, None, metrics)
678}
679
680/// Start the worker pool with vector persistence and optional ATIF journal.
681pub fn spawn_worker_with_spool(
682    capacity: usize,
683    bridge: Arc<dyn EventBus>,
684    embedder: Arc<dyn Embedder>,
685    vector_sink: Arc<dyn VectorSink>,
686    spool_dir: Option<std::path::PathBuf>,
687    metrics: Arc<Registry>,
688) -> WorkerHandle {
689    spawn_worker_with_spool_authenticated(
690        capacity,
691        bridge,
692        embedder,
693        vector_sink,
694        spool_dir,
695        [0; 32],
696        metrics,
697        None,
698    )
699}
700
701/// Start the worker pool with authenticated active-workflow journals.
702#[allow(clippy::too_many_arguments)]
703pub fn spawn_worker_with_spool_authenticated(
704    capacity: usize,
705    bridge: Arc<dyn EventBus>,
706    embedder: Arc<dyn Embedder>,
707    vector_sink: Arc<dyn VectorSink>,
708    spool_dir: Option<std::path::PathBuf>,
709    journal_key: [u8; 32],
710    metrics: Arc<Registry>,
711    redaction: Option<Arc<pv_redact::RedactionEngine>>,
712) -> WorkerHandle {
713    spawn_worker_with_outputs(
714        capacity,
715        bridge,
716        embedder,
717        vector_sink,
718        spool_dir,
719        journal_key,
720        metrics,
721        crate::content::AuditOutputs::with_redaction(redaction),
722    )
723}
724
725/// Start the worker pool with authenticated journals, redaction, and an
726/// optional content sink that receives every journaled step.
727#[allow(clippy::too_many_arguments)]
728pub fn spawn_worker_with_outputs(
729    capacity: usize,
730    bridge: Arc<dyn EventBus>,
731    embedder: Arc<dyn Embedder>,
732    vector_sink: Arc<dyn VectorSink>,
733    spool_dir: Option<std::path::PathBuf>,
734    journal_key: [u8; 32],
735    metrics: Arc<Registry>,
736    outputs: crate::content::AuditOutputs,
737) -> WorkerHandle {
738    // Sharding is decoupled from `capacity`: routing is by
739    // `partition_for(session_id, senders.len())`, so every spawned
740    // shard is routable by construction. Under `capacity = 1` all
741    // shards still spawn; the global semaphore continues to enforce
742    // the caller's admission cap so total in-flight work is still
743    // bounded by `capacity`.
744    //
745    // The count was hardcoded at 16 regardless of core
746    // count, so the throughput ceiling (~24k events/s measured, and
747    // 1/16th of the id space frozen behind any one stalled session)
748    // did not improve with more cores. Scale with available
749    // parallelism, floored at the historical 16 so small machines
750    // keep the old fan-out. Per-session ordering is unaffected: a
751    // session's shard is stable for the process lifetime because
752    // routing derives from the spawned count.
753    const MIN_SHARDS: usize = 16;
754    let capacity = capacity.max(1);
755    let shard_count = std::thread::available_parallelism()
756        .map(usize::from)
757        .unwrap_or(MIN_SHARDS)
758        .max(MIN_SHARDS);
759    // Per-shard channel size: enough that a single shard can absorb
760    // the full admission burst even if every envelope happens to hash
761    // to it. TWO same-sized semaphores feed the same shard channels —
762    // worker capacity (admission jobs) and response capacity
763    // (capture jobs riding their admission-time reservation) — so the
764    // worst-case in-flight envelope count against one shard is
765    // 2 × `capacity`. Sizing at `capacity` alone let response
766    // envelopes fill a shard and (a) fail-close response captures
767    // whose slot was reserved at admission, and (b) break
768    // `submit_and_wait`'s cancellation-safety invariant that
769    // `send().await` never blocks while a permit is held (a
770    // cancellation while blocked leaks `pending` and deadlocks
771    // `wait_idle`).
772    let per_shard_capacity = capacity.saturating_mul(2);
773    let global_capacity = Arc::new(tokio::sync::Semaphore::new(capacity));
774    // Response-slot capacity mirrors worker capacity by default. Keeping
775    // them equal preserves the historical behaviour (both counters were
776    // silently drawn from the same pool) while distinct semaphores let
777    // operators observe which class exhausts first via
778    // `pv_events_dropped_total{stage="response_slot"}` vs
779    // `stage="worker_queue"`. A follow-up can expose an independent
780    // `response_capacity` config field once operators have telemetry.
781    let response_capacity = Arc::new(tokio::sync::Semaphore::new(capacity));
782    let pending = Arc::new(std::sync::atomic::AtomicU64::new(0));
783    let drained = Arc::new(tokio::sync::Notify::new());
784    let mut senders = Vec::with_capacity(shard_count);
785    for _ in 0..shard_count {
786        let (sender, receiver) = mpsc::channel::<Envelope>(per_shard_capacity);
787        senders.push(sender);
788        spawn_worker_shard(
789            receiver,
790            Arc::clone(&bridge),
791            Arc::clone(&embedder),
792            Arc::clone(&vector_sink),
793            spool_dir.clone(),
794            journal_key,
795            Arc::clone(&metrics),
796            Arc::clone(&pending),
797            Arc::clone(&drained),
798            outputs.clone(),
799        );
800    }
801    WorkerHandle {
802        senders: Arc::new(senders),
803        metrics,
804        pending,
805        drained,
806        capacity: global_capacity,
807        response_capacity,
808    }
809}
810
811/// Sessions a shard may process concurrently. One
812/// stalled session (slow journal fsync, hung broker publish) used to
813/// freeze its entire shard — 31× measured head-of-line blocking for
814/// every other session hashed there. Eight slots bound the
815/// wasted-capacity blast radius of pathological neighbors to 1/8 of
816/// a shard while keeping per-shard task fan-out small.
817const MAX_ACTIVE_SESSIONS_PER_SHARD: usize = 8;
818
819/// Group commit: how many backlogged envelopes of ONE
820/// session a single dispatch drains into a batch (one journal
821/// fdatasync per batch instead of one per event). Sized to cap both
822/// the added completion latency of the batch's first job and the
823/// replay window a crash-before-sync leaves (nothing acknowledged is
824/// ever lost — Phase B runs only after the batch sync).
825const MAX_BATCH_PER_SESSION: usize = 16;
826
827#[allow(clippy::too_many_arguments)]
828fn spawn_worker_shard(
829    mut receiver: mpsc::Receiver<Envelope>,
830    bridge: Arc<dyn EventBus>,
831    embedder: Arc<dyn Embedder>,
832    vector_sink: Arc<dyn VectorSink>,
833    spool_dir: Option<std::path::PathBuf>,
834    journal_key: [u8; 32],
835    worker_metrics: Arc<Registry>,
836    worker_pending: Arc<std::sync::atomic::AtomicU64>,
837    worker_drained: Arc<tokio::sync::Notify>,
838    outputs: crate::content::AuditOutputs,
839) {
840    // To avoid intra-shard head-of-line blocking, the shard is
841    // a DISPATCHER over per-session FIFO queues, not a serial loop.
842    // Invariants:
843    //   * Per-session ordering: at most ONE envelope per session is in
844    //     flight; the next spawns only after its predecessor's task
845    //     joins. Arrival order per session is preserved (mpsc order →
846    //     VecDeque order).
847    //   * Cross-session progress: up to MAX_ACTIVE_SESSIONS_PER_SHARD
848    //     sessions run concurrently, so one stalled session occupies
849    //     one slot instead of the whole shard.
850    //   * Memory stays bounded by the GLOBAL capacity semaphore: every
851    //     queued envelope still holds its `_capacity_permit`, so
852    //     draining the channel into local queues does not raise the
853    //     in-flight ceiling.
854    //   * Shutdown: the loop exits only when the channel is closed AND
855    //     every local queue and in-flight task has drained, preserving
856    //     the drain contract (`worker_pending` accounting is inside
857    //     `process_envelope`, unchanged).
858    tokio::spawn(async move {
859        let mut queues: std::collections::HashMap<String, std::collections::VecDeque<Envelope>> =
860            std::collections::HashMap::new();
861        // Sessions with an envelope IN FLIGHT. Tracked separately from
862        // `queues`: a session can be active AND have a backlog queued
863        // behind its in-flight envelope, and the slot-release scan
864        // must never dispatch from an active session's backlog — that
865        // would run two envelopes of one session concurrently and
866        // corrupt the per-session journal index sequence.
867        let mut active: std::collections::HashSet<String> = std::collections::HashSet::new();
868        let mut tasks: tokio::task::JoinSet<String> = tokio::task::JoinSet::new();
869        let mut receiver_open = true;
870
871        let spawn_batch = |tasks: &mut tokio::task::JoinSet<String>, batch: Vec<Envelope>| {
872            let session_id = batch
873                .first()
874                .map(|envelope| envelope.job.session.id.clone())
875                .unwrap_or_default();
876            let bridge = Arc::clone(&bridge);
877            let embedder = Arc::clone(&embedder);
878            let vector_sink = Arc::clone(&vector_sink);
879            let spool_dir = spool_dir.clone();
880            let worker_metrics = Arc::clone(&worker_metrics);
881            let worker_pending = Arc::clone(&worker_pending);
882            let worker_drained = Arc::clone(&worker_drained);
883            let outputs = outputs.clone();
884            tasks.spawn(async move {
885                use futures::future::FutureExt as _;
886                // Supervise the whole envelope (routing + process_job)
887                // exactly like the pre-dispatcher shard loop did: the
888                // INNER tokio::spawn(process_job) already catches job
889                // panics; this catch_unwind covers the outer routing so
890                // a panic cannot lose the session's dispatch slot. The
891                // task cannot panic between here and returning the id.
892                let outcome = std::panic::AssertUnwindSafe(async {
893                    let metrics = Arc::clone(&worker_metrics);
894                    let outcome = std::panic::AssertUnwindSafe(process_envelope_batch(
895                        batch,
896                        bridge,
897                        embedder,
898                        vector_sink,
899                        spool_dir,
900                        journal_key,
901                        worker_metrics,
902                        worker_pending,
903                        worker_drained,
904                        outputs,
905                    ))
906                    .catch_unwind()
907                    .await;
908                    if let Err(panic) = outcome {
909                        let msg = panic
910                            .downcast_ref::<&'static str>()
911                            .copied()
912                            .or_else(|| panic.downcast_ref::<String>().map(String::as_str))
913                            .unwrap_or("panic payload was not a string");
914                        metrics
915                            .counter(
916                                "pv_worker_shard_panics_total",
917                                "Worker shard driver panicked outside a job; supervised via catch_unwind",
918                            )
919                            .inc();
920                        tracing::error!(
921                            panic = %msg,
922                            "worker envelope routing panicked; continuing"
923                        );
924                    }
925                })
926                .catch_unwind()
927                .await;
928                drop(outcome);
929                session_id
930            });
931        };
932
933        loop {
934            if !receiver_open && tasks.is_empty() && queues.is_empty() {
935                break;
936            }
937            tokio::select! {
938                maybe_envelope = receiver.recv(), if receiver_open => {
939                    match maybe_envelope {
940                        Some(envelope) => {
941                            let session_id = envelope.job.session.id.clone();
942                            if !active.contains(&session_id)
943                                // Defense in depth: a WAITING backlog
944                                // (queued while at cap) must drain
945                                // first or this envelope would jump
946                                // its predecessors. Unreachable today
947                                // (every join refills slots from the
948                                // waiting set before the next recv),
949                                // but cheap to make impossible.
950                                && !queues.contains_key(&session_id)
951                                && active.len() < MAX_ACTIVE_SESSIONS_PER_SHARD
952                            {
953                                // Idle session, free slot: dispatch now.
954                                active.insert(session_id);
955                                spawn_batch(&mut tasks, vec![envelope]);
956                            } else {
957                                // Active (order! behind the in-flight
958                                // envelope) or no free slot: queue.
959                                queues.entry(session_id).or_default().push_back(envelope);
960                            }
961                        }
962                        None => receiver_open = false,
963                    }
964                }
965                Some(joined) = tasks.join_next(), if !tasks.is_empty() => {
966                    match joined {
967                        Ok(session_id) => {
968                            let backlog = queues.get_mut(&session_id).map_or(Vec::new(), |queue| {
969                                let take = queue.len().min(MAX_BATCH_PER_SESSION);
970                                queue.drain(..take).collect::<Vec<_>>()
971                            });
972                            if !backlog.is_empty() {
973                                // Same session next-in-line keeps its
974                                // slot: per-session order is preserved
975                                // because dispatch for an ACTIVE session
976                                // happens only here, after its
977                                // predecessor joined. The backlog runs
978                                // as ONE group-committed batch (§7.3).
979                                if queues.get(&session_id).is_some_and(std::collections::VecDeque::is_empty) {
980                                    queues.remove(&session_id);
981                                }
982                                spawn_batch(&mut tasks, backlog);
983                            } else {
984                                queues.remove(&session_id);
985                                active.remove(&session_id);
986                                // Slot freed: activate WAITING sessions
987                                // (queued work, not active — enqueued
988                                // while the shard was at cap).
989                                let waiting = queues
990                                    .keys()
991                                    .filter(|id| !active.contains(*id))
992                                    .take(
993                                        MAX_ACTIVE_SESSIONS_PER_SHARD.saturating_sub(active.len()),
994                                    )
995                                    .cloned()
996                                    .collect::<Vec<_>>();
997                                for id in waiting {
998                                    let batch = queues.get_mut(&id).map_or(Vec::new(), |queue| {
999                                        let take = queue.len().min(MAX_BATCH_PER_SESSION);
1000                                        queue.drain(..take).collect::<Vec<_>>()
1001                                    });
1002                                    if batch.is_empty() {
1003                                        continue;
1004                                    }
1005                                    if queues.get(&id).is_some_and(std::collections::VecDeque::is_empty) {
1006                                        queues.remove(&id);
1007                                    }
1008                                    active.insert(id);
1009                                    spawn_batch(&mut tasks, batch);
1010                                }
1011                            }
1012                        }
1013                        Err(error) => {
1014                            // Only runtime teardown/abort can land here
1015                            // (the task body is panic-supervised and
1016                            // infallible). The session's queue entry
1017                            // stays; it re-activates on the next slot
1018                            // release via the waiting scan above.
1019                            tracing::error!(%error, "worker envelope task failed to join");
1020                        }
1021                    }
1022                }
1023                else => {
1024                    // Receiver closed and no tasks in flight. Queues can
1025                    // only be non-empty here after a JoinError leak
1026                    // (runtime teardown); surface it rather than hang.
1027                    if !queues.is_empty() {
1028                        tracing::error!(
1029                            sessions = queues.len(),
1030                            "worker shard exiting with undispatched envelopes after task join failure"
1031                        );
1032                    }
1033                    break;
1034                }
1035            }
1036        }
1037    });
1038}
1039
1040/// Group commit: fdatasync a session's events journal
1041/// once, covering every deferred append a batch wrote before it.
1042async fn sync_session_journal(directory: &std::path::Path, session: &Session) -> Result<(), String> {
1043    let digest = pv_core::digest::sha256_hex(session.id.as_bytes());
1044    let stem = digest.get(..32).unwrap_or(&digest);
1045    let path = directory.join(format!("{stem}.events.ndjson"));
1046    tokio::task::spawn_blocking(move || {
1047        // Single-invariant discipline: EVERY open on a spool-derived
1048        // path uses `O_NOFOLLOW`. The events-journal append path
1049        // (worker.rs `append_active_event_journal`), torn-tail
1050        // `set_len` reopen (reconciler.rs), broker segment reopen
1051        // (pv-bridge/embedded.rs), and sidecar reopen all pass
1052        // `O_NOFOLLOW`; this group-commit sync was the last outlier.
1053        // In the current 0o700 daemon-owned threat model a symlink
1054        // plant is not reachable; but a future refactor that widened
1055        // spool group permissions would silently regress durability
1056        // of group-committed batches (the sync would flush a decoy
1057        // inode instead of the real journal-fd). Close the surface
1058        // preemptively so the invariant "spool open ⇒ O_NOFOLLOW"
1059        // holds uniformly.
1060        let mut options = std::fs::OpenOptions::new();
1061        options.read(true);
1062        #[cfg(unix)]
1063        {
1064            use std::os::unix::fs::OpenOptionsExt as _;
1065            options.custom_flags(pv_core::fsutil::unix_o_nofollow());
1066        }
1067        // Windows' FlushFileBuffers needs write access ("Access is
1068        // denied" on a read-only handle). Opening for write neither
1069        // creates nor truncates, so the journal is untouched.
1070        #[cfg(not(unix))]
1071        options.write(true);
1072        match options.open(&path) {
1073            Ok(journal) => journal.sync_data().map_err(|error| error.to_string()),
1074            // No journal file: every job in the batch was skipped by
1075            // the capture-failed guard before appending anything.
1076            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1077            Err(error) => Err(error.to_string()),
1078        }
1079    })
1080    .await
1081    .map_err(|error| error.to_string())?
1082}
1083
1084/// Group commit: process a SAME-SESSION batch of
1085/// envelopes with one journal fdatasync.
1086///
1087/// Phase A appends every job's record with the per-append sync
1088/// deferred; one `sync_session_journal` then makes the whole batch
1089/// durable; Phase B (publish, ack, totals, marker clear — everything
1090/// externally visible) runs per job strictly afterwards, so no job's
1091/// effects can precede its record's durability. A crash before the
1092/// batch sync is indistinguishable from crashing before processing:
1093/// nothing was published or acked, the response markers are still on
1094/// disk, and recovery quarantines exactly as it would have.
1095///
1096/// Fail-stop semantics match the serial path: a Phase A error marks
1097/// the session capture-failed and the remaining jobs resolve as
1098/// skipped/failed (their guards would have skipped them serially); a
1099/// Phase B error fails the batch's remaining jobs (their durable
1100/// records replay through crash recovery's re-publish path).
1101#[allow(clippy::too_many_arguments)]
1102async fn process_envelope_batch(
1103    envelopes: Vec<Envelope>,
1104    bridge: Arc<dyn EventBus>,
1105    embedder: Arc<dyn Embedder>,
1106    vector_sink: Arc<dyn VectorSink>,
1107    spool_dir: Option<std::path::PathBuf>,
1108    journal_key: [u8; 32],
1109    worker_metrics: Arc<Registry>,
1110    worker_pending: Arc<std::sync::atomic::AtomicU64>,
1111    worker_drained: Arc<tokio::sync::Notify>,
1112    outputs: crate::content::AuditOutputs,
1113) {
1114    let mut iterator = envelopes.into_iter();
1115    let Some(first) = iterator.next() else { return };
1116    let rest: Vec<Envelope> = iterator.collect();
1117    if rest.is_empty() {
1118        return process_envelope(
1119            first,
1120            bridge,
1121            embedder,
1122            vector_sink,
1123            spool_dir,
1124            journal_key,
1125            worker_metrics,
1126            worker_pending,
1127            worker_drained,
1128            outputs,
1129        )
1130        .await;
1131    }
1132    let session = Arc::clone(&first.job.session);
1133    // Per-envelope bookkeeping: guards + permits live for the whole
1134    // batch; completions resolve individually below.
1135    let mut guards = Vec::new();
1136    let mut completions = Vec::new();
1137    let mut jobs = Vec::new();
1138    for envelope in std::iter::once(first).chain(rest) {
1139        let Envelope {
1140            job,
1141            completion,
1142            span,
1143            _capacity_permit: capacity_permit,
1144        } = envelope;
1145        guards.push((
1146            PendingGuard::new(Arc::clone(&worker_pending), Arc::clone(&worker_drained)),
1147            SessionPendingGuard {
1148                session: Arc::clone(&job.session),
1149            },
1150            capacity_permit,
1151        ));
1152        completions.push(completion);
1153        jobs.push((job, span));
1154    }
1155    let batch_metrics = Arc::clone(&worker_metrics);
1156    let batch_session = Arc::clone(&session);
1157    let spool = spool_dir.clone();
1158    let result = tokio::spawn(async move {
1159        let mut outcomes: Vec<Result<(), String>> = Vec::new();
1160        let mut persisted: Vec<Option<PersistedJob>> = Vec::new();
1161        let mut failed = false;
1162        // Phase A: append every record, sync deferred.
1163        for (job, span) in jobs {
1164            if failed {
1165                // Serial semantics: after a sibling failure the
1166                // capture-failed guard would have skipped this job.
1167                outcomes.push(Ok(()));
1168                persisted.push(None);
1169                continue;
1170            }
1171            let outcome = persist_job(
1172                job,
1173                Arc::clone(&embedder),
1174                Arc::clone(&vector_sink),
1175                spool.as_deref(),
1176                journal_key,
1177                &batch_metrics,
1178                /* sync_journal */ false,
1179                &outputs,
1180            )
1181            .instrument(span)
1182            .await;
1183            match outcome {
1184                Ok(slot) => {
1185                    persisted.push(slot);
1186                    outcomes.push(Ok(()));
1187                }
1188                Err(error) => {
1189                    // Latch NOW so recovery semantics match serial
1190                    // processing (later jobs must not consume seqs).
1191                    batch_session.mark_capture_failed();
1192                    persisted.push(None);
1193                    outcomes.push(Err(error));
1194                    failed = true;
1195                }
1196            }
1197        }
1198        // The batch fsync: everything Phase A appended becomes
1199        // durable in one fdatasync.
1200        if let Some(directory) = spool.as_deref() {
1201            if persisted.iter().any(Option::is_some) {
1202                if let Err(error) = sync_session_journal(directory, &batch_session).await {
1203                    // None of the batch is provably durable: fail every
1204                    // job that had persisted (fail-closed, like a
1205                    // failed per-append sync on the serial path).
1206                    for (index, slot) in persisted.iter_mut().enumerate() {
1207                        if slot.take().is_some() {
1208                            if let Some(outcome) = outcomes.get_mut(index) {
1209                                *outcome = Err(format!("batch journal sync failed: {error}"));
1210                            }
1211                        }
1212                    }
1213                }
1214            }
1215        }
1216        // Phase B, in order, fail-stop.
1217        let mut tail_failed = false;
1218        for (index, slot) in persisted.into_iter().enumerate() {
1219            let Some(slot) = slot else { continue };
1220            if tail_failed {
1221                if let Some(outcome) = outcomes.get_mut(index) {
1222                    *outcome = Err("skipped after a batch sibling's post-persist failure".to_owned());
1223                }
1224                continue;
1225            }
1226            if let Err(error) = finish_job(slot, Arc::clone(&bridge), spool.as_deref(), journal_key).await {
1227                if let Some(outcome) = outcomes.get_mut(index) {
1228                    *outcome = Err(error);
1229                }
1230                tail_failed = true;
1231            }
1232        }
1233        outcomes
1234    })
1235    .await;
1236    let outcomes = match result {
1237        Ok(outcomes) => outcomes,
1238        Err(error) => {
1239            worker_metrics
1240                .counter(
1241                    "pv_worker_panics_total",
1242                    "Worker job panics isolated by supervisor",
1243                )
1244                .inc();
1245            let failure = format!("worker batch panicked: {error}");
1246            vec![Err(failure); completions.len()]
1247        }
1248    };
1249    let mut any_failed = false;
1250    for (completion, outcome) in completions.into_iter().zip(outcomes) {
1251        if let Err(error) = &outcome {
1252            any_failed = true;
1253            tracing::warn!(session = %session.id, %error, "capture job failed; session is fail-closed");
1254            worker_metrics
1255                .counter("pv_worker_errors_total", "Worker jobs that failed")
1256                .inc();
1257        }
1258        if let Some(completion) = completion {
1259            let _ = completion.send(outcome);
1260        }
1261    }
1262    if any_failed {
1263        session.mark_capture_failed();
1264    }
1265    drop(guards);
1266}
1267
1268/// One envelope's worth of shard-driver work, factored out so
1269/// [`spawn_worker_shard`] can wrap the whole body in `catch_unwind`.
1270#[allow(clippy::too_many_arguments)]
1271async fn process_envelope(
1272    envelope: Envelope,
1273    bridge: Arc<dyn EventBus>,
1274    embedder: Arc<dyn Embedder>,
1275    vector_sink: Arc<dyn VectorSink>,
1276    spool_dir: Option<std::path::PathBuf>,
1277    journal_key: [u8; 32],
1278    worker_metrics: Arc<Registry>,
1279    worker_pending: Arc<std::sync::atomic::AtomicU64>,
1280    worker_drained: Arc<tokio::sync::Notify>,
1281    outputs: crate::content::AuditOutputs,
1282) {
1283    // `worker_pending` was previously decremented at the
1284    // bottom of this function. Any panic between here and that line
1285    // (tokio::spawn(...).await JoinError construction under runtime
1286    // shutdown races, or a panic inside tracing::warn's Display
1287    // formatter when the error string exceeds the allocator's
1288    // fragmentation threshold) would leak the pending count — and
1289    // `WorkerHandle::wait_idle()` at shutdown would spin on
1290    // `notify.notified().await` forever, tripping the 30 s drain
1291    // budget and terminating without capturing evidence. Encode the
1292    // decrement in an RAII guard so unwinding is a valid release
1293    // point.
1294    let _pending_guard = PendingGuard::new(Arc::clone(&worker_pending), Arc::clone(&worker_drained));
1295    let Envelope {
1296        job,
1297        completion,
1298        span,
1299        _capacity_permit: capacity_permit,
1300    } = envelope;
1301    let session = Arc::clone(&job.session);
1302    // Guard the session-level pending-jobs decrement in
1303    // the same RAII shape as `PendingGuard`. The bare
1304    // `session.worker_job_finished()` after `tokio::spawn(...).await`
1305    // used to leak the session-level counter on any panic or drop
1306    // between here and line ~712. A stuck `session.pending_jobs` means
1307    // `close_session_locked -> wait_for_worker_jobs().await` blocks
1308    // forever on `jobs_drained.notified()`, holding the session's
1309    // lifecycle lock and starving every subsequent close / promote /
1310    // recovery-adopt on that id. Same leak class as the worker-level
1311    // counter, closed one call
1312    // frame up.
1313    let _session_pending_guard = SessionPendingGuard {
1314        session: Arc::clone(&session),
1315    };
1316    let result = tokio::spawn(
1317        {
1318            let job_metrics = Arc::clone(&worker_metrics);
1319            async move {
1320                process_job(
1321                    job,
1322                    bridge,
1323                    embedder,
1324                    vector_sink,
1325                    spool_dir,
1326                    journal_key,
1327                    job_metrics,
1328                    outputs,
1329                )
1330                .await
1331            }
1332        }
1333        .instrument(span),
1334    )
1335    .await;
1336    let outcome = match result {
1337        Ok(result) => result,
1338        Err(error) => {
1339            worker_metrics
1340                .counter(
1341                    "pv_worker_panics_total",
1342                    "Worker job panics isolated by supervisor",
1343                )
1344                .inc();
1345            Err(format!("worker job panicked: {error}"))
1346        }
1347    };
1348    if let Err(error) = &outcome {
1349        // The session flips fail-closed silently otherwise: name the
1350        // session and the root cause (e.g. EACCES on the journal
1351        // file) so operators can trace "capture is incomplete"
1352        // refusals back to this event.
1353        tracing::warn!(session = %session.id, %error, "capture job failed; session is fail-closed");
1354        session.mark_capture_failed();
1355        worker_metrics
1356            .counter("pv_worker_errors_total", "Worker jobs that failed")
1357            .inc();
1358    }
1359    // `_session_pending_guard`'s Drop calls
1360    // `worker_job_finished()` — replaces the bare call previously
1361    // here so a panic between `spawn.await` and this point cannot
1362    // leak the session pending counter.
1363    drop(capacity_permit);
1364    // The pending guards (named bindings above) drop at end of scope —
1365    // AFTER the completion send below, not here. That order is fine:
1366    // the receiver needs only the outcome, and `wait_idle` /
1367    // `wait_for_worker_jobs` re-check the pending counters in a loop,
1368    // so a momentary completed-but-still-pending window is benign.
1369    if let Some(completion) = completion {
1370        let _ = completion.send(outcome);
1371    }
1372}
1373
1374/// RAII decrement for `worker_pending`. Runs on every path — normal
1375/// return, error return, or panic — so `wait_idle` never sees a
1376/// permanently non-zero pending count after a panic in
1377/// [`process_envelope`].
1378struct PendingGuard {
1379    pending: Arc<std::sync::atomic::AtomicU64>,
1380    drained: Arc<tokio::sync::Notify>,
1381}
1382
1383impl PendingGuard {
1384    fn new(pending: Arc<std::sync::atomic::AtomicU64>, drained: Arc<tokio::sync::Notify>) -> Self {
1385        Self { pending, drained }
1386    }
1387}
1388
1389impl Drop for PendingGuard {
1390    fn drop(&mut self) {
1391        if self.pending.fetch_sub(1, Ordering::AcqRel) == 1 {
1392            self.drained.notify_waiters();
1393        }
1394    }
1395}
1396
1397/// RAII pair to [`PendingGuard`] for the session-level
1398/// pending-jobs counter. `Session::worker_job_finished()` was
1399/// previously called as a bare method after `tokio::spawn(...).await`,
1400/// but a panic in the routing-side epilogue (Display-side allocator
1401/// failure, catch_unwind of the wrapper future, runtime shutdown
1402/// mid-envelope) between `.await` and that call left
1403/// `session.pending_jobs` stuck. `close_session_locked` then blocked
1404/// forever on `wait_for_worker_jobs().await`, holding the session's
1405/// lifecycle lock — the exact leak class already closed on the
1406/// worker-level counter.
1407struct SessionPendingGuard {
1408    session: Arc<crate::session::Session>,
1409}
1410
1411impl Drop for SessionPendingGuard {
1412    fn drop(&mut self) {
1413        self.session.worker_job_finished();
1414    }
1415}
1416
1417fn worker_span(job: &WorkerJob) -> tracing::Span {
1418    tracing::info_span!(
1419        parent: &tracing::Span::current(),
1420        "proofvisor.worker",
1421        session.id = %job.session.id,
1422        event.class = ?job.class,
1423    )
1424}
1425
1426#[allow(clippy::too_many_arguments)]
1427/// A job whose journal record is appended (Phase A of the
1428/// group commit) and which still owes its post-persist tail:
1429/// chain/step accounting, bridge publish, broker ack, totals, marker
1430/// clear. Carried between `persist_job` and `finish_job` so a
1431/// same-session batch can append N records under ONE fdatasync
1432/// before any job's effects become externally visible.
1433struct PersistedJob {
1434    session: Arc<Session>,
1435    identity: pv_events::AgentIdentity,
1436    class: EventClass,
1437    value: Value,
1438    event_uid: String,
1439    record: ActiveJournalRecord,
1440    response_marker: Option<String>,
1441    /// Content form of the step, handed to the content dispatcher only
1442    /// after the step is durable and published.
1443    content: Option<(
1444        Arc<crate::content::ContentDispatcher>,
1445        crate::content::ContentRecord,
1446    )>,
1447}
1448
1449#[allow(clippy::too_many_arguments)]
1450async fn process_job(
1451    job: WorkerJob,
1452    bridge: Arc<dyn EventBus>,
1453    embedder: Arc<dyn Embedder>,
1454    vector_sink: Arc<dyn VectorSink>,
1455    spool_dir: Option<std::path::PathBuf>,
1456    journal_key: [u8; 32],
1457    metrics: Arc<Registry>,
1458    outputs: crate::content::AuditOutputs,
1459) -> Result<(), String> {
1460    match persist_job(
1461        job,
1462        embedder,
1463        vector_sink,
1464        spool_dir.as_deref(),
1465        journal_key,
1466        &metrics,
1467        /* sync_journal */ true,
1468        &outputs,
1469    )
1470    .await?
1471    {
1472        None => Ok(()),
1473        Some(persisted) => finish_job(persisted, bridge, spool_dir.as_deref(), journal_key).await,
1474    }
1475}
1476
1477/// Redact both copies of the audit data before they reach durable storage.
1478fn redact_audit_capture(
1479    mut value: Value,
1480    mut atif_step: Option<pv_atif::Step>,
1481    class: EventClass,
1482    engine: &pv_redact::RedactionEngine,
1483) -> (Value, Option<pv_atif::Step>) {
1484    // Tool authorization stores the event payload as a JSON string in the
1485    // observation. Preserve that schema, but reuse the redacted payload for
1486    // exact copies. Arbitrary tool output is not a payload-relative document.
1487    let original_payload = (class == EventClass::ToolCall)
1488        .then(|| value.get("payload").map(Value::to_string))
1489        .flatten();
1490    if let Some(payload) = value.get_mut("payload") {
1491        engine.redact_value(payload);
1492    }
1493    if let Some(step) = &mut atif_step {
1494        engine.redact_value(&mut step.message);
1495        if let Some(reasoning) = &mut step.reasoning_content {
1496            let mut text = Value::String(std::mem::take(reasoning));
1497            engine.redact_value(&mut text);
1498            if let Value::String(redacted) = text {
1499                *reasoning = redacted;
1500            }
1501        }
1502        if let Some(calls) = &mut step.tool_calls {
1503            for call in calls {
1504                engine.redact_user_value(&mut call.arguments);
1505            }
1506        }
1507        if let Some(observation) = &mut step.observation {
1508            for result in &mut observation.results {
1509                if let Some(content) = &mut result.content {
1510                    if original_payload
1511                        .as_deref()
1512                        .is_some_and(|original| content.as_str() == Some(original))
1513                    {
1514                        if let Some(payload) = value.get("payload") {
1515                            *content = Value::String(payload.to_string());
1516                        }
1517                    }
1518                    engine.redact_value(content);
1519                }
1520            }
1521        }
1522    }
1523    (value, atif_step)
1524}
1525
1526/// Phase A: analyze, build the event + journal record, and append it
1527/// to the session journal (fsynced only when `sync_journal` — a batch
1528/// syncs once after its last append). Returns `None` when the session
1529/// is poisoned (capture-failed guard) and the job must be skipped
1530/// without consuming a sequence number.
1531#[allow(clippy::too_many_arguments)]
1532async fn persist_job(
1533    mut job: WorkerJob,
1534    embedder: Arc<dyn Embedder>,
1535    vector_sink: Arc<dyn VectorSink>,
1536    spool_dir: Option<&std::path::Path>,
1537    journal_key: [u8; 32],
1538    metrics: &Arc<Registry>,
1539    sync_journal: bool,
1540    outputs: &crate::content::AuditOutputs,
1541) -> Result<Option<PersistedJob>, String> {
1542    // A queued envelope for a session that has already been poisoned must not
1543    // consume a sequence number or write to the journal — otherwise the seq
1544    // its OcsfEvent carries (from `next_seq`, advanced by the failed prior
1545    // envelope) would not match the entry's position on disk, breaking
1546    // recovery's `event.metadata.sequence != index` check.
1547    if job.session.capture_failed() {
1548        return Ok(None);
1549    }
1550    let response_marker = job.response_marker.clone();
1551    let is_llm_agent_response = job
1552        .atif
1553        .as_ref()
1554        .is_some_and(|capture| capture.source == pv_atif::Source::Agent && capture.llm_call_count != Some(0));
1555    let step_tokens = job
1556        .metrics
1557        .prompt_tokens
1558        .unwrap_or(0)
1559        .saturating_add(job.metrics.completion_tokens.unwrap_or(0));
1560    let breaker = if job.analyze_loop {
1561        let text = job.text.clone();
1562        // vector-sink and embedder errors must not
1563        // brick the session — the sink's trait doc-comment describes it
1564        // as "off-path observability, not participating in the hot
1565        // path". A single 2 s Qdrant timeout or one transient
1566        // ONNX/HashEmbedder error used to `?`-propagate all the way to
1567        // `mark_capture_failed` (sticky) → 503 forever. Downgrade all
1568        // three failure modes (embed, query, record) to warn+counter +
1569        // continue-without-breaker: the audit chain still gets the
1570        // step; only the semantic-loop breaker is skipped for that
1571        // step. Journal and receipt integrity still fail-close as
1572        // before via other paths.
1573        let embedding_result = tokio::task::spawn_blocking(move || embedder.try_embed(&text))
1574            .await
1575            .map_err(|error| error.to_string())?;
1576        match embedding_result {
1577            Ok(embedding) => {
1578                let similarity = match vector_sink
1579                    .nearest_similarity(&job.session.session_scope, &embedding)
1580                    .await
1581                {
1582                    Ok(similarity) => similarity,
1583                    Err(error) => {
1584                        tracing::warn!(
1585                            session = %job.session.id,
1586                            %error,
1587                            "vector-sink nearest_similarity failed; skipping breaker for this step"
1588                        );
1589                        metrics
1590                            .counter(
1591                                "pv_vector_sink_errors_total",
1592                                "Vector-sink errors demoted to warn",
1593                            )
1594                            .inc();
1595                        None
1596                    }
1597                };
1598                let verdict = job.session.loop_state.observe_embedding_with_similarity(
1599                    embedding.clone(),
1600                    step_tokens,
1601                    similarity,
1602                );
1603                if let Err(error) = vector_sink.record(&job.session.session_scope, &embedding).await {
1604                    tracing::warn!(
1605                        session = %job.session.id,
1606                        %error,
1607                        "vector-sink record failed; the local breaker still observed this step"
1608                    );
1609                    metrics
1610                        .counter(
1611                            "pv_vector_sink_errors_total",
1612                            "Vector-sink errors demoted to warn",
1613                        )
1614                        .inc();
1615                }
1616                Some(verdict)
1617            }
1618            Err(error) => {
1619                tracing::warn!(
1620                    session = %job.session.id,
1621                    %error,
1622                    "embedder try_embed failed; skipping breaker for this step"
1623                );
1624                metrics
1625                    .counter("pv_embedder_errors_total", "Embedder errors demoted to warn")
1626                    .inc();
1627                None
1628            }
1629        }
1630    } else {
1631        None
1632    };
1633    // Accounting must key on the class the job was *submitted* with: a
1634    // breaker trip replaces `class` with StopReason below, and deciding the
1635    // prompt/completion buckets from the replaced class silently dropped a
1636    // tripped chat admission's prompt tokens from the journal record and the
1637    // receipt totals — undercounting exactly the runaway sessions the
1638    // breaker exists to attest.
1639    let submitted_class = job.class;
1640    let (class, status, stop_reason, payload) = match breaker {
1641        Some(BreakerVerdict::Tripped {
1642            delta,
1643            streak,
1644            tokens_consumed,
1645            action,
1646        }) => {
1647            // The trip is recorded in the trajectory, but operators watching
1648            // logs/metrics must also see why this session's next request
1649            // will be rejected/aborted/injected.
1650            tracing::warn!(
1651                session = %job.session.id,
1652                delta,
1653                streak,
1654                tokens_consumed,
1655                action = ?action,
1656                "semantic loop circuit breaker tripped"
1657            );
1658            metrics
1659                .counter("pv_breaker_trips_total", "Semantic loop breaker trips")
1660                .inc();
1661            // Latch at the trip itself — not only at the NEXT
1662            // admission's breaker-open refusal: a session whose breaker
1663            // trips and is then closed without another admission
1664            // attempt (explicit /close, idle sweep) would otherwise
1665            // recycle its id with a fresh breaker. Terminal actions
1666            // only, matching the admission arms: an Inject-configured
1667            // breaker resolves the trip by rewriting the next request,
1668            // so its trip must not make the id terminal.
1669            if matches!(
1670                action,
1671                pv_loopdetect::BreakerAction::Abort | pv_loopdetect::BreakerAction::Reject
1672            ) {
1673                job.session.latch_enforcement(StopReason::LoopDetected);
1674            }
1675            (
1676                EventClass::StopReason,
1677                StatusId::Failure,
1678                Some(StopReason::LoopDetected),
1679                serde_json::json!({
1680                    "delta": delta,
1681                    "streak": streak,
1682                    "tokens_consumed": tokens_consumed,
1683                    "action": action,
1684                }),
1685            )
1686        }
1687        _ => (job.class, job.status, job.stop_reason, job.payload),
1688    };
1689
1690    // Kept for the content sink: the event builder consumes it.
1691    let native_finish_reason = job.native_stop_reason.clone();
1692    let mut builder = OcsfEventBuilder::new(
1693        class,
1694        job.session.id.clone(),
1695        job.identity.clone(),
1696        job.session.next_seq(),
1697    )
1698    .status(status)
1699    .payload(payload)
1700    .metrics(job.metrics);
1701    if let Some(reason) = stop_reason {
1702        job.session.record_stop_reason(reason);
1703        builder = match job.native_stop_reason {
1704            Some(native) => builder.stop_reason_native(reason, native),
1705            None => builder.stop_reason(reason),
1706        };
1707    }
1708    let event = builder.build().map_err(|error| error.to_string())?;
1709    let event_uid = event.metadata.uid.clone();
1710    let value = serde_json::to_value(&event).map_err(|error| error.to_string())?;
1711
1712    let atif_step = if job.session.workflow == Workflow::Unsigned {
1713        let capture = job
1714            .atif
1715            .take()
1716            .ok_or_else(|| "unsigned worker job has no ATIF capture".to_owned())?;
1717        let is_llm_step = capture.source == pv_atif::Source::Agent && capture.llm_call_count != Some(0);
1718        Some(pv_atif::Step {
1719            step_id: 0,
1720            timestamp: Some(pv_core::time::now_iso8601()),
1721            source: capture.source,
1722            message: capture.message,
1723            reasoning_effort: None,
1724            reasoning_content: capture.reasoning_content,
1725            model_name: capture.model_name,
1726            tool_calls: capture.tool_calls,
1727            observation: capture.observation,
1728            metrics: is_llm_step.then(|| pv_atif::Metrics {
1729                prompt_tokens: Some(job.metrics.prompt_tokens.unwrap_or(0)),
1730                completion_tokens: Some(job.metrics.completion_tokens.unwrap_or(0)),
1731                cached_tokens: Some(job.metrics.cached_tokens.unwrap_or(0)),
1732                cost_usd: Some(job.cost_usd_micros as f64 / pv_core::units::USD_MICROS_PER_DOLLAR as f64),
1733                logprobs: None,
1734                completion_token_ids: None,
1735                prompt_token_ids: None,
1736                extra: None,
1737            }),
1738            is_copied_context: None,
1739            llm_call_count: capture.llm_call_count,
1740            extra: None,
1741        })
1742    } else {
1743        None
1744    };
1745    // Redaction scans caller-controlled text. Keep that CPU work off the
1746    // async runtime, while still completing it before any journal or bus write.
1747    let (value, atif_step) = if let Some(engine) = outputs.redaction.as_ref() {
1748        let engine = Arc::clone(engine);
1749        tokio::task::spawn_blocking(move || redact_audit_capture(value, atif_step, submitted_class, &engine))
1750            .await
1751            .map_err(|error| format!("redaction task failed: {error}"))?
1752    } else {
1753        (value, atif_step)
1754    };
1755    let is_tool_call = submitted_class == EventClass::ToolCall;
1756    let is_response_accounting = is_llm_agent_response && submitted_class != EventClass::Compression;
1757    let record = ActiveJournalRecord {
1758        event: value.clone(),
1759        identity: job.identity.clone(),
1760        atif_step: atif_step.clone(),
1761        tool_calls: u64::from(is_tool_call),
1762        tool_allowed: u64::from(is_tool_call && status == StatusId::Success),
1763        tool_blocked: u64::from(is_tool_call && status == StatusId::Failure),
1764        prompt_tokens: if submitted_class == EventClass::Compression {
1765            job.metrics.prompt_tokens.unwrap_or(0)
1766        } else {
1767            0
1768        },
1769        completion_tokens: if is_response_accounting {
1770            job.metrics.completion_tokens.unwrap_or(0)
1771        } else {
1772            0
1773        },
1774        cached_tokens: if is_response_accounting {
1775            job.metrics.cached_tokens.unwrap_or(0)
1776        } else {
1777            0
1778        },
1779        cost_usd_micros: if is_response_accounting {
1780            job.cost_usd_micros
1781        } else {
1782            0
1783        },
1784        prompt_token_correction: if is_response_accounting {
1785            job.prompt_token_correction
1786        } else {
1787            0
1788        },
1789        stop_reason_id: event.stop_reason_id,
1790        response_attempt: job.response_attempt.clone(),
1791    };
1792    if let Some(directory) = spool_dir {
1793        append_active_event_journal(
1794            directory,
1795            &job.session,
1796            &job.identity,
1797            &record,
1798            &journal_key,
1799            sync_journal,
1800        )
1801        .await?;
1802    }
1803    let content = outputs.dispatcher().map(|dispatcher| {
1804        (
1805            dispatcher,
1806            content_record(
1807                ContentSource {
1808                    session_id: &job.session.id,
1809                    identity: &job.identity,
1810                    capture: job.atif.as_ref(),
1811                    metrics: &job.metrics,
1812                    attempt: job.response_attempt.as_ref(),
1813                },
1814                atif_step.as_ref(),
1815                class,
1816                status,
1817                &value,
1818                &event_uid,
1819                native_finish_reason,
1820            ),
1821        )
1822    });
1823    Ok(Some(PersistedJob {
1824        session: job.session,
1825        identity: job.identity,
1826        class,
1827        value,
1828        event_uid,
1829        record,
1830        response_marker,
1831        content,
1832    }))
1833}
1834
1835/// The journaled step in content form. Unsigned sessions supply the
1836/// (redacted) trajectory step; signed sessions supply the original capture,
1837/// which the sink redacts itself.
1838struct ContentSource<'a> {
1839    session_id: &'a str,
1840    identity: &'a pv_events::AgentIdentity,
1841    capture: Option<&'a AtifCapture>,
1842    metrics: &'a EventMetrics,
1843    attempt: Option<&'a ResponseAttempt>,
1844}
1845
1846fn content_record(
1847    job: ContentSource<'_>,
1848    step: Option<&pv_atif::Step>,
1849    class: EventClass,
1850    status: StatusId,
1851    event: &Value,
1852    event_uid: &str,
1853    finish_reason: Option<String>,
1854) -> crate::content::ContentRecord {
1855    let label = |source: pv_atif::Source| {
1856        match source {
1857            pv_atif::Source::User => "user",
1858            pv_atif::Source::Agent => "agent",
1859            _ => "system",
1860        }
1861        .to_owned()
1862    };
1863    let (source, message, reasoning, model, tool_calls, observation) = match (step, job.capture) {
1864        (Some(step), _) => (
1865            Some(label(step.source)),
1866            Some(step.message.clone()),
1867            step.reasoning_content.clone(),
1868            step.model_name.clone(),
1869            step.tool_calls
1870                .as_ref()
1871                .and_then(|calls| serde_json::to_value(calls).ok()),
1872            step.observation
1873                .as_ref()
1874                .and_then(|observation| serde_json::to_value(observation).ok()),
1875        ),
1876        (None, Some(capture)) => (
1877            Some(label(capture.source)),
1878            Some(capture.message.clone()),
1879            capture.reasoning_content.clone(),
1880            capture.model_name.clone(),
1881            capture
1882                .tool_calls
1883                .as_ref()
1884                .and_then(|calls| serde_json::to_value(calls).ok()),
1885            capture
1886                .observation
1887                .as_ref()
1888                .and_then(|observation| serde_json::to_value(observation).ok()),
1889        ),
1890        (None, None) => (None, None, None, None, None, None),
1891    };
1892    crate::content::ContentRecord {
1893        session_id: job.session_id.to_owned(),
1894        event_uid: event_uid.to_owned(),
1895        class,
1896        success: status == StatusId::Success,
1897        identity: job.identity.clone(),
1898        time_ms: pv_core::time::now_ms(),
1899        event: event.clone(),
1900        source,
1901        message,
1902        reasoning,
1903        model,
1904        tool_calls,
1905        observation,
1906        prompt_tokens: job.metrics.prompt_tokens,
1907        completion_tokens: job.metrics.completion_tokens,
1908        finish_reason,
1909        attempt_id: job.attempt.map(|attempt| attempt.id.clone()),
1910        terminal: job.attempt.is_some_and(|attempt| attempt.terminal),
1911    }
1912}
1913
1914/// Phase B: everything that makes the job externally visible. MUST
1915/// run only after the job's journal record is durable (its own
1916/// synced append, or the batch fsync covering it) — publishing an
1917/// unsynced event would let a crash leave the bus ahead of the
1918/// journal.
1919async fn finish_job(
1920    persisted: PersistedJob,
1921    bridge: Arc<dyn EventBus>,
1922    spool_dir: Option<&std::path::Path>,
1923    journal_key: [u8; 32],
1924) -> Result<(), String> {
1925    let PersistedJob {
1926        session,
1927        identity,
1928        class,
1929        value,
1930        event_uid,
1931        record,
1932        response_marker,
1933        content,
1934    } = persisted;
1935    match session.workflow {
1936        Workflow::Signed => session
1937            .chain
1938            .lock()
1939            .append(&value)
1940            .map_err(|error| error.to_string())?,
1941        Workflow::Unsigned => {
1942            // RAM-cliff guard: the step was already
1943            // journaled durably in `record.atif_step` — do NOT
1944            // retain a second copy in the in-RAM builder (50 turns ×
1945            // 2 KB × 10k sessions was 1.35 GB held for the idle
1946            // window). Close rebuilds the trajectory from the
1947            // journal; RAM keeps only the count.
1948            if record.atif_step.is_none() {
1949                return Err("unsigned ATIF step disappeared".to_owned());
1950            }
1951            session.note_atif_step();
1952        }
1953    }
1954
1955    let key = identity.instance_uid.clone();
1956    let publish_topic = class.topic().to_owned();
1957    let publish_uid = event_uid.clone();
1958    let ack = tokio::task::spawn_blocking(move || {
1959        bridge.publish_idempotent(&publish_topic, &key, &value, &publish_uid)
1960    })
1961    .await
1962    .map_err(|error| error.to_string())?
1963    .map_err(|error| error.to_string())?;
1964    if let Some(directory) = spool_dir {
1965        persist_broker_ack(directory, &session.id, &event_uid, &ack, &journal_key).await?;
1966    }
1967    // The record IS the accounting rule. Its fields
1968    // were conditionalized once at construction above; apply them
1969    // through the same impl the recovery folds replay, so the live
1970    // path and a crash-recovered session can never attest different
1971    // numbers for identical traffic.
1972    record
1973        .apply_to_totals(&session.totals)
1974        .map_err(|error| error.to_string())?;
1975    if let (Some(directory), Some(attempt_id)) = (spool_dir, response_marker.as_deref()) {
1976        clear_response_marker(directory, &journal_key, &session.id, attempt_id).await?;
1977    }
1978    // Content leaves the process only after the step is durable and
1979    // published; the dispatcher never blocks and never fails this job.
1980    if let Some((dispatcher, content)) = content {
1981        dispatcher.submit(content);
1982    }
1983    Ok(())
1984}
1985
1986#[cfg(test)]
1987fn checked_atomic_add(counter: &std::sync::atomic::AtomicU64, value: u64, field: &str) -> Result<(), String> {
1988    counter
1989        .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
1990            current
1991                .checked_add(value)
1992                .filter(|next| *next <= pv_core::error::JCS_SAFE_MAX)
1993        })
1994        .map(|_| ())
1995        .map_err(|_| format!("{field} accounting exceeds JCS-safe bounds"))
1996}
1997
1998/// Reserve a fresh attempt id without touching the disk. The guard
1999/// **must** be armed with this id before the `write_response_marker`
2000/// await below, so a client cancellation between the two doesn't leave
2001/// a durable marker no in-memory guard owns.
2002pub(crate) fn reserve_response_attempt_id() -> String {
2003    pv_core::new_event_uid()
2004}
2005
2006pub(crate) async fn write_response_marker(
2007    spool_dir: &std::path::Path,
2008    journal_key: &[u8; 32],
2009    session_id: &str,
2010    attempt_id: &str,
2011    request_digest: String,
2012) -> Result<(), String> {
2013    let marker = InFlightResponse {
2014        session_id: session_id.to_owned(),
2015        attempt_id: attempt_id.to_owned(),
2016        request_digest,
2017    };
2018    let sealed = crate::journal::seal(journal_key, "in-flight-response", 0, &marker)?;
2019    let path = response_marker_path(spool_dir, session_id, attempt_id);
2020    tokio::task::spawn_blocking(move || write_atomic_control(&path, &sealed))
2021        .await
2022        .map_err(|error| error.to_string())??;
2023    Ok(())
2024}
2025
2026// Retained for tests + backwards compat — reserves id, writes marker,
2027// returns the id. Do not use on cancellable paths (see
2028// `reserve_response_attempt_id`).
2029#[allow(dead_code)]
2030pub(crate) async fn create_response_marker(
2031    spool_dir: &std::path::Path,
2032    journal_key: &[u8; 32],
2033    session_id: &str,
2034    request_digest: String,
2035) -> Result<String, String> {
2036    let attempt_id = reserve_response_attempt_id();
2037    write_response_marker(spool_dir, journal_key, session_id, &attempt_id, request_digest).await?;
2038    Ok(attempt_id)
2039}
2040
2041#[cfg(test)]
2042pub(crate) async fn ensure_no_inflight_responses(
2043    spool_dir: &std::path::Path,
2044    journal_key: &[u8; 32],
2045) -> Result<(), String> {
2046    let sessions = inflight_response_sessions(spool_dir, journal_key).await?;
2047    if let Some(session_id) = sessions.into_iter().next() {
2048        Err(format!(
2049            "provider response for session {session_id} was not durably captured"
2050        ))
2051    } else {
2052        Ok(())
2053    }
2054}
2055
2056pub(crate) async fn inflight_response_sessions(
2057    spool_dir: &std::path::Path,
2058    journal_key: &[u8; 32],
2059) -> Result<std::collections::HashSet<String>, String> {
2060    let spool_dir = spool_dir.to_path_buf();
2061    let directory = spool_dir.join(crate::spool::INFLIGHT_RESPONSES);
2062    let journal_key = *journal_key;
2063    tokio::task::spawn_blocking(move || {
2064        let entries = match std::fs::read_dir(&directory) {
2065            Ok(entries) => entries,
2066            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2067                return Ok(std::collections::HashSet::new());
2068            }
2069            Err(error) => return Err(error.to_string()),
2070        };
2071        let mut sessions = std::collections::HashSet::new();
2072        for entry in entries {
2073            let path = entry.map_err(|error| error.to_string())?.path();
2074            if path.extension().and_then(std::ffi::OsStr::to_str) != Some("json") {
2075                continue;
2076            }
2077            let marker_bytes = match pv_core::fsutil::read_capped(&path, pv_core::fsutil::MAX_CONTROL_BYTES) {
2078                Ok(bytes) => bytes,
2079                // A live request legitimately clears its marker between
2080                // the directory listing and this read — the response
2081                // completed; it is not in flight.
2082                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2083                Err(error) => return Err(error.to_string()),
2084            };
2085            // A single unverifiable marker (seed rotation, future field
2086            // added to `InFlightResponse`, torn payload) must not brick
2087            // recovery — which is fatal at boot in main.rs. Mirror the
2088            // tool-intent quarantine discipline: warn, rename to
2089            // `.corrupt-<uid>`, skip. The signed chain never referenced
2090            // this file; nothing else claims it.
2091            let marker: InFlightResponse =
2092                match crate::journal::open(&journal_key, "in-flight-response", 0, &marker_bytes) {
2093                    Ok(marker) => marker,
2094                    Err(error) => {
2095                        quarantine_inflight_marker(&path, &error);
2096                        continue;
2097                    }
2098                };
2099            if path != response_marker_path(&spool_dir, &marker.session_id, &marker.attempt_id) {
2100                // Same defense: a marker whose payload's ids don't
2101                // reconstruct its filename must not stall recovery.
2102                quarantine_inflight_marker(
2103                    &path,
2104                    "in-flight response marker path does not match its payload",
2105                );
2106                continue;
2107            }
2108            // CRASH-WINDOW FALSE-POSITIVE GUARD (R63 Finding 1).
2109            // `finish_job` fsyncs the terminal response
2110            // record BEFORE `clear_response_marker` runs, and the
2111            // interval between the two is broker publish
2112            // (spawn_blocking) + persist_broker_ack (spool write) +
2113            // apply_to_totals — hundreds of milliseconds to seconds
2114            // under load. A crash inside that window leaves this
2115            // marker on disk even though the journal already
2116            // witnesses the response as terminal. Without this
2117            // consultation the downstream quarantine paths at
2118            // reconciler.rs:1982/2458/2709 flip the session to
2119            // `capture_failed`: signed sessions never mint receipts,
2120            // unsigned sessions never write ATIF trajectories — a
2121            // silent-data-loss on a properly-fsynced completion.
2122            // Consult the journal (MAC-verified per line, so a
2123            // hostile local process cannot inject a fake terminal
2124            // to mask a real capture failure); if it records a
2125            // terminal `response_attempt` for this attempt_id, the
2126            // marker is a crash-window artifact — opportunistically
2127            // delete it and skip.
2128            match journal_witnesses_terminal_attempt(
2129                &spool_dir,
2130                &journal_key,
2131                &marker.session_id,
2132                &marker.attempt_id,
2133            ) {
2134                Ok(true) => {
2135                    match std::fs::remove_file(&path) {
2136                        Ok(()) => tracing::info!(
2137                            session = %marker.session_id,
2138                            attempt = %marker.attempt_id,
2139                            "cleared stale in-flight response marker (journal witnesses terminal record)"
2140                        ),
2141                        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2142                        Err(error) => tracing::warn!(
2143                            marker = %pv_core::fsutil::basename(&path),
2144                            %error,
2145                            "failed to clear stale in-flight response marker after journal witness"
2146                        ),
2147                    }
2148                    continue;
2149                }
2150                Ok(false) => {}
2151                Err(error) => {
2152                    // Journal may itself be torn or MAC-mismatched
2153                    // (a separate quarantine path will handle that).
2154                    // Do NOT silently drop the marker on a witness
2155                    // failure — fall through and let downstream
2156                    // quarantine treat this session as capture_failed
2157                    // (the safe default: false-positive quarantine
2158                    // preserves evidence; missed quarantine loses it).
2159                    tracing::warn!(
2160                        session = %marker.session_id,
2161                        attempt = %marker.attempt_id,
2162                        %error,
2163                        "in-flight response journal-witness check failed; retaining marker"
2164                    );
2165                }
2166            }
2167            sessions.insert(marker.session_id);
2168        }
2169        Ok(sessions)
2170    })
2171    .await
2172    .map_err(|error| error.to_string())?
2173}
2174
2175/// Consult `<stem>.events.ndjson` for a MAC-verified `response_attempt`
2176/// record with `terminal=true` and matching `attempt_id`. Used to
2177/// distinguish a crash-window stale in-flight marker from a genuinely
2178/// interrupted response. Truncates at the last complete newline so a
2179/// torn tail (a common crash pattern) doesn't cause a MAC-open error
2180/// on garbage; complete records earlier in the file are still
2181/// verified.
2182///
2183/// **Concurrent-scan invariant** (R64 M1): `inflight_response_sessions`
2184/// is called from EVERY reconciler tick (`recovery.rs:157`), not just
2185/// at boot. Live workers can be actively appending to the same
2186/// `events.ndjson` this helper reads. Safety today relies on THREE
2187/// independent defenses that must all remain intact under future
2188/// refactors:
2189///   1. Newline-boundary truncation in this fn: the writer emits
2190///      `line` then `\n` as two separate `write_all` calls
2191///      (`append_journal` below), so a partial mid-race read whose
2192///      tail lacks `\n` is truncated here — the still-fsyncing record
2193///      is dropped from consideration and the next tick will see it.
2194///   2. `clear_response_marker` is idempotent to `NotFound`
2195///      (below): if a live worker races with our `remove_file` of
2196///      the same marker, the loser observes `NotFound` and treats
2197///      it as success.
2198///   3. `QuarantineIncompleteEffectsPass` retains ONLY sessions
2199///      absent from the live registry (`recovery.rs:165`), so
2200///      even if our scan sees a live session's in-flight marker,
2201///      that session cannot end up in `quarantined_sessions` while
2202///      it is still owned by a worker.
2203///
2204/// Weaken ANY one of those and the helper silently regresses.
2205fn journal_witnesses_terminal_attempt(
2206    spool_dir: &std::path::Path,
2207    journal_key: &[u8; 32],
2208    session_id: &str,
2209    attempt_id: &str,
2210) -> Result<bool, String> {
2211    let digest = pv_core::digest::sha256_hex(session_id.as_bytes());
2212    let stem = digest.get(..32).unwrap_or(&digest);
2213    let journal_path = spool_dir.join(format!("{stem}.events.ndjson"));
2214    let bytes = match pv_core::fsutil::read_capped(&journal_path, pv_core::fsutil::MAX_ATIF_BYTES) {
2215        Ok(bytes) => bytes,
2216        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
2217        Err(error) => return Err(error.to_string()),
2218    };
2219    if bytes.is_empty() {
2220        return Ok(false);
2221    }
2222    let complete_len = if bytes.last() == Some(&b'\n') {
2223        bytes.len()
2224    } else {
2225        bytes
2226            .iter()
2227            .rposition(|byte| *byte == b'\n')
2228            .map_or(0, |index| index + 1)
2229    };
2230    let domain = format!("{session_id}:active");
2231    let mut index: u64 = 0;
2232    let mut cursor = 0usize;
2233    while cursor < complete_len {
2234        let Some(remaining) = bytes.get(cursor..complete_len) else {
2235            break;
2236        };
2237        let Some(newline_offset) = remaining.iter().position(|byte| *byte == b'\n') else {
2238            break;
2239        };
2240        let Some(line) = remaining.get(..newline_offset) else {
2241            break;
2242        };
2243        cursor += newline_offset + 1;
2244        if line.is_empty() {
2245            // R64 L1: `append_journal` never emits an empty line
2246            // (it writes a non-empty sealed record then `\n`), so
2247            // an empty line mid-file is corruption. Refuse to
2248            // silently skip it — Err defensively retains the
2249            // marker rather than desyncing our per-record `index`
2250            // from the writer's `session.journal_index()`.
2251            return Err("active journal contains an empty line".to_owned());
2252        }
2253        let record: ActiveJournalRecord = crate::journal::open(journal_key, &domain, index, line)
2254            .map_err(|error| format!("active journal record open failed: {error}"))?;
2255        if let Some(attempt) = record.response_attempt {
2256            if attempt.terminal && attempt.id == attempt_id {
2257                return Ok(true);
2258            }
2259        }
2260        index = index
2261            .checked_add(1)
2262            .ok_or_else(|| "active journal index overflow".to_owned())?;
2263    }
2264    Ok(false)
2265}
2266
2267async fn clear_response_marker(
2268    spool_dir: &std::path::Path,
2269    journal_key: &[u8; 32],
2270    session_id: &str,
2271    attempt_id: &str,
2272) -> Result<(), String> {
2273    let path = response_marker_path(spool_dir, session_id, attempt_id);
2274    let journal_key = *journal_key;
2275    let session_id = session_id.to_owned();
2276    let attempt_id = attempt_id.to_owned();
2277    tokio::task::spawn_blocking(move || {
2278        // Cancellation tolerance: the caller may race a write that
2279        // never landed (cancellation between spawn_blocking and
2280        // set_marker), or a marker that recovery already quarantined.
2281        // Absent marker means "already cleared" — do not surface as an
2282        // error, which would poison the terminal job and leak the
2283        // marker into unbounded scan/growth.
2284        let marker_bytes = match pv_core::fsutil::read_capped(&path, pv_core::fsutil::MAX_CONTROL_BYTES) {
2285            Ok(bytes) => bytes,
2286            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2287            Err(error) => return Err(error.to_string()),
2288        };
2289        let marker: InFlightResponse =
2290            crate::journal::open(&journal_key, "in-flight-response", 0, &marker_bytes)?;
2291        if marker.session_id != session_id || marker.attempt_id != attempt_id {
2292            return Err("in-flight response marker does not match completed job".to_owned());
2293        }
2294        match std::fs::remove_file(&path) {
2295            Ok(()) => {}
2296            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2297            Err(error) => return Err(error.to_string()),
2298        }
2299        let parent = path
2300            .parent()
2301            .ok_or_else(|| "in-flight response marker has no parent".to_owned())?;
2302        pv_core::fsutil::sync_directory(parent).map_err(|error| error.to_string())
2303    })
2304    .await
2305    .map_err(|error| error.to_string())?
2306}
2307
2308/// Rename an unverifiable in-flight-response marker out of the way and
2309/// log a single warn. Keeps the file as evidence at a stable
2310/// `.corrupt-<uid>` name so operators can retrieve it. The quarantine
2311/// name must NOT end in `.json`: the recovery scan filters on that
2312/// extension, so a `.json`-suffixed quarantine would be re-read,
2313/// MAC-fail, and re-quarantine on every reconciler tick — compounding
2314/// the name until ENAMETOOLONG and warning twice per tick forever
2315/// (same rule as the reconciler's quarantine rename).
2316fn quarantine_inflight_marker(path: &std::path::Path, error: impl std::fmt::Display) {
2317    let uid = pv_core::new_event_uid();
2318    let name = path
2319        .file_stem()
2320        .and_then(std::ffi::OsStr::to_str)
2321        .map(|stem| format!("{stem}.corrupt-{uid}"))
2322        .unwrap_or_else(|| format!("corrupt-{uid}"));
2323    let quarantine = path.with_file_name(name);
2324    tracing::warn!(
2325        %error,
2326        original = %pv_core::fsutil::basename(path),
2327        quarantine = %pv_core::fsutil::basename(&quarantine),
2328        "unverifiable in-flight-response marker quarantined so recovery can proceed"
2329    );
2330    if let Err(rename_err) = std::fs::rename(path, &quarantine) {
2331        tracing::warn!(
2332            %rename_err,
2333            "failed to quarantine in-flight-response marker — leaving in place"
2334        );
2335    }
2336}
2337
2338fn response_marker_path(
2339    spool_dir: &std::path::Path,
2340    session_id: &str,
2341    attempt_id: &str,
2342) -> std::path::PathBuf {
2343    let digest = pv_core::digest::sha256_hex(format!("{session_id}:{attempt_id}").as_bytes());
2344    spool_dir
2345        .join(crate::spool::INFLIGHT_RESPONSES)
2346        .join(format!("{}.json", &digest[..32]))
2347}
2348
2349fn write_atomic_control(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
2350    pv_core::fsutil::write_atomic(path, bytes).map_err(|error| error.to_string())
2351}
2352
2353async fn append_active_event_journal(
2354    directory: &std::path::Path,
2355    session: &Session,
2356    _identity: &pv_events::AgentIdentity,
2357    record: &ActiveJournalRecord,
2358    journal_key: &[u8; 32],
2359    sync: bool,
2360) -> Result<(), String> {
2361    let index = session.journal_index();
2362    let domain = format!("{}:active", session.id);
2363    let line = crate::journal::seal(journal_key, &domain, index, record)?;
2364    append_journal(directory, session, line, *journal_key, sync).await?;
2365    session.commit_journal_index(index)
2366}
2367
2368async fn append_journal(
2369    directory: &std::path::Path,
2370    session: &Session,
2371    line: Vec<u8>,
2372    journal_key: [u8; 32],
2373    sync: bool,
2374) -> Result<(), String> {
2375    let directory = directory.to_path_buf();
2376    let session_id = session.id.clone();
2377    let identity = session.identity.clone();
2378    let principal_binding = session.principal_binding();
2379    let workflow = session.workflow.as_str();
2380    tokio::task::spawn_blocking(move || -> Result<(), String> {
2381        use std::io::Write as _;
2382        // Skip the mkdir when the spool dir already
2383        // exists (every request after the first). If the boot-time
2384        // `acquire_spool_lock` root creation was undone (operator
2385        // wipe, mount flip) at runtime, use `create_dir_all_synced`
2386        // here so the recreated tree still lands at 0o700 — same
2387        // discipline the boot path applies.
2388        if !directory.is_dir() {
2389            pv_core::fsutil::create_dir_all_synced(&directory).map_err(|error| error.to_string())?;
2390        }
2391        let digest = pv_core::digest::sha256_hex(session_id.as_bytes());
2392        let stem = digest.get(..32).unwrap_or(&digest);
2393        let metadata_path = directory.join(format!("{stem}.session.json"));
2394        let metadata_payload = serde_json::json!({
2395            "journal_version": 2,
2396            "session_id": session_id,
2397            "identity": identity,
2398            "principal_binding": principal_binding,
2399            "workflow": workflow,
2400        });
2401        // `symlink_metadata` (NOT `exists()`) so a dangling symlink
2402        // at `metadata_path` is detected as "an entry lives here"
2403        // rather than "no entry" — write_atomic's exclusive-create
2404        // temp is elsewhere and its rename REPLACES the symlink at
2405        // the destination (safe) rather than following it, but
2406        // preserving symmetry with the events journal open below
2407        // and future-proofing against a change that switches
2408        // metadata to a non-atomic-rename recipe.
2409        if std::fs::symlink_metadata(&metadata_path).is_err() {
2410            let metadata = crate::journal::seal(&journal_key, "metadata", 0, &metadata_payload)?;
2411            // Use the centralized atomic writer: it uses an RAII guard
2412            // so any intermediate failure (write_all / sync_all /
2413            // rename) cannot leak a zero-byte `.tmp` orphan. A
2414            // repeatedly-failing journal writer used to be able to
2415            // exhaust the ext4 inode table long before disk-full.
2416            //
2417            // Basename the paths in error strings. These
2418            // errors bubble to `tracing::warn!(session = %session.id,
2419            // %error, "capture job failed; session is fail-closed")`
2420            // at process_envelope; that warn exports through
2421            // tracing_opentelemetry -> OTLP -> SIEM, so a single
2422            // disk-full incident used to emit one span per pending
2423            // event with the full absolute journal tree. Basename
2424            // preserves enough context for triage (the stem encodes
2425            // the session id) without leaking the deployment topology.
2426            pv_core::fsutil::write_atomic(&metadata_path, &metadata).map_err(|error| {
2427                format!(
2428                    "write journal metadata {}: {error}",
2429                    pv_core::fsutil::basename(&metadata_path)
2430                )
2431            })?;
2432        } else {
2433            let mut stored: serde_json::Value = crate::journal::open(
2434                &journal_key,
2435                "metadata",
2436                0,
2437                // Cap sealed journal-metadata read at
2438                // MAX_CONTROL_BYTES.
2439                &pv_core::fsutil::read_capped(&metadata_path, pv_core::fsutil::MAX_CONTROL_BYTES)
2440                    .map_err(|error| error.to_string())?,
2441            )?;
2442            // Older anonymous journals have no binding field. They may only
2443            // continue anonymously; a verified principal must never inherit
2444            // a journal whose owner was not durably recorded.
2445            if principal_binding.is_none() && stored.get("principal_binding").is_none() {
2446                if let Some(metadata) = stored.as_object_mut() {
2447                    metadata.insert("principal_binding".into(), Value::Null);
2448                }
2449            }
2450            if stored != metadata_payload {
2451                return Err(
2452                    "journal metadata does not match session workflow, identity, and principal".to_owned(),
2453                );
2454            }
2455        }
2456        let journal_path = directory.join(format!("{stem}.events.ndjson"));
2457        // Track whether the journal is being created by *this* append so
2458        // we can fsync the containing directory once the file exists —
2459        // the metadata fsync above only durably named the metadata
2460        // file, not this new journal file. Without the dirent fsync,
2461        // a power loss on POSIX-conformant filesystems (xfs, btrfs)
2462        // can lose the entry entirely — the file appears not to exist
2463        // on restart, `recover_signed_journals` treats the journal as
2464        // empty, deletes the metadata, and every already-acked event
2465        // becomes orphaned on the broker.
2466        // `symlink_metadata` (NOT `exists()`) so a dangling symlink
2467        // planted at `journal_path` is detected as "an entry lives
2468        // here" rather than "no entry" — pairs with `O_NOFOLLOW`
2469        // below to fail-close instead of following the symlink and
2470        // corrupting an arbitrary path the harness UID can write.
2471        let journal_created = std::fs::symlink_metadata(&journal_path).is_err();
2472        let mut open_options = std::fs::OpenOptions::new();
2473        open_options.create(true).append(true);
2474        #[cfg(unix)]
2475        {
2476            use std::os::unix::fs::OpenOptionsExt as _;
2477            // O_NOFOLLOW: refuse to follow any symlink at
2478            // `journal_path`. Signed events journals authenticate
2479            // via HMAC-per-envelope on read, so a symlink redirect
2480            // wouldn't produce a forged replay, but it would let a
2481            // co-tenant with write access to the spool dir redirect
2482            // the harness's authenticated append into an arbitrary
2483            // path the harness UID can create (a canonical
2484            // symlink-plant confused-deputy). 0o600 mode pairs with
2485            // the same discipline that `write_atomic` uses; here
2486            // the file is APPEND-created rather than atomic-renamed
2487            // so the mode is set inline on the initial open. The
2488            // O_NOFOLLOW constant lives in `pv_core::fsutil` so the
2489            // per-platform value is settled in one auditable place.
2490            open_options
2491                .custom_flags(pv_core::fsutil::unix_o_nofollow())
2492                .mode(0o600);
2493        }
2494        let mut journal = open_options.open(&journal_path).map_err(|error| {
2495            format!(
2496                "open event journal {}: {error}",
2497                pv_core::fsutil::basename(&journal_path)
2498            )
2499        })?;
2500        journal.write_all(&line).map_err(|error| error.to_string())?;
2501        journal.write_all(b"\n").map_err(|error| error.to_string())?;
2502        // Group commit: a same-session batch defers
2503        // this to ONE fdatasync issued by its LAST append —
2504        // `sync_data` flushes all of the file's dirty pages
2505        // regardless of which fd wrote them, so the final synced
2506        // append makes every prior deferred line in the batch
2507        // durable. No job's Phase B runs before the batch sync.
2508        if sync {
2509            journal.sync_data().map_err(|error| error.to_string())?;
2510        }
2511        if journal_created {
2512            pv_core::fsutil::sync_directory(&directory).map_err(|error| {
2513                // Drop the full path entirely; the
2514                // sibling `session = %session.id` field on the
2515                // downstream warn already scopes this to a
2516                // specific session, and the containing directory
2517                // is `atif_spool_dir` — same across the whole
2518                // deployment.
2519                format!("fsync journal directory: {error}")
2520            })?;
2521        }
2522        Ok(())
2523    })
2524    .await
2525    .map_err(|error| error.to_string())?
2526}
2527
2528/// Broker acks append to ONE per-session NDJSON
2529/// journal (`{stem}.acks.ndjson` in the spool root) instead of one
2530/// `write_atomic` file per event under `broker-acks/<hash>/`. Per
2531/// ack this replaces two durable syncs (tmp `sync_all` + parent-dir
2532/// sync) and the tmp-create/rename metadata ops with a single
2533/// appended line + `sync_data`, and stops leaving one file per
2534/// request on disk. Each line is the SAME sealed record bytes the
2535/// per-event file carried (domain "broker-ack", index 0): lines are
2536/// self-describing and order-independent, so reordering/duplication
2537/// grants nothing, and a truncated tail line simply reads as "no
2538/// ack" — the event is re-published, which the bridge tolerates
2539/// (same recovery semantics as a lost ack file).
2540pub(crate) async fn persist_broker_ack(
2541    directory: &std::path::Path,
2542    session_id: &str,
2543    event_uid: &str,
2544    ack: &PublishAck,
2545    journal_key: &[u8; 32],
2546) -> Result<(), String> {
2547    let path = ack_journal_path(directory, session_id);
2548    let record = BrokerAckRecord {
2549        session_id: session_id.to_owned(),
2550        event_uid: event_uid.to_owned(),
2551        ack: ack.clone(),
2552    };
2553    let sealed = crate::journal::seal(journal_key, "broker-ack", 0, &record)?;
2554    tokio::task::spawn_blocking(move || -> Result<(), String> {
2555        use std::io::Write as _;
2556        // `symlink_metadata` (NOT `exists()`) so a dangling symlink
2557        // planted at `path` is detected as "an entry lives here"
2558        // rather than "no entry" — pairs with `O_NOFOLLOW` below to
2559        // fail-close instead of following the symlink and corrupting
2560        // an arbitrary path the harness UID can write. Same posture
2561        // as the events-journal append; `BrokerAckRecord` carries
2562        // plaintext session id + event uid + broker offset, which
2563        // is the "low-entropy identifier" class that the deterministic
2564        // `sha256(session-id)[..32]` stem was already supposed to hide.
2565        let created = std::fs::symlink_metadata(&path).is_err();
2566        let mut open_options = std::fs::OpenOptions::new();
2567        open_options.create(true).append(true);
2568        #[cfg(unix)]
2569        {
2570            use std::os::unix::fs::OpenOptionsExt as _;
2571            open_options
2572                .custom_flags(pv_core::fsutil::unix_o_nofollow())
2573                .mode(0o600);
2574        }
2575        let mut journal = open_options
2576            .open(&path)
2577            .map_err(|error| format!("open ack journal {}: {error}", pv_core::fsutil::basename(&path)))?;
2578        journal.write_all(&sealed).map_err(|error| error.to_string())?;
2579        journal.write_all(b"\n").map_err(|error| error.to_string())?;
2580        journal.sync_data().map_err(|error| error.to_string())?;
2581        if created {
2582            // First ack for this session: make the dirent durable so a
2583            // crash cannot lose the whole journal (same discipline as
2584            // the events journal).
2585            let parent = path
2586                .parent()
2587                .ok_or_else(|| "ack journal has no parent".to_owned())?;
2588            pv_core::fsutil::sync_directory(parent).map_err(|error| error.to_string())?;
2589        }
2590        Ok(())
2591    })
2592    .await
2593    .map_err(|error| error.to_string())?
2594}
2595
2596/// Per-RECORD bound for ack-journal lines. Individual sealed ack
2597/// records are ~300 bytes, so 1 MiB (the MAX_CONTROL_BYTES class) is
2598/// far above any legitimate record while keeping a hostile oversized
2599/// line from ballooning memory. The journal FILE is deliberately
2600/// unbounded: it grows one line per published event for the session's
2601/// whole life, and every previous WHOLE-FILE cap here (1 MiB in the
2602/// first shape, then 16 MiB) recreated the same outage one order of
2603/// magnitude later — the first restart after a busy session crossed
2604/// the cap made EVERY `read_broker_ack` for it fail and recovery
2605/// skipped the session forever, stranding acknowledged evidence.
2606/// Stream line-by-line instead so journal length can never invalidate
2607/// the evidence inside it.
2608const MAX_ACK_RECORD_BYTES: u64 = 1024 * 1024;
2609
2610pub(crate) async fn read_broker_ack(
2611    directory: &std::path::Path,
2612    session_id: &str,
2613    event_uid: &str,
2614    journal_key: &[u8; 32],
2615) -> Result<Option<PublishAck>, String> {
2616    // New layout first: scan the per-session ack journal. Recovery-only
2617    // path, so the linear scan per lookup is acceptable; memory is
2618    // bounded per RECORD (see MAX_ACK_RECORD_BYTES), not per file.
2619    {
2620        let path = ack_journal_path(directory, session_id);
2621        let session = session_id.to_owned();
2622        let uid = event_uid.to_owned();
2623        let key = *journal_key;
2624        let found = tokio::task::spawn_blocking(move || -> Result<Option<PublishAck>, String> {
2625            use std::io::{BufRead as _, Read as _};
2626            let file = match std::fs::File::open(&path) {
2627                Ok(file) => file,
2628                Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2629                Err(error) => return Err(error.to_string()),
2630            };
2631            let mut reader = std::io::BufReader::new(file);
2632            let mut buf: Vec<u8> = Vec::new();
2633            loop {
2634                buf.clear();
2635                let read = {
2636                    let mut limited = (&mut reader).take(MAX_ACK_RECORD_BYTES);
2637                    limited
2638                        .read_until(b'\n', &mut buf)
2639                        .map_err(|error| error.to_string())?
2640                };
2641                if read == 0 {
2642                    break;
2643                }
2644                if buf.last() != Some(&b'\n') && read as u64 == MAX_ACK_RECORD_BYTES {
2645                    // Oversized line (hostile plant or corruption): drain
2646                    // to its newline in bounded chunks and keep scanning —
2647                    // one bad line must not poison the intact acks around
2648                    // it (same skip posture as the MAC-failure arm below).
2649                    loop {
2650                        buf.clear();
2651                        let drained = {
2652                            let mut limited = (&mut reader).take(MAX_ACK_RECORD_BYTES);
2653                            limited
2654                                .read_until(b'\n', &mut buf)
2655                                .map_err(|error| error.to_string())?
2656                        };
2657                        if drained == 0 || buf.last() == Some(&b'\n') {
2658                            break;
2659                        }
2660                    }
2661                    continue;
2662                }
2663                let line = buf.strip_suffix(b"\n").unwrap_or(&buf);
2664                if line.is_empty() {
2665                    continue;
2666                }
2667                // A torn tail line (crash mid-append) or a tampered line
2668                // must not poison the earlier, intact acks: skip lines
2669                // that fail to open and keep scanning. "No ack found"
2670                // degrades to a re-publish, exactly like a lost file.
2671                let Ok(record) = crate::journal::open::<BrokerAckRecord>(&key, "broker-ack", 0, line) else {
2672                    continue;
2673                };
2674                if record.session_id == session && record.event_uid == uid {
2675                    return Ok(Some(record.ack));
2676                }
2677            }
2678            Ok(None)
2679        })
2680        .await
2681        .map_err(|error| error.to_string())??;
2682        if found.is_some() {
2683            return Ok(found);
2684        }
2685    }
2686    // Legacy layout fallback (older per-event files), so a
2687    // deployment upgraded mid-session still sees its earlier acks.
2688    let path = broker_ack_path(directory, session_id, event_uid);
2689    // Bounded read — same policy as every other sealed marker in the
2690    // spool (close-complete marker, promotion marker, response marker,
2691    // ATIF sidecar). A local fs-tamper attacker plant at
2692    // `spool/broker-acks/<hash>/<hash>.json` would otherwise blow up
2693    // memory during finalize before MAC rejection: `journal::open` calls
2694    // `serde_json::from_slice` on the raw bytes, and a multi-GB file
2695    // reaches serde_json's own limit only after the initial `read` has
2696    // already allocated the whole buffer. `MAX_CONTROL_BYTES` (1 MiB)
2697    // is far above any legitimate ack record.
2698    let bytes = match tokio::task::spawn_blocking({
2699        let path = path.clone();
2700        move || pv_core::fsutil::read_capped(&path, pv_core::fsutil::MAX_CONTROL_BYTES)
2701    })
2702    .await
2703    .map_err(|error| error.to_string())?
2704    {
2705        Ok(bytes) => bytes,
2706        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2707        Err(error) => return Err(error.to_string()),
2708    };
2709    let record: BrokerAckRecord = crate::journal::open(journal_key, "broker-ack", 0, &bytes)?;
2710    if record.session_id != session_id || record.event_uid != event_uid {
2711        return Err("broker acknowledgment does not match event".to_owned());
2712    }
2713    Ok(Some(record.ack))
2714}
2715
2716/// Group-commit layout: one append-only ack journal per session.
2717fn ack_journal_path(directory: &std::path::Path, session_id: &str) -> std::path::PathBuf {
2718    let digest = pv_core::digest::sha256_hex(session_id.as_bytes());
2719    directory.join(format!("{}.acks.ndjson", digest.get(..32).unwrap_or(&digest)))
2720}
2721
2722/// Legacy per-event layout, read-only fallback.
2723fn broker_ack_path(directory: &std::path::Path, session_id: &str, event_uid: &str) -> std::path::PathBuf {
2724    let session_digest = pv_core::digest::sha256_hex(session_id.as_bytes());
2725    let event_digest = pv_core::digest::sha256_hex(event_uid.as_bytes());
2726    directory
2727        .join("broker-acks")
2728        .join(&session_digest[..32])
2729        .join(format!("{}.json", &event_digest[..32]))
2730}
2731
2732#[cfg(test)]
2733mod tests {
2734    #![allow(
2735        clippy::expect_used,
2736        clippy::indexing_slicing,
2737        clippy::panic,
2738        clippy::unwrap_used
2739    )]
2740
2741    use super::*;
2742    use parking_lot::Mutex;
2743    use pv_bridge::{BusError, PublishAck, StoredEvent};
2744    use pv_events::AgentIdentity;
2745    use pv_loopdetect::{BreakerConfig, HashEmbedder};
2746    use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
2747
2748    /// Mutation-run hardening (round 11): the queue-drain notify in
2749    /// `worker_job_finished` fires exactly on the LAST decrement — a
2750    /// surviving `==`→`!=` mutant notified on every decrement EXCEPT
2751    /// the final one, so a `wait_idle` caller that subscribed while the
2752    /// last job was in flight waited forever (the lost-final-wakeup
2753    /// class; shutdown and tests both sit in wait_idle).
2754    #[tokio::test]
2755    async fn wait_idle_wakes_on_the_final_job() {
2756        let bus = Arc::new(GatedBus::new("drain-session"));
2757        let worker = Arc::new(spawn_worker(
2758            64,
2759            Arc::clone(&bus) as Arc<dyn EventBus>,
2760            Arc::new(HashEmbedder::default()),
2761            Arc::new(Registry::new()),
2762        ));
2763        let session = session_with_id("drain-session");
2764        worker.try_submit(job(Arc::clone(&session))).unwrap();
2765        // Subscribe while the single job is still gated in publish.
2766        let waiter = tokio::spawn({
2767            let worker = Arc::clone(&worker);
2768            async move { worker.wait_idle().await }
2769        });
2770        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2771        assert!(
2772            !waiter.is_finished(),
2773            "wait_idle must block while the job is gated"
2774        );
2775        bus.release.store(true, std::sync::atomic::Ordering::Release);
2776        tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
2777            .await
2778            .expect("wait_idle must wake on the FINAL job's completion")
2779            .unwrap();
2780    }
2781
2782    /// Mutation-run hardening (round 11): a batch whose every job was
2783    /// skipped by the capture-failed guard writes NO journal file —
2784    /// the group-commit fsync must treat the missing file as success,
2785    /// not an error (a guard mutant converting NotFound to failure
2786    /// survived because no test synced a journal-less session).
2787    #[tokio::test]
2788    async fn sync_session_journal_tolerates_a_missing_journal() {
2789        let directory = tempfile::tempdir().unwrap();
2790        let session = session_with_id("never-journaled");
2791        sync_session_journal(directory.path(), &session)
2792            .await
2793            .expect("a missing journal is the every-job-skipped shape, not a fault");
2794    }
2795
2796    /// Mutation-run hardening (round 11): the attempt-id reservation
2797    /// must mint unique non-empty ids (a constant would collide the
2798    /// durable marker paths of concurrent attempts), the marker write
2799    /// must create its spool subdirectory on FIRST use, and the
2800    /// broker-ack lookup must refuse an ack whose session OR event uid
2801    /// differs (an ||→&& mutant accepted half-matching acks — cross-
2802    /// event ack reuse during recovery).
2803    #[tokio::test]
2804    async fn markers_and_acks_pin_identity_and_first_use() {
2805        let a = reserve_response_attempt_id();
2806        let b = reserve_response_attempt_id();
2807        assert!(!a.is_empty());
2808        assert_ne!(a, b, "attempt ids must be unique");
2809
2810        let directory = tempfile::tempdir().unwrap();
2811        let key = [21u8; 32];
2812        write_response_marker(directory.path(), &key, "marker-session", &a, "digest".to_owned())
2813            .await
2814            .expect("the first marker write must create the spool subdirectory");
2815
2816        let ack = PublishAck {
2817            topic: "agent.session".to_owned(),
2818            partition: 0,
2819            offset: 7,
2820        };
2821        persist_broker_ack(directory.path(), "ack-session", "uid-1", &ack, &key)
2822            .await
2823            .unwrap();
2824        assert_eq!(
2825            read_broker_ack(directory.path(), "ack-session", "uid-1", &key)
2826                .await
2827                .unwrap(),
2828            Some(ack),
2829            "the matching (session, uid) pair must resolve"
2830        );
2831        assert!(
2832            read_broker_ack(directory.path(), "ack-session", "uid-2", &key)
2833                .await
2834                .unwrap()
2835                .is_none()
2836                || read_broker_ack(directory.path(), "ack-session", "uid-2", &key)
2837                    .await
2838                    .is_err(),
2839            "a half-matching ack (same session, different uid) must never resolve"
2840        );
2841        assert!(
2842            read_broker_ack(directory.path(), "other-session", "uid-1", &key)
2843                .await
2844                .unwrap()
2845                .is_none()
2846                || read_broker_ack(directory.path(), "other-session", "uid-1", &key)
2847                    .await
2848                    .is_err(),
2849            "a half-matching ack (different session, same uid) must never resolve"
2850        );
2851    }
2852
2853    /// Mutation-run hardening (round 11): the fold invariant every
2854    /// journal replay checks — classified tool outcomes can never
2855    /// exceed observed calls — had a surviving Ok(()) mutant. A fold
2856    /// that accepts inconsistent accounting attests corrupted tool
2857    /// totals into the signed receipt.
2858    #[test]
2859    fn tool_accounting_invariant_is_enforced() {
2860        let consistent = RecoveredTotals {
2861            tool_calls: 5,
2862            tool_allowed: 3,
2863            tool_blocked: 2,
2864            ..RecoveredTotals::default()
2865        };
2866        assert!(consistent.validate_tool_accounting().is_ok());
2867        let inconsistent = RecoveredTotals {
2868            tool_calls: 4,
2869            tool_allowed: 3,
2870            tool_blocked: 2,
2871            ..RecoveredTotals::default()
2872        };
2873        assert!(
2874            inconsistent.validate_tool_accounting().is_err(),
2875            "classified > calls must be refused"
2876        );
2877        let overflowing = RecoveredTotals {
2878            tool_calls: u64::MAX,
2879            tool_allowed: u64::MAX,
2880            tool_blocked: 1,
2881            ..RecoveredTotals::default()
2882        };
2883        assert!(
2884            overflowing.validate_tool_accounting().is_err(),
2885            "the classified sum overflowing u64 must be refused"
2886        );
2887    }
2888
2889    #[derive(Default)]
2890    struct RecordingBus {
2891        events: Mutex<Vec<(String, String, Value)>>,
2892    }
2893
2894    struct PanicOnceSink {
2895        panicked: AtomicBool,
2896    }
2897
2898    struct SlowEmbedder;
2899
2900    impl Embedder for SlowEmbedder {
2901        fn dim(&self) -> usize {
2902            1
2903        }
2904
2905        fn embed(&self, _text: &str) -> Vec<f32> {
2906            std::thread::sleep(std::time::Duration::from_millis(100));
2907            vec![1.0]
2908        }
2909    }
2910
2911    /// A bus that BLOCKS publishes whose value names
2912    /// the gated session until released — deterministic stand-in for
2913    /// a hung broker/journal on one session.
2914    struct GatedBus {
2915        gated_session: String,
2916        release: AtomicBool,
2917        published: Mutex<Vec<String>>,
2918    }
2919
2920    impl GatedBus {
2921        fn new(gated_session: &str) -> Self {
2922            Self {
2923                gated_session: gated_session.to_owned(),
2924                release: AtomicBool::new(false),
2925                published: Mutex::new(Vec::new()),
2926            }
2927        }
2928    }
2929
2930    impl EventBus for GatedBus {
2931        fn publish(&self, topic: &str, _key: &str, value: &Value) -> Result<PublishAck, BusError> {
2932            let text = value.to_string();
2933            if text.contains(&self.gated_session) {
2934                while !self.release.load(Ordering::Acquire) {
2935                    std::thread::sleep(std::time::Duration::from_millis(5));
2936                }
2937            }
2938            self.published.lock().push(text);
2939            Ok(PublishAck {
2940                topic: topic.to_owned(),
2941                partition: 0,
2942                offset: 0,
2943            })
2944        }
2945
2946        fn fetch(
2947            &self,
2948            _topic: &str,
2949            _partition: u32,
2950            _offset: u64,
2951            _max: usize,
2952        ) -> Result<Vec<StoredEvent>, BusError> {
2953            Ok(Vec::new())
2954        }
2955
2956        fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
2957            Ok(1)
2958        }
2959
2960        fn topics(&self) -> Vec<String> {
2961            EventClass::all()
2962                .iter()
2963                .map(|class| class.topic().to_owned())
2964                .collect()
2965        }
2966    }
2967
2968    struct SlowBus;
2969
2970    impl EventBus for SlowBus {
2971        fn publish(&self, topic: &str, _key: &str, _value: &Value) -> Result<PublishAck, BusError> {
2972            std::thread::sleep(std::time::Duration::from_millis(100));
2973            Ok(PublishAck {
2974                topic: topic.to_owned(),
2975                partition: 0,
2976                offset: 0,
2977            })
2978        }
2979
2980        fn fetch(
2981            &self,
2982            _topic: &str,
2983            _partition: u32,
2984            _offset: u64,
2985            _max: usize,
2986        ) -> Result<Vec<StoredEvent>, BusError> {
2987            Ok(Vec::new())
2988        }
2989
2990        fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
2991            Ok(1)
2992        }
2993
2994        fn topics(&self) -> Vec<String> {
2995            EventClass::all()
2996                .iter()
2997                .map(|class| class.topic().to_owned())
2998                .collect()
2999        }
3000    }
3001
3002    impl VectorSink for PanicOnceSink {
3003        fn record<'a>(
3004            &'a self,
3005            _session_id: &'a str,
3006            _vector: &'a [f32],
3007        ) -> pv_loopdetect::VectorSinkFuture<'a> {
3008            Box::pin(async move {
3009                if !self.panicked.swap(true, AtomicOrdering::AcqRel) {
3010                    panic!("injected vector sink panic");
3011                }
3012                Ok(())
3013            })
3014        }
3015    }
3016
3017    impl EventBus for RecordingBus {
3018        fn publish(&self, topic: &str, key: &str, value: &Value) -> Result<PublishAck, BusError> {
3019            let mut events = self.events.lock();
3020            let offset = events.len() as u64;
3021            events.push((topic.to_owned(), key.to_owned(), value.clone()));
3022            Ok(PublishAck {
3023                topic: topic.to_owned(),
3024                partition: 0,
3025                offset,
3026            })
3027        }
3028
3029        fn publish_idempotent(
3030            &self,
3031            topic: &str,
3032            key: &str,
3033            value: &Value,
3034            event_uid: &str,
3035        ) -> Result<PublishAck, BusError> {
3036            if let Some((offset, _)) = self
3037                .events
3038                .lock()
3039                .iter()
3040                .enumerate()
3041                .find(|(_, (_, _, event))| event["metadata"]["uid"] == event_uid)
3042            {
3043                return Ok(PublishAck {
3044                    topic: topic.to_owned(),
3045                    partition: 0,
3046                    offset: offset as u64,
3047                });
3048            }
3049            self.publish(topic, key, value)
3050        }
3051
3052        fn fetch(
3053            &self,
3054            _topic: &str,
3055            _partition: u32,
3056            _offset: u64,
3057            _max: usize,
3058        ) -> Result<Vec<StoredEvent>, BusError> {
3059            Ok(Vec::new())
3060        }
3061
3062        fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
3063            Ok(1)
3064        }
3065
3066        fn topics(&self) -> Vec<String> {
3067            EventClass::all()
3068                .iter()
3069                .map(|class| class.topic().to_owned())
3070                .collect()
3071        }
3072    }
3073
3074    fn session(workflow: Workflow) -> Arc<Session> {
3075        Arc::new(Session::new(
3076            "session-1".to_owned(),
3077            workflow,
3078            AgentIdentity {
3079                version: "1".to_owned(),
3080                charter: "test".into(),
3081                instance_uid: "instance-1".to_owned(),
3082                ttl_remaining_s: Some(600),
3083            },
3084            BreakerConfig {
3085                min_tokens: u64::MAX,
3086                ..BreakerConfig::default()
3087            },
3088        ))
3089    }
3090
3091    fn job(session: Arc<Session>) -> WorkerJob {
3092        WorkerJob {
3093            identity: session.current_identity(),
3094            session,
3095            class: EventClass::Compression,
3096            payload: serde_json::json!({"kind": "response"}),
3097            text: "a useful response".to_owned(),
3098            analyze_loop: true,
3099            status: StatusId::Success,
3100            stop_reason: None,
3101            native_stop_reason: None,
3102            metrics: EventMetrics {
3103                prompt_tokens: Some(100),
3104                completion_tokens: Some(20),
3105                cached_tokens: Some(40),
3106                pruned_tokens: Some(10),
3107                pruning_ratio_millis: Some(100),
3108            },
3109            cost_usd_micros: 250,
3110            prompt_token_correction: 0,
3111            atif: Some(AtifCapture {
3112                source: pv_atif::Source::Agent,
3113                message: Value::String("a useful response".to_owned()),
3114                reasoning_content: None,
3115                model_name: None,
3116                tool_calls: None,
3117                observation: None,
3118                llm_call_count: Some(1),
3119            }),
3120            response_marker: None,
3121            response_attempt: None,
3122        }
3123    }
3124
3125    #[test]
3126    fn receipt_accounting_never_wraps_or_exceeds_jcs_bounds() {
3127        let counter = std::sync::atomic::AtomicU64::new(pv_core::error::JCS_SAFE_MAX);
3128        assert!(checked_atomic_add(&counter, 1, "test").is_err());
3129        assert_eq!(counter.load(Ordering::Acquire), pv_core::error::JCS_SAFE_MAX);
3130    }
3131
3132    #[tokio::test(flavor = "current_thread")]
3133    async fn synchronous_backends_do_not_block_tokio_worker_thread() {
3134        let worker = spawn_worker(
3135            4,
3136            Arc::new(SlowBus),
3137            Arc::new(SlowEmbedder),
3138            Arc::new(Registry::new()),
3139        );
3140        worker.try_submit(job(session(Workflow::Signed))).unwrap();
3141
3142        let started = std::time::Instant::now();
3143        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3144        // 250 ms allows for 25× scheduler jitter — still catches
3145        // the ~100 ms `SlowBus`-class regression that motivated
3146        // this test, tolerates loaded CI runners where a 10 ms
3147        // tokio timer can drift to 30-80 ms.
3148        assert!(
3149            started.elapsed() < std::time::Duration::from_millis(250),
3150            "synchronous worker dependency blocked the Tokio reactor"
3151        );
3152        worker.wait_idle().await;
3153    }
3154
3155    #[tokio::test]
3156    async fn response_marker_is_authenticated_and_cleared_after_publish() {
3157        let directory = tempfile::tempdir().unwrap();
3158        let journal_key = [13; 32];
3159        let marker = create_response_marker(
3160            directory.path(),
3161            &journal_key,
3162            "session-1",
3163            "request-digest".to_owned(),
3164        )
3165        .await
3166        .unwrap();
3167        assert!(ensure_no_inflight_responses(directory.path(), &journal_key)
3168            .await
3169            .unwrap_err()
3170            .contains("was not durably captured"));
3171
3172        let worker = spawn_worker_with_spool_authenticated(
3173            4,
3174            Arc::new(RecordingBus::default()),
3175            Arc::new(HashEmbedder::default()),
3176            Arc::new(NoopVectorSink),
3177            Some(directory.path().to_path_buf()),
3178            journal_key,
3179            Arc::new(Registry::new()),
3180            None,
3181        );
3182        let session = session(Workflow::Signed);
3183        let mut response = job(session);
3184        response.response_marker = Some(marker);
3185        worker.submit_and_wait(response).await.unwrap();
3186        ensure_no_inflight_responses(directory.path(), &journal_key)
3187            .await
3188            .unwrap();
3189    }
3190
3191    #[tokio::test]
3192    async fn response_marker_rejects_payload_mutation() {
3193        // Behavior contract: an unverifiable marker is
3194        // now quarantined (renamed to `.corrupt-<uid>`) instead of
3195        // bricking the recovery scan. Verify (a) the tampered file is
3196        // moved out of the way, so it no longer counts as "in-flight",
3197        // and (b) recovery reports "no in-flight sessions" cleanly. A
3198        // hard error would fail-close the entire boot, the exact
3199        // regression the fix eliminates.
3200        let directory = tempfile::tempdir().unwrap();
3201        let journal_key = [17; 32];
3202        create_response_marker(
3203            directory.path(),
3204            &journal_key,
3205            "session-1",
3206            "request-digest".to_owned(),
3207        )
3208        .await
3209        .unwrap();
3210        let marker_path = std::fs::read_dir(directory.path().join(crate::spool::INFLIGHT_RESPONSES))
3211            .unwrap()
3212            .next()
3213            .unwrap()
3214            .unwrap()
3215            .path();
3216        let mut envelope: Value = serde_json::from_slice(&std::fs::read(&marker_path).unwrap()).unwrap();
3217        envelope["payload"]["request_digest"] = Value::String("forged".to_owned());
3218        std::fs::write(&marker_path, serde_json::to_vec(&envelope).unwrap()).unwrap();
3219        ensure_no_inflight_responses(directory.path(), &journal_key)
3220            .await
3221            .expect("tampered marker quarantines cleanly instead of poisoning recovery");
3222        // Original path is renamed; a `.corrupt-*.json` sibling remains
3223        // for operator evidence retrieval.
3224        assert!(
3225            !marker_path.exists(),
3226            "tampered marker should be quarantined out of the primary name"
3227        );
3228        let inflight_dir = directory.path().join(crate::spool::INFLIGHT_RESPONSES);
3229        let mut has_corrupt = false;
3230        for entry in std::fs::read_dir(&inflight_dir).unwrap() {
3231            let name = entry.unwrap().file_name();
3232            if name.to_string_lossy().contains(".corrupt-") {
3233                has_corrupt = true;
3234                break;
3235            }
3236        }
3237        assert!(
3238            has_corrupt,
3239            "expected a `.corrupt-<uid>.json` sibling as evidence"
3240        );
3241    }
3242
3243    #[tokio::test]
3244    async fn signed_job_publishes_and_extends_chain() {
3245        let bridge = Arc::new(RecordingBus::default());
3246        let metrics = Arc::new(Registry::new());
3247        let worker = spawn_worker(4, bridge.clone(), Arc::new(HashEmbedder::default()), metrics);
3248        let session = session(Workflow::Signed);
3249        worker.submit_and_wait(job(Arc::clone(&session))).await.unwrap();
3250
3251        assert_eq!(session.chain.lock().count(), 1);
3252        assert_eq!(session.totals.prompt_tokens.load(Ordering::Acquire), 100);
3253        let events = bridge.events.lock();
3254        assert_eq!(events.len(), 1);
3255        assert_eq!(events[0].0, "agent.compression");
3256        assert_eq!(events[0].1, "instance-1");
3257    }
3258
3259    #[tokio::test]
3260    async fn signed_job_journals_event_and_accounting() {
3261        let directory = tempfile::tempdir().unwrap();
3262        let journal_key = [9; 32];
3263        let worker = spawn_worker_with_spool_authenticated(
3264            4,
3265            Arc::new(RecordingBus::default()),
3266            Arc::new(HashEmbedder::default()),
3267            Arc::new(NoopVectorSink),
3268            Some(directory.path().to_path_buf()),
3269            journal_key,
3270            Arc::new(Registry::new()),
3271            None,
3272        );
3273        let session = session(Workflow::Signed);
3274        worker.submit_and_wait(job(Arc::clone(&session))).await.unwrap();
3275
3276        let digest = pv_core::digest::sha256_hex(session.id.as_bytes());
3277        let stem = digest.get(..32).unwrap();
3278        let metadata: Value = crate::journal::open(
3279            &journal_key,
3280            "metadata",
3281            0,
3282            &std::fs::read(directory.path().join(format!("{stem}.session.json"))).unwrap(),
3283        )
3284        .unwrap();
3285        assert_eq!(metadata["workflow"], "signed");
3286        let journal =
3287            std::fs::read_to_string(directory.path().join(format!("{stem}.events.ndjson"))).unwrap();
3288        let record: ActiveJournalRecord =
3289            crate::journal::open(&journal_key, "session-1:active", 0, journal.trim().as_bytes()).unwrap();
3290        assert_eq!(record.prompt_tokens, 100);
3291        assert_eq!(record.completion_tokens, 0);
3292        assert_eq!(record.event["class_name"], "agent.compression");
3293    }
3294
3295    #[tokio::test]
3296    async fn journal_metadata_binds_principal_and_rejects_mismatch() {
3297        let directory = tempfile::tempdir().unwrap();
3298        let key = [9; 32];
3299        let first = session(Workflow::Signed);
3300        assert!(first.bind_principal(Some("principal-a")));
3301        append_journal(directory.path(), &first, b"first".to_vec(), key, true)
3302            .await
3303            .unwrap();
3304        let digest = pv_core::digest::sha256_hex(first.id.as_bytes());
3305        let stem = digest.get(..32).unwrap();
3306        let metadata: Value = crate::journal::open(
3307            &key,
3308            "metadata",
3309            0,
3310            &std::fs::read(directory.path().join(format!("{stem}.session.json"))).unwrap(),
3311        )
3312        .unwrap();
3313        assert_eq!(metadata["principal_binding"], "principal-a");
3314        let journal_path = directory.path().join(format!("{stem}.events.ndjson"));
3315        let original_journal = std::fs::read(&journal_path).unwrap();
3316        for principal in [Some("principal-b"), None] {
3317            let other = session(Workflow::Signed);
3318            assert!(other.bind_principal(principal));
3319            let error = append_journal(directory.path(), &other, b"second".to_vec(), key, true)
3320                .await
3321                .unwrap_err();
3322            assert!(error.contains("principal"));
3323            assert_eq!(std::fs::read(&journal_path).unwrap(), original_journal);
3324        }
3325    }
3326
3327    #[tokio::test]
3328    async fn legacy_journal_metadata_only_allows_anonymous_continuation() {
3329        let directory = tempfile::tempdir().unwrap();
3330        let key = [9; 32];
3331        let anonymous = session(Workflow::Signed);
3332        let digest = pv_core::digest::sha256_hex(anonymous.id.as_bytes());
3333        let stem = digest.get(..32).unwrap();
3334        let legacy = serde_json::json!({
3335            "journal_version": 2,
3336            "session_id": anonymous.id,
3337            "identity": anonymous.identity,
3338            "workflow": "signed",
3339        });
3340        std::fs::write(
3341            directory.path().join(format!("{stem}.session.json")),
3342            crate::journal::seal(&key, "metadata", 0, &legacy).unwrap(),
3343        )
3344        .unwrap();
3345        append_journal(directory.path(), &anonymous, b"first".to_vec(), key, true)
3346            .await
3347            .unwrap();
3348        let authenticated = session(Workflow::Signed);
3349        assert!(authenticated.bind_principal(Some("principal-a")));
3350        assert!(
3351            append_journal(directory.path(), &authenticated, b"second".to_vec(), key, true)
3352                .await
3353                .unwrap_err()
3354                .contains("principal")
3355        );
3356        assert_eq!(
3357            std::fs::read(directory.path().join(format!("{stem}.events.ndjson"))).unwrap(),
3358            b"first\n"
3359        );
3360    }
3361
3362    /// A session marked `capture_failed` (typically because a prior envelope's
3363    /// journal append failed) must not have subsequent queued envelopes advance
3364    /// `next_seq` and land in the journal — their `event.metadata.sequence`
3365    /// would exceed the entry's byte position, which recovery rejects with
3366    /// "signed event sequence does not match active journal index". Regression
3367    /// against the process_job pattern that treated capture_failed as a
3368    /// post-processing check only.
3369    #[tokio::test]
3370    async fn capture_failed_session_does_not_write_further_journal_entries() {
3371        let directory = tempfile::tempdir().unwrap();
3372        let journal_key = [23; 32];
3373        let worker = spawn_worker_with_spool_authenticated(
3374            4,
3375            Arc::new(RecordingBus::default()),
3376            Arc::new(HashEmbedder::default()),
3377            Arc::new(NoopVectorSink),
3378            Some(directory.path().to_path_buf()),
3379            journal_key,
3380            Arc::new(Registry::new()),
3381            None,
3382        );
3383        let session = session(Workflow::Signed);
3384        worker.submit_and_wait(job(Arc::clone(&session))).await.unwrap();
3385        let digest = pv_core::digest::sha256_hex(session.id.as_bytes());
3386        let stem = digest.get(..32).unwrap();
3387        let journal_path = directory.path().join(format!("{stem}.events.ndjson"));
3388        let bytes_after_first = std::fs::read(&journal_path).unwrap();
3389        assert!(
3390            !bytes_after_first.is_empty(),
3391            "first envelope must journal one line"
3392        );
3393        assert_eq!(
3394            bytes_after_first.iter().filter(|byte| **byte == b'\n').count(),
3395            1,
3396            "first envelope must produce exactly one journal line",
3397        );
3398        session.mark_capture_failed();
3399        // The second envelope must silently short-circuit: no journal line, no
3400        // chain append, no next_seq increment for the poisoned session.
3401        let seq_before = session.next_seq();
3402        worker.submit_and_wait(job(Arc::clone(&session))).await.unwrap();
3403        let seq_after = session.next_seq();
3404        assert_eq!(
3405            seq_after,
3406            seq_before + 1,
3407            "process_job for a capture_failed session must not advance next_seq",
3408        );
3409        assert_eq!(
3410            std::fs::read(&journal_path).unwrap(),
3411            bytes_after_first,
3412            "process_job for a capture_failed session must not append to the journal",
3413        );
3414        assert_eq!(
3415            session.chain.lock().count(),
3416            1,
3417            "process_job for a capture_failed session must not extend the chain",
3418        );
3419    }
3420
3421    #[tokio::test]
3422    async fn signed_job_journal_recovers_replays_and_issues_one_receipt() {
3423        let directory = tempfile::tempdir().unwrap();
3424        let bridge = Arc::new(RecordingBus::default());
3425        let metrics = Arc::new(Registry::new());
3426        let signer = Arc::new(pv_receipts::Ed25519Signer::from_seed(&[14; 32]));
3427        let journal_key = crate::journal::key_from_signer(signer.as_ref());
3428        let worker = spawn_worker_with_spool_authenticated(
3429            4,
3430            bridge.clone(),
3431            Arc::new(HashEmbedder::default()),
3432            Arc::new(NoopVectorSink),
3433            Some(directory.path().to_path_buf()),
3434            journal_key,
3435            Arc::clone(&metrics),
3436            None,
3437        );
3438        let active = session(Workflow::Signed);
3439        worker.submit_and_wait(job(Arc::clone(&active))).await.unwrap();
3440        let digest = pv_core::digest::sha256_hex(active.id.as_bytes());
3441        let stem = digest.get(..32).unwrap();
3442        let ack_path = directory.path().join(format!("{stem}.acks.ndjson"));
3443        std::fs::remove_file(&ack_path).unwrap();
3444
3445        let registry = crate::session::SessionRegistry::new();
3446        let finalizer = crate::reconciler::Finalizer::with_bridge(
3447            signer,
3448            directory.path().to_path_buf(),
3449            metrics,
3450            bridge.clone(),
3451        );
3452        assert_eq!(
3453            finalizer
3454                .recover_spooled_sessions(&registry, &Default::default())
3455                .await
3456                .unwrap(),
3457            1
3458        );
3459        let recovered = registry.get(&active.id).unwrap();
3460        let receipt = recovered.receipt.lock().clone().unwrap();
3461        receipt.verify_embedded().unwrap();
3462        assert!(matches!(
3463            receipt.body.subject,
3464            pv_receipts::ReceiptSubject::EventChain { event_count: 1, .. }
3465        ));
3466        assert_eq!(receipt.body.cost.prompt_tokens, 100);
3467        let events = bridge.events.lock();
3468        assert_eq!(events.len(), 3, "acknowledged active event must not be replayed");
3469        assert_eq!(events[0].2["metadata"]["sequence"], 0);
3470        assert_eq!(events[1].2["metadata"]["sequence"], 1);
3471        assert_eq!(events[2].2["metadata"]["sequence"], 2);
3472        assert!(!directory.path().join(format!("{stem}.events.ndjson")).exists());
3473    }
3474
3475    #[tokio::test]
3476    async fn recovery_quarantines_only_the_incomplete_signed_session() {
3477        let directory = tempfile::tempdir().unwrap();
3478        let bridge = Arc::new(RecordingBus::default());
3479        let metrics = Arc::new(Registry::new());
3480        let signer = Arc::new(pv_receipts::Ed25519Signer::from_seed(&[27; 32]));
3481        let journal_key = crate::journal::key_from_signer(signer.as_ref());
3482        let worker = spawn_worker_with_spool_authenticated(
3483            8,
3484            bridge.clone(),
3485            Arc::new(HashEmbedder::default()),
3486            Arc::new(NoopVectorSink),
3487            Some(directory.path().to_path_buf()),
3488            journal_key,
3489            Arc::clone(&metrics),
3490            None,
3491        );
3492        let incomplete = Arc::new(Session::new(
3493            "incomplete-signed".to_owned(),
3494            Workflow::Signed,
3495            session(Workflow::Signed).current_identity(),
3496            BreakerConfig::default(),
3497        ));
3498        let complete = Arc::new(Session::new(
3499            "complete-signed".to_owned(),
3500            Workflow::Signed,
3501            session(Workflow::Signed).current_identity(),
3502            BreakerConfig::default(),
3503        ));
3504        let mut incomplete_request = job(Arc::clone(&incomplete));
3505        incomplete_request.response_attempt = Some(ResponseAttempt {
3506            id: "incomplete-attempt".to_owned(),
3507            terminal: false,
3508        });
3509        worker.submit_and_wait(incomplete_request).await.unwrap();
3510        worker.submit_and_wait(job(Arc::clone(&complete))).await.unwrap();
3511
3512        let registry = crate::session::SessionRegistry::new();
3513        let finalizer = crate::reconciler::Finalizer::with_bridge(
3514            signer,
3515            directory.path().to_path_buf(),
3516            metrics,
3517            bridge,
3518        );
3519        assert_eq!(
3520            finalizer
3521                .recover_spooled_sessions(&registry, &Default::default())
3522                .await
3523                .unwrap(),
3524            2
3525        );
3526        let quarantined = registry.get("incomplete-signed").unwrap();
3527        assert!(quarantined.capture_failed());
3528        assert!(quarantined.receipt.lock().is_none());
3529        assert!(registry.get("complete-signed").unwrap().receipt.lock().is_some());
3530    }
3531
3532    /// Unsigned twin of the test above. The pending-response quarantine
3533    /// branch in `consolidate_step_journals` used to build a fresh
3534    /// `Session` and `try_insert_recovered` it — which always collided
3535    /// with the recovery placeholder claimed at the top of the candidate
3536    /// body, so the insert failed, the guard release evicted the
3537    /// placeholder, and the session never converged: no registry entry,
3538    /// no quarantine, journal left on disk, one silent skip per
3539    /// reconciler tick forever. The branch now converts the claimed
3540    /// placeholder in place.
3541    #[tokio::test]
3542    async fn recovery_quarantines_the_incomplete_unsigned_session_in_place() {
3543        let directory = tempfile::tempdir().unwrap();
3544        let bridge = Arc::new(RecordingBus::default());
3545        let metrics = Arc::new(Registry::new());
3546        let signer = Arc::new(pv_receipts::Ed25519Signer::from_seed(&[29; 32]));
3547        let journal_key = crate::journal::key_from_signer(signer.as_ref());
3548        let worker = spawn_worker_with_spool_authenticated(
3549            4,
3550            bridge.clone(),
3551            Arc::new(HashEmbedder::default()),
3552            Arc::new(NoopVectorSink),
3553            Some(directory.path().to_path_buf()),
3554            journal_key,
3555            Arc::clone(&metrics),
3556            None,
3557        );
3558        let active = session(Workflow::Unsigned);
3559        let mut incomplete_request = job(Arc::clone(&active));
3560        incomplete_request.response_attempt = Some(ResponseAttempt {
3561            id: "incomplete-attempt".to_owned(),
3562            terminal: false,
3563        });
3564        worker.submit_and_wait(incomplete_request).await.unwrap();
3565
3566        let registry = crate::session::SessionRegistry::new();
3567        let finalizer = crate::reconciler::Finalizer::with_bridge(
3568            signer,
3569            directory.path().to_path_buf(),
3570            metrics,
3571            bridge,
3572        );
3573        finalizer
3574            .recover_spooled_sessions(&registry, &Default::default())
3575            .await
3576            .unwrap();
3577        let quarantined = registry
3578            .get(&active.id)
3579            .expect("quarantined unsigned session must stay registered");
3580        assert!(quarantined.capture_failed());
3581        assert!(
3582            quarantined.is_closed() && quarantined.artifact_committed_flag(),
3583            "quarantine must be sealed so the idle sweeper skips it"
3584        );
3585        assert_eq!(
3586            quarantined.totals.prompt_tokens.load(Ordering::Acquire),
3587            100,
3588            "folded journal totals must land on the quarantined session"
3589        );
3590        // A second recovery pass is a no-op: the registered quarantine
3591        // short-circuits the candidate, with no placeholder churn.
3592        finalizer
3593            .recover_spooled_sessions(&registry, &Default::default())
3594            .await
3595            .unwrap();
3596        let still = registry
3597            .get(&active.id)
3598            .expect("second pass must not evict the quarantined session");
3599        assert!(still.capture_failed());
3600    }
3601
3602    #[tokio::test]
3603    async fn tampered_signed_journal_is_never_turned_into_a_receipt() {
3604        let directory = tempfile::tempdir().unwrap();
3605        let signer = Arc::new(pv_receipts::Ed25519Signer::from_seed(&[18; 32]));
3606        let journal_key = crate::journal::key_from_signer(signer.as_ref());
3607        let worker = spawn_worker_with_spool_authenticated(
3608            4,
3609            Arc::new(RecordingBus::default()),
3610            Arc::new(HashEmbedder::default()),
3611            Arc::new(NoopVectorSink),
3612            Some(directory.path().to_path_buf()),
3613            journal_key,
3614            Arc::new(Registry::new()),
3615            None,
3616        );
3617        let active = session(Workflow::Signed);
3618        worker.submit_and_wait(job(Arc::clone(&active))).await.unwrap();
3619        let digest = pv_core::digest::sha256_hex(active.id.as_bytes());
3620        let stem = digest.get(..32).unwrap();
3621        let journal_path = directory.path().join(format!("{stem}.events.ndjson"));
3622        let mut envelope: Value =
3623            serde_json::from_str(std::fs::read_to_string(&journal_path).unwrap().trim()).unwrap();
3624        envelope["payload"]["prompt_tokens"] = serde_json::json!(999_999);
3625        std::fs::write(
3626            &journal_path,
3627            format!("{}\n", serde_json::to_string(&envelope).unwrap()),
3628        )
3629        .unwrap();
3630        let registry = crate::session::SessionRegistry::new();
3631        let finalizer = crate::reconciler::Finalizer::new(
3632            signer,
3633            directory.path().to_path_buf(),
3634            Arc::new(Registry::new()),
3635        );
3636        // per-session errors during signed recovery no
3637        // longer propagate to the outer `recover_spooled_sessions`
3638        // Err (which used to head-of-line-block every other session
3639        // for the reconciler tick). They now warn+continue. The
3640        // security invariant this test locks in is unchanged: the
3641        // tampered journal MUST NOT be turned into a receipt AND
3642        // the corrupted session MUST NOT be installed into the
3643        // registry (both were previously ensured by the outer Err
3644        // short-circuit). Now they're ensured by the async block
3645        // returning Err BEFORE `try_insert_recovered` runs.
3646        let outcome = finalizer
3647            .recover_spooled_sessions(&registry, &Default::default())
3648            .await;
3649        assert!(
3650            outcome.is_ok(),
3651            "per-session HMAC failures warn+continue instead of propagating, got {outcome:?}"
3652        );
3653        // The corrupted session must NOT be installed into the
3654        // registry — the security property this test was written to
3655        // enforce.
3656        assert!(registry.get(&active.id).is_none());
3657    }
3658
3659    #[tokio::test]
3660    async fn signed_restart_reuses_receipt_persisted_before_journal_cleanup() {
3661        let directory = tempfile::tempdir().unwrap();
3662        let signer = Arc::new(pv_receipts::Ed25519Signer::from_seed(&[15; 32]));
3663        let journal_key = crate::journal::key_from_signer(signer.as_ref());
3664        let worker = spawn_worker_with_spool_authenticated(
3665            4,
3666            Arc::new(RecordingBus::default()),
3667            Arc::new(HashEmbedder::default()),
3668            Arc::new(NoopVectorSink),
3669            Some(directory.path().to_path_buf()),
3670            journal_key,
3671            Arc::new(Registry::new()),
3672            None,
3673        );
3674        let active = session(Workflow::Signed);
3675        worker.submit_and_wait(job(Arc::clone(&active))).await.unwrap();
3676        let digest = pv_core::digest::sha256_hex(active.id.as_bytes());
3677        let stem = digest.get(..32).unwrap();
3678        let metadata_path = directory.path().join(format!("{stem}.session.json"));
3679        let journal_path = directory.path().join(format!("{stem}.events.ndjson"));
3680        let metadata = std::fs::read(&metadata_path).unwrap();
3681        let journal = std::fs::read(&journal_path).unwrap();
3682        let ack_path = directory.path().join(format!("{stem}.acks.ndjson"));
3683        let ack = std::fs::read(&ack_path).unwrap();
3684        let first_finalizer = crate::reconciler::Finalizer::new(
3685            signer.clone(),
3686            directory.path().to_path_buf(),
3687            Arc::new(Registry::new()),
3688        );
3689        let crate::reconciler::FinalizeOutcome::Receipt { receipt: first } = first_finalizer
3690            .close_session(active, pv_events::StopReason::SessionClosed)
3691            .await
3692            .unwrap()
3693        else {
3694            panic!("expected signed receipt")
3695        };
3696
3697        std::fs::write(&metadata_path, metadata).unwrap();
3698        std::fs::write(&journal_path, journal).unwrap();
3699        std::fs::write(&ack_path, ack).unwrap();
3700        let registry = crate::session::SessionRegistry::new();
3701        let after_restart = crate::reconciler::Finalizer::new(
3702            signer,
3703            directory.path().to_path_buf(),
3704            Arc::new(Registry::new()),
3705        );
3706        assert_eq!(
3707            after_restart
3708                .recover_spooled_sessions(&registry, &Default::default())
3709                .await
3710                .unwrap(),
3711            1
3712        );
3713        let recovered = registry.get("session-1").unwrap();
3714        assert_eq!(
3715            recovered.receipt.lock().as_ref().unwrap().body.receipt_id,
3716            first.body.receipt_id
3717        );
3718    }
3719
3720    #[tokio::test]
3721    async fn unsigned_job_preserves_required_atif_metrics() {
3722        // RAM-cliff guard: the step now lives ONLY in the
3723        // events journal; RAM keeps a counter. Use a spooled worker
3724        // and assert the journaled step carries the metrics.
3725        let bridge = Arc::new(RecordingBus::default());
3726        let directory = tempfile::tempdir().unwrap();
3727        let journal_key = [4u8; 32];
3728        let worker = spawn_worker_with_spool_authenticated(
3729            4,
3730            bridge,
3731            Arc::new(HashEmbedder::default()),
3732            Arc::new(NoopVectorSink),
3733            Some(directory.path().to_path_buf()),
3734            journal_key,
3735            Arc::new(Registry::new()),
3736            None,
3737        );
3738        let session = session(Workflow::Unsigned);
3739        worker.submit_and_wait(job(Arc::clone(&session))).await.unwrap();
3740
3741        assert_eq!(session.atif_steps_count(), 1);
3742        assert!(
3743            session.atif.lock().is_empty(),
3744            "steps must not be retained in RAM (RAM-cliff guard)"
3745        );
3746        let digest = pv_core::digest::sha256_hex(session.id.as_bytes());
3747        let stem = digest.get(..32).unwrap();
3748        let journal =
3749            std::fs::read_to_string(directory.path().join(format!("{stem}.events.ndjson"))).unwrap();
3750        let line = journal.lines().next().unwrap();
3751        let record: ActiveJournalRecord = crate::journal::open(
3752            &journal_key,
3753            &format!("{}:active", session.id),
3754            0,
3755            line.as_bytes(),
3756        )
3757        .unwrap();
3758        let step = record.atif_step.unwrap();
3759        assert_eq!(step.metrics.as_ref().unwrap().cached_tokens, Some(40));
3760        assert_eq!(session.totals.cached_tokens.load(Ordering::Acquire), 0);
3761    }
3762
3763    #[tokio::test]
3764    async fn unsigned_job_journal_survives_restart_and_promotes() {
3765        let directory = tempfile::tempdir().unwrap();
3766        let bridge = Arc::new(RecordingBus::default());
3767        let metrics = Arc::new(Registry::new());
3768        let signer = Arc::new(pv_receipts::Ed25519Signer::from_seed(&[13; 32]));
3769        let journal_key = crate::journal::key_from_signer(signer.as_ref());
3770        let worker = spawn_worker_with_spool_authenticated(
3771            4,
3772            bridge.clone(),
3773            Arc::new(HashEmbedder::default()),
3774            Arc::new(NoopVectorSink),
3775            Some(directory.path().to_path_buf()),
3776            journal_key,
3777            Arc::clone(&metrics),
3778            None,
3779        );
3780        let active = session(Workflow::Unsigned);
3781        worker.submit_and_wait(job(Arc::clone(&active))).await.unwrap();
3782        let mut tool = job(Arc::clone(&active));
3783        tool.class = EventClass::ToolCall;
3784        tool.status = StatusId::Failure;
3785        tool.stop_reason = Some(StopReason::PolicyBlocked);
3786        tool.metrics = EventMetrics::default();
3787        tool.cost_usd_micros = 0;
3788        tool.atif.as_mut().unwrap().llm_call_count = Some(0);
3789        worker.submit_and_wait(tool).await.unwrap();
3790        assert_eq!(bridge.events.lock().len(), 2);
3791
3792        let registry = crate::session::SessionRegistry::new();
3793        let finalizer = crate::reconciler::Finalizer::with_bridge(
3794            signer,
3795            directory.path().to_path_buf(),
3796            metrics,
3797            bridge.clone(),
3798        );
3799        assert_eq!(
3800            finalizer
3801                .recover_spooled_sessions(&registry, &Default::default())
3802                .await
3803                .unwrap(),
3804            1
3805        );
3806        let recovered = registry.get(&active.id).unwrap();
3807        assert!(recovered.atif_path.lock().as_ref().unwrap().exists());
3808        assert_eq!(
3809            bridge.events.lock().len(),
3810            2,
3811            "acknowledged unsigned events must not replay"
3812        );
3813        assert_eq!(recovered.current_identity().ttl_remaining_s, Some(600));
3814        let receipt = finalizer.promote(recovered).await.unwrap();
3815        receipt.verify_embedded().unwrap();
3816        assert_eq!(receipt.body.cost.prompt_tokens, 100);
3817        assert_eq!(receipt.body.tool_calls.total, 1);
3818        assert_eq!(receipt.body.tool_calls.blocked, 1);
3819        assert_eq!(receipt.body.stop_reason_id, StopReason::PolicyBlocked.id());
3820        assert!(matches!(
3821            receipt.body.subject,
3822            pv_receipts::ReceiptSubject::AtifTrajectory {
3823                step_count: 2,
3824                retroactive: true,
3825                ..
3826            }
3827        ));
3828    }
3829
3830    #[tokio::test]
3831    async fn full_queue_is_counted_without_blocking() {
3832        let metrics = Arc::new(Registry::new());
3833        let (sender, _receiver) = mpsc::channel(1);
3834        let worker = WorkerHandle {
3835            senders: Arc::new(vec![sender]),
3836            metrics: Arc::clone(&metrics),
3837            pending: Arc::new(std::sync::atomic::AtomicU64::new(0)),
3838            drained: Arc::new(tokio::sync::Notify::new()),
3839            capacity: Arc::new(tokio::sync::Semaphore::new(1)),
3840            response_capacity: Arc::new(tokio::sync::Semaphore::new(1)),
3841        };
3842        worker.try_submit(job(session(Workflow::Signed))).unwrap();
3843        assert_eq!(
3844            worker.try_submit(job(session(Workflow::Signed))),
3845            Err(SubmitError::Full)
3846        );
3847        assert!(metrics
3848            .render()
3849            .contains("pv_events_dropped_total{stage=\"worker_queue\"} 1"));
3850    }
3851
3852    /// The fused permit split (worker_queue vs response_slot) exists
3853    /// so operators can distinguish which class of admission ran out.
3854    /// Assert the counters are actually distinct at the metrics
3855    /// registry level under two adversarial saturations.
3856    #[tokio::test]
3857    async fn worker_queue_and_response_slot_counters_are_distinct() {
3858        let metrics = Arc::new(Registry::new());
3859        let (sender, _receiver) = mpsc::channel(1);
3860        let worker = WorkerHandle {
3861            senders: Arc::new(vec![sender]),
3862            metrics: Arc::clone(&metrics),
3863            pending: Arc::new(std::sync::atomic::AtomicU64::new(0)),
3864            drained: Arc::new(tokio::sync::Notify::new()),
3865            capacity: Arc::new(tokio::sync::Semaphore::new(1)),
3866            response_capacity: Arc::new(tokio::sync::Semaphore::new(1)),
3867        };
3868        // First pair succeeds — worker and response semaphores each go
3869        // to 0.
3870        let first = worker.try_reserve_pair("s").expect("first pair");
3871        // Second pair fails at the worker stage (first failure). Only
3872        // the worker_queue counter should be bumped.
3873        assert_eq!(worker.try_reserve_pair("s").err(), Some(SubmitError::Full));
3874        let rendered = metrics.render();
3875        assert!(
3876            rendered.contains("pv_events_dropped_total{stage=\"worker_queue\"} 1"),
3877            "expected worker_queue counter increment, got:\n{rendered}"
3878        );
3879        assert!(
3880            !rendered.contains("pv_events_dropped_total{stage=\"response_slot\"}"),
3881            "response_slot must not have been touched when worker_queue exhausts first, got:\n{rendered}"
3882        );
3883        // Free the worker slot but keep the response permit held. A
3884        // fresh pair acquire should succeed on the worker side then
3885        // fail at the response stage, bumping response_slot.
3886        drop(first.worker);
3887        let _still_holding_response = first.response;
3888        assert_eq!(worker.try_reserve_pair("s").err(), Some(SubmitError::Full));
3889        let rendered = metrics.render();
3890        assert!(
3891            rendered.contains("pv_events_dropped_total{stage=\"response_slot\"} 1"),
3892            "expected response_slot counter increment, got:\n{rendered}"
3893        );
3894        assert!(
3895            rendered.contains("pv_events_dropped_total{stage=\"worker_queue\"} 1"),
3896            "worker_queue must remain at 1 (only response stage exhausted this time), got:\n{rendered}"
3897        );
3898    }
3899
3900    /// The pair reservation exists so an ADMITTED stream's response-
3901    /// capture job can never be starved by later admissions. A prior
3902    /// revision of `ResponsePermit::submit` dropped the reserved
3903    /// response slot and drew a fresh permit from the WORKER
3904    /// semaphore — so a worker backlog at stream-completion time
3905    /// failed the capture (latching the session capture-failed and
3906    /// destroying an intact response) and charged the drop to
3907    /// `stage="response_slot"` even though worker capacity was the
3908    /// exhausted budget. Lock the guarantee: with worker capacity
3909    /// fully exhausted, the response submit still succeeds on its
3910    /// admission-time reservation, and no drop counter fires.
3911    #[tokio::test]
3912    async fn response_permit_submit_succeeds_on_worker_capacity_exhaustion() {
3913        let bridge = Arc::new(RecordingBus::default());
3914        let metrics = Arc::new(Registry::new());
3915        let worker = spawn_worker(
3916            2,
3917            bridge.clone(),
3918            Arc::new(HashEmbedder::default()),
3919            Arc::clone(&metrics),
3920        );
3921        // Issue a pair permit (worker=1 held; response=1 held for the
3922        // rest of the test).
3923        let permits = worker
3924            .try_reserve_pair("mid-stream-response")
3925            .expect("initial pair");
3926        // Drop the worker permit so that half is free; the response
3927        // half stays held until we submit below.
3928        drop(permits.worker);
3929        // Now consume both remaining worker slots directly so any
3930        // fresh worker-side acquire would fail. The response semaphore
3931        // is untouched — permits.response still holds its slot.
3932        let hog_a = worker.try_reserve("hog-a").expect("hog-a");
3933        let hog_b = worker.try_reserve("hog-b").expect("hog-b");
3934        // Submit the response-capture job. It must ride the response
3935        // reservation taken at admission — worker-capacity exhaustion
3936        // is irrelevant to an already-admitted stream's capture.
3937        let job = job(session(Workflow::Signed));
3938        permits
3939            .response
3940            .submit(&worker, job)
3941            .expect("an admitted stream's capture must not be starved by worker admissions");
3942        let rendered = metrics.render();
3943        assert!(
3944            !rendered.contains("pv_events_dropped_total{stage=\"response_slot\"} 1"),
3945            "no response_slot drop: the reserved slot was used, not re-acquired; got:\n{rendered}"
3946        );
3947        assert!(
3948            !rendered.contains("pv_events_dropped_total{stage=\"worker_queue\"} 1"),
3949            "no worker_queue drop either; got:\n{rendered}"
3950        );
3951        drop(hog_a);
3952        drop(hog_b);
3953        worker.wait_idle().await;
3954    }
3955
3956    #[tokio::test]
3957    async fn one_shard_can_borrow_all_global_capacity() {
3958        const CAPACITY: usize = 16;
3959        let worker = spawn_worker(
3960            CAPACITY,
3961            Arc::new(RecordingBus::default()),
3962            Arc::new(HashEmbedder::default()),
3963            Arc::new(Registry::new()),
3964        );
3965        let partitions = u32::try_from(CAPACITY).unwrap();
3966        let target = pv_bridge::bus::partition_for("target", partitions);
3967        let session_ids: Vec<String> = (0..10_000)
3968            .map(|index| format!("same-shard-{index}"))
3969            .filter(|id| pv_bridge::bus::partition_for(id, partitions) == target)
3970            .take(CAPACITY)
3971            .collect();
3972        assert_eq!(session_ids.len(), CAPACITY);
3973        let permits: Vec<_> = session_ids
3974            .iter()
3975            .map(|id| worker.try_reserve(id).unwrap())
3976            .collect();
3977        assert!(matches!(
3978            worker.try_reserve("globally-full"),
3979            Err(SubmitError::Full)
3980        ));
3981        drop(permits);
3982        assert!(worker.try_reserve("capacity-released").is_ok());
3983    }
3984
3985    #[tokio::test]
3986    async fn panic_is_counted_and_supervisor_processes_next_job() {
3987        let bridge = Arc::new(RecordingBus::default());
3988        let metrics = Arc::new(Registry::new());
3989        let worker = spawn_worker_with_sink(
3990            4,
3991            bridge.clone(),
3992            Arc::new(HashEmbedder::default()),
3993            Arc::new(PanicOnceSink {
3994                panicked: AtomicBool::new(false),
3995            }),
3996            Arc::clone(&metrics),
3997        );
3998        let failed_session = session(Workflow::Signed);
3999        assert!(worker
4000            .submit_and_wait(job(Arc::clone(&failed_session)))
4001            .await
4002            .is_err());
4003        assert!(failed_session.capture_failed());
4004        worker
4005            .submit_and_wait(job(session(Workflow::Signed)))
4006            .await
4007            .unwrap();
4008        assert_eq!(bridge.events.lock().len(), 1);
4009        assert!(metrics.render().contains("pv_worker_panics_total 1"));
4010    }
4011
4012    // Regression: subscribe (enable) and await must operate on the SAME pinned
4013    // Notified, or notify_waiters() firing between the two loses the wakeup.
4014    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4015    async fn wait_idle_survives_notify_races_under_contention() {
4016        let bridge = Arc::new(RecordingBus::default());
4017        let worker = spawn_worker(
4018            64,
4019            bridge.clone(),
4020            Arc::new(HashEmbedder::default()),
4021            Arc::new(Registry::new()),
4022        );
4023        for _ in 0..64 {
4024            for _ in 0..4 {
4025                worker.try_submit(job(session(Workflow::Signed))).unwrap();
4026            }
4027            tokio::time::timeout(std::time::Duration::from_secs(5), worker.wait_idle())
4028                .await
4029                .expect("wait_idle deadlocked: notify_waiters was lost during subscribe/check race");
4030        }
4031    }
4032
4033    // Deterministic proof of the enable/await invariant: producer's
4034    // notify_waiters() fires between the consumer's check and its await.
4035    // If the consumer re-subscribes with a fresh notified() after check, the
4036    // notify_waiters() is lost and the consumer deadlocks. The correct pattern
4037    // keeps the same pinned Notified alive across the check.
4038    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4039    async fn notified_enable_check_await_pattern_is_race_free() {
4040        use std::sync::atomic::AtomicUsize;
4041        use tokio::sync::Notify;
4042
4043        for _ in 0..64 {
4044            let notify = Arc::new(Notify::new());
4045            let pending = Arc::new(AtomicUsize::new(1));
4046            let (check_done_tx, check_done_rx) = tokio::sync::oneshot::channel::<()>();
4047            let (notify_done_tx, notify_done_rx) = tokio::sync::oneshot::channel::<()>();
4048
4049            let n_c = Arc::clone(&notify);
4050            let p_c = Arc::clone(&pending);
4051            let consumer = tokio::spawn(async move {
4052                let notified = n_c.notified();
4053                let mut notified = std::pin::pin!(notified);
4054                notified.as_mut().enable();
4055                assert_ne!(p_c.load(AtomicOrdering::Acquire), 0, "producer ran early");
4056                check_done_tx.send(()).unwrap();
4057                notify_done_rx.await.unwrap();
4058                notified.await;
4059            });
4060
4061            let n_p = Arc::clone(&notify);
4062            let p_p = Arc::clone(&pending);
4063            let producer = tokio::spawn(async move {
4064                check_done_rx.await.unwrap();
4065                p_p.store(0, AtomicOrdering::Release);
4066                n_p.notify_waiters();
4067                notify_done_tx.send(()).unwrap();
4068            });
4069
4070            tokio::time::timeout(std::time::Duration::from_secs(2), async {
4071                producer.await.unwrap();
4072                consumer.await.unwrap();
4073            })
4074            .await
4075            .expect("consumer deadlocked: enable/await pattern violated");
4076        }
4077    }
4078
4079    // ------------------------------------------------------------------
4080    // Congestion & bottleneck stress tests.
4081    // ------------------------------------------------------------------
4082
4083    /// `try_submit` must never block the caller when the worker is saturated —
4084    /// it must return `SubmitError::Full` immediately. A regression that made
4085    /// the hot path await capacity would silently convert backpressure into
4086    /// head-of-line blocking on the request pipeline.
4087    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4088    async fn try_submit_returns_full_within_the_first_millisecond_when_saturated() {
4089        const CAPACITY: usize = 4;
4090        let worker = spawn_worker(
4091            CAPACITY,
4092            Arc::new(RecordingBus::default()),
4093            Arc::new(HashEmbedder::default()),
4094            Arc::new(Registry::new()),
4095        );
4096        // Hold every permit so try_submit sees a saturated semaphore.
4097        let session = session(Workflow::Signed);
4098        let _held: Vec<_> = (0..CAPACITY)
4099            .map(|i| worker.try_reserve(&format!("hold-{i}")).unwrap())
4100            .collect();
4101        let started = std::time::Instant::now();
4102        let result = worker.try_submit(job(Arc::clone(&session)));
4103        let elapsed = started.elapsed();
4104        assert!(matches!(result, Err(SubmitError::Full)));
4105        assert!(
4106            elapsed < std::time::Duration::from_millis(1),
4107            "try_submit blocked for {elapsed:?} when saturated \
4108             (backpressure should be synchronous)",
4109        );
4110    }
4111
4112    /// Metrics must record every drop; operators cannot see congestion
4113    /// otherwise. This locks the counter name and its increment on both
4114    /// the semaphore-exhaustion path (returning Full from `try_capacity`)
4115    /// and the closed-channel path.
4116    #[tokio::test]
4117    async fn every_dropped_job_increments_the_drop_metric() {
4118        const CAPACITY: usize = 2;
4119        let bridge = Arc::new(RecordingBus::default());
4120        let metrics = Arc::new(Registry::new());
4121        let worker = spawn_worker(
4122            CAPACITY,
4123            bridge,
4124            Arc::new(HashEmbedder::default()),
4125            Arc::clone(&metrics),
4126        );
4127        let _hold: Vec<_> = (0..CAPACITY)
4128            .map(|i| worker.try_reserve(&format!("s-{i}")).unwrap())
4129            .collect();
4130        for _ in 0..17 {
4131            assert!(matches!(worker.try_reserve("x"), Err(SubmitError::Full)));
4132        }
4133        let rendered = metrics.render();
4134        assert!(
4135            rendered.contains("pv_events_dropped_total{stage=\"worker_queue\"} 17"),
4136            "expected 17 drops in metrics, got:\n{rendered}",
4137        );
4138    }
4139
4140    /// Capacity released by dropping a permit is immediately reusable.
4141    /// If the semaphore forgot to release on drop, a bursty workload would
4142    /// hit a false Full condition and reject every subsequent request
4143    /// until the worker was recycled.
4144    #[tokio::test]
4145    async fn capacity_is_reusable_after_permit_drop() {
4146        const CAPACITY: usize = 8;
4147        let worker = spawn_worker(
4148            CAPACITY,
4149            Arc::new(RecordingBus::default()),
4150            Arc::new(HashEmbedder::default()),
4151            Arc::new(Registry::new()),
4152        );
4153        for cycle in 0..1_000 {
4154            let permits: Vec<_> = (0..CAPACITY)
4155                .map(|i| worker.try_reserve(&format!("cycle-{cycle}-{i}")).unwrap())
4156                .collect();
4157            assert!(matches!(worker.try_reserve("overflow"), Err(SubmitError::Full)));
4158            drop(permits);
4159            // Full capacity must be immediately re-acquirable.
4160            let recycled: Vec<_> = (0..CAPACITY)
4161                .map(|i| worker.try_reserve(&format!("recycle-{cycle}-{i}")).unwrap())
4162                .collect();
4163            drop(recycled);
4164        }
4165    }
4166
4167    /// Shard partitioning is deterministic and roughly balanced. If one
4168    /// session hashed to the same shard as many others, that shard's queue
4169    /// would fill up first and every other shard would run under-utilized
4170    /// (see `one_shard_can_borrow_all_global_capacity` for the extreme).
4171    /// This test asserts the *balance* invariant across a wide id space.
4172    #[test]
4173    fn partition_for_distributes_uniformly_across_shards() {
4174        const SHARDS: u32 = 16;
4175        const SAMPLES: u32 = 100_000;
4176        let mut hits = [0u32; SHARDS as usize];
4177        for i in 0..SAMPLES {
4178            let id = format!("session-{i}");
4179            let s = pv_bridge::bus::partition_for(&id, SHARDS) as usize;
4180            hits[s] += 1;
4181        }
4182        let expected = SAMPLES / SHARDS;
4183        for (shard, count) in hits.iter().enumerate() {
4184            let lower = expected * 9 / 10;
4185            let upper = expected * 11 / 10;
4186            assert!(
4187                (lower..=upper).contains(count),
4188                "shard {shard} got {count} hits, expected {expected} (10% band)",
4189            );
4190        }
4191    }
4192
4193    /// All 16 partitions must have a live shard channel, even when the
4194    /// caller passes a small `capacity`. An earlier version sized
4195    /// `shard_count = capacity.min(16)`, so with `capacity < 16` any
4196    /// session id hashing to a partition without a shard silently
4197    /// returned `SubmitError::Closed` — 15/16 of the id space was
4198    /// unroutable at capacity = 1.
4199    #[tokio::test]
4200    async fn every_shard_is_routable_at_capacity_one() {
4201        let worker = spawn_worker(
4202            1,
4203            Arc::new(RecordingBus::default()),
4204            Arc::new(HashEmbedder::default()),
4205            Arc::new(Registry::new()),
4206        );
4207        // Probe every one of the 16 possible partition indices with a
4208        // synthetic id crafted to hash there.
4209        for target in 0..16u32 {
4210            let mut candidate = None;
4211            for i in 0..10_000u64 {
4212                let id = format!("probe-{target}-{i}");
4213                if pv_bridge::bus::partition_for(&id, 16) == target {
4214                    candidate = Some(id);
4215                    break;
4216                }
4217            }
4218            let id = candidate.unwrap_or_else(|| panic!("no id found for shard {target}"));
4219            let permit = worker
4220                .try_reserve(&id)
4221                .unwrap_or_else(|error| panic!("shard {target} not routable: {error:?}"));
4222            drop(permit);
4223        }
4224    }
4225
4226    /// Design invariant: `submit_and_wait` canceled at any point must not
4227    /// leave the `pending` counter incremented, because a leaked pending
4228    /// count would deadlock `wait_idle` on shutdown. The coupled
4229    /// semaphore + shard-channel capacity guarantees `send().await` never
4230    /// blocks while a permit is held, so cancellation before the envelope
4231    /// enters the channel can only happen while the counter has not yet
4232    /// been incremented. This test locks that invariant across many
4233    /// cancellation timings.
4234    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4235    async fn cancelling_submit_and_wait_never_leaks_pending() {
4236        const CAPACITY: usize = 4;
4237        let bridge = Arc::new(RecordingBus::default());
4238        let worker = Arc::new(spawn_worker(
4239            CAPACITY,
4240            bridge.clone(),
4241            Arc::new(HashEmbedder::default()),
4242            Arc::new(Registry::new()),
4243        ));
4244        // Saturate the semaphore so submit_and_wait always blocks on
4245        // acquire_owned; abort at randomized delays to hit every polling
4246        // window of the acquire future.
4247        let held: Vec<_> = (0..CAPACITY)
4248            .map(|i| worker.try_reserve(&format!("hold-{i}")).unwrap())
4249            .collect();
4250        for delay_us in [0u64, 50, 200, 800, 3_200] {
4251            let hostile_session = session(Workflow::Signed);
4252            let worker_for_task = Arc::clone(&worker);
4253            let task = tokio::spawn(async move {
4254                let _ = worker_for_task
4255                    .submit_and_wait(job(Arc::clone(&hostile_session)))
4256                    .await;
4257            });
4258            if delay_us > 0 {
4259                tokio::time::sleep(std::time::Duration::from_micros(delay_us)).await;
4260            }
4261            task.abort();
4262            let _ = task.await;
4263        }
4264        drop(held);
4265        tokio::time::timeout(std::time::Duration::from_secs(2), worker.wait_idle())
4266            .await
4267            .expect("wait_idle deadlocked — a canceled submit_and_wait leaked pending");
4268    }
4269
4270    /// Group commit: a backlog that accumulates behind
4271    /// a gated first job drains as one same-session batch (Phase A
4272    /// appends + ONE fdatasync + per-job tails). Every job must
4273    /// complete successfully, the journal must carry every record in
4274    /// submission order at its sealed index, and publishes must
4275    /// follow submission order.
4276    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4277    async fn session_backlog_group_commits_in_order() {
4278        let directory = tempfile::tempdir().unwrap();
4279        let journal_key = [6u8; 32];
4280        let bus = Arc::new(GatedBus::new("batched-session"));
4281        let worker = spawn_worker_with_spool_authenticated(
4282            64,
4283            Arc::clone(&bus) as Arc<dyn EventBus>,
4284            Arc::new(HashEmbedder::default()),
4285            Arc::new(NoopVectorSink),
4286            Some(directory.path().to_path_buf()),
4287            journal_key,
4288            Arc::new(Registry::new()),
4289            None,
4290        );
4291        let session = session_with_id("batched-session");
4292        // First job blocks in publish (Phase B), so the next ten
4293        // envelopes pile up behind it and dispatch as one batch.
4294        // Sequential try_submit pins arrival order (spawned submitters
4295        // would race the channel); the gate holds job 0 in Phase B so
4296        // jobs 1..10 accumulate and dispatch as one batch.
4297        for n in 0..11 {
4298            let mut submitted = job(Arc::clone(&session));
4299            submitted.payload = serde_json::json!({"kind": "response", "burst": n});
4300            worker.try_submit(submitted).unwrap();
4301        }
4302        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4303        bus.release.store(true, std::sync::atomic::Ordering::Release);
4304        session.wait_for_worker_jobs().await;
4305        assert!(!session.capture_failed(), "a batched burst must not fail capture");
4306
4307        // Journal: 11 records, each MAC-sealed at its position.
4308        let digest = pv_core::digest::sha256_hex(session.id.as_bytes());
4309        let stem = digest.get(..32).unwrap();
4310        let journal =
4311            std::fs::read_to_string(directory.path().join(format!("{stem}.events.ndjson"))).unwrap();
4312        let lines: Vec<&str> = journal.lines().collect();
4313        assert_eq!(lines.len(), 11, "every batched record must be journaled");
4314        for (index, line) in lines.iter().enumerate() {
4315            let record: ActiveJournalRecord = crate::journal::open(
4316                &journal_key,
4317                &format!("{}:active", session.id),
4318                index as u64,
4319                line.as_bytes(),
4320            )
4321            .unwrap();
4322            assert_eq!(
4323                record.event.pointer("/payload/burst").and_then(Value::as_u64),
4324                Some(index as u64),
4325                "journal record {index} must carry the {index}th submitted payload"
4326            );
4327        }
4328        // Publishes followed submission order.
4329        let published = bus.published.lock();
4330        let bursts: Vec<u64> = published
4331            .iter()
4332            .filter_map(|text| {
4333                serde_json::from_str::<Value>(text)
4334                    .ok()?
4335                    .pointer("/payload/burst")
4336                    .and_then(Value::as_u64)
4337            })
4338            .collect();
4339        assert_eq!(
4340            bursts,
4341            (0..11).collect::<Vec<_>>(),
4342            "publish order must match submission order"
4343        );
4344    }
4345
4346    fn session_with_id(id: &str) -> Arc<Session> {
4347        Arc::new(Session::new(
4348            id.to_owned(),
4349            Workflow::Signed,
4350            AgentIdentity {
4351                version: "1".to_owned(),
4352                charter: "test".into(),
4353                instance_uid: "instance-1".to_owned(),
4354                ttl_remaining_s: Some(600),
4355            },
4356            BreakerConfig {
4357                min_tokens: u64::MAX,
4358                ..BreakerConfig::default()
4359            },
4360        ))
4361    }
4362
4363    /// Find a session id that shares `reference`'s shard, so the test
4364    /// provably exercises INTRA-shard scheduling.
4365    fn colliding_session_id(reference: &str, shard_count: usize) -> String {
4366        let target = pv_bridge::bus::partition_for(reference, u32::try_from(shard_count).unwrap());
4367        (0..)
4368            .map(|n| format!("victim-{n}"))
4369            .find(|candidate| {
4370                candidate != reference
4371                    && pv_bridge::bus::partition_for(candidate, u32::try_from(shard_count).unwrap()) == target
4372            })
4373            .unwrap()
4374    }
4375
4376    /// One stalled session must not freeze its shard.
4377    /// Pre-dispatcher, the victim (same shard) sat behind the stalled
4378    /// session's envelope forever (31× measured HOL blocking); now it
4379    /// completes while the stalled session occupies one slot.
4380    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4381    async fn stalled_session_does_not_block_shard_neighbors() {
4382        let shard_count = std::thread::available_parallelism()
4383            .map(std::num::NonZero::get)
4384            .unwrap_or(16)
4385            .max(16);
4386        let stalled_id = "stalled-session".to_owned();
4387        let victim_id = colliding_session_id(&stalled_id, shard_count);
4388        let bus = Arc::new(GatedBus::new(&stalled_id));
4389        let worker = spawn_worker(
4390            64,
4391            Arc::clone(&bus) as Arc<dyn EventBus>,
4392            Arc::new(HashEmbedder::default()),
4393            Arc::new(Registry::new()),
4394        );
4395        let stalled = session_with_id(&stalled_id);
4396        let victim = session_with_id(&victim_id);
4397
4398        let stalled_wait = {
4399            let worker = worker.clone();
4400            let job = job(Arc::clone(&stalled));
4401            tokio::spawn(async move { worker.submit_and_wait(job).await })
4402        };
4403        // The victim must complete while the stalled session is gated.
4404        tokio::time::timeout(
4405            std::time::Duration::from_secs(5),
4406            worker.submit_and_wait(job(Arc::clone(&victim))),
4407        )
4408        .await
4409        .expect("victim session must not be head-of-line blocked by a stalled shard neighbor")
4410        .unwrap();
4411        assert!(
4412            !stalled_wait.is_finished(),
4413            "the stalled session must still be gated (the victim did not just win a race)"
4414        );
4415        bus.release.store(true, Ordering::Release);
4416        stalled_wait.await.unwrap().unwrap();
4417    }
4418
4419    fn read_redaction_test_record(spool: &std::path::Path, session: &Session) -> ActiveJournalRecord {
4420        let digest = pv_core::digest::sha256_hex(session.id.as_bytes());
4421        let stem = digest.get(..32).unwrap();
4422        let contents = std::fs::read_to_string(spool.join(format!("{stem}.events.ndjson"))).unwrap();
4423        assert_eq!(contents.lines().count(), 1, "exactly one event must be journaled");
4424        crate::journal::open(
4425            &[0; 32],
4426            &format!("{}:active", session.id),
4427            0,
4428            contents.trim().as_bytes(),
4429        )
4430        .unwrap()
4431    }
4432
4433    #[tokio::test]
4434    async fn redaction_engine_strips_sensitive_payload_on_spool() {
4435        let spool = tempfile::tempdir().unwrap();
4436        let bus = Arc::new(RecordingBus::default());
4437        let engine = pv_redact::RedactionEngine::new(pv_redact::RedactionConfig {
4438            include_builtin_patterns: true,
4439            ..pv_redact::RedactionConfig::default()
4440        })
4441        .unwrap();
4442        let worker = spawn_worker_with_spool_authenticated(
4443            4,
4444            Arc::clone(&bus) as Arc<dyn EventBus>,
4445            Arc::new(HashEmbedder::default()),
4446            Arc::new(NoopVectorSink),
4447            Some(spool.path().to_owned()),
4448            [0; 32],
4449            Arc::new(Registry::new()),
4450            Some(Arc::new(engine)),
4451        );
4452        let sess = session(Workflow::Unsigned);
4453        let mut j = job(Arc::clone(&sess));
4454        j.payload = serde_json::json!({
4455            "kind": "tool_response",
4456            "api_key": "sk-BBBBBBBBBBBBBBBBBBBBBB"
4457        });
4458        worker.submit_and_wait(j).await.unwrap();
4459        let record = read_redaction_test_record(spool.path(), &sess);
4460        assert_eq!(record.event["payload"]["api_key"], "[REDACTED]");
4461        assert_eq!(record.event["payload"]["kind"], "tool_response");
4462    }
4463
4464    #[tokio::test]
4465    async fn redaction_engine_strips_sensitive_payload_on_bus() {
4466        let bus = Arc::new(RecordingBus::default());
4467        let engine = pv_redact::RedactionEngine::new(pv_redact::RedactionConfig {
4468            include_builtin_patterns: true,
4469            ..pv_redact::RedactionConfig::default()
4470        })
4471        .unwrap();
4472        let worker = spawn_worker_with_spool_authenticated(
4473            4,
4474            Arc::clone(&bus) as Arc<dyn EventBus>,
4475            Arc::new(HashEmbedder::default()),
4476            Arc::new(NoopVectorSink),
4477            None,
4478            [0; 32],
4479            Arc::new(Registry::new()),
4480            Some(Arc::new(engine)),
4481        );
4482        let sess = session(Workflow::Unsigned);
4483        let mut j = job(Arc::clone(&sess));
4484        j.payload = serde_json::json!({
4485            "kind": "tool_response",
4486            "api_key": "sk-AAAAAAAAAAAAAAAAAAAAAA"
4487        });
4488        worker.submit_and_wait(j).await.unwrap();
4489        let events = bus.events.lock();
4490        let published = events
4491            .iter()
4492            .find(|(_, _, v)| v.pointer("/payload").is_some())
4493            .expect("at least one event with a payload must reach the bus");
4494        let payload = &published.2["payload"];
4495        assert_eq!(payload["api_key"], "[REDACTED]");
4496    }
4497
4498    #[tokio::test]
4499    async fn redaction_engine_strips_sensitive_atif_step_fields() {
4500        let spool = tempfile::tempdir().unwrap();
4501        let bus = Arc::new(RecordingBus::default());
4502        let engine = pv_redact::RedactionEngine::new(pv_redact::RedactionConfig {
4503            include_builtin_patterns: true,
4504            ..pv_redact::RedactionConfig::default()
4505        })
4506        .unwrap();
4507        let worker = spawn_worker_with_spool_authenticated(
4508            4,
4509            Arc::clone(&bus) as Arc<dyn EventBus>,
4510            Arc::new(HashEmbedder::default()),
4511            Arc::new(NoopVectorSink),
4512            Some(spool.path().to_owned()),
4513            [0; 32],
4514            Arc::new(Registry::new()),
4515            Some(Arc::new(engine)),
4516        );
4517        let sess = session(Workflow::Unsigned);
4518        let mut j = job(Arc::clone(&sess));
4519        j.atif = Some(AtifCapture {
4520            source: pv_atif::Source::Agent,
4521            message: serde_json::json!("The API key is sk-CCCCCCCCCCCCCCCCCCCCCC"),
4522            reasoning_content: Some("User leaked sk-DDDDDDDDDDDDDDDDDDDDDD in context".into()),
4523            model_name: None,
4524            tool_calls: Some(vec![pv_atif::ToolCall {
4525                tool_call_id: "call-1".into(),
4526                function_name: "send".into(),
4527                arguments: serde_json::json!({
4528                    "key": "sk-proj-AbCdEf0123456789_-",
4529                    "card": 4_111_111_111_111_111_u64,
4530                    "share_with": { "alice@example.com": "editor" }
4531                }),
4532                extra: None,
4533            }]),
4534            observation: Some(pv_atif::Observation {
4535                results: vec![pv_atif::ObservationResult {
4536                    source_call_id: Some("call-1".into()),
4537                    content: Some(serde_json::json!("result: sk-EEEEEEEEEEEEEEEEEEEEEE")),
4538                    subagent_trajectory_ref: None,
4539                    extra: None,
4540                }],
4541            }),
4542            llm_call_count: Some(1),
4543        });
4544        worker.submit_and_wait(j).await.unwrap();
4545        let record = read_redaction_test_record(spool.path(), &sess);
4546        let step = record.atif_step.unwrap();
4547        assert_eq!(step.message, "The API key is [REDACTED]");
4548        assert_eq!(
4549            step.reasoning_content.as_deref(),
4550            Some("User leaked [REDACTED] in context")
4551        );
4552        assert_eq!(
4553            step.observation.unwrap().results[0].content,
4554            Some(serde_json::json!("result: [REDACTED]"))
4555        );
4556        assert_eq!(
4557            step.tool_calls.unwrap()[0].arguments,
4558            serde_json::json!({
4559                "key": "[REDACTED]",
4560                "card": "[REDACTED]",
4561                "share_with": { "[REDACTED]": "editor" }
4562            })
4563        );
4564    }
4565
4566    #[tokio::test]
4567    async fn redaction_engine_removes_paths_from_serialized_tool_payload_copies() {
4568        let spool = tempfile::tempdir().unwrap();
4569        let bus = Arc::new(RecordingBus::default());
4570        let engine = pv_redact::RedactionEngine::new(pv_redact::RedactionConfig {
4571            pointer_paths: vec!["/reason".into()],
4572            include_builtin_patterns: false,
4573            ..pv_redact::RedactionConfig::default()
4574        })
4575        .unwrap();
4576        let worker = spawn_worker_with_spool_authenticated(
4577            4,
4578            Arc::clone(&bus) as Arc<dyn EventBus>,
4579            Arc::new(HashEmbedder::default()),
4580            Arc::new(NoopVectorSink),
4581            Some(spool.path().to_owned()),
4582            [0; 32],
4583            Arc::new(Registry::new()),
4584            Some(Arc::new(engine)),
4585        );
4586        let sess = session(Workflow::Unsigned);
4587        let mut j = job(Arc::clone(&sess));
4588        j.class = EventClass::ToolCall;
4589        j.analyze_loop = false;
4590        j.status = StatusId::Failure;
4591        j.payload =
4592            serde_json::json!({"tool": "transfer", "allowed": false, "reason": "private denial detail"});
4593        j.atif.as_mut().unwrap().observation = Some(pv_atif::Observation {
4594            results: vec![
4595                pv_atif::ObservationResult {
4596                    source_call_id: Some("call-1".into()),
4597                    content: Some(Value::String(j.payload.to_string())),
4598                    subagent_trajectory_ref: None,
4599                    extra: None,
4600                },
4601                pv_atif::ObservationResult {
4602                    source_call_id: Some("unrelated".into()),
4603                    content: Some(Value::String(r#"{"reason":"unrelated tool data"}"#.into())),
4604                    subagent_trajectory_ref: None,
4605                    extra: None,
4606                },
4607            ],
4608        });
4609        worker.submit_and_wait(j).await.unwrap();
4610        let record = read_redaction_test_record(spool.path(), &sess);
4611        assert_eq!(record.event["payload"]["reason"], "[REDACTED]");
4612        let results = record.atif_step.unwrap().observation.unwrap().results;
4613        let copied_payload: Value =
4614            serde_json::from_str(results[0].content.as_ref().unwrap().as_str().unwrap()).unwrap();
4615        assert_eq!(copied_payload, record.event["payload"]);
4616        assert_eq!(
4617            results[1].content,
4618            Some(Value::String(r#"{"reason":"unrelated tool data"}"#.into()))
4619        );
4620        assert_eq!(bus.events.lock()[0].2["payload"], copied_payload);
4621    }
4622
4623    /// Per-session ordering survives the dispatcher: two jobs for one
4624    /// session publish in submission order even though the shard runs
4625    /// sessions concurrently.
4626    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4627    async fn per_session_ordering_is_preserved() {
4628        let bus = Arc::new(RecordingBus::default());
4629        let worker = spawn_worker(
4630            64,
4631            Arc::clone(&bus) as Arc<dyn EventBus>,
4632            Arc::new(HashEmbedder::default()),
4633            Arc::new(Registry::new()),
4634        );
4635        let session = session_with_id("ordered-session");
4636        for n in 0..8u64 {
4637            let mut ordered = job(Arc::clone(&session));
4638            ordered.payload = serde_json::json!({"kind": "response", "step": n});
4639            worker.submit_and_wait(ordered).await.unwrap();
4640        }
4641        let events = bus.events.lock();
4642        let steps: Vec<u64> = events
4643            .iter()
4644            .filter_map(|(_, _, value)| value.pointer("/payload/step").and_then(Value::as_u64))
4645            .collect();
4646        assert_eq!(
4647            steps,
4648            (0..8).collect::<Vec<_>>(),
4649            "per-session publish order must match submission order"
4650        );
4651    }
4652}
4653
4654#[cfg(test)]
4655mod unified_fold_tests {
4656    #![allow(clippy::unwrap_used, clippy::panic)]
4657
4658    use super::*;
4659
4660    fn record(
4661        tool: (u64, u64, u64),
4662        prompt: u64,
4663        completion: u64,
4664        cached: u64,
4665        cost: u64,
4666    ) -> ActiveJournalRecord {
4667        ActiveJournalRecord {
4668            event: serde_json::json!({"k": "v"}),
4669            identity: pv_events::AgentIdentity {
4670                version: "1".to_owned(),
4671                charter: "test".into(),
4672                instance_uid: "i".to_owned(),
4673                ttl_remaining_s: None,
4674            },
4675            atif_step: None,
4676            tool_calls: tool.0,
4677            tool_allowed: tool.1,
4678            tool_blocked: tool.2,
4679            prompt_tokens: prompt,
4680            completion_tokens: completion,
4681            cached_tokens: cached,
4682            cost_usd_micros: cost,
4683            prompt_token_correction: 0,
4684            stop_reason_id: None,
4685            response_attempt: None,
4686        }
4687    }
4688
4689    /// Shared-fold proof harness: the live-path atomic
4690    /// application and the recovery fold MUST produce identical
4691    /// totals over the same record stream — this is the property the
4692    /// three hand-rolled folds could silently violate ('a
4693    /// crash-recovered receipt attests different numbers than a
4694    /// clean-close receipt for identical traffic — and no test
4695    /// catches it, because both paths are asserted separately').
4696    #[test]
4697    fn unified_fold_matches_live_path() {
4698        // Representative shapes: a compression record (prompt only),
4699        // a terminal response (completion/cached/cost), an allowed
4700        // tool call, a blocked tool call, an unclassified tool call,
4701        // and a stop record (all zeros).
4702        let records = [
4703            record((0, 0, 0), 4_822, 0, 0, 0),
4704            record((0, 0, 0), 0, 319, 12, 84_340),
4705            record((1, 1, 0), 0, 0, 0, 0),
4706            record((1, 0, 1), 0, 0, 0, 0),
4707            record((1, 0, 0), 0, 0, 0, 0),
4708            record((0, 0, 0), 0, 0, 0, 0),
4709        ];
4710
4711        // Recovery fold.
4712        let mut folded = RecoveredTotals::default();
4713        for r in &records {
4714            r.fold_into(&mut folded).unwrap();
4715        }
4716        folded.validate_tool_accounting().unwrap();
4717
4718        // Live-path fold onto atomics.
4719        let live = crate::session::Totals::default();
4720        for r in &records {
4721            r.apply_to_totals(&live).unwrap();
4722        }
4723
4724        use std::sync::atomic::Ordering;
4725        assert_eq!(folded.tool_calls, live.tool_calls.load(Ordering::Acquire));
4726        assert_eq!(folded.tool_allowed, live.tool_allowed.load(Ordering::Acquire));
4727        assert_eq!(folded.tool_blocked, live.tool_blocked.load(Ordering::Acquire));
4728        assert_eq!(folded.prompt_tokens, live.prompt_tokens.load(Ordering::Acquire));
4729        assert_eq!(
4730            folded.completion_tokens,
4731            live.completion_tokens.load(Ordering::Acquire)
4732        );
4733        assert_eq!(folded.cached_tokens, live.cached_tokens.load(Ordering::Acquire));
4734        assert_eq!(
4735            folded.cost_usd_micros,
4736            live.cost_usd_micros.load(Ordering::Acquire)
4737        );
4738        assert_eq!(folded.prompt_tokens, 4_822);
4739        assert_eq!(folded.tool_calls, 3);
4740    }
4741
4742    /// Both fold directions must refuse a JCS-bound overflow rather
4743    /// than wrap or saturate — a receipt attesting a wrapped total is
4744    /// worse than a refused close.
4745    #[test]
4746    fn both_folds_refuse_jcs_overflow() {
4747        let poisoned = record((0, 0, 0), pv_core::error::JCS_SAFE_MAX, 0, 0, 0);
4748        let mut folded = RecoveredTotals::default();
4749        poisoned.fold_into(&mut folded).unwrap();
4750        assert!(
4751            record((0, 0, 0), 1, 0, 0, 0).fold_into(&mut folded).is_err(),
4752            "recovery fold must refuse crossing JCS_SAFE_MAX"
4753        );
4754
4755        let live = crate::session::Totals::default();
4756        poisoned.apply_to_totals(&live).unwrap();
4757        assert!(
4758            record((0, 0, 0), 1, 0, 0, 0).apply_to_totals(&live).is_err(),
4759            "live fold must refuse crossing JCS_SAFE_MAX"
4760        );
4761    }
4762
4763    /// A terminal record carrying a provider
4764    /// reconciliation (`prompt_token_correction`) must adjust the
4765    /// prompt total identically on the live path and in recovery — a
4766    /// crash between charge and close must not resurrect the
4767    /// heuristic over-estimate into the signed receipt.
4768    #[test]
4769    fn correction_applies_identically_and_refuses_underflow() {
4770        // Heuristic charged 30 prompt tokens, provider reported 10.
4771        let mut charged = record((0, 0, 0), 30, 0, 0, 0);
4772        let mut terminal = record((0, 0, 0), 0, 5, 0, 100);
4773        terminal.prompt_token_correction = -20;
4774
4775        let mut folded = RecoveredTotals::default();
4776        charged.fold_into(&mut folded).unwrap();
4777        terminal.fold_into(&mut folded).unwrap();
4778        assert_eq!(folded.prompt_tokens, 10);
4779
4780        let live = crate::session::Totals::default();
4781        charged.apply_to_totals(&live).unwrap();
4782        terminal.apply_to_totals(&live).unwrap();
4783        use std::sync::atomic::Ordering;
4784        assert_eq!(live.prompt_tokens.load(Ordering::Acquire), 10);
4785
4786        // A correction larger than the accumulated total is a
4787        // tampered/corrupt journal: both folds must refuse, not wrap.
4788        charged = record((0, 0, 0), 5, 0, 0, 0);
4789        terminal.prompt_token_correction = -20;
4790        let mut folded = RecoveredTotals::default();
4791        charged.fold_into(&mut folded).unwrap();
4792        assert!(
4793            terminal.fold_into(&mut folded).is_err(),
4794            "recovery fold must refuse correction underflow"
4795        );
4796        let live = crate::session::Totals::default();
4797        charged.apply_to_totals(&live).unwrap();
4798        assert!(
4799            terminal.apply_to_totals(&live).is_err(),
4800            "live fold must refuse correction underflow"
4801        );
4802    }
4803}
4804
4805#[cfg(test)]
4806mod ack_journal_tests {
4807    #![allow(clippy::unwrap_used)]
4808
4809    use super::*;
4810
4811    /// Acks round-trip through the per-session journal,
4812    /// tolerate a torn tail line, and the legacy per-event file layout
4813    /// is still readable (a deployment upgraded mid-session must see
4814    /// its earlier acks).
4815    #[tokio::test]
4816    async fn ack_journal_roundtrips_tolerates_torn_tail_and_reads_legacy() {
4817        let directory = tempfile::tempdir().unwrap();
4818        let key = [9u8; 32];
4819        let ack = PublishAck {
4820            topic: "t".to_owned(),
4821            partition: 0,
4822            offset: 7,
4823        };
4824        persist_broker_ack(directory.path(), "s1", "evt-1", &ack, &key)
4825            .await
4826            .unwrap();
4827        persist_broker_ack(directory.path(), "s1", "evt-2", &ack, &key)
4828            .await
4829            .unwrap();
4830        // Torn tail: simulate a crash mid-append.
4831        let journal = ack_journal_path(directory.path(), "s1");
4832        let mut bytes = std::fs::read(&journal).unwrap();
4833        bytes.extend_from_slice(b"{\"torn");
4834        std::fs::write(&journal, bytes).unwrap();
4835
4836        let found = read_broker_ack(directory.path(), "s1", "evt-1", &key)
4837            .await
4838            .unwrap();
4839        assert_eq!(found.map(|ack| ack.offset), Some(7));
4840        assert!(
4841            read_broker_ack(directory.path(), "s1", "missing", &key)
4842                .await
4843                .unwrap()
4844                .is_none(),
4845            "an unknown event has no ack (torn tail must not error the scan)"
4846        );
4847
4848        // Legacy layout: an older per-event file is still found.
4849        let legacy = broker_ack_path(directory.path(), "s2", "evt-9");
4850        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
4851        let record = BrokerAckRecord {
4852            session_id: "s2".to_owned(),
4853            event_uid: "evt-9".to_owned(),
4854            ack: ack.clone(),
4855        };
4856        let sealed = crate::journal::seal(&key, "broker-ack", 0, &record).unwrap();
4857        std::fs::write(&legacy, sealed).unwrap();
4858        let found = read_broker_ack(directory.path(), "s2", "evt-9", &key)
4859            .await
4860            .unwrap();
4861        assert_eq!(found.map(|ack| ack.offset), Some(7));
4862    }
4863
4864    /// Journal LENGTH must never invalidate the evidence inside it.
4865    /// Every previous whole-file read cap (1 MiB, then 16 MiB)
4866    /// recreated the same outage one order of magnitude later: the
4867    /// first restart after a busy session crossed the cap failed EVERY
4868    /// ack lookup and recovery skipped the session forever. The scan
4869    /// now streams per-record — an ack sitting past the old 16 MiB
4870    /// mark must be found, and an oversized garbage line mid-file must
4871    /// be skipped without poisoning its intact neighbors.
4872    #[tokio::test]
4873    async fn ack_scan_survives_journals_past_the_old_cap_and_oversized_lines() {
4874        let directory = tempfile::tempdir().unwrap();
4875        let key = [9u8; 32];
4876        let ack = PublishAck {
4877            topic: "t".to_owned(),
4878            partition: 0,
4879            offset: 42,
4880        };
4881        persist_broker_ack(directory.path(), "s1", "evt-first", &ack, &key)
4882            .await
4883            .unwrap();
4884        let journal = ack_journal_path(directory.path(), "s1");
4885        {
4886            use std::io::Write as _;
4887            let mut file = std::fs::OpenOptions::new().append(true).open(&journal).unwrap();
4888            // An oversized single line (2 MiB > MAX_ACK_RECORD_BYTES)
4889            // that must be drained and skipped, not fatal.
4890            let oversized = vec![b'x'; 2 * 1024 * 1024];
4891            file.write_all(&oversized).unwrap();
4892            file.write_all(b"\n").unwrap();
4893            // Bulk filler pushing the file past the old 16 MiB
4894            // whole-file cap, as many well-formed-but-foreign lines.
4895            let filler = vec![b'y'; 8 * 1024];
4896            for _ in 0..2_200 {
4897                file.write_all(&filler).unwrap();
4898                file.write_all(b"\n").unwrap();
4899            }
4900        }
4901        // The ack the session actually needs lands at the very end,
4902        // past the old cap.
4903        let tail_ack = PublishAck {
4904            topic: "t".to_owned(),
4905            partition: 0,
4906            offset: 77,
4907        };
4908        persist_broker_ack(directory.path(), "s1", "evt-tail", &tail_ack, &key)
4909            .await
4910            .unwrap();
4911        assert!(
4912            std::fs::metadata(&journal).unwrap().len() > 16 * 1024 * 1024,
4913            "test setup: journal must exceed the old whole-file cap",
4914        );
4915
4916        let found = read_broker_ack(directory.path(), "s1", "evt-tail", &key)
4917            .await
4918            .unwrap();
4919        assert_eq!(
4920            found.map(|ack| ack.offset),
4921            Some(77),
4922            "an ack past the old 16 MiB cap must still be found",
4923        );
4924        let first = read_broker_ack(directory.path(), "s1", "evt-first", &key)
4925            .await
4926            .unwrap();
4927        assert_eq!(
4928            first.map(|ack| ack.offset),
4929            Some(42),
4930            "the oversized garbage line must not poison earlier acks",
4931        );
4932    }
4933}