Skip to main content

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