Skip to main content

av_harness/
worker.rs

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