Skip to main content

khive_runtime/
audit_batch.rs

1//! ADR-133 Slice 1: the audit-batch seam.
2//!
3//! Incidental audit writes (gate denials, dispatch outcomes, config-lock
4//! rows, git.digest receipts, and pure-observability rows like
5//! `RecallExecuted`) no longer take one writer-task acquisition per row on
6//! the request hot path. Concurrent submissions arriving while a generation
7//! is committing share the *next* generation instead of each taking their
8//! own writer acquisition, so N concurrent producers collapse to one
9//! [`khive_storage::EventStore::append_events_idempotent`] call per
10//! generation rather than N.
11//!
12//! [`AuditBatch`] owns this seam. A lazily-spawned supervisor task drains
13//! pending rows into generations and drives each through the store; the
14//! supervisor's own `JoinHandle` is retained (never discarded) so an
15//! abnormal exit — panic, cancellation, a lost child join, or a driver that
16//! returns `Ok` while state is not terminally consistent — is observed and
17//! converted into a `Failed` transition with all accepted waiters resolved,
18//! per owner ruling R1/R4 (`.khive/OWNER_RULING_adr133_gate.md`).
19
20#[cfg(any(test, feature = "fault-injection"))]
21use std::sync::atomic::Ordering;
22use std::sync::Arc;
23use std::time::Duration;
24
25use parking_lot::Mutex;
26use tokio::sync::oneshot;
27use tokio::task::JoinHandle;
28
29use khive_storage::event::EventAppendDisposition;
30use khive_storage::{Event, EventStore, StorageError, WriterTaskRequestState};
31
32/// Coarse durability classification for an [`AuditProducer`]. See
33/// [`classify`] for the exhaustive mapping.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub(crate) enum AuditProductionClass {
36    /// The dispatch this row audits carries an obligation: the caller-visible
37    /// outcome must not silently diverge from what was durably recorded.
38    DispatchObligation,
39    /// The row is a best-effort observability signal; a non-commit degrades
40    /// gracefully rather than blocking or failing the dispatch it audits.
41    PureObservability,
42}
43
44/// Every call site that can submit a row through [`AuditBatchControl`].
45/// Adding a variant here without extending the crate-private `classify`
46/// function's match is a compile error — there is no wildcard arm.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum AuditProducer {
49    /// The gate denied a dispatch; the denial itself is audited.
50    GateDenied,
51    /// The gate backend was unreachable and the dispatch failed closed; the
52    /// refusal is audited best-effort: the typed `GateUnavailable` refusal
53    /// already fails the dispatch, so a lost row degrades diagnostics only.
54    GateUnavailable,
55    /// A pack dispatch returned a successful result.
56    DispatchSucceeded,
57    /// A pack dispatch returned an error result.
58    DispatchFailed,
59    /// A redirected KG read consulted the gate on its effective entity id.
60    EffectiveTargetCheck,
61    /// The gate allowed a verb no pack owns.
62    UnknownVerb,
63    /// The strict `git.digest` success receipt (schema v2).
64    GitDigestReceipt,
65    /// A drained process-lifetime `OnceLock` config-lock row.
66    ConfigLocked,
67    /// A `memory.recall` execution's pure-observability audit row.
68    /// Submitted by the recall handler through its owning registry's batch.
69    RecallExecuted,
70}
71
72/// Classify an [`AuditProducer`] into its [`AuditProductionClass`]. One
73/// exhaustive match, no wildcard arm.
74pub(crate) const fn classify(producer: AuditProducer) -> AuditProductionClass {
75    match producer {
76        AuditProducer::GateDenied
77        | AuditProducer::DispatchSucceeded
78        | AuditProducer::DispatchFailed
79        | AuditProducer::EffectiveTargetCheck
80        | AuditProducer::UnknownVerb
81        | AuditProducer::GitDigestReceipt => AuditProductionClass::DispatchObligation,
82        AuditProducer::ConfigLocked
83        | AuditProducer::RecallExecuted
84        | AuditProducer::GateUnavailable => AuditProductionClass::PureObservability,
85    }
86}
87
88/// Exhaustive terminal reasons an [`AuditBatchControl::submit`],
89/// [`AuditBatchControl::quiesce`], or [`AuditBatchControl::close_and_drain`]
90/// call can resolve to. Never mapped through a wildcard arm anywhere in this
91/// module (R4).
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum AuditTerminalReason {
94    /// `EventStore::preflight_event` rejected the row before it was ever
95    /// enqueued. The row was never counted as submitted.
96    PreflightRejected,
97    /// The batch is `Closing` or `Closed`; new admission is refused.
98    AdmissionClosed,
99    /// `AuditBatchConfig::max_pending_rows` was reached before this row was
100    /// enqueued. The row was never counted as submitted and never shared a
101    /// generation with anyone — safe to retry, and doing so applies the
102    /// obligation at most once.
103    QueueAdmissionExhausted,
104    /// The row was already enqueued (counted in `submitted_rows`) when this
105    /// caller's `AuditBatchConfig::admission_deadline` elapsed waiting for
106    /// its generation's outcome. Unlike [`Self::QueueAdmissionExhausted`],
107    /// the row was not refused: by the moment the deadline fires it may
108    /// still be sitting in `state.pending`, or the driver may have already
109    /// drained it into an in-flight generation — either way it remains
110    /// enqueued and unresolved, and is committed (or terminally failed) by
111    /// the generation driver independently of this caller's timeout, so the
112    /// caller cannot tell from this reason alone whether the row eventually
113    /// committed, or even which of those two states it was in when the
114    /// deadline elapsed. Retrying is only safe for an idempotent caller —
115    /// the prior submission may still land. When this reason degrades an
116    /// admission-degrade-safe read's own audit obligation, it is counted
117    /// separately from [`Self::QueueAdmissionExhausted`] — see
118    /// `pack::audit_admission_unresolved_obligation_count` — precisely
119    /// because a row counted here may still commit, unlike one refused
120    /// before enqueue.
121    AdmissionDeadlineExpired,
122    /// Reached only through `AuditBatch::submit_until_resolved`: the row's
123    /// [`Self::AdmissionDeadlineExpired`] wait had already elapsed, and this
124    /// caller's own `AuditBatchConfig::resolution_deadline` then also
125    /// elapsed still waiting for the row's real generation outcome. The
126    /// caller reaches this only after its domain effect has already
127    /// committed, so the effect is never retried and the row is never
128    /// re-enqueued — but unlike an ordinary commit, the caller now learns
129    /// the effect committed while the audit outcome itself is unresolved.
130    /// Same as [`Self::AdmissionDeadlineExpired`], the row is left exactly
131    /// where the driver holds it (`state.pending`, or already mid-generation)
132    /// for the driver to resolve independently — this reason performs no
133    /// removal. Kept distinct from [`Self::AdmissionDeadlineExpired`] so a
134    /// caller, and diagnostics reading this reason, can tell a merely-slow
135    /// admission wait apart from a resolution wait that gave up entirely.
136    ResolutionDeadlineExpired,
137    /// A row shared this generation's id with a previously stored row whose
138    /// columns or observation projection did not match exactly.
139    IdentityConflict,
140    /// `classify_store_error` judged the store's error non-retryable, and the
141    /// generation stopped on that attempt. Usually that is the first attempt,
142    /// but not necessarily: a generation whose earlier attempts failed
143    /// retryably and whose next one returns a non-retryable error reports
144    /// this reason too, because the attempt that decided the outcome is the
145    /// non-retryable one. Distinct from [`Self::RetryExhausted`], which the
146    /// classifier judged safe to retry on every attempt and which failed
147    /// anyway once they ran out — this reason carries no such hope. Whatever
148    /// the store returned on the deciding attempt, it is not the kind of
149    /// failure `AuditBatchConfig::max_commit_attempts` exists to ride out,
150    /// so a caller or an automated retry policy reading this reason should
151    /// not schedule a bare retry of the same call and should instead treat
152    /// it as a storage fault needing attention.
153    StoreFailure,
154    /// The store returned a `classify_store_error`-retryable error on every
155    /// one of `AuditBatchConfig::max_commit_attempts` attempts for this
156    /// generation, and the last attempt still failed retryable. Kept
157    /// distinct from [`Self::StoreFailure`] — the same way
158    /// [`Self::ResolutionDeadlineExpired`] is kept distinct from
159    /// [`Self::AdmissionDeadlineExpired`] — so a caller, and diagnostics
160    /// reading this reason, can tell a store call the classifier judged
161    /// hopeless apart from one that kept failing a condition (write-queue or
162    /// writer-task pressure, pool or timeout) the classifier judged
163    /// transient. The underlying condition may still be
164    /// transient at the moment attempts run out (a daemon restart, pool
165    /// pressure outlasting the configured backoff), so an operator or an
166    /// automated retry policy sitting above this batch can choose to wait
167    /// longer and try again rather than treating it identically to
168    /// [`Self::StoreFailure`]. This reason changes no tolerance, deadline,
169    /// retry count, or backoff on its own — it only names which of the two
170    /// causes produced the generation's failure.
171    RetryExhausted,
172    /// The configured `EventStore` backend does not implement
173    /// `append_events_idempotent`.
174    IdempotencyUnsupported,
175    /// The generation driver task panicked, or the supervisor awaiting it
176    /// unwound while armed.
177    DriverPanicked,
178    /// The generation driver task was cancelled/aborted, or the supervisor
179    /// awaiting it was dropped mid-await (shutdown abort) while armed.
180    DriverCancelled,
181    /// The child driver's `JoinHandle` was lost — dropped without ever being
182    /// inspected — so its outcome could not be classified.
183    DriverJoinLost,
184    /// The driver returned `Ok` but locked batch state was not proved
185    /// terminally consistent afterward (`in_flight` still set, or the
186    /// generation's rows were never resolved).
187    DriverExitedInconsistent,
188    /// The driver's own bound on a single generation's
189    /// `EventStore::append_events_idempotent()` call
190    /// (`supervisor_loop`'s `driver_append_deadline`) elapsed while that
191    /// call was still in flight. Unlike [`Self::AdmissionDeadlineExpired`]
192    /// and [`Self::ResolutionDeadlineExpired`], which bound only how long a
193    /// *caller* keeps waiting while the row stays wherever the driver holds
194    /// it, this bounds the driver's own hold: every waiter on the abandoned
195    /// generation is resolved with this reason and the generation is
196    /// removed from the driver's in-flight state, so a stalled store call
197    /// cannot pin `pending` at `max_pending_rows` and starve all later
198    /// admission (khive#2331). The underlying append is not cancelled — it
199    /// may not be safely cancellable mid-flight — so it is left to run to
200    /// completion in the background and its eventual result, whatever it
201    /// is, is discarded; no waiter is still listening for it. The caller's
202    /// domain effect (if any already committed before this row was
203    /// enqueued) is never retried, and the row is never re-enqueued.
204    DriverAppendAbandoned,
205    /// Before spawning a generation's child, the driver found
206    /// `AuditBatchConfig::max_abandoned_appends` detached appends already
207    /// outstanding from prior [`Self::DriverAppendAbandoned`] generations
208    /// (khive#2331). The store is treated as wedged: this generation's rows
209    /// are shed without ever attempting an append — no child task is
210    /// spawned, no store call is made, and every waiter resolves with this
211    /// reason immediately, well inside `driver_append_deadline`. Same
212    /// non-retry contract as [`Self::DriverAppendAbandoned`]: any
213    /// already-committed domain effect is not retried and the row is never
214    /// re-enqueued, and pure-observability producers record degradation.
215    /// The driver reattempts an append on the next generation as soon as an
216    /// outstanding append returns (commit or failure) and the count drops
217    /// back below the cap — recovery needs no timer of its own.
218    StoreWedged,
219}
220
221/// One row accepted for batching: the immutable event identity plus the
222/// producer that minted it, used for classification and, on failure,
223/// degradation accounting.
224pub struct PreparedAuditRow {
225    pub event: Event,
226    pub producer: AuditProducer,
227}
228
229/// What [`AuditBatchControl::submit`] resolves to on a non-error outcome.
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum AuditCommitOutcome {
232    /// The row was freshly inserted.
233    Committed,
234    /// A prior row with the same identity already matched exactly (internal
235    /// retry replaying the same producer-minted identity).
236    AlreadyPresentIdentical,
237}
238
239/// Production-visible snapshot of [`AuditBatch::health_metrics`]. See there
240/// for field semantics. `khive_db::diagnostics::RuntimeAuditBatchMetrics` carries
241/// these three fields plus `admission_refused_obligations`,
242/// `retry_exhausted_obligations`, and `admission_unresolved_obligations`,
243/// which are sourced from process-wide
244/// counters outside `AuditBatch` rather than from this struct — see
245/// `VerbRegistry::audit_batch_metrics`.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub struct AuditBatchHealthMetrics {
248    pub flush_failures: u64,
249    pub degraded_rows: u64,
250    pub degraded: bool,
251    /// Detached [`AuditTerminalReason::DriverAppendAbandoned`] appends that
252    /// eventually returned a commit, after their generation had already
253    /// resolved every waiter with `DriverAppendAbandoned`. Lets an operator
254    /// see that a store recorded as wedged later drained.
255    pub late_append_commits: u64,
256    /// Detached [`AuditTerminalReason::DriverAppendAbandoned`] appends that
257    /// eventually returned a failure (store error, panic, or cancellation),
258    /// after their generation had already resolved every waiter with
259    /// `DriverAppendAbandoned`.
260    pub late_append_failures: u64,
261}
262
263/// Tunables for the batch seam. Defaults are conservative; every field is
264/// exercised by at least one mechanism test.
265#[derive(Debug, Clone)]
266pub struct AuditBatchConfig {
267    pub max_pending_rows: std::num::NonZeroUsize,
268    pub max_rows_per_generation: std::num::NonZeroUsize,
269    pub max_commit_attempts: std::num::NonZeroU8,
270    pub retry_backoff: Duration,
271    pub admission_deadline: Duration,
272    /// Caps how much longer `AuditBatch::submit_until_resolved` keeps
273    /// waiting on a row's real generation outcome once `admission_deadline`
274    /// has already elapsed on it. Without this bound a generation stuck on a
275    /// stalled `EventStore::append_events_idempotent` call retains the
276    /// completed write's caller, its request slot, and its audit-lane waiter
277    /// forever, exhausting both request and audit capacity (khive#2331).
278    /// Must be at least `admission_deadline` — validated (debug-only) in
279    /// [`AuditBatch::new`]. Defaults to 6x `admission_deadline`.
280    ///
281    /// Also the single source value the driver's own per-generation append
282    /// bound (`supervisor_loop`'s `driver_append_deadline`) is derived from
283    /// — that bound exists so a stalled append cannot keep the driver from
284    /// ever draining `pending` again, which is what actually exhausts
285    /// admission for every later caller, not just the one already waiting
286    /// on the stuck row. See [`AuditTerminalReason::DriverAppendAbandoned`].
287    pub resolution_deadline: Duration,
288    /// Caps how many [`AuditTerminalReason::DriverAppendAbandoned`] appends
289    /// may be outstanding (handed to a detached task, still running) at
290    /// once. Each outstanding append retains up to
291    /// `max_rows_per_generation` events until it finally returns, so the
292    /// retained-buffer bound this places on a wedged store is
293    /// `max_abandoned_appends * max_rows_per_generation` rows — without it,
294    /// a store whose append never returns mints one such task per
295    /// `driver_append_deadline` with no cap, and both the live task count
296    /// and the retained event batches inside them grow until the process
297    /// exits (khive#2331). Once the cap is reached, `supervisor_loop` sheds
298    /// further generations with [`AuditTerminalReason::StoreWedged`] instead
299    /// of attempting another append; it resumes attempting appends as soon
300    /// as an outstanding one returns and the count drops back below the
301    /// cap. Defaults to 4.
302    pub max_abandoned_appends: std::num::NonZeroUsize,
303}
304
305impl Default for AuditBatchConfig {
306    fn default() -> Self {
307        let admission_deadline = Duration::from_secs(5);
308        Self {
309            max_pending_rows: std::num::NonZeroUsize::new(4096).unwrap(),
310            max_rows_per_generation: std::num::NonZeroUsize::new(256).unwrap(),
311            max_commit_attempts: std::num::NonZeroU8::new(3).unwrap(),
312            retry_backoff: Duration::from_millis(20),
313            admission_deadline,
314            resolution_deadline: admission_deadline * 6,
315            max_abandoned_appends: std::num::NonZeroUsize::new(4).unwrap(),
316        }
317    }
318}
319
320#[async_trait::async_trait]
321pub trait AuditBatchControl: Send + Sync {
322    async fn submit(
323        &self,
324        row: PreparedAuditRow,
325    ) -> Result<AuditCommitOutcome, AuditTerminalReason>;
326    async fn quiesce(&self) -> Result<(), AuditTerminalReason>;
327    async fn close_and_drain(&self) -> Result<(), AuditTerminalReason>;
328}
329
330#[derive(Debug, Clone, PartialEq, Eq)]
331enum Lifecycle {
332    Open,
333    Closing,
334    Closed,
335    Failed(AuditTerminalReason),
336}
337
338struct Waiting {
339    event: Event,
340    producer: AuditProducer,
341    responder: oneshot::Sender<Result<AuditCommitOutcome, AuditTerminalReason>>,
342}
343
344/// A committed/failed generation's accounting, retained for the lifetime of
345/// the process (bounded in practice by process lifetime and generation
346/// volume; this slice makes no attempt to prune history).
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub struct AuditGenerationSnapshot {
349    pub generation_id: u64,
350    pub submitted_rows: u64,
351    pub committed_rows: u64,
352    pub store_batch_calls: u64,
353    pub terminal_reason: Option<AuditTerminalReason>,
354}
355
356struct State {
357    lifecycle: Lifecycle,
358    pending: Vec<Waiting>,
359    in_flight_generation: Option<u64>,
360    driver_active: bool,
361    next_generation_id: u64,
362    submitted_rows: u64,
363    committed_rows: u64,
364    store_batch_calls: u64,
365    generations: Vec<AuditGenerationSnapshot>,
366    flush_failures: u64,
367    degraded_rows: u64,
368    degraded: bool,
369    /// Detached `DriverAppendAbandoned` appends currently still running,
370    /// bounded by `AuditBatchConfig::max_abandoned_appends`. Incremented
371    /// when `supervisor_loop` abandons a generation and hands its child to a
372    /// detached task; decremented by that task once the child finally
373    /// returns.
374    outstanding_abandoned_appends: usize,
375    late_append_commits: u64,
376    late_append_failures: u64,
377}
378
379impl State {
380    fn new() -> Self {
381        Self {
382            lifecycle: Lifecycle::Open,
383            pending: Vec::new(),
384            in_flight_generation: None,
385            driver_active: false,
386            next_generation_id: 0,
387            submitted_rows: 0,
388            committed_rows: 0,
389            store_batch_calls: 0,
390            generations: Vec::new(),
391            flush_failures: 0,
392            degraded_rows: 0,
393            degraded: false,
394            outstanding_abandoned_appends: 0,
395            late_append_commits: 0,
396            late_append_failures: 0,
397        }
398    }
399
400    fn is_idle(&self) -> bool {
401        self.pending.is_empty() && self.in_flight_generation.is_none() && !self.driver_active
402    }
403}
404
405struct Inner {
406    state: Mutex<State>,
407}
408
409impl Inner {
410    /// Wins once: the first abnormal driver/supervisor exit sets `Failed`
411    /// and drains every accepted waiter (this generation's, plus anything
412    /// still queued for a future one) with the same typed reason. A later
413    /// abnormal path observes the existing terminal state and does not
414    /// double-count (R1).
415    fn fail_driver(&self, reason: AuditTerminalReason, in_flight_waiters: Vec<Waiting>) {
416        let mut state = self.state.lock();
417        let already_failed = matches!(state.lifecycle, Lifecycle::Failed(_));
418        if !already_failed {
419            state.lifecycle = Lifecycle::Failed(reason);
420        }
421        state.driver_active = false;
422        state.in_flight_generation = None;
423        let drained: Vec<Waiting> = std::mem::take(&mut state.pending);
424        if !already_failed {
425            state.flush_failures += 1;
426            state.degraded = true;
427            let generation_id = state.next_generation_id;
428            state.generations.push(AuditGenerationSnapshot {
429                generation_id,
430                submitted_rows: (in_flight_waiters.len() + drained.len()) as u64,
431                committed_rows: 0,
432                store_batch_calls: 0,
433                terminal_reason: Some(reason),
434            });
435            state.next_generation_id += 1;
436        }
437        drop(state);
438        for waiting in in_flight_waiters.into_iter().chain(drained) {
439            record_degradation_if_pure(self, waiting.producer);
440            let _ = waiting.responder.send(Err(reason));
441        }
442    }
443
444    fn record_degradation(&self) {
445        let mut state = self.state.lock();
446        state.degraded_rows += 1;
447        state.degraded = true;
448    }
449}
450
451fn record_degradation_if_pure(inner: &Inner, producer: AuditProducer) {
452    if classify(producer) == AuditProductionClass::PureObservability {
453        inner.record_degradation();
454    }
455}
456
457/// Armed for the lifetime of one generation's supervision. If dropped while
458/// still armed — the supervisor's own frame unwinding (panic) or being
459/// dropped mid-await (cancellation/shutdown abort) — the guard fails the
460/// generation before returning control to whatever tore it down, so the
461/// caller never observes a background-task-count restoration that raced
462/// ahead of the failure broadcast (R1).
463struct SupervisorGuard<'a> {
464    inner: &'a Arc<Inner>,
465    waiting: Option<Vec<Waiting>>,
466}
467
468impl<'a> SupervisorGuard<'a> {
469    fn armed(inner: &'a Arc<Inner>, waiting: Vec<Waiting>) -> Self {
470        Self {
471            inner,
472            waiting: Some(waiting),
473        }
474    }
475
476    /// Clean disarm: the caller has fully classified the outcome and taken
477    /// ownership of the waiters to resolve them itself.
478    fn disarm(mut self) -> Vec<Waiting> {
479        self.waiting.take().unwrap_or_default()
480    }
481
482    /// Explicit failure classification reached without unwinding/cancelling
483    /// this frame (e.g. a `JoinError` was returned normally). Equivalent to
484    /// what `Drop` does when armed, but callable inline.
485    fn fail(mut self, reason: AuditTerminalReason) {
486        if let Some(waiting) = self.waiting.take() {
487            self.inner.fail_driver(reason, waiting);
488        }
489    }
490}
491
492impl Drop for SupervisorGuard<'_> {
493    fn drop(&mut self) {
494        let Some(waiting) = self.waiting.take() else {
495            return;
496        };
497        let reason = if std::thread::panicking() {
498            AuditTerminalReason::DriverPanicked
499        } else {
500            AuditTerminalReason::DriverCancelled
501        };
502        self.inner.fail_driver(reason, waiting);
503    }
504}
505
506enum RetryDecision {
507    Retry,
508    Terminal(AuditTerminalReason),
509}
510
511fn classify_store_error(err: &StorageError) -> RetryDecision {
512    match err.without_sqlite_write_stage() {
513        StorageError::WriteQueueFull { .. } | StorageError::WriterTaskBusy { .. } => {
514            RetryDecision::Retry
515        }
516        StorageError::CapacityUnavailable {
517            phase: khive_storage::CapacityUnavailablePhase::LockContended,
518            ..
519        } => RetryDecision::Retry,
520        // Transient availability conditions, not judgments on the batch. The
521        // events-daemon forwarding lane (ADR-170) reports an unreachable or
522        // stalled daemon as `Pool`/`Timeout`; the direct SQL path reports
523        // acquisition pressure the same way. Both are exactly what the
524        // configured bounded retries exist for — treating them as terminal
525        // would abandon a generation on the first blip of a daemon restart.
526        StorageError::Pool { .. } | StorageError::Timeout { .. } => RetryDecision::Retry,
527        // The writer task wraps any request operation that failed and was rolled
528        // back, whatever the cause, so the wrapper alone does not say whether a
529        // retry can succeed: a missing column fails the same way on every
530        // attempt. Judge the cause it carries. A request left in an unknown
531        // state is replayed regardless, because the append is idempotent.
532        StorageError::WriterTaskRequestFailed {
533            request_state,
534            source,
535        } => match request_state {
536            WriterTaskRequestState::NotStarted | WriterTaskRequestState::TransactionRolledBack => {
537                if is_sqlite_busy_or_locked(source) {
538                    RetryDecision::Retry
539                } else {
540                    classify_store_error(source)
541                }
542            }
543            WriterTaskRequestState::SideEffectsUnknown => RetryDecision::Retry,
544        },
545        StorageError::WriterTaskTerminated { request_state, .. } => match request_state {
546            WriterTaskRequestState::NotStarted | WriterTaskRequestState::TransactionRolledBack => {
547                RetryDecision::Retry
548            }
549            WriterTaskRequestState::SideEffectsUnknown => RetryDecision::Retry,
550        },
551        StorageError::Unsupported { operation, .. }
552            if operation.as_ref() == "append_events_idempotent" =>
553        {
554            RetryDecision::Terminal(AuditTerminalReason::IdempotencyUnsupported)
555        }
556        _ => RetryDecision::Terminal(AuditTerminalReason::StoreFailure),
557    }
558}
559
560/// Whether a failed writer request's preserved cause is SQLite contention. The
561/// writer keeps the request body's driver error as the cause, and a driver
562/// error is not one of the typed transient variants `classify_store_error`
563/// retries on its own.
564fn is_sqlite_busy_or_locked(err: &StorageError) -> bool {
565    let StorageError::Driver { source, .. } = err.without_sqlite_write_stage() else {
566        return false;
567    };
568    matches!(
569        source
570            .downcast_ref::<rusqlite::Error>()
571            .and_then(|error| error.sqlite_error_code()),
572        Some(rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked)
573    )
574}
575
576enum GenerationResult {
577    Committed(Vec<EventAppendDisposition>),
578    Failed(AuditTerminalReason),
579    /// Fault-injection only: proves the supervisor's post-`Ok` consistency
580    /// check actually runs.
581    #[cfg_attr(not(any(test, feature = "fault-injection")), allow(dead_code))]
582    FakedInconsistent,
583}
584
585/// The driver's own bound on how long `supervisor_loop` waits for one
586/// generation's `run_generation` child task before abandoning it
587/// (khive#2331; see [`AuditTerminalReason::DriverAppendAbandoned`]).
588///
589/// Derived from `resolution_deadline` rather than a second config field, at
590/// 3x it. That margin is load-bearing, not arbitrary: `AuditBatch::new`
591/// already enforces `resolution_deadline >= admission_deadline`, so the
592/// worst-case *caller*-side wait for a `submit_until_resolved` row —
593/// `admission_deadline` then `resolution_deadline` in sequence — is always
594/// `<= 2 * resolution_deadline`. Bounding the driver at `3 *
595/// resolution_deadline` guarantees it can never resolve a still-waiting
596/// caller's row with `DriverAppendAbandoned` ahead of that caller's own,
597/// more specific `ResolutionDeadlineExpired` reason.
598///
599/// This bound alone only stops the driver from holding one stalled
600/// generation forever — a store whose append never returns still mints one
601/// detached append per `driver_append_deadline` with nothing capping how
602/// many run at once. `AuditBatchConfig::max_abandoned_appends` closes that:
603/// once that many detached appends are outstanding, further generations are
604/// shed with [`AuditTerminalReason::StoreWedged`] instead of attempting
605/// another append. Combined, the two bounds guarantee at most
606/// `max_abandoned_appends` live detached appends at any time, at most that
607/// many retained event batches, and a shed generation costs no store work
608/// at all — it resolves inside this function's caller without ever calling
609/// `run_generation`.
610fn driver_append_deadline(config: &AuditBatchConfig) -> Duration {
611    config.resolution_deadline.saturating_mul(3)
612}
613
614async fn run_generation(
615    store: Arc<dyn EventStore>,
616    events: Vec<Event>,
617    config: Arc<AuditBatchConfig>,
618) -> GenerationResult {
619    #[cfg(any(test, feature = "fault-injection"))]
620    if fault::CHILD_PANIC.swap(false, Ordering::SeqCst) {
621        panic!("adr133 fault injection: audit_batch child_panic");
622    }
623    #[cfg(any(test, feature = "fault-injection"))]
624    if fault::INCONSISTENT_EXIT.swap(false, Ordering::SeqCst) {
625        return GenerationResult::FakedInconsistent;
626    }
627
628    let mut attempt: u8 = 0;
629    loop {
630        attempt += 1;
631        match store.append_events_idempotent(events.clone()).await {
632            Ok(result) => return GenerationResult::Committed(result.rows),
633            Err(err) => {
634                let reason = match classify_store_error(&err) {
635                    RetryDecision::Retry if attempt < config.max_commit_attempts.get() => {
636                        tokio::time::sleep(config.retry_backoff).await;
637                        continue;
638                    }
639                    RetryDecision::Retry => AuditTerminalReason::RetryExhausted,
640                    RetryDecision::Terminal(reason) => reason,
641                };
642                tracing::warn!(
643                    error = %err,
644                    attempts = attempt,
645                    ?reason,
646                    "audit generation failed; its rows were not committed"
647                );
648                return GenerationResult::Failed(reason);
649            }
650        }
651    }
652}
653
654/// The batch owner. Constructed once per configured `EventStore`; every
655/// dispatch-audit call site routes its row through [`AuditBatch::submit`]
656/// instead of taking its own writer-task acquisition.
657pub struct AuditBatch {
658    inner: Arc<Inner>,
659    store: Arc<dyn EventStore>,
660    config: Arc<AuditBatchConfig>,
661    supervisor: Mutex<Option<JoinHandle<()>>>,
662}
663
664impl AuditBatch {
665    pub fn new(store: Arc<dyn EventStore>, config: AuditBatchConfig) -> Arc<Self> {
666        debug_assert!(
667            config.resolution_deadline >= config.admission_deadline,
668            "resolution_deadline ({:?}) must be at least admission_deadline ({:?})",
669            config.resolution_deadline,
670            config.admission_deadline
671        );
672        Arc::new(Self {
673            inner: Arc::new(Inner {
674                state: Mutex::new(State::new()),
675            }),
676            store,
677            config: Arc::new(config),
678            supervisor: Mutex::new(None),
679        })
680    }
681
682    /// Process-lifetime audit-batch health counters, for the registry/
683    /// runtime owner to feed into `db_diagnostics` (D8's operator surface).
684    /// Unlike `test_internals::AuditBatchSnapshot::metrics_snapshot`,
685    /// which is test-only (the module is cfg-gated and invisible to the
686    /// default doc build, so an intra-doc link cannot resolve), this is
687    /// always available.
688    pub fn health_metrics(&self) -> AuditBatchHealthMetrics {
689        let state = self.inner.state.lock();
690        AuditBatchHealthMetrics {
691            flush_failures: state.flush_failures,
692            degraded_rows: state.degraded_rows,
693            degraded: state.degraded,
694            late_append_commits: state.late_append_commits,
695            late_append_failures: state.late_append_failures,
696        }
697    }
698
699    fn spawn_supervisor_if_idle(&self) {
700        let inner = self.inner.clone();
701        let store = self.store.clone();
702        let config = self.config.clone();
703        let handle = tokio::spawn(async move {
704            supervisor_loop(inner, store, config).await;
705        });
706        *self.supervisor.lock() = Some(handle);
707    }
708
709    /// Enqueue one row and keep waiting for its real generation outcome past
710    /// the ordinary admission wait deadline, up to `resolution_deadline`.
711    ///
712    /// This is the narrow khive#2256 seam for a successful operation whose
713    /// domain effect has already committed. Returning
714    /// [`AuditTerminalReason::AdmissionDeadlineExpired`] there would report a
715    /// false operation failure and invite an unsafe retry while the same
716    /// audit row remains enqueued. Pre-enqueue refusal and genuine terminal
717    /// generation failures still return normally. The post-admission wait is
718    /// itself bounded by `resolution_deadline`
719    /// ([`AuditTerminalReason::ResolutionDeadlineExpired`]) so a stalled
720    /// store cannot retain this caller, its request slot, and its audit-lane
721    /// waiter forever (khive#2331).
722    ///
723    /// The two deadlines therefore mean different things to the caller, and
724    /// callers key on the difference. Admission expiry means the row is
725    /// enqueued and this seam keeps waiting; the generation commits it
726    /// independently, so a dispatch that reaches it reports its committed
727    /// result and counts the row as unresolved. Resolution expiry means the
728    /// caller waited for the real outcome and never received one: the commit
729    /// is unconfirmed, not proven absent. That is returned as a terminal
730    /// reason and the dispatch propagates it as a structured
731    /// committed-outcome error carrying the domain result, with no retryable
732    /// context, for every verb rather than only for the receipt verb. Never
733    /// replay the handler or re-enqueue the row on it; the original driver
734    /// work continues on its own.
735    pub(crate) async fn submit_until_resolved(
736        &self,
737        row: PreparedAuditRow,
738    ) -> Result<AuditCommitOutcome, AuditTerminalReason> {
739        self.submit_with_wait_policy(row, true).await
740    }
741
742    async fn submit_with_wait_policy(
743        &self,
744        row: PreparedAuditRow,
745        wait_until_resolved: bool,
746    ) -> Result<AuditCommitOutcome, AuditTerminalReason> {
747        // Pre-enqueue validation (invariant 3): a malformed row is rejected
748        // before it can share a generation with anyone else's.
749        if self.store.preflight_event(&row.event).is_err() {
750            return Err(AuditTerminalReason::PreflightRejected);
751        }
752
753        let producer = row.producer;
754        let (tx, mut rx) = oneshot::channel();
755        let need_spawn = {
756            let mut state = self.inner.state.lock();
757            match state.lifecycle {
758                Lifecycle::Closed | Lifecycle::Closing => {
759                    return Err(AuditTerminalReason::AdmissionClosed)
760                }
761                Lifecycle::Failed(reason) => return Err(reason),
762                Lifecycle::Open => {}
763            }
764            if state.pending.len() >= self.config.max_pending_rows.get() {
765                return Err(AuditTerminalReason::QueueAdmissionExhausted);
766            }
767            state.pending.push(Waiting {
768                event: row.event,
769                producer,
770                responder: tx,
771            });
772            state.submitted_rows += 1;
773            let need_spawn = !state.driver_active;
774            if need_spawn {
775                state.driver_active = true;
776            }
777            need_spawn
778        };
779        if need_spawn {
780            self.spawn_supervisor_if_idle();
781        }
782
783        if wait_until_resolved {
784            match tokio::time::timeout(self.config.admission_deadline, &mut rx).await {
785                Ok(Ok(result)) => return result,
786                Ok(Err(_recv_error)) => return Err(AuditTerminalReason::DriverJoinLost),
787                Err(_elapsed) => tracing::warn!(
788                    ?producer,
789                    "strict audit obligation remains enqueued after the admission wait deadline; \
790                     waiting up to the resolution deadline for its real terminal outcome"
791                ),
792            }
793            return match tokio::time::timeout(self.config.resolution_deadline, rx).await {
794                Ok(Ok(result)) => result,
795                Ok(Err(_recv_error)) => Err(AuditTerminalReason::DriverJoinLost),
796                // Same non-removal contract as the `AdmissionDeadlineExpired`
797                // arm below: the row is left exactly where the driver holds
798                // it. The caller's domain effect already committed by the
799                // time it reached this wait, so it is never retried here;
800                // the audit outcome itself is what remains unresolved.
801                Err(_elapsed) => {
802                    tracing::warn!(
803                        ?producer,
804                        "strict audit obligation remains unresolved after the resolution \
805                         deadline; giving up on this caller's wait — the committed effect is \
806                         not retried and the row is not re-enqueued"
807                    );
808                    Err(AuditTerminalReason::ResolutionDeadlineExpired)
809                }
810            };
811        }
812
813        match tokio::time::timeout(self.config.admission_deadline, rx).await {
814            Ok(Ok(result)) => result,
815            Ok(Err(_recv_error)) => Err(AuditTerminalReason::DriverJoinLost),
816            // The row was already pushed onto `state.pending` above (and
817            // `submitted_rows` incremented) before this wait began — this is
818            // a deadline elapsing on an enqueued row, not a queue-full
819            // refusal, so it gets its own terminal reason (khive#2117,
820            // khive#2208). The row is left in place for the driver to drain;
821            // this arm performs no removal.
822            Err(_elapsed) => Err(AuditTerminalReason::AdmissionDeadlineExpired),
823        }
824    }
825}
826
827async fn supervisor_loop(
828    inner: Arc<Inner>,
829    store: Arc<dyn EventStore>,
830    config: Arc<AuditBatchConfig>,
831) {
832    loop {
833        let (waiting, wedged_generation_id) = {
834            let mut state = inner.state.lock();
835            if matches!(state.lifecycle, Lifecycle::Failed(_)) {
836                state.driver_active = false;
837                break;
838            }
839            if state.pending.is_empty() {
840                state.driver_active = false;
841                break;
842            }
843            let take_n = state
844                .pending
845                .len()
846                .min(config.max_rows_per_generation.get());
847            let waiting: Vec<Waiting> = state.pending.drain(..take_n).collect();
848            let generation_id = state.next_generation_id;
849            state.next_generation_id += 1;
850            // khive#2331: a store whose append never returns must not mint
851            // an unbounded number of detached appends. Before committing to
852            // spawning this generation's child, check whether the cap is
853            // already saturated by prior abandoned generations still
854            // running in the background — if so, this generation is shed
855            // below instead of ever calling the store.
856            if state.outstanding_abandoned_appends >= config.max_abandoned_appends.get() {
857                (waiting, Some(generation_id))
858            } else {
859                state.in_flight_generation = Some(generation_id);
860                (waiting, None)
861            }
862        };
863
864        if let Some(generation_id) = wedged_generation_id {
865            tracing::warn!(
866                generation_id,
867                max_abandoned_appends = config.max_abandoned_appends.get(),
868                "audit store treated as wedged: max_abandoned_appends detached appends are \
869                 already outstanding; shedding this generation without attempting an append"
870            );
871            let submitted = waiting.len() as u64;
872            {
873                let mut state = inner.state.lock();
874                state.flush_failures += 1;
875                state.generations.push(AuditGenerationSnapshot {
876                    generation_id,
877                    submitted_rows: submitted,
878                    committed_rows: 0,
879                    store_batch_calls: 0,
880                    terminal_reason: Some(AuditTerminalReason::StoreWedged),
881                });
882            }
883            for w in waiting {
884                record_degradation_if_pure(&inner, w.producer);
885                let _ = w.responder.send(Err(AuditTerminalReason::StoreWedged));
886            }
887            continue;
888        }
889
890        #[cfg(any(test, feature = "fault-injection"))]
891        if fault::SUPERVISOR_PANIC.swap(false, Ordering::SeqCst) {
892            let _guard = SupervisorGuard::armed(&inner, waiting);
893            panic!("adr133 fault injection: audit_batch supervisor_panic");
894        }
895        #[cfg(any(test, feature = "fault-injection"))]
896        if fault::SUPERVISOR_SLEEP_BEFORE_SPAWN.swap(false, Ordering::SeqCst) {
897            let guard = SupervisorGuard::armed(&inner, waiting);
898            tokio::time::sleep(Duration::from_secs(3600)).await;
899            drop(guard);
900            continue;
901        }
902
903        let guard = SupervisorGuard::armed(&inner, waiting);
904        let events: Vec<Event> = guard
905            .waiting
906            .as_ref()
907            .expect("guard freshly armed")
908            .iter()
909            .map(|w| w.event.clone())
910            .collect();
911
912        let mut child: JoinHandle<GenerationResult> =
913            tokio::spawn(run_generation(store.clone(), events, config.clone()));
914
915        #[cfg(any(test, feature = "fault-injection"))]
916        if fault::CHILD_CANCEL.swap(false, Ordering::SeqCst) {
917            child.abort();
918        }
919        #[cfg(any(test, feature = "fault-injection"))]
920        if fault::JOIN_LOST.swap(false, Ordering::SeqCst) {
921            drop(child);
922            guard.fail(AuditTerminalReason::DriverJoinLost);
923            continue;
924        }
925
926        let append_deadline = driver_append_deadline(&config);
927        let join_result = match tokio::time::timeout(append_deadline, &mut child).await {
928            Ok(join_result) => join_result,
929            Err(_elapsed) => {
930                // The append is still in flight and may not be safely
931                // cancellable mid-write (a blocking storage call cannot be
932                // forced to stop), so `child` is not aborted here — it is
933                // handed to a detached reaper that drives it to completion
934                // and discards whatever it eventually returns. Every waiter
935                // on this generation is resolved now, and the driver loops
936                // back to `pending` immediately instead of staying pinned on
937                // this one stalled call, which is what actually starves
938                // later admission (khive#2331).
939                tracing::warn!(
940                    ?append_deadline,
941                    "audit generation append exceeded the driver's own bound; abandoning \
942                     this generation so admission capacity recovers for later callers"
943                );
944                let waiting = guard.disarm();
945                let submitted = waiting.len() as u64;
946                {
947                    let mut state = inner.state.lock();
948                    state.in_flight_generation = None;
949                    state.flush_failures += 1;
950                    state.outstanding_abandoned_appends += 1;
951                    let generation_id = state.next_generation_id.saturating_sub(1);
952                    state.generations.push(AuditGenerationSnapshot {
953                        generation_id,
954                        submitted_rows: submitted,
955                        committed_rows: 0,
956                        store_batch_calls: 0,
957                        terminal_reason: Some(AuditTerminalReason::DriverAppendAbandoned),
958                    });
959                }
960                for w in waiting {
961                    record_degradation_if_pure(&inner, w.producer);
962                    let _ = w
963                        .responder
964                        .send(Err(AuditTerminalReason::DriverAppendAbandoned));
965                }
966                let reaper_inner = inner.clone();
967                tokio::spawn(async move {
968                    let result = child.await;
969                    let mut state = reaper_inner.state.lock();
970                    state.outstanding_abandoned_appends =
971                        state.outstanding_abandoned_appends.saturating_sub(1);
972                    match result {
973                        Ok(GenerationResult::Committed(_)) => state.late_append_commits += 1,
974                        Ok(GenerationResult::Failed(_) | GenerationResult::FakedInconsistent)
975                        | Err(_) => state.late_append_failures += 1,
976                    }
977                });
978                continue;
979            }
980        };
981        match join_result {
982            Err(join_err) => {
983                let reason = if join_err.is_panic() {
984                    AuditTerminalReason::DriverPanicked
985                } else {
986                    AuditTerminalReason::DriverCancelled
987                };
988                guard.fail(reason);
989            }
990            Ok(GenerationResult::FakedInconsistent) => {
991                guard.fail(AuditTerminalReason::DriverExitedInconsistent);
992            }
993            Ok(GenerationResult::Failed(reason)) => {
994                let waiting = guard.disarm();
995                let submitted = waiting.len() as u64;
996                {
997                    let mut state = inner.state.lock();
998                    state.in_flight_generation = None;
999                    state.store_batch_calls += 1;
1000                    state.flush_failures += 1;
1001                    state.degraded = true;
1002                    let generation_id = state.next_generation_id.saturating_sub(1);
1003                    state.generations.push(AuditGenerationSnapshot {
1004                        generation_id,
1005                        submitted_rows: submitted,
1006                        committed_rows: 0,
1007                        store_batch_calls: 1,
1008                        terminal_reason: Some(reason),
1009                    });
1010                }
1011                for w in waiting {
1012                    record_degradation_if_pure(&inner, w.producer);
1013                    let _ = w.responder.send(Err(reason));
1014                }
1015            }
1016            Ok(GenerationResult::Committed(dispositions)) => {
1017                let waiting = guard.disarm();
1018                if dispositions.len() != waiting.len() {
1019                    // Defensive: the store must preserve input order/length.
1020                    // Treat a mismatch as the driver having exited in a
1021                    // state that cannot be reconciled with the accepted
1022                    // waiters.
1023                    {
1024                        let mut state = inner.state.lock();
1025                        state.in_flight_generation = None;
1026                    }
1027                    inner.fail_driver(AuditTerminalReason::DriverExitedInconsistent, waiting);
1028                    continue;
1029                }
1030                let mut committed_n = 0u64;
1031                for d in &dispositions {
1032                    if !matches!(d, EventAppendDisposition::IdentityConflict) {
1033                        committed_n += 1;
1034                    }
1035                }
1036                let submitted = dispositions.len() as u64;
1037                {
1038                    let mut state = inner.state.lock();
1039                    state.in_flight_generation = None;
1040                    state.store_batch_calls += 1;
1041                    state.committed_rows += committed_n;
1042                    let generation_id = state.next_generation_id.saturating_sub(1);
1043                    state.generations.push(AuditGenerationSnapshot {
1044                        generation_id,
1045                        submitted_rows: submitted,
1046                        committed_rows: committed_n,
1047                        store_batch_calls: 1,
1048                        terminal_reason: None,
1049                    });
1050                }
1051                for (w, disposition) in waiting.into_iter().zip(dispositions) {
1052                    let result = match disposition {
1053                        EventAppendDisposition::Inserted => Ok(AuditCommitOutcome::Committed),
1054                        EventAppendDisposition::AlreadyPresentIdentical => {
1055                            Ok(AuditCommitOutcome::AlreadyPresentIdentical)
1056                        }
1057                        EventAppendDisposition::IdentityConflict => {
1058                            record_degradation_if_pure(&inner, w.producer);
1059                            Err(AuditTerminalReason::IdentityConflict)
1060                        }
1061                    };
1062                    let _ = w.responder.send(result);
1063                }
1064            }
1065        }
1066    }
1067}
1068
1069#[async_trait::async_trait]
1070impl AuditBatchControl for AuditBatch {
1071    async fn submit(
1072        &self,
1073        row: PreparedAuditRow,
1074    ) -> Result<AuditCommitOutcome, AuditTerminalReason> {
1075        self.submit_with_wait_policy(row, false).await
1076    }
1077
1078    async fn quiesce(&self) -> Result<(), AuditTerminalReason> {
1079        loop {
1080            let (lifecycle, idle) = {
1081                let state = self.inner.state.lock();
1082                (state.lifecycle.clone(), state.is_idle())
1083            };
1084            match lifecycle {
1085                Lifecycle::Failed(reason) => return Err(reason),
1086                Lifecycle::Closed => return Ok(()),
1087                _ if idle => return Ok(()),
1088                _ => tokio::time::sleep(Duration::from_millis(2)).await,
1089            }
1090        }
1091    }
1092
1093    async fn close_and_drain(&self) -> Result<(), AuditTerminalReason> {
1094        {
1095            let mut state = self.inner.state.lock();
1096            if matches!(state.lifecycle, Lifecycle::Open) {
1097                state.lifecycle = Lifecycle::Closing;
1098            }
1099        }
1100        let result = AuditBatchControl::quiesce(self).await;
1101        {
1102            let mut state = self.inner.state.lock();
1103            if result.is_ok() && matches!(state.lifecycle, Lifecycle::Closing) {
1104                state.lifecycle = Lifecycle::Closed;
1105            }
1106        }
1107        let handle = self.supervisor.lock().take();
1108        if let Some(handle) = handle {
1109            let _ = handle.await;
1110        }
1111        result
1112    }
1113}
1114
1115#[cfg(any(test, feature = "fault-injection"))]
1116mod fault {
1117    use std::sync::atomic::AtomicBool;
1118
1119    pub(super) static CHILD_PANIC: AtomicBool = AtomicBool::new(false);
1120    pub(super) static CHILD_CANCEL: AtomicBool = AtomicBool::new(false);
1121    pub(super) static SUPERVISOR_PANIC: AtomicBool = AtomicBool::new(false);
1122    pub(super) static SUPERVISOR_SLEEP_BEFORE_SPAWN: AtomicBool = AtomicBool::new(false);
1123    pub(super) static JOIN_LOST: AtomicBool = AtomicBool::new(false);
1124    pub(super) static INCONSISTENT_EXIT: AtomicBool = AtomicBool::new(false);
1125}
1126
1127/// Deterministic fault-injection arms consumed by exactly one subsequent
1128/// generation each. Test-only surface, gated identically to the rest of this
1129/// crate's `fault-injection` fixtures (see `crate::operations`).
1130#[cfg(any(test, feature = "fault-injection"))]
1131pub mod fault_injection {
1132    use std::sync::atomic::Ordering;
1133
1134    pub fn arm_child_panic() {
1135        super::fault::CHILD_PANIC.store(true, Ordering::SeqCst);
1136    }
1137    pub fn arm_child_cancel() {
1138        super::fault::CHILD_CANCEL.store(true, Ordering::SeqCst);
1139    }
1140    pub fn arm_supervisor_panic() {
1141        super::fault::SUPERVISOR_PANIC.store(true, Ordering::SeqCst);
1142    }
1143    pub fn arm_supervisor_sleep_before_spawn() {
1144        super::fault::SUPERVISOR_SLEEP_BEFORE_SPAWN.store(true, Ordering::SeqCst);
1145    }
1146    pub fn arm_join_lost() {
1147        super::fault::JOIN_LOST.store(true, Ordering::SeqCst);
1148    }
1149    pub fn arm_inconsistent_exit() {
1150        super::fault::INCONSISTENT_EXIT.store(true, Ordering::SeqCst);
1151    }
1152}
1153
1154// ── Test-attribution surface (R2) ────────────────────────────────────────
1155//
1156// Additive only: production attribution still lands in the public
1157// `db_diagnostics` counters via `metrics_snapshot`-shaped data supplied at
1158// that seam. This surface never substitutes for it.
1159#[cfg(any(test, feature = "test-internals"))]
1160mod test_internals {
1161    use super::*;
1162
1163    /// A point-in-time view of the batch's counters and generation history,
1164    /// used by mechanism tests to compute a delta across a measured
1165    /// operation.
1166    #[derive(Debug, Clone)]
1167    pub struct AuditBatchSnapshot {
1168        pub pending_rows: usize,
1169        pub in_flight_generation: Option<u64>,
1170        pub driver_active: bool,
1171        pub next_generation_id: u64,
1172        pub submitted_rows: u64,
1173        pub committed_rows: u64,
1174        pub store_batch_calls: u64,
1175        pub per_generation: Vec<AuditGenerationSnapshot>,
1176        /// Live count of detached `DriverAppendAbandoned` appends still
1177        /// running, bounded by `AuditBatchConfig::max_abandoned_appends`.
1178        pub outstanding_abandoned_appends: usize,
1179    }
1180
1181    impl AuditBatchSnapshot {
1182        pub fn is_idle(&self) -> bool {
1183            self.pending_rows == 0 && self.in_flight_generation.is_none() && !self.driver_active
1184        }
1185    }
1186
1187    #[derive(Debug, Clone, Copy)]
1188    pub struct AuditBatchMetricsSnapshot {
1189        pub flush_failures: u64,
1190        pub degraded_rows: u64,
1191        pub degraded: bool,
1192        pub late_append_commits: u64,
1193        pub late_append_failures: u64,
1194    }
1195
1196    #[derive(Debug, Clone, PartialEq)]
1197    pub struct AuditBatchDelta {
1198        pub submitted_rows: u64,
1199        pub committed_rows: u64,
1200        pub store_batch_calls: u64,
1201        pub per_generation: Vec<AuditGenerationSnapshot>,
1202    }
1203
1204    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1205    pub enum AuditSnapshotError {
1206        CounterRegressed,
1207        GenerationHistoryRegressed,
1208    }
1209
1210    impl AuditBatch {
1211        pub fn test_snapshot(&self) -> AuditBatchSnapshot {
1212            let state = self.inner.state.lock();
1213            AuditBatchSnapshot {
1214                pending_rows: state.pending.len(),
1215                in_flight_generation: state.in_flight_generation,
1216                driver_active: state.driver_active,
1217                next_generation_id: state.next_generation_id,
1218                submitted_rows: state.submitted_rows,
1219                committed_rows: state.committed_rows,
1220                store_batch_calls: state.store_batch_calls,
1221                per_generation: state.generations.clone(),
1222                outstanding_abandoned_appends: state.outstanding_abandoned_appends,
1223            }
1224        }
1225
1226        pub fn metrics_snapshot(&self) -> AuditBatchMetricsSnapshot {
1227            let state = self.inner.state.lock();
1228            AuditBatchMetricsSnapshot {
1229                flush_failures: state.flush_failures,
1230                degraded_rows: state.degraded_rows,
1231                degraded: state.degraded,
1232                late_append_commits: state.late_append_commits,
1233                late_append_failures: state.late_append_failures,
1234            }
1235        }
1236
1237        /// Abort the currently-retained supervisor `JoinHandle`, if any,
1238        /// simulating a shutdown abort landing mid-generation. Returns
1239        /// whether a handle was found and aborted. Test-only: exercises the
1240        /// R1 supervisor-cancellation path without reaching into a private
1241        /// field from outside this module.
1242        pub fn test_abort_supervisor(&self) -> bool {
1243            let handle = self.supervisor.lock().take();
1244            match handle {
1245                Some(handle) => {
1246                    handle.abort();
1247                    true
1248                }
1249                None => false,
1250            }
1251        }
1252    }
1253
1254    /// Checked monotonic subtraction. Rejects a regressed counter or a
1255    /// generation history that is not an append-only extension of `before`.
1256    pub fn audit_delta(
1257        before: &AuditBatchSnapshot,
1258        after: &AuditBatchSnapshot,
1259    ) -> Result<AuditBatchDelta, AuditSnapshotError> {
1260        let submitted_rows = after
1261            .submitted_rows
1262            .checked_sub(before.submitted_rows)
1263            .ok_or(AuditSnapshotError::CounterRegressed)?;
1264        let committed_rows = after
1265            .committed_rows
1266            .checked_sub(before.committed_rows)
1267            .ok_or(AuditSnapshotError::CounterRegressed)?;
1268        let store_batch_calls = after
1269            .store_batch_calls
1270            .checked_sub(before.store_batch_calls)
1271            .ok_or(AuditSnapshotError::CounterRegressed)?;
1272        if after.per_generation.len() < before.per_generation.len() {
1273            return Err(AuditSnapshotError::GenerationHistoryRegressed);
1274        }
1275        if after.per_generation[..before.per_generation.len()] != before.per_generation[..] {
1276            return Err(AuditSnapshotError::GenerationHistoryRegressed);
1277        }
1278        let per_generation = after.per_generation[before.per_generation.len()..].to_vec();
1279        Ok(AuditBatchDelta {
1280            submitted_rows,
1281            committed_rows,
1282            store_batch_calls,
1283            per_generation,
1284        })
1285    }
1286
1287    /// Exhaustive, non-wildcard producer classification (see
1288    /// [`super::AuditProducer`] and [`super::classify`], the real
1289    /// crate-private definitions this mirrors one-for-one). The doctest
1290    /// below proves the general property those definitions rely on: a match
1291    /// over a non-`#[non_exhaustive]` enum that omits a variant, with no
1292    /// wildcard arm to silently absorb it, fails to compile rather than
1293    /// passing an incomplete classification.
1294    ///
1295    /// ```compile_fail
1296    /// enum AuditProducer {
1297    ///     GateDenied,
1298    ///     DispatchSucceeded,
1299    ///     DispatchFailed,
1300    ///     UnknownVerb,
1301    ///     GitDigestReceipt,
1302    ///     ConfigLocked,
1303    ///     RecallExecuted,
1304    /// }
1305    ///
1306    /// fn describe(p: AuditProducer) -> &'static str {
1307    ///     match p {
1308    ///         AuditProducer::GateDenied => "obligation",
1309    ///         AuditProducer::DispatchSucceeded => "obligation",
1310    ///         AuditProducer::DispatchFailed => "obligation",
1311    ///         AuditProducer::UnknownVerb => "obligation",
1312    ///         AuditProducer::GitDigestReceipt => "obligation",
1313    ///         AuditProducer::ConfigLocked => "observability",
1314    ///         // RecallExecuted intentionally omitted: a non-exhaustive
1315    ///         // match must fail to compile, proving no variant can
1316    ///         // silently fall through an absent wildcard arm.
1317    ///     }
1318    /// }
1319    /// ```
1320    #[allow(dead_code)]
1321    struct DoctestAnchor;
1322}
1323
1324#[cfg(any(test, feature = "test-internals"))]
1325pub use test_internals::*;