Skip to main content

av_harness/
pipeline.rs

1//! Ordered hot-path middleware and upstream forwarding.
2
3use crate::config::HarnessConfig;
4use crate::reconciler::Finalizer;
5use crate::session::{Session, SessionLease, SessionRegistry, Workflow};
6use crate::worker::{AtifCapture, ResponsePermit, WorkerHandle, WorkerJob};
7use av_bridge::EventBus;
8use av_core::metrics::Registry;
9use av_core::time::elapsed_us;
10use av_events::{AgentIdentity, EventClass, EventMetrics, StatusId, StopReason};
11use av_identity::IdentityValidator;
12use av_loopdetect::{BreakerAction, BreakerState, Embedder, HashEmbedder, NoopVectorSink, VectorSink};
13use av_receipts::Signer;
14use av_sandbox::{Sandbox, ToolVerdict};
15use av_state::{ActionBudget, BudgetDecision, StateStore};
16use axum::http::{HeaderMap, HeaderName, HeaderValue};
17use serde_json::Value;
18use std::sync::Arc;
19use std::time::Instant;
20
21/// Shared HELP text for the supervised-loop counters that are
22/// registered TWICE: eagerly at boot (this module — so `rate() > 0`
23/// alerts can see the FIRST fire) and lazily at the observation site
24/// (main.rs / reconciler.rs panic arms). The metrics registry is
25/// first-wins on HELP and warns on divergence in debug builds; these
26/// six pairs had drifted into permanent per-tick warn spam (R285).
27/// One const per metric, referenced from both sites.
28pub mod supervision_help {
29    /// HELP for `av_reconciler_panics_total` (boot + panic-arm sites).
30    pub const RECONCILER_PANICS: &str = "Reconciler tick body panicked and was caught; the reconciler \
31         continues on the next tick";
32    /// HELP for `av_bridge_maintenance_panics_total` (boot + panic-arm sites).
33    pub const BRIDGE_MAINTENANCE_PANICS: &str = "Bridge maintenance tick panicked and was caught; the loop \
34         continues on the next interval";
35    /// HELP for `av_bridge_maintenance_errors_total` (boot + error-arm sites).
36    pub const BRIDGE_MAINTENANCE_ERRORS: &str =
37        "Bridge maintenance tick returned an error; the loop continues \
38         on the next interval";
39    /// HELP for `av_bridge_maintenance_join_errors_total` (boot + join-error sites).
40    pub const BRIDGE_MAINTENANCE_JOIN_ERRORS: &str =
41        "Bridge maintenance tick's spawned task failed to join (panic \
42         or cancellation); the loop continues on the next interval";
43    /// HELP for `av_atif_retention_panics_total` (boot + panic-arm sites).
44    pub const ATIF_RETENTION_PANICS: &str = "ATIF retention sweep tick panicked and was caught; the loop \
45         continues on the next hour";
46    /// HELP for `av_atif_retention_errors_total` (boot + error-arm sites).
47    pub const ATIF_RETENTION_ERRORS: &str =
48        "ATIF retention sweep tick returned an error; the loop continues \
49         on the next hour";
50}
51
52pub(crate) const SESSION_HEADER: &str = "x-av-session";
53const WORKFLOW_HEADER: &str = "x-av-workflow";
54pub(crate) const MIDDLEWARE_US_HEADER: &str = "x-av-middleware-us";
55
56/// TCP connect timeout for every outbound HTTPS client the harness builds.
57pub const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
58
59/// Shared HELP for `av_shutdown_session_close_timeouts_total`, registered
60/// at boot (pre-registration in the pipeline's metric block) and
61/// looked up again on the shutdown path (`main.rs` finalize loop). The
62/// registry is first-wins on HELP and warns on drift — a single const is
63/// the only arrangement that cannot drift.
64pub const SESSION_CLOSE_TIMEOUTS_HELP: &str =
65    "Per-session close hit the shutdown-time per-session deadline (3 s) and \
66     was deferred to restart-time spool recovery. A sustained rate > 0 on \
67     every rollout indicates a class of sessions that regularly hang their \
68     close (leaked leases, dropped worker permits, unresponsive bridge \
69     publish) — the coincident session id in the shutdown warn log is the \
70     correlation key. Distinct from `av_http_shutdown_drain_timeouts_total` \
71     which fires on the OUTER phase timeout.";
72
73/// Shared HELP for `av_shutdown_mcp_drain_timeouts_total` (boot
74/// pre-registration and shutdown-path lookup in `main.rs`). Same
75/// first-wins/no-drift rationale as [`SESSION_CLOSE_TIMEOUTS_HELP`].
76pub const MCP_DRAIN_TIMEOUTS_HELP: &str =
77    "Shutdown MCP-inflight drain hit its 5 s deadline before every detached \
78     mcp_call_inner spawn completed. Downstream effects: some detached \
79     tool bodies were interrupted at close and their sessions may be \
80     quarantined at restart-time recovery. Any occurrence during a \
81     rollout indicates a class of tool calls that regularly outlive HTTP \
82     drain (long-running upstream tool, hung sandbox); check the \
83     coincident tracing warn line.";
84
85/// Shared HELP for `av_idle_close_timeouts_total` (boot pre-registration
86/// and reconciler idle-sweep lookup). Same first-wins/no-drift rationale
87/// as [`SESSION_CLOSE_TIMEOUTS_HELP`].
88pub const IDLE_CLOSE_TIMEOUTS_HELP: &str =
89    "Idle-close reached the per-session deadline (90 s) and returned to \
90     the next tick — indicates a session with an active lease that never \
91     drops (stuck stream, hung worker) or a bridge publish stalled behind \
92     an unresponsive broker. A steady rate > 0 requires operator \
93     investigation of the coincident session id.";
94
95/// Fallback identity scope when the tool name cannot be parsed from the request.
96pub(crate) const TOOL_INVOKE_SCOPE: &str = "tool:invoke";
97
98/// Identity scope required to invoke a specific tool by name.
99pub(crate) fn tool_scope(tool: &str) -> String {
100    format!("tool:{tool}")
101}
102
103/// Shared application state passed through HTTP handlers and background tasks.
104#[derive(Clone)]
105pub struct AppState {
106    /// Versioned harness configuration.
107    pub config: Arc<HarnessConfig>,
108    /// Atomic quota and action-budget state.
109    pub store: Arc<dyn StateStore>,
110    /// MCP schema, policy, and action-budget sandbox.
111    pub sandbox: Arc<Sandbox>,
112    /// Event Bridge backend used by asynchronous workers and by lifecycle
113    /// finalization (close/promote publish through it inline).
114    pub bridge: Arc<dyn EventBus>,
115    /// Live session registry.
116    pub sessions: Arc<SessionRegistry>,
117    /// Bounded non-blocking worker queue.
118    pub worker: WorkerHandle,
119    /// Optional NHI validator. Required in production identity mode.
120    pub identity: Option<Arc<IdentityValidator>>,
121    /// Separate revocation namespace for tokens signed by this gateway.
122    pub exchanged_revocations: Arc<dyn av_identity::RevocationStore>,
123    pub(crate) revocation_audit_window: Arc<parking_lot::Mutex<(Instant, u32)>>,
124    /// Prometheus-compatible metrics registry.
125    pub metrics: Arc<Registry>,
126    /// Pre-resolved metric handles for the request hot
127    /// path so `observe_stage` doesn't take the registry mutex once
128    /// per stage per request. Same series names as the lazy path;
129    /// pre-registered at boot in `new_with_backends_and_metrics`.
130    pub(crate) hot_metrics: Arc<HotMetrics>,
131    /// Reused upstream HTTP client.
132    pub client: reqwest::Client,
133    /// HTTP client for tool backends (private addresses only when
134    /// `require_private_backends` is on; otherwise the shared client).
135    pub(crate) backend_client: reqwest::Client,
136    /// Static credential injected into every chat-completions forward
137    /// (resolved once at startup; value is marked sensitive).
138    pub(crate) upstream_auth: Option<(HeaderName, HeaderValue)>,
139    /// Bearer credential injected into every tool-upstream forward.
140    pub(crate) tool_auth: Option<HeaderValue>,
141    /// Serializes tool-audit completion per execution key (see
142    /// `routes.rs::complete_tool_audit`): an Unaudited replay racing a
143    /// fresh completion for the same key would otherwise submit a second
144    /// `tool_completed` worker job — a duplicate execution record on the
145    /// audit stream and in the signed chain.
146    pub(crate) tool_audit_gates:
147        Arc<parking_lot::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
148    /// Execution keys whose `tool_completed` audit job has been durably
149    /// journaled in this process but whose `.audited` marker write failed;
150    /// a retry must re-attempt the marker without re-emitting the event.
151    pub(crate) tool_audits_emitted: Arc<parking_lot::Mutex<std::collections::HashSet<String>>>,
152    /// The upstream provider's wire dialect,
153    /// selected by `config.provider` at boot. All SSE/body parsing on
154    /// the response path routes through this adapter.
155    pub(crate) provider_adapter: Arc<dyn crate::provider::ProviderAdapter>,
156    /// Asynchronous session close and promotion service.
157    pub finalizer: Finalizer,
158    /// Flips to `true` when shutdown starts. `/readyz` reads this and
159    /// reports 503 so a Kubernetes readinessProbe (or any external LB)
160    /// stops routing new traffic to a draining pod before axum's
161    /// graceful drain begins.
162    pub draining: Arc<std::sync::atomic::AtomicBool>,
163    /// Sliding one-minute budget for FULL identity-
164    /// rejection audit records (window start, records written).
165    /// Rejections beyond the budget are counted only — see
166    /// `enqueue_transient_failure`.
167    pub(crate) identity_rejection_window: Arc<parking_lot::Mutex<(Instant, u32)>>,
168    pub(crate) journal_key: [u8; 32],
169    /// Tracks in-flight `mcp_call_inner` detached spawns so
170    /// `finish_shutdown` can await their completion between HTTP drain
171    /// and `worker.wait_idle`. Without this barrier, a client-
172    /// disconnected `mcp_call` running past axum's graceful drain
173    /// could reach `worker.try_submit` AFTER `wait_idle` returned,
174    /// stranding a fresh job across `finalize_sessions` and tripping
175    /// `av_shutdown_session_close_timeouts_total` on an otherwise-
176    /// recoverable session.
177    pub mcp_inflight: Arc<crate::inflight::InflightTracker>,
178    /// Admission semaphore bounding concurrent `mcp_call_inner`
179    /// executions to `config.mcp_concurrency`. R70 F1 (landed R71):
180    /// without this limit a slow-tool-upstream attack + N
181    /// concurrent MCP callers each buffering up to
182    /// `MAX_TOOL_RESPONSE_BYTES = 16 MiB` in
183    /// `read_limited_tool_response` stacked N × 16 MiB unbounded.
184    /// `mcp_inflight` above is a shutdown-drain COUNTER — not a
185    /// limiter. Chat path already has admission via
186    /// `worker.try_reserve_pair`; MCP was the odd one out.
187    ///
188    /// Refused admissions increment `av_mcp_admission_refusals_
189    /// total` (see mcp_call in routes.rs) and respond 503 with a
190    /// Retry-After header — 503 not 429 because the refusal is a
191    /// server-side capacity signal (matches breaker-open 503-not-
192    /// 429 discipline documented in finalize_error_response.rs).
193    pub mcp_admission: Arc<tokio::sync::Semaphore>,
194    /// Pre-resolved `Arc<Counter>` handle for
195    /// `av_mcp_admission_refusals_total`. R72 review F2 (landed
196    /// R74): the R72 eager registration removed only
197    /// `HashMap::insert` + `String::to_owned` from the refusal-
198    /// arm cost; the `.counter(NAME, HELP)` lookup itself still
199    /// took `metrics.lock()` (a global parking_lot::Mutex across
200    /// the entire registry) on every refusal. Storing the handle
201    /// here — matching the HotMetrics discipline — lets the
202    /// refusal arm call `.inc()` directly with zero registry
203    /// contention.
204    pub(crate) mcp_admission_refusals_counter: Arc<av_core::metrics::Counter>,
205    /// Per-backend credential routing (pillar 4). Resolves tool names to
206    /// their designated backend and attaches the pre-resolved credential.
207    pub backend_router: Arc<crate::backend::BackendRouter>,
208    /// Local policy decision point (pillar 2). Maps tool names to business
209    /// intents and enforces mission constraints per call.
210    pub pdp: Arc<crate::authz::PolicyDecisionPoint>,
211    /// Token signer (pillar 3). Signs exchange tokens and intent tokens
212    /// with EdDSA. Present only when `token_exchange_seed_file` is configured.
213    pub token_signer: Option<Arc<crate::authz::TokenSigner>>,
214    /// Redaction engine (pillar 5). Strips sensitive data from event
215    /// payloads and ATIF step text before they are journaled or published.
216    pub redaction: Option<Arc<av_redact::RedactionEngine>>,
217    /// MCP Streamable HTTP client and per-agent-session backend sessions
218    /// for backends with `transport = "mcp"`.
219    pub mcp_backends: Arc<crate::mcp_client::McpBackendClient>,
220    /// Signs and verifies the `MCP-Session-Id` values this gateway issues.
221    pub(crate) mcp_sessions: Arc<crate::mcp::SessionSigner>,
222    /// External AuthZEN policy decision point, when `[authzen]` is set.
223    pub authzen: Option<Arc<crate::authzen::AuthzenClient>>,
224    /// Content sink slot shared with the audit worker (tenant telemetry).
225    pub(crate) content_sink: crate::content::SharedContentSink,
226    /// Workload assertion verifier, when `[workload_identity]` is set.
227    pub(crate) workload: crate::workload::SharedWorkloadIssuer,
228}
229
230/// Pre-resolved metric handles for the request hot path so the
231/// per-stage histogram observation doesn't take the registry mutex.
232/// Same series names as the lazy path — the label suffix matches
233/// `av_stage_duration_seconds{stage="…"}` exactly.
234///
235/// Ordering of the `[Stage; 5]` arrays mirrors the boot registration
236/// in `new_with_backends_and_metrics`: identity, quota, sanitize,
237/// compression, dispatch. `Stage::index()` returns the offset so
238/// `observe_stage` is O(1) with no string alloc on the hot path.
239pub(crate) struct HotMetrics {
240    pub(crate) stage_histograms: [Arc<av_core::metrics::Histogram>; 5],
241    pub(crate) stage_strict_budget_counters: [Arc<av_core::metrics::Counter>; 5],
242    /// `config.strict_stage_budget || AV_STRICT_BUDGET`
243    /// resolved ONCE at construction. `std::env::var` was previously
244    /// called per stage per request because `||` cannot short-circuit
245    /// a false config default.
246    pub(crate) strict_stage_budget: bool,
247    /// Pre-resolved `av_requests_total{route,status_class}` counters,
248    /// indexed by `(Route::index(), StatusClass::index())`. Populated
249    /// at boot; every hit is O(1) with zero string alloc and zero
250    /// registry-mutex acquisition — R58 audit found the previous
251    /// per-request `metrics.counter(&format!("..."))` path did 4
252    /// mutex ops on the shared `Registry` per HTTP request plus
253    /// ~120 B of allocator churn (at 10k req/s: ~40k mutex ops/s +
254    /// 1.2 MB/s allocs on the request middleware alone).
255    pub(crate) request_counters: [[Arc<av_core::metrics::Counter>; 5]; 15],
256    /// Pre-resolved `av_request_duration_seconds{route}` histograms,
257    /// indexed by `Route::index()`. Same rationale as
258    /// `request_counters` above; the histogram fetch used to hit
259    /// TWO Registry mutex ops per request (base_kinds + metrics
260    /// map).
261    pub(crate) request_duration_histograms: [Arc<av_core::metrics::Histogram>; 15],
262    /// Pre-resolved `av_upstream_latency_seconds` histogram — hit
263    /// on every successful chat forward. R59 continuation of R58's
264    /// hot-path pre-resolution: prior code called
265    /// `metrics.histogram_with_bounds("av_upstream_latency_seconds",
266    /// ...)` per request, doing 2 Registry mutex ops per chat.
267    pub(crate) upstream_latency_histogram: Arc<av_core::metrics::Histogram>,
268    /// Pre-resolved `av_upstream_errors_total{kind}` counters,
269    /// indexed by `UpstreamErrorKind::index()`. Prior code called
270    /// `metrics.counter(&format!("av_upstream_errors_total{...}"))`
271    /// per error, doing 2 Registry mutex ops + a String alloc per
272    /// upstream failure. Kinds order matches boot pre-registration.
273    pub(crate) upstream_errors_counters: [Arc<av_core::metrics::Counter>; 4],
274    /// Pre-resolved `av_dashboard_requests_total{endpoint,status}`
275    /// counters, indexed by `(DashboardEndpoint::index(),
276    /// DashboardStatus::index())`. Same rationale as
277    /// `request_counters`; the dashboard is lower volume but
278    /// hits the same shared Registry mutex.
279    pub(crate) dashboard_request_counters: [[Arc<av_core::metrics::Counter>; 2]; 3],
280    /// Pre-resolved `av_dashboard_request_duration_seconds{endpoint}`
281    /// histograms, indexed by `DashboardEndpoint::index()`.
282    pub(crate) dashboard_request_duration_histograms: [Arc<av_core::metrics::Histogram>; 3],
283}
284
285#[derive(Copy, Clone, Debug)]
286pub(crate) enum Stage {
287    Identity,
288    Quota,
289    Sanitize,
290    Compression,
291    Dispatch,
292}
293
294impl Stage {
295    pub(crate) const ORDER: [Stage; 5] = [
296        Stage::Identity,
297        Stage::Quota,
298        Stage::Sanitize,
299        Stage::Compression,
300        Stage::Dispatch,
301    ];
302
303    pub(crate) fn label(self) -> &'static str {
304        match self {
305            Stage::Identity => "identity",
306            Stage::Quota => "quota",
307            Stage::Sanitize => "sanitize",
308            Stage::Compression => "compression",
309            Stage::Dispatch => "dispatch",
310        }
311    }
312
313    pub(crate) fn index(self) -> usize {
314        match self {
315            Stage::Identity => 0,
316            Stage::Quota => 1,
317            Stage::Sanitize => 2,
318            Stage::Compression => 3,
319            Stage::Dispatch => 4,
320        }
321    }
322
323    fn from_label(label: &str) -> Option<Self> {
324        Stage::ORDER.into_iter().find(|s| s.label() == label)
325    }
326}
327
328/// HTTP route label enum. Mirrors the closed vocabulary in
329/// `route_label(path)` — pre-resolved into a fixed-index array of
330/// `Arc<Counter>` / `Arc<Histogram>` so the request-metrics
331/// middleware never touches the shared `Registry` mutex on the hot
332/// path (see `HotMetrics::request_counters`).
333#[derive(Copy, Clone, Debug)]
334pub(crate) enum Route {
335    Chat,
336    Mcp,
337    Health,
338    Livez,
339    Readyz,
340    Metrics,
341    SessionClose,
342    SessionPromote,
343    Dashboard,
344    TokenExchange,
345    Revoke,
346    Introspect,
347    AdminRevocation,
348    Jwks,
349    Other,
350}
351
352impl Route {
353    pub(crate) const ORDER: [Route; 15] = [
354        Route::Chat,
355        Route::Mcp,
356        Route::Health,
357        Route::Livez,
358        Route::Readyz,
359        Route::Metrics,
360        Route::SessionClose,
361        Route::SessionPromote,
362        Route::Dashboard,
363        Route::TokenExchange,
364        Route::Revoke,
365        Route::Introspect,
366        Route::AdminRevocation,
367        Route::Jwks,
368        Route::Other,
369    ];
370
371    pub(crate) fn label(self) -> &'static str {
372        match self {
373            Route::Chat => "chat",
374            Route::Mcp => "mcp",
375            Route::Health => "health",
376            Route::Livez => "livez",
377            Route::Readyz => "readyz",
378            Route::Metrics => "metrics",
379            Route::SessionClose => "session_close",
380            Route::SessionPromote => "session_promote",
381            Route::Dashboard => "dashboard",
382            Route::TokenExchange => "token_exchange",
383            Route::Revoke => "revoke",
384            Route::Introspect => "introspect",
385            Route::AdminRevocation => "admin_revocation",
386            Route::Jwks => "jwks",
387            Route::Other => "other",
388        }
389    }
390
391    pub(crate) fn index(self) -> usize {
392        match self {
393            Route::Chat => 0,
394            Route::Mcp => 1,
395            Route::Health => 2,
396            Route::Livez => 3,
397            Route::Readyz => 4,
398            Route::Metrics => 5,
399            Route::SessionClose => 6,
400            Route::SessionPromote => 7,
401            Route::Dashboard => 8,
402            Route::TokenExchange => 9,
403            Route::Revoke => 10,
404            Route::Introspect => 11,
405            Route::AdminRevocation => 12,
406            Route::Jwks => 13,
407            Route::Other => 14,
408        }
409    }
410}
411
412/// HTTP status-class label. Mirrors the closed vocabulary in the
413/// request-metrics middleware. Kept as a separate enum so future
414/// additions (e.g., a hypothetical `2xx-slow`) surface via
415/// exhaustive-match compile checks.
416#[derive(Copy, Clone, Debug)]
417pub(crate) enum StatusClass {
418    Informational, // 1xx
419    Success,       // 2xx
420    Redirection,   // 3xx
421    ClientError,   // 4xx
422    ServerError,   // 5xx
423}
424
425impl StatusClass {
426    pub(crate) const ORDER: [StatusClass; 5] = [
427        StatusClass::Informational,
428        StatusClass::Success,
429        StatusClass::Redirection,
430        StatusClass::ClientError,
431        StatusClass::ServerError,
432    ];
433
434    pub(crate) fn label(self) -> &'static str {
435        match self {
436            StatusClass::Informational => "1xx",
437            StatusClass::Success => "2xx",
438            StatusClass::Redirection => "3xx",
439            StatusClass::ClientError => "4xx",
440            StatusClass::ServerError => "5xx",
441        }
442    }
443
444    pub(crate) fn index(self) -> usize {
445        match self {
446            StatusClass::Informational => 0,
447            StatusClass::Success => 1,
448            StatusClass::Redirection => 2,
449            StatusClass::ClientError => 3,
450            StatusClass::ServerError => 4,
451        }
452    }
453
454    pub(crate) fn from_u16(status: u16) -> Self {
455        match status {
456            100..=199 => StatusClass::Informational,
457            200..=299 => StatusClass::Success,
458            300..=399 => StatusClass::Redirection,
459            400..=499 => StatusClass::ClientError,
460            _ => StatusClass::ServerError,
461        }
462    }
463}
464
465/// Upstream error classifier used for `av_upstream_errors_total{kind}`.
466/// Mirrors `classify_upstream_error(err)` — same set as the boot
467/// pre-registration in `Pipeline::new` at pipeline.rs:511.
468#[derive(Copy, Clone, Debug)]
469pub(crate) enum UpstreamErrorKind {
470    Timeout,
471    Connect,
472    Send,
473    Http5xx,
474}
475
476impl UpstreamErrorKind {
477    pub(crate) const ORDER: [UpstreamErrorKind; 4] = [
478        UpstreamErrorKind::Timeout,
479        UpstreamErrorKind::Connect,
480        UpstreamErrorKind::Send,
481        UpstreamErrorKind::Http5xx,
482    ];
483
484    pub(crate) fn label(self) -> &'static str {
485        match self {
486            UpstreamErrorKind::Timeout => "timeout",
487            UpstreamErrorKind::Connect => "connect",
488            UpstreamErrorKind::Send => "send",
489            UpstreamErrorKind::Http5xx => "http_5xx",
490        }
491    }
492
493    pub(crate) fn index(self) -> usize {
494        match self {
495            UpstreamErrorKind::Timeout => 0,
496            UpstreamErrorKind::Connect => 1,
497            UpstreamErrorKind::Send => 2,
498            UpstreamErrorKind::Http5xx => 3,
499        }
500    }
501
502    /// Convert `classify_upstream_error`'s `&'static str` output into
503    /// a typed variant. The classifier at pipeline.rs:2736 already
504    /// exhaustively covers this set; this fn is the typed mirror.
505    pub(crate) fn from_reqwest(error: &reqwest::Error) -> Self {
506        if error.is_timeout() {
507            UpstreamErrorKind::Timeout
508        } else if error.is_connect() {
509            UpstreamErrorKind::Connect
510        } else {
511            UpstreamErrorKind::Send
512        }
513    }
514}
515
516/// Dashboard endpoint label. Mirrors the `record()` call sites in
517/// dashboard.rs (list, detail, stats).
518#[derive(Copy, Clone, Debug)]
519pub(crate) enum DashboardEndpoint {
520    Stats,
521    List,
522    Detail,
523}
524
525impl DashboardEndpoint {
526    pub(crate) const ORDER: [DashboardEndpoint; 3] = [
527        DashboardEndpoint::Stats,
528        DashboardEndpoint::List,
529        DashboardEndpoint::Detail,
530    ];
531
532    pub(crate) fn label(self) -> &'static str {
533        match self {
534            DashboardEndpoint::Stats => "stats",
535            DashboardEndpoint::List => "list",
536            DashboardEndpoint::Detail => "detail",
537        }
538    }
539
540    pub(crate) fn index(self) -> usize {
541        match self {
542            DashboardEndpoint::Stats => 0,
543            DashboardEndpoint::List => 1,
544            DashboardEndpoint::Detail => 2,
545        }
546    }
547}
548
549/// Dashboard endpoint response status label. Mirrors the `record()`
550/// call sites (`ok`, `not_found`) in dashboard.rs.
551#[derive(Copy, Clone, Debug)]
552pub(crate) enum DashboardStatus {
553    Ok,
554    NotFound,
555}
556
557impl DashboardStatus {
558    pub(crate) const ORDER: [DashboardStatus; 2] = [DashboardStatus::Ok, DashboardStatus::NotFound];
559
560    pub(crate) fn label(self) -> &'static str {
561        match self {
562            DashboardStatus::Ok => "ok",
563            DashboardStatus::NotFound => "not_found",
564        }
565    }
566
567    pub(crate) fn index(self) -> usize {
568        match self {
569            DashboardStatus::Ok => 0,
570            DashboardStatus::NotFound => 1,
571        }
572    }
573}
574
575impl HotMetrics {
576    fn new(metrics: &Registry, config_strict_stage_budget: bool) -> Self {
577        // Registrations here mirror the boot loop below. Using the
578        // same `metrics.histogram_with_bounds` / `metrics.counter`
579        // getters means these entries are shared with the lazy path
580        // — a caller that still uses `metrics.counter(name).inc()`
581        // hits the exact same Arc<Counter> we cache here.
582        let stage_histograms: [Arc<av_core::metrics::Histogram>; 5] = Stage::ORDER.map(|stage| {
583            metrics.histogram_with_bounds(
584                &format!("av_stage_duration_seconds{{stage=\"{}\"}}", stage.label()),
585                "Harness stage latency",
586                av_core::metrics::WIDE_LATENCY_BOUNDS_US,
587            )
588        });
589        let stage_strict_budget_counters: [Arc<av_core::metrics::Counter>; 5] = Stage::ORDER.map(|stage| {
590            metrics.counter(
591                &format!("av_strict_budget_breaches_total{{stage=\"{}\"}}", stage.label()),
592                "Middleware stages that exceeded the strict per-stage budget",
593            )
594        });
595        // The post-compression quota debit reports under its own label
596        // (`quota_post_compression`, not in `Stage::ORDER`) through
597        // observe_stage's lazy fallback. Pre-register it here with the
598        // SAME wide bounds as the enum stages: first-registration wins
599        // in the Registry, so without this the lazy path created it
600        // with the default bounds and the one stage doing real backend
601        // work (Redis) had incomparable buckets to its siblings.
602        let _ = metrics.histogram_with_bounds(
603            "av_stage_duration_seconds{stage=\"quota_post_compression\"}",
604            "Harness stage latency",
605            av_core::metrics::WIDE_LATENCY_BOUNDS_US,
606        );
607        // Pre-resolve the request-metrics middleware's counters +
608        // histograms as a [route][status_class] array so the hot
609        // path is O(1) with zero string alloc and zero Registry
610        // mutex ops. Registration order matches the enum ORDER
611        // constants, matched at read time via `Route::index()` and
612        // `StatusClass::index()`.
613        let request_counters: [[Arc<av_core::metrics::Counter>; 5]; 15] = Route::ORDER.map(|route| {
614            StatusClass::ORDER.map(|status| {
615                metrics.counter(
616                    &format!(
617                        "av_requests_total{{route=\"{}\",status_class=\"{}\"}}",
618                        route.label(),
619                        status.label()
620                    ),
621                    "HTTP requests by route and status class",
622                )
623            })
624        });
625        let request_duration_histograms: [Arc<av_core::metrics::Histogram>; 15] = Route::ORDER.map(|route| {
626            metrics.histogram_with_bounds(
627                &format!("av_request_duration_seconds{{route=\"{}\"}}", route.label()),
628                "End-to-end HTTP request latency by route",
629                av_core::metrics::WIDE_LATENCY_BOUNDS_US,
630            )
631        });
632        // Pre-resolve upstream latency + error counters — hit on
633        // every chat forward. Same rationale as request_counters.
634        let upstream_latency_histogram = metrics.histogram_with_bounds(
635            "av_upstream_latency_seconds",
636            "Time to upstream response headers",
637            av_core::metrics::WIDE_LATENCY_BOUNDS_US,
638        );
639        let upstream_errors_counters: [Arc<av_core::metrics::Counter>; 4] =
640            UpstreamErrorKind::ORDER.map(|kind| {
641                metrics.counter(
642                    &format!("av_upstream_errors_total{{kind=\"{}\"}}", kind.label()),
643                    "Upstream failures by kind",
644                )
645            });
646        // Pre-resolve dashboard endpoint counters + histograms. Same
647        // rationale as request_counters; lower volume but hits the
648        // same shared Registry mutex.
649        let dashboard_request_counters: [[Arc<av_core::metrics::Counter>; 2]; 3] = DashboardEndpoint::ORDER
650            .map(|endpoint| {
651                DashboardStatus::ORDER.map(|status| {
652                    metrics.counter(
653                        &format!(
654                            "av_dashboard_requests_total{{endpoint=\"{}\",status=\"{}\"}}",
655                            endpoint.label(),
656                            status.label()
657                        ),
658                        "Dashboard endpoint requests, labeled by status",
659                    )
660                })
661            });
662        let dashboard_request_duration_histograms: [Arc<av_core::metrics::Histogram>; 3] =
663            DashboardEndpoint::ORDER.map(|endpoint| {
664                metrics.histogram(
665                    &format!(
666                        "av_dashboard_request_duration_seconds{{endpoint=\"{}\"}}",
667                        endpoint.label()
668                    ),
669                    "Dashboard endpoint latency",
670                )
671            });
672        Self {
673            stage_histograms,
674            stage_strict_budget_counters,
675            strict_stage_budget: config_strict_stage_budget || truthy_env("AV_STRICT_BUDGET"),
676            request_counters,
677            request_duration_histograms,
678            upstream_latency_histogram,
679            upstream_errors_counters,
680            dashboard_request_counters,
681            dashboard_request_duration_histograms,
682        }
683    }
684}
685
686/// How the harness authenticates to the chat upstream, for startup logs
687/// and `avctl doctor` — never contains key material.
688pub fn describe_upstream_auth(config: &HarnessConfig) -> String {
689    if config.upstream_authorization_passthrough {
690        "passthrough(client Authorization)".to_owned()
691    } else if let Some(env) = config.upstream_api_key_env.as_deref() {
692        format!("api-key from ${env} in header {:?}", config.upstream_auth_header)
693    } else if let Some(file) = config.upstream_api_key_file.as_deref() {
694        format!(
695            "api-key from file {file} in header {:?}",
696            config.upstream_auth_header
697        )
698    } else {
699        "none".to_owned()
700    }
701}
702
703/// Resolve the configured upstream credential into a ready header pair.
704///
705/// Key material is accepted only from an environment variable or an
706/// owner-only file — never from TOML or argv — and the resulting header
707/// value is marked sensitive so `Debug` output redacts it.
708pub(crate) fn resolve_upstream_auth(
709    config: &HarnessConfig,
710) -> Result<Option<(HeaderName, HeaderValue)>, PipelineError> {
711    let key = match read_secret(
712        config.upstream_api_key_env.as_deref(),
713        config.upstream_api_key_file.as_deref(),
714        "upstream API key",
715    )? {
716        Some(key) => key,
717        None => return Ok(None),
718    };
719    let name = HeaderName::try_from(config.upstream_auth_header.as_str()).map_err(|_| {
720        PipelineError::upstream(format!(
721            "upstream_auth_header {:?} is not a valid header name",
722            config.upstream_auth_header
723        ))
724    })?;
725    let rendered = if config.upstream_auth_scheme.is_empty() {
726        key
727    } else {
728        format!("{} {key}", config.upstream_auth_scheme)
729    };
730    let mut value = HeaderValue::from_str(&rendered).map_err(|_| {
731        PipelineError::upstream(
732            "upstream API key contains bytes that cannot appear in an HTTP header".to_owned(),
733        )
734    })?;
735    value.set_sensitive(true);
736    Ok(Some((name, value)))
737}
738
739/// Resolve the optional tool-upstream bearer token.
740pub(crate) fn resolve_tool_auth(config: &HarnessConfig) -> Result<Option<HeaderValue>, PipelineError> {
741    let token = match read_secret(
742        config.tool_upstream_bearer_env.as_deref(),
743        config.tool_upstream_bearer_file.as_deref(),
744        "tool upstream bearer token",
745    )? {
746        Some(token) => token,
747        None => return Ok(None),
748    };
749    let mut value = HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| {
750        PipelineError::upstream(
751            "tool upstream bearer token contains bytes that cannot appear in an HTTP header".to_owned(),
752        )
753    })?;
754    value.set_sensitive(true);
755    Ok(Some(value))
756}
757
758/// Read a secret from an env var or an owner-only file, trimming
759/// surrounding whitespace. A configured-but-missing source is a loud startup error:
760/// silently proxying unauthenticated would produce baffling upstream 401s.
761pub(crate) fn read_secret(
762    env_name: Option<&str>,
763    file_path: Option<&str>,
764    what: &str,
765) -> Result<Option<String>, PipelineError> {
766    read_secret_from(|name| std::env::var(name).ok(), env_name, file_path, what)
767}
768
769/// Testable core of [`read_secret`] with an injected environment.
770fn read_secret_from(
771    get_env: impl Fn(&str) -> Option<String>,
772    env_name: Option<&str>,
773    file_path: Option<&str>,
774    what: &str,
775) -> Result<Option<String>, PipelineError> {
776    if let Some(name) = env_name {
777        let value = get_env(name).ok_or_else(|| {
778            PipelineError::upstream(format!(
779                "{what}: environment variable {name} is not set (export it or update the config)"
780            ))
781        })?;
782        let value = value.trim().to_owned();
783        if value.is_empty() {
784            return Err(PipelineError::upstream(format!(
785                "{what}: environment variable {name} is set but empty"
786            )));
787        }
788        return Ok(Some(value));
789    }
790    if let Some(path) = file_path {
791        require_owner_only_secret(std::path::Path::new(path))
792            .map_err(|error| PipelineError::upstream(format!("{what}: {error}")))?;
793        let value = std::fs::read_to_string(path).map_err(|error| PipelineError::Upstream {
794            context: format!("{what}: read {path}: {error}"),
795            source: Some(Box::new(error)),
796        })?;
797        let value = value.trim().to_owned();
798        if value.is_empty() {
799            return Err(PipelineError::upstream(format!("{what}: file {path} is empty")));
800        }
801        return Ok(Some(value));
802    }
803    Ok(None)
804}
805
806/// Same posture as the signing-seed loader: refuse symlinks (a pre-planted
807/// link would fool the mode check, CWE-59) and group/other-readable modes
808/// on Unix. Windows deployments rely on operator-set ACLs.
809pub(crate) fn require_owner_only_secret(path: &std::path::Path) -> Result<(), String> {
810    #[cfg(unix)]
811    {
812        use std::os::unix::fs::MetadataExt as _;
813        let metadata = std::fs::symlink_metadata(path)
814            .map_err(|error| format!("stat secret file {}: {error}", path.display()))?;
815        let file_type = metadata.file_type();
816        if file_type.is_symlink() {
817            return Err(format!(
818                "secret file {} is a symbolic link; refusing to follow",
819                path.display()
820            ));
821        }
822        if !file_type.is_file() {
823            return Err(format!("secret file {} is not a regular file", path.display()));
824        }
825        let mode = metadata.mode() & 0o777;
826        if mode & 0o077 != 0 {
827            return Err(format!(
828                "secret file {} has mode 0o{mode:03o}; must be owner-only (chmod 600 {})",
829                path.display(),
830                path.display()
831            ));
832        }
833    }
834    #[cfg(not(unix))]
835    {
836        let _ = path;
837    }
838    Ok(())
839}
840
841/// A request after all local hot-path gates have passed.
842pub struct PreparedRequest {
843    /// Session bound to this request.
844    pub session: Arc<Session>,
845    /// Identity validated for this request.
846    pub identity: AgentIdentity,
847    /// Payload forwarded to the upstream provider after compression.
848    pub payload: Value,
849    /// Total local middleware time before upstream I/O.
850    pub middleware_us: u64,
851    /// Prompt tokens debited from the session and principal budgets at
852    /// admission (`ActionBudget::try_tokens`), held as an RAII guard so
853    /// BOTH a provably-not-forwarded explicit abort and a cancelled
854    /// request future (client disconnect at any pre-dispatch await)
855    /// refund exactly what was debited. Disarmed at upstream dispatch.
856    admission_debit: AdmissionDebit,
857    // Fields drop in declaration order. `capture_guard` MUST drop before
858    // `lease` on a cancelled request future — the lease's Drop notifies
859    // `wait_for_streams`, unblocking `close_session_locked`, which then
860    // checks `wait_for_worker_jobs()`. If the guard submits its terminal
861    // job after the lease releases, the close's `pending_jobs == 0` load
862    // can win the race, finalize proceeds, and the guard's job lands
863    // after the receipt was sealed (chain/receipt divergence for signed
864    // sessions; a resurrected step journal after remove_step_journal for
865    // unsigned). AbortFinalizingStream preserves the same invariant by
866    // convention (its Drop runs before `_lease` drops).
867    capture_guard: ResponseCaptureGuard,
868    lease: SessionLease,
869    /// Client `Authorization` header captured for passthrough mode only.
870    client_authorization: Option<HeaderValue>,
871    /// Provider-semantic client headers relayed upstream verbatim, from
872    /// a strict allowlist only (org/project routing and beta gating —
873    /// dropping them broke org-scoped keys and beta features for
874    /// otherwise drop-in SDK clients). Never auth-bearing.
875    upstream_passthrough_headers: Vec<(axum::http::HeaderName, HeaderValue)>,
876}
877
878/// Provider response paired with its active session lease.
879pub struct ForwardedResponse {
880    /// Provider HTTP response.
881    pub response: reqwest::Response,
882    /// Prompt tokens debited at admission (post-compression heuristic).
883    /// The response relay reconciles this against the
884    /// provider's reported `usage.prompt_tokens` — correcting the
885    /// session totals and refunding the over-charge.
886    pub(crate) billed_prompt_tokens: u64,
887    // Same ordering invariant as `PreparedRequest`: guard drops before
888    // lease so its terminal job registers `pending_jobs` before the
889    // close barrier sees the streams drained. The reserved response
890    // slot travels inside the guard until routes.rs takes it.
891    pub(crate) capture_guard: ResponseCaptureGuard,
892    pub(crate) lease: SessionLease,
893}
894
895/// RAII owner of the journalled response attempt and (once created) the
896/// durable in-flight response marker, from request admission until a
897/// downstream owner takes over (`AbortFinalizingStream`, a terminal
898/// failure job, or the worker's capture publish).
899///
900/// Axum drops the request future wholesale when the client disconnects.
901/// Every *explicit* failure path already retires the marker and closes
902/// the journalled attempt — but a cancelled future runs none of them:
903/// the journal kept a dangling non-terminal attempt (crash recovery then
904/// quarantines the whole session, see `track_response_attempt`) and the
905/// marker stranded on disk (once the session evicts, every reconciler
906/// tick re-quarantines its id forever). Drop submits the same terminal
907/// failure job the explicit paths use.
908pub(crate) struct ResponseCaptureGuard {
909    worker: crate::worker::WorkerHandle,
910    session: Arc<Session>,
911    identity: AgentIdentity,
912    marker: Option<String>,
913    attempt_id: String,
914    /// The response-capture slot reserved at admission. Owned by the
915    /// guard so the cancellation path (Drop) submits its terminal
916    /// record through the RESERVED slot, exactly like every explicit
917    /// failure path — a saturated worker queue must not be able to
918    /// poison an admitted session whose slot was pre-paid
919    /// (`mark_capture_failed` quarantines the id permanently).
920    /// Explicit paths `take_permit()` when they assume ownership.
921    permit: Option<ResponsePermit>,
922    armed: bool,
923}
924
925impl ResponseCaptureGuard {
926    fn new(
927        worker: crate::worker::WorkerHandle,
928        session: Arc<Session>,
929        identity: AgentIdentity,
930        attempt_id: String,
931        permit: Option<ResponsePermit>,
932    ) -> Self {
933        Self {
934            worker,
935            session,
936            identity,
937            marker: None,
938            attempt_id,
939            permit,
940            armed: true,
941        }
942    }
943
944    /// Hand the reserved response slot to an explicit owner (the
945    /// SSE/buffered relay, or a failure path that submits its own
946    /// terminal record). The guard's Drop falls back to the plain
947    /// worker queue once the permit is taken.
948    pub(crate) fn take_permit(&mut self) -> Option<ResponsePermit> {
949        self.permit.take()
950    }
951
952    /// Record the durable in-flight marker so a later drop retires it.
953    pub(crate) fn set_marker(&mut self, marker: String) {
954        self.marker = Some(marker);
955    }
956
957    pub(crate) fn attempt_id(&self) -> &str {
958        &self.attempt_id
959    }
960
961    /// Take ownership of the marker and attempt id, disarming the guard.
962    /// The caller now owns terminal-record submission.
963    pub(crate) fn disarm(mut self) -> (Option<String>, String) {
964        self.armed = false;
965        (self.marker.take(), std::mem::take(&mut self.attempt_id))
966    }
967
968    /// Disarm in place, returning any marker: for callers that submit the
969    /// terminal record themselves while retaining the guard (abandon paths).
970    fn defuse(&mut self) -> Option<String> {
971        self.armed = false;
972        self.marker.take()
973    }
974}
975
976impl Drop for ResponseCaptureGuard {
977    fn drop(&mut self) {
978        if !self.armed {
979            return;
980        }
981        let job = refused_response_failure_job(
982            Arc::clone(&self.session),
983            self.identity.clone(),
984            "request_cancelled_before_response_capture".to_owned(),
985            self.marker.take(),
986            std::mem::take(&mut self.attempt_id),
987        );
988        // Submit through the reserved response slot when the guard
989        // still owns it (cancellation before an explicit owner took
990        // over) so worker saturation cannot fail this submission; fall
991        // back to the plain queue otherwise. Same fallback as every
992        // explicit refusal path: if the submission fails, fail the
993        // session closed rather than dropping the capture silently.
994        let submitted = match self.permit.take() {
995            Some(permit) => permit.submit(&self.worker, job),
996            None => self.worker.try_submit(job),
997        };
998        if submitted.is_err() {
999            self.session.mark_capture_failed();
1000        }
1001    }
1002}
1003
1004/// RAII owner of the admission-time token debit (session AND principal
1005/// ledgers), from the post-compression charge in `prepare_chat` until
1006/// the request is handed to the upstream HTTP client.
1007///
1008/// Every *explicit* pre-dispatch abort refunds via `abandon_prepared` —
1009/// but axum drops the request future wholesale on client disconnect,
1010/// running none of them. Without this guard, a cancellation between
1011/// admission and `send()` (the `spawn_blocking` join in
1012/// `prepare_chat_nonblocking`, or the awaited marker write in
1013/// `forward_chat`) leaked the debit on both ledgers; the principal
1014/// ledger persists across sessions and never heals, so a client that
1015/// disconnected mid-admission drained its principal budget with zero
1016/// LLM work.
1017///
1018/// [`Self::take_for_dispatch`] disarms the guard at the last
1019/// pre-dispatch point: from there the request may reach the provider,
1020/// and the debit conservatively stands (mirroring `forward_chat`'s
1021/// `is_connect`-only refund on send errors).
1022pub(crate) struct AdmissionDebit {
1023    store: Arc<dyn StateStore>,
1024    session_id: String,
1025    budget: av_state::BudgetSpec,
1026    principal: Option<(String, av_state::BudgetSpec)>,
1027    tokens: u64,
1028    /// Metrics registry for the Drop-path panic supervisor.
1029    ///
1030    /// If the refund closure panics inside the spawn_blocking pool (a
1031    /// backend allocator failure, a parking_lot mutex panic during
1032    /// unwind, a HashMap grow OOM), tokio's default
1033    /// `UnhandledPanic::Ignore` swallows it silently. The debit then
1034    /// stays permanent on BOTH the session ledger and — the material
1035    /// consequence — the principal ledger, which persists across
1036    /// sessions and never heals. Surface the panic via a bumped
1037    /// counter and a tracing::error so `rate(av_admission_refund_panics_total)`
1038    /// catches it.
1039    metrics: Arc<Registry>,
1040}
1041
1042impl AdmissionDebit {
1043    /// Disarm and hand the debited amount to the dispatch path; the
1044    /// debit stands from here (reconciled against provider usage, or
1045    /// refunded by the explicit `is_connect` arm).
1046    fn take_for_dispatch(&mut self) -> u64 {
1047        std::mem::take(&mut self.tokens)
1048    }
1049
1050    /// Refund both ledgers now, in the order they were spent. Zeroed so
1051    /// a later drop (or a hypothetical double abandon) cannot
1052    /// double-refund.
1053    fn refund_now(&mut self) {
1054        let tokens = std::mem::take(&mut self.tokens);
1055        if tokens == 0 {
1056            return;
1057        }
1058        ActionBudget::new(self.store.as_ref(), &self.session_id, &self.budget).refund_tokens(tokens);
1059        if let Some((principal_id, spec)) = self.principal.as_ref() {
1060            ActionBudget::for_principal(self.store.as_ref(), principal_id, spec).refund_tokens(tokens);
1061        }
1062    }
1063}
1064
1065impl Drop for AdmissionDebit {
1066    fn drop(&mut self) {
1067        if self.tokens == 0 {
1068            return;
1069        }
1070        // A refund is a synchronous store call (a network round-trip on
1071        // the redis backend). A cancelled request future drops on an
1072        // async worker thread, where that round-trip would stall every
1073        // other stream polled there — move it to the blocking pool when
1074        // a runtime is available (drops on the `spawn_blocking` path of
1075        // `prepare_chat_nonblocking` already run on blocking threads,
1076        // and unit tests may have no runtime at all: refund inline).
1077        let tokens = std::mem::take(&mut self.tokens);
1078        let store = Arc::clone(&self.store);
1079        let session_id = std::mem::take(&mut self.session_id);
1080        let budget = std::mem::take(&mut self.budget);
1081        let principal = self.principal.take();
1082        let metrics = Arc::clone(&self.metrics);
1083        // `catch_unwind` around the refund body so a panic inside
1084        // spawn_blocking (backend allocator OOM, parking_lot mutex
1085        // panic during unwind, a HashMap grow at capacity) doesn't
1086        // get swallowed by tokio's default `UnhandledPanic::Ignore`.
1087        // The `store: Arc<dyn StateStore>` and `metrics: Arc<Registry>`
1088        // captures are already `UnwindSafe` (Arc is UnwindSafe when
1089        // its inner T is `RefUnwindSafe`, and the store trait's
1090        // implementors are RefUnwindSafe by convention here).
1091        // `session_id: String`, `budget: BudgetSpec`, and
1092        // `principal: Option<(String, BudgetSpec)>` are all
1093        // Send + UnwindSafe. Wrap the whole thing in
1094        // `AssertUnwindSafe` to bypass the `RefUnwindSafe` check on
1095        // the trait object rather than plumb the bound through the
1096        // whole StateStore hierarchy — the invariant we care about
1097        // (no shared mutable state leaked from the refund) is
1098        // upheld by construction.
1099        let sess_for_log = session_id.clone();
1100        let refund = move || {
1101            let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1102                ActionBudget::new(store.as_ref(), &session_id, &budget).refund_tokens(tokens);
1103                if let Some((principal_id, spec)) = principal.as_ref() {
1104                    ActionBudget::for_principal(store.as_ref(), principal_id, spec).refund_tokens(tokens);
1105                }
1106            }));
1107            if let Err(panic) = outcome {
1108                let msg = panic
1109                    .downcast_ref::<&'static str>()
1110                    .copied()
1111                    .or_else(|| panic.downcast_ref::<String>().map(String::as_str))
1112                    .unwrap_or("panic payload was not a string");
1113                metrics
1114                    .counter(
1115                        "av_admission_refund_panics_total",
1116                        "Admission-debit refund closure panicked in Drop; the session \
1117                         and principal ledgers retain the debit until operator repair",
1118                    )
1119                    .inc();
1120                tracing::error!(
1121                    session = %sess_for_log,
1122                    tokens,
1123                    panic = %msg,
1124                    "admission-debit refund PANICKED (caught); \
1125                     principal ledger drain persists until operator repair"
1126                );
1127            }
1128        };
1129        match tokio::runtime::Handle::try_current() {
1130            Ok(handle) => {
1131                handle.spawn_blocking(refund);
1132            }
1133            Err(_) => refund(),
1134        }
1135    }
1136}
1137
1138/// Terminal failure job used by response-refusal paths (permit missing,
1139/// unsupported content-encoding, cancelled request future). Carrying
1140/// `response_marker` lets the worker retire the durable in-flight marker,
1141/// and `terminal: true` closes the journalled response attempt so crash
1142/// recovery does not quarantine the session over a request the client
1143/// already saw fail (or abandoned).
1144pub(crate) fn refused_response_failure_job(
1145    session: Arc<Session>,
1146    identity: AgentIdentity,
1147    reason: String,
1148    response_marker: Option<String>,
1149    response_attempt_id: String,
1150) -> WorkerJob {
1151    let atif = (session.workflow == Workflow::Unsigned).then(|| AtifCapture {
1152        source: av_atif::Source::Agent,
1153        message: Value::String(reason.clone()),
1154        reasoning_content: None,
1155        model_name: None,
1156        tool_calls: None,
1157        observation: None,
1158        llm_call_count: Some(0),
1159    });
1160    WorkerJob {
1161        session,
1162        identity,
1163        class: EventClass::StopReason,
1164        payload: serde_json::json!({"reason": reason, "direction": "upstream_response"}),
1165        text: String::new(),
1166        analyze_loop: false,
1167        status: StatusId::Failure,
1168        stop_reason: Some(StopReason::Other),
1169        native_stop_reason: None,
1170        metrics: EventMetrics::default(),
1171        cost_usd_micros: 0,
1172        prompt_token_correction: 0,
1173        atif,
1174        response_marker,
1175        response_attempt: Some(crate::worker::ResponseAttempt {
1176            id: response_attempt_id,
1177            terminal: true,
1178        }),
1179    }
1180}
1181
1182/// Boxed dynamic error source for harness error variants whose
1183/// underlying cause can be any of several concrete types (`io::Error`,
1184/// `serde_json::Error`, `av_receipts::ReceiptError`, ...). Kept dynamic
1185/// so one variant covers heterogeneous call sites while still exposing
1186/// the typed error through [`std::error::Error::source`] — callers can
1187/// `source().downcast_ref::<std::io::Error>()` to branch on e.g.
1188/// `io::ErrorKind`, and tracing subscribers receive the full chain.
1189pub type ErrorSource = Box<dyn std::error::Error + Send + Sync + 'static>;
1190
1191/// Hot-path failures, each carrying an HTTP status mapping.
1192///
1193/// Variants that wrap an underlying failure carry it as a typed
1194/// `#[source]` instead of a pre-rendered `String`; `context` keeps the
1195/// exact text the old `String` payloads rendered, so Display output
1196/// (embedded verbatim in the OpenAI-shaped HTTP error bodies) is
1197/// unchanged. `Unauthorized` and `Abort` stay plain strings: their
1198/// messages are deliberately classified policy verdicts (the raw
1199/// identity error is logged separately and must not travel with the
1200/// client-facing value).
1201#[derive(Debug, thiserror::Error)]
1202#[non_exhaustive]
1203pub enum PipelineError {
1204    /// A request header was malformed or inconsistent.
1205    #[error("bad request: {context}")]
1206    BadRequest {
1207        /// Human-readable description.
1208        context: String,
1209        /// Underlying typed error, when one exists.
1210        #[source]
1211        source: Option<ErrorSource>,
1212    },
1213    /// Identity was missing or invalid.
1214    #[error("unauthorized: {0}")]
1215    Unauthorized(String),
1216    /// A stateful quota or loop breaker blocked execution.
1217    #[error("request blocked: {context}")]
1218    Blocked {
1219        /// Human-readable description.
1220        context: String,
1221        /// Underlying typed error, when one exists.
1222        #[source]
1223        source: Option<ErrorSource>,
1224    },
1225    /// The upstream provider request failed.
1226    #[error("upstream request failed: {context}")]
1227    Upstream {
1228        /// Human-readable description.
1229        context: String,
1230        /// Underlying typed error, when one exists.
1231        #[source]
1232        source: Option<ErrorSource>,
1233    },
1234    /// The upstream provider accepted the connection but did not
1235    /// finish responding within `upstream_read_timeout_s`. Distinct
1236    /// from [`Self::Upstream`] so the response layer can map to
1237    /// `504 Gateway Timeout` (RFC 7231 §6.6.5) rather than
1238    /// `502 Bad Gateway`, letting operator dashboards distinguish
1239    /// "provider slow" (504 — check provider health) from
1240    /// "provider unreachable" (502 — check DNS / network / config).
1241    /// SDK retry behaviour is unchanged (both retryable).
1242    #[error("upstream request timed out: {context}")]
1243    UpstreamTimeout {
1244        /// Human-readable description.
1245        context: String,
1246        /// Underlying typed error, when one exists.
1247        #[source]
1248        source: Option<ErrorSource>,
1249    },
1250    /// The breaker requested immediate connection closure.
1251    #[error("connection aborted: {0}")]
1252    Abort(String),
1253    /// Required audit capture infrastructure is unavailable.
1254    #[error("service unavailable: {context}")]
1255    Unavailable {
1256        /// Human-readable description.
1257        context: String,
1258        /// Underlying typed error, when one exists.
1259        #[source]
1260        source: Option<ErrorSource>,
1261    },
1262}
1263
1264impl PipelineError {
1265    /// Semantic bad-request refusal with no underlying typed error.
1266    pub fn bad_request(context: impl Into<String>) -> Self {
1267        Self::BadRequest {
1268            context: context.into(),
1269            source: None,
1270        }
1271    }
1272
1273    /// Bad request caused by a typed error. The error text is kept in
1274    /// `context` so Display matches the old stringified payload, while
1275    /// the value itself stays reachable via `Error::source()`.
1276    pub fn bad_request_source<E>(error: E) -> Self
1277    where
1278        E: std::error::Error + Send + Sync + 'static,
1279    {
1280        Self::BadRequest {
1281            context: error.to_string(),
1282            source: Some(Box::new(error)),
1283        }
1284    }
1285
1286    /// Semantic policy block with no underlying typed error.
1287    pub fn blocked(context: impl Into<String>) -> Self {
1288        Self::Blocked {
1289            context: context.into(),
1290            source: None,
1291        }
1292    }
1293
1294    /// Block caused by a typed error (see [`Self::bad_request_source`]).
1295    pub fn blocked_source<E>(error: E) -> Self
1296    where
1297        E: std::error::Error + Send + Sync + 'static,
1298    {
1299        Self::Blocked {
1300            context: error.to_string(),
1301            source: Some(Box::new(error)),
1302        }
1303    }
1304
1305    /// Semantic upstream failure with no underlying typed error.
1306    pub fn upstream(context: impl Into<String>) -> Self {
1307        Self::Upstream {
1308            context: context.into(),
1309            source: None,
1310        }
1311    }
1312
1313    /// Upstream failure caused by a typed error (see
1314    /// [`Self::bad_request_source`]).
1315    pub fn upstream_source<E>(error: E) -> Self
1316    where
1317        E: std::error::Error + Send + Sync + 'static,
1318    {
1319        Self::Upstream {
1320            context: error.to_string(),
1321            source: Some(Box::new(error)),
1322        }
1323    }
1324
1325    /// Semantic upstream-timeout failure with no underlying typed
1326    /// error. Response layer maps this to `504 Gateway Timeout`
1327    /// (see [`Self::UpstreamTimeout`] docs).
1328    pub fn upstream_timeout(context: impl Into<String>) -> Self {
1329        Self::UpstreamTimeout {
1330            context: context.into(),
1331            source: None,
1332        }
1333    }
1334
1335    /// Semantic unavailability with no underlying typed error.
1336    pub fn unavailable(context: impl Into<String>) -> Self {
1337        Self::Unavailable {
1338            context: context.into(),
1339            source: None,
1340        }
1341    }
1342
1343    /// Unavailability caused by a typed error (see
1344    /// [`Self::bad_request_source`]).
1345    pub fn unavailable_source<E>(error: E) -> Self
1346    where
1347        E: std::error::Error + Send + Sync + 'static,
1348    {
1349        Self::Unavailable {
1350            context: error.to_string(),
1351            source: Some(Box::new(error)),
1352        }
1353    }
1354    /// HTTP status code corresponding to this failure.
1355    ///
1356    /// * `Blocked` / `Abort` are permanent policy verdicts for the current
1357    ///   session: 403 / 409 stop mainstream SDK auto-retry loops that
1358    ///   would otherwise interpret 429 as transient rate limiting and
1359    ///   burn budget re-hitting the same breaker.
1360    /// * `Unavailable` is transient: 503 is paired with `Retry-After` at
1361    ///   the response layer so intermediaries and clients back off in
1362    ///   bounded fashion (RFC 7231 §7.1.3).
1363    /// * `Unauthorized` is paired with `WWW-Authenticate` at the
1364    ///   response layer (RFC 7235 §3.1).
1365    pub fn status(&self) -> axum::http::StatusCode {
1366        match self {
1367            Self::BadRequest { .. } => axum::http::StatusCode::BAD_REQUEST,
1368            Self::Unauthorized(_) => axum::http::StatusCode::UNAUTHORIZED,
1369            Self::Blocked { .. } => axum::http::StatusCode::FORBIDDEN,
1370            Self::Upstream { .. } => axum::http::StatusCode::BAD_GATEWAY,
1371            Self::UpstreamTimeout { .. } => axum::http::StatusCode::GATEWAY_TIMEOUT,
1372            Self::Abort(_) => axum::http::StatusCode::CONFLICT,
1373            Self::Unavailable { .. } => axum::http::StatusCode::SERVICE_UNAVAILABLE,
1374        }
1375    }
1376}
1377
1378impl AppState {
1379    /// Build fully wired application state with a bounded worker and shared
1380    /// lifecycle services.
1381    pub fn new(
1382        config: HarnessConfig,
1383        store: Arc<dyn StateStore>,
1384        sandbox: Arc<Sandbox>,
1385        bridge: Arc<dyn EventBus>,
1386        identity: Option<Arc<IdentityValidator>>,
1387        signer: Arc<dyn Signer>,
1388    ) -> Result<Self, PipelineError> {
1389        Self::new_with_embedder(
1390            config,
1391            store,
1392            sandbox,
1393            bridge,
1394            identity,
1395            signer,
1396            Arc::new(HashEmbedder::default()),
1397        )
1398    }
1399
1400    /// Build application state with an explicit embedding backend.
1401    #[allow(clippy::too_many_arguments)]
1402    pub fn new_with_embedder(
1403        config: HarnessConfig,
1404        store: Arc<dyn StateStore>,
1405        sandbox: Arc<Sandbox>,
1406        bridge: Arc<dyn EventBus>,
1407        identity: Option<Arc<IdentityValidator>>,
1408        signer: Arc<dyn Signer>,
1409        embedder: Arc<dyn Embedder>,
1410    ) -> Result<Self, PipelineError> {
1411        Self::new_with_backends(
1412            config,
1413            store,
1414            sandbox,
1415            bridge,
1416            identity,
1417            signer,
1418            embedder,
1419            Arc::new(NoopVectorSink),
1420        )
1421    }
1422
1423    /// Build application state with explicit embedding and vector backends.
1424    #[allow(clippy::too_many_arguments)]
1425    pub fn new_with_backends(
1426        config: HarnessConfig,
1427        store: Arc<dyn StateStore>,
1428        sandbox: Arc<Sandbox>,
1429        bridge: Arc<dyn EventBus>,
1430        identity: Option<Arc<IdentityValidator>>,
1431        signer: Arc<dyn Signer>,
1432        embedder: Arc<dyn Embedder>,
1433        vector_sink: Arc<dyn VectorSink>,
1434    ) -> Result<Self, PipelineError> {
1435        Self::new_with_backends_and_metrics(
1436            config,
1437            store,
1438            sandbox,
1439            bridge,
1440            identity,
1441            signer,
1442            embedder,
1443            vector_sink,
1444            Arc::new(Registry::new()),
1445        )
1446    }
1447
1448    /// Build application state reusing a pre-existing metrics registry.
1449    ///
1450    /// `main.rs` uses this so counters registered outside `AppState`
1451    /// (JWKS refresh errors, HTTP shutdown drain timeouts) live on the
1452    /// same registry that gets scraped at `/metrics` — otherwise their
1453    /// samples would be invisible to Prometheus.
1454    #[allow(clippy::too_many_arguments)]
1455    pub fn new_with_backends_and_metrics(
1456        config: HarnessConfig,
1457        store: Arc<dyn StateStore>,
1458        sandbox: Arc<Sandbox>,
1459        bridge: Arc<dyn EventBus>,
1460        identity: Option<Arc<IdentityValidator>>,
1461        signer: Arc<dyn Signer>,
1462        embedder: Arc<dyn Embedder>,
1463        vector_sink: Arc<dyn VectorSink>,
1464        metrics: Arc<Registry>,
1465    ) -> Result<Self, PipelineError> {
1466        let config = Arc::new(config);
1467        // Every stage of the `av_stage_duration_seconds` series MUST
1468        // share the same bucket bounds, otherwise
1469        // `histogram_quantile(0.99, sum by(le) (rate(...[5m])))` in
1470        // Prometheus silently produces nonsense: the `le` values from
1471        // different label combinations do not align and the summation
1472        // is undefined. Use WIDE_LATENCY_BOUNDS_US for all stages so
1473        // a future stage that spans real I/O fits without a bounds
1474        // migration — the fast stages just get more granular
1475        // low-end buckets they will never light up, which is fine.
1476        for stage in ["identity", "quota", "sanitize", "compression", "dispatch"] {
1477            metrics.histogram_with_bounds(
1478                &format!("av_stage_duration_seconds{{stage=\"{stage}\"}}"),
1479                "Harness stage latency",
1480                av_core::metrics::WIDE_LATENCY_BOUNDS_US,
1481            );
1482        }
1483        metrics.counter(
1484            "av_events_dropped_total{stage=\"worker_queue\"}",
1485            "Worker jobs or response-slot reservations dropped, labeled by admission stage",
1486        );
1487        // The OPERATIONS.md alert table names the response_slot stage
1488        // specifically ("Rate > 0 sustained ⇒ response capture
1489        // backpressure"); a lazily-created series renders that alert
1490        // blind until the first drop actually happens.
1491        metrics.counter(
1492            "av_events_dropped_total{stage=\"response_slot\"}",
1493            "Worker jobs or response-slot reservations dropped, labeled by admission stage",
1494        );
1495        // Documented as an Escalate alert ("sudden increase"); it must
1496        // exist from boot for absent()/increase() alerts to arm on
1497        // healthy nodes that never had a capture failure.
1498        metrics.counter(
1499            "av_incomplete_sessions_total",
1500            "Sessions refused due to incomplete capture",
1501        );
1502        metrics.counter(
1503            "av_worker_panics_total",
1504            "Worker job panics isolated by supervisor",
1505        );
1506        metrics.counter("av_worker_errors_total", "Worker jobs that failed");
1507        metrics.counter("av_sessions_finalized_total", "Sessions finalized");
1508        metrics.counter("av_sessions_promoted_total", "Unsigned sessions promoted");
1509        metrics.counter("av_reconcile_errors_total", "Reconciliation errors");
1510        // pre-register recovery-skipped counters so
1511        // Prometheus `absent()` alerts do not fire on healthy nodes
1512        // that have never hit a per-session recovery error, and so
1513        // dashboards render flat-zero instead of "No data". Each
1514        // series must exist on `/metrics` from boot; `Registry::counter`
1515        // is otherwise lazy and only inserts on first `.inc()`.
1516        metrics.counter(
1517            "av_signed_recovery_skipped_total",
1518            "Signed sessions skipped during recovery due to per-session errors",
1519        );
1520        metrics.counter(
1521            "av_signed_recovery_quarantined_total",
1522            "Signed sessions re-adopted as capture-failed quarantines during recovery",
1523        );
1524        metrics.counter(
1525            "av_unsigned_recovery_skipped_total",
1526            "Unsigned step-journal consolidations skipped during recovery due to per-session errors",
1527        );
1528        metrics.counter(
1529            "av_atif_trajectory_recovery_skipped_total",
1530            "ATIF trajectories skipped during recovery due to per-session errors",
1531        );
1532        metrics.counter(
1533            "av_pending_close_completion_failed_total",
1534            "Pending-close completions that failed to finish their tail",
1535        );
1536        // pre-register data-plane series so dashboards
1537        // render flat-zero from boot instead of "No data", and
1538        // `rate() > 0` alerts can fire the first time the bad thing
1539        // happens.
1540        //
1541        // A boot-time completeness test in the same crate
1542        // (`documented_alert_series_are_preregistered_at_boot`) pins
1543        // this set — new fallible arms added in later work must be
1544        // added here or fail CI. That test caught three counters
1545        // (`av_ephemeral_close_failures_total`,
1546        // `av_stream_abort_close_failures_total`,
1547        // `av_stream_abort_no_runtime_total`) that leaked past this
1548        // block as lazy series — the exact defect this pre-registration
1549        // pass exists to prevent.
1550        for kind in ["timeout", "connect", "send", "http_5xx"] {
1551            metrics.counter(
1552                &format!("av_upstream_errors_total{{kind=\"{kind}\"}}"),
1553                "Upstream failures by kind",
1554            );
1555        }
1556        metrics.histogram_with_bounds(
1557            "av_upstream_latency_seconds",
1558            "Time to upstream response headers",
1559            av_core::metrics::WIDE_LATENCY_BOUNDS_US,
1560        );
1561        for route in ["chat", "mcp", "session_close", "session_promote"] {
1562            for status_class in ["2xx", "4xx", "5xx"] {
1563                metrics.counter(
1564                    &format!("av_requests_total{{route=\"{route}\",status_class=\"{status_class}\"}}"),
1565                    "HTTP requests by route and status class",
1566                );
1567            }
1568            metrics.histogram_with_bounds(
1569                &format!("av_request_duration_seconds{{route=\"{route}\"}}"),
1570                "End-to-end HTTP request latency by route",
1571                av_core::metrics::WIDE_LATENCY_BOUNDS_US,
1572            );
1573        }
1574        for reason in [
1575            "too_large",
1576            "read_error",
1577            "invalid_json",
1578            "nonconformant",
1579            "unauthenticated",
1580            "provenance",
1581        ] {
1582            metrics.counter(
1583                &format!("av_atif_recovery_skipped_total{{reason=\"{reason}\"}}"),
1584                "ATIF spool files skipped during recovery",
1585            );
1586        }
1587        metrics.histogram("av_receipt_sign_duration_seconds", "Receipt signing latency");
1588        // pre-register the stream-close failure counters. Each one is
1589        // written only from an error arm on a background drop path,
1590        // so left lazy they never appear on `/metrics` until the first
1591        // failure — the exact "rate() > 0 never fires on a lazily-
1592        // created series" defect this block exists to defeat. Adding
1593        // one here is cheaper than an alert that never triggers.
1594        metrics.counter(
1595            "av_ephemeral_close_failures_total",
1596            "Auto-close of a completed ephemeral one-shot failed; \
1597             the session is left open until the idle sweeper reaps it",
1598        );
1599        metrics.counter(
1600            "av_stream_abort_close_failures_total",
1601            "Background close after a stream abort failed; \
1602             the session is left open until the idle sweeper reaps it",
1603        );
1604        metrics.counter(
1605            "av_stream_abort_no_runtime_total",
1606            "Stream abort observed no tokio runtime; capture marked failed for \
1607             reconciler retry",
1608        );
1609        // Panic-supervision counters — same lazy-series defect the
1610        // stream-close counters above defeat, applied to every
1611        // background loop's `catch_unwind` arm. Absent-series alerts
1612        // (`absent(av_reconciler_panics_total)`) fire a false-positive
1613        // on healthy nodes when the counter is registered only inside
1614        // the panic arm, and `rate() > 0` alerts cannot distinguish
1615        // "never panicked" from "never registered" — so the FIRST
1616        // panic that the guardrail exists to catch slips past the
1617        // alert. Pre-register the five outliers alongside the
1618        // stream-close set. `av_jwks_refresh_panics_total` is
1619        // already eagerly registered at its call site (main.rs
1620        // beside its interval creation); the five below are the
1621        // panic-arm-only stragglers. HELP text lives in the
1622        // `supervision_help` consts (shared with the observation
1623        // sites in main.rs/reconciler.rs) — the registry is
1624        // first-wins on HELP and warns on divergence, and these six
1625        // pairs drifted apart exactly that way (R285).
1626        metrics.counter("av_reconciler_panics_total", supervision_help::RECONCILER_PANICS);
1627        // `av_worker_shard_panics_total` fires from the OUTER
1628        // envelope-routing supervisor in worker.rs — NOT from the
1629        // inner job body (that path uses `av_worker_panics_total`).
1630        // The distinction is load-bearing during incident triage:
1631        // a routing/dispatcher panic points at the shard task
1632        // structure (backlog, channel, batch-boundary logic), while
1633        // a job-body panic points at the specific request that
1634        // panicked. HELP text and comment kept aligned with the
1635        // write site at worker.rs:864.
1636        metrics.counter(
1637            "av_worker_shard_panics_total",
1638            "Worker shard driver panicked outside a job; supervised via catch_unwind",
1639        );
1640        metrics.counter(
1641            "av_bridge_maintenance_panics_total",
1642            supervision_help::BRIDGE_MAINTENANCE_PANICS,
1643        );
1644        metrics.counter(
1645            "av_bridge_maintenance_errors_total",
1646            supervision_help::BRIDGE_MAINTENANCE_ERRORS,
1647        );
1648        metrics.counter(
1649            "av_bridge_maintenance_join_errors_total",
1650            supervision_help::BRIDGE_MAINTENANCE_JOIN_ERRORS,
1651        );
1652        // ATIF retention loop: same supervision discipline. A silent
1653        // panic in the hourly sweep would let the spool grow unbounded
1654        // until it fills the disk (retention is the only in-process
1655        // reclaim path). Pre-register both counters so `rate() > 0`
1656        // alerts see the FIRST fire.
1657        metrics.counter(
1658            "av_atif_retention_panics_total",
1659            supervision_help::ATIF_RETENTION_PANICS,
1660        );
1661        metrics.counter(
1662            "av_atif_retention_errors_total",
1663            supervision_help::ATIF_RETENTION_ERRORS,
1664        );
1665        // TCP_NODELAY setsockopt failure counter — written from the
1666        // axum `tap_io` per-accept hook when `set_nodelay(true)` on
1667        // the freshly-accepted socket fails (typically ENOTCONN /
1668        // EBADF on a SYN-flood / Slowloris torn-down socket, or
1669        // ENOPROTOOPT on an embedded target without full socket-
1670        // option support). The tracing warn is dampened via
1671        // `std::sync::Once` to avoid a per-accept log storm; this
1672        // counter keeps operator visibility of the failure rate.
1673        //
1674        // Help string MUST match the main.rs registration site
1675        // byte-exact: `Registry::counter` returns the pre-registered
1676        // Arc<Counter> and discards subsequent `help` strings, so
1677        // divergent HELP would silently drop the more informative
1678        // one from `/metrics`. Same discipline as every sibling
1679        // `*_panics_total` pre-registration.
1680        metrics.counter(
1681            "av_tcp_nodelay_failures_total",
1682            "TCP_NODELAY setsockopt failed on an accepted connection; the \
1683             suboptimal-latency connection still serves. Rate-limited to a \
1684             single tracing warn per process to avoid a per-accept log storm \
1685             under half-open flood / SYN flood.",
1686        );
1687        // Drop-spawned close-session and admission-refund panics —
1688        // same rationale. Each is written only from a Drop-spawned
1689        // background task on the panic arm.
1690        metrics.counter(
1691            "av_stream_abort_panics_total",
1692            "Background close after a stream abort panicked and was caught; \
1693             the session is left open until the idle sweeper reaps it",
1694        );
1695        metrics.counter(
1696            "av_ephemeral_close_panics_total",
1697            "Auto-close of a completed ephemeral one-shot panicked and was \
1698             caught; the session is left open until the idle sweeper reaps it",
1699        );
1700        metrics.counter(
1701            "av_admission_refund_panics_total",
1702            "Admission-debit refund closure panicked in Drop; the session \
1703             and principal ledgers retain the debit until operator repair",
1704        );
1705        // Idle-close per-session deadline counter. Same pre-
1706        // registration discipline: this counter is emitted only
1707        // from the timeout arm of `close_session` in the reconciler
1708        // idle-close sweep, so a lazy `rate() > 0` alert wouldn't
1709        // see the FIRST fire — exactly the incident the alert
1710        // exists to catch. See OPERATIONS.md.
1711        metrics.counter("av_idle_close_timeouts_total", IDLE_CLOSE_TIMEOUTS_HELP);
1712        // Shutdown-time per-session close deadline counter. Fires
1713        // when the shutdown-path close_session (main.rs
1714        // finalize_sessions loop) hits its 3 s per-session deadline
1715        // — distinct from `av_idle_close_timeouts_total` (steady-
1716        // state idle sweep) and `av_http_shutdown_drain_timeouts_
1717        // total` (outer HTTP-drain phase). Pre-registration matches
1718        // the discipline for the rest of this block: a lazy
1719        // `rate() > 0` alert would miss the FIRST fire on the very
1720        // shutdown that surfaces the class.
1721        metrics.counter(
1722            "av_shutdown_session_close_timeouts_total",
1723            SESSION_CLOSE_TIMEOUTS_HELP,
1724        );
1725        // Shutdown-time MCP-inflight drain-deadline counter. Fires when
1726        // the shutdown barrier waiting for detached mcp_call_inner
1727        // spawns to complete hits its 5 s deadline before `wait_idle`.
1728        // Without this barrier the detached body could reach
1729        // `worker.try_submit` AFTER `wait_idle` returned, race
1730        // `finalize_sessions`, and trip
1731        // `av_shutdown_session_close_timeouts_total` on an otherwise-
1732        // recoverable session. Pre-registration matches the rest of
1733        // the block — any occurrence must be visible to `rate() > 0`.
1734        metrics.counter("av_shutdown_mcp_drain_timeouts_total", MCP_DRAIN_TIMEOUTS_HELP);
1735        // Per-tick recovery-scan cap counter. Every recovery pass that
1736        // walks `read_dir` yields after `MAX_RECOVERY_ENTRIES_PER_TICK`
1737        // entries so a poisoned spool (millions of stale files) does
1738        // not starve legitimate recovery work. Pre-registering each
1739        // labelled series eagerly closes the same "lazy series that
1740        // rate()>0 never sees on the FIRST fire" defect the counters
1741        // above defeat. Keep this labels list in-sync with every call
1742        // to `bump_recovery_scan_cap`.
1743        for pass in [
1744            "adopt_strict_atif",
1745            "recover_signed_journals",
1746            "consolidate_step_journals",
1747            "retry_marked_promotions",
1748            "remove_acked_outboxes",
1749            "replay_lifecycle_outboxes",
1750            "quarantine_orphan_json",
1751        ] {
1752            metrics.counter(
1753                &format!("av_recovery_scan_capped_total{{pass=\"{pass}\"}}"),
1754                "Recovery pass hit the per-tick entry-scan cap and returned \
1755                 early; the next tick will re-open the directory and resume. \
1756                 A steady rate > 0 indicates a poisoned spool (millions of \
1757                 stale files) that will starve legitimate recovery work \
1758                 until an operator quarantines it.",
1759            );
1760        }
1761        // Lifecycle-outbox backlog gauge. Rising baseline is the
1762        // earliest signal of a broker outage — outboxes persist
1763        // across restarts, so the disk-bounded queue grows until the
1764        // bridge is reachable again. Pre-registering the gauge (with
1765        // value 0) makes `absent(av_lifecycle_outbox_pending)`
1766        // alerts distinguish "healthy, no backlog" from
1767        // "reconciler tick hasn't fired yet" — on a fresh boot the
1768        // gauge appears at 0 immediately, then updates on the first
1769        // `remove_acked_lifecycle_outboxes` tick.
1770        metrics.gauge(
1771            "av_lifecycle_outbox_pending",
1772            crate::reconciler::LIFECYCLE_OUTBOX_PENDING_HELP,
1773        );
1774        // Reconciler ticks scan the ATIF spool dir, which can be large;
1775        // finalisation waits for worker drain + broker publish. Wide
1776        // bounds keep long-tail p99 useful under load.
1777        metrics.histogram_with_bounds(
1778            "av_reconcile_duration_seconds",
1779            "Idle reconciliation duration",
1780            av_core::metrics::WIDE_LATENCY_BOUNDS_US,
1781        );
1782        metrics.histogram_with_bounds(
1783            "av_session_finalize_duration_seconds",
1784            "Session finalization latency",
1785            av_core::metrics::WIDE_LATENCY_BOUNDS_US,
1786        );
1787        for endpoint in ["stats", "list", "detail"] {
1788            metrics.histogram(
1789                &format!("av_dashboard_request_duration_seconds{{endpoint=\"{endpoint}\"}}"),
1790                "Dashboard endpoint latency",
1791            );
1792            metrics.counter(
1793                &format!("av_dashboard_requests_total{{endpoint=\"{endpoint}\",status=\"ok\"}}"),
1794                "Dashboard endpoint requests, labeled by status",
1795            );
1796            metrics.counter(
1797                &format!("av_dashboard_requests_total{{endpoint=\"{endpoint}\",status=\"not_found\"}}"),
1798                "Dashboard endpoint requests, labeled by status",
1799            );
1800        }
1801        let sessions = Arc::new(SessionRegistry::new());
1802        let journal_key = crate::journal::key_from_signer(signer.as_ref());
1803        let redaction = if !config.redaction_patterns.is_empty() || !config.redaction_paths.is_empty() {
1804            let redact_config = av_redact::RedactionConfig {
1805                regex_patterns: config.redaction_patterns.clone(),
1806                pointer_paths: config.redaction_paths.clone(),
1807                include_builtin_patterns: true,
1808                ..av_redact::RedactionConfig::default()
1809            };
1810            let engine = av_redact::RedactionEngine::new(redact_config)
1811                .map_err(|e| PipelineError::bad_request(format!("redaction engine: {e}")))?;
1812            Some(Arc::new(engine))
1813        } else {
1814            None
1815        };
1816        let content_sink = crate::content::SharedContentSink::default();
1817        let worker = crate::worker::spawn_worker_with_outputs(
1818            config.worker_channel_capacity,
1819            Arc::clone(&bridge),
1820            embedder,
1821            Arc::clone(&vector_sink),
1822            Some(std::path::PathBuf::from(&config.atif_spool_dir)),
1823            journal_key,
1824            Arc::clone(&metrics),
1825            crate::content::AuditOutputs {
1826                redaction: redaction.clone(),
1827                content: Arc::clone(&content_sink),
1828            },
1829        );
1830        let finalizer = Finalizer::with_bridge(
1831            signer.clone(),
1832            std::path::PathBuf::from(&config.atif_spool_dir),
1833            Arc::clone(&metrics),
1834            Arc::clone(&bridge),
1835        )
1836        .with_state_store(Arc::clone(&store))
1837        // Closes delete the session's vector-store
1838        // points (best-effort).
1839        .with_vector_sink(Arc::clone(&vector_sink));
1840        // One builder for every outbound client, so the backend client
1841        // differs from the shared one only by its resolver.
1842        let build_client = |private_only: bool| -> Result<reqwest::Client, PipelineError> {
1843            let mut client_builder = reqwest::Client::builder()
1844                .connect_timeout(HTTP_CONNECT_TIMEOUT)
1845                .redirect(reqwest::redirect::Policy::none())
1846                // Providers (OpenAI, Anthropic, Azure, Bedrock fronting)
1847                // downgrade or block empty / generic UAs; identifying
1848                // ourselves also gives operators a stable string for
1849                // provider-side triage. Version is baked in at compile
1850                // time so a rolling deploy makes the change visible.
1851                .user_agent(concat!("AgentVisor AI/", env!("CARGO_PKG_VERSION")))
1852                // TCP keepalive so pooled connections behind NAT/L4 LBs
1853                // with short idle windows (AWS NLB 350 s, GCP TCP 600 s,
1854                // stateful FWs often 60-120 s) do not turn every first
1855                // request per pool cycle into a full TCP+TLS re-handshake
1856                // that manifests as a spurious 502/`connection reset`.
1857                .tcp_keepalive(std::time::Duration::from_secs(30))
1858                // Cap pool idle at 60 s: shorter than any realistic NAT
1859                // window, longer than any burst-of-requests batch. Bounds
1860                // the pool memory footprint and forces frequent-enough
1861                // TLS refresh under a rolling cert rotation.
1862                .pool_idle_timeout(std::time::Duration::from_secs(60));
1863            // Reqwest 0.12 is built here
1864            // without decompression features, so the proxy cannot decode any
1865            // content-coding an origin might apply. RFC 9110 §12.5.3 says
1866            // an ABSENT Accept-Encoding permits any coding, so a CDN in
1867            // front of the upstream may legally gzip the response — which
1868            // the chat path then 502's on and the tool path (with the
1869            // decompression guard applied above) would also refuse. Advertise
1870            // `Accept-Encoding: identity` so the routine outcome is
1871            // "identity as requested" and the refusal guards are pure
1872            // defense-in-depth.
1873            let mut default_headers = reqwest::header::HeaderMap::new();
1874            default_headers.insert(
1875                reqwest::header::ACCEPT_ENCODING,
1876                reqwest::header::HeaderValue::from_static("identity"),
1877            );
1878            client_builder = client_builder.default_headers(default_headers);
1879            // Apply a read-timeout floor unconditionally so an
1880            // adversarial or merely broken upstream (chat OR tool) cannot pin
1881            // a session lease + WorkerPermit + tool-intent claim
1882            // indefinitely by accepting the request and then never
1883            // responding. TCP keepalive above only detects a hung
1884            // *connection*, not a slow/silent HTTP response. Operators can
1885            // widen or override via `upstream_read_timeout_s`; the shipped
1886            // default (60 s) is well past any realistic first-token
1887            // latency (Claude p99 ~15 s, GPT-4 p99 ~30 s) but firm enough
1888            // that a stalled provider surfaces as a definite 502 rather
1889            // than a resource-starving hang.
1890            const DEFAULT_UPSTREAM_READ_TIMEOUT_S: u64 = crate::config::DEFAULT_UPSTREAM_READ_TIMEOUT_S;
1891            let read_timeout_s = config
1892                .upstream_read_timeout_s
1893                .unwrap_or(DEFAULT_UPSTREAM_READ_TIMEOUT_S);
1894            client_builder = client_builder.read_timeout(std::time::Duration::from_secs(read_timeout_s));
1895            if config.upstream_http2_prior_knowledge {
1896                client_builder = client_builder.http2_prior_knowledge();
1897            }
1898            if private_only {
1899                client_builder = client_builder.dns_resolver(Arc::new(crate::backend::PrivateOnlyResolver));
1900            }
1901            client_builder.build().map_err(PipelineError::upstream_source)
1902        };
1903        let client = build_client(false)?;
1904        // Tool backends get their own client when `require_private_backends`
1905        // is on: it refuses to connect to any address that is not private.
1906        // The LLM upstream and the AuthZEN PDP keep the shared client.
1907        let backend_client = if config.require_private_backends {
1908            build_client(true)?
1909        } else {
1910            client.clone()
1911        };
1912        let upstream_auth = resolve_upstream_auth(&config)?;
1913        let tool_auth = resolve_tool_auth(&config)?;
1914        let hot_metrics = Arc::new(HotMetrics::new(&metrics, config.strict_stage_budget));
1915        // S3: config validation already refused unsupported providers;
1916        // this defense-in-depth error covers direct constructors that
1917        // bypassed validate() (test builders).
1918        let provider_adapter = crate::provider::adapter_for(&config.provider).ok_or_else(|| {
1919            PipelineError::bad_request(format!("unsupported provider {:?}", config.provider))
1920        })?;
1921        tracing::info!(provider = provider_adapter.name(), "provider adapter selected");
1922        let mcp_admission = Arc::new(tokio::sync::Semaphore::new(config.mcp_concurrency));
1923        // R72 review F1 + F2 (landed R74): eager registration
1924        // stores the returned `Arc<Counter>` handle so the
1925        // refusal arm calls `.inc()` directly, bypassing the
1926        // registry mutex on the hot path. Prior R72 shape
1927        // eagerly registered but discarded the handle,
1928        // requiring a re-lookup per refusal.
1929        let mcp_admission_refusals_counter = metrics.counter(
1930            "av_mcp_admission_refusals_total",
1931            "MCP admission was refused because `mcp_concurrency` was already at capacity — \
1932             each admitted call can buffer up to 16 MiB in `read_limited_tool_response`, \
1933             so the cap bounds worst-case MCP resident memory. Sustained > 0 indicates \
1934             tool-upstream slowdown, a burst-traffic event, or an undersized \
1935             `mcp_concurrency`.",
1936        );
1937        for pdp_code in ["UNMAPPED_TOOL", "MISSION_EXPIRED", "MISSION_DENIED", "PDP_DENIED"] {
1938            metrics.counter(
1939                &format!("av_pdp_denials_total{{code=\"{pdp_code}\"}}"),
1940                "PDP denials by denial code",
1941            );
1942        }
1943        metrics.counter(
1944            crate::routes::TOKENS_REVOKED_METRIC,
1945            crate::routes::TOKENS_REVOKED_HELP,
1946        );
1947        let backend_router = crate::backend::BackendRouter::new(
1948            &config.backends,
1949            config.tool_upstream_url.as_deref(),
1950            tool_auth.clone(),
1951        )
1952        .map_err(|e| PipelineError::bad_request(format!("backend routing: {e}")))?
1953        .with_fallback_transport(config.tool_upstream_transport);
1954        let mcp_backends = Arc::new(crate::mcp_client::McpBackendClient::new(backend_client.clone()));
1955        let mcp_sessions = Arc::new(crate::mcp::SessionSigner::derive(&journal_key));
1956        let authzen = config
1957            .authzen
1958            .as_ref()
1959            .map(|authzen| crate::authzen::AuthzenClient::from_config(authzen, client.clone()).map(Arc::new))
1960            .transpose()
1961            .map_err(|e| PipelineError::bad_request(format!("authzen: {e}")))?;
1962        let workload = crate::workload::WorkloadIssuer::from_config(&config)
1963            .map_err(|e| PipelineError::bad_request(format!("workload identity: {e}")))?
1964            .map(Arc::new);
1965        let token_signer = if let Some(ref seed_path) = config.token_exchange_seed_file {
1966            let seed = load_exchange_seed(seed_path)
1967                .map_err(|e| PipelineError::bad_request(format!("exchange signing key: {e}")))?;
1968            let ts = Arc::new(crate::authz::TokenSigner::from_seed(&seed));
1969            if ts.kid() == signer.key_id() {
1970                return Err(PipelineError::bad_request(
1971                    "token_exchange_seed_file must use a different key than the receipt signer \
1972                     (both derive the same kid, which means the same seed)"
1973                        .to_owned(),
1974                ));
1975            }
1976            Some(ts)
1977        } else {
1978            None
1979        };
1980        let pdp = crate::authz::PolicyDecisionPoint::from_config(&config, token_signer.clone());
1981        let exchanged_revocations: Arc<dyn av_identity::RevocationStore> = if config.state_backend == "redis"
1982        {
1983            Arc::new(crate::revocation::GuardedRevocationStore::new(
1984                Arc::new(crate::revocation::StateRevocationStore::with_namespace(
1985                    Arc::clone(&store),
1986                    "exchanged",
1987                )),
1988                &metrics,
1989                "exchanged",
1990            ))
1991        } else {
1992            Arc::new(av_identity::InMemoryRevocationStore::new())
1993        };
1994        Ok(Self {
1995            config,
1996            store,
1997            sandbox,
1998            bridge,
1999            sessions,
2000            worker,
2001            identity,
2002            exchanged_revocations,
2003            revocation_audit_window: Arc::new(parking_lot::Mutex::new((Instant::now(), 0))),
2004            hot_metrics,
2005            metrics,
2006            client,
2007            backend_client,
2008            upstream_auth,
2009            tool_auth,
2010            tool_audit_gates: Arc::default(),
2011            tool_audits_emitted: Arc::default(),
2012            provider_adapter,
2013            finalizer,
2014            draining: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2015            identity_rejection_window: Arc::new(parking_lot::Mutex::new((Instant::now(), 0))),
2016            journal_key,
2017            mcp_inflight: Arc::new(crate::inflight::InflightTracker::new()),
2018            mcp_admission,
2019            mcp_admission_refusals_counter,
2020            backend_router: Arc::new(backend_router),
2021            pdp: Arc::new(pdp),
2022            token_signer,
2023            redaction,
2024            mcp_backends,
2025            mcp_sessions,
2026            authzen,
2027            content_sink,
2028            workload,
2029        })
2030    }
2031
2032    /// Install the sink that receives every journaled step in content form
2033    /// (used by `agentvisord` for full-content tenant telemetry).
2034    ///
2035    /// The sink runs on its own thread behind a bounded queue, so it can
2036    /// neither slow down nor fail the audit worker.
2037    pub fn set_content_sink(&self, sink: Arc<dyn crate::content::ContentSink>) -> std::io::Result<()> {
2038        let dispatcher = crate::content::ContentDispatcher::spawn(sink, &self.metrics)?;
2039        *self.content_sink.write() = Some(Arc::new(dispatcher));
2040        Ok(())
2041    }
2042
2043    /// Run identity, breaker, quota, sanitize, compression, and asynchronous
2044    /// dispatch in the mandated order without awaiting worker or upstream I/O.
2045    ///
2046    /// `identity_hint`, when `Some`, is trusted as the resolved identity for
2047    /// this request — the internal `resolve_identity` + audit-enqueue step
2048    /// is skipped. Used by `chat_completions` (R72 F3 pre-admission gate)
2049    /// which already resolved the identity to fail-fast on unauthenticated
2050    /// probes before the body parses. Without this hint, the R72 pre-check
2051    /// path would `enqueue_transient_failure` on a JWKS-rotation race
2052    /// (pre-check success + re-check failure between the two calls),
2053    /// double-counting `av_identity_rejections_total` and burning two
2054    /// of the sampled full-audit slots per rejected request (R73 review
2055    /// F3). `None` preserves the historical behaviour for callers that
2056    /// do NOT pre-resolve.
2057    pub fn prepare_chat(
2058        &self,
2059        headers: &HeaderMap,
2060        payload: Value,
2061        identity_hint: Option<AgentIdentity>,
2062    ) -> Result<PreparedRequest, PipelineError> {
2063        self.prepare_chat_with_caller(headers, payload, identity_hint, None)
2064    }
2065
2066    fn prepare_chat_with_caller(
2067        &self,
2068        headers: &HeaderMap,
2069        mut payload: Value,
2070        identity_hint: Option<AgentIdentity>,
2071        caller: Option<&AuthenticatedCaller>,
2072    ) -> Result<PreparedRequest, PipelineError> {
2073        let total_started = Instant::now();
2074        let session_id = session_id(headers)?;
2075        let workflow = workflow(headers, &self.config.default_workflow)?;
2076        // Multi-choice completions are not attestable: the capture path
2077        // accumulates ONE response message per step, so `n > 1` deltas
2078        // from different choices would interleave into a text no model
2079        // produced — and the ATIF step, receipt, and loop-detector
2080        // embedding would attest it (the tool-call accumulator is keyed
2081        // by choice index for exactly this reason). Refuse up front,
2082        // before any budget debit. Fail closed on ANY present `n` that
2083        // is not exactly the integer 1: upstream OpenAI-compatible
2084        // servers lax-coerce `2.0` / `"2"` to a real multi-choice
2085        // request, so an as_u64-only check was bypassable by float or
2086        // string encodings.
2087        match payload.get("n") {
2088            None | Some(serde_json::Value::Null) => {}
2089            Some(n) if n.as_u64() == Some(1) => {}
2090            Some(_) => {
2091                return Err(PipelineError::bad_request(
2092                    "multi-choice completions (n != 1) are not supported: the audit capture attests a single response message per request".to_owned(),
2093                ));
2094            }
2095        }
2096
2097        // Validate and extract the relayed headers BEFORE any budget
2098        // debit or admission journaling: `single_header` refuses
2099        // multi-valued occurrences, and its `?` used to fire from the
2100        // `PreparedRequest` construction AFTER both ledgers were
2101        // debited with no refund arm — a client repeating a duplicate
2102        // `openai-beta` header drained session + principal budgets
2103        // without a single upstream call.
2104        let client_authorization = if self.config.upstream_authorization_passthrough {
2105            single_header(headers, "authorization")?.cloned()
2106        } else {
2107            None
2108        };
2109        // Strict allowlist of provider-semantic headers. Same
2110        // single-value discipline as every other relayed header
2111        // (a multi-valued occurrence is refused at ingress).
2112        const UPSTREAM_HEADER_ALLOWLIST: [&str; 3] = ["openai-organization", "openai-project", "openai-beta"];
2113        let mut upstream_passthrough_headers = Vec::new();
2114        for name in UPSTREAM_HEADER_ALLOWLIST {
2115            if let Some(value) = single_header(headers, name)? {
2116                upstream_passthrough_headers.push((axum::http::HeaderName::from_static(name), value.clone()));
2117            }
2118        }
2119
2120        let stage = Instant::now();
2121        let identity = if let Some(pre_resolved) = identity_hint {
2122            // R73 review F3: pre-resolved from an earlier caller
2123            // (chat_completions pre-admission gate). Skip the
2124            // internal resolve + audit-enqueue so a rotation race
2125            // between the two calls cannot double-count
2126            // `av_identity_rejections_total` or burn a second
2127            // full-audit sample slot per request.
2128            pre_resolved
2129        } else {
2130            match self.resolve_identity(headers, Some(&self.config.chat_scope)) {
2131                Ok(identity) => identity,
2132                Err(error) => {
2133                    self.audit_authentication_error(headers, &error)?;
2134                    return Err(error);
2135                }
2136            }
2137        };
2138        self.observe_stage("identity", stage);
2139
2140        let session = self
2141            .sessions
2142            .get_or_open(&session_id, workflow, &identity, &self.config.breaker);
2143        let admission = session.admission_guard();
2144        validate_session_binding(&session, workflow, &identity)?;
2145        if let Some(caller) = caller {
2146            caller.bind_session(&session)?;
2147        }
2148        session.refresh_identity(&identity);
2149        if session.is_closed() {
2150            // Distinguish the PERMANENT quarantine (empty-unsigned close
2151            // refusal — deliberately never recycled) from the TRANSIENT
2152            // closing window. `get_or_open` recycles completed-close
2153            // entries, so a session observed here as closed-but-not-
2154            // complete is mid-close: an SDK auto-retry (e.g. after a
2155            // read-timeout disconnect triggered the background close)
2156            // used to hit a terminal 400 "already closed" — misleading
2157            // (the client closed nothing) and never retried by SDKs,
2158            // even though the very next attempt would have succeeded
2159            // against the recycled id. 503 + Retry-After lets standard
2160            // retry policies ride out the window.
2161            // Sealed capture-failed quarantines are ALSO closed-but-
2162            // never-complete — permanently (never recycled, never
2163            // evicted). They keep the terminal 400 (permanent
2164            // refusals route to 400 with no Retry-After);
2165            // the transient 503 below would otherwise instruct SDK
2166            // retry policies to poll forever against a session that
2167            // will refuse forever. Checked BEFORE the empty-quarantine
2168            // predicate, which capture-failed conversions can also
2169            // match — the "no captured steps" message was inaccurate
2170            // for sessions that DID capture steps before failing.
2171            if session.capture_failed() {
2172                return Err(PipelineError::bad_request(
2173                    "session is quarantined (audit capture failed); use a new session id".to_owned(),
2174                ));
2175            }
2176            if session.is_empty_unsigned_quarantine() {
2177                return Err(PipelineError::bad_request(
2178                    "session is quarantined (closed with no captured steps); use a new session id".to_owned(),
2179                ));
2180            }
2181            // Enforcement-latched sessions are terminal for the id:
2182            // `get_or_open` deliberately refuses to recycle them (a
2183            // fresh incarnation would carry a fresh budget/breaker on
2184            // the same id). 403, matching the deliberate not-429/not-
2185            // transient policy for budget and breaker refusals. Keyed
2186            // on the sticky latch, not the overwritable last stop
2187            // reason.
2188            if session.close_complete_flag() && session.enforcement_tripped() {
2189                return Err(PipelineError::blocked(
2190                    "session was closed by budget or loop enforcement; use a new session id".to_owned(),
2191                ));
2192            }
2193            return Err(PipelineError::unavailable(
2194                "session close is completing; retry shortly or use a new session id".to_owned(),
2195            ));
2196        }
2197        if session.capture_failed() {
2198            return Err(PipelineError::unavailable(
2199                "session audit capture is incomplete".to_owned(),
2200            ));
2201        }
2202        session.touch();
2203        if session.loop_state.state() == BreakerState::Open {
2204            match session.loop_state.action() {
2205                BreakerAction::Reject => {
2206                    session.latch_enforcement(StopReason::LoopDetected);
2207                    return Err(PipelineError::blocked(
2208                        "semantic loop circuit breaker is open".to_owned(),
2209                    ));
2210                }
2211                BreakerAction::Abort => {
2212                    session.latch_enforcement(StopReason::LoopDetected);
2213                    return Err(PipelineError::Abort(
2214                        "semantic loop circuit breaker requested connection close".to_owned(),
2215                    ));
2216                }
2217                BreakerAction::Inject => {
2218                    inject_corrective_message(&mut payload)?;
2219                    session.loop_state.reset();
2220                }
2221                _ => {
2222                    return Err(PipelineError::blocked(
2223                        "unsupported semantic loop enforcement action".to_owned(),
2224                    ));
2225                }
2226            }
2227        }
2228
2229        // Fused acquire: one worker slot + one response slot atomically.
2230        // If the worker slot succeeds and the response slot fails, the
2231        // worker permit drops via RAII (its OwnedSemaphorePermit and
2232        // mpsc reservation release cleanly), so the caller never sees
2233        // an orphaned half-reservation. Distinct
2234        // `av_events_dropped_total{stage=worker_queue|response_slot}`
2235        // counters let operators tell which one exhausted.
2236        let permits = self
2237            .worker
2238            .try_reserve_pair(&session_id)
2239            .map_err(PipelineError::unavailable_source)?;
2240        let worker_permit = permits.worker;
2241        let response_permit = permits.response;
2242
2243        let stage = Instant::now();
2244        let raw_prompt_tokens = av_core::tokens::approx_tokens_json(&payload);
2245        // Budget is charged AFTER compression on `compression.tokens_after`
2246        // (below). Deferring the debit closes the enforcement/attestation
2247        // gap the review flagged in §6.3: previously the ledger was debited
2248        // on the uncompressed payload while the receipt attested the
2249        // post-compression count, over-charging clients by ~40% on any
2250        // compressible history and making compression's savings invisible
2251        // to `max_tokens`. The genuine-cap-refusal latch (upstream's
2252        // enforcement-latch rounds 11-13) is applied at the deferred
2253        // charge site below.
2254        self.observe_stage("quota", stage);
2255
2256        let stage = Instant::now();
2257        if let Err(reason) = self.sandbox.sanitize("chat/completions", &payload) {
2258            let error = PipelineError::blocked(reason);
2259            // No token refund needed: the budget debit happens AFTER
2260            // compression (below), so a sanitize block exits before
2261            // anything was charged.
2262            worker_permit.submit(self.failure_job(
2263                Arc::clone(&session),
2264                identity.clone(),
2265                StopReason::PolicyBlocked,
2266                error.to_string(),
2267            ));
2268            return Err(error);
2269        }
2270        self.observe_stage("sanitize", stage);
2271
2272        let stage = Instant::now();
2273        let compression = if self.config.compression_enabled {
2274            av_compress::compress(
2275                &payload,
2276                &av_compress::CompressionConfig {
2277                    // Authenticate compression stubs under a key derived
2278                    // from the journal key, so user text that quotes a
2279                    // stub cannot impersonate a machine-emitted one and
2280                    // disable the middle-history pass.
2281                    marker_key: Some(av_compress::derive_marker_key(&self.journal_key)),
2282                    ..av_compress::CompressionConfig::default()
2283                },
2284            )
2285        } else {
2286            av_compress::CompressionOutcome {
2287                payload,
2288                tokens_before: raw_prompt_tokens,
2289                tokens_after: raw_prompt_tokens,
2290                changed: false,
2291            }
2292        };
2293        self.observe_stage("compression", stage);
2294
2295        // Post-compression budget charge. Both the principal and session
2296        // ledgers spend `compression.tokens_after` — the same number
2297        // the signed receipt attests, so budget refusals and the audit
2298        // artifact always agree. On refusal, if the principal debit
2299        // landed but the session cap refuses, the principal amount is
2300        // refunded to keep failed requests non-consumptive.
2301        let stage = Instant::now();
2302        let billed_tokens = compression.tokens_after;
2303        let principal_billing_id = self
2304            .config
2305            .principal_budget
2306            .as_ref()
2307            .map(|_| principal_id_for_budget(&identity));
2308        if let (Some(spec), Some(principal_id)) = (
2309            self.config.principal_budget.as_ref(),
2310            principal_billing_id.as_deref(),
2311        ) {
2312            let quota = match ActionBudget::for_principal(self.store.as_ref(), principal_id, spec)
2313                .try_tokens(billed_tokens)
2314            {
2315                Ok(quota) => quota,
2316                Err(error) => {
2317                    let error = PipelineError::Blocked {
2318                        context: format!("quota backend failed closed (principal): {error}"),
2319                        source: Some(Box::new(error)),
2320                    };
2321                    worker_permit.submit(self.failure_job(
2322                        Arc::clone(&session),
2323                        identity.clone(),
2324                        StopReason::BudgetExceeded,
2325                        error.to_string(),
2326                    ));
2327                    return Err(error);
2328                }
2329            };
2330            if let BudgetDecision::Refused { limit, cap } = quota {
2331                // Genuine cap exhaustion — latch (upstream rounds 11-13;
2332                // the backend-ERROR arms deliberately do not: a store
2333                // outage is not enforcement).
2334                session.latch_enforcement(StopReason::BudgetExceeded);
2335                let error = PipelineError::blocked(format!("principal.{limit} exceeded (cap {cap})"));
2336                worker_permit.submit(self.failure_job(
2337                    Arc::clone(&session),
2338                    identity.clone(),
2339                    StopReason::BudgetExceeded,
2340                    error.to_string(),
2341                ));
2342                return Err(error);
2343            }
2344        }
2345        let quota = match ActionBudget::new(self.store.as_ref(), &session_id, &self.config.budget)
2346            .try_tokens(billed_tokens)
2347        {
2348            Ok(quota) => quota,
2349            Err(error) => {
2350                if let (Some(spec), Some(principal_id)) = (
2351                    self.config.principal_budget.as_ref(),
2352                    principal_billing_id.as_deref(),
2353                ) {
2354                    ActionBudget::for_principal(self.store.as_ref(), principal_id, spec)
2355                        .refund_tokens(billed_tokens);
2356                }
2357                let error = PipelineError::Blocked {
2358                    context: format!("quota backend failed closed: {error}"),
2359                    source: Some(Box::new(error)),
2360                };
2361                worker_permit.submit(self.failure_job(
2362                    Arc::clone(&session),
2363                    identity.clone(),
2364                    StopReason::BudgetExceeded,
2365                    error.to_string(),
2366                ));
2367                return Err(error);
2368            }
2369        };
2370        if let BudgetDecision::Refused { limit, cap } = quota {
2371            if let (Some(spec), Some(principal_id)) = (
2372                self.config.principal_budget.as_ref(),
2373                principal_billing_id.as_deref(),
2374            ) {
2375                ActionBudget::for_principal(self.store.as_ref(), principal_id, spec)
2376                    .refund_tokens(billed_tokens);
2377            }
2378            // Genuine cap exhaustion — latch (upstream rounds 11-13).
2379            session.latch_enforcement(StopReason::BudgetExceeded);
2380            let error = PipelineError::blocked(format!("{limit} exceeded (cap {cap})"));
2381            worker_permit.submit(self.failure_job(
2382                Arc::clone(&session),
2383                identity.clone(),
2384                StopReason::BudgetExceeded,
2385                error.to_string(),
2386            ));
2387            return Err(error);
2388        }
2389        self.observe_stage("quota_post_compression", stage);
2390
2391        let stage = Instant::now();
2392        let text = last_message_text(&compression.payload);
2393        let atif = match atif_capture_from_request(&compression.payload) {
2394            Ok(atif) => atif,
2395            Err(error) => {
2396                // Never forwarded — refund the post-compression debit on
2397                // BOTH ledgers (see the refusal arms above).
2398                ActionBudget::new(self.store.as_ref(), &session_id, &self.config.budget)
2399                    .refund_tokens(billed_tokens);
2400                if let (Some(spec), Some(principal_id)) = (
2401                    self.config.principal_budget.as_ref(),
2402                    principal_billing_id.as_deref(),
2403                ) {
2404                    ActionBudget::for_principal(self.store.as_ref(), principal_id, spec)
2405                        .refund_tokens(billed_tokens);
2406                }
2407                worker_permit.submit(self.failure_job(
2408                    Arc::clone(&session),
2409                    identity.clone(),
2410                    StopReason::Other,
2411                    error.to_string(),
2412                ));
2413                return Err(error);
2414            }
2415        };
2416        let analyze_loop = atif.source == av_atif::Source::Agent && !text.is_empty();
2417        let response_attempt_id = av_core::new_event_uid();
2418        let job = WorkerJob {
2419            session: Arc::clone(&session),
2420            identity: identity.clone(),
2421            class: EventClass::Compression,
2422            payload: serde_json::json!({
2423                "changed": compression.changed,
2424                "tokens_before": compression.tokens_before,
2425                "tokens_after": compression.tokens_after,
2426            }),
2427            text,
2428            analyze_loop,
2429            status: StatusId::Success,
2430            stop_reason: None,
2431            native_stop_reason: None,
2432            metrics: EventMetrics {
2433                prompt_tokens: Some(compression.tokens_after),
2434                completion_tokens: Some(0),
2435                cached_tokens: Some(0),
2436                pruned_tokens: Some(compression.pruned_tokens()),
2437                pruning_ratio_millis: Some(compression.pruning_ratio_millis()),
2438            },
2439            cost_usd_micros: 0,
2440            prompt_token_correction: 0,
2441            atif: Some(atif),
2442            response_marker: None,
2443            response_attempt: Some(crate::worker::ResponseAttempt {
2444                id: response_attempt_id.clone(),
2445                terminal: false,
2446            }),
2447        };
2448        worker_permit.submit(job);
2449        self.observe_stage("dispatch", stage);
2450        let lease = SessionLease::new(Arc::clone(&session));
2451        // Armed from here on: any early exit that drops the request —
2452        // including the `?` on the passthrough header below and, above
2453        // all, the request future being cancelled by a client disconnect
2454        // at any later await — submits the terminal failure record that
2455        // closes the journalled attempt (and retires the marker, once
2456        // `forward_chat` sets one).
2457        let capture_guard = ResponseCaptureGuard::new(
2458            self.worker.clone(),
2459            Arc::clone(&session),
2460            identity.clone(),
2461            response_attempt_id,
2462            Some(response_permit),
2463        );
2464        drop(admission);
2465
2466        let admission_debit = AdmissionDebit {
2467            store: Arc::clone(&self.store),
2468            session_id: session.id.clone(),
2469            budget: self.config.budget.clone(),
2470            principal: self
2471                .config
2472                .principal_budget
2473                .as_ref()
2474                .map(|spec| (principal_id_for_budget(&identity), spec.clone())),
2475            tokens: billed_tokens,
2476            metrics: Arc::clone(&self.metrics),
2477        };
2478        Ok(PreparedRequest {
2479            session,
2480            identity,
2481            payload: compression.payload,
2482            middleware_us: elapsed_us(total_started),
2483            admission_debit,
2484            lease,
2485            capture_guard,
2486            client_authorization,
2487            upstream_passthrough_headers,
2488        })
2489    }
2490
2491    /// Run synchronous local gates without waiting for off-path journal,
2492    /// embedding, or broker work.
2493    ///
2494    /// The inline/blocking decision keys off PAYLOAD
2495    /// SIZE, not off whether a token budget happens to be configured.
2496    /// The cost of `prepare_chat` (parse-adjacent token scanning,
2497    /// sanitize, compression) is a function of body size — measured
2498    /// 1.9 ms p50 of uninterruptible CPU at a 128 KB body, which
2499    /// stalls every in-flight SSE stream's poll on that tokio worker.
2500    /// Previously a configured `budget.max_tokens` (the one thing that
2501    /// makes quotas work) was what moved the work to `spawn_blocking`,
2502    /// which was backwards. Small bodies stay inline (spawn_blocking
2503    /// costs ~10-20 µs of handoff — 2× the total gate cost at 75 B).
2504    ///
2505    /// The `budget.max_tokens.is_none()` clause below is load-bearing,
2506    /// not a leftover: with a budget configured, `prepare_chat` calls
2507    /// `ActionBudget::try_tokens` → `StateStore::try_spend`, which on
2508    /// the redis backend is a synchronous network round-trip — that
2509    /// must never run inline on a tokio worker, whatever the body size.
2510    /// The same applies to `[principal_budget]`: it drives its own
2511    /// `for_principal(...).try_tokens` store round-trip in
2512    /// `prepare_chat` even when the session `max_tokens` is unset, so
2513    /// it must equally force the blocking pool.
2514    /// Async wrapper over [`Self::prepare_chat`] that spawn-blocks on
2515    /// bodies large enough to stall the reactor. See `prepare_chat`
2516    /// for the `identity_hint` contract.
2517    pub async fn prepare_chat_nonblocking(
2518        &self,
2519        headers: &HeaderMap,
2520        payload: Value,
2521        body_bytes: usize,
2522        identity_hint: Option<AgentIdentity>,
2523    ) -> Result<PreparedRequest, PipelineError> {
2524        if self.chat_preparation_can_run_inline(body_bytes)
2525            // Without a trusted hint, a configured validator may perform
2526            // synchronous shared-revocation I/O even for a tiny request.
2527            && (identity_hint.is_some() || self.identity.is_none())
2528        {
2529            return self.prepare_chat(headers, payload, identity_hint);
2530        }
2531        let state = self.clone();
2532        let headers = headers.clone();
2533        tokio::task::spawn_blocking(move || state.prepare_chat(&headers, payload, identity_hint))
2534            .await
2535            .map_err(PipelineError::unavailable_source)?
2536    }
2537
2538    fn chat_preparation_can_run_inline(&self, body_bytes: usize) -> bool {
2539        // Above 16 KiB, CPU-bound gates can stall the reactor. Any token
2540        // budget can also call a synchronous state backend, regardless of size.
2541        body_bytes <= 16 * 1024
2542            && self.config.budget.max_tokens.is_none()
2543            && self.config.principal_budget.is_none()
2544    }
2545
2546    pub(crate) async fn authenticate_chat_nonblocking(
2547        &self,
2548        headers: &HeaderMap,
2549    ) -> Result<AuthenticatedCaller, PipelineError> {
2550        if self.identity.is_none() {
2551            // Preserve all anonymous/header checks without queueing local work
2552            // behind durable filesystem writes in the shared blocking pool.
2553            let caller = self.authenticate(headers)?;
2554            caller.authorize(self, Some(&self.config.chat_scope))?;
2555            return Ok(caller);
2556        }
2557        let state = self.clone();
2558        let headers = headers.clone();
2559        tokio::task::spawn_blocking(move || {
2560            let caller = state.authenticate(&headers)?;
2561            caller.authorize(&state, Some(&state.config.chat_scope))?;
2562            Ok(caller)
2563        })
2564        .await
2565        .map_err(PipelineError::unavailable_source)?
2566    }
2567
2568    pub(crate) async fn prepare_authenticated_chat(
2569        &self,
2570        headers: &HeaderMap,
2571        payload: Value,
2572        body_bytes: usize,
2573        caller: AuthenticatedCaller,
2574    ) -> Result<PreparedRequest, PipelineError> {
2575        if self.chat_preparation_can_run_inline(body_bytes) {
2576            // Authentication already finished. This rechecks only local
2577            // expiry/scope and retains principal binding during preparation.
2578            let identity = caller.authorize(self, Some(&self.config.chat_scope))?;
2579            return self.prepare_chat_with_caller(headers, payload, Some(identity), Some(&caller));
2580        }
2581        let state = self.clone();
2582        let headers = headers.clone();
2583        tokio::task::spawn_blocking(move || {
2584            let identity = caller.authorize(&state, Some(&state.config.chat_scope))?;
2585            state.prepare_chat_with_caller(&headers, payload, Some(identity), Some(&caller))
2586        })
2587        .await
2588        .map_err(PipelineError::unavailable_source)?
2589    }
2590
2591    /// Prepare a request and wait until its audit record is durably captured
2592    /// before the provider can observe it.
2593    pub async fn prepare_chat_durable(
2594        &self,
2595        headers: &HeaderMap,
2596        payload: Value,
2597    ) -> Result<PreparedRequest, PipelineError> {
2598        if let Ok(id) = session_id(headers) {
2599            if let Some(session) = self.sessions.get(&id) {
2600                session.wait_for_worker_jobs().await;
2601                if session.capture_failed() {
2602                    return Err(PipelineError::unavailable(
2603                        "session audit capture is incomplete".to_owned(),
2604                    ));
2605                }
2606            }
2607        }
2608        let state = self.clone();
2609        let headers = headers.clone();
2610        let mut prepared = tokio::task::spawn_blocking(move || state.prepare_chat(&headers, payload, None))
2611            .await
2612            .map_err(PipelineError::unavailable_source)??;
2613        prepared.session.wait_for_worker_jobs().await;
2614        if prepared.session.capture_failed() {
2615            let error = PipelineError::unavailable(
2616                "request audit capture failed before provider dispatch".to_owned(),
2617            );
2618            self.abandon_prepared(&mut prepared, StopReason::Other, &error.to_string());
2619            return Err(error);
2620        }
2621        if prepared.session.loop_state.state() == BreakerState::Open {
2622            let action = prepared.session.loop_state.action();
2623            // Latch the terminal enforcement actions only: Inject's
2624            // refusal here is genuinely transient (the client retry
2625            // re-enters prepare_chat, which injects the corrective
2626            // message and proceeds), so it must not make the id
2627            // terminal. Without this latch, a breaker trip delivered
2628            // from THIS site (opened during the audit wait) left the
2629            // id recyclable with a fresh breaker.
2630            if matches!(action, BreakerAction::Abort | BreakerAction::Reject) {
2631                prepared.session.latch_enforcement(StopReason::LoopDetected);
2632            }
2633            let error = match action {
2634                BreakerAction::Abort => {
2635                    PipelineError::Abort("semantic loop circuit breaker opened during audit".to_owned())
2636                }
2637                _ => PipelineError::blocked(
2638                    "semantic loop circuit breaker opened during audit; retry required".to_owned(),
2639                ),
2640            };
2641            self.abandon_prepared(&mut prepared, StopReason::LoopDetected, &error.to_string());
2642            return Err(error);
2643        }
2644        Ok(prepared)
2645    }
2646
2647    /// Close out a prepared request that will never reach `forward_chat`.
2648    ///
2649    /// `prepare_chat` journals a non-terminal [`crate::worker::ResponseAttempt`]
2650    /// with its admission record; the matching terminal record normally comes
2651    /// from `forward_chat`'s failure path or the response relay. A caller that
2652    /// abandons the request after admission must submit the terminal failure
2653    /// record itself — otherwise the journal ends with a dangling non-terminal
2654    /// attempt and a later crash-recovery scan quarantines the whole session
2655    /// over a request the client already saw fail.
2656    fn abandon_prepared(&self, prepared: &mut PreparedRequest, stop_reason: StopReason, reason: &str) {
2657        // Every abandon site runs strictly before upstream dispatch
2658        // (durable-capture failure, breaker-open, marker-write failure),
2659        // so the admission token debit provably bought no LLM work —
2660        // refund it exactly once on BOTH ledgers (the guard zeroes
2661        // itself, so a hypothetical double abandon — or the guard's own
2662        // Drop — cannot double-refund).
2663        prepared.admission_debit.refund_now();
2664        let Some(permit) = prepared.capture_guard.take_permit() else {
2665            // Defensive: no permit means the guard's Drop is the only
2666            // resolver left. Leave it armed so the terminal record lands
2667            // through the plain worker queue on drop.
2668            return;
2669        };
2670        // Take the marker so the terminal job below retires it too.
2671        let response_marker = prepared.capture_guard.defuse();
2672        let mut job = self.failure_job(
2673            Arc::clone(&prepared.session),
2674            prepared.identity.clone(),
2675            stop_reason,
2676            reason.to_owned(),
2677        );
2678        job.response_marker = response_marker;
2679        job.response_attempt = Some(crate::worker::ResponseAttempt {
2680            id: prepared.capture_guard.attempt_id().to_owned(),
2681            terminal: true,
2682        });
2683        // Every sibling refusal path (`forward_chat` Err, both
2684        // `chat_completions` arms, the guard's own Drop) fails the
2685        // session closed on submit failure so the dangling attempt
2686        // cannot silently strand. Mirror that here: without it,
2687        // defusing the guard first would lose the safety net this whole
2688        // guard machinery exists to provide.
2689        if permit.submit(&self.worker, job).is_err() {
2690            prepared.session.mark_capture_failed();
2691        }
2692    }
2693
2694    /// Forward a prepared OpenAI-compatible request to the configured provider.
2695    pub async fn forward_chat(
2696        &self,
2697        mut request: PreparedRequest,
2698    ) -> Result<ForwardedResponse, PipelineError> {
2699        // Digest the request payload so the in-flight marker can be
2700        // matched to the observed response bytes at recovery time.
2701        //
2702        // `serde_json::to_vec` on a `Value` is effectively infallible
2703        // (Value can only carry JSON-serialisable data), but we handle
2704        // the theoretical error path anyway: falling back to
2705        // `sha256(b"")` — the well-known empty digest — would make
2706        // every concurrent failed serialisation collide on the same
2707        // request_digest, silently violating the marker's
2708        // one-to-one-with-request invariant. Fall back to a
2709        // session-id-derived digest so a hypothetical failure at
2710        // least keeps distinct sessions distinct.
2711        // Serialize the payload exactly ONCE — the
2712        // bytes feed both the request digest and the upstream body.
2713        // Previously `serde_json::to_vec` here and reqwest's
2714        // `.json(&payload)` one screen apart produced identical bytes
2715        // twice per request.
2716        let payload_bytes = match serde_json::to_vec(&request.payload) {
2717            Ok(bytes) => Some(bytes),
2718            Err(error) => {
2719                tracing::error!(
2720                    %error,
2721                    session = %request.session.id,
2722                    "failed to serialise chat payload for request digest; falling back to session-derived digest"
2723                );
2724                None
2725            }
2726        };
2727        let request_digest = match &payload_bytes {
2728            Some(bytes) => av_core::digest::sha256_hex(bytes),
2729            None => av_core::digest::sha256_hex(request.session.id.as_bytes()),
2730        };
2731        // Cancellation fix: arm the guard with the marker id
2732        // BEFORE the awaited disk write. If the client disconnects
2733        // between here and the write completing, `spawn_blocking` still
2734        // runs the write to durability; the guard's terminal-failure
2735        // job then retires the marker via `clear_response_marker`,
2736        // which now treats an absent file as success — so a never-written
2737        // marker is a clean no-op and a written one is deleted. Under
2738        // the previous ordering, cancellation between the write and
2739        // `set_marker` left the marker stranded, poisoning quarantine
2740        // sets and (across restart) bricking receipts.
2741        let attempt_id = crate::worker::reserve_response_attempt_id();
2742        request.capture_guard.set_marker(attempt_id.clone());
2743        if let Err(error) = crate::worker::write_response_marker(
2744            std::path::Path::new(&self.config.atif_spool_dir),
2745            &self.journal_key,
2746            &request.session.id,
2747            &attempt_id,
2748            request_digest,
2749        )
2750        .await
2751        {
2752            // Fail closed: the marker is what lets a restart-time scan
2753            // (`inflight_response_sessions`) quarantine sessions whose
2754            // provider response may have been observed but never
2755            // captured. Dispatching without it would silently drop that
2756            // crash-durability guarantee, so refuse before upstream I/O
2757            // — terminating the journaled response attempt like every
2758            // other post-admission abort (see `abandon_prepared`).
2759            tracing::warn!(
2760                %error,
2761                session = %request.session.id,
2762                "could not write in-flight response marker; refusing upstream dispatch"
2763            );
2764            let client_error =
2765                PipelineError::unavailable("in-flight response marker could not be persisted".to_owned());
2766            self.abandon_prepared(&mut request, StopReason::Other, &client_error.to_string());
2767            return Err(client_error);
2768        }
2769        let PreparedRequest {
2770            session,
2771            identity,
2772            payload,
2773            lease,
2774            mut capture_guard,
2775            client_authorization,
2776            upstream_passthrough_headers,
2777            mut admission_debit,
2778            ..
2779        } = request;
2780        let url = format!(
2781            "{}{}",
2782            self.config.upstream_url.trim_end_matches('/'),
2783            self.config.upstream_chat_path
2784        );
2785        // Reuse the digest's serialization for the upstream body; the
2786        // theoretical to_vec failure above falls back to reqwest's
2787        // own `.json()` serialization (same bytes, second attempt).
2788        let mut upstream_request = match payload_bytes {
2789            Some(bytes) => self
2790                .client
2791                .post(url)
2792                .header(reqwest::header::CONTENT_TYPE, "application/json")
2793                .body(bytes),
2794            None => self.client.post(url).json(&payload),
2795        };
2796        // Allowlisted provider-semantic headers (org/project/beta) relay
2797        // verbatim so org-scoped keys and beta-gated features keep
2798        // working through the proxy.
2799        for (name, value) in upstream_passthrough_headers {
2800            upstream_request = upstream_request.header(name, value);
2801        }
2802        if let Some((name, value)) = &self.upstream_auth {
2803            upstream_request = upstream_request.header(name.clone(), value.clone());
2804        } else if let Some(authorization) = client_authorization {
2805            // Passthrough mode: the client's own credential travels to the
2806            // upstream. `validate()` guarantees this is mutually exclusive
2807            // with static keys and with NHI identity enforcement.
2808            upstream_request = upstream_request.header(reqwest::header::AUTHORIZATION, authorization);
2809        }
2810        // Upstream latency (time-to-response-headers)
2811        // and error counters — the two failure-mode-table rows the
2812        // review marked "logs only, no metric".
2813        let upstream_started = Instant::now();
2814        // Last pre-dispatch point: from here the request may reach the
2815        // provider, so the admission debit stands (the `is_connect` arm
2816        // below refunds the one provably-not-sent case). Disarming here
2817        // also ends the cancellation-refund window — a disconnect
2818        // during `send()` keeps the debit, deliberately.
2819        let debited_tokens = admission_debit.take_for_dispatch();
2820        match upstream_request.send().await {
2821            Ok(response) => {
2822                self.hot_metrics
2823                    .upstream_latency_histogram
2824                    .observe_us(elapsed_us(upstream_started));
2825                if response.status().is_server_error() {
2826                    #[allow(clippy::indexing_slicing)]
2827                    {
2828                        self.hot_metrics.upstream_errors_counters[UpstreamErrorKind::Http5xx.index()].inc();
2829                    }
2830                }
2831                Ok(ForwardedResponse {
2832                    response,
2833                    billed_prompt_tokens: debited_tokens,
2834                    lease,
2835                    capture_guard,
2836                })
2837            }
2838            Err(error) => {
2839                let kind = UpstreamErrorKind::from_reqwest(&error);
2840                #[allow(clippy::indexing_slicing)]
2841                {
2842                    self.hot_metrics.upstream_errors_counters[kind.index()].inc();
2843                }
2844                // `reqwest::Error::Display` embeds the
2845                // request URL (see the redaction rule at
2846                // routes.rs::read_limited_tool_response — the same
2847                // rule applies here). `%error` on `reqwest::Error`
2848                // would render `error sending request for url
2849                // (https://api.openai.com/v1/chat/completions): dns
2850                // error: ...` into every OTLP sink. In multi-tenant
2851                // / air-gapped deployments the upstream URL is not
2852                // something the operator wants leaked to the
2853                // customer's SIEM (it may be an internal LiteLLM
2854                // router, an on-prem Azure resource name, a
2855                // per-tenant model deployment path). Log the
2856                // stable classifier + reqwest's structured
2857                // predicates only.
2858                //
2859                // Also propagate the classifier — NOT
2860                // `error.to_string()` — into the persisted failure
2861                // event so the URL doesn't land in the on-disk
2862                // journal or in the Bridge-published failure
2863                // record either.
2864                let client_reason = classify_upstream_error(&error);
2865                // `is_connect()` proves the request never left this
2866                // process (TCP/TLS establishment failed) — the admission
2867                // token debit bought no LLM work; refund it on BOTH
2868                // ledgers, in the same order they were spent. Any other
2869                // send error (timeout, body, reset mid-response) may have
2870                // reached the provider, so the debit conservatively
2871                // stands. Best-effort; a store blip must never turn a
2872                // 502 into a 5xx cascade.
2873                if error.is_connect() {
2874                    ActionBudget::new(self.store.as_ref(), &session.id, &self.config.budget)
2875                        .refund_tokens(debited_tokens);
2876                    if let Some(spec) = self.config.principal_budget.as_ref() {
2877                        let principal_id = principal_id_for_budget(&identity);
2878                        ActionBudget::for_principal(self.store.as_ref(), &principal_id, spec)
2879                            .refund_tokens(debited_tokens);
2880                    }
2881                }
2882                tracing::warn!(
2883                    session = %session.id,
2884                    category = client_reason,
2885                    error.status = ?error.status(),
2886                    error.is_timeout = error.is_timeout(),
2887                    error.is_connect = error.is_connect(),
2888                    error.is_body = error.is_body(),
2889                    "upstream forwarding failed"
2890                );
2891                // Route timeouts (upstream accepted the connection but
2892                // never finished responding) to 504 Gateway Timeout
2893                // instead of the generic 502 Bad Gateway. Operator
2894                // dashboards keying off HTTP status can now distinguish
2895                // "provider slow" (504) from "provider unreachable"
2896                // (502) without joining against the metric label.
2897                let client_error = if error.is_timeout() {
2898                    PipelineError::upstream_timeout(client_reason.to_owned())
2899                } else {
2900                    PipelineError::upstream(client_reason.to_owned())
2901                };
2902                let persisted_reason = format!("upstream_{client_reason}");
2903                if let Some(permit) = capture_guard.take_permit() {
2904                    let (response_marker, response_attempt_id) = capture_guard.disarm();
2905                    let capture_session = Arc::clone(&session);
2906                    let mut job =
2907                        self.failure_job(session, identity, StopReason::Other, persisted_reason.clone());
2908                    job.response_marker = response_marker;
2909                    job.response_attempt = Some(crate::worker::ResponseAttempt {
2910                        id: response_attempt_id,
2911                        terminal: true,
2912                    });
2913                    // This terminal failure capture is the only thing that
2914                    // retires the durable in-flight marker and the
2915                    // journalled response attempt. If the shard is full,
2916                    // fail the session closed instead of dropping the
2917                    // capture silently: otherwise the marker strands, no
2918                    // terminal response event exists, and the session can
2919                    // still close "cleanly" with a receipt over an
2920                    // unresolved response attempt.
2921                    if permit.submit(&self.worker, job).is_err() {
2922                        capture_session.mark_capture_failed();
2923                    }
2924                } else {
2925                    // Defensive (a permit always exists here): keep the
2926                    // guard armed so its drop retires the marker and
2927                    // closes the attempt through the plain worker queue.
2928                    drop(capture_guard);
2929                    self.enqueue_failure(session, identity, StopReason::Other, persisted_reason)?;
2930                }
2931                Err(client_error)
2932            }
2933        }
2934    }
2935
2936    /// Intercept one MCP JSON-RPC tool call, emit its OCSF verdict
2937    /// asynchronously, and return the immediate authorization decision.
2938    pub fn intercept_tool(&self, headers: &HeaderMap, raw: &[u8]) -> Result<ToolVerdict, PipelineError> {
2939        self.intercept_tool_with_session(headers, raw)
2940            .map(|(verdict, _session)| verdict)
2941    }
2942
2943    /// Cheap pre-parse identity gate for the tool paths. When identity
2944    /// is REQUIRED, a request with no Authorization header can never be
2945    /// admitted — refuse before `parse_tool_call` /
2946    /// `ToolExecution::from_request` run their duplicate-key scan, JSON
2947    /// parse, canonicalization, and hashing. The chat path resolves
2948    /// identity before any body processing; the tool paths parsed FIRST
2949    /// (the required scope depends on the parsed tool name), handing an
2950    /// unauthenticated flood the expensive pre-auth work — bounded by
2951    /// the MCP admission cap, but blocking-pool cycles anonymous
2952    /// garbage shouldn't get. A present-but-garbage bearer still costs
2953    /// one signature verification before the parse (the full
2954    /// scope-aware `resolve_identity` still runs afterwards) — the same
2955    /// cost order as the chat path.
2956    pub(crate) fn refuse_unauthenticated_tool_call(&self, headers: &HeaderMap) -> Result<(), PipelineError> {
2957        if !self.config.require_identity {
2958            return Ok(());
2959        }
2960        if single_header(headers, "authorization")?.is_none() {
2961            // Mirrors resolve_identity's (None, _) require_identity arm,
2962            // including the identity-rejection audit event.
2963            let session_id = session_id(headers)?;
2964            let error = PipelineError::Unauthorized("missing bearer token".to_owned());
2965            self.enqueue_transient_failure(&session_id, StopReason::IdentityRejected, error.to_string())?;
2966            return Err(error);
2967        }
2968        Ok(())
2969    }
2970
2971    /// Core of [`Self::intercept_tool`] that also returns the bound session.
2972    ///
2973    /// Callers that must await audit durability need the same session the
2974    /// verdict was recorded under: re-deriving it from headers would mint a
2975    /// fresh random id for a header-less request (see [`session_id`]) and
2976    /// look up a session that does not exist.
2977    fn intercept_tool_with_session(
2978        &self,
2979        headers: &HeaderMap,
2980        raw: &[u8],
2981    ) -> Result<(ToolVerdict, Arc<Session>), PipelineError> {
2982        let caller = match self.authenticate(headers) {
2983            Ok(caller) => caller,
2984            Err(error) => {
2985                self.audit_authentication_error(headers, &error)?;
2986                return Err(error);
2987            }
2988        };
2989        self.intercept_tool_authenticated(headers, raw, &caller, None)
2990    }
2991
2992    fn intercept_tool_authenticated(
2993        &self,
2994        headers: &HeaderMap,
2995        raw: &[u8],
2996        caller: &AuthenticatedCaller,
2997        preflight_denial: Option<ToolVerdict>,
2998    ) -> Result<(ToolVerdict, Arc<Session>), PipelineError> {
2999        let session_id = session_id(headers)?;
3000        let workflow = workflow(headers, &self.config.default_workflow)?;
3001        let parsed_call = av_sandbox::parse_tool_call(raw).ok();
3002        let required_scope = parsed_call
3003            .as_ref()
3004            .map(|request| tool_scope(&request.tool))
3005            .unwrap_or_else(|| TOOL_INVOKE_SCOPE.to_owned());
3006        let identity = match caller.authorize(self, Some(&required_scope)) {
3007            Ok(identity) => identity,
3008            Err(error) => {
3009                self.enqueue_transient_failure(&session_id, StopReason::IdentityRejected, error.to_string())?;
3010                return Err(error);
3011            }
3012        };
3013        // Tool interception must NOT resurrect a closed session: a
3014        // tool call extends an in-progress conversation, so silently
3015        // opening a fresh session under the same id would let the
3016        // client accumulate tool calls past a signed receipt boundary
3017        // (chain-of-custody split). Chat requests use `get_or_open`
3018        // (recycles closed ids); the strict `_no_reopen` variant here
3019        // makes the `is_closed()` check below fire.
3020        let session =
3021            self.sessions
3022                .get_or_open_no_reopen(&session_id, workflow, &identity, &self.config.breaker);
3023        let admission = session.admission_guard();
3024        validate_session_binding(&session, workflow, &identity)?;
3025        caller.bind_session(&session)?;
3026        session.refresh_identity(&identity);
3027        if session.is_closed() {
3028            return Err(PipelineError::bad_request("session is already closed".to_owned()));
3029        }
3030        if session.capture_failed() {
3031            return Err(PipelineError::unavailable(
3032                "session audit capture is incomplete".to_owned(),
3033            ));
3034        }
3035        session.touch();
3036        // The semantic-loop breaker gates CHAT admission (both the
3037        // prepare arm above and the streaming re-check), but tool
3038        // execution ignored it entirely: a runaway agent whose chat is
3039        // already refused/aborted could keep firing otherwise-permitted
3040        // MCP tool calls on the same session until idle close — the
3041        // "stop the runaway agent" decision only stopped half the
3042        // agent. Gate the hard-stop actions here. `Inject` deliberately
3043        // passes: its remedy is a corrective CHAT message (the next
3044        // chat turn injects and resets the breaker); blocking tools
3045        // under Inject would strand tool-driven agents the operator
3046        // chose to steer rather than stop.
3047        if session.loop_state.state() == BreakerState::Open {
3048            match session.loop_state.action() {
3049                BreakerAction::Reject | BreakerAction::Abort => {
3050                    session.latch_enforcement(StopReason::LoopDetected);
3051                    return Err(PipelineError::blocked(
3052                        "semantic loop circuit breaker is open".to_owned(),
3053                    ));
3054                }
3055                _ => {}
3056            }
3057        }
3058        let worker_permit = self
3059            .worker
3060            .try_reserve(&session_id)
3061            .map_err(PipelineError::unavailable_source)?;
3062        let budgeted_check = || {
3063            let principal_binding = self
3064                .config
3065                .principal_budget
3066                .as_ref()
3067                .map(|spec| (principal_id_for_budget(&identity), spec));
3068            let principal_ref = principal_binding.as_ref().map(|(id, spec)| (id.as_str(), *spec));
3069            self.sandbox
3070                .check_with_principal(self.store.as_ref(), &session_id, principal_ref, raw)
3071        };
3072        let verdict = if let Some(denial) = preflight_denial {
3073            denial
3074        } else {
3075            match parsed_call.as_ref() {
3076                // `sandbox.check`'s budget gate spends before we return, so we must
3077                // veto workflow-mismatched consequential tools before it runs.
3078                Some(request)
3079                    if workflow == Workflow::Unsigned
3080                        && self
3081                            .config
3082                            .consequential_tools
3083                            .iter()
3084                            .any(|required| required == &request.tool) =>
3085                {
3086                    let reason = format!("tool {:?} requires a signed workflow", request.tool);
3087                    ToolVerdict::Blocked {
3088                        tool: request.tool.clone(),
3089                        stage: "policy",
3090                        // Built-in workflow gate, named so console-side
3091                        // attribution has a stable handle an operator can
3092                        // create a matching policy row for.
3093                        policy: Some("workflow.signed_required".to_owned()),
3094                        denial_code: av_sandbox::DenialCode::PolicyDenied,
3095                        reason: reason.clone(),
3096                        response: av_sandbox::rpc::denial_error(
3097                            request.id.as_ref(),
3098                            av_sandbox::DenialCode::PolicyDenied,
3099                            &reason,
3100                        ),
3101                        elapsed_us: 0,
3102                    }
3103                }
3104                // Same pre-spend veto for the intent map and the active
3105                // mission: a denied call must neither consume budget nor be
3106                // audited as allowed. This also covers verdict-only mode, where
3107                // no forwarding branch ever runs.
3108                Some(request) => {
3109                    match self
3110                        .pdp
3111                        .decide_for(&request.tool, av_core::time::now_ms() / 1000, &caller.facts())
3112                    {
3113                        crate::authz::AuthzDecision::Deny { code, reason } => {
3114                            self.metrics
3115                                .counter(
3116                                    &format!("av_pdp_denials_total{{code=\"{}\"}}", code.as_str()),
3117                                    "PDP denials by denial code",
3118                                )
3119                                .inc();
3120                            ToolVerdict::Blocked {
3121                                tool: request.tool.clone(),
3122                                stage: "policy",
3123                                policy: Some(pdp_policy_name(code).to_owned()),
3124                                denial_code: code,
3125                                response: av_sandbox::rpc::denial_error(request.id.as_ref(), code, &reason),
3126                                reason,
3127                                elapsed_us: 0,
3128                            }
3129                        }
3130                        crate::authz::AuthzDecision::Permit { .. } => budgeted_check(),
3131                    }
3132                }
3133                None => budgeted_check(),
3134            }
3135        };
3136        let (status, payload) = match &verdict {
3137            ToolVerdict::Allowed {
3138                tool,
3139                budget_remaining,
3140                elapsed_us,
3141                ..
3142            } => (
3143                StatusId::Success,
3144                serde_json::json!({
3145                    "tool": tool,
3146                    "allowed": true,
3147                    "budget_remaining": (*budget_remaining != u64::MAX)
3148                        .then_some(*budget_remaining),
3149                    "budget_unlimited": *budget_remaining == u64::MAX,
3150                    "decision_us": elapsed_us,
3151                }),
3152            ),
3153            ToolVerdict::Blocked {
3154                tool,
3155                stage,
3156                policy,
3157                denial_code,
3158                reason,
3159                elapsed_us,
3160                ..
3161            } => (
3162                StatusId::Failure,
3163                serde_json::json!({
3164                    "tool": tool,
3165                    "allowed": false,
3166                    "stage": stage,
3167                    // Present only for named policy-chain denials —
3168                    // consumed by console-sync as the per-policy
3169                    // attribution (`payload.policy` → ingest policyName).
3170                    "policy": policy,
3171                    "denial_code": denial_code.as_str(),
3172                    "reason": reason,
3173                    "decision_us": elapsed_us,
3174                }),
3175            ),
3176            // `ToolVerdict` is `#[non_exhaustive]` (R56 SemVer
3177            // hardening) — a future variant (e.g. `Warn` for shadow-
3178            // mode, `Deferred` for async policy evaluation) must NOT
3179            // be silently forwarded to the tool server. Fail-closed:
3180            // emit a Blocked-shaped audit payload and log at ERROR
3181            // level so operators discover the unhandled variant
3182            // during rollout. Same defensive posture the sandbox's
3183            // own `is_allowed` uses (whitelist Allowed, treat
3184            // everything else as blocked).
3185            _ => {
3186                tracing::error!(
3187                    "unhandled ToolVerdict variant reached the harness matcher; \
3188                     failing closed. This is a bug — extend the match to handle the new variant."
3189                );
3190                (
3191                    StatusId::Failure,
3192                    serde_json::json!({
3193                        "tool": "<unknown>",
3194                        "allowed": false,
3195                        "stage": "unhandled_verdict",
3196                        "reason": "unhandled ToolVerdict variant; fail-closed",
3197                    }),
3198                )
3199            }
3200        };
3201        let tool_call_id = parsed_call
3202            .as_ref()
3203            .and_then(|request| request.id.as_ref())
3204            .map(|value| value.as_str().map_or_else(|| value.to_string(), str::to_owned))
3205            .unwrap_or_else(av_core::new_event_uid);
3206        let tool_calls = parsed_call.as_ref().map(|request| {
3207            vec![av_atif::ToolCall {
3208                tool_call_id: tool_call_id.clone(),
3209                function_name: request.tool.clone(),
3210                arguments: request.arguments.clone(),
3211                extra: None,
3212            }]
3213        });
3214        let observation = parsed_call.as_ref().map(|_| av_atif::Observation {
3215            results: vec![av_atif::ObservationResult {
3216                source_call_id: Some(tool_call_id),
3217                content: Some(Value::String(payload.to_string())),
3218                subagent_trajectory_ref: None,
3219                extra: None,
3220            }],
3221        });
3222        let stop_reason = match &verdict {
3223            ToolVerdict::Allowed { .. } => None,
3224            ToolVerdict::Blocked { stage: "budget", .. } => Some(StopReason::BudgetExceeded),
3225            ToolVerdict::Blocked { .. } => Some(StopReason::PolicyBlocked),
3226            // See the sibling `match &verdict` above for the rationale.
3227            // R58 correction: use `StopReason::Other` (the ships-with-
3228            // `#[serde(other)]` forward-compat sink documented for
3229            // exactly this "unrecognised upstream variant during
3230            // heterogeneous rolling upgrade" case) rather than
3231            // `PolicyBlocked`. Returning `PolicyBlocked` here would
3232            // contaminate policy-block counters / alerts / dashboards
3233            // with a HARNESS CODE BUG signal, and disagree with the
3234            // sibling `stage: "unhandled_verdict"` payload label that
3235            // is honestly synthetic. `Other` preserves fail-closed
3236            // semantics (non-None, terminates the turn, seals the
3237            // receipt) while telling the truth on the wire.
3238            _ => Some(StopReason::Other),
3239        };
3240        worker_permit.submit(WorkerJob {
3241            session: Arc::clone(&session),
3242            identity,
3243            class: EventClass::ToolCall,
3244            payload,
3245            text: String::from_utf8_lossy(raw).into_owned(),
3246            analyze_loop: false,
3247            status,
3248            stop_reason,
3249            native_stop_reason: None,
3250            metrics: EventMetrics::default(),
3251            cost_usd_micros: 0,
3252            prompt_token_correction: 0,
3253            atif: Some(AtifCapture {
3254                source: av_atif::Source::Agent,
3255                message: Value::String("MCP tool authorization decision".to_owned()),
3256                reasoning_content: None,
3257                model_name: None,
3258                tool_calls,
3259                observation,
3260                llm_call_count: Some(0),
3261            }),
3262            response_marker: None,
3263            response_attempt: None,
3264        });
3265        drop(admission);
3266        Ok((verdict, session))
3267    }
3268
3269    /// Authorize a tool call and wait for its verdict event to become durable.
3270    pub async fn intercept_tool_durable(
3271        &self,
3272        headers: &HeaderMap,
3273        raw: &[u8],
3274    ) -> Result<ToolVerdict, PipelineError> {
3275        let state = self.clone();
3276        let owned_headers = headers.clone();
3277        let raw = raw.to_vec();
3278        let (verdict, session) =
3279            tokio::task::spawn_blocking(move || state.intercept_tool_with_session(&owned_headers, &raw))
3280                .await
3281                .map_err(PipelineError::unavailable_source)??;
3282        session.wait_for_worker_jobs().await;
3283        if session.capture_failed() {
3284            return Err(PipelineError::unavailable(
3285                "tool authorization audit capture failed".to_owned(),
3286            ));
3287        }
3288        Ok(verdict)
3289    }
3290
3291    /// Authorize a tool call on the blocking pool without waiting for the
3292    /// off-path event journal or broker publication.
3293    pub async fn intercept_tool_nonblocking(
3294        &self,
3295        headers: &HeaderMap,
3296        raw: &[u8],
3297    ) -> Result<ToolVerdict, PipelineError> {
3298        let state = self.clone();
3299        let owned_headers = headers.clone();
3300        let raw = raw.to_vec();
3301        tokio::task::spawn_blocking(move || state.intercept_tool(&owned_headers, &raw))
3302            .await
3303            .map_err(PipelineError::unavailable_source)?
3304    }
3305
3306    pub(crate) async fn intercept_authenticated_nonblocking(
3307        &self,
3308        headers: &HeaderMap,
3309        raw: &[u8],
3310        caller: AuthenticatedCaller,
3311        preflight_denial: Option<ToolVerdict>,
3312    ) -> Result<ToolVerdict, PipelineError> {
3313        let state = self.clone();
3314        let headers = headers.clone();
3315        let raw = raw.to_vec();
3316        tokio::task::spawn_blocking(move || {
3317            state
3318                .intercept_tool_authenticated(&headers, &raw, &caller, preflight_denial)
3319                .map(|(verdict, _)| verdict)
3320        })
3321        .await
3322        .map_err(PipelineError::unavailable_source)?
3323    }
3324
3325    pub(crate) fn lease_session(&self, headers: &HeaderMap) -> Result<SessionLease, PipelineError> {
3326        let id = session_id(headers)?;
3327        let session = self
3328            .sessions
3329            .get(&id)
3330            .ok_or_else(|| PipelineError::bad_request("unknown session".to_owned()))?;
3331        session
3332            .try_lease()
3333            .ok_or_else(|| PipelineError::bad_request("session is already closed".to_owned()))
3334    }
3335
3336    pub(crate) fn enqueue_transient_failure(
3337        &self,
3338        session_id: &str,
3339        stop_reason: StopReason,
3340        reason: String,
3341    ) -> Result<(), PipelineError> {
3342        // Every identity rejection
3343        // used to mint a DISTINCT signed audit session — two fsynced
3344        // spool files, a bridge publish, a reconciler adoption and an
3345        // Ed25519 signature per credential-free 401 probe. A curl loop
3346        // against a hardened deployment turned each rejection into
3347        // durable server-side work that nothing ever coalesced. Keep
3348        // the full per-rejection audit record for a bounded number of
3349        // rejections per minute (enough for forensic sampling), and
3350        // beyond that budget count-only: the metric and a rate-limited
3351        // WARN preserve the signal while the signed-receipt lifecycle
3352        // is no longer attacker-drivable.
3353        self.metrics
3354            .counter(
3355                "av_identity_rejections_total",
3356                "Requests refused by identity validation",
3357            )
3358            .inc();
3359        const MAX_AUDITED_REJECTIONS_PER_MINUTE: u32 = 60;
3360        {
3361            let now = Instant::now();
3362            let mut window = self.identity_rejection_window.lock();
3363            if now.duration_since(window.0) >= std::time::Duration::from_secs(60) {
3364                *window = (now, 0);
3365            }
3366            if window.1 >= MAX_AUDITED_REJECTIONS_PER_MINUTE {
3367                self.metrics
3368                    .counter(
3369                        "av_identity_rejections_unaudited_total",
3370                        "Identity rejections beyond the per-minute audit-record budget (counted only)",
3371                    )
3372                    .inc();
3373                return Ok(());
3374            }
3375            window.1 += 1;
3376        }
3377        let audit_session_id = format!("identity-rejected-{}", av_core::new_event_uid());
3378        let identity = AgentIdentity {
3379            version: "unknown".to_owned(),
3380            charter: "identity-rejected".into(),
3381            instance_uid: audit_session_id.clone(),
3382            ttl_remaining_s: None,
3383        };
3384        let session = Arc::new(Session::new(
3385            audit_session_id,
3386            Workflow::Signed,
3387            identity.clone(),
3388            self.config.breaker.clone(),
3389        ));
3390        // Cap the attacker-controlled `session_id` echo so a
3391        // maliciously long header cannot bloat every audit record.
3392        // 64 bytes is enough to keep well-formed UUIDs and legitimate
3393        // client-chosen ids intact; anything larger is truncated with
3394        // a marker so operators can still tell what the caller sent.
3395        const MAX_ECHO: usize = 64;
3396        let mut boundary = MAX_ECHO.min(session_id.len());
3397        while boundary < session_id.len() && !session_id.is_char_boundary(boundary) {
3398            boundary -= 1;
3399        }
3400        let truncated = boundary < session_id.len();
3401        let echo = &session_id[..boundary];
3402        let echo_marker = if truncated { "…(truncated)" } else { "" };
3403        self.enqueue_failure(
3404            session,
3405            identity,
3406            stop_reason,
3407            format!("requested session {echo:?}{echo_marker}: {reason}"),
3408        )
3409    }
3410
3411    fn enqueue_failure(
3412        &self,
3413        session: Arc<Session>,
3414        identity: AgentIdentity,
3415        stop_reason: StopReason,
3416        reason: String,
3417    ) -> Result<(), PipelineError> {
3418        self.worker
3419            .try_submit(self.failure_job(session, identity, stop_reason, reason))
3420            .map_err(PipelineError::unavailable_source)
3421    }
3422
3423    fn failure_job(
3424        &self,
3425        session: Arc<Session>,
3426        identity: AgentIdentity,
3427        stop_reason: StopReason,
3428        reason: String,
3429    ) -> WorkerJob {
3430        let atif = (session.workflow == Workflow::Unsigned).then(|| AtifCapture {
3431            source: av_atif::Source::Agent,
3432            message: Value::String(reason.clone()),
3433            reasoning_content: None,
3434            model_name: None,
3435            tool_calls: None,
3436            observation: None,
3437            llm_call_count: Some(0),
3438        });
3439        WorkerJob {
3440            session,
3441            identity,
3442            class: EventClass::StopReason,
3443            payload: serde_json::json!({"reason": reason}),
3444            text: String::new(),
3445            analyze_loop: false,
3446            status: StatusId::Failure,
3447            stop_reason: Some(stop_reason),
3448            native_stop_reason: None,
3449            metrics: EventMetrics::default(),
3450            cost_usd_micros: 0,
3451            prompt_token_correction: 0,
3452            atif,
3453            response_marker: None,
3454            response_attempt: None,
3455        }
3456    }
3457
3458    #[cfg(test)]
3459    pub(crate) fn authorize_session(
3460        &self,
3461        headers: &HeaderMap,
3462        session: &Session,
3463        required_scope: &str,
3464    ) -> Result<(), PipelineError> {
3465        self.authenticate(headers)?
3466            .authorize_session(self, session, required_scope)
3467    }
3468
3469    pub(crate) fn resolve_identity(
3470        &self,
3471        headers: &HeaderMap,
3472        required_scope: Option<&str>,
3473    ) -> Result<AgentIdentity, PipelineError> {
3474        self.authenticate(headers)?.authorize(self, required_scope)
3475    }
3476
3477    pub(crate) fn authenticate(&self, headers: &HeaderMap) -> Result<AuthenticatedCaller, PipelineError> {
3478        let bearer = single_header(headers, "authorization")?
3479            .and_then(|value| value.to_str().ok())
3480            .and_then(strip_bearer_scheme);
3481        if let (Some(token), Some(validator)) = (bearer, &self.identity) {
3482            self.metrics
3483                .counter(
3484                    "av_identity_validations_total",
3485                    "Full inbound identity validations",
3486                )
3487                .inc();
3488            let validated = validator.validate(token).map_err(identity_error_to_pipeline)?;
3489            return Ok(AuthenticatedCaller {
3490                identity: validated.agent_identity(),
3491                validated: Some(validated),
3492            });
3493        }
3494        Ok(AuthenticatedCaller {
3495            identity: self.resolve_anonymous_identity(headers, None)?,
3496            validated: None,
3497        })
3498    }
3499
3500    pub(crate) fn audit_authentication_error(
3501        &self,
3502        headers: &HeaderMap,
3503        error: &PipelineError,
3504    ) -> Result<(), PipelineError> {
3505        if matches!(
3506            error,
3507            PipelineError::Unauthorized(_) | PipelineError::Blocked { .. }
3508        ) {
3509            self.enqueue_transient_failure(
3510                &session_id(headers).unwrap_or_default(),
3511                StopReason::IdentityRejected,
3512                error.to_string(),
3513            )?;
3514        } else if matches!(error, PipelineError::Unavailable { .. }) {
3515            self.metrics
3516                .counter(
3517                    "av_identity_unavailable_total",
3518                    "Identity validation dependency failures",
3519                )
3520                .inc();
3521        }
3522        Ok(())
3523    }
3524
3525    fn resolve_anonymous_identity(
3526        &self,
3527        headers: &HeaderMap,
3528        required_scope: Option<&str>,
3529    ) -> Result<AgentIdentity, PipelineError> {
3530        // Reuse the duplicate-header refusal
3531        // pattern for `Authorization` on the identity hot path.
3532        // Previously `HeaderMap::get(AUTHORIZATION)` returned the
3533        // first value while the `PreparedRequest.client_authorization`
3534        // capture and header-smuggling proxies could observe a
3535        // merged `A, B` form — auth split-brain: the harness
3536        // authenticates as A while log aggregators / WAFs / OTLP
3537        // exporters attribute the request to B. Refuse the multi-
3538        // value case at ingress, symmetric with the X-AV-Session /
3539        // X-AV-Workflow guards.
3540        // RFC 7235 §2.1 declares the auth-scheme token
3541        // case-insensitive. Historically `strip_prefix("Bearer ")`
3542        // silently dropped `bearer eyJ...` / `BEARER eyJ...` /
3543        // `Bearer\teyJ...` to `None`, then the outer match arm
3544        // returned anonymous (when require_identity=false, the
3545        // shipped default) — the caller believed they had
3546        // authenticated while the audit trail attributed the
3547        // request to `anonymous`. That's the exact repudiation
3548        // vector the surrounding refuse-anonymous-with-token
3549        // guard was written to close. Now strip the scheme
3550        // case-insensitively.
3551        let bearer = single_header(headers, "authorization")?
3552            .and_then(|value| value.to_str().ok())
3553            .and_then(strip_bearer_scheme);
3554        match (bearer, &self.identity) {
3555            (Some(token), Some(validator)) => {
3556                let validated = validator.validate(token).map_err(identity_error_to_pipeline)?;
3557                if self.config.enforce_identity_scopes {
3558                    if let Some(required) = required_scope {
3559                        if !scope_allows(&validated.claims.scopes, required) {
3560                            // RFC 6750 §3.1: a syntactically valid, correctly
3561                            // signed token that lacks the required scope is an
3562                            // AUTHORIZATION failure — 403 insufficient_scope —
3563                            // not a 401. Returning 401 (the old behavior) told
3564                            // SDK token-refreshers to re-authenticate, which
3565                            // cannot help: the grant itself lacks the scope.
3566                            return Err(PipelineError::blocked(format!(
3567                                "identity scope {required:?} is required"
3568                            )));
3569                        }
3570                    }
3571                }
3572                Ok(validated.agent_identity())
3573            }
3574            // Client presented a bearer but the validator is not
3575            // configured. Two legitimate cases and one attack surface:
3576            //  (a) `upstream_authorization_passthrough = true`: the
3577            //      client's bearer is the CREDENTIAL FOR THE UPSTREAM
3578            //      PROVIDER, not an identity claim for us. Accept
3579            //      anonymous locally so the request proceeds and the
3580            //      relay layer forwards the header. This is the
3581            //      documented dev-mode BYO-key posture.
3582            //  (b) Neither passthrough nor validator: a bearer sent to
3583            //      a no-op harness is a configuration mismatch —
3584            //      REJECTED with a diagnostic 401 (see the closing
3585            //      paragraph below and the arm's else branch) rather
3586            //      than silently recorded as anonymous.
3587            //  (c) A validator EXISTS but this arm ran because
3588            //      match fell through: unreachable because arm 1
3589            //      catches (Some, Some). No action.
3590            //
3591            // The attack case — a caller sends a scoped identity token
3592            // and we silently attribute the request as `anonymous`
3593            // producing a repudiation vector — was closed at commit
3594            // e56... by rejecting here. But that broke passthrough
3595            // mode. Restore acceptance when passthrough is on so the
3596            // BYO-key path works, and continue rejecting only when
3597            // NO passthrough is configured and identity was not
3598            // required (i.e. the client presenting a bearer to a
3599            // no-op harness).
3600            (Some(_), None) => {
3601                let has_static_key =
3602                    self.config.upstream_api_key_env.is_some() || self.config.upstream_api_key_file.is_some();
3603                if self.config.upstream_authorization_passthrough {
3604                    Ok(AgentIdentity {
3605                        version: "dev".to_owned(),
3606                        charter: "anonymous".into(),
3607                        instance_uid: "anonymous".to_owned(),
3608                        ttl_remaining_s: None,
3609                    })
3610                } else if has_static_key || self.config.ignore_client_authorization {
3611                    // Static-key posture (the wizard's default): the
3612                    // operator has declared that THIS PROXY owns the
3613                    // upstream credential, and OpenAI-compatible SDKs
3614                    // unconditionally send a placeholder bearer (the
3615                    // client library refuses to start without one).
3616                    // Rejecting here made the flagship onboarding fail
3617                    // on request 1 with the official SDK. The
3618                    // repudiation rationale for the rejection below
3619                    // does not apply: exactly like the passthrough arm
3620                    // above, the config itself assigns the client
3621                    // bearer a non-identity meaning (a placeholder to
3622                    // be REPLACED by the static key — it is never
3623                    // forwarded). Accept anonymous; the placeholder
3624                    // never reaches the provider.
3625                    //
3626                    // `ignore_client_authorization` is the
3627                    // explicit opt-in covering the same posture for
3628                    // KEYLESS upstreams (Ollama, LM Studio, vLLM — no
3629                    // static key configured, so the implicit rule above
3630                    // cannot fire). validate() refuses the flag in
3631                    // combination with require_identity (the validator
3632                    // must see the header) and with passthrough (cannot
3633                    // both discard and forward).
3634                    tracing::debug!(
3635                        "client bearer ignored: static upstream key posture (placeholder \
3636                         SDK credential, not an identity claim)"
3637                    );
3638                    Ok(AgentIdentity {
3639                        version: "dev".to_owned(),
3640                        charter: "anonymous".into(),
3641                        instance_uid: "anonymous".to_owned(),
3642                        ttl_remaining_s: None,
3643                    })
3644                } else {
3645                    Err(PipelineError::Unauthorized(
3646                        "bearer token presented but identity validator is not configured — \
3647                         refusing to silently record the request as anonymous (either \
3648                         configure identity_jwks_url / identity_hmac_secret_file, or set \
3649                         upstream_authorization_passthrough=true if the bearer is meant \
3650                         for the upstream provider)"
3651                            .to_owned(),
3652                    ))
3653                }
3654            }
3655            (None, _) if self.config.require_identity => {
3656                Err(PipelineError::Unauthorized("missing bearer token".to_owned()))
3657            }
3658            _ => Ok(AgentIdentity {
3659                version: "dev".to_owned(),
3660                charter: "anonymous".into(),
3661                instance_uid: "anonymous".to_owned(),
3662                ttl_remaining_s: None,
3663            }),
3664        }
3665    }
3666
3667    fn observe_stage(&self, stage: &str, started: Instant) {
3668        let elapsed = elapsed_us(started);
3669        // Prefer the pre-resolved handle when the caller's stage name
3670        // maps to a known Stage variant — that path is O(1) with no
3671        // mutex or allocation. Callers still passing legacy or
3672        // unrecognized labels fall through to the lazy registry path
3673        // so the metric remains discoverable (the lazy Registry call
3674        // uses the same shared Arc<Histogram> the hot cache points
3675        // to for known stages).
3676        if let Some(known) = Stage::from_label(stage) {
3677            // `Stage::index()` is a closed 0..5 map over the same
3678            // `Stage::ORDER` array both HotMetrics arrays are built
3679            // from — `.get()` keeps clippy's indexing lint satisfied
3680            // without a reachable None arm.
3681            if let Some(histogram) = self.hot_metrics.stage_histograms.get(known.index()) {
3682                histogram.observe_us(elapsed);
3683            }
3684            if self.hot_metrics.strict_stage_budget && elapsed > 2_000 {
3685                if let Some(counter) = self.hot_metrics.stage_strict_budget_counters.get(known.index()) {
3686                    counter.inc();
3687                }
3688                tracing::warn!(stage, elapsed_us = elapsed, "strict stage budget exceeded");
3689            }
3690            return;
3691        }
3692        self.metrics
3693            .histogram(
3694                &format!("av_stage_duration_seconds{{stage=\"{stage}\"}}"),
3695                "Harness stage latency",
3696            )
3697            .observe_us(elapsed);
3698        if self.hot_metrics.strict_stage_budget && elapsed > 2_000 {
3699            self.metrics
3700                .counter(
3701                    &format!("av_strict_budget_breaches_total{{stage=\"{stage}\"}}"),
3702                    "Middleware stages that exceeded the strict per-stage budget",
3703                )
3704                .inc();
3705            tracing::warn!(stage, elapsed_us = elapsed, "strict stage budget exceeded");
3706        }
3707    }
3708}
3709
3710/// Authentication result confined to one request. It cannot be constructed by callers.
3711#[derive(Clone)]
3712pub(crate) struct AuthenticatedCaller {
3713    pub(crate) identity: AgentIdentity,
3714    pub(crate) validated: Option<av_identity::ValidatedIdentity>,
3715}
3716
3717impl AuthenticatedCaller {
3718    pub(crate) fn authorize(
3719        &self,
3720        state: &AppState,
3721        scope: Option<&str>,
3722    ) -> Result<AgentIdentity, PipelineError> {
3723        if let Some(validated) = &self.validated {
3724            let now = av_core::time::now_ms() / 1000;
3725            let leeway = state.identity.as_ref().map_or(0, |v| v.leeway_secs());
3726            if now > validated.claims.exp.saturating_add(leeway) {
3727                return Err(PipelineError::Unauthorized("identity validation failed".into()));
3728            }
3729            if state.config.enforce_identity_scopes {
3730                if let Some(required) = scope {
3731                    if !scope_allows(&validated.claims.scopes, required) {
3732                        return Err(PipelineError::blocked(format!(
3733                            "identity scope {required:?} is required"
3734                        )));
3735                    }
3736                }
3737            }
3738            let mut identity = self.identity.clone();
3739            identity.ttl_remaining_s = Some(validated.claims.exp.saturating_sub(now));
3740            return Ok(identity);
3741        }
3742        Ok(self.identity.clone())
3743    }
3744
3745    pub(crate) fn authorize_session(
3746        &self,
3747        state: &AppState,
3748        session: &Session,
3749        scope: &str,
3750    ) -> Result<(), PipelineError> {
3751        let identity = self.authorize(state, Some(scope))?;
3752        validate_session_binding(session, session.workflow, &identity)?;
3753        self.bind_session(session)?;
3754        session.refresh_identity(&identity);
3755        Ok(())
3756    }
3757
3758    /// What the policy decision point may know about this caller.
3759    pub(crate) fn facts(&self) -> crate::authz::CallerFacts<'_> {
3760        match &self.validated {
3761            Some(validated) => crate::authz::CallerFacts {
3762                instance_uid: &validated.claims.instance_uid,
3763                charter: &validated.claims.charter,
3764                subject: Some(&validated.claims.sub),
3765            },
3766            None => crate::authz::CallerFacts {
3767                instance_uid: &self.identity.instance_uid,
3768                charter: "anonymous",
3769                subject: None,
3770            },
3771        }
3772    }
3773
3774    pub(crate) fn principal_digest(&self) -> Option<String> {
3775        self.validated.as_ref().map(|validated| {
3776            let pair = serde_json::json!([validated.claims.iss, validated.claims.sub]);
3777            av_core::digest::sha256_hex(pair.to_string().as_bytes())
3778        })
3779    }
3780
3781    fn bind_session(&self, session: &Session) -> Result<(), PipelineError> {
3782        if !session.bind_principal(self.principal_digest().as_deref()) {
3783            return Err(PipelineError::Unauthorized(
3784                "session is bound to a different principal".into(),
3785            ));
3786        }
3787        Ok(())
3788    }
3789}
3790
3791fn truthy_env(name: &str) -> bool {
3792    match std::env::var(name) {
3793        Ok(value) => matches!(
3794            value.trim().to_ascii_lowercase().as_str(),
3795            "1" | "true" | "yes" | "on"
3796        ),
3797        Err(_) => false,
3798    }
3799}
3800
3801/// Map a `reqwest::Error` to a client-safe reason (CWE-209): the raw
3802/// `Display` includes the request URL, which leaks the operator-configured
3803/// upstream URL — potentially an internal hostname — to the caller. Return a
3804/// stable, non-identifying category instead; the detailed message is logged
3805/// server-side by the caller.
3806pub(crate) fn classify_upstream_error(error: &reqwest::Error) -> &'static str {
3807    if error.is_timeout() {
3808        "upstream timed out"
3809    } else if error.is_connect() {
3810        "upstream unreachable"
3811    } else if error.is_request() {
3812        "upstream request rejected"
3813    } else if error.is_body() || error.is_decode() {
3814        "upstream response malformed"
3815    } else if error.is_status() {
3816        "upstream returned error status"
3817    } else {
3818        "upstream forwarding failed"
3819    }
3820}
3821
3822/// Map an [`IdentityError`] to a client-safe reason (CWE-209).
3823///
3824/// The `Display` impl on `IdentityError` embeds attacker-influenced strings
3825/// (the presented `kid`, `iss`, and structural detail from the underlying
3826/// JWT parser) and distinguishes between at least sixteen distinct failure
3827/// modes. Returning that verbatim to the client turns each 401 response
3828/// into an *enumeration oracle* for the validator's configured JWKS and
3829/// issuer allowlist: an attacker can iterate candidate `kid`s and issuers
3830/// and read the response body to discover which values are registered
3831/// (`UnknownKid` vs `AlgorithmRejected` vs `Verification` vs `BadIssuer`
3832/// all produce distinguishable text).
3833///
3834/// We collapse every attacker-reachable variant to a single stable string.
3835/// The detailed cause is preserved server-side by the caller via
3836/// `tracing::warn!` and the failure-job audit chain, so operators keep
3837/// full diagnostic detail without exposing it to the network.
3838/// An unreachable revocation list is an availability fault: answer 503 so
3839/// clients back off and retry, instead of a 401 that tells them to throw
3840/// away a credential that may be perfectly valid.
3841pub(crate) fn identity_error_to_pipeline(error: av_identity::IdentityError) -> PipelineError {
3842    if matches!(error, av_identity::IdentityError::RevocationUnavailable(_)) {
3843        tracing::error!(error = %error, "identity revocation list unavailable; refusing the request");
3844        return PipelineError::unavailable(
3845            "identity revocation list is unavailable; retry shortly".to_owned(),
3846        );
3847    }
3848    tracing::warn!(error = %error, "identity validation failed");
3849    PipelineError::Unauthorized(classify_identity_error(&error).to_owned())
3850}
3851
3852pub(crate) fn classify_identity_error(error: &av_identity::IdentityError) -> &'static str {
3853    match error {
3854        // Server-side misconfiguration is not attacker-reachable in the
3855        // normal request path (validator construction rejects a bad JWKS
3856        // at startup); report it distinctly so operator dashboards can
3857        // surface it if it ever appears.
3858        av_identity::IdentityError::Jwks(_) => "identity validator misconfigured",
3859        // Every other variant is at least partially attacker-influenced —
3860        // collapse to one opaque message. Any finer distinction leaks
3861        // configured `kid`s, issuers, or acceptable algorithms.
3862        _ => "identity validation failed",
3863    }
3864}
3865
3866/// Extract the single value for a control header, refusing duplicates.
3867///
3868/// `HeaderMap::get(name)` returns only the first entry when a header
3869/// appears multiple times. A client sending
3870/// `X-AV-Session: sessA` followed by `X-AV-Session: sessB` would then
3871/// have `sessA` used for state while an intermediary log-aggregator
3872/// might observe `sessA, sessB` (some proxies merge on the wire). The
3873/// resulting session-desync is hard to diagnose and is a header
3874/// smuggling primitive at boundaries where our proxy is behind
3875/// another one. Refuse the multi-value case at ingress rather than
3876/// silently accept the first.
3877pub(crate) fn single_header<'a>(
3878    headers: &'a HeaderMap,
3879    name: &'static str,
3880) -> Result<Option<&'a axum::http::HeaderValue>, PipelineError> {
3881    let mut iter = headers.get_all(name).into_iter();
3882    let first = iter.next();
3883    if iter.next().is_some() {
3884        return Err(PipelineError::bad_request(format!(
3885            "request carries more than one {name} header; provide exactly one"
3886        )));
3887    }
3888    Ok(first)
3889}
3890
3891/// Strip the RFC 7235 §2.1 `Bearer` scheme from an Authorization header
3892/// value, case-insensitively (per the RFC), and allow one or more
3893/// whitespace characters (`SP` / `HTAB`) between the scheme token and
3894/// the credential (per `token1` production in §2.1).
3895///
3896/// `str::strip_prefix("Bearer ")` used to be the sole
3897/// parser. It missed `bearer`, `BEARER`, and (per the RFC's own
3898/// grammar) `Bearer\t...`, silently dropping to `None` and — when
3899/// `require_identity = false` — letting the request execute as
3900/// anonymous while the caller believed they had authenticated.
3901pub(crate) fn strip_bearer_scheme(value: &str) -> Option<&str> {
3902    let (scheme, rest) = value.split_at_checked(6)?;
3903    if !scheme.eq_ignore_ascii_case("Bearer") {
3904        return None;
3905    }
3906    let rest = rest.trim_start_matches([' ', '\t']);
3907    if rest.len() == value.len() - scheme.len() {
3908        // No whitespace at all after `Bearer` — the credential MUST
3909        // be separated per the ABNF (`token1 = 1*( SP / HTAB )
3910        // token`). Refuse `BearerXYZ` — that's a scheme other than
3911        // Bearer's credential, not a Bearer credential.
3912        return None;
3913    }
3914    if rest.is_empty() {
3915        // Empty credential.
3916        return None;
3917    }
3918    Some(rest)
3919}
3920
3921pub(crate) fn session_id(headers: &HeaderMap) -> Result<String, PipelineError> {
3922    match single_header(headers, SESSION_HEADER)? {
3923        Some(value) => {
3924            let value = value
3925                .to_str()
3926                .map_err(|_| PipelineError::bad_request("X-AV-Session is not valid text".to_owned()))?;
3927            av_core::SessionId::parse(value)
3928                .map(|id| id.to_string())
3929                .map_err(PipelineError::bad_request_source)
3930        }
3931        None => Ok(av_core::new_session_id().to_string()),
3932    }
3933}
3934
3935/// Stable id used as the principal-scoped budget key.
3936///
3937/// For authenticated requests this is the JWT's `instance_uid` (the
3938/// per-instance principal name — a single agent workload can hold several
3939/// concurrent tokens under one uid). For requests without a validator
3940/// (dev mode / passthrough) the identity's `instance_uid` is already the
3941/// literal `"anonymous"` sentinel, so this function is a straight
3942/// pass-through; the shipping default is a single shared bucket and the
3943/// config validator refuses `principal_budget` while `require_identity =
3944/// false` unless the operator explicitly acknowledges that shape.
3945/// Stable policy handle for a PDP denial, following the
3946/// `workflow.signed_required` naming: the console attributes denials by
3947/// this name, and it points the operator at the config block responsible.
3948fn pdp_policy_name(code: av_sandbox::DenialCode) -> &'static str {
3949    match code {
3950        av_sandbox::DenialCode::UnmappedTool => "pdp.intent_map",
3951        av_sandbox::DenialCode::MissionExpired | av_sandbox::DenialCode::MissionDenied => "pdp.mission",
3952        av_sandbox::DenialCode::PdpDenied => "pdp.authzen",
3953        _ => "pdp",
3954    }
3955}
3956
3957pub(crate) fn principal_id_for_budget(identity: &AgentIdentity) -> String {
3958    if identity.instance_uid.is_empty() {
3959        "anonymous".to_owned()
3960    } else {
3961        identity.instance_uid.clone()
3962    }
3963}
3964
3965fn workflow(headers: &HeaderMap, default: &str) -> Result<Workflow, PipelineError> {
3966    let value = single_header(headers, WORKFLOW_HEADER)?
3967        .map(|header| {
3968            header
3969                .to_str()
3970                .map_err(|_| PipelineError::bad_request("X-AV-Workflow is not valid text".to_owned()))
3971        })
3972        .transpose()?
3973        .unwrap_or(default);
3974    Workflow::parse(value).ok_or_else(|| {
3975        PipelineError::bad_request(format!("X-AV-Workflow must be signed or unsigned, got {value:?}"))
3976    })
3977}
3978
3979fn last_message_text(payload: &Value) -> String {
3980    let Some(message) = payload
3981        .get("messages")
3982        .and_then(Value::as_array)
3983        .and_then(|messages| messages.last())
3984    else {
3985        return String::new();
3986    };
3987    // Primary source: string `content`.
3988    if let Some(text) = message.get("content").and_then(Value::as_str) {
3989        if !text.is_empty() {
3990            return text.to_owned();
3991        }
3992    }
3993    // Mirror of the response-side fix: a
3994    // tool-call-only assistant message carries `content: null` and a
3995    // `tool_calls` array. `last_message_text` returning "" here would
3996    // feed a zero-vector embedding into the breaker in worker.rs and
3997    // false-trip legitimate tool-driven agents on the request side.
3998    // Synthesize `tool_name(arguments)` for each tool call, in
3999    // wire-order — same shape as the response-side synthesis in
4000    // routes.rs::AbortFinalizingStream::submit_response_capture.
4001    //
4002    // Beware: `function.arguments` reaches the
4003    // wire in TWO shapes: a JSON-encoded string (OpenAI reference)
4004    // OR a bare JSON object (some clients / most Anthropic-shaped
4005    // gateways). `.as_str()` on the object variant returned None and
4006    // the synthesis collapsed to `tool_name()` — worse than the
4007    // pre-fix behavior, because now two different-argument calls to
4008    // the same tool produced IDENTICAL synthesized text, causing
4009    // false loop trips. Match the tolerant pattern from
4010    // `atif_capture_from_request` in the same file: render the raw
4011    // Value directly when it isn't a stringified JSON. Empty and
4012    // missing collapse to "" and get gated off analyze_loop
4013    // downstream.
4014    if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
4015        let synthesized: Vec<String> = tool_calls
4016            .iter()
4017            .filter_map(|call| {
4018                let name = call
4019                    .get("function")
4020                    .and_then(|f| f.get("name"))
4021                    .and_then(Value::as_str)?;
4022                let arguments = call.get("function").and_then(|f| f.get("arguments"));
4023                let args_rendered = match arguments {
4024                    Some(Value::String(s)) => s.clone(),
4025                    Some(other) => serde_json::to_string(other).unwrap_or_default(),
4026                    None => String::new(),
4027                };
4028                Some(format!("{name}({args_rendered})"))
4029            })
4030            .collect();
4031        if !synthesized.is_empty() {
4032            return synthesized.join("\n");
4033        }
4034    }
4035    // Content-parts array (multimodal). Concatenate every text part
4036    // so an assistant reply built from mixed text+image parts still
4037    // feeds a non-zero vector to the breaker rather than "".
4038    if let Some(parts) = message.get("content").and_then(Value::as_array) {
4039        let text: String = parts
4040            .iter()
4041            .filter_map(|part| part.get("text").and_then(Value::as_str))
4042            .collect::<Vec<_>>()
4043            .join("\n");
4044        if !text.is_empty() {
4045            return text;
4046        }
4047    }
4048    String::new()
4049}
4050
4051/// Human-readable JSON type name for diagnostics — mirrors `typeof` in
4052/// JS. Used by [`atif_capture_from_request`] to say
4053/// `'messages' must be a JSON array, got object` rather than the
4054/// historical opaque `chat payload has no messages`.
4055fn json_type_name(value: &Value) -> &'static str {
4056    match value {
4057        Value::Null => "null",
4058        Value::Bool(_) => "boolean",
4059        Value::Number(_) => "number",
4060        Value::String(_) => "string",
4061        Value::Array(_) => "array",
4062        Value::Object(_) => "object",
4063    }
4064}
4065
4066fn atif_capture_from_request(payload: &Value) -> Result<AtifCapture, PipelineError> {
4067    // Split the two failure classes so support engineers can tell
4068    // "field missing" from "field is the wrong shape" — chasing a
4069    // phantom "no messages" ticket for a caller who typed
4070    // `"messages": {}` used to be the top diagnostic-quality
4071    // complaint. The OpenAI Responses API also uses `input`
4072    // instead of `messages`; the missing-field message now points
4073    // directly at the correct fix.
4074    let messages_value = payload
4075        .get("messages")
4076        .ok_or_else(|| PipelineError::bad_request("chat payload is missing 'messages'".to_owned()))?;
4077    let messages = messages_value.as_array().ok_or_else(|| {
4078        PipelineError::bad_request(format!(
4079            "'messages' must be a JSON array, got {}",
4080            json_type_name(messages_value)
4081        ))
4082    })?;
4083    let message = messages
4084        .last()
4085        .ok_or_else(|| PipelineError::bad_request("chat payload 'messages' is empty".to_owned()))?;
4086    let role = message.get("role").and_then(Value::as_str);
4087    let source = match role {
4088        // "function" is the legacy OpenAI function-calling spelling of
4089        // "tool" (a tool result fed back to the model) — still emitted
4090        // by older LangChain, AutoGen and llama-index versions.
4091        // Refusing it 400'd legitimate agent traffic.
4092        Some("system" | "developer" | "tool" | "function") => av_atif::Source::System,
4093        Some("user") => av_atif::Source::User,
4094        Some("assistant") => av_atif::Source::Agent,
4095        Some(other) => {
4096            return Err(PipelineError::bad_request(format!(
4097                "unsupported chat role {other:?}"
4098            )))
4099        }
4100        None => return Err(PipelineError::bad_request("chat message has no role".to_owned())),
4101    };
4102    let content = message
4103        .get("content")
4104        .filter(|value| value.is_string() || value.is_array())
4105        .cloned()
4106        .unwrap_or_else(|| Value::String(String::new()));
4107    let tool_calls = message.get("tool_calls").and_then(Value::as_array).map(|calls| {
4108        calls
4109            .iter()
4110            .filter_map(|call| {
4111                let function = call.get("function")?;
4112                let arguments = function
4113                    .get("arguments")
4114                    .cloned()
4115                    .unwrap_or(Value::Object(Default::default()));
4116                let arguments = arguments
4117                    .as_str()
4118                    .and_then(|raw| serde_json::from_str(raw).ok())
4119                    .unwrap_or(arguments);
4120                Some(av_atif::ToolCall {
4121                    tool_call_id: call
4122                        .get("id")
4123                        .and_then(Value::as_str)
4124                        .map_or_else(av_core::new_event_uid, str::to_owned),
4125                    function_name: function.get("name")?.as_str()?.to_owned(),
4126                    arguments,
4127                    extra: None,
4128                })
4129            })
4130            .collect()
4131    });
4132    let observation = (role == Some("tool")).then(|| av_atif::Observation {
4133        results: vec![av_atif::ObservationResult {
4134            source_call_id: None,
4135            content: Some(content.clone()),
4136            subagent_trajectory_ref: None,
4137            extra: message
4138                .get("tool_call_id")
4139                .cloned()
4140                .map(|tool_call_id| serde_json::json!({"tool_call_id": tool_call_id})),
4141        }],
4142    });
4143    Ok(AtifCapture {
4144        source,
4145        message: content,
4146        reasoning_content: if source == av_atif::Source::Agent {
4147            message
4148                .get("reasoning_content")
4149                .and_then(Value::as_str)
4150                .map(str::to_owned)
4151        } else {
4152            None
4153        },
4154        model_name: if source == av_atif::Source::Agent {
4155            payload.get("model").and_then(Value::as_str).map(str::to_owned)
4156        } else {
4157            None
4158        },
4159        tool_calls: if source == av_atif::Source::Agent {
4160            tool_calls
4161        } else {
4162            None
4163        },
4164        observation,
4165        llm_call_count: (source == av_atif::Source::Agent).then_some(1),
4166    })
4167}
4168
4169fn validate_session_binding(
4170    session: &Session,
4171    workflow: Workflow,
4172    identity: &AgentIdentity,
4173) -> Result<(), PipelineError> {
4174    if session.workflow != workflow {
4175        return Err(PipelineError::bad_request(
4176            "session workflow cannot change after open".to_owned(),
4177        ));
4178    }
4179    if session.identity.instance_uid != identity.instance_uid
4180        || session.identity.charter != identity.charter
4181        || session.identity.version != identity.version
4182    {
4183        return Err(PipelineError::Unauthorized(
4184            "session is bound to a different agent identity".to_owned(),
4185        ));
4186    }
4187    Ok(())
4188}
4189
4190pub(crate) fn scope_allows(scopes: &[String], required: &str) -> bool {
4191    scopes.iter().any(|scope| {
4192        scope == "*"
4193            || scope == required
4194            // allocation-free prefix match. The old
4195            // `required.starts_with(&format!("{prefix}:"))` allocated a
4196            // String per granted scope per request on the identity hot
4197            // path. `strip_prefix(prefix)` + a `:` head check is the
4198            // same predicate with zero allocations.
4199            || scope.strip_suffix(":*").is_some_and(|prefix| {
4200                required
4201                    .strip_prefix(prefix)
4202                    .is_some_and(|rest| rest.starts_with(':'))
4203            })
4204    })
4205}
4206
4207fn inject_corrective_message(payload: &mut Value) -> Result<(), PipelineError> {
4208    let messages = payload
4209        .get_mut("messages")
4210        .and_then(Value::as_array_mut)
4211        .ok_or_else(|| PipelineError::bad_request("chat payload has no messages array".to_owned()))?;
4212    messages.push(serde_json::json!({
4213        "role": "system",
4214        "content": "AgentVisor AI detected a semantic loop. Stop repeating the previous approach, identify new evidence, and choose a materially different next action."
4215    }));
4216    Ok(())
4217}
4218
4219pub(crate) fn load_exchange_seed(path: &str) -> Result<zeroize::Zeroizing<[u8; 32]>, String> {
4220    use zeroize::Zeroizing;
4221    require_owner_only_secret(std::path::Path::new(path)).map_err(|e| format!("exchange seed file: {e}"))?;
4222    let encoded = Zeroizing::new(
4223        av_core::fsutil::read_capped_string(std::path::Path::new(path), av_core::fsutil::MAX_CONTROL_BYTES)
4224            .map_err(|e| format!("read exchange seed {path}: {e}"))?,
4225    );
4226    let bytes =
4227        Zeroizing::new(hex::decode(encoded.trim()).map_err(|e| format!("decode exchange seed as hex: {e}"))?);
4228    let seed: Zeroizing<[u8; 32]> = Zeroizing::new(
4229        <[u8; 32]>::try_from(bytes.as_slice())
4230            .map_err(|_| "exchange seed must contain exactly 32 bytes".to_owned())?,
4231    );
4232    if *seed == [0u8; 32] || *seed == [0xFFu8; 32] {
4233        return Err(format!("exchange seed at {path} is a known-weak value; refusing"));
4234    }
4235    Ok(seed)
4236}
4237
4238#[cfg(test)]
4239mod tests {
4240    #![allow(
4241        clippy::expect_used,
4242        clippy::indexing_slicing,
4243        clippy::panic,
4244        clippy::unwrap_used
4245    )]
4246
4247    /// R285: the twice-registered supervised-loop metrics (eager boot
4248    /// registration here + lazy observation-site registration in
4249    /// main.rs/reconciler.rs) must take their HELP from the shared
4250    /// `supervision_help` consts / `LIFECYCLE_OUTBOX_PENDING_HELP`.
4251    /// Six counter pairs and the outbox gauge had drifted into
4252    /// per-tick "divergent HELP" warn spam; first-wins semantics also
4253    /// let a terse boot HELP shadow the rich forensic text alerting
4254    /// playbooks key on. A string literal beside any of these names
4255    /// reintroduces the drift the consts exist to prevent.
4256    #[test]
4257    fn twice_registered_metrics_use_shared_help_consts() {
4258        let sources = [
4259            ("pipeline.rs", include_str!("pipeline.rs")),
4260            ("main.rs", include_str!("main.rs")),
4261            ("reconciler.rs", include_str!("reconciler.rs")),
4262        ];
4263        let shared = [
4264            ("av_reconciler_panics_total", "RECONCILER_PANICS"),
4265            ("av_bridge_maintenance_panics_total", "BRIDGE_MAINTENANCE_PANICS"),
4266            ("av_bridge_maintenance_errors_total", "BRIDGE_MAINTENANCE_ERRORS"),
4267            (
4268                "av_bridge_maintenance_join_errors_total",
4269                "BRIDGE_MAINTENANCE_JOIN_ERRORS",
4270            ),
4271            ("av_atif_retention_panics_total", "ATIF_RETENTION_PANICS"),
4272            ("av_atif_retention_errors_total", "ATIF_RETENTION_ERRORS"),
4273            ("av_lifecycle_outbox_pending", "LIFECYCLE_OUTBOX_PENDING_HELP"),
4274        ];
4275        for (file, src) in sources {
4276            for (metric, const_name) in shared {
4277                let needle = format!("\"{metric}\"");
4278                let mut from = 0;
4279                while let Some(pos) = src[from..].find(&needle) {
4280                    let at = from + pos;
4281                    // The ~200 chars after the name must reference the
4282                    // shared const (registration site) — doc comments,
4283                    // alert examples, and scrape asserts don't register
4284                    // and carry no adjacent `.counter(`/`.gauge(` call.
4285                    let window = &src[at..(at + 220).min(src.len())];
4286                    let registers = src[..at]
4287                        .rfind(|c: char| !c.is_whitespace())
4288                        .map(|p| {
4289                            src[p.saturating_sub(20)..at].contains(".counter(")
4290                                || src[p.saturating_sub(20)..at].contains(".gauge(")
4291                        })
4292                        .unwrap_or(false);
4293                    if registers {
4294                        assert!(
4295                            window.contains(const_name),
4296                            "{file}: registration of {metric} does not use \
4297                             supervision_help::{const_name} — string-literal HELP \
4298                             reintroduces the divergent-HELP drift (R285)"
4299                        );
4300                    }
4301                    from = at + needle.len();
4302                }
4303            }
4304        }
4305    }
4306
4307    use super::*;
4308    use av_bridge::{BusError, PublishAck, StoredEvent};
4309    use av_receipts::Ed25519Signer;
4310    use av_sandbox::SandboxConfig;
4311    use av_state::InMemoryStore;
4312    use axum::http::HeaderValue;
4313    use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
4314
4315    /// `last_message_text`
4316    /// must produce distinct output for two different tool-call
4317    /// argument shapes so the loop breaker's embedding varies.
4318    /// Both string-shaped and object-shaped arguments MUST reach the
4319    /// synthesized text — the first take collapsed
4320    /// object-shaped arguments to `tool_name()`.
4321    #[test]
4322    fn last_message_text_tool_call_synthesis_varies_with_object_arguments() {
4323        let payload_string_args = serde_json::json!({
4324            "messages": [{
4325                "role": "assistant",
4326                "content": null,
4327                "tool_calls": [{
4328                    "id": "c1",
4329                    "type": "function",
4330                    "function": {"name": "read", "arguments": "{\"path\":\"a\"}"}
4331                }]
4332            }]
4333        });
4334        let payload_object_args_a = serde_json::json!({
4335            "messages": [{
4336                "role": "assistant",
4337                "content": null,
4338                "tool_calls": [{
4339                    "id": "c1",
4340                    "type": "function",
4341                    "function": {"name": "read", "arguments": {"path": "a"}}
4342                }]
4343            }]
4344        });
4345        let payload_object_args_b = serde_json::json!({
4346            "messages": [{
4347                "role": "assistant",
4348                "content": null,
4349                "tool_calls": [{
4350                    "id": "c1",
4351                    "type": "function",
4352                    "function": {"name": "read", "arguments": {"path": "b"}}
4353                }]
4354            }]
4355        });
4356        let t_string = last_message_text(&payload_string_args);
4357        let t_obj_a = last_message_text(&payload_object_args_a);
4358        let t_obj_b = last_message_text(&payload_object_args_b);
4359        assert!(!t_string.is_empty(), "string-arg synthesis must not be empty");
4360        assert!(!t_obj_a.is_empty(), "object-arg synthesis must not be empty");
4361        assert_ne!(
4362            t_obj_a, t_obj_b,
4363            "object-shape args with different content must produce different text; \
4364             got identical {t_obj_a:?}"
4365        );
4366        assert!(
4367            t_obj_a.contains("\"path\":\"a\"") || t_obj_a.contains("path"),
4368            "object-arg synthesis must include the argument content, got {t_obj_a:?}"
4369        );
4370    }
4371
4372    /// A tool-call message with EMPTY arguments still synthesizes to
4373    /// `tool_name()` and reaches the breaker as a non-empty string
4374    /// — otherwise every tool call with no arguments would false-trip
4375    /// the breaker via zero-vector embedding.
4376    #[test]
4377    fn last_message_text_tool_call_with_empty_arguments_still_synthesizes() {
4378        let payload = serde_json::json!({
4379            "messages": [{
4380                "role": "assistant",
4381                "content": null,
4382                "tool_calls": [{
4383                    "id": "c1",
4384                    "type": "function",
4385                    "function": {"name": "ping"}
4386                }]
4387            }]
4388        });
4389        let text = last_message_text(&payload);
4390        assert_eq!(text, "ping()");
4391    }
4392
4393    struct NullBus;
4394
4395    struct BlockingSink {
4396        entered: AtomicBool,
4397        release: tokio::sync::Notify,
4398    }
4399
4400    impl VectorSink for BlockingSink {
4401        fn record<'a>(
4402            &'a self,
4403            _session_id: &'a str,
4404            _vector: &'a [f32],
4405        ) -> av_loopdetect::VectorSinkFuture<'a> {
4406            Box::pin(async move {
4407                self.entered.store(true, AtomicOrdering::Release);
4408                self.release.notified().await;
4409                Ok(())
4410            })
4411        }
4412    }
4413
4414    impl EventBus for NullBus {
4415        fn publish(&self, topic: &str, _key: &str, _value: &Value) -> Result<PublishAck, BusError> {
4416            Ok(PublishAck {
4417                topic: topic.to_owned(),
4418                partition: 0,
4419                offset: 0,
4420            })
4421        }
4422
4423        fn fetch(
4424            &self,
4425            _topic: &str,
4426            _partition: u32,
4427            _offset: u64,
4428            _max: usize,
4429        ) -> Result<Vec<StoredEvent>, BusError> {
4430            Ok(Vec::new())
4431        }
4432
4433        fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
4434            Ok(1)
4435        }
4436
4437        fn topics(&self) -> Vec<String> {
4438            EventClass::all()
4439                .iter()
4440                .map(|class| class.topic().to_owned())
4441                .collect()
4442        }
4443    }
4444
4445    fn state(mut config: HarnessConfig) -> AppState {
4446        config.atif_spool_dir = tempfile::tempdir().unwrap().keep().to_string_lossy().into_owned();
4447        AppState::new(
4448            config,
4449            Arc::new(InMemoryStore::new()),
4450            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
4451            Arc::new(NullBus),
4452            None,
4453            Arc::new(Ed25519Signer::from_seed(&[9; 32])),
4454        )
4455        .unwrap()
4456    }
4457
4458    fn payload() -> Value {
4459        serde_json::json!({
4460            "model": "test",
4461            "messages": [{"role": "user", "content": "hello"}],
4462        })
4463    }
4464
4465    fn admission_test_runtime() -> tokio::runtime::Runtime {
4466        tokio::runtime::Builder::new_multi_thread()
4467            .worker_threads(2)
4468            .max_blocking_threads(1)
4469            .enable_all()
4470            .build()
4471            .unwrap()
4472    }
4473
4474    async fn occupy_admission_blocking_pool(
4475    ) -> (tokio::sync::oneshot::Sender<()>, tokio::task::JoinHandle<()>) {
4476        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
4477        let (release_tx, release_rx) = tokio::sync::oneshot::channel();
4478        let task = tokio::task::spawn_blocking(move || {
4479            let _ = started_tx.send(());
4480            // Dropping the sender also releases this thread if a test panics.
4481            let _ = release_rx.blocking_recv();
4482        });
4483        started_rx.await.unwrap();
4484        (release_tx, task)
4485    }
4486
4487    async fn poll_admission_once<F: std::future::Future>(
4488        mut future: std::pin::Pin<&mut F>,
4489    ) -> std::task::Poll<F::Output> {
4490        std::future::poll_fn(|context| std::task::Poll::Ready(future.as_mut().poll(context))).await
4491    }
4492
4493    #[test]
4494    fn small_chat_admission_bypasses_saturated_blocking_pool() {
4495        admission_test_runtime().block_on(async {
4496            for authenticated in [false, true] {
4497                let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4498                config.require_identity = authenticated;
4499                config.enforce_identity_scopes = authenticated;
4500                let mut state = state(config);
4501                let mut headers = HeaderMap::new();
4502                headers.insert(SESSION_HEADER, HeaderValue::from_static("small-local-admission"));
4503                if authenticated {
4504                    let validator = IdentityValidator::new("agentvisor-ai");
4505                    validator
4506                        .add_key(
4507                            "scope-key",
4508                            av_identity::KeyMaterial::HmacSecret(b"scope-secret".to_vec()),
4509                        )
4510                        .unwrap();
4511                    state.identity = Some(Arc::new(validator));
4512                    headers.insert(
4513                        axum::http::header::AUTHORIZATION,
4514                        format!("Bearer {}", scoped_token(&["chat:write"]))
4515                            .parse()
4516                            .unwrap(),
4517                    );
4518                }
4519                let caller = state.authenticate_chat_nonblocking(&headers).await.unwrap();
4520                let principal = caller.principal_digest();
4521                let mut other_principal = caller.clone();
4522                if let Some(validated) = &mut other_principal.validated {
4523                    validated.claims.sub = "another-principal".into();
4524                }
4525                let (release, blocker) = occupy_admission_blocking_pool().await;
4526                if !authenticated {
4527                    let mut authenticate = Box::pin(state.authenticate_chat_nonblocking(&headers));
4528                    assert!(matches!(
4529                        poll_admission_once(authenticate.as_mut()).await,
4530                        std::task::Poll::Ready(Ok(_))
4531                    ));
4532                    let mut duplicated = headers.clone();
4533                    duplicated.append(
4534                        axum::http::header::AUTHORIZATION,
4535                        HeaderValue::from_static("Bearer a"),
4536                    );
4537                    duplicated.append(
4538                        axum::http::header::AUTHORIZATION,
4539                        HeaderValue::from_static("Bearer b"),
4540                    );
4541                    let mut authenticate = Box::pin(state.authenticate_chat_nonblocking(&duplicated));
4542                    assert!(matches!(
4543                        poll_admission_once(authenticate.as_mut()).await,
4544                        std::task::Poll::Ready(Err(PipelineError::BadRequest { .. }))
4545                    ));
4546                }
4547                let body = payload();
4548                let body_bytes = body.to_string().len();
4549                let mut prepare =
4550                    Box::pin(state.prepare_authenticated_chat(&headers, body, body_bytes, caller));
4551                let outcome = poll_admission_once(prepare.as_mut()).await;
4552                let completed_inline = outcome.is_ready();
4553                release.send(()).unwrap();
4554                blocker.await.unwrap();
4555                let prepared = match outcome {
4556                    std::task::Poll::Ready(result) => result.unwrap(),
4557                    std::task::Poll::Pending => prepare.await.unwrap(),
4558                };
4559                assert!(
4560                    completed_inline,
4561                    "small local admission queued behind unrelated blocking work"
4562                );
4563                assert_eq!(prepared.session.principal_binding(), principal);
4564                if authenticated {
4565                    assert_eq!(
4566                        state
4567                            .metrics
4568                            .counter(
4569                                "av_identity_validations_total",
4570                                "Full inbound identity validations"
4571                            )
4572                            .get(),
4573                        1
4574                    );
4575                    // The inline path must still refuse another principal on
4576                    // this session even when agent instance claims coincide.
4577                    let refused = state
4578                        .prepare_authenticated_chat(&headers, payload(), body_bytes, other_principal)
4579                        .await;
4580                    assert!(matches!(refused, Err(PipelineError::Unauthorized(_))));
4581                }
4582                drop(prepared);
4583                state.worker.wait_idle().await;
4584            }
4585        });
4586    }
4587
4588    #[test]
4589    fn chat_preparation_offloads_large_bodies_and_both_quota_classes() {
4590        admission_test_runtime().block_on(async {
4591            for (session_quota, principal_quota, large_body) in [(true, false, false), (false, true, false), (false, false, true)] {
4592                let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4593                if session_quota {
4594                    config.budget.max_tokens = Some(1_000_000);
4595                }
4596                if principal_quota {
4597                    config.principal_budget = Some(av_state::BudgetSpec { max_tokens: Some(1_000_000), ..Default::default() });
4598                }
4599                let state = state(config);
4600                let mut headers = HeaderMap::new();
4601                headers.insert(SESSION_HEADER, HeaderValue::from_static("offloaded-admission"));
4602                let caller = state.authenticate_chat_nonblocking(&headers).await.unwrap();
4603                let body = if large_body {
4604                    serde_json::json!({"model":"test", "messages":[{"role":"user", "content":"x".repeat(32 * 1024)}]})
4605                } else {
4606                    payload()
4607                };
4608                let body_bytes = body.to_string().len();
4609                let (release, blocker) = occupy_admission_blocking_pool().await;
4610                let mut prepare = Box::pin(state.prepare_authenticated_chat(&headers, body, body_bytes, caller));
4611                let outcome = poll_admission_once(prepare.as_mut()).await;
4612                let offloaded = outcome.is_pending();
4613                let sessions_before_release = state.sessions.len();
4614                release.send(()).unwrap();
4615                blocker.await.unwrap();
4616                let prepared = match outcome {
4617                    std::task::Poll::Ready(result) => result.unwrap(),
4618                    std::task::Poll::Pending => prepare.await.unwrap(),
4619                };
4620                assert!(offloaded, "large-body or quota preparation ran inline");
4621                assert_eq!(sessions_before_release, 0, "preparation ran before the blocking worker was available");
4622                drop(prepared);
4623                state.worker.wait_idle().await;
4624            }
4625        });
4626    }
4627
4628    #[test]
4629    fn chat_route_passes_actual_body_size_to_admission() {
4630        use tower::ServiceExt as _;
4631
4632        admission_test_runtime().block_on(async {
4633            let state = state(HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp"));
4634            let body = serde_json::json!({
4635                "model": "test",
4636                "messages": [{"role": "user", "content": "x".repeat(32 * 1024)}],
4637            })
4638            .to_string();
4639            let request = axum::http::Request::builder()
4640                .method("POST")
4641                .uri("/v1/chat/completions")
4642                .header("content-type", "application/json")
4643                .header(SESSION_HEADER, "large-route-admission")
4644                .body(axum::body::Body::from(body))
4645                .unwrap();
4646            let (release, blocker) = occupy_admission_blocking_pool().await;
4647            let mut request = Box::pin(crate::build_router(state.clone()).oneshot(request));
4648            let outcome = poll_admission_once(request.as_mut()).await;
4649            let offloaded = outcome.is_pending();
4650            // If the route passed zero instead of body.len(), preparation
4651            // would already have opened a session before forwarding blocked.
4652            let sessions_before_release = state.sessions.len();
4653            release.send(()).unwrap();
4654            blocker.await.unwrap();
4655            let response = match outcome {
4656                std::task::Poll::Ready(result) => result.unwrap(),
4657                std::task::Poll::Pending => request.await.unwrap(),
4658            };
4659            assert!(offloaded);
4660            assert_eq!(sessions_before_release, 0);
4661            assert_eq!(response.status(), axum::http::StatusCode::BAD_GATEWAY);
4662            assert_eq!(state.sessions.len(), 1);
4663            state.worker.wait_idle().await;
4664        });
4665    }
4666
4667    #[test]
4668    fn unresolved_identity_never_runs_revocation_on_the_admission_task() {
4669        struct CountRevocations(std::sync::atomic::AtomicUsize);
4670        impl av_identity::RevocationStore for CountRevocations {
4671            fn revoke(&self, _jti: &str, _expires_at: u64) -> Result<(), String> {
4672                Ok(())
4673            }
4674            fn is_revoked(&self, _jti: &str) -> Result<bool, String> {
4675                self.0.fetch_add(1, AtomicOrdering::SeqCst);
4676                Ok(false)
4677            }
4678        }
4679        admission_test_runtime().block_on(async {
4680            let revocations = Arc::new(CountRevocations(std::sync::atomic::AtomicUsize::new(0)));
4681            let mut validator = IdentityValidator::new("agentvisor-ai");
4682            validator
4683                .add_key(
4684                    "scope-key",
4685                    av_identity::KeyMaterial::HmacSecret(b"scope-secret".to_vec()),
4686                )
4687                .unwrap();
4688            validator.set_revocation_store(revocations.clone());
4689            let mut state = state(HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp"));
4690            state.identity = Some(Arc::new(validator));
4691            let mut headers = HeaderMap::new();
4692            headers.insert(SESSION_HEADER, HeaderValue::from_static("unresolved-admission"));
4693            headers.insert(
4694                axum::http::header::AUTHORIZATION,
4695                format!("Bearer {}", scoped_token(&["chat:write"]))
4696                    .parse()
4697                    .unwrap(),
4698            );
4699            let (release, blocker) = occupy_admission_blocking_pool().await;
4700            let mut authenticate = Box::pin(state.authenticate_chat_nonblocking(&headers));
4701            let outcome = poll_admission_once(authenticate.as_mut()).await;
4702            let offloaded = outcome.is_pending();
4703            let calls_before_release = revocations.0.load(AtomicOrdering::SeqCst);
4704            release.send(()).unwrap();
4705            blocker.await.unwrap();
4706            let _caller = match outcome {
4707                std::task::Poll::Ready(result) => result.unwrap(),
4708                std::task::Poll::Pending => authenticate.await.unwrap(),
4709            };
4710            assert!(offloaded);
4711            assert_eq!(calls_before_release, 0);
4712            assert_eq!(revocations.0.load(AtomicOrdering::SeqCst), 1);
4713
4714            let (release, blocker) = occupy_admission_blocking_pool().await;
4715            let body = payload();
4716            let body_bytes = body.to_string().len();
4717            let mut prepare = Box::pin(state.prepare_chat_nonblocking(&headers, body, body_bytes, None));
4718            let outcome = poll_admission_once(prepare.as_mut()).await;
4719            let offloaded = outcome.is_pending();
4720            let calls_before_release = revocations.0.load(AtomicOrdering::SeqCst);
4721            release.send(()).unwrap();
4722            blocker.await.unwrap();
4723            let prepared = match outcome {
4724                std::task::Poll::Ready(result) => result.unwrap(),
4725                std::task::Poll::Pending => prepare.await.unwrap(),
4726            };
4727            assert!(offloaded);
4728            assert_eq!(calls_before_release, 1);
4729            assert_eq!(revocations.0.load(AtomicOrdering::SeqCst), 2);
4730            drop(prepared);
4731            state.worker.wait_idle().await;
4732        });
4733    }
4734
4735    async fn trip_loop(state: &AppState, headers: &HeaderMap, repeated: &Value) {
4736        for expected in 1..=4u64 {
4737            let mut prepared = state.prepare_chat(headers, repeated.clone(), None).unwrap();
4738            // This helper abandons the prepared request without forwarding.
4739            // Defuse the capture guard so its terminal failure job does not
4740            // pad the chain — the chain-count waits below must count only
4741            // the admission jobs, or the analyze jobs race the assertion.
4742            prepared.capture_guard.defuse();
4743            drop(prepared);
4744            let session = state.sessions.get("loop-session").unwrap();
4745            // Generous budget: the chain append rides an async worker job and
4746            // must survive heavily loaded parallel test runs.
4747            tokio::time::timeout(std::time::Duration::from_secs(10), async {
4748                while session.chain.lock().count() < expected {
4749                    tokio::task::yield_now().await;
4750                }
4751            })
4752            .await
4753            .unwrap();
4754        }
4755    }
4756
4757    #[tokio::test]
4758    async fn prepare_binds_session_and_stays_below_hot_path_budget() {
4759        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4760        let state = state(config);
4761        let mut headers = HeaderMap::new();
4762        headers.insert(SESSION_HEADER, HeaderValue::from_static("session-1"));
4763
4764        let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
4765        assert_eq!(prepared.session.id, "session-1");
4766        // Debug-build ceiling: 100ms is 3 orders of magnitude above the release
4767        // SLA (~33us p95); a regression an order of magnitude worse still trips
4768        // this. The strict perf gate lives in the SLA bench suite.
4769        assert!(
4770            prepared.middleware_us < 100_000,
4771            "middleware took {}us",
4772            prepared.middleware_us
4773        );
4774        assert!(state.metrics.render().contains("av_stage_duration_seconds"));
4775    }
4776
4777    #[tokio::test]
4778    async fn missing_identity_fails_closed_when_required() {
4779        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4780        config.require_identity = true;
4781        let error = match state(config).prepare_chat(&HeaderMap::new(), payload(), None) {
4782            Ok(_) => panic!("missing identity was accepted"),
4783            Err(error) => error,
4784        };
4785        assert!(matches!(error, PipelineError::Unauthorized(_)));
4786    }
4787
4788    #[tokio::test]
4789    async fn token_quota_blocks_before_dispatch() {
4790        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4791        config.budget.max_tokens = Some(1);
4792        let state = state(config);
4793        let mut headers = HeaderMap::new();
4794        headers.insert(SESSION_HEADER, HeaderValue::from_static("quota-failure"));
4795        let error = match state.prepare_chat(&headers, payload(), None) {
4796            Ok(_) => panic!("over-budget request was accepted"),
4797            Err(error) => error,
4798        };
4799        assert!(matches!(error, PipelineError::Blocked { .. }));
4800        assert_eq!(state.sessions.len(), 1);
4801        let session = state.sessions.get("quota-failure").unwrap();
4802        tokio::time::timeout(std::time::Duration::from_secs(10), async {
4803            while session.atif_steps_count() == 0 {
4804                tokio::task::yield_now().await;
4805            }
4806        })
4807        .await
4808        .unwrap();
4809    }
4810
4811    /// The principal-scoped budget (§3.2 fix) binds across rotating
4812    /// X-AV-Session headers: sessions A and B pointed at the same
4813    /// principal share one ledger. Without the principal budget, a
4814    /// caller could reset the session bucket per request and never
4815    /// hit `max_tokens`.
4816    #[tokio::test]
4817    async fn principal_budget_binds_across_rotated_session_headers() {
4818        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4819        // Wide session budget so it never binds — we're proving the
4820        // *principal* ledger is what refuses the second request.
4821        config.budget.max_tokens = Some(1_000_000);
4822        // The test payload approximates to ~30 tokens (roughly 2 tokens per
4823        // ASCII word + one per punctuation glyph, on the ~60-char JSON body).
4824        // A cap of 40 admits the first request and refuses the second under
4825        // the same principal — the anti-rotation property in one assertion.
4826        config.principal_budget = Some(av_state::BudgetSpec {
4827            max_tokens: Some(40),
4828            ..av_state::BudgetSpec::default()
4829        });
4830        let state = state(config);
4831
4832        // First request under session A: fits the principal budget.
4833        let mut headers_a = HeaderMap::new();
4834        headers_a.insert(SESSION_HEADER, HeaderValue::from_static("rotated-a"));
4835        let first = state.prepare_chat(&headers_a, payload(), None);
4836        assert!(first.is_ok(), "first principal request must be admitted");
4837        // Mark the admission dispatched before dropping: a dropped
4838        // UNdispatched request now (correctly) refunds via
4839        // `AdmissionDebit`'s cancellation guard, which would empty the
4840        // very ledger this test needs to stay charged.
4841        if let Ok(mut prepared) = first {
4842            let _ = prepared.admission_debit.take_for_dispatch();
4843        }
4844
4845        // Second request under a DIFFERENT session id but the same
4846        // principal (anonymous — both requests unauthenticated). The
4847        // session ledger for `rotated-b` is virgin; only the principal
4848        // ledger can refuse. Refusal proves the header-rotation attack
4849        // is closed.
4850        let mut headers_b = HeaderMap::new();
4851        headers_b.insert(SESSION_HEADER, HeaderValue::from_static("rotated-b"));
4852        match state.prepare_chat(&headers_b, payload(), None) {
4853            Ok(_) => panic!("header rotation defeated principal budget — the fix from §3.2 has regressed"),
4854            Err(PipelineError::Blocked { context: msg, .. }) => {
4855                assert!(
4856                    msg.contains("principal."),
4857                    "refusal must name the principal-scoped limit, got {msg:?}"
4858                );
4859            }
4860            Err(other) => panic!("unexpected refusal shape {other:?}"),
4861        }
4862    }
4863
4864    /// `abandon_prepared` runs strictly pre-dispatch, so its refund must
4865    /// mirror the DUAL-ledger admission debit. Pre-fix it refunded only
4866    /// the session ledger: every pre-dispatch abort (durable-capture
4867    /// failure, breaker-open, marker-write failure) permanently drained
4868    /// principal tokens for zero LLM work — under header rotation the
4869    /// principal ledger is the only one that binds, so aborts alone
4870    /// could exhaust an identity's budget.
4871    #[tokio::test]
4872    async fn abandon_prepared_refunds_the_principal_ledger_too() {
4873        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4874        config.budget.max_tokens = Some(1_000_000);
4875        // Fits ONE ~30-token payload; a leaked (unrefunded) debit makes
4876        // the next same-principal request refuse.
4877        config.principal_budget = Some(av_state::BudgetSpec {
4878            max_tokens: Some(40),
4879            ..av_state::BudgetSpec::default()
4880        });
4881        let state = state(config);
4882
4883        let mut headers = HeaderMap::new();
4884        headers.insert(SESSION_HEADER, HeaderValue::from_static("abandon-a"));
4885        let mut prepared = state.prepare_chat(&headers, payload(), None).unwrap();
4886        state.abandon_prepared(&mut prepared, StopReason::Other, "test abandon");
4887        drop(prepared);
4888
4889        // Same (anonymous) principal, different session: only the
4890        // principal ledger can refuse here. It must not — the abandoned
4891        // request's debit was refunded on both ledgers.
4892        let mut headers_b = HeaderMap::new();
4893        headers_b.insert(SESSION_HEADER, HeaderValue::from_static("abandon-b"));
4894        let second = state.prepare_chat(&headers_b, payload(), None);
4895        assert!(
4896            second.is_ok(),
4897            "an abandoned request must not consume principal budget; got {:?}",
4898            second.err()
4899        );
4900    }
4901
4902    /// Axum drops the request future wholesale on client disconnect —
4903    /// no explicit abort path runs. The admission debit must refund via
4904    /// `AdmissionDebit`'s Drop (both ledgers), or every mid-admission
4905    /// disconnect permanently drains the never-healing principal
4906    /// ledger with zero LLM work.
4907    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4908    async fn dropping_a_prepared_request_refunds_both_ledgers() {
4909        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4910        config.budget.max_tokens = Some(1_000_000);
4911        // Fits ONE ~30-token payload; a leaked (unrefunded) debit makes
4912        // the next same-principal request refuse.
4913        config.principal_budget = Some(av_state::BudgetSpec {
4914            max_tokens: Some(40),
4915            ..av_state::BudgetSpec::default()
4916        });
4917        let state = state(config);
4918
4919        let mut headers = HeaderMap::new();
4920        headers.insert(SESSION_HEADER, HeaderValue::from_static("cancelled-a"));
4921        let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
4922        // Simulate the cancelled future: drop with no explicit abort.
4923        drop(prepared);
4924
4925        // The Drop refund runs on the blocking pool; poll until it lands.
4926        let mut headers_b = HeaderMap::new();
4927        headers_b.insert(SESSION_HEADER, HeaderValue::from_static("cancelled-b"));
4928        let mut second = state.prepare_chat(&headers_b, payload(), None);
4929        for _ in 0..200 {
4930            if second.is_ok() {
4931                break;
4932            }
4933            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4934            second = state.prepare_chat(&headers_b, payload(), None);
4935        }
4936        assert!(
4937            second.is_ok(),
4938            "a dropped (cancelled) prepared request must refund the principal ledger; got {:?}",
4939            second.err()
4940        );
4941    }
4942
4943    /// The refund window ends at upstream dispatch: once
4944    /// `take_for_dispatch` ran (immediately before `send()`), dropping
4945    /// the request must NOT refund — the request may have reached the
4946    /// provider, and the debit conservatively stands (mirroring the
4947    /// `is_connect`-only refund on send errors).
4948    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4949    async fn dispatched_debit_stands_when_the_request_drops() {
4950        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4951        config.budget.max_tokens = Some(1_000_000);
4952        config.principal_budget = Some(av_state::BudgetSpec {
4953            max_tokens: Some(40),
4954            ..av_state::BudgetSpec::default()
4955        });
4956        let state = state(config);
4957
4958        let mut headers = HeaderMap::new();
4959        headers.insert(SESSION_HEADER, HeaderValue::from_static("dispatched-a"));
4960        let mut prepared = state.prepare_chat(&headers, payload(), None).unwrap();
4961        let debited = prepared.admission_debit.take_for_dispatch();
4962        assert!(debited > 0, "admission must have debited tokens");
4963        drop(prepared);
4964
4965        // Give a hypothetical (buggy) Drop refund time to land, then
4966        // verify the principal ledger still holds the debit.
4967        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4968        let mut headers_b = HeaderMap::new();
4969        headers_b.insert(SESSION_HEADER, HeaderValue::from_static("dispatched-b"));
4970        assert!(
4971            matches!(
4972                state.prepare_chat(&headers_b, payload(), None),
4973                Err(PipelineError::Blocked { .. })
4974            ),
4975            "a dispatched request's debit must stand after drop"
4976        );
4977    }
4978
4979    /// The relayed-header single-value checks must run BEFORE the
4980    /// budget debit: pre-fix, `single_header`'s `?` fired from the
4981    /// `PreparedRequest` construction after BOTH ledgers were debited,
4982    /// with no refund arm — a client repeating a duplicate
4983    /// `openai-beta` header drained budgets without one upstream call.
4984    #[tokio::test]
4985    async fn duplicate_passthrough_header_is_refused_without_consuming_budget() {
4986        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4987        // Fits ONE ~30-token payload: a pre-refusal debit leak makes the
4988        // follow-up legitimate request refuse.
4989        config.budget.max_tokens = Some(40);
4990        let state = state(config);
4991
4992        let mut headers = HeaderMap::new();
4993        headers.insert(SESSION_HEADER, HeaderValue::from_static("dup-header"));
4994        headers.append("openai-beta", HeaderValue::from_static("assistants=v1"));
4995        headers.append("openai-beta", HeaderValue::from_static("assistants=v2"));
4996        assert!(
4997            matches!(
4998                state.prepare_chat(&headers, payload(), None),
4999                Err(PipelineError::BadRequest { .. })
5000            ),
5001            "duplicate allowlisted headers must be refused"
5002        );
5003
5004        // The refusal must not have consumed the session budget.
5005        let mut clean = HeaderMap::new();
5006        clean.insert(SESSION_HEADER, HeaderValue::from_static("dup-header"));
5007        clean.insert("openai-beta", HeaderValue::from_static("assistants=v2"));
5008        let second = state.prepare_chat(&clean, payload(), None);
5009        assert!(
5010            second.is_ok(),
5011            "a header-refused request must not consume budget; got {:?}",
5012            second.err()
5013        );
5014    }
5015
5016    #[tokio::test]
5017    async fn invalid_workflow_is_rejected() {
5018        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5019        let state = state(config);
5020        let mut headers = HeaderMap::new();
5021        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("sometimes"));
5022        assert!(matches!(
5023            state.prepare_chat(&headers, payload(), None),
5024            Err(PipelineError::BadRequest { .. })
5025        ));
5026    }
5027
5028    #[tokio::test]
5029    async fn open_session_cannot_change_workflow() {
5030        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5031        let state = state(config);
5032        let mut headers = HeaderMap::new();
5033        headers.insert(SESSION_HEADER, HeaderValue::from_static("bound-session"));
5034        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5035        state.prepare_chat(&headers, payload(), None).unwrap();
5036        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("unsigned"));
5037        assert!(matches!(
5038            state.prepare_chat(&headers, payload(), None),
5039            Err(PipelineError::BadRequest { .. })
5040        ));
5041    }
5042
5043    #[tokio::test]
5044    async fn asynchronous_loop_verdict_blocks_the_next_request() {
5045        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5046        config.breaker.min_tokens = 0;
5047        let state = state(config);
5048        let mut headers = HeaderMap::new();
5049        headers.insert(SESSION_HEADER, HeaderValue::from_static("loop-session"));
5050        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5051        let repeated = serde_json::json!({
5052            "model": "test",
5053            "messages": [{
5054                "role": "assistant",
5055                "content": "I should check the database again for pending orders"
5056            }]
5057        });
5058        trip_loop(&state, &headers, &repeated).await;
5059        assert!(matches!(
5060            state.prepare_chat(&headers, repeated, None),
5061            Err(PipelineError::Blocked { .. })
5062        ));
5063    }
5064
5065    #[tokio::test]
5066    async fn loop_inject_action_adds_correction_and_resets_breaker() {
5067        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5068        config.breaker.min_tokens = 0;
5069        config.breaker.action = BreakerAction::Inject;
5070        let state = state(config);
5071        let mut headers = HeaderMap::new();
5072        headers.insert(SESSION_HEADER, HeaderValue::from_static("loop-session"));
5073        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5074        let repeated = serde_json::json!({
5075            "messages": [{"role": "assistant", "content": "repeat the database query"}]
5076        });
5077        trip_loop(&state, &headers, &repeated).await;
5078        let prepared = state.prepare_chat(&headers, repeated, None).unwrap();
5079        let correction = prepared
5080            .payload
5081            .get("messages")
5082            .and_then(Value::as_array)
5083            .and_then(|messages| messages.last())
5084            .and_then(|message| message.get("content"))
5085            .and_then(Value::as_str)
5086            .unwrap();
5087        assert!(correction.contains("semantic loop"));
5088        assert_eq!(prepared.session.loop_state.state(), BreakerState::Closed);
5089    }
5090
5091    #[tokio::test]
5092    async fn loop_abort_action_returns_abort_error() {
5093        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5094        config.breaker.min_tokens = 0;
5095        config.breaker.action = BreakerAction::Abort;
5096        let state = state(config);
5097        let mut headers = HeaderMap::new();
5098        headers.insert(SESSION_HEADER, HeaderValue::from_static("loop-session"));
5099        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5100        let repeated = serde_json::json!({
5101            "messages": [{"role": "assistant", "content": "repeat the database query"}]
5102        });
5103        trip_loop(&state, &headers, &repeated).await;
5104        assert!(matches!(
5105            state.prepare_chat(&headers, repeated, None),
5106            Err(PipelineError::Abort(_))
5107        ));
5108    }
5109
5110    /// A breaker trip used to replace the job's Compression class with
5111    /// StopReason *before* the accounting decisions ran, so the tripped
5112    /// admission's prompt tokens vanished from the session totals (and the
5113    /// journal record) — receipts undercounted exactly the runaway sessions
5114    /// the breaker exists to attest. Accounting must key on the submitted
5115    /// class, not the swapped one.
5116    #[tokio::test]
5117    async fn breaker_trip_does_not_drop_the_admissions_prompt_token_accounting() {
5118        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5119        config.breaker.min_tokens = 0;
5120        let state = state(config);
5121        let mut headers = HeaderMap::new();
5122        headers.insert(SESSION_HEADER, HeaderValue::from_static("loop-session"));
5123        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5124        let repeated = serde_json::json!({
5125            "model": "test",
5126            "messages": [{
5127                "role": "assistant",
5128                "content": "I should check the database again for pending orders"
5129            }]
5130        });
5131        // Four identical admissions, tracking chain growth like `trip_loop`;
5132        // the breaker trips on one of the later worker jobs. Capture the
5133        // per-admission token amount after the first job lands.
5134        let mut per_request = 0u64;
5135        for expected in 1..=4u64 {
5136            state.prepare_chat(&headers, repeated.clone(), None).unwrap();
5137            let session = state.sessions.get("loop-session").unwrap();
5138            tokio::time::timeout(std::time::Duration::from_secs(10), async {
5139                while session.chain.lock().count() < expected {
5140                    tokio::task::yield_now().await;
5141                }
5142            })
5143            .await
5144            .unwrap();
5145            if expected == 1 {
5146                tokio::time::timeout(std::time::Duration::from_secs(10), session.wait_for_worker_jobs())
5147                    .await
5148                    .unwrap();
5149                per_request = session
5150                    .totals
5151                    .prompt_tokens
5152                    .load(std::sync::atomic::Ordering::Acquire);
5153                assert!(per_request > 0, "admission must account prompt tokens");
5154            }
5155        }
5156        let session = state.sessions.get("loop-session").unwrap();
5157        tokio::time::timeout(std::time::Duration::from_secs(10), session.wait_for_worker_jobs())
5158            .await
5159            .unwrap();
5160        assert_eq!(
5161            session.loop_state.state(),
5162            BreakerState::Open,
5163            "precondition: the breaker must have tripped during the four admissions",
5164        );
5165        assert_eq!(
5166            session
5167                .totals
5168                .prompt_tokens
5169                .load(std::sync::atomic::Ordering::Acquire),
5170            per_request * 4,
5171            "every admission's prompt tokens must be accounted, including the one whose \
5172             worker job carried the breaker trip",
5173        );
5174    }
5175
5176    /// Budget counters are dead weight once a session is sealed (admission
5177    /// rejects closed sessions before any quota check); finalization must
5178    /// clear them or the in-memory state store grows by a few cells per
5179    /// client-chosen session id forever.
5180    #[tokio::test]
5181    async fn finalization_clears_the_sessions_budget_counters() {
5182        let store: Arc<dyn StateStore> = Arc::new(InMemoryStore::new());
5183        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5184        config.budget.max_tokens = Some(1_000_000);
5185        config.atif_spool_dir = tempfile::tempdir().unwrap().keep().to_string_lossy().into_owned();
5186        let state = AppState::new(
5187            config,
5188            Arc::clone(&store),
5189            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
5190            Arc::new(NullBus),
5191            None,
5192            Arc::new(Ed25519Signer::from_seed(&[9; 32])),
5193        )
5194        .unwrap();
5195        let mut headers = HeaderMap::new();
5196        headers.insert(SESSION_HEADER, HeaderValue::from_static("budget-cleanup"));
5197        let mut prepared = state.prepare_chat(&headers, payload(), None).unwrap();
5198        // Root cause of the intermittent CI-only failure (3× as of
5199        // 2026-09-08, never in 40+ local runs): dropping the prepared
5200        // request arms AdmissionDebit::drop, whose refund runs on the
5201        // blocking pool and races the precondition read below — on a
5202        // loaded runner the refund won and `observed` read Ok(0). The
5203        // race also made the FINAL assert vacuous: a drop-refund zeroes
5204        // the cells with no help from the finalizer. Disarm the debit
5205        // exactly like a real dispatch (`take_for_dispatch`, the
5206        // :4021 idiom) so the cells stay genuinely spent until
5207        // `close_session` clears them — the behavior this test pins.
5208        let _ = prepared.admission_debit.take_for_dispatch();
5209        drop(prepared);
5210        let tokens_key = format!(
5211            "{}tokens",
5212            av_state::ActionBudget::session_prefix("budget-cleanup")
5213        );
5214        let observed = store.get(&tokens_key);
5215        assert!(
5216            observed.as_ref().copied().unwrap_or(0) > 0,
5217            "precondition: admission must have spent from the token budget \
5218             (key {tokens_key:?}, observed {observed:?})",
5219        );
5220        let session = state.sessions.get("budget-cleanup").unwrap();
5221        session.wait_for_worker_jobs().await;
5222        state
5223            .finalizer
5224            .close_session(session, StopReason::SessionClosed)
5225            .await
5226            .unwrap();
5227        assert_eq!(
5228            store.get(&tokens_key).unwrap(),
5229            0,
5230            "finalization must remove the sealed session's budget counters",
5231        );
5232    }
5233
5234    #[tokio::test]
5235    async fn consequential_tools_require_signed_workflows() {
5236        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5237        let state = state(config);
5238        let raw = serde_json::to_vec(&serde_json::json!({
5239            "jsonrpc": "2.0",
5240            "id": 1,
5241            "method": "tools/call",
5242            "params": {"name": "db_write", "arguments": {}}
5243        }))
5244        .unwrap();
5245        let mut unsigned = HeaderMap::new();
5246        unsigned.insert(SESSION_HEADER, HeaderValue::from_static("unsigned-write"));
5247        assert!(matches!(
5248            state.intercept_tool(&unsigned, &raw).unwrap(),
5249            ToolVerdict::Blocked { stage: "policy", .. }
5250        ));
5251
5252        let mut signed = HeaderMap::new();
5253        signed.insert(SESSION_HEADER, HeaderValue::from_static("signed-write"));
5254        signed.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5255        assert!(state.intercept_tool(&signed, &raw).unwrap().is_allowed());
5256    }
5257
5258    /// The semantic-loop breaker's hard-stop actions (Reject/Abort) must
5259    /// gate TOOL admission exactly like they gate chat: pre-fix, a
5260    /// breaker-open session — whose chat requests were already refused —
5261    /// kept executing otherwise-permitted MCP tool calls on the same
5262    /// session until idle close, so the "stop the runaway agent"
5263    /// decision only stopped half the agent.
5264    #[tokio::test]
5265    async fn open_breaker_hard_stop_refuses_tool_calls() {
5266        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5267        let state = state(config);
5268        let identity = av_events::AgentIdentity {
5269            version: "dev".into(),
5270            charter: "anonymous".into(),
5271            instance_uid: "anonymous".into(),
5272            ttl_remaining_s: None,
5273        };
5274        let session = state.sessions.get_or_open(
5275            "breaker-tools",
5276            crate::session::Workflow::Unsigned,
5277            &identity,
5278            &state.config.breaker,
5279        );
5280        // Trip the breaker directly: identical embeddings above the
5281        // min-token floor are zero-novelty steps; the default window
5282        // trips after three.
5283        for _ in 0..4 {
5284            session.loop_state.observe_embedding(vec![1.0, 2.0, 3.0], 2_000);
5285        }
5286        assert_eq!(
5287            session.loop_state.state(),
5288            BreakerState::Open,
5289            "precondition: breaker must be open",
5290        );
5291
5292        let raw = serde_json::to_vec(&serde_json::json!({
5293            "jsonrpc": "2.0",
5294            "id": 1,
5295            "method": "tools/call",
5296            "params": {"name": "db_read", "arguments": {}}
5297        }))
5298        .unwrap();
5299        let mut headers = HeaderMap::new();
5300        headers.insert(SESSION_HEADER, HeaderValue::from_static("breaker-tools"));
5301        let refusal = state.intercept_tool(&headers, &raw);
5302        assert!(
5303            matches!(&refusal, Err(PipelineError::Blocked { context, .. }) if context.contains("circuit breaker")),
5304            "an open hard-stop breaker must refuse tool execution, got {refusal:?}",
5305        );
5306        assert!(
5307            session.enforcement_tripped(),
5308            "the tool-path refusal latches enforcement like the chat path",
5309        );
5310    }
5311
5312    /// Required-identity tool calls with NO Authorization header are
5313    /// refused by the cheap pre-parse gate — an unauthenticated flood
5314    /// must not reach the MCP parse/canonicalize/hash work, and the
5315    /// refusal shape matches resolve_identity's missing-bearer arm.
5316    #[tokio::test]
5317    async fn required_identity_tool_call_without_bearer_is_refused_before_parse() {
5318        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5319        config.require_identity = true;
5320        let state = state(config);
5321        let mut headers = HeaderMap::new();
5322        headers.insert(SESSION_HEADER, HeaderValue::from_static("anon-tools"));
5323        // Deliberately unparseable body: the gate fires on the header
5324        // alone, so the parse must never get a say in the verdict.
5325        let refusal = state.intercept_tool(&headers, b"{this is not json");
5326        assert!(
5327            matches!(&refusal, Err(PipelineError::Unauthorized(reason)) if reason.contains("missing bearer token")),
5328            "missing bearer under require_identity must refuse pre-parse, got {refusal:?}",
5329        );
5330    }
5331
5332    /// `intercept_tool_durable` used to re-derive the session id from headers
5333    /// AFTER the verdict. For a header-less request `session_id()` mints a
5334    /// fresh random id on every call, so the post-verdict lookup targeted a
5335    /// session that never existed and the call always failed with "tool
5336    /// session disappeared" — after the verdict had already been computed and
5337    /// audited under the real (generated) session.
5338    #[tokio::test]
5339    async fn intercept_tool_durable_works_without_a_session_header() {
5340        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5341        let state = state(config);
5342        let raw = serde_json::to_vec(&serde_json::json!({
5343            "jsonrpc": "2.0",
5344            "id": 1,
5345            "method": "tools/call",
5346            "params": {"name": "safe_tool", "arguments": {}}
5347        }))
5348        .unwrap();
5349        let verdict = state
5350            .intercept_tool_durable(&HeaderMap::new(), &raw)
5351            .await
5352            .unwrap();
5353        assert!(
5354            verdict.is_allowed(),
5355            "durable interception must await the verdict's own session, not a re-derived id",
5356        );
5357    }
5358
5359    /// When `prepare_chat_durable` refuses a request AFTER admission (the
5360    /// request's own loop analysis opened the breaker during the audit wait),
5361    /// it must submit the terminal failure record for the response attempt
5362    /// journaled at admission. Otherwise the active journal ends with a
5363    /// dangling non-terminal attempt and a later crash-recovery scan
5364    /// quarantines the session over a request the client already saw fail.
5365    #[tokio::test]
5366    async fn durable_breaker_refusal_journals_a_terminal_response_attempt() {
5367        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5368        config.breaker.min_tokens = 0;
5369        let state = state(config);
5370        let mut headers = HeaderMap::new();
5371        headers.insert(SESSION_HEADER, HeaderValue::from_static("durable-loop"));
5372        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5373        let repeated = serde_json::json!({
5374            "model": "test",
5375            "messages": [{
5376                "role": "assistant",
5377                "content": "I should check the database again for pending orders"
5378            }],
5379        });
5380        // Attempts the test itself abandons after a successful prepare: the
5381        // capture guard terminates them on drop (same as a client
5382        // disconnect), so the journal must end with NO dangling attempts.
5383        let mut abandoned_by_test = std::collections::HashSet::new();
5384        let mut refusal = None;
5385        for _ in 0..8 {
5386            match state.prepare_chat_durable(&headers, repeated.clone()).await {
5387                Ok(prepared) => {
5388                    abandoned_by_test.insert(prepared.capture_guard.attempt_id().to_owned());
5389                }
5390                Err(error) => {
5391                    refusal = Some(error);
5392                    break;
5393                }
5394            }
5395        }
5396        assert!(
5397            matches!(refusal, Some(PipelineError::Blocked { .. })),
5398            "the breaker must refuse during the audit wait, got {refusal:?}",
5399        );
5400        assert!(
5401            !abandoned_by_test.is_empty(),
5402            "at least one prepare must succeed first"
5403        );
5404        let session = state.sessions.get("durable-loop").unwrap();
5405        session.wait_for_worker_jobs().await;
5406
5407        let digest = av_core::digest::sha256_hex("durable-loop".as_bytes());
5408        let stem = digest.get(..32).unwrap();
5409        let journal_path =
5410            std::path::Path::new(&state.config.atif_spool_dir).join(format!("{stem}.events.ndjson"));
5411        let journal = std::fs::read_to_string(&journal_path).unwrap();
5412        let mut dangling = std::collections::HashSet::new();
5413        for (index, line) in journal.lines().enumerate() {
5414            let record: crate::worker::ActiveJournalRecord = crate::journal::open(
5415                &state.journal_key,
5416                "durable-loop:active",
5417                index as u64,
5418                line.as_bytes(),
5419            )
5420            .unwrap();
5421            if let Some(attempt) = record.response_attempt {
5422                if attempt.terminal {
5423                    assert!(
5424                        dangling.remove(&attempt.id),
5425                        "terminal record for attempt {} has no admission record",
5426                        attempt.id,
5427                    );
5428                } else {
5429                    dangling.insert(attempt.id);
5430                }
5431            }
5432        }
5433        assert!(
5434            dangling.is_empty(),
5435            "every response attempt must be terminated: the refusal terminates its own \
5436             attempt, and dropping a PreparedRequest (client disconnect) terminates via \
5437             the capture guard; still dangling: {dangling:?} (test dropped: {abandoned_by_test:?})",
5438        );
5439    }
5440
5441    /// `forward_chat` must fail closed when the in-flight response marker
5442    /// cannot be persisted: the marker is what lets the restart-time scan
5443    /// (`inflight_response_sessions`) quarantine sessions whose provider
5444    /// response may have been observed but never captured. Dispatching
5445    /// without it would silently drop that crash-durability guarantee.
5446    /// The refusal must also terminate the journaled response attempt so
5447    /// no dangling non-terminal record quarantines the session later.
5448    #[tokio::test]
5449    async fn forward_chat_fails_closed_when_marker_cannot_persist() {
5450        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5451        let state = state(config);
5452        let mut headers = HeaderMap::new();
5453        headers.insert(SESSION_HEADER, HeaderValue::from_static("marker-outage"));
5454        let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
5455        let attempt_id = prepared.capture_guard.attempt_id().to_owned();
5456        // Sabotage the marker directory: a regular file at
5457        // <spool>/inflight-responses makes `create_dir_all` inside
5458        // `write_atomic` fail for every marker write.
5459        std::fs::write(
5460            std::path::Path::new(&state.config.atif_spool_dir).join(crate::spool::INFLIGHT_RESPONSES),
5461            b"",
5462        )
5463        .unwrap();
5464        let error = state.forward_chat(prepared).await.err().unwrap();
5465        // Unavailable (not Upstream) proves the refusal happened BEFORE
5466        // any upstream dispatch was attempted.
5467        assert!(
5468            matches!(error, PipelineError::Unavailable { .. }),
5469            "marker persist failure must map to Unavailable, got {error:?}",
5470        );
5471
5472        let session = state.sessions.get("marker-outage").unwrap();
5473        session.wait_for_worker_jobs().await;
5474        let digest = av_core::digest::sha256_hex("marker-outage".as_bytes());
5475        let stem = digest.get(..32).unwrap();
5476        let journal_path =
5477            std::path::Path::new(&state.config.atif_spool_dir).join(format!("{stem}.events.ndjson"));
5478        let journal = std::fs::read_to_string(&journal_path).unwrap();
5479        let mut saw_admission = false;
5480        let mut saw_terminal = false;
5481        for (index, line) in journal.lines().enumerate() {
5482            let record: crate::worker::ActiveJournalRecord = crate::journal::open(
5483                &state.journal_key,
5484                "marker-outage:active",
5485                index as u64,
5486                line.as_bytes(),
5487            )
5488            .unwrap();
5489            if let Some(attempt) = record.response_attempt {
5490                if attempt.id == attempt_id {
5491                    if attempt.terminal {
5492                        saw_terminal = true;
5493                    } else {
5494                        saw_admission = true;
5495                    }
5496                }
5497            }
5498        }
5499        assert!(saw_admission, "admission must have journaled the attempt");
5500        assert!(
5501            saw_terminal,
5502            "the refused request's response attempt must be terminated in the journal",
5503        );
5504    }
5505
5506    /// A session that only routes MCP tool calls (no chat completions) must
5507    /// still be considered active by `idle_sessions` — otherwise the
5508    /// reconciler tick force-closes tool-only sessions after
5509    /// `session_idle_close_s` and every subsequent tool call fails with
5510    /// "session is already closed". `prepare_chat` refreshes
5511    /// `last_activity_ms` via `touch()`; `intercept_tool` did not.
5512    #[tokio::test]
5513    async fn intercept_tool_refreshes_session_activity() {
5514        use std::sync::atomic::Ordering as AtomicOrdering;
5515
5516        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5517        let state = state(config);
5518        let raw = serde_json::to_vec(&serde_json::json!({
5519            "jsonrpc": "2.0",
5520            "id": 1,
5521            "method": "tools/call",
5522            "params": {"name": "safe_tool", "arguments": {}}
5523        }))
5524        .unwrap();
5525        let mut headers = HeaderMap::new();
5526        headers.insert(SESSION_HEADER, HeaderValue::from_static("tool-only-session"));
5527        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5528
5529        state.intercept_tool(&headers, &raw).unwrap();
5530        let session = state.sessions.get("tool-only-session").unwrap();
5531        // Backdate the session to before the epoch's first second — this stands
5532        // in for a real session that has been quiet long enough to hit the
5533        // idle sweeper's cutoff.
5534        session.last_activity_ms.store(0, AtomicOrdering::Release);
5535
5536        state.intercept_tool(&headers, &raw).unwrap();
5537
5538        let refreshed = session.last_activity_ms.load(AtomicOrdering::Acquire);
5539        assert!(
5540            refreshed > 0,
5541            "intercept_tool must refresh last_activity_ms so a tool-only session is not force-closed by the idle sweeper — got {refreshed}",
5542        );
5543    }
5544
5545    /// `intercept_tool` used to call `sandbox.check` unconditionally and then
5546    /// post-hoc override an `Allowed` verdict to `Blocked` when the workflow
5547    /// gate vetoes a consequential tool on an unsigned session. But
5548    /// `sandbox.check`'s budget gate (`try_spend_many`) spends before the
5549    /// override runs, so a legitimate client asking for a consequential tool
5550    /// on the wrong workflow got their `max_total_tool_calls` counter
5551    /// decremented for a call the sandbox never actually authorized. Once the
5552    /// client upgraded to a signed session to make the call for real, they
5553    /// hit the cap short of the number they were configured for.
5554    #[tokio::test]
5555    async fn unsigned_consequential_veto_does_not_spend_budget() {
5556        let store: Arc<dyn StateStore> = Arc::new(InMemoryStore::new());
5557        let sandbox_config = SandboxConfig {
5558            budget: av_state::BudgetSpec {
5559                max_total_tool_calls: Some(5),
5560                ..av_state::BudgetSpec::default()
5561            },
5562            ..SandboxConfig::default()
5563        };
5564        let sandbox = Arc::new(Sandbox::new(sandbox_config, Vec::new()).unwrap());
5565        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5566        config.atif_spool_dir = tempfile::tempdir().unwrap().keep().to_string_lossy().into_owned();
5567        let state = AppState::new(
5568            config,
5569            Arc::clone(&store),
5570            sandbox,
5571            Arc::new(NullBus),
5572            None,
5573            Arc::new(Ed25519Signer::from_seed(&[9; 32])),
5574        )
5575        .unwrap();
5576
5577        let raw = serde_json::to_vec(&serde_json::json!({
5578            "jsonrpc": "2.0",
5579            "id": 1,
5580            "method": "tools/call",
5581            "params": {"name": "db_write", "arguments": {}}
5582        }))
5583        .unwrap();
5584        let mut headers = HeaderMap::new();
5585        headers.insert(SESSION_HEADER, HeaderValue::from_static("unsigned-consequential"));
5586        // Default workflow is unsigned; no explicit x-av-workflow header.
5587
5588        let verdict = state.intercept_tool(&headers, &raw).unwrap();
5589        assert!(
5590            matches!(verdict, ToolVerdict::Blocked { stage: "policy", .. }),
5591            "consequential tool on unsigned workflow must block at the policy gate; got {verdict:?}",
5592        );
5593
5594        let session_digest = av_core::digest::sha256_hex("unsigned-consequential".as_bytes());
5595        let key = format!("budget:{{{}}}:total_calls", session_digest.get(..32).unwrap());
5596        let count = store.get(&key).unwrap();
5597        assert_eq!(
5598            count, 0,
5599            "sandbox.check must not spend the budget for a call the workflow gate vetoes — otherwise a legitimate client that upgrades to signed and retries hits the cap short of the configured limit",
5600        );
5601    }
5602
5603    #[tokio::test]
5604    async fn full_capture_queue_fails_closed_before_upstream() {
5605        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5606        // The fused permit split (worker_capacity + response_capacity)
5607        // gives distinct exhaustion counters but keeps the *effective*
5608        // total admission the same as before, because response
5609        // capture doesn't hold an mpsc slot up front — only the
5610        // response semaphore. So with worker_channel_capacity=2 the
5611        // first request consumes 1 worker semaphore + 1 response
5612        // semaphore + 1 mpsc slot; the second request consumes the
5613        // remaining worker semaphore + response semaphore + mpsc slot;
5614        // the third would fail. Set to 1 for a clean single-request
5615        // saturation.
5616        config.worker_channel_capacity = 1;
5617        config.breaker.min_tokens = u64::MAX;
5618        let sink = Arc::new(BlockingSink {
5619            entered: AtomicBool::new(false),
5620            release: tokio::sync::Notify::new(),
5621        });
5622        let state = AppState::new_with_backends(
5623            config,
5624            Arc::new(InMemoryStore::new()),
5625            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
5626            Arc::new(NullBus),
5627            None,
5628            Arc::new(Ed25519Signer::from_seed(&[10; 32])),
5629            Arc::new(HashEmbedder::default()),
5630            sink.clone(),
5631        )
5632        .unwrap();
5633        let mut headers = HeaderMap::new();
5634        headers.insert(SESSION_HEADER, HeaderValue::from_static("overload"));
5635        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5636        let mut agent_payload = payload();
5637        agent_payload["messages"][0]["role"] = Value::String("assistant".to_owned());
5638        let mut first = state.prepare_chat(&headers, agent_payload.clone(), None).unwrap();
5639        // Abandoning without forwarding: defuse the guard, or its terminal
5640        // job (refused by the size-1 queue) marks the session
5641        // capture-failed and the worker skips the sink this test blocks on.
5642        first.capture_guard.defuse();
5643        drop(first);
5644        tokio::time::timeout(std::time::Duration::from_secs(10), async {
5645            while !sink.entered.load(AtomicOrdering::Acquire) {
5646                tokio::task::yield_now().await;
5647            }
5648        })
5649        .await
5650        .unwrap();
5651        assert!(matches!(
5652            state.prepare_chat(&headers, agent_payload, None),
5653            Err(PipelineError::Unavailable { .. })
5654        ));
5655        sink.release.notify_waiters();
5656    }
5657
5658    #[test]
5659    fn identity_scope_matching_supports_exact_and_namespace_wildcards() {
5660        assert!(scope_allows(&["chat:write".into()], "chat:write"));
5661        assert!(scope_allows(&["tool:*".into()], "tool:db_write"));
5662        assert!(scope_allows(&["*".into()], "session:promote"));
5663        assert!(!scope_allows(&["chat:read".into()], "chat:write"));
5664        assert!(!scope_allows(&["tooling:*".into()], "tool:db_write"));
5665    }
5666
5667    #[test]
5668    fn developer_and_tool_roles_map_to_valid_system_steps() {
5669        for (role, expects_observation) in [("developer", false), ("tool", true)] {
5670            let capture = atif_capture_from_request(&serde_json::json!({
5671                "messages": [{
5672                    "role": role,
5673                    "content": "context",
5674                    "tool_call_id": "call-1"
5675                }]
5676            }))
5677            .unwrap();
5678            assert_eq!(capture.source, av_atif::Source::System);
5679            assert_eq!(capture.observation.is_some(), expects_observation);
5680            assert!(capture.model_name.is_none());
5681        }
5682    }
5683
5684    /// `messages` field with the wrong type used to return
5685    /// the same "chat payload has no messages" as an absent field,
5686    /// steering support tickets at a phantom bug. Verify each class
5687    /// now returns a discriminating error message.
5688    #[test]
5689    fn atif_capture_rejects_non_array_messages_with_precise_diagnostics() {
5690        fn bad_request_message(payload: serde_json::Value) -> String {
5691            match atif_capture_from_request(&payload) {
5692                Ok(_) => panic!("expected BadRequest, got Ok"),
5693                Err(PipelineError::BadRequest { context: msg, .. }) => msg,
5694                Err(other) => panic!("expected BadRequest, got {other:?}"),
5695            }
5696        }
5697        // (1) Field entirely missing (e.g., OpenAI Responses API caller
5698        //     who used `input` instead of `messages`).
5699        let msg = bad_request_message(serde_json::json!({
5700            "input": [{ "role": "user", "content": "hi" }]
5701        }));
5702        assert!(msg.contains("missing 'messages'"), "got {msg}");
5703        // (2) Field present but wrong JSON shape — object.
5704        let msg = bad_request_message(serde_json::json!({
5705            "messages": { "0": { "role": "user", "content": "hi" } }
5706        }));
5707        assert!(msg.contains("must be a JSON array"), "got {msg}");
5708        assert!(msg.contains("object"), "got {msg}");
5709        // (3) Field present, is an array, but empty.
5710        let msg = bad_request_message(serde_json::json!({ "messages": [] }));
5711        assert!(msg.contains("empty"), "got {msg}");
5712    }
5713
5714    /// Multiple X-AV-Session (or X-AV-Workflow) headers on
5715    /// a single request must be refused. HeaderMap::get() returns
5716    /// only the first, so an intermediary that merges duplicates on
5717    /// the wire and downstream code that reads them separately can
5718    /// disagree on which session id was in effect — a header
5719    /// smuggling desync. Refuse loudly at ingress.
5720    #[test]
5721    fn duplicate_x_av_session_header_is_refused() {
5722        let mut headers = HeaderMap::new();
5723        headers.append(SESSION_HEADER, HeaderValue::from_static("sessA"));
5724        headers.append(SESSION_HEADER, HeaderValue::from_static("sessB"));
5725        match session_id(&headers) {
5726            Ok(id) => panic!("expected duplicate-header refusal, got session_id {id:?}"),
5727            Err(PipelineError::BadRequest { context: msg, .. }) => {
5728                assert!(msg.contains("more than one"), "got {msg}");
5729                assert!(msg.contains("x-av-session"), "got {msg}");
5730            }
5731            Err(other) => panic!("expected BadRequest, got {other:?}"),
5732        }
5733    }
5734
5735    #[test]
5736    fn duplicate_x_av_workflow_header_is_refused() {
5737        let mut headers = HeaderMap::new();
5738        headers.append(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
5739        headers.append(WORKFLOW_HEADER, HeaderValue::from_static("unsigned"));
5740        match workflow(&headers, "signed") {
5741            Ok(w) => panic!("expected duplicate-header refusal, got workflow {w:?}"),
5742            Err(PipelineError::BadRequest { context: msg, .. }) => {
5743                assert!(msg.contains("more than one"), "got {msg}");
5744                assert!(msg.contains("x-av-workflow"), "got {msg}");
5745            }
5746            Err(other) => panic!("expected BadRequest, got {other:?}"),
5747        }
5748    }
5749
5750    /// Single X-AV-Session must continue to work — this is the
5751    /// happy-path regression guard for the duplicate-header refusal.
5752    #[test]
5753    fn single_x_av_session_header_still_flows() {
5754        let mut headers = HeaderMap::new();
5755        headers.insert(SESSION_HEADER, HeaderValue::from_static("only-one"));
5756        assert_eq!(session_id(&headers).unwrap(), "only-one");
5757    }
5758
5759    /// Identity hot path (`resolve_identity`) must refuse
5760    /// duplicate `Authorization` headers, symmetric with X-AV-Session.
5761    /// Previously `HeaderMap::get(AUTHORIZATION)` returned the first
5762    /// value only — the harness would authenticate as `A` while
5763    /// log aggregators / WAFs seeing a merged `A, B` form would
5764    /// attribute the request to `B`. This is the same identity split-brain
5765    /// already closed for session headers.
5766    #[tokio::test]
5767    async fn resolve_identity_refuses_duplicate_authorization_header() {
5768        let state = null_state();
5769        let mut headers = HeaderMap::new();
5770        headers.append(
5771            axum::http::header::AUTHORIZATION,
5772            HeaderValue::from_static("Bearer aaaa"),
5773        );
5774        headers.append(
5775            axum::http::header::AUTHORIZATION,
5776            HeaderValue::from_static("Bearer bbbb"),
5777        );
5778        match state.resolve_identity(&headers, None) {
5779            Ok(_) => panic!("expected duplicate-header refusal, got Ok"),
5780            Err(PipelineError::BadRequest { context: msg, .. }) => {
5781                assert!(msg.contains("more than one"), "got {msg}");
5782                assert!(msg.contains("authorization"), "got {msg}");
5783            }
5784            Err(other) => panic!("expected BadRequest, got {other:?}"),
5785        }
5786    }
5787
5788    /// Single-value Authorization still flows through (happy-path
5789    /// regression guard for the dedup refusal).
5790    #[tokio::test]
5791    async fn resolve_identity_accepts_single_authorization_header() {
5792        let state = null_state();
5793        let mut headers = HeaderMap::new();
5794        headers.insert(
5795            axum::http::header::AUTHORIZATION,
5796            HeaderValue::from_static("Bearer aaaa"),
5797        );
5798        // No validator configured → the refuse-401 posture fires.
5799        // The important assertion is that we did NOT get a
5800        // BadRequest("more than one") on a single header.
5801        let outcome = state.resolve_identity(&headers, None);
5802        assert!(
5803            !matches!(&outcome, Err(PipelineError::BadRequest { context: msg, .. }) if msg.contains("more than one")),
5804            "single Authorization header must not be refused as duplicate; got {outcome:?}"
5805        );
5806    }
5807
5808    /// With `ignore_client_authorization`
5809    /// opted in and no validator configured, a stock OpenAI SDK's
5810    /// mandatory `Authorization` header must be accepted-and-discarded
5811    /// — the request proceeds anonymously instead of hard-401ing on
5812    /// request one. Without the opt-in, the diagnostic 401 stays.
5813    #[tokio::test]
5814    async fn ignore_client_authorization_accepts_and_discards_sdk_bearer() {
5815        let mut headers = HeaderMap::new();
5816        headers.insert(
5817            axum::http::header::AUTHORIZATION,
5818            HeaderValue::from_static("Bearer sk-test-openai-sdk-key"),
5819        );
5820
5821        // Default (flag off): diagnostic 401.
5822        let refused = null_state().resolve_identity(&headers, None);
5823        assert!(
5824            matches!(&refused, Err(PipelineError::Unauthorized(_))),
5825            "bearer with no validator and no opt-in must stay 401; got {refused:?}"
5826        );
5827
5828        // Flag on: accepted as anonymous.
5829        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5830        config.require_identity = false;
5831        config.ignore_client_authorization = true;
5832        let accepted = state(config).resolve_identity(&headers, None);
5833        match accepted {
5834            Ok(identity) => {
5835                assert_eq!(identity.instance_uid, "anonymous");
5836            }
5837            Err(error) => {
5838                panic!("ignore_client_authorization must accept the SDK bearer anonymously; got {error:?}")
5839            }
5840        }
5841    }
5842
5843    /// The two contradictory combinations must be refused at
5844    /// validate(): require_identity needs the header, passthrough
5845    /// forwards it — neither can coexist with discarding it.
5846    #[test]
5847    fn ignore_client_authorization_is_refused_with_identity_or_passthrough() {
5848        let mut with_identity = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5849        with_identity.ignore_client_authorization = true;
5850        with_identity.require_identity = true;
5851        with_identity.identity_hmac_secret_file = Some("/tmp/hmac".into());
5852        let err = with_identity.validate().unwrap_err();
5853        assert!(err.contains("ignore_client_authorization"), "{err}");
5854        assert!(err.contains("require_identity"), "{err}");
5855
5856        let mut with_passthrough = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5857        with_passthrough.ignore_client_authorization = true;
5858        with_passthrough.upstream_authorization_passthrough = true;
5859        let err = with_passthrough.validate().unwrap_err();
5860        assert!(err.contains("ignore_client_authorization"), "{err}");
5861        assert!(err.contains("passthrough"), "{err}");
5862    }
5863
5864    /// A credential-free 401 flood must not mint an
5865    /// unbounded stream of durable signed audit sessions. The first
5866    /// 60 rejections per minute get full audit records; the rest are
5867    /// counted only. Proven by driving 100 rejections and asserting
5868    /// the overflow counter carries the surplus.
5869    #[tokio::test]
5870    async fn identity_rejection_audit_records_are_bounded_per_minute() {
5871        let state = null_state();
5872        for i in 0..100u32 {
5873            state
5874                .enqueue_transient_failure(
5875                    &format!("probe-{i}"),
5876                    StopReason::IdentityRejected,
5877                    "missing bearer token".to_owned(),
5878                )
5879                .unwrap();
5880        }
5881        let total = state
5882            .metrics
5883            .counter(
5884                "av_identity_rejections_total",
5885                "Requests refused by identity validation",
5886            )
5887            .get();
5888        let unaudited = state
5889            .metrics
5890            .counter(
5891                "av_identity_rejections_unaudited_total",
5892                "Identity rejections beyond the per-minute audit-record budget (counted only)",
5893            )
5894            .get();
5895        assert_eq!(total, 100, "every rejection must be counted");
5896        assert_eq!(
5897            unaudited, 40,
5898            "rejections beyond the 60/min budget must be count-only, not signed audit sessions"
5899        );
5900    }
5901
5902    /// RFC 7235 §2.1 auth-scheme is case-insensitive.
5903    /// A caller sending `Authorization: bearer eyJ...` or `BEARER`
5904    /// used to be silently downgraded to anonymous (when
5905    /// require_identity=false, the shipped default) — repudiation
5906    /// class. Verify each case now parses to the same credential.
5907    #[test]
5908    fn strip_bearer_scheme_matches_case_insensitively() {
5909        for scheme in ["Bearer aaaa", "bearer aaaa", "BEARER aaaa", "BeArEr aaaa"] {
5910            assert_eq!(
5911                strip_bearer_scheme(scheme),
5912                Some("aaaa"),
5913                "scheme {scheme:?} did not parse to `aaaa`"
5914            );
5915        }
5916        // Tab between scheme and credential (per the RFC's `SP / HTAB` grammar).
5917        assert_eq!(strip_bearer_scheme("Bearer\taaaa"), Some("aaaa"));
5918        // Multiple spaces are allowed.
5919        assert_eq!(strip_bearer_scheme("Bearer   aaaa"), Some("aaaa"));
5920        // Missing whitespace or empty credential — refused.
5921        assert_eq!(strip_bearer_scheme("BearerXYZ"), None);
5922        assert_eq!(strip_bearer_scheme("Bearer "), None);
5923        assert_eq!(strip_bearer_scheme("Bearer"), None);
5924        // Different scheme — refused.
5925        assert_eq!(strip_bearer_scheme("Basic ZmY6bGFyZQ=="), None);
5926        // Too short to hold the scheme name — refused.
5927        assert_eq!(strip_bearer_scheme(""), None);
5928    }
5929
5930    fn null_state() -> AppState {
5931        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5932        config.require_identity = false;
5933        let sandbox = Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap();
5934        let store = Arc::new(InMemoryStore::new());
5935        let bridge: Arc<dyn EventBus> = Arc::new(NullBus);
5936        let signer: Arc<dyn Signer> = Arc::new(Ed25519Signer::generate());
5937        let embedder = Arc::new(HashEmbedder::default());
5938        let vector_sink: Arc<dyn VectorSink> = Arc::new(NoopVectorSink);
5939        AppState::new_with_backends(
5940            config,
5941            store,
5942            Arc::new(sandbox),
5943            bridge,
5944            None,
5945            signer,
5946            embedder,
5947            vector_sink,
5948        )
5949        .unwrap()
5950    }
5951
5952    fn scoped_token(scopes: &[&str]) -> String {
5953        let now = av_core::time::now_ms() / av_core::units::MS_PER_SEC;
5954        let claims = av_identity::NhiClaims {
5955            sub: "agent:test".into(),
5956            iss: "https://idp.example".into(),
5957            aud: "agentvisor-ai".into(),
5958            iat: now,
5959            nbf: None,
5960            exp: now + 600,
5961            jti: av_core::new_event_uid(),
5962            azp: None,
5963            act: None,
5964            instance_uid: "scoped-instance".into(),
5965            charter: "scoped-charter".into(),
5966            version: "1".into(),
5967            scopes: scopes.iter().map(|scope| (*scope).to_owned()).collect(),
5968            parent_token: None,
5969        };
5970        let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
5971        header.kid = Some("scope-key".into());
5972        jsonwebtoken::encode(
5973            &header,
5974            &claims,
5975            &jsonwebtoken::EncodingKey::from_secret(b"scope-secret"),
5976        )
5977        .unwrap()
5978    }
5979
5980    fn scoped_state() -> AppState {
5981        let mut config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
5982        config.require_identity = true;
5983        config.enforce_identity_scopes = true;
5984        let validator = av_identity::IdentityValidator::new("agentvisor-ai");
5985        validator
5986            .add_key(
5987                "scope-key",
5988                av_identity::KeyMaterial::HmacSecret(b"scope-secret".to_vec()),
5989            )
5990            .unwrap();
5991        AppState::new(
5992            config,
5993            Arc::new(InMemoryStore::new()),
5994            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
5995            Arc::new(NullBus),
5996            Some(Arc::new(validator)),
5997            Arc::new(Ed25519Signer::from_seed(&[12; 32])),
5998        )
5999        .unwrap()
6000    }
6001
6002    #[tokio::test]
6003    async fn real_jwt_scopes_gate_chat_and_lifecycle_operations() {
6004        let state = scoped_state();
6005        let mut headers = HeaderMap::new();
6006        headers.insert(SESSION_HEADER, HeaderValue::from_static("scoped-session"));
6007        headers.insert(
6008            axum::http::header::AUTHORIZATION,
6009            HeaderValue::from_str(&format!("Bearer {}", scoped_token(&["chat:write"]))).unwrap(),
6010        );
6011        let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
6012        // RFC 6750 §3.1: missing scope on a valid token is 403
6013        // insufficient_scope (Blocked), NOT 401 — a refreshed token would
6014        // carry the same grant, so telling the SDK to re-authenticate
6015        // (401's semantic) is wrong and loops.
6016        assert!(matches!(
6017            state.authorize_session(&headers, &prepared.session, "session:close"),
6018            Err(PipelineError::Blocked { .. })
6019        ));
6020        headers.insert(
6021            axum::http::header::AUTHORIZATION,
6022            HeaderValue::from_str(&format!(
6023                "Bearer {}",
6024                scoped_token(&["chat:write", "session:close"])
6025            ))
6026            .unwrap(),
6027        );
6028        state
6029            .authorize_session(&headers, &prepared.session, "session:close")
6030            .unwrap();
6031    }
6032
6033    #[tokio::test]
6034    async fn upstream_failure_is_added_to_signed_chain() {
6035        let config = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
6036        let state = state(config);
6037        let mut headers = HeaderMap::new();
6038        headers.insert(SESSION_HEADER, HeaderValue::from_static("upstream-failure"));
6039        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
6040        let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
6041        let session = Arc::clone(&prepared.session);
6042        assert!(matches!(
6043            state.forward_chat(prepared).await,
6044            Err(PipelineError::Upstream { .. })
6045        ));
6046        tokio::time::timeout(std::time::Duration::from_secs(10), async {
6047            while session.chain.lock().count() < 2 {
6048                tokio::task::yield_now().await;
6049            }
6050        })
6051        .await
6052        .unwrap();
6053    }
6054
6055    /// Regression lock for CWE-209 information exposure. Before the fix,
6056    /// `PipelineError::upstream(reqwest_err.to_string())` embedded the
6057    /// operator-configured upstream URL in the JSON error body — any client
6058    /// that triggered an upstream failure could discover the URL, potentially
6059    /// leaking an internal hostname. The client-facing message must now be a
6060    /// stable category ("upstream unreachable", "upstream timed out", …) that
6061    /// does not depend on the URL.
6062    #[tokio::test]
6063    async fn upstream_failure_message_does_not_leak_configured_url() {
6064        // Use a distinctive private-network host so a regression is unmissable
6065        // in the assertion below.
6066        let sentinel_url = "http://internal-sentinel-host.corp.example:65001";
6067        let config = HarnessConfig::for_tests(sentinel_url, "/tmp", "/tmp");
6068        let state = state(config);
6069        let mut headers = HeaderMap::new();
6070        headers.insert(SESSION_HEADER, HeaderValue::from_static("cwe-209-check"));
6071        headers.insert(WORKFLOW_HEADER, HeaderValue::from_static("signed"));
6072        let prepared = state.prepare_chat(&headers, payload(), None).unwrap();
6073        let error = match state.forward_chat(prepared).await {
6074            Ok(_) => panic!("connect to unroutable host must fail"),
6075            Err(error) => error,
6076        };
6077        let message = error.to_string();
6078        assert!(
6079            !message.contains("internal-sentinel-host"),
6080            "upstream error message {message:?} leaks the configured URL"
6081        );
6082        assert!(
6083            !message.contains("65001"),
6084            "upstream error message {message:?} leaks the configured port"
6085        );
6086        assert!(
6087            !message.contains("corp.example"),
6088            "upstream error message {message:?} leaks the configured domain"
6089        );
6090    }
6091
6092    /// Regression lock for CWE-209 information exposure. Before the fix,
6093    /// `PipelineError::Unauthorized(identity_err.to_string())` embedded the
6094    /// specific `IdentityError` variant's `Display` text in the JSON error
6095    /// body. Because that text distinguishes at least sixteen distinct
6096    /// failure modes — and echoes the attacker-supplied `kid`, `iss`, and
6097    /// `alg` values — each response became an enumeration oracle: an
6098    /// attacker could iterate candidate `kid`s and read the response body
6099    /// to discover which are configured on our validator. The client-facing
6100    /// message must now be a single stable string, and must never echo
6101    /// attacker-controlled token fields.
6102    #[test]
6103    fn identity_validation_failure_does_not_leak_kid_or_issuer() {
6104        // Every variant that an attacker can reach via a crafted token must
6105        // classify to exactly the same client-facing string. Any divergence
6106        // is an enumeration oracle for validator configuration.
6107        //
6108        // Sentinels that would appear in the raw `Display` output are
6109        // asserted absent — this catches accidental future variants that
6110        // slip through the `_` catch-all with a different classification.
6111        let sentinel_kid = "sentinel-attacker-kid";
6112        let sentinel_iss = "https://sentinel-attacker-iss.example.invalid";
6113        let sentinel_alg = "HS512";
6114        let cases: Vec<(&'static str, av_identity::IdentityError)> = vec![
6115            (
6116                "Malformed",
6117                av_identity::IdentityError::Malformed("jwt parse".into()),
6118            ),
6119            ("MissingKid", av_identity::IdentityError::MissingKid),
6120            (
6121                "UnknownKid",
6122                av_identity::IdentityError::UnknownKid(sentinel_kid.into()),
6123            ),
6124            (
6125                "AlgorithmRejected",
6126                av_identity::IdentityError::AlgorithmRejected {
6127                    alg: sentinel_alg.into(),
6128                    kid: sentinel_kid.into(),
6129                },
6130            ),
6131            (
6132                "Verification",
6133                av_identity::IdentityError::Verification("bad sig".into()),
6134            ),
6135            ("TtlTooLong", av_identity::IdentityError::TtlTooLong(9999)),
6136            (
6137                "BadTimestamps",
6138                av_identity::IdentityError::BadTimestamps { iat: 10, exp: 5 },
6139            ),
6140            (
6141                "FutureIat",
6142                av_identity::IdentityError::FutureIat {
6143                    iat: 999_999_999,
6144                    now: 1,
6145                },
6146            ),
6147            ("EmptyField", av_identity::IdentityError::EmptyField("charter")),
6148            (
6149                "SpoofingCharacter",
6150                av_identity::IdentityError::SpoofingCharacter("charter"),
6151            ),
6152            (
6153                "BadIssuer",
6154                av_identity::IdentityError::BadIssuer(sentinel_iss.into()),
6155            ),
6156            (
6157                "ScopeEscalation",
6158                av_identity::IdentityError::ScopeEscalation {
6159                    scope: "chat:write".into(),
6160                },
6161            ),
6162            (
6163                "ExpEscalation",
6164                av_identity::IdentityError::ExpEscalation {
6165                    child: 100,
6166                    parent: 50,
6167                },
6168            ),
6169            ("ChainTooDeep", av_identity::IdentityError::ChainTooDeep(5)),
6170        ];
6171
6172        let mut classifications: std::collections::BTreeSet<&'static str> = std::collections::BTreeSet::new();
6173        for (name, error) in &cases {
6174            let client_msg = super::classify_identity_error(error);
6175            classifications.insert(client_msg);
6176            assert!(
6177                !client_msg.contains(sentinel_kid),
6178                "{name}: client message {client_msg:?} echoes attacker kid"
6179            );
6180            assert!(
6181                !client_msg.contains(sentinel_iss),
6182                "{name}: client message {client_msg:?} echoes attacker iss"
6183            );
6184            assert!(
6185                !client_msg.contains(sentinel_alg),
6186                "{name}: client message {client_msg:?} echoes attacker alg"
6187            );
6188            // The full PipelineError as rendered to the client must also
6189            // not leak — this is what actually goes on the wire via
6190            // `pipeline_error()` -> `Json(json!({"error": err.to_string()}))`.
6191            let wire = PipelineError::Unauthorized(client_msg.to_owned()).to_string();
6192            assert!(
6193                !wire.contains(sentinel_kid),
6194                "{name}: wire error {wire:?} echoes attacker kid"
6195            );
6196            assert!(
6197                !wire.contains(sentinel_iss),
6198                "{name}: wire error {wire:?} echoes attacker iss"
6199            );
6200            assert!(
6201                !wire.contains(sentinel_alg),
6202                "{name}: wire error {wire:?} echoes attacker alg"
6203            );
6204        }
6205        // All attacker-reachable variants collapse to exactly one class —
6206        // the `Jwks` variant (server-side misconfig only, not on the
6207        // request path) may distinctly classify.
6208        assert_eq!(
6209            classifications.len(),
6210            1,
6211            "attacker-reachable IdentityError variants classify to multiple messages {classifications:?}: this is an enumeration oracle"
6212        );
6213
6214        // And Jwks classifies distinctly (server-side, not attacker-reachable).
6215        let jwks_msg = super::classify_identity_error(&av_identity::IdentityError::Jwks("bad jwks".into()));
6216        assert_ne!(
6217            jwks_msg,
6218            *classifications.iter().next().unwrap(),
6219            "server-side misconfig should classify distinctly for operator dashboards"
6220        );
6221    }
6222
6223    /// The secret reader must fail loudly on missing/empty sources and
6224    /// trim whitespace — silent unauthenticated proxying is the worst
6225    /// failure mode because the operator sees only upstream 401s.
6226    #[test]
6227    fn read_secret_source_handling() {
6228        let env = |name: &str| -> Option<String> {
6229            match name {
6230                "GOOD_KEY" => Some("sk-live-123\n".into()),
6231                "EMPTY_KEY" => Some("   ".into()),
6232                _ => None,
6233            }
6234        };
6235        assert_eq!(
6236            super::read_secret_from(env, Some("GOOD_KEY"), None, "upstream API key")
6237                .unwrap()
6238                .as_deref(),
6239            Some("sk-live-123"),
6240            "value must be trimmed"
6241        );
6242        assert!(super::read_secret_from(env, Some("MISSING_KEY"), None, "upstream API key").is_err());
6243        assert!(super::read_secret_from(env, Some("EMPTY_KEY"), None, "upstream API key").is_err());
6244        assert!(super::read_secret_from(env, None, None, "upstream API key")
6245            .unwrap()
6246            .is_none());
6247
6248        // File source: owner-only accepted (with trim), world-readable refused.
6249        let dir = tempfile::tempdir().unwrap();
6250        let path = dir.path().join("key");
6251        std::fs::write(&path, "sk-file-456\n").unwrap();
6252        #[cfg(unix)]
6253        {
6254            use std::os::unix::fs::PermissionsExt as _;
6255            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
6256            let error = super::read_secret_from(env, None, path.to_str(), "upstream API key").unwrap_err();
6257            assert!(error.to_string().contains("must be owner-only"), "{error}");
6258            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
6259        }
6260        assert_eq!(
6261            super::read_secret_from(env, None, path.to_str(), "upstream API key")
6262                .unwrap()
6263                .as_deref(),
6264            Some("sk-file-456")
6265        );
6266        // Missing file is a hard error, not silent None.
6267        assert!(
6268            super::read_secret_from(env, None, dir.path().join("absent").to_str(), "upstream API key")
6269                .is_err()
6270        );
6271    }
6272
6273    /// Auth resolution renders scheme-prefixed and raw header values,
6274    /// marks them sensitive, and never leaks the key through `describe`.
6275    #[test]
6276    fn upstream_auth_resolution_and_description() {
6277        let dir = tempfile::tempdir().unwrap();
6278        let key_path = dir.path().join("azure.key");
6279        std::fs::write(&key_path, "azure-raw-key").unwrap();
6280        #[cfg(unix)]
6281        {
6282            use std::os::unix::fs::PermissionsExt as _;
6283            std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
6284        }
6285
6286        // Bearer scheme.
6287        let mut config = HarnessConfig::for_tests("http://up", "spool", "bridge");
6288        config.upstream_api_key_file = Some(key_path.to_string_lossy().into_owned());
6289        let (name, value) = super::resolve_upstream_auth(&config).unwrap().unwrap();
6290        assert_eq!(name.as_str(), "authorization");
6291        assert_eq!(value.to_str().unwrap(), "Bearer azure-raw-key");
6292        assert!(value.is_sensitive(), "credential must redact in Debug output");
6293
6294        // Raw scheme (Azure api-key style).
6295        config.upstream_auth_header = "api-key".into();
6296        config.upstream_auth_scheme = String::new();
6297        let (name, value) = super::resolve_upstream_auth(&config).unwrap().unwrap();
6298        assert_eq!(name.as_str(), "api-key");
6299        assert_eq!(value.to_str().unwrap(), "azure-raw-key");
6300
6301        // Description names the source but never the value.
6302        let described = super::describe_upstream_auth(&config);
6303        assert!(described.contains("api-key from file"), "{described}");
6304        assert!(!described.contains("azure-raw-key"), "{described}");
6305
6306        // No auth configured resolves to None and describes as none.
6307        let bare = HarnessConfig::for_tests("http://up", "spool", "bridge");
6308        assert!(super::resolve_upstream_auth(&bare).unwrap().is_none());
6309        assert_eq!(super::describe_upstream_auth(&bare), "none");
6310
6311        // Tool bearer renders Bearer form from a file source.
6312        let mut tooling = HarnessConfig::for_tests("http://up", "spool", "bridge");
6313        tooling.tool_upstream_url = Some("http://tools/mcp".into());
6314        tooling.tool_upstream_bearer_file = Some(key_path.to_string_lossy().into_owned());
6315        let bearer = super::resolve_tool_auth(&tooling).unwrap().unwrap();
6316        assert_eq!(bearer.to_str().unwrap(), "Bearer azure-raw-key");
6317        assert!(bearer.is_sensitive());
6318    }
6319}