Skip to main content

av_harness/
routes.rs

1//! Axum HTTP routes for proxy, MCP interception, lifecycle, and operations.
2
3use crate::pipeline::AppState;
4use av_core::time::elapsed_us;
5use av_events::StopReason;
6use av_sandbox::ToolVerdict;
7use axum::body::{Body, Bytes};
8use axum::extract::{Path, State};
9use axum::http::{HeaderMap, HeaderValue, Request, StatusCode};
10use axum::middleware::Next;
11use axum::response::{IntoResponse, Response};
12use axum::routing::{get, post};
13use axum::{Json, Router};
14use futures::stream::BoxStream;
15use futures::{FutureExt, Stream, StreamExt};
16use serde_json::{json, Value};
17use std::future::Future as _;
18use std::pin::Pin;
19use std::sync::Arc;
20use std::task::{Context, Poll};
21use tracing::Instrument as _;
22
23/// Build the complete harness router.
24pub fn build_router(state: AppState) -> Router {
25    let max_body = state.config.max_request_bytes;
26    let dashboard_enabled = state.config.dashboard_enabled;
27    let mut router = Router::new()
28        .route("/health", get(health))
29        .route("/livez", get(livez))
30        .route("/readyz", get(readyz))
31        .route("/metrics", get(metrics))
32        .route("/v1/chat/completions", post(chat_completions).options(cors_deny))
33        .route(
34            "/v1/mcp",
35            post(mcp_call).get(mcp_get).delete(mcp_delete).options(cors_deny),
36        )
37        .route(
38            "/mcp",
39            post(mcp_call).get(mcp_get).delete(mcp_delete).options(cors_deny),
40        )
41        .route(
42            "/v1/mcp/{backend}",
43            post(mcp_backend_call)
44                .get(mcp_get)
45                .delete(mcp_delete)
46                .options(cors_deny),
47        )
48        .route(
49            "/mcp/{backend}",
50            post(mcp_backend_call)
51                .get(mcp_get)
52                .delete(mcp_delete)
53                .options(cors_deny),
54        )
55        .route("/v1/sessions/{id}/close", post(close_session).options(cors_deny))
56        .route(
57            "/v1/sessions/{id}/promote",
58            post(promote_session).options(cors_deny),
59        )
60        .route("/v1/token", post(token_exchange).options(cors_deny))
61        .route("/v1/revoke", post(crate::token_routes::revoke).options(cors_deny))
62        .route("/.well-known/jwks.json", get(jwks));
63    if !state.config.introspection_tokens.is_empty() {
64        router = router.route(
65            "/v1/introspect",
66            post(crate::token_routes::introspect).options(cors_deny),
67        );
68    }
69    if !state.config.operator_tokens.is_empty() {
70        router = router.route(
71            "/admin/v1/revocations",
72            post(crate::token_routes::admin_revoke).options(cors_deny),
73        );
74    }
75    if dashboard_enabled {
76        router = router
77            .route("/dashboard", get(crate::dashboard::index))
78            .route("/dashboard/", get(crate::dashboard::index))
79            .route("/dashboard/style.css", get(crate::dashboard::style_css))
80            .route("/dashboard/app.js", get(crate::dashboard::app_js))
81            .route("/api/v1/dashboard/stats", get(crate::dashboard::stats))
82            .route("/api/v1/dashboard/sessions", get(crate::dashboard::list_sessions))
83            .route(
84                "/api/v1/dashboard/sessions/{id}",
85                get(crate::dashboard::session_detail),
86            );
87    }
88    router
89        .layer(axum::middleware::from_fn_with_state(
90            state.clone(),
91            enforce_allowed_hosts,
92        ))
93        .layer(axum::middleware::from_fn_with_state(
94            state.clone(),
95            request_metrics,
96        ))
97        .layer(axum::middleware::from_fn(trace_request))
98        // axum's DefaultBodyLimit rejection is a bare
99        // text/plain "length limit exceeded" — it names neither the
100        // knob nor the limit, breaks the JSON error contract every
101        // other response honours, and is the first error a
102        // vision-agent user hits. Rewrite it into the OpenAI error
103        // shape naming `max_request_bytes` and its configured value.
104        // Sits OUTSIDE the limit layer so it sees the 413 response.
105        .layer(axum::middleware::from_fn_with_state(state.clone(), explain_body_limit))
106        // axum's default body limit is 2 MiB, which silently rejects large
107        // chat contexts (Claude 200k, GPT-4 128k) before the sandbox even
108        // sees the payload. `max_request_bytes` (default 4 MiB, matching
109        // the sandbox `MAX_PAYLOAD_BYTES`) is the single knob operators
110        // control.
111        .layer(axum::extract::DefaultBodyLimit::max(max_body))
112        .with_state(state)
113}
114
115/// See the layer comment in [`build_router`]: turn the bare-text 413
116/// from `DefaultBodyLimit` into the OpenAI-shaped JSON error, naming
117/// the config knob and the configured limit.
118async fn explain_body_limit(
119    State(state): State<AppState>,
120    request: Request<Body>,
121    next: axum::middleware::Next,
122) -> Response {
123    let response = next.run(request).await;
124    if response.status() != StatusCode::PAYLOAD_TOO_LARGE {
125        return response;
126    }
127    // Only rewrite the extractor's bare-text rejection; a JSON 413
128    // minted by a handler already honours the contract.
129    let is_json = response
130        .headers()
131        .get(axum::http::header::CONTENT_TYPE)
132        .and_then(|value| value.to_str().ok())
133        .is_some_and(|value| value.starts_with("application/json"));
134    if is_json {
135        return response;
136    }
137    let limit = state.config.max_request_bytes;
138    (
139        StatusCode::PAYLOAD_TOO_LARGE,
140        Json(serde_json::json!({
141            "error": {
142                "message": format!(
143                    "request body exceeds max_request_bytes ({limit} bytes); \
144                     raise `max_request_bytes` in the harness config if larger \
145                     payloads are intended"
146                ),
147                "type": "invalid_request_error",
148                "param": serde_json::Value::Null,
149                // Numeric, matching `pipeline_error`'s 413 and the
150                // documented contract ("`code` is the numeric HTTP
151                // status"): the same 413 class must not have two
152                // incompatible shapes depending on whether axum's body
153                // limit or the compression floor tripped first.
154                "code": StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
155            }
156        })),
157    )
158        .into_response()
159}
160
161/// DNS-rebinding defense: when `allowed_hosts` is non-empty,
162/// refuse any request whose `Host` header (port stripped,
163/// case-insensitive) is not listed — BEFORE any handler runs. A
164/// hostile page's JavaScript can reach this service under an
165/// attacker-controlled hostname whose DNS was rebound to our IP;
166/// same-origin policy doesn't help because the browser believes the
167/// origin is the attacker's. An absent or unparseable Host is refused
168/// too (every HTTP/1.1 client sends one; HTTP/2 :authority maps to it).
169async fn enforce_allowed_hosts(
170    State(state): State<AppState>,
171    request: Request<Body>,
172    next: Next,
173) -> Response {
174    if state.config.allowed_hosts.is_empty() {
175        return next.run(request).await;
176    }
177    let host = request
178        .headers()
179        .get(axum::http::header::HOST)
180        .and_then(|value| value.to_str().ok())
181        .map(|value| {
182            // Strip :port — IPv6 literals carry brackets ([::1]:8484).
183            let stripped = value
184                .rsplit_once(':')
185                .filter(|(head, port)| !head.is_empty() && port.chars().all(|c| c.is_ascii_digit()))
186                .map_or(value, |(head, _)| head);
187            stripped.trim_start_matches('[').trim_end_matches(']')
188        });
189    let allowed = host.is_some_and(|host| {
190        state.config.allowed_hosts.iter().any(|entry| {
191            entry
192                .trim_start_matches('[')
193                .trim_end_matches(']')
194                .eq_ignore_ascii_case(host)
195        })
196    });
197    if !allowed {
198        state
199            .metrics
200            .counter(
201                "av_host_header_refused_total",
202                "Requests refused by the allowed_hosts Host-header allowlist",
203            )
204            .inc();
205        return (
206            StatusCode::FORBIDDEN,
207            Json(json!({
208                "error": {
209                    "message": "Host header is not in allowed_hosts",
210                    "type": "permission_error",
211                    "param": null,
212                    "code": 403,
213                }
214            })),
215        )
216            .into_response();
217    }
218    next.run(request).await
219}
220
221/// data-plane request metrics. Every HTTP request —
222/// including the early-4xx returns the review flagged as unaudited —
223/// lands in `av_requests_total{route,status_class}` and
224/// `av_request_duration_seconds{route}`. The route label is drawn
225/// from a FIXED set (no client-controlled values) so metric
226/// cardinality is bounded; unknown paths collapse to `other`.
227///
228/// R58 hot-path: metric handles are pre-resolved into
229/// `HotMetrics.request_counters` / `request_duration_histograms`
230/// arrays indexed by `Route::index()` and `StatusClass::index()`.
231/// This eliminates the per-request `format!` (~120 B alloc) and the
232/// 4 shared-`Registry` mutex acquisitions (2 for each `.counter()`
233/// and `.histogram_with_bounds()` call) that the prior lazy path
234/// incurred — at 10k req/s that's ~40k mutex ops/s + 1.2 MB/s of
235/// allocator churn on the middleware alone. K8s liveness /
236/// readiness probes at 5-10 s intervals per replica are the
237/// dominant callers by count.
238async fn request_metrics(State(state): State<AppState>, request: Request<Body>, next: Next) -> Response {
239    let route = route(request.uri().path());
240    let started = std::time::Instant::now();
241    let response = next.run(request).await;
242    let status_class = crate::pipeline::StatusClass::from_u16(response.status().as_u16());
243    // Safe: `Route::index()` returns `0..10` (see `Route::ORDER`
244    // length) and `StatusClass::index()` returns `0..5` (see
245    // `StatusClass::ORDER` length); `HotMetrics` allocates arrays
246    // of exactly those sizes. Silence `clippy::indexing_slicing`
247    // — a debug-assertion sanity check would be more expensive than
248    // the guarded lookup on the hot path.
249    #[allow(clippy::indexing_slicing)]
250    {
251        state.hot_metrics.request_counters[route.index()][status_class.index()].inc();
252        state.hot_metrics.request_duration_histograms[route.index()].observe_us(elapsed_us(started));
253    }
254    response
255}
256
257/// Fixed route-label set for `av_requests_total`. NEVER interpolate a
258/// client-controlled value here — Prometheus cardinality is bounded
259/// only because this set is closed.
260fn route(path: &str) -> crate::pipeline::Route {
261    use crate::pipeline::Route;
262    match path {
263        "/v1/chat/completions" => Route::Chat,
264        "/v1/mcp" | "/mcp" => Route::Mcp,
265        "/health" => Route::Health,
266        "/livez" => Route::Livez,
267        "/readyz" => Route::Readyz,
268        "/metrics" => Route::Metrics,
269        "/v1/token" => Route::TokenExchange,
270        "/v1/revoke" => Route::Revoke,
271        "/v1/introspect" => Route::Introspect,
272        "/admin/v1/revocations" => Route::AdminRevocation,
273        "/.well-known/jwks.json" => Route::Jwks,
274        _ if path.starts_with("/v1/mcp/") || path.starts_with("/mcp/") => Route::Mcp,
275        _ if path.starts_with("/v1/sessions/") && path.ends_with("/close") => Route::SessionClose,
276        _ if path.starts_with("/v1/sessions/") && path.ends_with("/promote") => Route::SessionPromote,
277        _ if path.starts_with("/dashboard") || path.starts_with("/api/v1/dashboard") => Route::Dashboard,
278        _ => Route::Other,
279    }
280}
281
282async fn trace_request(request: Request<Body>, next: Next) -> Response {
283    let method = request.method().clone();
284    let path = request.uri().path().to_owned();
285    // Sanitize the session id BEFORE binding it into the
286    // span. Previously the raw header value went in verbatim, which
287    // (1) risked unbounded label cardinality on OTLP exporters that
288    // map span attributes to metric labels — every distinct
289    // client-supplied session id (including hostile garbage) became
290    // its own series; (2) created a split-brain when a client sent
291    // two `X-AV-Session` headers — `HeaderMap::get` returned the
292    // first while `pipeline::single_header` refused the
293    // whole request, so traces named a "friendly" id for a hard-400;
294    // (3) accepted values that pass `HeaderValue::to_str` but fail
295    // `SessionId::parse` (too long, control chars in disguise). Use
296    // `single_header` + `SessionId::parse` to bind ONE consistent
297    // value or the sentinel `"invalid"`.
298    // Use a sentinel that CANNOT pass `SessionId::parse`
299    // (0x21..=0x7e visible-ASCII only), so a client cannot legitimately
300    // send `X-AV-Session: invalid` and share a trace label with a
301    // rejected request. The leading space (0x20) is outside the
302    // allowed range → collision-free.
303    let headers = request.headers();
304    let session_id = match crate::pipeline::single_header(headers, crate::pipeline::SESSION_HEADER) {
305        Ok(Some(value)) => value
306            .to_str()
307            .ok()
308            .and_then(|v| av_core::SessionId::parse(v).ok())
309            .map(|id| id.to_string())
310            .unwrap_or_else(|| " rejected".to_owned()),
311        Ok(None) => " unbound".to_owned(),
312        Err(_) => " duplicate-header".to_owned(),
313    };
314    let span = tracing::info_span!(
315        "agentvisor.request",
316        otel.kind = "server",
317        http.request.method = %method,
318        url.path = %path,
319        session.id = %session_id,
320        http.response.status_code = tracing::field::Empty,
321    );
322    let response = next.run(request).instrument(span.clone()).await;
323    span.record("http.response.status_code", response.status().as_u16());
324    response
325}
326
327async fn health() -> impl IntoResponse {
328    // DO NOT expose the CARGO_PKG_VERSION on this
329    // unauthenticated endpoint. Version disclosure lets a LAN
330    // attacker correlate an agentvisor deployment to a specific
331    // known-vulnerable release without needing a chat probe. No
332    // harness endpoint surfaces build info (`/metrics` included);
333    // the version appears only in the *outbound* upstream
334    // `User-Agent` built in `pipeline.rs`.
335    Json(json!({
336        "status": "ok",
337        // Product identifier so callers (avctl start) can tell a real
338        // AgentVisor AI apart from an unrelated service squatting the
339        // port. This is not a version number and reveals no
340        // vulnerability-relevant information.
341        "service": "agentvisor",
342    }))
343}
344
345/// Kubernetes-style liveness probe.
346///
347/// Constant response: liveness answers "is the process alive at all?"
348/// and must NOT couple to backend health — otherwise a transient state
349/// backend outage triggers a pod restart cascade instead of a
350/// readiness-based traffic drain. This mirrors `/health` (kept for
351/// backward compatibility) but with a name that reads as its intent.
352/// Point `livenessProbe` at `/livez`; point `readinessProbe` at
353/// `/readyz`.
354async fn livez() -> impl IntoResponse {
355    Json(json!({
356        "status": "alive",
357        "service": "agentvisor",
358    }))
359}
360
361/// Kubernetes-style readiness probe.
362///
363/// 200 when the harness can serve a request end-to-end; 503 while
364/// draining or when a required backend is unavailable. Checks:
365///   * `draining` — set by the SIGTERM handler before axum's graceful
366///     drain begins, so the LB stops sending new traffic first.
367///   * `atif_spool_dir` — attempts a metadata read so a missing / read-only
368///     spool (the shape that produces `503 audit capture unavailable`
369///     for every chat request) is reflected in readiness rather than
370///     hidden behind a hardcoded `/health` 200.
371///
372/// Returns the JSON body regardless of status so operators can diff
373/// which check failed without opening a shell.
374async fn readyz(State(state): State<AppState>) -> impl IntoResponse {
375    let draining = state.draining.load(std::sync::atomic::Ordering::SeqCst);
376    // Writability probe, not just metadata: the outage this endpoint
377    // exists to surface is a FULL DISK (observed: 100% of traffic
378    // 503-ing while the pod stayed Ready) — and a full disk keeps the
379    // spool directory perfectly readable, so an `is_dir()` check
380    // passes through the whole incident. Create-write-sync-remove a
381    // probe file so ENOSPC/EROFS/EACCES all flip readiness. The name
382    // ends in `.tmp`, matching the boot sweep's orphan pattern, so a
383    // crash between create and remove self-heals on restart; UUIDv7
384    // uniqueness keeps concurrent probes from colliding.
385    //
386    // Run under `spawn_blocking` so a partial I/O outage (the exact
387    // condition this probe exists to catch) cannot stall a Tokio
388    // worker thread. Under a K8s probe interval smaller than a
389    // stall's ms-to-second latency, blocking the runtime here would
390    // escalate a filesystem hiccup into runtime-wide starvation
391    // exactly when other request paths need the workers most.
392    let spool_writable = {
393        let dir = state.config.atif_spool_dir.clone();
394        tokio::task::spawn_blocking(move || {
395            let probe = std::path::Path::new(&dir).join(format!(".readyz-{}.tmp", av_core::new_event_uid()));
396            std::fs::write(&probe, b"readyz")
397                .and_then(|()| {
398                    // fsync would double the probe's IOPS cost for no
399                    // signal: ENOSPC surfaces at write() on every
400                    // mainstream filesystem.
401                    std::fs::remove_file(&probe)
402                })
403                .is_ok()
404        })
405        .await
406        .unwrap_or(false)
407    };
408    // Report a shared revocation outage without removing every replica from
409    // service. Authentication still refuses requests when the store is unreadable.
410    let revocation_health: Vec<_> = state
411        .identity
412        .as_ref()
413        .and_then(|validator| validator.revocation_store())
414        .and_then(|store| store.health())
415        .into_iter()
416        .chain(state.exchanged_revocations.health())
417        .collect();
418    let revocation_available = revocation_health.iter().all(|health| health.available);
419    let local_revocations: usize = revocation_health.iter().map(|health| health.local_entries).sum();
420    let ready = !draining && spool_writable;
421    let body = Json(json!({
422        "status": if ready { "ready" } else { "not_ready" },
423        "service": "agentvisor",
424        "checks": {
425            "draining": draining,
426            "spool_dir_writable": spool_writable,
427            "revocation_available": revocation_available,
428            "revocation_local_entries": local_revocations,
429        },
430    }));
431    if ready {
432        (StatusCode::OK, body).into_response()
433    } else {
434        (StatusCode::SERVICE_UNAVAILABLE, body).into_response()
435    }
436}
437
438/// Explicit deny-CORS OPTIONS handler.
439///
440/// The harness is a same-origin proxy; no cross-origin client is
441/// expected or supported. Without this route, axum's default reply to
442/// a preflight (`OPTIONS /v1/chat/completions`) is `405 Method Not
443/// Allowed` with an `Allow: POST` header — inconsistent with the
444/// "no discoverable posture" hygiene, and confusing to any
445/// operator whose LAN browser client accidentally triggers a preflight.
446/// Reply with `204 No Content` and NO `Access-Control-Allow-Origin`
447/// header: browsers correctly treat this as "cross-origin denied" and
448/// refuse the actual request, making the posture explicit at the wire.
449async fn cors_deny() -> Response {
450    let mut response = Response::new(Body::empty());
451    *response.status_mut() = StatusCode::NO_CONTENT;
452    response.headers_mut().insert(
453        axum::http::header::CACHE_CONTROL,
454        HeaderValue::from_static("no-store"),
455    );
456    // Do not echo the requester's Origin. Do not include any
457    // Access-Control-Allow-* header. This deliberately fails the
458    // browser's preflight check.
459    response
460}
461
462async fn metrics(State(state): State<AppState>) -> Response {
463    // Refresh gauges immediately before rendering so the scrape
464    // reflects the live registry state. A gauge held
465    // as `Arc<Gauge>` in AppState would also work, but sampling
466    // once per scrape avoids inc/dec bookkeeping on every session
467    // insert/remove and cannot drift out of sync with the actual
468    // dashmap state.
469    state
470        .metrics
471        .gauge(
472            "av_open_sessions",
473            "Currently-open sessions in the in-memory registry",
474        )
475        .set(state.sessions.len() as u64);
476    // Worker backlog + spool footprint, sampled per
477    // scrape. Queue depth is one atomic load; the spool walk is a
478    // single-level read_dir summing entry sizes (a few ms even at
479    // tens of thousands of files, amortized across the scrape
480    // interval).
481    state
482        .metrics
483        .gauge(
484            "av_worker_queue_depth",
485            "Accepted-but-not-yet-completed worker jobs",
486        )
487        .set(state.worker.queue_depth());
488    // Move the synchronous `read_dir` + per-entry `metadata` calls to
489    // the blocking pool. Pre-fix this ran directly on the tokio worker
490    // polling the /metrics request, so any co-scheduled task on that
491    // worker (SSE frame writes, per-connection reads, close-session
492    // waits, the reconciler tick if it landed on the same worker)
493    // stalled for the walk's duration. Prometheus/OTel scrape every
494    // 15-30 s from every replica, so this was a periodic tail-
495    // latency step visible on the streaming path. Worse: a stuck
496    // NFS/EFS mount for the spool volume could hang the request
497    // handler entirely, letting `/readyz` and `/metrics` lie about
498    // liveness while workers hung. `spawn_blocking` isolates the
499    // syscall on the blocking pool and lets the tokio scheduler
500    // continue polling other futures.
501    let spool_dir = state.config.atif_spool_dir.clone();
502    let (spool_bytes, spool_files) =
503        tokio::task::spawn_blocking(move || spool_footprint(std::path::Path::new(&spool_dir)))
504            .await
505            .unwrap_or((0, 0));
506    state
507        .metrics
508        .gauge(
509            "av_spool_bytes",
510            "Total bytes in the top-level ATIF spool directory",
511        )
512        .set(spool_bytes);
513    state
514        .metrics
515        .gauge(
516            "av_spool_files",
517            "File count in the top-level ATIF spool directory",
518        )
519        .set(spool_files);
520    let mut response = Response::new(Body::from(state.metrics.render()));
521    response.headers_mut().insert(
522        axum::http::header::CONTENT_TYPE,
523        HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
524    );
525    response
526}
527
528/// Sum size and count of regular files in the top level of the spool
529/// directory. Non-recursive on purpose: subdirectories (broker-acks/,
530/// receipts/) have bounded per-session cost and a recursive walk per
531/// scrape would reintroduce the §8.1 O(spool) tax on another path.
532fn spool_footprint(dir: &std::path::Path) -> (u64, u64) {
533    let Ok(entries) = std::fs::read_dir(dir) else {
534        return (0, 0);
535    };
536    let mut bytes = 0u64;
537    let mut files = 0u64;
538    for entry in entries.flatten() {
539        if let Ok(metadata) = entry.metadata() {
540            if metadata.is_file() {
541                bytes = bytes.saturating_add(metadata.len());
542                files = files.saturating_add(1);
543            }
544        }
545    }
546    (bytes, files)
547}
548
549/// Refuse an inbound request whose declared `Content-Type` is not
550/// `application/json`. Shared by `chat_completions` and `mcp_call`
551/// so both trust-boundary ingress points agree on the same rules —
552/// a client sending e.g. `Content-Type: multipart/form-data` with a
553/// JSON body would otherwise process successfully on a route that
554/// forgot the check. Missing Content-Type is tolerated (minimal
555/// clients skip it and JSON parsers accept the body's shape).
556///
557/// `context_label` is inlined into the client-facing 400 body so
558/// operators reading the message know which endpoint refused.
559fn refuse_non_json_content_type(headers: &HeaderMap, context_label: &str) -> Option<Response> {
560    let value = headers.get(axum::http::header::CONTENT_TYPE)?;
561    let is_json = value
562        .to_str()
563        .ok()
564        .and_then(|s| s.split(';').next())
565        .map(|main| main.trim().eq_ignore_ascii_case("application/json"))
566        .unwrap_or(false);
567    if is_json {
568        return None;
569    }
570    let display = value.to_str().unwrap_or("<non-ascii>");
571    Some(pipeline_error(crate::pipeline::PipelineError::bad_request(
572        format!("{context_label} requires Content-Type: application/json, got {display:?}"),
573    )))
574}
575
576async fn chat_completions(State(state): State<AppState>, headers: HeaderMap, body: Bytes) -> Response {
577    // Parity with the MCP path (routes.rs::mcp_call): refuse an
578    // inbound request whose declared `Content-Type` is not
579    // `application/json`. The chat endpoint historically accepted
580    // any Content-Type and relied on `serde_json::from_slice` to
581    // fail — that pattern falls back to a stringly-typed 400 on
582    // deserialise error instead of a specific "spec expects
583    // application/json" 400 the operator can triage. Missing
584    // Content-Type stays tolerated (minimal clients skip it and
585    // JSON parsers accept the body's shape anyway); an EXPLICIT
586    // non-JSON declaration is refused. Same helper as MCP for
587    // strictly one place to change the parsing rules.
588    if let Some(err) = refuse_non_json_content_type(&headers, "chat completions") {
589        return err;
590    }
591    // R71 F3 (landed R72): pre-admission identity gate. Before
592    // this, an unauthenticated client could force
593    // `refuse_duplicate_json_keys` (full byte scan) +
594    // `serde_json::from_slice` (full parse) + `depth_of` (full
595    // recursion) on a `max_request_bytes = 16 MiB` body BEFORE
596    // any admission decision — a DoS amplification surface (three
597    // full-body passes × concurrent unauthenticated clients).
598    // Authenticate once before parsing, then carry the validated caller into
599    // preparation for scope rechecks and principal binding. Configured identity
600    // validators run off the reactor because revocation may use synchronous I/O;
601    // the anonymous path stays local even when the blocking pool is saturated.
602    let caller = match state.authenticate_chat_nonblocking(&headers).await {
603        Ok(caller) => caller,
604        Err(error) => {
605            if let Err(audit_error) = state.audit_authentication_error(&headers, &error) {
606                return pipeline_error(audit_error);
607            }
608            return pipeline_error(error);
609        }
610    };
611    // Refuse duplicate top-level or
612    // nested JSON keys before parsing. `Json<Value>` used
613    // `serde_json` default "last-wins" semantics, so a hostile client
614    // could send `{"messages":[safe],"messages":[hostile]}` — the
615    // harness saw the hostile array, but any auditor/dashboard
616    // reading the raw request bytes (e.g. via
617    // `av_events::atif_capture_from_request` chain input) sees an
618    // ambiguous document, breaking the "same signature ⇔ same
619    // bytes" auditor invariant pinned for receipts.
620    // Same fix as `parse_tool_call` on the MCP path — use the shared
621    // primitive.
622    if let Err(reason) = av_sandbox::refuse_duplicate_json_keys(&body) {
623        return pipeline_error(crate::pipeline::PipelineError::bad_request(format!(
624            "chat request rejected: {reason}"
625        )));
626    }
627    let payload: Value = match serde_json::from_slice(&body) {
628        Ok(value) => value,
629        Err(error) => return pipeline_error(crate::pipeline::PipelineError::bad_request_source(error)),
630    };
631    // Parity with the MCP path (`av_sandbox::parse_tool_call`) which
632    // caps `MAX_JSON_DEPTH = 64`. serde_json's built-in
633    // `RECURSION_LIMIT = 128` prevents stack blowout, but the chat
634    // trust-boundary discipline is uneven relative to the tool-call
635    // gate: a 4 MiB nested payload that MCP would refuse silently
636    // parsed on chat. Apply the same cap so both trust-boundary
637    // ingress points agree on JSON-shape limits.
638    if av_sandbox::rpc::depth_of(&payload, 0) > av_sandbox::rpc::MAX_JSON_DEPTH {
639        return pipeline_error(crate::pipeline::PipelineError::bad_request(format!(
640            "chat request nesting depth exceeds {}",
641            av_sandbox::rpc::MAX_JSON_DEPTH
642        )));
643    }
644    let admission_started = std::time::Instant::now();
645    let mut prepared = match state
646        .prepare_authenticated_chat(&headers, payload, body.len(), caller)
647        .await
648    {
649        Ok(prepared) => prepared,
650        Err(error) => return pipeline_error(error),
651    };
652    prepared.middleware_us = elapsed_us(admission_started);
653    let session = Arc::clone(&prepared.session);
654    let identity = prepared.identity.clone();
655    let session_id = prepared.session.id.clone();
656    let middleware_us = prepared.middleware_us;
657    let forwarded = match state.forward_chat(prepared).await {
658        Ok(response) => response,
659        Err(error) => return pipeline_error_for_session(error, &session_id),
660    };
661    let crate::pipeline::ForwardedResponse {
662        response: upstream,
663        billed_prompt_tokens,
664        lease,
665        mut capture_guard,
666    } = forwarded;
667    let Some(response_permit) = capture_guard.take_permit() else {
668        // Defensive: `prepare_chat` always reserves a permit. Even so,
669        // retire the durable marker and the journalled attempt through
670        // the plain worker queue before failing — returning directly
671        // would leak the spool/inflight-responses/ marker (crash
672        // recovery then quarantines the session) and leave a dangling
673        // non-terminal ResponseAttempt. If even that submit fails, fail
674        // the session closed rather than dropping the capture silently.
675        let (response_marker, response_attempt_id) = capture_guard.disarm();
676        let capture_session = Arc::clone(&session);
677        if state
678            .worker
679            .try_submit(crate::pipeline::refused_response_failure_job(
680                session,
681                identity,
682                "response_capture_permit_missing".to_owned(),
683                response_marker,
684                response_attempt_id,
685            ))
686            .is_err()
687        {
688            capture_session.mark_capture_failed();
689        }
690        let mut response = lifecycle_error("durable response capture permit is missing".to_owned());
691        if let Ok(value) = HeaderValue::from_str(&session_id) {
692            response
693                .headers_mut()
694                .insert(crate::pipeline::SESSION_HEADER, value);
695        }
696        return response;
697    };
698
699    let status = upstream.status();
700    let upstream_headers = upstream.headers().clone();
701    // reqwest is built without the `gzip` feature (see Cargo.toml), so
702    // the client does not decode `Content-Encoding: gzip`. If the
703    // provider or a CDN in front of it compresses the SSE response,
704    // `absorb_network_chunk` immediately fails
705    // `std::str::from_utf8(&frame)` and aborts every stream. Refuse
706    // the response up front with a clear error rather than corrupt
707    // the audit trail with UTF-8-error frames.
708    //
709    // Multi-value / multi-line semantics: an upstream that sends
710    // `Content-Encoding: identity` on one line and
711    // `Content-Encoding: gzip` on another (some layered reverse
712    // proxies do this) would slip past a `.get(...)` that only reads
713    // the first value. Iterate `get_all` and require EVERY value to
714    // be empty or `identity` case-insensitively; also split each
715    // value on `,` so `gzip, identity` (single header, two tokens)
716    // is caught.
717    let mut refused_encoding: Option<(String, String)> = None;
718    'encodings: for value in upstream_headers.get_all(axum::http::header::CONTENT_ENCODING) {
719        let raw = value.to_str().unwrap_or_default();
720        for token in raw.split(',') {
721            let token = token.trim();
722            if token.is_empty() || token.eq_ignore_ascii_case("identity") {
723                continue;
724            }
725            refused_encoding = Some((token.to_owned(), raw.to_owned()));
726            break 'encodings;
727        }
728    }
729    if let Some((token, raw)) = refused_encoding {
730        // Retire the durable in-flight marker and terminally fail the
731        // journalled response attempt before refusing — mirroring
732        // forward_chat's Err arm. Returning without this leaked the
733        // marker (recover_spooled_sessions quarantined the session on
734        // every subsequent boot) and left a dangling non-terminal
735        // attempt while the session later closed with a "clean"
736        // receipt. Stable classifier only in the persisted record —
737        // the header token is upstream-controlled bytes. If the shard
738        // is full, fail the session closed rather than dropping the
739        // capture silently (same rationale as forward_chat's Err arm).
740        let (response_marker, response_attempt_id) = capture_guard.disarm();
741        let capture_session = Arc::clone(&session);
742        if response_permit
743            .submit(
744                &state.worker,
745                crate::pipeline::refused_response_failure_job(
746                    session,
747                    identity,
748                    "upstream_unsupported_content_encoding".to_owned(),
749                    response_marker,
750                    response_attempt_id,
751                ),
752            )
753            .is_err()
754        {
755            capture_session.mark_capture_failed();
756        }
757        return pipeline_error_for_session(
758            crate::pipeline::PipelineError::upstream(format!(
759                "upstream responded with unsupported Content-Encoding token {token:?} \
760                 (full header: {raw:?}) — the proxy is built without decompression \
761                 support; enable it upstream (Accept-Encoding: identity) or rebuild \
762                 with the reqwest `gzip` feature"
763            )),
764            &session_id,
765        );
766    }
767    // RFC 7231 §3.1.1.1 says media type/subtype are
768    // case-insensitive and the header value may carry parameters
769    // (`; charset=utf-8`). Byte-exact `starts_with("text/event-stream")`
770    // misses `Text/Event-Stream` (some CDNs re-title-case) and can also
771    // mis-fire on hypothetical `text/event-stream-json`. Split on `;`
772    // then compare with `eq_ignore_ascii_case` so SSE is detected
773    // regardless of casing and parameters. A misclassified SSE stream
774    // gets buffered to the 16 MiB provider cap in the non-SSE branch
775    // and either loses streaming semantics (client sees no deltas
776    // until upstream EOF) or is refused with 502 despite being valid.
777    let is_sse = is_sse_content_type(&upstream_headers);
778    let stream = upstream
779        .bytes_stream()
780        .map(|chunk| chunk.map_err(std::io::Error::other));
781    // `AbortFinalizingStream` owns marker retirement and attempt
782    // termination from here (including on mid-stream client disconnect,
783    // via its own Drop); hand both over and disarm the guard.
784    let (response_marker, response_attempt_id) = capture_guard.disarm();
785    // Same gating as admission (`prepare_chat`): a configured
786    // [principal_budget] binds every request, keyed by
787    // `principal_id_for_budget` — anonymous deployments share one
788    // bucket by explicit opt-in.
789    let principal_budget = state
790        .config
791        .principal_budget
792        .as_ref()
793        .map(|spec| (crate::pipeline::principal_id_for_budget(&identity), spec.clone()));
794    let stream = AbortFinalizingStream {
795        inner: stream.boxed(),
796        session,
797        identity,
798        response_permit: Some(response_permit),
799        worker: state.worker.clone(),
800        store: Arc::clone(&state.store),
801        budget: state.config.budget.clone(),
802        finalizer: state.finalizer.clone(),
803        _lease: lease,
804        response_marker,
805        response_attempt_id,
806        response_message: String::new(),
807        response_reasoning: String::new(),
808        response_model: None,
809        response_finish_reason: None,
810        upstream_status: status,
811        response_cost_usd_micros: 0,
812        response_tool_calls: std::collections::BTreeMap::new(),
813        response_metrics: av_events::EventMetrics::default(),
814        charged_completion_tokens: 0,
815        last_reported_completion_tokens: None,
816        last_reported_prompt_tokens: None,
817        last_reported_cached_tokens: None,
818        last_reported_cost_usd_micros: None,
819        saw_chunk: false,
820        capture_attempted: false,
821        is_sse,
822        protocol_buffer: Vec::new(),
823        frame_scratch: Vec::new(),
824        pending_output: std::collections::VecDeque::new(),
825        pending_budget: None,
826        principal_budget,
827        captured_bytes: 0,
828        billed_prompt_tokens,
829        provider_adapter: Arc::clone(&state.provider_adapter),
830        completed: false,
831        // Validated single-value by `prepare_chat`; absence means the
832        // id was generated server-side this request.
833        ephemeral: !headers.contains_key(crate::pipeline::SESSION_HEADER),
834    };
835    let mut response = if is_sse {
836        // Peek the FIRST item before committing the response head: when
837        // the first poll fails (empty 200 body, first frame invalid
838        // JSON, hostile usage fields), returning `Body::from_stream`
839        // directly made hyper abort the connection before flushing the
840        // status line — the client saw a raw empty reply (curl exit 52,
841        // http_code 000) instead of the clean 502/403 this file's own
842        // policy mandates. SDKs classify that as a network error and
843        // auto-retry, and each retry burns admission budget and seals
844        // another artifact-less session. Mid-stream failures (bytes
845        // already relayed) remain severed — unavoidable once the head
846        // is committed — but the pre-first-byte case surfaces as a real
847        // HTTP error exactly like the buffered non-SSE branch.
848        let mut relay = Box::pin(stream);
849        match relay.next().await {
850            Some(Err(error)) => {
851                let mapped = if error.kind() == std::io::ErrorKind::QuotaExceeded {
852                    crate::pipeline::PipelineError::blocked_source(error)
853                } else {
854                    crate::pipeline::PipelineError::upstream_source(error)
855                };
856                // Dropping the relay runs its finalization Drop
857                // (evidence capture + session seal).
858                return pipeline_error_for_session(mapped, &session_id);
859            }
860            Some(Ok(first)) => {
861                let head = futures::stream::once(async move { Ok::<_, std::io::Error>(first) });
862                Response::new(Body::from_stream(head.chain(relay)))
863            }
864            // Clean zero-item stream: every capture gate already ran
865            // inside the relay's EOF arm and succeeded.
866            None => Response::new(Body::empty()),
867        }
868    } else {
869        // A non-SSE body is fully buffered inside the relay before any byte
870        // is released: worker capture and the completion-token budget gate
871        // run on the complete body. Handing axum a stream here would commit
872        // the upstream status line before those gates run, so a budget
873        // refusal degraded into a 200 head followed by an empty body with
874        // no explanation. Drain the relay first (no extra buffering beyond
875        // what the relay already does) and surface refusals as real errors.
876        let mut relay = Box::pin(stream);
877        let mut buffered: Vec<u8> = Vec::new();
878        loop {
879            match relay.next().await {
880                Some(Ok(bytes)) => buffered.extend_from_slice(&bytes),
881                Some(Err(error)) => {
882                    // Route through
883                    // `pipeline_error` so the status class matches the
884                    // admission-time decision for the SAME condition
885                    // and the advisory headers are attached. The prior
886                    // hand-built 429 contradicted the deliberate
887                    // 403-not-429 choice for budget caps (a permanent
888                    // per-session refusal SDKs must not auto-retry)
889                    // and omitted the Retry-After that this file's own
890                    // policy mandates on every 502.
891                    let mapped = if error.kind() == std::io::ErrorKind::QuotaExceeded {
892                        crate::pipeline::PipelineError::blocked_source(error)
893                    } else {
894                        crate::pipeline::PipelineError::upstream_source(error)
895                    };
896                    // Dropping the relay here runs its finalization Drop
897                    // (evidence capture + session seal), same as when a
898                    // client observed the severed stream.
899                    return pipeline_error_for_session(mapped, &session_id);
900                }
901                None => break,
902            }
903        }
904        Response::new(Body::from(buffered))
905    };
906    *response.status_mut() = status;
907    for (name, value) in &upstream_headers {
908        if is_forwardable_upstream_header(name, &state.config.upstream_auth_header) {
909            // `append`, not `insert`: iterating a HeaderMap repeats the name
910            // for each value of a multi-valued header, and `insert` would
911            // keep only the last one.
912            response.headers_mut().append(name.clone(), value.clone());
913        }
914    }
915    if let Ok(value) = HeaderValue::from_str(&session_id) {
916        response
917            .headers_mut()
918            .insert(crate::pipeline::SESSION_HEADER, value);
919    }
920    if let Ok(value) = HeaderValue::from_str(&middleware_us.to_string()) {
921        response
922            .headers_mut()
923            .insert(crate::pipeline::MIDDLEWARE_US_HEADER, value);
924    }
925    // Pin `X-Content-Type-Options: nosniff` on every
926    // upstream-relayed response. The relay forwards the upstream's
927    // Content-Type verbatim (validated by our `is_sse_content_type`
928    // for framing decisions, but not sanitised for the client).
929    // A rogue upstream, MITM at egress, or CDN mis-config could
930    // otherwise flip Content-Type to `text/html; charset=utf-8` on
931    // a body carrying prompt-echoed attacker bytes — turning what
932    // the audit trail attests as "assistant output" into HTML the
933    // browser might render. `nosniff` prevents browser-side MIME
934    // sniffing from disagreeing with the declared type and closes
935    // the reflected-content path.
936    response.headers_mut().insert(
937        axum::http::HeaderName::from_static("x-content-type-options"),
938        HeaderValue::from_static("nosniff"),
939    );
940    // SSE relays fronted by nginx (or an ingress that honours the
941    // hint) must set `X-Accel-Buffering: no` on the response, or
942    // nginx's default `proxy_buffering on` accumulates the entire
943    // SSE body before flushing — the SDK sees no incremental
944    // deltas until upstream EOF and the chat appears hung to
945    // end-users. Same class of ops footgun even under GCP HTTPS LB
946    // and other buffering front-ends that recognise this vendor
947    // header. Non-SSE responses do not need or want the hint (it's
948    // a no-op there).
949    if is_sse {
950        response.headers_mut().insert(
951            axum::http::HeaderName::from_static("x-accel-buffering"),
952            HeaderValue::from_static("no"),
953        );
954    }
955    response
956}
957
958/// Decide whether an upstream response header may be forwarded to the client.
959///
960/// This is a *proxy* trust boundary: the upstream LLM provider is not on the
961/// same trust domain as our client, so blindly forwarding response headers
962/// would let the upstream (or an attacker who influences the upstream)
963/// - set cookies in our domain (`Set-Cookie` — cookie injection),
964/// - open CORS on our origin (`Access-Control-Allow-*`),
965/// - inject invalid or contradictory framing/hop-by-hop metadata
966///   (`Transfer-Encoding`, `Content-Length`, `Connection`, `Keep-Alive`,
967///   `Trailer`, `TE`, `Upgrade`, `Proxy-Authenticate`, `Proxy-Authorization`),
968/// - leak upstream implementation identity (`Server`, `X-Powered-By`,
969///   `Via`, `X-Request-ID`).
970///
971/// Hyper computes framing metadata (`Content-Length`, `Transfer-Encoding`)
972/// itself when we build the response; forwarding the upstream's copies risks
973/// double-encoded or contradictory headers and — with `Transfer-Encoding` —
974/// classical HTTP request smuggling. Hop-by-hop headers are forbidden by
975/// RFC 7230 §6.1 from crossing a proxy.
976///
977/// The denylist once covered `Authorization` (RFC 7235)
978/// but NOT any custom-name auth header the harness itself uses to
979/// authenticate to the upstream. Every LLM provider uses a distinct
980/// custom header — `api-key` (Azure), `x-api-key` (Amazon Bedrock,
981/// Together AI), `x-goog-api-key` (Google), `anthropic-api-key`
982/// (Anthropic). A malicious or compromised upstream that echoes back
983/// its request's auth header in the response would then leak the
984/// operator's provider credential straight through to the caller.
985/// Two-layer defense: (a) refuse every well-known API-key header name
986/// by static allowlist below, AND (b) refuse the currently-configured
987/// `upstream_auth_header` name so operator-specific choices are also
988/// covered.
989fn is_forwardable_upstream_header(name: &axum::http::HeaderName, upstream_auth_header: &str) -> bool {
990    use axum::http::header;
991    // Any header name whose lowercased ASCII equals the configured
992    // upstream auth header MUST be stripped. `HeaderName` display is
993    // already lowercase-normalised.
994    if name.as_str().eq_ignore_ascii_case(upstream_auth_header) {
995        return false;
996    }
997    let is_denied = *name == header::CONTENT_LENGTH
998        || *name == header::TRANSFER_ENCODING
999        || *name == header::CONNECTION
1000        || *name == header::UPGRADE
1001        || *name == header::TE
1002        || *name == header::TRAILER
1003        || *name == header::PROXY_AUTHENTICATE
1004        || *name == header::PROXY_AUTHORIZATION
1005        // The harness holds the operator's provider credential; an upstream
1006        // that echoes the request's Authorization header back in its
1007        // response must not leak it to the client.
1008        || *name == header::AUTHORIZATION
1009        || *name == header::SET_COOKIE
1010        || *name == header::SERVER
1011        || *name == header::VIA
1012        || name.as_str().eq_ignore_ascii_case("keep-alive")
1013        || name.as_str().eq_ignore_ascii_case("x-powered-by")
1014        || name.as_str().eq_ignore_ascii_case("x-request-id")
1015        // Well-known provider API-key header names.
1016        // Verified against public docs of Azure OpenAI, AWS Bedrock,
1017        // Google Vertex, Anthropic, Cohere, DeepSeek, Together AI,
1018        // Groq, Mistral, and Fireworks.
1019        || name.as_str().eq_ignore_ascii_case("api-key")
1020        || name.as_str().eq_ignore_ascii_case("x-api-key")
1021        || name.as_str().eq_ignore_ascii_case("x-goog-api-key")
1022        || name.as_str().eq_ignore_ascii_case("anthropic-api-key")
1023        || name.as_str().eq_ignore_ascii_case("openai-api-key")
1024        || name.as_str().eq_ignore_ascii_case("x-auth-token")
1025        || name.as_str().eq_ignore_ascii_case("x-amz-security-token")
1026        // Never let the upstream open CORS on our origin — if we want CORS
1027        // we set it deliberately in our own router.
1028        || name.as_str().to_ascii_lowercase().starts_with("access-control-");
1029    !is_denied
1030}
1031
1032/// Detect `text/event-stream` in `Content-Type`
1033/// case-insensitively, accepting parameters like `; charset=utf-8`.
1034/// RFC 7231 §3.1.1.1 says media type/subtype are case-insensitive.
1035/// Byte-exact matching missed `Text/Event-Stream` (some CDNs
1036/// re-title-case) and mis-fired on hypothetical
1037/// `text/event-stream-json`. Misclassification cost: the non-SSE
1038/// branch buffers up to the 16 MiB provider cap and either loses
1039/// streaming semantics or refuses valid streams with 502.
1040fn is_sse_content_type(headers: &HeaderMap) -> bool {
1041    headers
1042        .get(axum::http::header::CONTENT_TYPE)
1043        .and_then(|value| value.to_str().ok())
1044        .is_some_and(|value| {
1045            let head = value.split(';').next().unwrap_or("").trim();
1046            head.eq_ignore_ascii_case("text/event-stream")
1047        })
1048}
1049
1050/// Per-execution-key completion gate. Retains only gates that another
1051/// request currently holds (strong count > 1), so the map stays bounded
1052/// by in-flight completions rather than growing per historical key.
1053fn tool_audit_gate(state: &AppState, key: &str) -> Arc<tokio::sync::Mutex<()>> {
1054    let mut gates = state.tool_audit_gates.lock();
1055    gates.retain(|_, gate| Arc::strong_count(gate) > 1);
1056    Arc::clone(gates.entry(key.to_owned()).or_default())
1057}
1058
1059async fn mcp_call(State(state): State<AppState>, headers: HeaderMap, body: Bytes) -> Response {
1060    mcp_entry(state, headers, body, crate::mcp::Scope::All).await
1061}
1062
1063/// `/mcp/{backend}`: the same MCP endpoint, restricted to one backend's
1064/// tools. Each backend gets its own route behind the one gateway host.
1065async fn mcp_backend_call(
1066    State(state): State<AppState>,
1067    Path(backend): Path<String>,
1068    headers: HeaderMap,
1069    body: Bytes,
1070) -> Response {
1071    if state.backend_router.get(&backend).is_none() {
1072        return (
1073            StatusCode::NOT_FOUND,
1074            Json(openai_error_body(StatusCode::NOT_FOUND, "unknown MCP backend")),
1075        )
1076            .into_response();
1077    }
1078    mcp_entry(state, headers, body, crate::mcp::Scope::Backend(backend)).await
1079}
1080
1081/// The gateway opens no server-to-client SSE stream, which the Streamable
1082/// HTTP transport allows by answering `GET` with 405.
1083async fn mcp_get(State(state): State<AppState>, headers: HeaderMap) -> Response {
1084    if let Err(response) = crate::mcp::check_origin(&state, &headers) {
1085        return *response;
1086    }
1087    let mut response = StatusCode::METHOD_NOT_ALLOWED.into_response();
1088    response.headers_mut().insert(
1089        axum::http::header::ALLOW,
1090        HeaderValue::from_static("POST, DELETE"),
1091    );
1092    response
1093}
1094
1095/// `DELETE` with an `MCP-Session-Id` ends the MCP session: its AgentVisor
1096/// session is closed exactly like `POST /v1/sessions/{id}/close`, so the
1097/// session's receipt or trajectory is finalized.
1098async fn mcp_delete(State(state): State<AppState>, headers: HeaderMap) -> Response {
1099    if let Err(response) = crate::mcp::check_origin(&state, &headers) {
1100        return *response;
1101    }
1102    let caller = match lifecycle_caller(&state, &headers, &state.config.session_close_scope).await {
1103        Ok(caller) => caller,
1104        Err(error) => return pipeline_error(error),
1105    };
1106    let mut headers = headers;
1107    let session = match crate::mcp::bind_session(&state, &mut headers, &caller) {
1108        Ok(Some(session)) => session,
1109        Ok(None) => {
1110            return (
1111                StatusCode::BAD_REQUEST,
1112                Json(openai_error_body(
1113                    StatusCode::BAD_REQUEST,
1114                    "MCP-Session-Id is required",
1115                )),
1116            )
1117                .into_response()
1118        }
1119        Err(response) => return *response,
1120    };
1121    state.mcp_backends.forget_agent_session(&session.agent_session);
1122    let Some(agent_session) = state.sessions.get(&session.agent_session) else {
1123        // No call ever opened the audited session: nothing to finalize.
1124        return StatusCode::NO_CONTENT.into_response();
1125    };
1126    if let Err(error) = caller.authorize_session(&state, &agent_session, &state.config.session_close_scope) {
1127        return pipeline_error(error);
1128    }
1129    match state
1130        .finalizer
1131        .close_session(agent_session, StopReason::SessionClosed)
1132        .await
1133    {
1134        Ok(outcome) => Json(outcome).into_response(),
1135        Err(error) => finalize_error_response(&error),
1136    }
1137}
1138
1139async fn mcp_entry(state: AppState, headers: HeaderMap, body: Bytes, scope: crate::mcp::Scope) -> Response {
1140    // Reject an inbound request whose declared
1141    // `Content-Type` is not `application/json`. MCP is JSON-RPC 2.0
1142    // (spec: MUST be `application/json`), and a client sending e.g.
1143    // `Content-Type: multipart/form-data` with a JSON body would
1144    // otherwise process successfully — a spec-violation surface with
1145    // no defensible use case. Missing Content-Type is tolerated
1146    // (some minimal clients skip it and JSON-RPC parsers accept the
1147    // body's shape).
1148    if let Some(err) = refuse_non_json_content_type(&headers, "MCP") {
1149        return err;
1150    }
1151    // R70 F1 (landed R71): admission cap before spawn. `mcp_call_inner`
1152    // buffers up to `MAX_TOOL_RESPONSE_BYTES = 16 MiB` per call in
1153    // `read_limited_tool_response`; without a limiter, N concurrent
1154    // slow-tool-upstream callers stacked N × 16 MiB unbounded. Chat
1155    // path is capped via `worker.try_reserve_pair`; the MCP path was
1156    // the odd one out. `try_acquire_owned` is non-blocking: on
1157    // capacity refusal we return 503 (server-side capacity signal,
1158    // matches breaker-open discipline) with Retry-After: 1 so
1159    // well-behaved SDKs pace themselves.
1160    let admission_permit = match Arc::clone(&state.mcp_admission).try_acquire_owned() {
1161        Ok(permit) => permit,
1162        Err(_) => {
1163            // R72 review F2 (landed R74): direct `.inc()` on the
1164            // pre-resolved handle in AppState — no registry mutex
1165            // acquired on the refusal hot path. Matches HotMetrics
1166            // discipline.
1167            state.mcp_admission_refusals_counter.inc();
1168            let mut response = (
1169                StatusCode::SERVICE_UNAVAILABLE,
1170                Json(openai_error_body(
1171                    StatusCode::SERVICE_UNAVAILABLE,
1172                    "MCP admission at capacity; retry shortly",
1173                )),
1174            )
1175                .into_response();
1176            response
1177                .headers_mut()
1178                .insert(axum::http::header::RETRY_AFTER, HeaderValue::from_static("1"));
1179            return response;
1180        }
1181    };
1182    // The tool path mutates durable state at several awaits: the sandbox
1183    // gate debits the budget, `execution.claim()` claims the execution
1184    // key, the upstream call executes the tool, and persist/audit resolve
1185    // the outcome. Axum drops the handler future on client disconnect; a
1186    // cancellation between any two of those steps used to strand the
1187    // intermediate state with no owner — a claimed-but-unresolved key
1188    // answers every retry with 409 TOOL_OUTCOME_UNCERTAIN while the
1189    // session lives (restart-time quarantine skips active sessions), and
1190    // a debited-but-unrefunded budget burns quota headroom per
1191    // disconnect. Run the whole body on a spawned task so it always runs
1192    // to completion; the handler merely awaits (and may abandon) the
1193    // result.
1194    //
1195    // The `mcp_inflight` guard is entered here on the outer handler and
1196    // moved into the spawn so its `Drop` decrements the tracker on the
1197    // spawn's completion path (including panic-caught unwind), NOT the
1198    // outer handler's cancellation. `finish_shutdown` in main.rs waits
1199    // for the tracker to drain before `worker.wait_idle`, so a detached
1200    // body cannot submit a fresh worker job after shutdown thinks it has
1201    // drained (which would trip `av_shutdown_session_close_timeouts_
1202    // total` on an otherwise-recoverable session).
1203    let inflight_guard = state.mcp_inflight.enter();
1204    match tokio::spawn(async move {
1205        let _admission_permit = admission_permit;
1206        let _inflight_guard = inflight_guard;
1207        mcp_call_inner(state, headers, body, scope).await
1208    })
1209    .await
1210    {
1211        Ok(response) => response,
1212        Err(join_error) => lifecycle_error(format!("tool call task failed: {join_error}")),
1213    }
1214}
1215
1216/// Complete compensation before the detached MCP owner releases its permit.
1217/// StateStore is synchronous, so network-backed refunds must not run on Tokio.
1218async fn refund_tool_admission(
1219    state: &AppState,
1220    execution: &ToolExecution,
1221    payout_micros: u64,
1222    principal_id: Option<&str>,
1223) -> Result<(), String> {
1224    let store = Arc::clone(&state.store);
1225    let session_id = execution.session_id.clone();
1226    let tool = execution.tool.clone();
1227    let budget = state.config.budget.clone();
1228    let principal = principal_id
1229        .zip(state.config.principal_budget.as_ref())
1230        .map(|(id, spec)| (id.to_owned(), spec.clone()));
1231    tokio::task::spawn_blocking(move || {
1232        av_state::ActionBudget::new(store.as_ref(), &session_id, &budget)
1233            .refund_tool_call(&tool, payout_micros);
1234        if let Some((principal_id, spec)) = principal {
1235            av_state::ActionBudget::for_principal(store.as_ref(), &principal_id, &spec)
1236                .refund_tool_call(&tool, payout_micros);
1237        }
1238    })
1239    .await
1240    .map_err(|_| "tool admission refund task failed".to_owned())
1241}
1242
1243async fn mcp_call_inner(
1244    state: AppState,
1245    headers: HeaderMap,
1246    body: Bytes,
1247    scope: crate::mcp::Scope,
1248) -> Response {
1249    // Streamable HTTP transport rules come first: a refused Origin or an
1250    // unsupported protocol version never reaches authentication.
1251    if let Err(response) = crate::mcp::check_origin(&state, &headers) {
1252        return *response;
1253    }
1254    if let Err(response) = crate::mcp::check_protocol_version(&headers) {
1255        return *response;
1256    }
1257    let mut headers = headers;
1258    // Idempotent execution tracking is needed whenever a call CAN be
1259    // forwarded — through `tool_upstream_url` or any `[[backends]]` entry —
1260    // because the forwarding branch below has no other execution state.
1261    let forwarding_configured = state.config.tool_upstream_url.is_some() || !state.backend_router.is_empty();
1262    let auth_state = state.clone();
1263    let auth_headers = headers.clone();
1264    let authenticated =
1265        match tokio::task::spawn_blocking(move || auth_state.authenticate(&auth_headers)).await {
1266            Ok(Ok(caller)) => caller,
1267            Ok(Err(error)) => {
1268                if let Err(audit_error) = state.audit_authentication_error(&headers, &error) {
1269                    return pipeline_error(audit_error);
1270                }
1271                return pipeline_error(error);
1272            }
1273            Err(error) => return pipeline_error(crate::pipeline::PipelineError::unavailable_source(error)),
1274        };
1275    // An `MCP-Session-Id` names the audited session every call of that
1276    // MCP session belongs to; bind it as `X-AV-Session` for the tool path.
1277    let mcp_session = match crate::mcp::bind_session(&state, &mut headers, &authenticated) {
1278        Ok(session) => session,
1279        Err(response) => return *response,
1280    };
1281    match crate::mcp::answer(
1282        &state,
1283        &headers,
1284        &authenticated,
1285        crate::mcp::classify(&body),
1286        &scope,
1287        mcp_session.as_ref(),
1288    )
1289    .await
1290    {
1291        Ok(Some(response)) => return response,
1292        Ok(None) => {}
1293        Err(error) => {
1294            if let Err(audit_error) = state.audit_authentication_error(&headers, &error) {
1295                return pipeline_error(audit_error);
1296            }
1297            return pipeline_error(error);
1298        }
1299    }
1300    let (execution, unaudited_outcome) = if forwarding_configured {
1301        // Same cheap pre-parse identity gate as the intercept path
1302        // (pipeline::refuse_unauthenticated_tool_call): required
1303        // identity + no Authorization header can never be admitted —
1304        // refuse before `from_request` runs its parse/canonicalize/
1305        // hash work on anonymous bytes.
1306        if let Err(error) = state.refuse_unauthenticated_tool_call(&headers) {
1307            return pipeline_error(error);
1308        }
1309        match ToolExecution::from_request(&state.config.atif_spool_dir, &headers, &body, state.journal_key) {
1310            Ok(mut execution) => {
1311                let required_scope = crate::pipeline::tool_scope(&execution.tool);
1312                let identity = match authenticated.authorize(&state, Some(&required_scope)) {
1313                    Ok(identity) => identity,
1314                    Err(error) => {
1315                        if let Err(audit_error) = state.audit_authentication_error(&headers, &error) {
1316                            return pipeline_error(audit_error);
1317                        }
1318                        return pipeline_error(error);
1319                    }
1320                };
1321                if let Err(error) = execution.bind_principal(&identity) {
1322                    return pipeline_error(error);
1323                }
1324                if let Some(principal) = authenticated.principal_digest() {
1325                    execution.principal_digest = av_core::digest::sha256_hex(
1326                        format!("{}:{principal}", execution.principal_digest).as_bytes(),
1327                    );
1328                }
1329                // The three cached-state arms below used
1330                // to repeat this lookup+authorize block verbatim,
1331                // differing only in the error string, and recomputed
1332                // `tool_scope` per arm. One helper, one scope.
1333                // Boxed Err keeps the closure's Result at pointer size
1334                // (clippy::result_large_err — Response is 128+ bytes).
1335                let authorized_session = |missing_context: &str| -> Result<(), Box<Response>> {
1336                    let Some(session) = state.sessions.get(&execution.session_id) else {
1337                        return Err(Box::new(pipeline_error(
1338                            crate::pipeline::PipelineError::bad_request(format!(
1339                                "unknown session for {missing_context}"
1340                            )),
1341                        )));
1342                    };
1343                    authenticated
1344                        .authorize_session(&state, &session, &required_scope)
1345                        .map_err(|error| Box::new(pipeline_error(error)))
1346                };
1347                match execution.load().await {
1348                    Ok(ToolExecutionState::Completed(outcome)) => {
1349                        if let Err(response) = authorized_session("cached tool result") {
1350                            return *response;
1351                        }
1352                        return outcome.into_response();
1353                    }
1354                    Ok(ToolExecutionState::Unaudited(outcome)) => {
1355                        if let Err(response) = authorized_session("pending tool audit") {
1356                            return *response;
1357                        }
1358                        (Some(execution), Some(outcome))
1359                    }
1360                    Ok(ToolExecutionState::Pending) => {
1361                        if let Err(response) = authorized_session("pending tool execution") {
1362                            return *response;
1363                        }
1364                        return (
1365                            StatusCode::CONFLICT,
1366                            Json(json!({"error": TOOL_OUTCOME_UNCERTAIN})),
1367                        )
1368                            .into_response();
1369                    }
1370                    Ok(ToolExecutionState::Missing) => (Some(execution), None),
1371                    Err(error) if error == TOOL_REQUEST_MISMATCH => {
1372                        // Same session-binding discipline as every other
1373                        // `load()` arm above: without it, any holder of a
1374                        // valid `tool:<name>`-scoped token could probe
1375                        // whether an execution key exists under a DIFFERENT
1376                        // request/principal (409 vs the Missing path) for
1377                        // sessions it is not bound to — a session-content
1378                        // oracle of the same shape as the fixed
1379                        // close/promote existence oracle.
1380                        let Some(session) = state.sessions.get(&execution.session_id) else {
1381                            return pipeline_error(crate::pipeline::PipelineError::bad_request(
1382                                "unknown session for tool execution".to_owned(),
1383                            ));
1384                        };
1385                        if let Err(error) = authenticated.authorize_session(
1386                            &state,
1387                            &session,
1388                            &crate::pipeline::tool_scope(&execution.tool),
1389                        ) {
1390                            return pipeline_error(error);
1391                        }
1392                        return (
1393                            StatusCode::CONFLICT,
1394                            Json(json!({"error": TOOL_REQUEST_MISMATCH})),
1395                        )
1396                            .into_response();
1397                    }
1398                    Err(error) => return lifecycle_error(error),
1399                }
1400            }
1401            Err(error) => return pipeline_error(error),
1402        }
1403    } else {
1404        (None, None)
1405    };
1406    if let (Some(execution), Some(_)) = (execution.as_ref(), unaudited_outcome.as_ref()) {
1407        let _lease = match state.lease_session(&headers) {
1408            Ok(lease) => lease,
1409            Err(error) => return pipeline_error(error),
1410        };
1411        // Serialize with any racing completion of the same execution key
1412        // (fresh forward awaiting its audit, or another replay), then
1413        // re-check the on-disk state: without this, both requests would
1414        // submit a `tool_completed` job for one execution — two durable
1415        // execution records on the audit stream and in the signed chain.
1416        let gate = tool_audit_gate(&state, &execution.key);
1417        let _gate = gate.lock().await;
1418        let outcome = match execution.load().await {
1419            Ok(ToolExecutionState::Completed(outcome)) => return outcome.into_response(),
1420            Ok(ToolExecutionState::Unaudited(outcome)) => outcome,
1421            Ok(_) => return lifecycle_error("tool execution state changed during audit".to_owned()),
1422            Err(error) => return lifecycle_error(error),
1423        };
1424        let completion_permit = match state.worker.try_reserve(&execution.session_id) {
1425            Ok(permit) => permit,
1426            Err(error) => {
1427                return pipeline_error(crate::pipeline::PipelineError::unavailable_source(error));
1428            }
1429        };
1430        let Some(session) = state.sessions.get(&execution.session_id) else {
1431            return lifecycle_error("tool session disappeared".to_owned());
1432        };
1433        return complete_tool_audit(&state, execution, outcome, completion_permit, session).await;
1434    }
1435    // Closes the concurrent-MCP budget
1436    // double-spend by threading the debited `payout_micros` out of
1437    // `ToolVerdict::Allowed` and calling `ActionBudget::refund_
1438    // tool_call` on the lost-claim branch. `refund` is best-effort
1439    // (backend errors are silently absorbed) so a Redis blip on the
1440    // compensation path cannot turn the CONFLICT response into 5xx.
1441    // Credential exchange is an authorization gate: finish it before the
1442    // sandbox charges budget or records an allowed verdict.
1443    let mut exchange_token = None;
1444    let mut preflight_denial = None;
1445    // A per-backend endpoint serves only its own backend's tools. The
1446    // refusal is an audited policy denial, decided before any budget spend.
1447    if let crate::mcp::Scope::Backend(name) = &scope {
1448        if let Ok(request) = av_sandbox::parse_tool_call(&body) {
1449            let routed = state
1450                .backend_router
1451                .resolve(&request.tool)
1452                .map(|backend| backend.name.as_str());
1453            if routed != Some(name.as_str()) {
1454                let code = av_sandbox::DenialCode::NoBackend;
1455                let reason = format!("tool {:?} is not served by backend {name:?}", request.tool);
1456                preflight_denial = Some(ToolVerdict::Blocked {
1457                    tool: request.tool.clone(),
1458                    stage: "policy",
1459                    policy: Some("backend.route".into()),
1460                    denial_code: code,
1461                    response: av_sandbox::rpc::denial_error(request.id.as_ref(), code, &reason),
1462                    reason,
1463                    elapsed_us: 0,
1464                });
1465            }
1466        }
1467    }
1468    // External AuthZEN PDP: consulted only when the local intent map and
1469    // missions permit the call (a local denial is audited by the
1470    // intercept), before any budget is spent. Its denial is an audited
1471    // policy denial; an unreachable PDP fails closed with 503.
1472    if let (None, Some(authzen)) = (&preflight_denial, &state.authzen) {
1473        if let Ok(request) = av_sandbox::parse_tool_call(&body) {
1474            let facts = authenticated.facts();
1475            let now_s = av_core::time::now_ms() / 1000;
1476            if let crate::authz::AuthzDecision::Permit { intent } =
1477                state.pdp.decide_for(&request.tool, now_s, &facts)
1478            {
1479                let missions: Vec<String> = state
1480                    .pdp
1481                    .applicable_missions(&facts)
1482                    .into_iter()
1483                    .map(|mission| mission.id.clone())
1484                    .collect();
1485                let backend = state
1486                    .backend_router
1487                    .resolve(&request.tool)
1488                    .map(|backend| backend.name.as_str());
1489                let session = crate::pipeline::single_header(&headers, crate::pipeline::SESSION_HEADER)
1490                    .ok()
1491                    .flatten()
1492                    .and_then(|value| value.to_str().ok());
1493                let evaluation = crate::authzen::evaluation_request(
1494                    &authenticated,
1495                    &request.tool,
1496                    &intent,
1497                    backend,
1498                    session,
1499                    &missions,
1500                );
1501                match authzen.evaluate(&evaluation).await {
1502                    Ok(crate::authzen::AuthzenDecision::Permit) => {
1503                        state
1504                            .metrics
1505                            .counter(
1506                                "av_authzen_decisions_total{decision=\"permit\"}",
1507                                "External AuthZEN decisions by outcome",
1508                            )
1509                            .inc();
1510                    }
1511                    Ok(crate::authzen::AuthzenDecision::Deny(reason)) => {
1512                        state
1513                            .metrics
1514                            .counter(
1515                                "av_authzen_decisions_total{decision=\"deny\"}",
1516                                "External AuthZEN decisions by outcome",
1517                            )
1518                            .inc();
1519                        let code = av_sandbox::DenialCode::PdpDenied;
1520                        preflight_denial = Some(ToolVerdict::Blocked {
1521                            tool: request.tool.clone(),
1522                            stage: "policy",
1523                            policy: Some("pdp.authzen".into()),
1524                            denial_code: code,
1525                            response: av_sandbox::rpc::denial_error(request.id.as_ref(), code, &reason),
1526                            reason,
1527                            elapsed_us: 0,
1528                        });
1529                    }
1530                    Err(error) => {
1531                        state
1532                            .metrics
1533                            .counter(
1534                                "av_authzen_decisions_total{decision=\"error\"}",
1535                                "External AuthZEN decisions by outcome",
1536                            )
1537                            .inc();
1538                        tracing::warn!(%error, "external policy decision point unavailable; refusing the call");
1539                        return pipeline_error(crate::pipeline::PipelineError::unavailable(
1540                            "the external policy decision point is unavailable; retry shortly".to_owned(),
1541                        ));
1542                    }
1543                }
1544            }
1545        }
1546    }
1547    if let (Some(execution), None) = (&execution, &preflight_denial) {
1548        if let Some(backend) = state
1549            .backend_router
1550            .resolve(&execution.tool)
1551            .filter(|backend| backend.auth_mode == crate::backend::BackendAuthMode::Exchange)
1552        {
1553            match exchange_for_backend(&state, &authenticated, &backend.name, &execution.tool) {
1554                Ok(token) => exchange_token = Some(token),
1555                Err(crate::pipeline::PipelineError::Blocked { context: reason, .. }) => {
1556                    let request = av_sandbox::parse_tool_call(&body).ok();
1557                    let code = av_sandbox::DenialCode::PolicyDenied;
1558                    preflight_denial = Some(ToolVerdict::Blocked {
1559                        tool: execution.tool.clone(),
1560                        stage: "policy",
1561                        policy: Some("backend.exchange".into()),
1562                        denial_code: code,
1563                        response: av_sandbox::rpc::denial_error(
1564                            request.as_ref().and_then(|r| r.id.as_ref()),
1565                            code,
1566                            &reason,
1567                        ),
1568                        reason,
1569                        elapsed_us: 0,
1570                    });
1571                }
1572                Err(error) => return pipeline_error(error),
1573            }
1574        }
1575    }
1576    // Kept for the intent token: the intercept consumes the caller.
1577    let intent_caller = authenticated.clone();
1578    match state
1579        .intercept_authenticated_nonblocking(&headers, &body, authenticated, preflight_denial)
1580        .await
1581    {
1582        Ok(ToolVerdict::Allowed {
1583            tool,
1584            budget_remaining,
1585            elapsed_us,
1586            payout_micros,
1587            principal_id,
1588        }) => {
1589            let resolved_backend = state.backend_router.resolve(&tool);
1590            if let Some(url) = resolved_backend
1591                .map(|b| b.url.as_str())
1592                .or(state.config.tool_upstream_url.as_deref())
1593            {
1594                let execution = match execution {
1595                    Some(execution) => execution,
1596                    None => return lifecycle_error("tool execution state is missing".to_owned()),
1597                };
1598                // The sandbox debited the budget when it returned Allowed —
1599                // on BOTH ledgers when a principal budget is bound. Every
1600                // failure between here and a successful `claim()` must
1601                // refund BOTH, or a saturated worker / close race / lost
1602                // claim drains `max_total_tool_calls` and payout headroom
1603                // (session AND principal — the principal ledger persists
1604                // across sessions, so a leak there never heals) with zero
1605                // tools executed; each client retry re-debits.
1606                let refund_admission =
1607                    || refund_tool_admission(&state, &execution, payout_micros, principal_id.as_deref());
1608                let now_s = av_core::time::now_ms() / 1000;
1609                // The pipeline made (and audited) the PDP decision before
1610                // charging the budget; deciding again here could disagree
1611                // with that audit record, so only the intent is looked up.
1612                let intent = state.pdp.intent_for(&tool);
1613                let audience =
1614                    resolved_backend.map_or(crate::backend::DEFAULT_BACKEND_NAME, |b| b.name.as_str());
1615                let intent_token =
1616                    state
1617                        .pdp
1618                        .mint_intent_token(&tool, &intent, &intent_caller.facts(), audience, now_s);
1619                // The credential this backend (and only this backend)
1620                // receives, resolved before the execution is claimed so a
1621                // missing forwarded token fails without an uncertain claim.
1622                let credential: Option<(axum::http::HeaderName, HeaderValue)> =
1623                    if let Some(token) = &exchange_token {
1624                        crate::mcp::bearer_header(token)
1625                    } else if let Some(backend) = resolved_backend {
1626                        if backend.auth_mode == crate::backend::BackendAuthMode::ForwardToken {
1627                            let Some(header) = crate::mcp::caller_bearer(&headers) else {
1628                                if let Err(error) = refund_admission().await {
1629                                    return lifecycle_error(error);
1630                                }
1631                                return pipeline_error(crate::pipeline::PipelineError::Unauthorized(
1632                                    "this backend requires the caller's bearer token".to_owned(),
1633                                ));
1634                            };
1635                            Some(header)
1636                        } else {
1637                            backend.auth_header.clone()
1638                        }
1639                    } else {
1640                        state
1641                            .tool_auth
1642                            .clone()
1643                            .map(|bearer| (axum::http::header::AUTHORIZATION, bearer))
1644                    };
1645                let intent_header = intent_token
1646                    .as_deref()
1647                    .and_then(|token| HeaderValue::from_str(token).ok())
1648                    .map(|value| (axum::http::HeaderName::from_static("x-av-intent-token"), value));
1649                let _lease = match state.lease_session(&headers) {
1650                    Ok(lease) => lease,
1651                    Err(error) => {
1652                        if let Err(error) = refund_admission().await {
1653                            return lifecycle_error(error);
1654                        }
1655                        return pipeline_error(error);
1656                    }
1657                };
1658                let completion_permit = match state.worker.try_reserve(&execution.session_id) {
1659                    Ok(permit) => permit,
1660                    Err(error) => {
1661                        if let Err(error) = refund_admission().await {
1662                            return lifecycle_error(error);
1663                        }
1664                        return pipeline_error(crate::pipeline::PipelineError::unavailable(
1665                            error.to_string(),
1666                        ));
1667                    }
1668                };
1669                match execution.claim().await {
1670                    Ok(()) => {}
1671                    Err(ClaimError::Race) => {
1672                        // A lost claim race means another in-flight request owns
1673                        // this execution; answer exactly like the Pending state
1674                        // and keep the underlying io detail out of the wire.
1675                        tracing::warn!(
1676                            session = %execution.session_id,
1677                            "concurrent tool execution claim lost; refunding budget"
1678                        );
1679                        // Refund the exact amount debited so
1680                        // the budget counters reflect only admitted work,
1681                        // not the lost race.
1682                        if let Err(error) = refund_admission().await {
1683                            return lifecycle_error(error);
1684                        }
1685                        return (
1686                            StatusCode::CONFLICT,
1687                            Json(json!({"error": TOOL_OUTCOME_UNCERTAIN})),
1688                        )
1689                            .into_response();
1690                    }
1691                    Err(ClaimError::Backend(error)) => {
1692                        // Infrastructure failure — StorageFull, PermissionDenied,
1693                        // etc. The tool provably did not execute (we never
1694                        // reached upstream); refund the admission charge and
1695                        // surface a 503 so the client backs off, instead of a
1696                        // 409 that implies "someone else already ran it".
1697                        tracing::warn!(
1698                            session = %execution.session_id,
1699                            %error,
1700                            "tool execution claim failed; refunding budget"
1701                        );
1702                        if let Err(error) = refund_admission().await {
1703                            return lifecycle_error(error);
1704                        }
1705                        return pipeline_error(crate::pipeline::PipelineError::unavailable(
1706                            "tool execution intent could not be persisted".to_owned(),
1707                        ));
1708                    }
1709                }
1710                // Once claimed, a failure to capture the result must poison
1711                // complete evidence before the session lease is released. A
1712                // successful durable outcome still follows the existing audit
1713                // retry path, including recoverable audited-marker failures.
1714                let mark_incomplete = || {
1715                    if let Some(session) = state.sessions.get(&execution.session_id) {
1716                        session.mark_capture_failed();
1717                    }
1718                };
1719                // A fixed request-local deadline follows the response
1720                // through body reads. Idle reads alone let a slow drip
1721                // occupy this detached task and its admission slot forever.
1722                let tool_timeout = std::time::Duration::from_secs(
1723                    state
1724                        .config
1725                        .mcp_request_timeout_s
1726                        .unwrap_or(crate::config::DEFAULT_MCP_REQUEST_TIMEOUT_S),
1727                );
1728                if let Some(backend) = resolved_backend
1729                    .filter(|backend| backend.transport == crate::config::BackendTransport::Mcp)
1730                {
1731                    // MCP Streamable HTTP: the backend session of this
1732                    // agent session, SSE decoding, and one retry after a
1733                    // session-expiry 404 (see mcp_client.rs).
1734                    let request_id = serde_json::from_slice::<Value>(&body)
1735                        .ok()
1736                        .and_then(|request| request.get("id").cloned())
1737                        .unwrap_or(Value::Null);
1738                    let endpoint = crate::mcp_client::BackendEndpoint {
1739                        url: url.to_owned(),
1740                        auth: credential,
1741                        extra: intent_header.into_iter().collect(),
1742                        deadline: tokio::time::Instant::now() + tool_timeout,
1743                    };
1744                    let exchanged = state
1745                        .mcp_backends
1746                        .request(
1747                            &execution.session_id,
1748                            &backend.name,
1749                            &endpoint,
1750                            &body,
1751                            &request_id,
1752                        )
1753                        .await;
1754                    let outcome = match exchanged {
1755                        Ok((status, bytes, content_type)) => ToolOutcome {
1756                            status,
1757                            body_hex: hex::encode(&bytes),
1758                            content_type,
1759                        },
1760                        Err(crate::mcp_client::McpError::NotDelivered(category)) => {
1761                            // The call itself never reached the backend:
1762                            // release the claim and refund, exactly like a
1763                            // connect failure on the plain transport.
1764                            match execution.release_unexecuted().await {
1765                                Ok(()) => {
1766                                    if let Err(error) = refund_admission().await {
1767                                        return lifecycle_error(error);
1768                                    }
1769                                }
1770                                Err(release_error) => {
1771                                    mark_incomplete();
1772                                    tracing::warn!(
1773                                        session = %execution.session_id,
1774                                        error = %release_error,
1775                                        "failed to release undelivered MCP tool intent; charge kept, key stays claimed"
1776                                    );
1777                                }
1778                            }
1779                            return pipeline_error(crate::pipeline::PipelineError::upstream(format!(
1780                                "forward tool call: {category}"
1781                            )));
1782                        }
1783                        Err(crate::mcp_client::McpError::AfterDelivery(error)) => ToolOutcome {
1784                            status: StatusCode::BAD_GATEWAY.as_u16(),
1785                            body_hex: hex::encode(
1786                                serde_json::to_vec(&json!({
1787                                    "error": format!("tool executed but its response could not be read: {error}"),
1788                                }))
1789                                .unwrap_or_default(),
1790                            ),
1791                            content_type: Some("application/json".to_owned()),
1792                        },
1793                    };
1794                    let gate = tool_audit_gate(&state, &execution.key);
1795                    let _gate = gate.lock().await;
1796                    if let Err(error) = execution.persist(&outcome).await {
1797                        mark_incomplete();
1798                        return lifecycle_error(error);
1799                    }
1800                    let Some(session) = state.sessions.get(&execution.session_id) else {
1801                        return lifecycle_error("tool session disappeared".to_owned());
1802                    };
1803                    return complete_tool_audit(&state, &execution, outcome, completion_permit, session)
1804                        .await;
1805                }
1806                let mut tool_request = state
1807                    .backend_client
1808                    .post(url)
1809                    .timeout(tool_timeout)
1810                    .header(axum::http::header::CONTENT_TYPE, "application/json")
1811                    .body(body);
1812                if let Some((name, value)) = &credential {
1813                    tool_request = tool_request.header(name.clone(), value.clone());
1814                }
1815                if let Some((name, value)) = &intent_header {
1816                    tool_request = tool_request.header(name.clone(), value.clone());
1817                }
1818                match tool_request.send().await {
1819                    Ok(upstream) => {
1820                        let status = upstream.status();
1821                        // Reject non-identity
1822                        // Content-Encoding on the tool relay for the same
1823                        // reason the chat path does (reqwest 0.12 has no
1824                        // decompression features enabled). Without this
1825                        // guard the compressed bytes would be relayed to
1826                        // the MCP client with a plain application/json
1827                        // header, persisted as the durable ToolOutcome
1828                        // replayed on every retry, and journaled via
1829                        // from_utf8_lossy as the ATTESTED tool output —
1830                        // mojibake in the signed audit chain.
1831                        let upstream_headers = upstream.headers();
1832                        let mut refused_encoding: Option<(String, String)> = None;
1833                        'tool_encodings: for value in
1834                            upstream_headers.get_all(axum::http::header::CONTENT_ENCODING)
1835                        {
1836                            let raw = value.to_str().unwrap_or_default();
1837                            for token in raw.split(',') {
1838                                let token = token.trim();
1839                                if token.is_empty() || token.eq_ignore_ascii_case("identity") {
1840                                    continue;
1841                                }
1842                                refused_encoding = Some((token.to_owned(), raw.to_owned()));
1843                                break 'tool_encodings;
1844                            }
1845                        }
1846                        if let Some((token, raw)) = refused_encoding {
1847                            let failure = ToolOutcome {
1848                                status: StatusCode::BAD_GATEWAY.as_u16(),
1849                                body_hex: hex::encode(
1850                                    serde_json::to_vec(&json!({
1851                                        "error": format!(
1852                                            "tool upstream responded with unsupported Content-Encoding token {token:?} (full header: {raw:?})",
1853                                        ),
1854                                    }))
1855                                    .unwrap_or_default(),
1856                                ),
1857                                content_type: Some("application/json".to_owned()),
1858                            };
1859                            let gate = tool_audit_gate(&state, &execution.key);
1860                            let _gate = gate.lock().await;
1861                            if let Err(error) = execution.persist(&failure).await {
1862                                mark_incomplete();
1863                                return lifecycle_error(error);
1864                            }
1865                            let session = match state.sessions.get(&execution.session_id) {
1866                                Some(session) => session,
1867                                None => return lifecycle_error("tool session disappeared".to_owned()),
1868                            };
1869                            return complete_tool_audit(
1870                                &state,
1871                                &execution,
1872                                failure,
1873                                completion_permit,
1874                                session,
1875                            )
1876                            .await;
1877                        }
1878                        match read_limited_tool_response(upstream).await {
1879                            Ok((bytes, content_type)) => {
1880                                let outcome = ToolOutcome {
1881                                    status: status.as_u16(),
1882                                    body_hex: hex::encode(&bytes),
1883                                    content_type,
1884                                };
1885                                // Hold the per-key gate across persist +
1886                                // audit so an Unaudited replay arriving
1887                                // between them serializes behind us and
1888                                // re-checks state instead of double-
1889                                // emitting the completion event.
1890                                let gate = tool_audit_gate(&state, &execution.key);
1891                                let _gate = gate.lock().await;
1892                                if let Err(error) = execution.persist(&outcome).await {
1893                                    mark_incomplete();
1894                                    return lifecycle_error(error);
1895                                }
1896                                let session = match state.sessions.get(&execution.session_id) {
1897                                    Some(session) => session,
1898                                    None => return lifecycle_error("tool session disappeared".to_owned()),
1899                                };
1900                                complete_tool_audit(&state, &execution, outcome, completion_permit, session)
1901                                    .await
1902                            }
1903                            Err(error) => {
1904                                // The upstream received the request and began
1905                                // responding: the tool executed (or may
1906                                // have). Record the failure as the outcome so
1907                                // the execution key resolves and the
1908                                // completion audit is emitted — otherwise the
1909                                // key strands at Pending (every retry gets
1910                                // 409) with an Allowed verdict and no
1911                                // completion on the audit stream, resolvable
1912                                // only by restart-time quarantine. `error`
1913                                // carries only the stable category / size
1914                                // text, never the upstream URL.
1915                                let failure = ToolOutcome {
1916                                    status: StatusCode::BAD_GATEWAY.as_u16(),
1917                                    body_hex: hex::encode(
1918                                        serde_json::to_vec(&json!({
1919                                            "error": format!("tool executed but its response could not be read: {error}"),
1920                                        }))
1921                                        .unwrap_or_default(),
1922                                    ),
1923                                    content_type: Some("application/json".to_owned()),
1924                                };
1925                                let gate = tool_audit_gate(&state, &execution.key);
1926                                let _gate = gate.lock().await;
1927                                if execution.persist(&failure).await.is_ok() {
1928                                    if let Some(session) = state.sessions.get(&execution.session_id) {
1929                                        return complete_tool_audit(
1930                                            &state,
1931                                            &execution,
1932                                            failure,
1933                                            completion_permit,
1934                                            session,
1935                                        )
1936                                        .await;
1937                                    }
1938                                }
1939                                mark_incomplete();
1940                                pipeline_error(crate::pipeline::PipelineError::upstream(format!(
1941                                    "read tool response: {error}"
1942                                )))
1943                            }
1944                        }
1945                    }
1946                    Err(error) => {
1947                        // CWE-209: `reqwest::Error::Display` embeds the request URL —
1948                        // returning it verbatim would leak the operator-configured
1949                        // tool-upstream URL (potentially an internal hostname) to
1950                        // whichever client called `/mcp`. Report a stable category
1951                        // and preserve the raw detail server-side for operators.
1952                        //
1953                        // Also do not log the raw error to
1954                        // tracing::warn — `reqwest::Error::Display` embeds
1955                        // the same URL, and the tracing subscriber flows
1956                        // to Vector -> OTLP -> SIEM per the deploy
1957                        // topology. If OTLP is exported to a third party
1958                        // with a lower trust boundary than the operator,
1959                        // the internal `tool_upstream_url` leaks there.
1960                        // Log structured fields only.
1961                        let category = crate::pipeline::classify_upstream_error(&error);
1962                        tracing::warn!(
1963                            category = category,
1964                            error.status = ?error.status(),
1965                            error.is_timeout = error.is_timeout(),
1966                            error.is_connect = error.is_connect(),
1967                            error.is_request = error.is_request(),
1968                            "tool upstream forwarding failed"
1969                        );
1970                        // A connect failure means the connection never
1971                        // opened: the upstream provably did not execute the
1972                        // tool. Release the claimed execution key and refund
1973                        // the admission charge so the client's retry is
1974                        // clean, instead of stranded at "outcome uncertain"
1975                        // with a burned budget slot. Release-then-refund
1976                        // ordering: a crash between the two leaves the
1977                        // charge in place (conservative) rather than a
1978                        // refunded-but-claimed key. Timeout/read/request
1979                        // failures stay claimed — the tool may have executed.
1980                        if error.is_connect() {
1981                            match execution.release_unexecuted().await {
1982                                Ok(()) => {
1983                                    if let Err(error) = refund_admission().await {
1984                                        return lifecycle_error(error);
1985                                    }
1986                                }
1987                                Err(release_error) => {
1988                                    mark_incomplete();
1989                                    tracing::warn!(
1990                                        session = %execution.session_id,
1991                                        error = %release_error,
1992                                        "failed to release unexecuted tool intent; charge kept, key stays claimed"
1993                                    );
1994                                }
1995                            }
1996                        } else {
1997                            // No headers does not prove the tool never executed.
1998                            // Preserve its durable claim and the charged budget.
1999                            mark_incomplete();
2000                        }
2001                        // Upstream faults must surface as 502 (as the chat
2002                        // relay does), not 500: a 500 blames the harness and
2003                        // misroutes operator alerting/retry policy.
2004                        pipeline_error(crate::pipeline::PipelineError::upstream(format!(
2005                            "forward tool call: {category}"
2006                        )))
2007                    }
2008                }
2009            } else {
2010                // verdict-only mode must
2011                // return a conformant JSON-RPC response — the Blocked
2012                // arm already does (id echo + error object), so a
2013                // client could correlate failures but not successes.
2014                // Echo the request id (string/number/null per spec).
2015                let request_id = serde_json::from_slice::<Value>(&body)
2016                    .ok()
2017                    .and_then(|request| request.get("id").cloned())
2018                    .unwrap_or(Value::Null);
2019                (
2020                    StatusCode::OK,
2021                    Json(json!({
2022                        "jsonrpc": "2.0",
2023                        "id": request_id,
2024                        "result": {
2025                            "allowed": true,
2026                            "tool": tool,
2027                            "budget_remaining": budget_remaining,
2028                            "decision_us": elapsed_us,
2029                        },
2030                    })),
2031                )
2032                    .into_response()
2033            }
2034        }
2035        Ok(ToolVerdict::Blocked { response, stage, .. }) => {
2036            // A request that never parsed
2037            // (stage "parse") gets 400, not 403 — no authorization
2038            // decision was made. Policy/schema/budget refusals keep 403.
2039            let status = if stage == "parse" {
2040                StatusCode::BAD_REQUEST
2041            } else {
2042                StatusCode::FORBIDDEN
2043            };
2044            (status, Json(response)).into_response()
2045        }
2046        Err(error) => pipeline_error(error),
2047        // `ToolVerdict` is `#[non_exhaustive]` (R56 SemVer-hardening).
2048        // A future variant reaching this matcher must fail closed:
2049        // emit a 503 with a stable message rather than silently
2050        // returning success. Mirrors the pipeline-level unhandled-
2051        // verdict handling at pipeline.rs's `check_with_principal`
2052        // matcher, so both entry points to the tool sandbox agree on
2053        // the defensive posture.
2054        Ok(_) => {
2055            tracing::error!(
2056                "unhandled ToolVerdict variant reached routes::tool_forward; failing closed. \
2057                 This is a bug — extend the match to handle the new variant."
2058            );
2059            pipeline_error(crate::pipeline::PipelineError::unavailable(
2060                "tool sandbox produced an unhandled verdict; refusing to proceed".to_owned(),
2061            ))
2062        }
2063    }
2064}
2065
2066const MAX_TOOL_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
2067
2068/// Capture and preserve the upstream tool response's
2069/// `Content-Type` so the MCP client sees exactly what the tool
2070/// upstream sent. Without this, `Bytes: IntoResponse` stamps
2071/// `application/octet-stream`, which strict JSON-RPC 2.0 clients
2072/// (spec: MUST be `application/json`) reject and downstream SIEM
2073/// filters mis-classify as binary. We store the string (not
2074/// HeaderValue) so it round-trips through the on-disk `ToolOutcome`
2075/// journal for cached-outcome replay.
2076async fn read_limited_tool_response(response: reqwest::Response) -> Result<(Bytes, Option<String>), String> {
2077    let content_type = response
2078        .headers()
2079        .get(axum::http::header::CONTENT_TYPE)
2080        .and_then(|value| value.to_str().ok())
2081        .map(str::to_owned);
2082    let mut stream = response.bytes_stream();
2083    let mut body = Vec::new();
2084    while let Some(chunk) = stream.next().await {
2085        // CWE-209: `reqwest::Error::Display` embeds the request URL — leaking
2086        // the operator-configured tool-upstream URL to the client if we
2087        // returned it verbatim. Use the stable classifier and log
2088        // structured fields for operators (never `%error`
2089        // — that renders the URL into any downstream OTLP sink).
2090        let chunk = chunk.map_err(|error| {
2091            let category = crate::pipeline::classify_upstream_error(&error);
2092            tracing::warn!(
2093                category = category,
2094                error.status = ?error.status(),
2095                error.is_timeout = error.is_timeout(),
2096                error.is_connect = error.is_connect(),
2097                error.is_body = error.is_body(),
2098                "tool upstream stream chunk failed"
2099            );
2100            category.to_owned()
2101        })?;
2102        let next = body
2103            .len()
2104            .checked_add(chunk.len())
2105            .ok_or_else(|| "tool response size overflow".to_owned())?;
2106        if next > MAX_TOOL_RESPONSE_BYTES {
2107            return Err(format!("tool response exceeds {MAX_TOOL_RESPONSE_BYTES} bytes"));
2108        }
2109        body.extend_from_slice(&chunk);
2110    }
2111    Ok((Bytes::from(body), content_type))
2112}
2113
2114/// Build a tool-response with the upstream Content-Type
2115/// preserved. Defaults to `application/json` — MCP is JSON-RPC 2.0
2116/// by convention — when the upstream did not set one or set an
2117/// unrepresentable value.
2118fn tool_response(status: StatusCode, bytes: Bytes, content_type: Option<&str>) -> Response {
2119    let mut response = (status, bytes).into_response();
2120    let value = content_type
2121        .and_then(|ct| HeaderValue::from_str(ct).ok())
2122        .unwrap_or_else(|| HeaderValue::from_static("application/json"));
2123    response
2124        .headers_mut()
2125        .insert(axum::http::header::CONTENT_TYPE, value);
2126    // The MCP relay forwards the tool upstream's
2127    // Content-Type verbatim; pin nosniff exactly like the chat relay
2128    // so a rogue tool upstream flipping the type to
2129    // text/html cannot get attacker-echoed bytes rendered by a
2130    // browser-side MIME sniff.
2131    response.headers_mut().insert(
2132        axum::http::HeaderName::from_static("x-content-type-options"),
2133        HeaderValue::from_static("nosniff"),
2134    );
2135    response
2136}
2137
2138async fn complete_tool_audit(
2139    state: &AppState,
2140    execution: &ToolExecution,
2141    outcome: ToolOutcome,
2142    completion_permit: crate::worker::WorkerPermit,
2143    session: Arc<crate::session::Session>,
2144) -> Response {
2145    let status = StatusCode::from_u16(outcome.status).unwrap_or(StatusCode::BAD_GATEWAY);
2146    let bytes = match decode_cached_tool_body(&outcome.body_hex) {
2147        Ok(bytes) => bytes,
2148        Err(response) => return *response,
2149    };
2150    let success = status.is_success();
2151    // If a prior attempt in this process already journaled the completion
2152    // event but failed to write the `.audited` marker, re-attempt only the
2153    // marker — re-submitting the job would put a second execution record
2154    // (with a fresh event uid) on the audit stream and in the signed chain.
2155    let already_emitted = state.tool_audits_emitted.lock().contains(&execution.key);
2156    if !already_emitted {
2157        completion_permit.submit(crate::worker::WorkerJob {
2158            session: Arc::clone(&session),
2159            identity: session.current_identity(),
2160            class: av_events::EventClass::Session,
2161            payload: json!({
2162                "action": "tool_completed",
2163                "execution_key": &execution.key,
2164                "status": status.as_u16(),
2165                "response_sha256": av_core::digest::sha256_hex(&bytes),
2166            }),
2167            text: String::new(),
2168            analyze_loop: false,
2169            status: if success {
2170                av_events::StatusId::Success
2171            } else {
2172                av_events::StatusId::Failure
2173            },
2174            stop_reason: (!success).then_some(StopReason::Other),
2175            native_stop_reason: None,
2176            metrics: av_events::EventMetrics::default(),
2177            cost_usd_micros: 0,
2178            prompt_token_correction: 0,
2179            atif: Some(crate::worker::AtifCapture {
2180                source: av_atif::Source::System,
2181                message: Value::String(String::from_utf8_lossy(&bytes).into_owned()),
2182                reasoning_content: None,
2183                model_name: None,
2184                tool_calls: None,
2185                observation: None,
2186                llm_call_count: None,
2187            }),
2188            response_marker: None,
2189            response_attempt: None,
2190        });
2191        session.wait_for_worker_jobs().await;
2192        if session.capture_failed() {
2193            return lifecycle_error("tool completed but completion audit failed".to_owned());
2194        }
2195        // The event is durably journaled; remember that before attempting
2196        // the marker so a marker failure retries without re-emitting.
2197        state.tool_audits_emitted.lock().insert(execution.key.clone());
2198    }
2199    if let Err(error) = execution.mark_audited().await {
2200        return lifecycle_error(error);
2201    }
2202    state.tool_audits_emitted.lock().remove(&execution.key);
2203    // Preserve the upstream Content-Type so a spec-
2204    // conforming JSON-RPC 2.0 client sees `application/json`
2205    // (default) or whatever the tool upstream declared, not axum's
2206    // `application/octet-stream`.
2207    tool_response(status, bytes, outcome.content_type.as_deref())
2208}
2209
2210#[derive(Clone, serde::Serialize, serde::Deserialize)]
2211struct ToolOutcome {
2212    status: u16,
2213    body_hex: String,
2214    /// MCP client requires the upstream tool response's
2215    /// `Content-Type` to round-trip on cached-outcome replay too, so
2216    /// strict JSON-RPC 2.0 clients see `application/json` on replay
2217    /// just as they did on the fresh forward. `#[serde(default)]` so
2218    /// journals persisted before the field existed decode as `None`
2219    /// (which the response builder will map to the JSON default).
2220    #[serde(default)]
2221    content_type: Option<String>,
2222}
2223
2224impl ToolOutcome {
2225    fn into_response(self) -> Response {
2226        let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::BAD_GATEWAY);
2227        match decode_cached_tool_body(&self.body_hex) {
2228            Ok(body) => tool_response(status, body, self.content_type.as_deref()),
2229            Err(response) => *response,
2230        }
2231    }
2232}
2233
2234/// Decode a MAC-sealed cached tool outcome's `body_hex` payload into
2235/// wire bytes. Shared by both consumers — `ToolOutcome::into_response`
2236/// (the Completed fast-path replay) and `complete_tool_audit` (the
2237/// Unaudited path that runs the completion-audit job) — so a future
2238/// refactor cannot asymmetrically regress the leak-free error
2239/// contract on one branch while preserving it on the other.
2240///
2241/// The body_hex is server-authored + MAC-sealed by
2242/// `ToolExecution::load` (which opens the outcome file through
2243/// `crate::journal::open` under the MAC `TOOL_OUTCOME_DOMAIN`), so a
2244/// decode failure implies either a control-key compromise or an
2245/// on-disk MAC bypass — not attacker-influencable from the wire.
2246/// Even so, echoing the `hex::FromHexError` Display (which includes
2247/// the offending byte offset and hex character) leaks internal
2248/// spool detail into the client-facing 500 body. Return a stable
2249/// string on the wire; log the decode error separately at
2250/// `tracing::error!` so operators still get the diagnostic without
2251/// the client seeing it.
2252///
2253/// Returns `Err(Box<Response>)` on failure rather than `Err(Response)`
2254/// so the discriminant stays small (`clippy::result_large_err`).
2255fn decode_cached_tool_body(body_hex: &str) -> Result<Bytes, Box<Response>> {
2256    match hex::decode(body_hex) {
2257        Ok(bytes) => Ok(Bytes::from(bytes)),
2258        Err(error) => {
2259            tracing::error!(
2260                %error,
2261                "cached tool outcome body_hex failed to decode; \
2262                 possible control-key compromise or on-disk MAC bypass"
2263            );
2264            Err(Box::new(lifecycle_error(
2265                "cached tool outcome is corrupt".to_owned(),
2266            )))
2267        }
2268    }
2269}
2270
2271enum ToolExecutionState {
2272    Missing,
2273    Pending,
2274    Unaudited(ToolOutcome),
2275    Completed(ToolOutcome),
2276}
2277
2278/// Two distinct failure modes for `ToolExecution::claim` that must
2279/// produce different client responses. Previously
2280/// `claim_sync` mapped ALL failures to a `String` and the caller in
2281/// `mcp_call_inner` answered every one with `409 TOOL_OUTCOME_UNCERTAIN`
2282/// — reporting infrastructure faults (StorageFull, PermissionDenied,
2283/// ReadOnlyFilesystem, Interrupted) as if the client had lost a
2284/// concurrent race, so retries looped on the underlying disk fault
2285/// while the client learned nothing useful.
2286#[derive(Debug)]
2287enum ClaimError {
2288    /// The intent file already existed (`create_new` returned
2289    /// `AlreadyExists`). Another in-flight request owns this
2290    /// execution key — the legitimate race case that `409
2291    /// TOOL_OUTCOME_UNCERTAIN` was designed for.
2292    Race,
2293    /// An I/O or MAC-seal failure independent of concurrent claim
2294    /// contention. Maps to a 5xx so operators are alerted to a real
2295    /// infrastructure problem instead of retrying on false uncertainty.
2296    Backend(String),
2297}
2298
2299const TOOL_REQUEST_MISMATCH: &str = "JSON-RPC id is already bound to a different tool request or principal";
2300/// Canonical duplicate-execution refusal, shared by the pre-flight Pending
2301/// state and a lost concurrent claim race so neither reveals more than the
2302/// other (a raw claim error would leak filesystem detail, CWE-209).
2303const TOOL_OUTCOME_UNCERTAIN: &str = "tool execution outcome is uncertain; refusing duplicate execution";
2304
2305/// Read-only lifecycle barrier. Unlike startup recovery, this check never
2306/// renames incomplete evidence belonging to another concurrent live request.
2307pub(crate) async fn ensure_tool_executions_resolved(
2308    spool: &std::path::Path,
2309    session_id: &str,
2310    control_key: &[u8; 32],
2311) -> Result<(), String> {
2312    let directory = spool.join(crate::spool::TOOL_EXECUTIONS);
2313    let session_id = session_id.to_owned();
2314    let control_key = *control_key;
2315    tokio::task::spawn_blocking(move || {
2316        ensure_tool_executions_resolved_with_limit(
2317            &directory,
2318            &session_id,
2319            &control_key,
2320            crate::reconciler::MAX_RECOVERY_DIRENTS_PER_TICK,
2321        )
2322    })
2323    .await
2324    .map_err(|_| "tool evidence lookup task failed".to_owned())?
2325}
2326
2327fn ensure_tool_executions_resolved_with_limit(
2328    directory: &std::path::Path,
2329    session_id: &str,
2330    control_key: &[u8; 32],
2331    max_dirents: usize,
2332) -> Result<(), String> {
2333    let entries = match std::fs::read_dir(directory) {
2334        Ok(entries) => entries,
2335        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2336        Err(_) => return Err("tool evidence directory is unavailable".to_owned()),
2337    };
2338    for (index, entry) in entries.enumerate() {
2339        if index >= max_dirents {
2340            return Err("tool evidence directory exceeds the bounded scan limit".to_owned());
2341        }
2342        let path = entry
2343            .map_err(|_| "tool evidence entry is unavailable".to_owned())?
2344            .path();
2345        let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
2346            continue;
2347        };
2348        let Some(key) = name.strip_suffix(crate::spool::TOOL_INTENT_SUFFIX) else {
2349            continue;
2350        };
2351        let bytes = av_core::fsutil::read_capped(&path, av_core::fsutil::MAX_CONTROL_BYTES)
2352            .map_err(|_| "tool execution intent is unavailable".to_owned())?;
2353        let intent: ToolIntent = crate::journal::open(
2354            control_key,
2355            &format!("{}:{key}", crate::journal::TOOL_INTENT_DOMAIN),
2356            0,
2357            &bytes,
2358        )
2359        .map_err(|_| "tool execution intent could not be authenticated".to_owned())?;
2360        if intent.execution_key != key {
2361            return Err("tool intent path does not match its authenticated key".to_owned());
2362        }
2363        if intent.session_id == session_id && !tool_completion_is_authenticated(directory, key, control_key) {
2364            return Err("tool execution outcome or completion audit is unresolved".to_owned());
2365        }
2366    }
2367    Ok(())
2368}
2369
2370/// A capture-failed close may archive only independently authenticated
2371/// completed tool records. Callers authenticate the intent and session first.
2372/// Missing, corrupt or unreadable evidence remains active for recovery.
2373pub(crate) fn tool_completion_is_authenticated(
2374    directory: &std::path::Path,
2375    key: &str,
2376    control_key: &[u8; 32],
2377) -> bool {
2378    let outcome_path = directory.join(format!("{key}{}", crate::spool::TOOL_OUTCOME_SUFFIX));
2379    let Ok(outcome_bytes) = av_core::fsutil::read_capped(&outcome_path, av_core::fsutil::MAX_ATIF_BYTES)
2380    else {
2381        return false;
2382    };
2383    if crate::journal::open::<ToolOutcome>(
2384        control_key,
2385        &format!("{}:{key}", crate::journal::TOOL_OUTCOME_DOMAIN),
2386        0,
2387        &outcome_bytes,
2388    )
2389    .is_err()
2390    {
2391        return false;
2392    }
2393    let audited_path = directory.join(format!("{key}{}", crate::spool::TOOL_AUDITED_SUFFIX));
2394    let Ok(audited_bytes) = av_core::fsutil::read_capped(&audited_path, av_core::fsutil::MAX_CONTROL_BYTES)
2395    else {
2396        return false;
2397    };
2398    crate::journal::open::<serde_json::Value>(
2399        control_key,
2400        &format!("{}:{key}", crate::journal::TOOL_AUDITED_DOMAIN),
2401        0,
2402        &audited_bytes,
2403    )
2404    .is_ok()
2405}
2406
2407#[derive(PartialEq, serde::Serialize, serde::Deserialize)]
2408struct ToolIntent {
2409    pub(crate) execution_key: String,
2410    pub(crate) session_id: String,
2411    pub(crate) tool: String,
2412    pub(crate) request_digest: String,
2413    pub(crate) principal_digest: String,
2414}
2415
2416#[cfg(test)]
2417pub(crate) async fn ensure_no_unresolved_tool_executions(
2418    spool: &std::path::Path,
2419    control_key: &[u8; 32],
2420) -> Result<(), String> {
2421    let sessions = unresolved_tool_sessions(spool, control_key).await?;
2422    if let Some(session_id) = sessions.into_iter().next() {
2423        Err(format!("session {session_id} has an unresolved tool execution"))
2424    } else {
2425        Ok(())
2426    }
2427}
2428
2429pub(crate) async fn unresolved_tool_sessions(
2430    spool: &std::path::Path,
2431    control_key: &[u8; 32],
2432) -> Result<std::collections::HashSet<String>, String> {
2433    let directory = spool.join(crate::spool::TOOL_EXECUTIONS);
2434    let control_key = *control_key;
2435    tokio::task::spawn_blocking(move || {
2436        let entries = match std::fs::read_dir(&directory) {
2437            Ok(entries) => entries,
2438            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2439                return Ok(std::collections::HashSet::new());
2440            }
2441            Err(error) => return Err(error.to_string()),
2442        };
2443        let mut intent_keys = std::collections::HashSet::new();
2444        let mut unresolved_sessions = std::collections::HashSet::new();
2445        let mut dirents_seen = 0usize;
2446        for entry in entries {
2447            // Dirent cap: every other spool scan bounds its directory
2448            // walk (MAX_RECOVERY_DIRENTS_PER_TICK); this one also
2449            // accumulates one HashSet key per `*.intent.json` name
2450            // BEFORE MAC validation, so a poisoned directory with
2451            // millions of planted names meant unbounded memory + CPU
2452            // on every recovery tick and every session close. Unlike
2453            // the per-tick scans, a TRUNCATED result here is unsafe —
2454            // callers use it to prove "no unresolved executions"
2455            // before finalizing — so fail closed instead of returning
2456            // a partial set: the close refuses, and the operator sees
2457            // the poisoned directory instead of a silently incomplete
2458            // audit.
2459            dirents_seen = dirents_seen.saturating_add(1);
2460            if dirents_seen > crate::reconciler::MAX_RECOVERY_DIRENTS_PER_TICK {
2461                return Err(format!(
2462                    "tool-executions scan aborted: more than {} directory entries; \
2463                     the spool directory is poisoned or leaking files",
2464                    crate::reconciler::MAX_RECOVERY_DIRENTS_PER_TICK
2465                ));
2466            }
2467            let path = entry.map_err(|error| error.to_string())?.path();
2468            let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
2469                continue;
2470            };
2471            let Some(key) = name.strip_suffix(crate::spool::TOOL_INTENT_SUFFIX) else {
2472                continue;
2473            };
2474            intent_keys.insert(key.to_owned());
2475            // A torn intent (crash between create_new and sync_all in
2476            // claim_sync) leaves a 0-byte or partial file at the final
2477            // intent_path. If we bubble the journal::open error via `?`
2478            // the recovery loop aborts on the first torn intent and
2479            // every subsequent tool session stays unrecovered
2480            // indefinitely. Quarantine the file so the reconciler makes
2481            // progress on the rest of the spool; the tool execution
2482            // itself is still uncertain — a subsequent retry by the
2483            // client will get TOOL_OUTCOME_UNCERTAIN and can decide.
2484            // Bounded read: every other recovery scan reads spool files
2485            // through `read_capped` — these three reads were the
2486            // unbounded stragglers, so one planted multi-GB file under
2487            // `tool_executions/` could stall or OOM startup recovery.
2488            // Cap-class failures (oversize, non-regular file) route to
2489            // the same quarantine arm as a torn MAC so the scan keeps
2490            // making progress; only genuine IO faults abort.
2491            let intent_check: Result<ToolIntent, String> =
2492                match av_core::fsutil::read_capped(&path, av_core::fsutil::MAX_CONTROL_BYTES) {
2493                    Ok(intent_bytes) => crate::journal::open(
2494                        &control_key,
2495                        &format!("{}:{key}", crate::journal::TOOL_INTENT_DOMAIN),
2496                        0,
2497                        &intent_bytes,
2498                    ),
2499                    // The session's close legitimately removes its tool-execution
2500                    // files between the directory listing and this read.
2501                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2502                        intent_keys.remove(key);
2503                        continue;
2504                    }
2505                    Err(error)
2506                        if matches!(
2507                            error.kind(),
2508                            std::io::ErrorKind::InvalidData | std::io::ErrorKind::InvalidInput
2509                        ) =>
2510                    {
2511                        Err(error.to_string())
2512                    }
2513                    Err(error) => return Err(error.to_string()),
2514                };
2515            let intent: ToolIntent = match intent_check {
2516                Ok(intent) => intent,
2517                Err(error) => {
2518                    // Build
2519                    // the quarantine name EXPLICITLY. The prior use
2520                    // of `path.with_extension("intent.torn")` on a
2521                    // `<key>.intent.json` path replaces only the
2522                    // `json` component and produces
2523                    // `<key>.intent.intent.torn` — the ugly
2524                    // double-`intent` form. Explicit join gives the
2525                    // clean `<key>.intent.torn` name that
2526                    // remove_tool_executions's defense-in-depth
2527                    // cleanup expects.
2528                    let file_name = path
2529                        .file_stem()
2530                        .and_then(std::ffi::OsStr::to_str)
2531                        .and_then(|stem| stem.strip_suffix(".intent"))
2532                        .map(|key| format!("{key}.intent.torn"));
2533                    let quarantine = match file_name {
2534                        Some(name) => path.with_file_name(name),
2535                        None => path.with_extension("intent.torn"),
2536                    };
2537                    tracing::warn!(
2538                        %error,
2539                        original = %av_core::fsutil::basename(&path),
2540                        quarantine = %av_core::fsutil::basename(&quarantine),
2541                        "torn tool-execution intent quarantined so recovery can proceed"
2542                    );
2543                    if let Err(rename_err) = std::fs::rename(&path, &quarantine) {
2544                        tracing::warn!(%rename_err, "failed to quarantine torn intent — leaving in place");
2545                    }
2546                    // Also quarantine the outcome
2547                    // and audited siblings. Leaving them behind trips
2548                    // the orphan-outcome check below in the same tick
2549                    // and every tick thereafter, permanently stalling
2550                    // recovery — the exact brick this quarantine
2551                    // discipline exists to prevent.
2552                    quarantine_orphan_sibling(&directory, key, crate::spool::TOOL_OUTCOME_SUFFIX, "outcome");
2553                    quarantine_orphan_sibling(&directory, key, crate::spool::TOOL_AUDITED_SUFFIX, "audited");
2554                    intent_keys.remove(key);
2555                    continue;
2556                }
2557            };
2558            if intent.execution_key != key {
2559                return Err("tool intent path does not match authenticated execution key".to_owned());
2560            }
2561            let outcome_path = directory.join(format!("{key}{}", crate::spool::TOOL_OUTCOME_SUFFIX));
2562            // NotFound between the exists-style check and the read: the
2563            // outcome is genuinely absent (or was GC'd with its session's
2564            // close mid-scan) — record unresolved and move on rather than
2565            // aborting the whole recovery pass. Cap: a legitimate outcome
2566            // carries up to MAX_TOOL_RESPONSE_BYTES hex-encoded (~2×), so
2567            // MAX_ATIF_BYTES (64 MiB) clears every legal blob while still
2568            // refusing a planted multi-GB file (routed to quarantine).
2569            let outcome_check: Result<ToolOutcome, String> =
2570                match av_core::fsutil::read_capped(&outcome_path, av_core::fsutil::MAX_ATIF_BYTES) {
2571                    Ok(outcome_bytes) => crate::journal::open(
2572                        &control_key,
2573                        &format!("{}:{key}", crate::journal::TOOL_OUTCOME_DOMAIN),
2574                        0,
2575                        &outcome_bytes,
2576                    ),
2577                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2578                        unresolved_sessions.insert(intent.session_id);
2579                        continue;
2580                    }
2581                    Err(error)
2582                        if matches!(
2583                            error.kind(),
2584                            std::io::ErrorKind::InvalidData | std::io::ErrorKind::InvalidInput
2585                        ) =>
2586                    {
2587                        Err(error.to_string())
2588                    }
2589                    Err(error) => return Err(error.to_string()),
2590                };
2591            let _: ToolOutcome = match outcome_check {
2592                Ok(outcome) => outcome,
2593                Err(error) => {
2594                    // warn+quarantine the unverifiable
2595                    // outcome (and any sibling audited file) instead of
2596                    // aborting the whole recovery pass and bricking the
2597                    // startup.
2598                    tracing::warn!(
2599                        %error,
2600                        key = %key,
2601                        "torn tool-execution outcome quarantined so recovery can proceed"
2602                    );
2603                    quarantine_orphan_sibling(&directory, key, crate::spool::TOOL_OUTCOME_SUFFIX, "outcome");
2604                    quarantine_orphan_sibling(&directory, key, crate::spool::TOOL_AUDITED_SUFFIX, "audited");
2605                    unresolved_sessions.insert(intent.session_id);
2606                    continue;
2607                }
2608            };
2609            let audited_path = directory.join(format!("{key}{}", crate::spool::TOOL_AUDITED_SUFFIX));
2610            let audited_check: Result<serde_json::Value, String> =
2611                match av_core::fsutil::read_capped(&audited_path, av_core::fsutil::MAX_CONTROL_BYTES) {
2612                    Ok(audited_bytes) => crate::journal::open(
2613                        &control_key,
2614                        &format!("{}:{key}", crate::journal::TOOL_AUDITED_DOMAIN),
2615                        0,
2616                        &audited_bytes,
2617                    ),
2618                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2619                        unresolved_sessions.insert(intent.session_id);
2620                        continue;
2621                    }
2622                    Err(error)
2623                        if matches!(
2624                            error.kind(),
2625                            std::io::ErrorKind::InvalidData | std::io::ErrorKind::InvalidInput
2626                        ) =>
2627                    {
2628                        Err(error.to_string())
2629                    }
2630                    Err(error) => return Err(error.to_string()),
2631                };
2632            let _: serde_json::Value = match audited_check {
2633                Ok(value) => value,
2634                Err(error) => {
2635                    tracing::warn!(
2636                        %error,
2637                        key = %key,
2638                        "torn tool-execution audited record quarantined so recovery can proceed"
2639                    );
2640                    quarantine_orphan_sibling(&directory, key, crate::spool::TOOL_AUDITED_SUFFIX, "audited");
2641                    unresolved_sessions.insert(intent.session_id);
2642                    continue;
2643                }
2644            };
2645        }
2646        let mut orphan_dirents = 0usize;
2647        for entry in std::fs::read_dir(&directory).map_err(|error| error.to_string())? {
2648            // Same fail-closed dirent cap as the intent pass above.
2649            orphan_dirents = orphan_dirents.saturating_add(1);
2650            if orphan_dirents > crate::reconciler::MAX_RECOVERY_DIRENTS_PER_TICK {
2651                return Err(format!(
2652                    "tool-executions orphan scan aborted: more than {} directory entries; \
2653                     the spool directory is poisoned or leaking files",
2654                    crate::reconciler::MAX_RECOVERY_DIRENTS_PER_TICK
2655                ));
2656            }
2657            let path = entry.map_err(|error| error.to_string())?.path();
2658            let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
2659                continue;
2660            };
2661            if let Some(key) = name.strip_suffix(crate::spool::TOOL_OUTCOME_SUFFIX) {
2662                if !intent_keys.contains(key) {
2663                    // An orphan outcome must not
2664                    // return Err — that turns the recovery tick into a
2665                    // permanent brick, especially at boot where
2666                    // recover_spooled_sessions is fatal. Rename the
2667                    // outcome (and any sibling audited) to a
2668                    // `.corrupt-*` name so nothing scans them again.
2669                    tracing::warn!(
2670                        key = %key,
2671                        "orphan tool outcome without authenticated intent quarantined"
2672                    );
2673                    quarantine_orphan_sibling(&directory, key, crate::spool::TOOL_OUTCOME_SUFFIX, "outcome");
2674                    quarantine_orphan_sibling(&directory, key, crate::spool::TOOL_AUDITED_SUFFIX, "audited");
2675                }
2676            }
2677        }
2678        Ok(unresolved_sessions)
2679    })
2680    .await
2681    .map_err(|error| error.to_string())?
2682}
2683
2684/// Rename `<directory>/<key><suffix>` to a `.corrupt-<uid>` name so
2685/// nothing else scans it. Silent-ok when the sibling doesn't exist
2686/// (the common case).
2687fn quarantine_orphan_sibling(directory: &std::path::Path, key: &str, suffix: &str, role: &str) {
2688    let path = directory.join(format!("{key}{suffix}"));
2689    if !path.exists() {
2690        return;
2691    }
2692    let uid = av_core::new_event_uid();
2693    let quarantine = directory.join(format!("{key}{suffix}.corrupt-{uid}"));
2694    if let Err(rename_err) = std::fs::rename(&path, &quarantine) {
2695        tracing::warn!(
2696            %rename_err,
2697            role = %role,
2698            original = %av_core::fsutil::basename(&path),
2699            "failed to quarantine orphan tool-execution sibling — leaving in place"
2700        );
2701    } else {
2702        tracing::warn!(
2703            role = %role,
2704            original = %av_core::fsutil::basename(&path),
2705            quarantine = %av_core::fsutil::basename(&quarantine),
2706            "orphan tool-execution sibling quarantined"
2707        );
2708    }
2709}
2710
2711#[derive(Clone)]
2712struct ToolExecution {
2713    key: String,
2714    session_id: String,
2715    tool: String,
2716    request_digest: String,
2717    principal_digest: String,
2718    control_key: [u8; 32],
2719    intent_path: std::path::PathBuf,
2720    outcome_path: std::path::PathBuf,
2721    audited_path: std::path::PathBuf,
2722}
2723
2724impl ToolExecution {
2725    fn from_request(
2726        spool: &str,
2727        headers: &HeaderMap,
2728        body: &[u8],
2729        control_key: [u8; 32],
2730    ) -> Result<Self, crate::pipeline::PipelineError> {
2731        // Refuse duplicate x-av-session headers (see
2732        // `pipeline::single_header` for the rationale — proxies
2733        // sometimes merge duplicates on the wire and log aggregators
2734        // can then observe a comma-joined value that leaks a
2735        // client-desync into audit).
2736        // Distinguish "header not present" from "header value is not
2737        // valid UTF-8 text" — the chat path at pipeline.rs::session_id
2738        // emits the specific "X-AV-Session is not valid text" 400 for
2739        // the non-UTF-8 case, and the tool path historically reported
2740        // the same non-UTF-8 case as "missing" — misleading operator
2741        // triage. Same discipline as the chat path now.
2742        let raw_header = crate::pipeline::single_header(headers, crate::pipeline::SESSION_HEADER)?;
2743        let session_id = match raw_header {
2744            None => {
2745                return Err(crate::pipeline::PipelineError::bad_request(
2746                    "missing x-av-session".to_owned(),
2747                ));
2748            }
2749            Some(value) => value.to_str().map_err(|_| {
2750                crate::pipeline::PipelineError::bad_request("X-AV-Session is not valid text".to_owned())
2751            })?,
2752        };
2753        // Same validation as the pipeline's `session_id`: an id the intercept
2754        // path would reject must not key a tool-execution intent either.
2755        let session_id = av_core::SessionId::parse(session_id)
2756            .map(|id| id.to_string())
2757            .map_err(crate::pipeline::PipelineError::bad_request_source)?;
2758        let call =
2759            av_sandbox::parse_tool_call(body).map_err(crate::pipeline::PipelineError::bad_request_source)?;
2760        // `parse_tool_call` accepts `"id": null` per JSON-RPC 2.0, but the
2761        // forwarded-execution key is `sha256("{session}:{id}")` — a null id
2762        // would collapse every null-id call in a session onto one execution
2763        // key, replaying the first call's cached outcome for later,
2764        // different calls. Require a concrete id here.
2765        let id = call.id.filter(|id| !id.is_null()).ok_or_else(|| {
2766            crate::pipeline::PipelineError::bad_request(
2767                "forwarded tool calls require a non-null JSON-RPC id for idempotency".to_owned(),
2768            )
2769        })?;
2770        let request: Value =
2771            serde_json::from_slice(body).map_err(crate::pipeline::PipelineError::bad_request_source)?;
2772        let canonical = av_receipts::canonicalize(&request)
2773            .map_err(crate::pipeline::PipelineError::bad_request_source)?;
2774        let request_digest = av_core::digest::sha256_hex(canonical.as_bytes());
2775        let key_material = format!("{session_id}:{}", id);
2776        let key = av_core::digest::sha256_hex(key_material.as_bytes());
2777        let directory = std::path::Path::new(spool).join(crate::spool::TOOL_EXECUTIONS);
2778        Ok(Self {
2779            intent_path: directory.join(format!("{key}{}", crate::spool::TOOL_INTENT_SUFFIX)),
2780            outcome_path: directory.join(format!("{key}{}", crate::spool::TOOL_OUTCOME_SUFFIX)),
2781            audited_path: directory.join(format!("{key}{}", crate::spool::TOOL_AUDITED_SUFFIX)),
2782            key,
2783            session_id,
2784            tool: call.tool,
2785            request_digest,
2786            principal_digest: String::new(),
2787            control_key,
2788        })
2789    }
2790
2791    fn bind_principal(
2792        &mut self,
2793        identity: &av_events::AgentIdentity,
2794    ) -> Result<(), crate::pipeline::PipelineError> {
2795        let stable_identity = json!({
2796            "version": identity.version,
2797            "charter": identity.charter,
2798            "instance_uid": identity.instance_uid,
2799        });
2800        let canonical = av_receipts::canonicalize(&stable_identity)
2801            .map_err(crate::pipeline::PipelineError::bad_request_source)?;
2802        self.principal_digest = av_core::digest::sha256_hex(canonical.as_bytes());
2803        Ok(())
2804    }
2805
2806    async fn load(&self) -> Result<ToolExecutionState, String> {
2807        let execution = self.clone();
2808        tokio::task::spawn_blocking(move || execution.load_sync())
2809            .await
2810            .map_err(|error| error.to_string())?
2811    }
2812
2813    fn load_sync(&self) -> Result<ToolExecutionState, String> {
2814        // Read directly instead of `exists()` + `read()`: a concurrent
2815        // `release_unexecuted()` (client abandoned the call) or close-time
2816        // cleanup can unlink between the two, turning a retryable
2817        // Missing/Pending state into an opaque "No such file" lifecycle
2818        // error for the caller.
2819        let intent_bytes = match std::fs::read(&self.intent_path) {
2820            Ok(bytes) => Some(bytes),
2821            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
2822            Err(error) => return Err(error.to_string()),
2823        };
2824        let Some(intent_bytes) = intent_bytes else {
2825            if self.outcome_path.exists() || self.audited_path.exists() {
2826                return Err("tool outcome exists without an authenticated intent".to_owned());
2827            }
2828            return Ok(ToolExecutionState::Missing);
2829        };
2830        let intent: ToolIntent = crate::journal::open(
2831            &self.control_key,
2832            &format!("{}:{}", crate::journal::TOOL_INTENT_DOMAIN, self.key),
2833            0,
2834            &intent_bytes,
2835        )?;
2836        if intent != self.intent() {
2837            return Err(TOOL_REQUEST_MISMATCH.to_owned());
2838        }
2839        let outcome_bytes = match std::fs::read(&self.outcome_path) {
2840            Ok(bytes) => Some(bytes),
2841            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
2842            Err(error) => return Err(error.to_string()),
2843        };
2844        if let Some(outcome_bytes) = outcome_bytes {
2845            let outcome: ToolOutcome = crate::journal::open(
2846                &self.control_key,
2847                &format!("{}:{}", crate::journal::TOOL_OUTCOME_DOMAIN, self.key),
2848                0,
2849                &outcome_bytes,
2850            )?;
2851            let audited = match std::fs::read(&self.audited_path) {
2852                Ok(bytes) => crate::journal::open::<serde_json::Value>(
2853                    &self.control_key,
2854                    &format!("{}:{}", crate::journal::TOOL_AUDITED_DOMAIN, self.key),
2855                    0,
2856                    &bytes,
2857                )
2858                .is_ok(),
2859                Err(_) => false,
2860            };
2861            return if audited {
2862                Ok(ToolExecutionState::Completed(outcome))
2863            } else {
2864                Ok(ToolExecutionState::Unaudited(outcome))
2865            };
2866        }
2867        Ok(ToolExecutionState::Pending)
2868    }
2869
2870    async fn claim(&self) -> Result<(), ClaimError> {
2871        let execution = self.clone();
2872        tokio::task::spawn_blocking(move || execution.claim_sync())
2873            .await
2874            .map_err(|error| ClaimError::Backend(error.to_string()))?
2875    }
2876
2877    /// Delete this execution's intent file after a provably-not-executed
2878    /// forward (connect failure), restoring the key for a clean retry.
2879    /// NotFound is tolerated — the claim was ours, so a missing file only
2880    /// means a prior release already ran.
2881    ///
2882    /// The post-remove directory fsync is best-effort.
2883    /// `remove_file` returning Ok is durable-enough for the release
2884    /// semantics — a crash before the dir fsync makes the intent file
2885    /// resurrect on recovery, at which point `unresolved_tool_sessions`
2886    /// correctly quarantines the session (fail-closed). Previously any
2887    /// fsync failure returned Err and the caller at `mcp_call_inner`'s
2888    /// connect-failure branch skipped the budget refund, leaving the
2889    /// key unclaimed AND the budget debited — the exact bug the
2890    /// release-then-refund ordering was supposed to prevent.
2891    async fn release_unexecuted(&self) -> Result<(), String> {
2892        let execution = self.clone();
2893        tokio::task::spawn_blocking(move || {
2894            match std::fs::remove_file(&execution.intent_path) {
2895                Ok(()) => {}
2896                Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2897                Err(error) => return Err(error.to_string()),
2898            }
2899            if let Some(directory) = execution.intent_path.parent() {
2900                if let Err(error) = std::fs::File::open(directory).and_then(|directory| directory.sync_all())
2901                {
2902                    tracing::warn!(
2903                        path = %av_core::fsutil::basename(&execution.intent_path),
2904                        %error,
2905                        "release_unexecuted: intent removed but directory fsync failed; treating as released"
2906                    );
2907                }
2908            }
2909            Ok(())
2910        })
2911        .await
2912        .map_err(|error| error.to_string())?
2913    }
2914
2915    fn claim_sync(&self) -> Result<(), ClaimError> {
2916        use std::io::Write as _;
2917        let directory = self
2918            .intent_path
2919            .parent()
2920            .ok_or_else(|| ClaimError::Backend("tool execution directory is missing".to_owned()))?;
2921        // Skip the mkdir on the (steady-state) existing
2922        // directory — `create_dir_all` on an existing path still issues
2923        // an EEXIST mkdir syscall on the tool-claim hot path.
2924        if !directory.is_dir() {
2925            // `create_dir_all_synced` (not `create_dir_all`) so the
2926            // first-write path applies the 0o700 dir mode discipline
2927            // — pairs with the 0o600 file mode below so the tool
2928            // intents subtree cannot be enumerated by a co-tenant.
2929            av_core::fsutil::create_dir_all_synced(directory)
2930                .map_err(|error| ClaimError::Backend(error.to_string()))?;
2931        }
2932        // `create_new(true)` is atomic on the underlying filesystem —
2933        // its only reason to return `AlreadyExists` is that another
2934        // in-flight request has already claimed this execution key
2935        // (the sole legitimate race source). Any other IoError kind
2936        // (StorageFull, PermissionDenied, InvalidInput, ReadOnlyFilesystem,
2937        // Interrupted, etc.) is a genuine infrastructure failure and
2938        // must NOT be reported to the client as a lost race — the
2939        // caller would answer 409 TOOL_OUTCOME_UNCERTAIN and retries
2940        // would loop on the same underlying disk fault while the
2941        // client learns nothing useful. Split the two shapes at the
2942        // source so the caller can respond appropriately.
2943        //
2944        // This path deliberately bypasses `write_atomic` (the sibling
2945        // `persist`/`mark_audited` calls go through it) because the
2946        // AlreadyExists→Race distinction is destroyed by the temp+
2947        // rename pattern (a colliding claim would land on a fresh
2948        // temp path with a UUIDv7 suffix). That bypass means we need
2949        // to set the 0o600 mode explicitly here — otherwise this
2950        // intent file (sealed but plaintext session_id, tool name,
2951        // and request digest) inherits the ambient umask, typically
2952        // 0022 → 0o644, and becomes co-tenant readable.
2953        let mut open_options = std::fs::OpenOptions::new();
2954        open_options.write(true).create_new(true);
2955        #[cfg(unix)]
2956        {
2957            use std::os::unix::fs::OpenOptionsExt as _;
2958            open_options.mode(0o600);
2959        }
2960        let mut file = open_options
2961            .open(&self.intent_path)
2962            .map_err(|error| match error.kind() {
2963                std::io::ErrorKind::AlreadyExists => ClaimError::Race,
2964                _ => ClaimError::Backend(format!("tool execution intent unavailable: {error}")),
2965            })?;
2966        let intent = crate::journal::seal(
2967            &self.control_key,
2968            &format!("{}:{}", crate::journal::TOOL_INTENT_DOMAIN, self.key),
2969            0,
2970            &self.intent(),
2971        )
2972        .map_err(ClaimError::Backend)?;
2973        file.write_all(&intent)
2974            .map_err(|error| ClaimError::Backend(error.to_string()))?;
2975        file.sync_all()
2976            .map_err(|error| ClaimError::Backend(error.to_string()))?;
2977        std::fs::File::open(directory)
2978            .and_then(|directory| directory.sync_all())
2979            .map_err(|error| ClaimError::Backend(error.to_string()))
2980    }
2981
2982    async fn persist(&self, outcome: &ToolOutcome) -> Result<(), String> {
2983        let execution = self.clone();
2984        let outcome = outcome.clone();
2985        tokio::task::spawn_blocking(move || {
2986            let sealed = crate::journal::seal(
2987                &execution.control_key,
2988                &format!("{}:{}", crate::journal::TOOL_OUTCOME_DOMAIN, execution.key),
2989                0,
2990                &outcome,
2991            )?;
2992            write_atomic_bytes(&execution.outcome_path, &sealed)
2993        })
2994        .await
2995        .map_err(|error| error.to_string())?
2996    }
2997
2998    async fn mark_audited(&self) -> Result<(), String> {
2999        let execution = self.clone();
3000        tokio::task::spawn_blocking(move || {
3001            let sealed = crate::journal::seal(
3002                &execution.control_key,
3003                &format!("{}:{}", crate::journal::TOOL_AUDITED_DOMAIN, execution.key),
3004                0,
3005                &json!({"audited": true}),
3006            )?;
3007            write_atomic_bytes(&execution.audited_path, &sealed)
3008        })
3009        .await
3010        .map_err(|error| error.to_string())?
3011    }
3012
3013    fn intent(&self) -> ToolIntent {
3014        ToolIntent {
3015            execution_key: self.key.clone(),
3016            session_id: self.session_id.clone(),
3017            tool: self.tool.clone(),
3018            request_digest: self.request_digest.clone(),
3019            principal_digest: self.principal_digest.clone(),
3020        }
3021    }
3022}
3023
3024fn write_atomic_bytes(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
3025    av_core::fsutil::write_atomic(path, bytes).map_err(|error| error.to_string())
3026}
3027
3028async fn lifecycle_caller(
3029    state: &AppState,
3030    headers: &HeaderMap,
3031    scope: &str,
3032) -> Result<crate::pipeline::AuthenticatedCaller, crate::pipeline::PipelineError> {
3033    let state = state.clone();
3034    let headers = headers.clone();
3035    let scope = scope.to_owned();
3036    tokio::task::spawn_blocking(move || {
3037        let caller = state.authenticate(&headers)?;
3038        caller.authorize(&state, Some(&scope))?;
3039        Ok(caller)
3040    })
3041    .await
3042    .map_err(crate::pipeline::PipelineError::unavailable_source)?
3043}
3044
3045async fn close_session(
3046    State(state): State<AppState>,
3047    Path(id): Path<String>,
3048    headers: HeaderMap,
3049) -> Response {
3050    let caller = match lifecycle_caller(&state, &headers, &state.config.session_close_scope).await {
3051        Ok(caller) => caller,
3052        Err(error) => return pipeline_error(error),
3053    };
3054    let Some(session) = state.sessions.get(&id) else {
3055        return (
3056            StatusCode::NOT_FOUND,
3057            Json(openai_error_body(StatusCode::NOT_FOUND, "unknown session")),
3058        )
3059            .into_response();
3060    };
3061    if let Err(error) = caller.authorize_session(&state, &session, &state.config.session_close_scope) {
3062        return pipeline_error(error);
3063    }
3064    match state
3065        .finalizer
3066        .close_session(session, StopReason::SessionClosed)
3067        .await
3068    {
3069        Ok(outcome) => Json(outcome).into_response(),
3070        Err(error) => finalize_error_response(&error),
3071    }
3072}
3073
3074async fn promote_session(
3075    State(state): State<AppState>,
3076    Path(id): Path<String>,
3077    headers: HeaderMap,
3078) -> Response {
3079    let caller = match lifecycle_caller(&state, &headers, &state.config.session_promote_scope).await {
3080        Ok(caller) => caller,
3081        Err(error) => return pipeline_error(error),
3082    };
3083    let Some(session) = state.sessions.get(&id) else {
3084        return (
3085            StatusCode::NOT_FOUND,
3086            Json(openai_error_body(StatusCode::NOT_FOUND, "unknown session")),
3087        )
3088            .into_response();
3089    };
3090    if let Err(error) = caller.authorize_session(&state, &session, &state.config.session_promote_scope) {
3091        return pipeline_error(error);
3092    }
3093    // `promote()` silently drives `close_session_locked`
3094    // on any still-open session. If an operator split the two scopes
3095    // (compliance auditor gets `session:promote`, on-call gets
3096    // `session:close`), a `session:promote`-only bearer could
3097    // otherwise force-close any live agent session by promoting it —
3098    // bypassing `session:close` entirely. When the session is still
3099    // open, additionally require the close scope so promote ⊇ close
3100    // in the scope authority sense.
3101    if !session.is_closed() {
3102        if let Err(error) = caller.authorize_session(&state, &session, &state.config.session_close_scope) {
3103            return pipeline_error(error);
3104        }
3105    }
3106    match state.finalizer.promote(session).await {
3107        Ok(receipt) => Json(receipt).into_response(),
3108        Err(error) => finalize_error_response(&error),
3109    }
3110}
3111
3112/// JWKS endpoint exposing the exchange signer's public key so backends
3113/// can verify signed tokens offline.
3114async fn jwks(State(state): State<AppState>) -> Response {
3115    match &state.token_signer {
3116        Some(signer) => Json(signer.jwks_json()).into_response(),
3117        None => Json(serde_json::json!({"keys": []})).into_response(),
3118    }
3119}
3120
3121/// RFC 8693 token exchange endpoint. Accepts a form-encoded request,
3122/// validates the inbound subject token, narrows scopes to their
3123/// intersection with the requested set, and returns a fresh JWT
3124/// scoped to the target backend audience.
3125async fn token_exchange(State(state): State<AppState>, body: Bytes) -> Response {
3126    crate::token_routes::control_request(state, move |state| async move {
3127        tokio::task::spawn_blocking(move || token_exchange_inner(state, body))
3128            .await
3129            .unwrap_or_else(|_| oauth_unavailable())
3130    })
3131    .await
3132}
3133
3134fn token_exchange_inner(state: AppState, body: Bytes) -> Response {
3135    // RFC 7523 workload assertions have their own grant and their own gate
3136    // (`[workload_identity]`), independent of `token_exchange_enabled`.
3137    let grant_type = serde_urlencoded::from_bytes::<Vec<(String, String)>>(&body)
3138        .ok()
3139        .and_then(|pairs| pairs.into_iter().find(|(key, _)| key == "grant_type"))
3140        .map(|(_, value)| value);
3141    if grant_type.as_deref() == Some(av_identity::JWT_BEARER_GRANT_TYPE) {
3142        return crate::workload::grant(&state, &body);
3143    }
3144    if !state.config.token_exchange_enabled {
3145        return StatusCode::NOT_FOUND.into_response();
3146    }
3147    let validator = match &state.identity {
3148        Some(v) => v,
3149        None => {
3150            return (
3151                StatusCode::INTERNAL_SERVER_ERROR,
3152                Json(oauth_error(
3153                    "server_error",
3154                    "token exchange requires an identity validator",
3155                )),
3156            )
3157                .into_response();
3158        }
3159    };
3160    let req: av_identity::TokenExchangeRequest = match serde_urlencoded::from_bytes(&body) {
3161        Ok(r) => r,
3162        Err(e) => {
3163            return (
3164                StatusCode::BAD_REQUEST,
3165                Json(oauth_error(
3166                    "invalid_request",
3167                    &format!("invalid token exchange request: {e}"),
3168                )),
3169            )
3170                .into_response();
3171        }
3172    };
3173    if req.grant_type != av_identity::TOKEN_EXCHANGE_GRANT_TYPE {
3174        return (
3175            StatusCode::BAD_REQUEST,
3176            Json(oauth_error(
3177                "unsupported_grant_type",
3178                &format!("unsupported grant_type: {:?}", req.grant_type),
3179            )),
3180        )
3181            .into_response();
3182    }
3183    if req.subject_token_type != av_identity::exchange::JWT_TOKEN_TYPE {
3184        return (
3185            StatusCode::BAD_REQUEST,
3186            Json(oauth_error(
3187                "invalid_request",
3188                &format!("unsupported subject_token_type: {:?}", req.subject_token_type),
3189            )),
3190        )
3191            .into_response();
3192    }
3193    state
3194        .metrics
3195        .counter(
3196            "av_identity_validations_total",
3197            "Full inbound identity validations",
3198        )
3199        .inc();
3200    let validated = match validator.validate(&req.subject_token) {
3201        Ok(v) => v,
3202        Err(av_identity::IdentityError::RevocationUnavailable(detail)) => {
3203            tracing::error!(%detail, "identity revocation list unavailable; refusing token exchange");
3204            return oauth_unavailable();
3205        }
3206        Err(e) => {
3207            tracing::warn!(error = %e, "token exchange subject token rejected");
3208            // Same opaque description as the proxy routes: the detailed
3209            // error names configured kids, issuers, and algorithms.
3210            return (
3211                StatusCode::BAD_REQUEST,
3212                Json(oauth_error(
3213                    "invalid_request",
3214                    crate::pipeline::classify_identity_error(&e),
3215                )),
3216            )
3217                .into_response();
3218        }
3219    };
3220    let now_s = av_core::time::now_ms() / 1000;
3221    let ttl = state.config.token_exchange_ttl_s.min(900);
3222    let azp = &validated.claims.instance_uid;
3223    let allowed_audiences: Vec<String> = state.config.backends.iter().map(|b| b.name.clone()).collect();
3224    // `build_exchanged_claims` reads an empty allow-list as "any audience".
3225    // The gateway must sign only for audiences it knows (RFC 8693 §2.2.2),
3226    // so with no backends configured every target is refused.
3227    if allowed_audiences.is_empty() {
3228        return (
3229            StatusCode::BAD_REQUEST,
3230            Json(oauth_error(
3231                "invalid_target",
3232                "no [[backends]] are configured, so no exchange audience is permitted",
3233            )),
3234        )
3235            .into_response();
3236    }
3237    // RFC 8693 §2.1: `actor_token_type` is REQUIRED when `actor_token` is
3238    // present and MUST NOT be sent without it. The actor is validated
3239    // exactly like the subject (signature, revocation, human subject).
3240    let actor = match (req.actor_token.as_deref(), req.actor_token_type.as_deref()) {
3241        (None, None) => None,
3242        (Some(token), Some(av_identity::exchange::JWT_TOKEN_TYPE)) => match validator.validate(token) {
3243            Ok(actor) => Some(actor),
3244            Err(av_identity::IdentityError::RevocationUnavailable(detail)) => {
3245                tracing::error!(%detail, "identity revocation list unavailable; refusing token exchange");
3246                return oauth_unavailable();
3247            }
3248            Err(error) => {
3249                tracing::warn!(%error, "token exchange actor token rejected");
3250                return (
3251                    StatusCode::BAD_REQUEST,
3252                    Json(oauth_error(
3253                        "invalid_request",
3254                        crate::pipeline::classify_identity_error(&error),
3255                    )),
3256                )
3257                    .into_response();
3258            }
3259        },
3260        (Some(_), Some(other)) => {
3261            return (
3262                StatusCode::BAD_REQUEST,
3263                Json(oauth_error(
3264                    "invalid_request",
3265                    &format!("unsupported actor_token_type: {other:?}"),
3266                )),
3267            )
3268                .into_response();
3269        }
3270        _ => {
3271            return (
3272                StatusCode::BAD_REQUEST,
3273                Json(oauth_error(
3274                    "invalid_request",
3275                    "actor_token and actor_token_type must be sent together",
3276                )),
3277            )
3278                .into_response();
3279        }
3280    };
3281    let params = av_identity::ExchangeParams {
3282        subject: &validated.claims,
3283        subject_chain: &validated.chain_tokens,
3284        subject_delegation_depth: validated.total_delegation_depth,
3285        target_audience: &req.audience,
3286        requested_scopes: req.scope.as_deref(),
3287        azp,
3288        now_s,
3289        ttl_s: ttl,
3290        issuer: &state.config.audience,
3291        max_depth: state.config.max_delegation_depth,
3292        allowed_audiences: &allowed_audiences,
3293    };
3294    let actor = actor.as_ref().map(|actor| av_identity::ExchangeActor {
3295        claims: &actor.claims,
3296        chain: &actor.chain_tokens,
3297    });
3298    let exchanged = match av_identity::build_exchanged_claims_with_actor(&params, actor.as_ref()) {
3299        Ok(c) => c,
3300        Err(e) => {
3301            let code = match &e {
3302                av_identity::ExchangeError::EmptyIntersection
3303                | av_identity::ExchangeError::ScopeExceedsSubject(_)
3304                | av_identity::ExchangeError::InvalidScopes => "invalid_scope",
3305                av_identity::ExchangeError::DelegationExceeded { .. }
3306                | av_identity::ExchangeError::ActorNotDirect => "invalid_grant",
3307                av_identity::ExchangeError::TargetNotAllowed(_) => "invalid_target",
3308                _ => "invalid_request",
3309            };
3310            return (StatusCode::BAD_REQUEST, Json(oauth_error(code, &e.to_string()))).into_response();
3311        }
3312    };
3313    let signer = match &state.token_signer {
3314        Some(s) => s,
3315        None => {
3316            return (
3317                StatusCode::INTERNAL_SERVER_ERROR,
3318                Json(oauth_error(
3319                    "server_error",
3320                    "token exchange is enabled but no signing key is configured",
3321                )),
3322            )
3323                .into_response();
3324        }
3325    };
3326    let access_token = match signer.sign(&exchanged) {
3327        Ok(jwt) => jwt,
3328        Err(e) => {
3329            return (
3330                StatusCode::INTERNAL_SERVER_ERROR,
3331                Json(oauth_error("server_error", &format!("token signing failed: {e}"))),
3332            )
3333                .into_response();
3334        }
3335    };
3336    let resp = av_identity::TokenExchangeResponse {
3337        access_token,
3338        issued_token_type: av_identity::exchange::JWT_TOKEN_TYPE.to_owned(),
3339        token_type: "Bearer".to_owned(),
3340        expires_in: exchanged.exp.saturating_sub(now_s),
3341        scope: if exchanged.scopes.is_empty() {
3342            None
3343        } else {
3344            Some(exchanged.scopes.join(" "))
3345        },
3346    };
3347    (
3348        [
3349            (axum::http::header::CACHE_CONTROL, "no-store"),
3350            (axum::http::header::PRAGMA, "no-cache"),
3351        ],
3352        Json(resp),
3353    )
3354        .into_response()
3355}
3356
3357/// Typed status mapping for close/promote failures. Flattening every
3358/// `FinalizeError` to 500 (the old behavior) misclassified deterministic
3359/// fail-closed refusals as server faults: SDKs retry 500s pointlessly
3360/// against a permanently-quarantined session, and operators page on 5xx
3361/// rates for what is working policy (the same rationale as the breaker's
3362/// deliberate 403-not-429 choice, and the tool path's CONFLICT mapping).
3363fn finalize_error_response(error: &crate::reconciler::FinalizeError) -> Response {
3364    use crate::reconciler::FinalizeError;
3365    // Transient I/O (spool disk full, read-only filesystem,
3366    // temporarily unavailable storage) wrapping an Atif or Receipt
3367    // failure gets 503 + Retry-After, mirroring the `/readyz` probe
3368    // policy that already treats ENOSPC/EROFS as transient. Without
3369    // this split the same incident produced `readyz=503` (correct)
3370    // but `close=500` (misclassified as a permanent server bug),
3371    // making SDKs configured with default retry-on-503 stop
3372    // retrying and paging operators for a disk-full infra event.
3373    // Same permanence-split discipline as `Bridge` vs `BridgeConfig`
3374    // — the underlying `io::ErrorKind` is the discriminant.
3375    if error.is_transient_io() {
3376        let mut response = (
3377            StatusCode::SERVICE_UNAVAILABLE,
3378            Json(openai_error_body(
3379                StatusCode::SERVICE_UNAVAILABLE,
3380                &error.to_string(),
3381            )),
3382        )
3383            .into_response();
3384        response
3385            .headers_mut()
3386            .insert(axum::http::header::RETRY_AFTER, HeaderValue::from_static("5"));
3387        return response;
3388    }
3389    let status = match error {
3390        // Deterministic state conflicts: the request conflicts with the
3391        // session's current state and retrying cannot change the outcome
3392        // (quarantined capture, no artifact to promote, promotion already
3393        // in progress).
3394        FinalizeError::CaptureIncomplete | FinalizeError::Promotion(_) => StatusCode::CONFLICT,
3395        // Permanent bridge misconfiguration (unknown
3396        // topic, unresolvable schema) — retrying without operator
3397        // action cannot succeed. Map to 400 so SDKs stop retrying
3398        // and pagers fire on operator config errors, not on
3399        // transient outages.
3400        FinalizeError::BridgeConfig { .. } => StatusCode::BAD_REQUEST,
3401        // Transient infrastructure: the broker is unreachable; the close
3402        // stays pending and a retry (or the reconciler sweep) completes it.
3403        FinalizeError::Bridge { .. } | FinalizeError::ToolEvidence(_) => StatusCode::SERVICE_UNAVAILABLE,
3404        // Everything else is a genuine internal fault.
3405        _ => StatusCode::INTERNAL_SERVER_ERROR,
3406    };
3407    let mut response = (status, Json(openai_error_body(status, &error.to_string()))).into_response();
3408    if status == StatusCode::SERVICE_UNAVAILABLE {
3409        // Same advisory as pipeline_error's retryable class.
3410        response
3411            .headers_mut()
3412            .insert(axum::http::header::RETRY_AFTER, HeaderValue::from_static("5"));
3413    }
3414    response
3415}
3416
3417/// OpenAI-shape error body used by `pipeline_error`,
3418/// `lifecycle_error`, and `finalize_error_response` (R66 F1).
3419/// SDKs dispatch on `error.type` / `error.code`
3420/// (`openai.APIError.code`); the flat `{"error": <string>}` shape the
3421/// close/promote handlers previously emitted left retry classifiers
3422/// dead, silently retrying `409 CaptureIncomplete` and `409
3423/// Promotion(...)` until the SDK's request budget expired against a
3424/// permanently-quarantined session. Centralising the body construction
3425/// here also fixes the earlier five-different-shapes drift across
3426/// sibling routes.
3427fn openai_error_body(status: StatusCode, message: &str) -> serde_json::Value {
3428    let error_type = match status.as_u16() {
3429        400 | 404 | 409 | 413 => "invalid_request_error",
3430        401 => "authentication_error",
3431        403 => "permission_error",
3432        429 => "rate_limit_error",
3433        _ => "api_error",
3434    };
3435    json!({
3436        "error": {
3437            "message": message,
3438            "type": error_type,
3439            "param": null,
3440            "code": status.as_u16(),
3441        }
3442    })
3443}
3444
3445pub(crate) const TOKENS_REVOKED_METRIC: &str = "av_tokens_revoked_total";
3446pub(crate) const TOKENS_REVOKED_HELP: &str = "Successful holder revocations and operator revocation commands";
3447
3448/// Exchange the caller's NHI bearer for a token scoped to one backend and
3449/// one tool. Refusals (no bearer, exchange rules) are 403; faults on the
3450/// gateway's side (missing validator or signer, unreachable revocation
3451/// list, signing failure) are 503 so they never read as the caller's fault.
3452fn exchange_for_backend(
3453    state: &AppState,
3454    caller: &crate::pipeline::AuthenticatedCaller,
3455    target_audience: &str,
3456    tool: &str,
3457) -> Result<String, crate::pipeline::PipelineError> {
3458    exchange_token_for(state, caller, target_audience, &crate::pipeline::tool_scope(tool))
3459}
3460
3461/// Mint a gateway-signed, backend-scoped token for `caller` holding
3462/// `requested_scopes ∩ caller scopes` (space-separated request).
3463pub(crate) fn exchange_token_for(
3464    state: &AppState,
3465    caller: &crate::pipeline::AuthenticatedCaller,
3466    target_audience: &str,
3467    requested_scopes: &str,
3468) -> Result<String, crate::pipeline::PipelineError> {
3469    use crate::pipeline::PipelineError;
3470    let refused = |reason: String| PipelineError::blocked(format!("backend token exchange failed: {reason}"));
3471    let Some(signer) = state.token_signer.as_ref() else {
3472        return Err(PipelineError::unavailable(
3473            "backend token exchange is not configured on this gateway".to_owned(),
3474        ));
3475    };
3476    let validated = caller
3477        .validated
3478        .as_ref()
3479        .ok_or_else(|| refused("no validated caller identity".into()))?;
3480    let now_s = av_core::time::now_ms() / 1000;
3481    let ttl = state.config.token_exchange_ttl_s.min(900);
3482    let allowed_audiences: Vec<String> = state.config.backends.iter().map(|b| b.name.clone()).collect();
3483    let params = av_identity::ExchangeParams {
3484        subject: &validated.claims,
3485        subject_chain: &validated.chain_tokens,
3486        subject_delegation_depth: validated.total_delegation_depth,
3487        target_audience,
3488        requested_scopes: Some(requested_scopes),
3489        azp: &validated.claims.instance_uid,
3490        now_s,
3491        ttl_s: ttl,
3492        issuer: &state.config.audience,
3493        max_depth: state.config.max_delegation_depth,
3494        allowed_audiences: &allowed_audiences,
3495    };
3496    let exchanged = av_identity::build_exchanged_claims(&params).map_err(|e| refused(e.to_string()))?;
3497    signer.sign_with_typ(&exchanged, "av-tool+jwt").map_err(|error| {
3498        tracing::error!(%error, "exchanged token signing failed");
3499        PipelineError::unavailable("backend token exchange could not sign a token".to_owned())
3500    })
3501}
3502
3503fn oauth_error(error: &str, description: &str) -> serde_json::Value {
3504    json!({
3505        "error": error,
3506        "error_description": description,
3507    })
3508}
3509
3510/// 503 for OAuth endpoints when a dependency (the revocation list) is
3511/// down; `Retry-After` tells the client when to try again.
3512fn oauth_unavailable() -> Response {
3513    let mut response = (
3514        StatusCode::SERVICE_UNAVAILABLE,
3515        Json(oauth_error(
3516            "temporarily_unavailable",
3517            "the token revocation list is unavailable; retry shortly",
3518        )),
3519    )
3520        .into_response();
3521    response
3522        .headers_mut()
3523        .insert(axum::http::header::RETRY_AFTER, HeaderValue::from_static("5"));
3524    response
3525}
3526
3527/// `pipeline_error` plus the `X-AV-Session` echo. Once a request is
3528/// admitted, its session is captured to the spool and finalizes into an
3529/// audit artifact even when the upstream call fails — but the id of a
3530/// server-generated session was only revealed on the success path, so
3531/// `avctl receipt-locate` was impossible for exactly the failed calls
3532/// an auditor most wants (the documented contract echoes the id so the
3533/// artifact can be located later). Use this for every error return
3534/// that occurs after admission bound a session.
3535fn pipeline_error_for_session(error: crate::pipeline::PipelineError, session_id: &str) -> Response {
3536    let mut response = pipeline_error(error);
3537    if let Ok(value) = HeaderValue::from_str(session_id) {
3538        response
3539            .headers_mut()
3540            .insert(crate::pipeline::SESSION_HEADER, value);
3541    }
3542    response
3543}
3544
3545pub(crate) fn pipeline_error(error: crate::pipeline::PipelineError) -> Response {
3546    use crate::pipeline::PipelineError;
3547    let close = matches!(error, PipelineError::Abort(_));
3548    let status = error.status();
3549    let mut response = (status, Json(openai_error_body(status, &error.to_string()))).into_response();
3550    if close {
3551        response
3552            .headers_mut()
3553            .insert(axum::http::header::CONNECTION, HeaderValue::from_static("close"));
3554    }
3555    // Drive advisory-header attachment off the numeric HTTP status
3556    // rather than variant identity. `PipelineError` is
3557    // `#[non_exhaustive]`, so a future variant (e.g. `Timeout` → 504,
3558    // `RateLimited` → 429) would silently skip Retry-After under a
3559    // `matches!(error, PipelineError::Unavailable { .. })` chain — exactly
3560    // the SDK-hammering regression this arm exists to prevent. Using
3561    // the status code centralises the semantic once and covers every
3562    // present and future variant that maps to the same class.
3563    match status.as_u16() {
3564        // 429 / 502 / 503 / 504 — retryable per RFC 7231 §7.1.3; the
3565        // header value is deliberately short so the audit-capture
3566        // recovery window is fast, but long enough that clients do not
3567        // hammer during transient failures.
3568        429 | 502 | 503 | 504 => {
3569            response
3570                .headers_mut()
3571                .insert(axum::http::header::RETRY_AFTER, HeaderValue::from_static("5"));
3572        }
3573        // 401 — RFC 7235 §3.1 MUST send `WWW-Authenticate`; RFC 6750 §3
3574        // defines the Bearer challenge shape used with NHI JWTs.
3575        401 => {
3576            response.headers_mut().insert(
3577                axum::http::header::WWW_AUTHENTICATE,
3578                HeaderValue::from_static("Bearer realm=\"agentvisor\", error=\"invalid_token\""),
3579            );
3580        }
3581        _ => {}
3582    }
3583    response
3584}
3585
3586fn lifecycle_error(error: String) -> Response {
3587    // Same OpenAI-shaped body as `pipeline_error` and
3588    // `finalize_error_response` so all four sibling routes agree on
3589    // one error contract. Central via `openai_error_body` (R66 F1).
3590    (
3591        StatusCode::INTERNAL_SERVER_ERROR,
3592        Json(openai_error_body(StatusCode::INTERNAL_SERVER_ERROR, &error)),
3593    )
3594        .into_response()
3595}
3596
3597struct AbortFinalizingStream {
3598    inner: BoxStream<'static, Result<Bytes, std::io::Error>>,
3599    session: Arc<crate::session::Session>,
3600    identity: av_events::AgentIdentity,
3601    response_permit: Option<crate::worker::ResponsePermit>,
3602    /// Handle to submit the response-capture job at end-of-stream. The
3603    /// permit above only holds the `response_capacity` semaphore slot
3604    /// — the mpsc queue slot is re-acquired at submit time via
3605    /// `ResponsePermit::submit(&worker, job)`.
3606    worker: crate::worker::WorkerHandle,
3607    store: Arc<dyn av_state::StateStore>,
3608    budget: av_state::BudgetSpec,
3609    finalizer: crate::reconciler::Finalizer,
3610    _lease: crate::session::SessionLease,
3611    response_marker: Option<String>,
3612    response_attempt_id: String,
3613    response_message: String,
3614    response_reasoning: String,
3615    response_model: Option<String>,
3616    response_finish_reason: Option<String>,
3617    upstream_status: StatusCode,
3618    response_cost_usd_micros: u64,
3619    response_tool_calls: std::collections::BTreeMap<(u64, u64), PartialToolCall>,
3620    response_metrics: av_events::EventMetrics,
3621    charged_completion_tokens: u64,
3622    last_reported_completion_tokens: Option<u64>,
3623    last_reported_prompt_tokens: Option<u64>,
3624    last_reported_cached_tokens: Option<u64>,
3625    last_reported_cost_usd_micros: Option<u64>,
3626    saw_chunk: bool,
3627    /// At-most-once guard for `submit_response_capture`. Set BEFORE the
3628    /// fallible `ResponsePermit::submit` on purpose: if the shard is full
3629    /// the capture event is intentionally dropped (counted in
3630    /// `av_events_dropped_total{stage="response_slot"}`) and MUST NOT be
3631    /// retried by a later call. Do not "fix" the ordering by setting this
3632    /// after the submit — that would reintroduce double-submit attempts
3633    /// against a consumed permit.
3634    capture_attempted: bool,
3635    is_sse: bool,
3636    protocol_buffer: Vec<u8>,
3637    /// Reusable per-frame parse buffer. R62 perf optimisation: the
3638    /// SSE absorb loop previously did `let frame: Vec<u8> = self
3639    /// .protocol_buffer.drain(..end).collect();` per frame — one
3640    /// heap alloc per frame × 50-500 frames per streamed completion.
3641    /// Reusing a single Vec (drained-then-refilled from
3642    /// `protocol_buffer[..end]`) drops that to ONE amortised alloc
3643    /// per stream. The scratch is `mem::take`'d into a local across
3644    /// the absorb loop to satisfy the borrow checker against
3645    /// `self.absorb_frame(&mut self, &str)`.
3646    frame_scratch: Vec<u8>,
3647    pending_output: std::collections::VecDeque<Bytes>,
3648    pending_budget: Option<PendingBudget>,
3649    /// Principal-scoped ledger (id + spec) when `[principal_budget]` is
3650    /// configured. Mid-stream completion tokens must debit BOTH ledgers:
3651    /// the principal ledger is the only one that binds under the
3652    /// session-header-rotation attack, and before it was threaded here
3653    /// every streamed completion token was invisible to it — a rotating
3654    /// caller consumed >10× the principal cap in live testing because
3655    /// only the ~admission prompt heuristic ever landed on the
3656    /// principal counter.
3657    principal_budget: Option<(String, av_state::BudgetSpec)>,
3658    captured_bytes: usize,
3659    /// admission-time prompt-token heuristic, reconciled
3660    /// against the provider's `usage.prompt_tokens` at terminal capture.
3661    billed_prompt_tokens: u64,
3662    /// S3: the provider wire dialect this stream parses with.
3663    provider_adapter: Arc<dyn crate::provider::ProviderAdapter>,
3664    completed: bool,
3665    /// True when the client sent no `X-AV-Session` header and the id
3666    /// was server-generated. The client cannot address this session
3667    /// again (the id only travels back in the response header), so
3668    /// OPENAI-COMPATIBILITY.md promises "the session then closes
3669    /// immediately after the response — one turn, one audit event".
3670    /// Before this flag existed, a completed one-shot lingered OPEN
3671    /// until the idle sweeper (`session_idle_close_s`, default 900 s):
3672    /// a stock SDK caller got no receipt for 15 minutes.
3673    ephemeral: bool,
3674}
3675
3676const MAX_PROVIDER_CAPTURE_BYTES: usize = 16 * 1024 * 1024;
3677const MAX_PROVIDER_FIELD_BYTES: usize = 8 * 1024 * 1024;
3678const MAX_PROVIDER_TOOL_CALLS: usize = 128;
3679
3680impl AbortFinalizingStream {
3681    fn absorb_network_chunk(&mut self, raw: &[u8]) -> Result<u64, String> {
3682        self.saw_chunk |= !raw.is_empty();
3683        self.captured_bytes = self
3684            .captured_bytes
3685            .checked_add(raw.len())
3686            .ok_or_else(|| "provider response size overflow".to_owned())?;
3687        if self.captured_bytes > MAX_PROVIDER_CAPTURE_BYTES {
3688            return Err(format!(
3689                "provider response exceeds {MAX_PROVIDER_CAPTURE_BYTES} capture bytes"
3690            ));
3691        }
3692        self.protocol_buffer.extend_from_slice(raw);
3693        if !self.is_sse {
3694            return Ok(0);
3695        }
3696        // non-success upstream bodies are relayed
3697        // verbatim; do NOT try to parse them as chat-completion SSE
3698        // frames. An `event: error` / `data: {"error":...}` frame
3699        // from a 4xx/5xx stream would otherwise fail the strict
3700        // parser and collapse the true status into a 502. Drop the
3701        // buffered bytes on the floor (the wire body has already
3702        // been relayed to the client via `pending_output`) and
3703        // let flush_protocol_buffer's own guard finalise.
3704        if !self.upstream_status.is_success() {
3705            self.protocol_buffer.clear();
3706            return Ok(0);
3707        }
3708        let mut budget_delta = 0u64;
3709        // R62 perf: reuse `frame_scratch` across iterations instead
3710        // of the prior `let frame: Vec<u8> = self.protocol_buffer
3711        // .drain(..end).collect();` which allocated a fresh Vec per
3712        // frame (50-500 frames per streamed completion). `mem::take`
3713        // moves the buffer into a local so the loop body can `&scratch`
3714        // in a &str while simultaneously calling `&mut self` methods
3715        // (borrow checker won't allow the direct `&self.frame_scratch`
3716        // + `&mut self` pattern). Restored to `self` after the loop
3717        // so subsequent `absorb_network_chunk` calls reuse the same
3718        // capacity.
3719        let mut scratch: Vec<u8> = std::mem::take(&mut self.frame_scratch);
3720        let absorb_result = loop {
3721            let Some(end) = sse_frame_end(&self.protocol_buffer) else {
3722                break Ok(());
3723            };
3724            scratch.clear();
3725            // Safe: `sse_frame_end` returns `end <= self.protocol_buffer.len()`
3726            // by construction (it locates a frame boundary within the buffer).
3727            #[allow(clippy::indexing_slicing)]
3728            scratch.extend_from_slice(&self.protocol_buffer[..end]);
3729            self.protocol_buffer.drain(..end);
3730            let frame = match std::str::from_utf8(&scratch) {
3731                Ok(frame) => frame,
3732                Err(error) => break Err(format!("provider SSE frame is not UTF-8: {error}")),
3733            };
3734            match self.absorb_frame(frame) {
3735                Ok(delta) => match budget_delta.checked_add(delta) {
3736                    Some(total) => budget_delta = total,
3737                    None => break Err("provider completion-token delta overflow".to_owned()),
3738                },
3739                Err(error) => break Err(error),
3740            }
3741        };
3742        self.frame_scratch = scratch;
3743        if let Err(error) = absorb_result {
3744            // A chunk's deltas are only debited AFTER this loop (one
3745            // budget check per network chunk), but `absorb_frame` bumps
3746            // `charged_completion_tokens` per frame. An error on a later
3747            // frame abandons the never-debited `budget_delta` while the
3748            // earlier frames' bumps stay in `charged` — and the terminal
3749            // over-charge refund computes `charged − reported`, so it
3750            // would credit tokens that were never debited (a hostile
3751            // upstream could replenish the session ledger at will). Roll
3752            // `charged` back to exactly what was debited. `absorb_frame`
3753            // errors strictly before charging its own frame, so the
3754            // accumulated `budget_delta` is precisely the undebited part.
3755            self.charged_completion_tokens = self.charged_completion_tokens.saturating_sub(budget_delta);
3756            return Err(error);
3757        }
3758        if self.protocol_buffer.len() > MAX_PROVIDER_FIELD_BYTES {
3759            // Same rollback as above: this Err also means
3760            // `begin_budget_check` never debits the frames this call DID
3761            // drain successfully, so their charges must not survive into
3762            // the terminal refund either.
3763            self.charged_completion_tokens = self.charged_completion_tokens.saturating_sub(budget_delta);
3764            return Err("unterminated provider SSE frame exceeds limit".to_owned());
3765        }
3766        Ok(budget_delta)
3767    }
3768
3769    fn flush_protocol_buffer(&mut self) -> Result<u64, String> {
3770        if self.protocol_buffer.is_empty() {
3771            return Ok(0);
3772        }
3773        // Never fail-closed on a non-success upstream body.
3774        // Providers ship text/plain and HTML error pages on 4xx/5xx
3775        // (OpenAI's Cloudflare frontend returns text/html on 429;
3776        // Anthropic ships 503 HTML from AWS ALBs during backend
3777        // restarts). The strict JSON parse below would fail and be
3778        // mapped to a fresh 502, silently dropping the real status +
3779        // Retry-After header. SDKs treat "502 without Retry-After" as
3780        // an immediate retry candidate, so a rate-limited upstream
3781        // gets hammered instead of backed off. Skip the parse for
3782        // non-success responses; the buffered body still relays
3783        // through the buffered non-SSE branch and the true
3784        // upstream_status is preserved into the response later.
3785        if !self.upstream_status.is_success() {
3786            let _ = std::mem::take(&mut self.protocol_buffer);
3787            return Ok(0);
3788        }
3789        let frame = std::mem::take(&mut self.protocol_buffer);
3790        let frame = std::str::from_utf8(&frame)
3791            .map_err(|error| format!("provider response is not UTF-8: {error}"))?;
3792        self.absorb_frame(frame)
3793    }
3794
3795    fn absorb_frame(&mut self, raw: &str) -> Result<u64, String> {
3796        let Some(parsed) = self.provider_adapter.parse_sse_chunk(raw)? else {
3797            return Ok(0);
3798        };
3799        if self.upstream_status.is_success() && !parsed.has_choices {
3800            return Err("successful provider response has no choices array".to_owned());
3801        }
3802        push_bounded(&mut self.response_message, &parsed.message, "response message")?;
3803        if let Some(reasoning) = parsed.reasoning {
3804            push_bounded(&mut self.response_reasoning, &reasoning, "response reasoning")?;
3805        }
3806        if self.response_model.is_none() {
3807            self.response_model = parsed.model_name;
3808        }
3809        if parsed.finish_reason.is_some() {
3810            self.response_finish_reason = parsed.finish_reason;
3811        }
3812        // Cumulative-cost contract: providers
3813        // that report `cost_usd` are expected to report it CUMULATIVELY
3814        // (OpenRouter-style final/rolling usage frames) — `max()` folds
3815        // that correctly and tolerates repeated identical frames. A
3816        // provider emitting per-chunk INCREMENTAL costs is mostly
3817        // caught by `reject_metric_regression` (any decrease fails the
3818        // stream), but a monotone non-decreasing incremental sequence
3819        // (e.g. equal per-chunk costs) passes the guard and attests
3820        // only the LAST chunk's amount. That shape is indistinguishable
3821        // from legitimate repeated cumulative frames on the wire, so it
3822        // cannot be rejected without breaking conformant providers —
3823        // recorded here as the documented accepted limitation
3824        // (undercount is bounded by the true total; never an
3825        // overcharge).
3826        self.response_cost_usd_micros = self.response_cost_usd_micros.max(parsed.cost_usd_micros);
3827        for delta in parsed.tool_call_deltas {
3828            let key = if delta.complete {
3829                // A complete delta's `index` is frame-local (Gemini part
3830                // position, restarting at 0 every chunk): two whole
3831                // calls arriving in separate chunks would collide at
3832                // (choice, 0) and either concatenate into one corrupt
3833                // attested call or hard-fail a legitimate stream on the
3834                // name-conflict guard below. Allocate the next free
3835                // per-choice slot instead; arrival order is preserved
3836                // by the BTreeMap key.
3837                let next = self
3838                    .response_tool_calls
3839                    .range((delta.choice_index, 0)..=(delta.choice_index, u64::MAX))
3840                    .next_back()
3841                    .map_or(0, |(&(_, index), _)| index.saturating_add(1));
3842                (delta.choice_index, next)
3843            } else {
3844                if delta.index >= MAX_PROVIDER_TOOL_CALLS as u64 {
3845                    return Err(format!(
3846                        "provider tool-call index {} is out of range",
3847                        delta.index
3848                    ));
3849                }
3850                (delta.choice_index, delta.index)
3851            };
3852            if !self.response_tool_calls.contains_key(&key)
3853                && self.response_tool_calls.len() >= MAX_PROVIDER_TOOL_CALLS
3854            {
3855                return Err(format!(
3856                    "provider response exceeds {MAX_PROVIDER_TOOL_CALLS} tool calls"
3857                ));
3858            }
3859            let partial = self.response_tool_calls.entry(key).or_default();
3860            // An upstream that reuses an accumulated (choice, index) slot
3861            // with a DIFFERENT id or name is emitting a malformed stream:
3862            // silently overwriting would merge two distinct calls into one
3863            // synthetic record (last id/name + concatenated arguments) in
3864            // the attested trajectory. Fail the stream instead, mirroring
3865            // `reject_metric_regression`. Late repeats of the SAME values
3866            // remain accepted.
3867            if let (Some(existing), Some(new)) = (partial.id.as_deref(), delta.id.as_deref()) {
3868                if existing != new {
3869                    return Err(format!(
3870                        "provider reused tool-call index {} with conflicting id",
3871                        delta.index
3872                    ));
3873                }
3874            }
3875            if let (Some(existing), Some(new)) = (partial.name.as_deref(), delta.name.as_deref()) {
3876                if existing != new {
3877                    return Err(format!(
3878                        "provider reused tool-call index {} with conflicting name",
3879                        delta.index
3880                    ));
3881                }
3882            }
3883            if delta.id.is_some() {
3884                partial.id = delta.id;
3885            }
3886            if delta.name.is_some() {
3887                partial.name = delta.name;
3888            }
3889            push_bounded(&mut partial.arguments, &delta.arguments, "tool-call arguments")?;
3890        }
3891        if parsed.usage_reported {
3892            reject_metric_regression(
3893                "prompt tokens",
3894                &mut self.last_reported_prompt_tokens,
3895                parsed.metrics.prompt_tokens,
3896            )?;
3897            reject_metric_regression(
3898                "cached tokens",
3899                &mut self.last_reported_cached_tokens,
3900                parsed.metrics.cached_tokens,
3901            )?;
3902        }
3903        if parsed.cost_reported {
3904            reject_metric_regression(
3905                "cost",
3906                &mut self.last_reported_cost_usd_micros,
3907                Some(parsed.cost_usd_micros),
3908            )?;
3909        }
3910        if let Some(prompt) = parsed.metrics.prompt_tokens {
3911            self.response_metrics.prompt_tokens = Some(
3912                self.response_metrics
3913                    .prompt_tokens
3914                    .map_or(prompt, |current| current.max(prompt)),
3915            );
3916        }
3917        if let Some(cached) = parsed.metrics.cached_tokens {
3918            self.response_metrics.cached_tokens = Some(
3919                self.response_metrics
3920                    .cached_tokens
3921                    .map_or(cached, |current| current.max(cached)),
3922            );
3923        }
3924        let reported_completion = parsed.metrics.completion_tokens.unwrap_or(0);
3925        // Cumulative-usage contract (same accepted limitation as the
3926        // `cost_usd` note above): providers reporting `completion_tokens`
3927        // in usage frames are expected to report CUMULATIVELY (OpenAI
3928        // `stream_options.include_usage`, Anthropic message_delta).
3929        // A hostile/broken upstream emitting monotone non-decreasing
3930        // INCREMENTAL counts is indistinguishable from repeated
3931        // cumulative frames on the wire, so only the LAST frame's value
3932        // is charged/attested — an undercount bounded by the true
3933        // total, never an overcharge; the regression guard below still
3934        // fails the stream on any decrease.
3935        let delta = if parsed.usage_reported && parsed.completion_reported {
3936            let previous = self.last_reported_completion_tokens.unwrap_or(0);
3937            if reported_completion < previous {
3938                return Err("provider completion usage regressed".to_owned());
3939            }
3940            self.last_reported_completion_tokens = Some(reported_completion);
3941            let delta = reported_completion.saturating_sub(self.charged_completion_tokens);
3942            // Authoritative usage overrides any prior estimate. The
3943            // regression check above guarantees monotonicity across
3944            // authoritative frames, so `max()` here would only keep a
3945            // stale estimate that happened to exceed the true count —
3946            // and that estimate lands in the attested `completion_tokens`
3947            // recorded on the ATIF step, signed receipt, and session
3948            // totals. Trigger: content chunks land with `"usage": null`
3949            // (OpenAI `stream_options.include_usage`), the parser fills
3950            // in `approx_tokens`, and those estimates get folded into
3951            // `response_metrics.completion_tokens` via the estimate
3952            // branch below; a final cumulative `{"usage":{"completion_tokens":6}}`
3953            // with the accumulated estimate at 10 would attest 10.
3954            self.response_metrics.completion_tokens = Some(reported_completion);
3955            delta
3956        } else {
3957            let total = self
3958                .response_metrics
3959                .completion_tokens
3960                .unwrap_or(0)
3961                .checked_add(reported_completion)
3962                .filter(|total| *total <= av_core::error::JCS_SAFE_MAX)
3963                .ok_or_else(|| "provider completion-token total exceeds JCS-safe bounds".to_owned())?;
3964            self.response_metrics.completion_tokens = Some(total);
3965            reported_completion
3966        };
3967        if delta == 0 {
3968            return Ok(0);
3969        }
3970        self.charged_completion_tokens = self
3971            .charged_completion_tokens
3972            .checked_add(delta)
3973            .ok_or_else(|| "charged completion-token counter overflow".to_owned())?;
3974        Ok(delta)
3975    }
3976
3977    fn begin_budget_check(&mut self, delta: u64, continuation: BudgetContinuation) {
3978        let store = Arc::clone(&self.store);
3979        let session_id = self.session.id.clone();
3980        let budget = self.budget.clone();
3981        let principal = self.principal_budget.clone();
3982        let task = tokio::task::spawn_blocking(move || {
3983            // Principal first, mirroring admission's debit order and its
3984            // refund discipline: if the principal debit lands but the
3985            // session ledger refuses, the principal amount is refunded so
3986            // a refused stream tail is non-consumptive on both ledgers.
3987            if let Some((principal_id, spec)) = principal.as_ref() {
3988                match av_state::ActionBudget::for_principal(store.as_ref(), principal_id, spec)
3989                    .try_tokens(delta)
3990                    .map_err(|error| format!("token budget backend failed closed (principal): {error}"))?
3991                {
3992                    av_state::BudgetDecision::Refused { limit, cap } => {
3993                        return Ok(av_state::BudgetDecision::Refused {
3994                            limit: format!("principal.{limit}"),
3995                            cap,
3996                        });
3997                    }
3998                    av_state::BudgetDecision::Allowed { .. } => {}
3999                }
4000            }
4001            // Mirror admission's backend-ERROR arm too
4002            // (`prepare_chat`): a session-ledger backend failure after
4003            // the principal debit landed must also refund, or every
4004            // transient store blip mid-stream leaks the delta on the
4005            // principal ledger — which persists across sessions and
4006            // never heals.
4007            let decision =
4008                match av_state::ActionBudget::new(store.as_ref(), &session_id, &budget).try_tokens(delta) {
4009                    Ok(decision) => decision,
4010                    Err(error) => {
4011                        if let Some((principal_id, spec)) = principal.as_ref() {
4012                            av_state::ActionBudget::for_principal(store.as_ref(), principal_id, spec)
4013                                .refund_tokens(delta);
4014                        }
4015                        return Err(format!("token budget backend failed closed: {error}"));
4016                    }
4017                };
4018            if matches!(decision, av_state::BudgetDecision::Refused { .. }) {
4019                if let Some((principal_id, spec)) = principal.as_ref() {
4020                    av_state::ActionBudget::for_principal(store.as_ref(), principal_id, spec)
4021                        .refund_tokens(delta);
4022                }
4023            }
4024            Ok(decision)
4025        });
4026        self.pending_budget = Some(PendingBudget {
4027            task,
4028            continuation,
4029            delta,
4030        });
4031    }
4032
4033    fn submit_response_capture(
4034        &mut self,
4035        failure: Option<(av_events::StopReason, String)>,
4036    ) -> Result<(), crate::worker::SubmitError> {
4037        if self.capture_attempted {
4038            return Ok(());
4039        }
4040        self.capture_attempted = true;
4041        let reasoning =
4042            (!self.response_reasoning.is_empty()).then(|| std::mem::take(&mut self.response_reasoning));
4043        // Materialize the tool-call list up front so we can fall back to
4044        // its content when the response body has neither an assistant
4045        // message nor reasoning. Without this, tool-only
4046        // responses (a legitimate agent mode: "return only tool_calls,
4047        // no assistant text") collapsed to an all-zero embedding at
4048        // `av_loopdetect::embed`, which the breaker treats as a hostile
4049        // duplicate and grows the loop streak on — so a healthy agent
4050        // that just happened to be tool-driven would get flagged as
4051        // looping after the min-tokens threshold.
4052        let tool_calls: Vec<av_atif::ToolCall> = std::mem::take(&mut self.response_tool_calls)
4053            .into_values()
4054            .map(|partial| {
4055                let arguments = normalize_tool_call_arguments(&partial.arguments);
4056                av_atif::ToolCall {
4057                    tool_call_id: partial.id.unwrap_or_else(av_core::new_event_uid),
4058                    function_name: partial.name.unwrap_or_else(|| "unknown".to_owned()),
4059                    arguments,
4060                    extra: None,
4061                }
4062            })
4063            .collect();
4064        let analysis_text = if let Some(text) = reasoning.clone() {
4065            text
4066        } else if !self.response_message.is_empty() {
4067            self.response_message.clone()
4068        } else if !tool_calls.is_empty() {
4069            tool_calls
4070                .iter()
4071                .map(|tc| format!("{}({})", tc.function_name, tc.arguments))
4072                .collect::<Vec<_>>()
4073                .join(" ")
4074        } else {
4075            String::new()
4076        };
4077        // Skip loop analysis when the response is genuinely empty (no
4078        // reasoning, no message, no tool calls) — a zero-vector embed
4079        // would otherwise poison the breaker's duplicate detection.
4080        let analyze_loop = !analysis_text.is_empty();
4081        let native_finish_reason = self.response_finish_reason.clone();
4082        // The failure arm records the CALLER's stop-reason verdict:
4083        // BudgetExceeded only for the genuine mid-stream cap refusal,
4084        // Other for everything else (network cuts, garbled frames,
4085        // quota-backend outages, client disconnects). Hard-coding
4086        // BudgetExceeded here previously attested every one of those
4087        // as an enforcement stop.
4088        let (class, status, stop_reason, payload) = if let Some((failure_reason, reason)) = failure {
4089            (
4090                av_events::EventClass::StopReason,
4091                av_events::StatusId::Failure,
4092                Some(failure_reason),
4093                json!({"reason": reason, "direction": "upstream_response"}),
4094            )
4095        } else if !self.upstream_status.is_success() {
4096            (
4097                av_events::EventClass::StopReason,
4098                av_events::StatusId::Failure,
4099                Some(av_events::StopReason::Other),
4100                json!({
4101                    "direction": "upstream_response",
4102                    "http_status": self.upstream_status.as_u16(),
4103                }),
4104            )
4105        } else if let Some(native) = &native_finish_reason {
4106            // Cross-provider stop-reason normalization for the tool-use
4107            // shape. OpenAI emits `finish_reason: "tool_calls"` and
4108            // Anthropic emits `stop_reason: "tool_use"` when the model
4109            // decides to invoke tools; both map cleanly to
4110            // StopReason::ToolUse. Gemini does NOT: it emits
4111            // `finishReason: STOP` and expects the client to notice
4112            // `functionCall` parts in the candidate content. Left un-
4113            // normalized, a receipt attests StopReason::Stop for the
4114            // Gemini flow — an auditor grouping sessions by stop_reason
4115            // would count Gemini tool sessions as clean stops and
4116            // OpenAI/Anthropic tool sessions as tool_use, corrupting
4117            // any cross-provider tool-invocation analytics. This is the
4118            // adapter-leak `ProviderAdapter` exists to prevent. Verified
4119            // live pre-fix: a Gemini stream with a functionCall part
4120            // followed by finishReason:STOP journalled stop_reason_id=1.
4121            let mapped = map_finish_reason(native);
4122            let normalized = if mapped == av_events::StopReason::Stop && !tool_calls.is_empty() {
4123                av_events::StopReason::ToolUse
4124            } else {
4125                mapped
4126            };
4127            (
4128                av_events::EventClass::StopReason,
4129                av_events::StatusId::Success,
4130                Some(normalized),
4131                json!({
4132                    "direction": "upstream_response",
4133                    "finish_reason": native,
4134                    "http_status": self.upstream_status.as_u16(),
4135                }),
4136            )
4137        } else if self.is_sse {
4138            // An SSE stream that
4139            // ended without ANY finish_reason chunk is a truncated
4140            // generation — provider crash, LB idle-timeout, worker
4141            // OOM. OpenAI-protocol streams always carry a
4142            // finish_reason chunk before [DONE], so its absence must
4143            // not be attested as a complete success: a compliance
4144            // reviewer could not otherwise distinguish a truncated
4145            // response from a finished one (previously this shape
4146            // recorded StatusId::Success with no stop reason — and a
4147            // zero-byte 200 stream looked identical to a real one).
4148            (
4149                av_events::EventClass::StopReason,
4150                av_events::StatusId::Unknown,
4151                Some(av_events::StopReason::Other),
4152                json!({
4153                    "direction": "upstream_response",
4154                    "http_status": self.upstream_status.as_u16(),
4155                    "truncated": true,
4156                    "reason": "stream ended without a finish_reason chunk",
4157                }),
4158            )
4159        } else if self.response_message.is_empty()
4160            && self.response_reasoning.is_empty()
4161            && tool_calls.is_empty()
4162        {
4163            // Non-SSE 200 whose body carried NO finish_reason and NO
4164            // content — e.g. a bare `{"choices":[]}` (or `{}`-shaped)
4165            // body from a broken shim. Every compliant non-streaming
4166            // completion carries choices[0].finish_reason, so this is
4167            // not attestable as a clean success: previously it recorded
4168            // StatusId::Success with no stop reason, making an
4169            // empty-response failure indistinguishable from a real
4170            // completion in the signed audit trail (the SSE sibling
4171            // branch above closed the same hole for streams).
4172            (
4173                av_events::EventClass::StopReason,
4174                av_events::StatusId::Unknown,
4175                Some(av_events::StopReason::Other),
4176                json!({
4177                    "direction": "upstream_response",
4178                    "http_status": self.upstream_status.as_u16(),
4179                    "truncated": true,
4180                    "reason": "response carried no choices, content, or finish_reason",
4181                }),
4182            )
4183        } else {
4184            (
4185                av_events::EventClass::Session,
4186                av_events::StatusId::Success,
4187                None,
4188                json!({
4189                    "direction": "upstream_response",
4190                    "http_status": self.upstream_status.as_u16(),
4191                }),
4192            )
4193        };
4194        // Enforcement side of the correction: refund the heuristic
4195        // over-charge to the session ledger — an SDK retry loop on a
4196        // CJK-heavy conversation otherwise drains max_tokens 3-4×
4197        // faster than the provider actually bills. Only the
4198        // over-charge direction is compensated (provider < billed);
4199        // an under-estimate conservatively stands (charging more
4200        // mid-stream could turn a delivered response into a
4201        // retroactive refusal). The principal ledger keeps the
4202        // conservative debit — its caps are coarse anti-abuse bounds,
4203        // not billing.
4204        if let Some(provider) = self.response_metrics.prompt_tokens {
4205            let over_charge = self.billed_prompt_tokens.saturating_sub(provider);
4206            if over_charge > 0 {
4207                av_state::ActionBudget::new(self.store.as_ref(), &self.session.id, &self.budget)
4208                    .refund_tokens(over_charge);
4209            }
4210        }
4211        // Same correction for the completion side: per-chunk estimates
4212        // (content frames with `"usage": null`) debit the ledger as the
4213        // stream progresses, and an authoritative final usage frame LOWER
4214        // than the accumulated estimate leaves `charged_completion_tokens`
4215        // above the attested count with no refund path — the over-debit
4216        // outlives the response and starves the session's remaining
4217        // budget. Only the over-charge direction is compensated,
4218        // mirroring the prompt-side rationale above.
4219        if let Some(provider) = self.response_metrics.completion_tokens {
4220            let over_charge = self.charged_completion_tokens.saturating_sub(provider);
4221            if over_charge > 0 {
4222                av_state::ActionBudget::new(self.store.as_ref(), &self.session.id, &self.budget)
4223                    .refund_tokens(over_charge);
4224            }
4225        }
4226        let permit = self
4227            .response_permit
4228            .take()
4229            .ok_or(crate::worker::SubmitError::Closed)?;
4230        permit.submit(
4231            &self.worker,
4232            crate::worker::WorkerJob {
4233                session: Arc::clone(&self.session),
4234                identity: self.identity.clone(),
4235                class,
4236                payload,
4237                text: analysis_text,
4238                analyze_loop,
4239                status,
4240                stop_reason,
4241                native_stop_reason: native_finish_reason,
4242                metrics: self.response_metrics,
4243                cost_usd_micros: self.response_cost_usd_micros,
4244                // Reconcile the admission-time heuristic
4245                // against the provider's reported usage. The correction
4246                // rides the terminal record so ALL folds (live + both
4247                // recovery paths, via the unified
4248                // ActiveJournalRecord fold) attest the provider-true
4249                // prompt count; CJK-heavy workloads previously
4250                // over-reported 3-4× into the signed receipt.
4251                prompt_token_correction: self.response_metrics.prompt_tokens.map_or(0i64, |provider| {
4252                    let provider = i64::try_from(provider).unwrap_or(i64::MAX);
4253                    let billed = i64::try_from(self.billed_prompt_tokens).unwrap_or(i64::MAX);
4254                    provider.saturating_sub(billed)
4255                }),
4256                atif: Some(crate::worker::AtifCapture {
4257                    source: av_atif::Source::Agent,
4258                    message: Value::String(std::mem::take(&mut self.response_message)),
4259                    reasoning_content: reasoning,
4260                    model_name: self.response_model.take(),
4261                    tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
4262                    observation: None,
4263                    llm_call_count: Some(1),
4264                }),
4265                response_marker: self.response_marker.take(),
4266                response_attempt: Some(crate::worker::ResponseAttempt {
4267                    id: self.response_attempt_id.clone(),
4268                    terminal: true,
4269                }),
4270            },
4271        )?;
4272        Ok(())
4273    }
4274}
4275
4276enum BudgetContinuation {
4277    Emit(Bytes),
4278    ContinueNonSse,
4279    FinishSse,
4280    FinishNonSse,
4281}
4282
4283struct PendingBudget {
4284    task: tokio::task::JoinHandle<Result<av_state::BudgetDecision, String>>,
4285    continuation: BudgetContinuation,
4286    /// The completion-token delta this check would debit. `absorb_frame`
4287    /// already folded it into `charged_completion_tokens`, but the debit
4288    /// only lands when the task resolves `Allowed` — every other outcome
4289    /// (mid-stream refusal, backend outage, drop-abort) must roll the
4290    /// delta back out of `charged`, or the terminal over-charge refund
4291    /// (`charged − reported`) credits tokens that were never debited
4292    /// (same invariant as the drain-loop rollback in
4293    /// `drain_protocol_frames`).
4294    delta: u64,
4295}
4296
4297#[derive(Default)]
4298struct PartialToolCall {
4299    id: Option<String>,
4300    name: Option<String>,
4301    arguments: String,
4302}
4303
4304#[derive(Debug)]
4305pub(crate) struct ProviderToolCallDelta {
4306    /// The `choices[].index` this delta belongs to. Multi-choice
4307    /// (`n > 1`) responses reuse tool-call index 0 in every choice, so
4308    /// reassembly must key on (choice, tool index) or distinct calls
4309    /// merge into one corrupt audit record. (Anthropic has no choices;
4310    /// its adapter always uses 0.)
4311    pub(crate) choice_index: u64,
4312    /// Stream-global tool-call slot within the choice for incremental
4313    /// dialects (OpenAI `tool_calls[].index`, Anthropic content-block
4314    /// index). For `complete` deltas this is only the part's position
4315    /// within the CURRENT frame and is NOT stable across frames —
4316    /// `absorb_frame` must ignore it and allocate a fresh slot.
4317    pub(crate) index: u64,
4318    /// True when the delta carries an entire tool call (Gemini
4319    /// `functionCall` parts arrive whole, never split across frames).
4320    /// Complete deltas never need cross-frame accumulation keyed on
4321    /// `index`; merging them by frame-local position collides distinct
4322    /// calls emitted in separate chunks.
4323    pub(crate) complete: bool,
4324    pub(crate) id: Option<String>,
4325    pub(crate) name: Option<String>,
4326    pub(crate) arguments: String,
4327}
4328
4329#[derive(Debug)]
4330pub(crate) struct ParsedProviderChunk {
4331    pub(crate) message: String,
4332    pub(crate) reasoning: Option<String>,
4333    pub(crate) model_name: Option<String>,
4334    pub(crate) metrics: av_events::EventMetrics,
4335    pub(crate) usage_reported: bool,
4336    /// True only when the provider itself supplied `completion_tokens`
4337    /// (as opposed to the per-chunk heuristic estimate filled in below).
4338    /// `absorb_frame`'s cumulative-usage branch must key on this, not on
4339    /// `usage_reported` alone: a usage object without a completion count
4340    /// (`{"prompt_tokens": 7}` or `"completion_tokens": null`) would
4341    /// otherwise feed a non-cumulative estimate into cumulative math.
4342    pub(crate) completion_reported: bool,
4343    pub(crate) finish_reason: Option<String>,
4344    pub(crate) cost_usd_micros: u64,
4345    pub(crate) cost_reported: bool,
4346    pub(crate) has_choices: bool,
4347    pub(crate) tool_call_deltas: Vec<ProviderToolCallDelta>,
4348}
4349
4350fn reject_metric_regression(
4351    field: &str,
4352    previous: &mut Option<u64>,
4353    current: Option<u64>,
4354) -> Result<(), String> {
4355    let Some(current) = current else {
4356        return Ok(());
4357    };
4358    if previous.is_some_and(|previous| current < previous) {
4359        return Err(format!("provider {field} regressed"));
4360    }
4361    *previous = Some(current);
4362    Ok(())
4363}
4364
4365fn push_bounded(target: &mut String, value: &str, field: &str) -> Result<(), String> {
4366    let size = target
4367        .len()
4368        .checked_add(value.len())
4369        .ok_or_else(|| format!("{field} size overflow"))?;
4370    if size > MAX_PROVIDER_FIELD_BYTES {
4371        return Err(format!("{field} exceeds {MAX_PROVIDER_FIELD_BYTES} bytes"));
4372    }
4373    target.push_str(value);
4374    Ok(())
4375}
4376
4377/// Fuzz-only shim over the internal `parse_provider_chunk` — accepts
4378/// arbitrary bytes and returns nothing (the fuzz harness just cares
4379/// that the parser is total under Unicode / SSE malformation). Not
4380/// part of the stable API; see [`crate::fuzz`] for the documented
4381/// re-export path.
4382#[doc(hidden)]
4383pub fn __fuzz_parse_provider_chunk(raw: &[u8]) {
4384    if let Ok(s) = std::str::from_utf8(raw) {
4385        let _ = parse_provider_chunk(s);
4386    }
4387}
4388
4389/// Fuzz-only shim over the internal `sse_frame_end` framing scanner.
4390/// See [`__fuzz_parse_provider_chunk`] for rationale.
4391#[doc(hidden)]
4392pub fn __fuzz_sse_frame_end(raw: &[u8]) {
4393    let _ = sse_frame_end(raw);
4394}
4395
4396fn sse_frame_end(buffer: &[u8]) -> Option<usize> {
4397    let mut line_start = 0usize;
4398    let mut index = 0usize;
4399    while index < buffer.len() {
4400        let byte = buffer.get(index).copied()?;
4401        let newline = match byte {
4402            b'\r' if buffer.get(index + 1) == Some(&b'\n') => 2,
4403            b'\r' if buffer.get(index + 1).is_none() => return None,
4404            b'\r' | b'\n' => 1,
4405            _ => {
4406                index += 1;
4407                continue;
4408            }
4409        };
4410        if index == line_start {
4411            return Some(index + newline);
4412        }
4413        index += newline;
4414        line_start = index;
4415    }
4416    None
4417}
4418
4419impl AbortFinalizingStream {
4420    /// Fail-closed relay abort: the client connection is severed (the
4421    /// status line already went out, so a clean error response is no
4422    /// longer possible). Without this log the reason would vanish into
4423    /// an io::Error that hyper discards — leaving "empty reply from
4424    /// server" as the only symptom of e.g. an upstream returning HTML.
4425    fn abort_error(&self, reason: String) -> std::io::Error {
4426        tracing::warn!(
4427            session = %self.session.id,
4428            %reason,
4429            "aborting client response; upstream reply could not be captured"
4430        );
4431        std::io::Error::other(reason)
4432    }
4433
4434    /// Scope a mid-stream capture
4435    /// failure to THIS response instead of sealing the whole session.
4436    /// The failure reason is recorded into the audit trail via
4437    /// `submit_response_capture(Some(_))` (at-most-once), so the turn
4438    /// is attested as failed and the session stays usable for future
4439    /// turns — a transient provider framing quirk no longer costs the
4440    /// operator every previously-captured turn. Only when the audit
4441    /// worker itself cannot accept the capture do we fall back to
4442    /// sealing the session: without the failure record the audit chain
4443    /// has a hole no later close could explain.
4444    fn record_response_failure(&mut self, reason: &str) {
4445        // StopReason::Other, not BudgetExceeded: this path carries
4446        // network cuts, garbled provider frames, and flush failures —
4447        // none of them enforcement. Attesting them as "Budget
4448        // Exceeded" (verified live: a provider-side TCP RST
4449        // mid-stream journalled stop_reason_id 92) tells a compliance
4450        // reviewer the harness cut the turn off when the provider
4451        // crashed. Only the genuine mid-stream budget refusal passes
4452        // BudgetExceeded (see the pending-budget Refused arm).
4453        if let Err(error) =
4454            self.submit_response_capture(Some((av_events::StopReason::Other, reason.to_owned())))
4455        {
4456            tracing::warn!(
4457                %error,
4458                session = %self.session.id,
4459                "response-failure capture submit failed; sealing session capture"
4460            );
4461            self.session.mark_capture_failed();
4462        }
4463    }
4464}
4465
4466impl Stream for AbortFinalizingStream {
4467    type Item = Result<Bytes, std::io::Error>;
4468
4469    fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
4470        if self.pending_budget.is_some() {
4471            let result = {
4472                let Some(pending) = self.pending_budget.as_mut() else {
4473                    self.session.mark_capture_failed();
4474                    return Poll::Ready(Some(Err(std::io::Error::other(
4475                        "pending completion budget state disappeared",
4476                    ))));
4477                };
4478                match Pin::new(&mut pending.task).poll(context) {
4479                    Poll::Pending => return Poll::Pending,
4480                    Poll::Ready(result) => result,
4481                }
4482            };
4483            let Some(pending) = self.pending_budget.take() else {
4484                self.session.mark_capture_failed();
4485                return Poll::Ready(Some(Err(std::io::Error::other(
4486                    "completed completion budget state disappeared",
4487                ))));
4488            };
4489            let failure = match result {
4490                Ok(Ok(av_state::BudgetDecision::Allowed { .. })) => None,
4491                Ok(Ok(av_state::BudgetDecision::Refused { limit, cap })) => {
4492                    // The refused delta was never debited (check-and-spend
4493                    // refuses without spending; the principal side is
4494                    // refunded inside the task), but `absorb_frame` already
4495                    // folded it into `charged_completion_tokens` — roll it
4496                    // back or the terminal over-charge refund credits
4497                    // tokens that were never spent (drain-loop rollback
4498                    // invariant).
4499                    self.charged_completion_tokens =
4500                        self.charged_completion_tokens.saturating_sub(pending.delta);
4501                    // Genuine mid-stream cap exhaustion — the ONLY arm
4502                    // that latches. The Ok(Err(..)) arm below is the
4503                    // quota-backend outage (fail-closed refusal, not
4504                    // enforcement): latching it converted a transient
4505                    // store blip into a permanent id lockout, the exact
4506                    // defect the chat path's backend-error arm
4507                    // deliberately avoids.
4508                    self.session
4509                        .latch_enforcement(av_events::StopReason::BudgetExceeded);
4510                    Some((
4511                        av_events::StopReason::BudgetExceeded,
4512                        format!("{limit} exceeded (cap {cap})"),
4513                    ))
4514                }
4515                // Backend outage: fail-closed refusal, not enforcement —
4516                // attested Other, mirroring the no-latch decision above.
4517                // The session debit did not land (the task's error arm
4518                // refunds the principal side too) — same rollback as the
4519                // Refused arm.
4520                Ok(Err(reason)) => {
4521                    self.charged_completion_tokens =
4522                        self.charged_completion_tokens.saturating_sub(pending.delta);
4523                    Some((av_events::StopReason::Other, reason))
4524                }
4525                Err(error) => {
4526                    self.session.mark_capture_failed();
4527                    self.pending_output.clear();
4528                    return Poll::Ready(Some(Err(std::io::Error::other(format!(
4529                        "completion budget task failed: {error}"
4530                    )))));
4531                }
4532            };
4533            if let Some((failure_reason, reason)) = failure {
4534                if let Err(error) = self.submit_response_capture(Some((failure_reason, reason.clone()))) {
4535                    self.session.mark_capture_failed();
4536                    self.pending_output.clear();
4537                    return Poll::Ready(Some(Err(std::io::Error::other(format!(
4538                        "response capture failed closed: {error}"
4539                    )))));
4540                }
4541                self.pending_output.clear();
4542                // QuotaExceeded is an in-process marker: the non-SSE
4543                // drain in `chat_completions` maps it to
4544                // `PipelineError::Blocked` → HTTP 403 (the same
4545                // deliberately-not-429 policy as the breaker path)
4546                // so budget refusals reach the client as a real
4547                // error instead of a severed body.
4548                return Poll::Ready(Some(Err(std::io::Error::new(
4549                    std::io::ErrorKind::QuotaExceeded,
4550                    format!("response blocked by token budget: {reason}"),
4551                ))));
4552            }
4553            match pending.continuation {
4554                BudgetContinuation::Emit(bytes) => return Poll::Ready(Some(Ok(bytes))),
4555                BudgetContinuation::ContinueNonSse => {}
4556                BudgetContinuation::FinishSse => {
4557                    if let Err(error) = self.submit_response_capture(None) {
4558                        self.session.mark_capture_failed();
4559                        return Poll::Ready(Some(Err(std::io::Error::other(format!(
4560                            "response capture failed closed: {error}"
4561                        )))));
4562                    }
4563                    self.completed = true;
4564                    return Poll::Ready(None);
4565                }
4566                BudgetContinuation::FinishNonSse => {
4567                    if let Err(error) = self.submit_response_capture(None) {
4568                        self.session.mark_capture_failed();
4569                        self.pending_output.clear();
4570                        return Poll::Ready(Some(Err(std::io::Error::other(format!(
4571                            "response capture failed closed: {error}"
4572                        )))));
4573                    }
4574                    self.completed = true;
4575                    return Poll::Ready(self.pending_output.pop_front().map(Ok));
4576                }
4577            }
4578        }
4579
4580        if !self.is_sse {
4581            if self.completed {
4582                return Poll::Ready(self.pending_output.pop_front().map(Ok));
4583            }
4584            loop {
4585                match self.inner.as_mut().poll_next(context) {
4586                    Poll::Ready(Some(Ok(bytes))) => {
4587                        let delta = match self.absorb_network_chunk(&bytes) {
4588                            Ok(delta) => delta,
4589                            Err(error) => {
4590                                self.record_response_failure(&error);
4591                                self.pending_output.clear();
4592                                let error = self.abort_error(error);
4593                                return Poll::Ready(Some(Err(error)));
4594                            }
4595                        };
4596                        self.pending_output.push_back(bytes);
4597                        if delta > 0 {
4598                            self.begin_budget_check(delta, BudgetContinuation::ContinueNonSse);
4599                            context.waker().wake_by_ref();
4600                            return Poll::Pending;
4601                        }
4602                    }
4603                    Poll::Ready(Some(Err(error))) => {
4604                        let reason = error.to_string();
4605                        self.record_response_failure(&reason);
4606                        self.pending_output.clear();
4607                        let error = self.abort_error(reason);
4608                        return Poll::Ready(Some(Err(error)));
4609                    }
4610                    Poll::Pending => return Poll::Pending,
4611                    Poll::Ready(None) => {
4612                        let delta = match self.flush_protocol_buffer() {
4613                            Ok(delta) => delta,
4614                            Err(error) => {
4615                                self.record_response_failure(&error);
4616                                self.pending_output.clear();
4617                                let error = self.abort_error(error);
4618                                return Poll::Ready(Some(Err(error)));
4619                            }
4620                        };
4621                        if delta > 0 {
4622                            self.begin_budget_check(delta, BudgetContinuation::FinishNonSse);
4623                            context.waker().wake_by_ref();
4624                            return Poll::Pending;
4625                        }
4626                        // A 200 with a completely EMPTY body produced no
4627                        // frame, so `absorb_frame`'s "successful provider
4628                        // response has no choices array" guard never ran —
4629                        // without this check the session would attest a
4630                        // clean assistant turn that never happened (while
4631                        // the strictly-less-broken `200 {}` body fails
4632                        // capture). `saw_chunk` exists precisely to gate
4633                        // this.
4634                        if self.upstream_status.is_success() && !self.saw_chunk {
4635                            // Attest the refused turn as a TERMINAL
4636                            // FAILURE before sealing: returning without a
4637                            // capture left Drop to submit one with
4638                            // failure=None (no flush error, no unbilled
4639                            // delta), journalling a Success-shaped Session
4640                            // event for exactly the body this guard
4641                            // refuses to attest.
4642                            let reason = "successful provider response has an empty body".to_owned();
4643                            self.record_response_failure(&reason);
4644                            self.session.mark_capture_failed();
4645                            self.pending_output.clear();
4646                            let error = self.abort_error(reason);
4647                            return Poll::Ready(Some(Err(error)));
4648                        }
4649                        if let Err(error) = self.submit_response_capture(None) {
4650                            self.session.mark_capture_failed();
4651                            self.pending_output.clear();
4652                            return Poll::Ready(Some(Err(std::io::Error::other(format!(
4653                                "response capture failed closed: {error}"
4654                            )))));
4655                        }
4656                        self.completed = true;
4657                        return Poll::Ready(self.pending_output.pop_front().map(Ok));
4658                    }
4659                }
4660            }
4661        }
4662
4663        match self.inner.as_mut().poll_next(context) {
4664            Poll::Ready(Some(Ok(bytes))) => match self.absorb_network_chunk(&bytes) {
4665                Ok(0) => Poll::Ready(Some(Ok(bytes))),
4666                Ok(delta) => {
4667                    self.begin_budget_check(delta, BudgetContinuation::Emit(bytes));
4668                    context.waker().wake_by_ref();
4669                    Poll::Pending
4670                }
4671                Err(error) => {
4672                    self.record_response_failure(&error);
4673                    let error = self.abort_error(error);
4674                    Poll::Ready(Some(Err(error)))
4675                }
4676            },
4677            Poll::Ready(Some(Err(error))) => {
4678                let reason = error.to_string();
4679                self.record_response_failure(&reason);
4680                let error = self.abort_error(reason);
4681                Poll::Ready(Some(Err(error)))
4682            }
4683            Poll::Pending => Poll::Pending,
4684            Poll::Ready(None) => {
4685                let delta = match self.flush_protocol_buffer() {
4686                    Ok(delta) => delta,
4687                    Err(error) => {
4688                        self.record_response_failure(&error);
4689                        let error = self.abort_error(error);
4690                        return Poll::Ready(Some(Err(error)));
4691                    }
4692                };
4693                if delta > 0 {
4694                    self.begin_budget_check(delta, BudgetContinuation::FinishSse);
4695                    context.waker().wake_by_ref();
4696                    return Poll::Pending;
4697                }
4698                // Same empty-body fail-closed guard as the non-SSE arm
4699                // above: a 200 SSE response that delivered zero bytes
4700                // must not be attested as a clean assistant turn.
4701                if self.upstream_status.is_success() && !self.saw_chunk {
4702                    // Same terminal-failure attestation as the non-SSE
4703                    // twin: seal AFTER capturing the refusal so Drop's
4704                    // failure=None capture cannot journal a
4705                    // Success-shaped event for the refused body.
4706                    let reason = "successful provider response has an empty body".to_owned();
4707                    self.record_response_failure(&reason);
4708                    self.session.mark_capture_failed();
4709                    let error = self.abort_error(reason);
4710                    return Poll::Ready(Some(Err(error)));
4711                }
4712                if let Err(error) = self.submit_response_capture(None) {
4713                    self.session.mark_capture_failed();
4714                    return Poll::Ready(Some(Err(std::io::Error::other(format!(
4715                        "response capture failed closed: {error}"
4716                    )))));
4717                }
4718                self.completed = true;
4719                Poll::Ready(None)
4720            }
4721        }
4722    }
4723}
4724
4725pub(crate) fn parse_provider_chunk(raw: &str) -> Result<Option<ParsedProviderChunk>, String> {
4726    // SSE spec (§9.2.4) requires the leading U+FEFF (BOM) to be
4727    // discarded once, at the start of the stream. Rust's `.trim()` does
4728    // not strip U+FEFF (char::is_whitespace() returns false for it), so
4729    // a provider that ships a BOM would otherwise cause every following
4730    // parse to fail on either `strip_prefix("data:")` (BOM before
4731    // "data") or `serde_json::from_str` (BOM before "{"). Do the strip
4732    // exactly once here; subsequent chunks in a stream will not carry
4733    // another BOM.
4734    let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
4735    let mut message = String::new();
4736    let mut reasoning = String::new();
4737    let mut prompt_tokens = None;
4738    let mut completion_tokens = None;
4739    let mut cached_tokens = None;
4740    let mut usage_reported = false;
4741    let mut finish_reason = None;
4742    let mut cost_usd_micros = 0u64;
4743    let mut cost_reported = false;
4744    let mut tool_call_deltas = Vec::new();
4745    let mut is_sse = false;
4746    let mut data = Vec::new();
4747    // Track the last `event:` type seen in this frame. SSE §9.2.8 says
4748    // an SSE client dispatches by event name; "message" (empty or
4749    // omitted) is the default. AgentVisor AI attributes the `data:`
4750    // payload to the audit trail (receipt, ATIF) as if it were the
4751    // model's output — but a hostile upstream (rogue provider,
4752    // TLS-MITM at egress, misconfigured cache) can send
4753    // `event: error\ndata: {"choices":[{"delta":{"content":"…"}}]}` —
4754    // spec-compliant SSE clients (browsers, OpenAI SDK's error hook)
4755    // would dispatch that to the ERROR listener, so the user sees an
4756    // error UI while our receipt records the payload as the model's
4757    // response. That defeats the cryptographic-attestation posture.
4758    // Only accept `event:` empty or `"message"` for capture; other
4759    // types abort the frame with a diagnostic and let the caller
4760    // mark_capture_failed rather than sign attributable content.
4761    let mut event_type = String::new();
4762    for line in raw.split(['\r', '\n']) {
4763        if let Some(value) = line.strip_prefix("data:") {
4764            is_sse = true;
4765            // SSE spec (§9.2.6): strip exactly ONE leading U+0020
4766            // SPACE — the field is otherwise verbatim. `.trim_start()`
4767            // would eat runs of Unicode whitespace and silently
4768            // mis-account any provider that sends `data:  {...}` with a
4769            // meaningful second byte.
4770            data.push(value.strip_prefix(' ').unwrap_or(value));
4771        } else if let Some(value) = line.strip_prefix("event:") {
4772            is_sse = true;
4773            event_type = value.strip_prefix(' ').unwrap_or(value).to_owned();
4774        } else if line == "data"
4775            || line.starts_with("id:")
4776            || line.starts_with("retry:")
4777            || line.starts_with(':')
4778        {
4779            is_sse = true;
4780        }
4781    }
4782    if is_sse && !event_type.is_empty() && event_type != "message" {
4783        // Narrow the named-event refusal to
4784        // frames that actually carry captured data. A named-event
4785        // keepalive with an empty `data:` field (`event: ping\n\n`,
4786        // `event: heartbeat\n\n`) can't taint the audit surface — no
4787        // bytes are attributed to the receipt — so treat it as a
4788        // skipped frame rather than aborting the whole stream and
4789        // sealing the session. Only refuse when non-empty `data:`
4790        // lines would otherwise be attributed to the model output.
4791        //
4792        // A `data: [DONE]` line under a named event (e.g.
4793        // `event: summary\ndata: [DONE]\n\n` emitted by some OpenAI-
4794        // compat gateways as they finalise their own event stream)
4795        // is also attribution-free — the sentinel is not model
4796        // output — so it must NOT fail-close the whole stream just
4797        // because the terminator arrived under an unexpected event
4798        // name. Matches the Anthropic parser's ordering, which
4799        // short-circuits `[DONE]` before its named-event refusal.
4800        if data.iter().all(|entry| {
4801            let trimmed = entry.trim();
4802            trimmed.is_empty() || trimmed == "[DONE]"
4803        }) {
4804            return Ok(None);
4805        }
4806        return Err(format!(
4807            "provider SSE frame carries unsupported event type {event_type:?}; \
4808             AgentVisor AI only captures the default `message` event because non-message \
4809             events (error, ping, custom) are dispatched to different client listeners \
4810             per SSE §9.2.8 and would be attributed to the wrong audit surface"
4811        ));
4812    }
4813    let candidate = if is_sse {
4814        if data.is_empty() {
4815            return Ok(None);
4816        }
4817        data.join("\n")
4818    } else {
4819        raw.trim().to_owned()
4820    };
4821    // Robust `[DONE]` sentinel handling. Some providers double-terminate
4822    // (`data: [DONE]\ndata: [DONE]`), send trailing whitespace, or emit
4823    // an empty keepalive line followed by `[DONE]`. The strict
4824    // byte-exact check used to fail every subsequent stream on such
4825    // benign variants — `trim` + per-line probe catches them.
4826    if candidate.is_empty()
4827        || candidate.trim() == "[DONE]"
4828        || data
4829            .iter()
4830            .all(|entry| entry.trim().is_empty() || entry.trim() == "[DONE]")
4831            && !data.is_empty()
4832    {
4833        return Ok(None);
4834    }
4835    let value = serde_json::from_str::<Value>(&candidate)
4836        .map_err(|error| format!("invalid provider JSON frame: {error}"))?;
4837    // OpenAI-shape inline error: mid-stream on a 200 OK the provider
4838    // can ship `data: {"error":{"code":"…","message":"…","type":"…"}}`
4839    // (Azure OpenAI and every OpenAI-compat shim do the same). Pre-fix
4840    // this fell through to `has_choices = false` and aborted the
4841    // stream with the generic "successful provider response has no
4842    // choices array" — the actual `error.message` was discarded and
4843    // the receipt attested `stop_reason = Other` with no error text
4844    // for exactly the diagnostics an auditor needs (context overflow,
4845    // billing 402, safety terminal). Refuse the frame carrying the
4846    // provider's own reason instead. Anthropic's `event: error`
4847    // handler in provider.rs does the same for that dialect.
4848    if let Some(err_obj) = value.get("error").filter(|v| !v.is_null()) {
4849        let message = err_obj
4850            .get("message")
4851            .and_then(Value::as_str)
4852            .unwrap_or("provider ended stream with an unspecified error");
4853        let code = err_obj.get("code").and_then(Value::as_str).unwrap_or("");
4854        return Err(if code.is_empty() {
4855            format!("provider stream error: {message}")
4856        } else {
4857            format!("provider stream error [{code}]: {message}")
4858        });
4859    }
4860    let model_name = value.get("model").and_then(Value::as_str).map(str::to_owned);
4861    // OpenAI streams with `stream_options: {"include_usage": true}` carry
4862    // `"usage": null` on every content chunk, with the real usage object
4863    // only on the final chunk (vLLM/LiteLLM shims do the same). A null
4864    // usage must fall through to the estimate/accumulate path exactly like
4865    // an absent key: flagging it as reported would feed absorb_frame's
4866    // cumulative-usage branch a per-chunk heuristic estimate, which both
4867    // kills the stream with a spurious "provider completion usage
4868    // regressed" error (estimates are not monotonic) and, when estimates
4869    // happen to stay monotonic, zeroes every mid-stream budget delta.
4870    if let Some(usage) = value.get("usage").filter(|usage| !usage.is_null()) {
4871        usage_reported = true;
4872        prompt_tokens = provider_u64(usage.get("prompt_tokens"), "prompt_tokens")?.or(prompt_tokens);
4873        completion_tokens =
4874            provider_u64(usage.get("completion_tokens"), "completion_tokens")?.or(completion_tokens);
4875        cached_tokens = provider_u64(
4876            usage.pointer("/prompt_tokens_details/cached_tokens"),
4877            "cached_tokens",
4878        )?
4879        .or(cached_tokens);
4880    }
4881    let cost_value = value.pointer("/usage/cost_usd").or_else(|| value.get("cost_usd"));
4882    if let Some(cost_value) = cost_value {
4883        let cost = cost_value
4884            .as_f64()
4885            .ok_or_else(|| "provider cost_usd is not a number".to_owned())?;
4886        if !cost.is_finite() || cost < 0.0 {
4887            return Err("provider cost_usd is not finite and nonnegative".to_owned());
4888        }
4889        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
4890        {
4891            let micros = (cost * av_core::units::USD_MICROS_PER_DOLLAR as f64).round();
4892            if micros > av_core::error::JCS_SAFE_MAX as f64 {
4893                return Err("provider cost exceeds JCS-safe receipt bounds".to_owned());
4894            }
4895            cost_usd_micros = micros as u64;
4896            cost_reported = true;
4897        }
4898    }
4899    let has_choices = value.get("choices").is_some();
4900    let choices = match value.get("choices") {
4901        Some(Value::Array(choices)) => Some(choices),
4902        Some(_) => return Err("provider choices is not an array".to_owned()),
4903        None => None,
4904    };
4905    if let Some(choices) = choices {
4906        for (choice_position, choice) in choices.iter().enumerate() {
4907            // Distinguish choices in multi-choice (`n > 1`) responses:
4908            // tool-call deltas are keyed by (choice, tool index) downstream,
4909            // otherwise `choices[0].tool_calls[0]` and
4910            // `choices[1].tool_calls[0]` merge into one corrupt audit record
4911            // (concatenated argument fragments, last-writer-wins id/name).
4912            let choice_index = choice
4913                .get("index")
4914                .and_then(Value::as_u64)
4915                .or_else(|| u64::try_from(choice_position).ok())
4916                .unwrap_or(u64::MAX);
4917            if let Some(reason) = choice
4918                .get("finish_reason")
4919                .and_then(Value::as_str)
4920                .filter(|s| !s.is_empty())
4921            {
4922                // empty-string finish_reason from
4923                // OpenAI-compatible shims used to enter the audit
4924                // chain as `Some("")`, which the JSON schema then
4925                // rejects at publish (`minLength: 1`) — permanently
4926                // fail-closing the session AFTER the bytes were
4927                // already relayed. Treat empty as absent.
4928                finish_reason = Some(reason.to_owned());
4929            }
4930            let content = choice
4931                .pointer("/delta/content")
4932                .or_else(|| choice.pointer("/message/content"))
4933                .and_then(Value::as_str);
4934            if let Some(content) = content {
4935                message.push_str(content);
4936            }
4937            let reasoning_content = choice
4938                .pointer("/delta/reasoning_content")
4939                .or_else(|| choice.pointer("/message/reasoning_content"))
4940                .and_then(Value::as_str);
4941            if let Some(content) = reasoning_content {
4942                reasoning.push_str(content);
4943            }
4944            let streaming_calls = choice.pointer("/delta/tool_calls");
4945            let calls = streaming_calls
4946                .or_else(|| choice.pointer("/message/tool_calls"))
4947                .map(|calls| {
4948                    calls
4949                        .as_array()
4950                        .ok_or_else(|| "provider tool_calls is not an array".to_owned())
4951                })
4952                .transpose()?;
4953            if let Some(calls) = calls {
4954                for (position, call) in calls.iter().enumerate() {
4955                    let index = match call.get("index").and_then(Value::as_u64) {
4956                        Some(index) => index,
4957                        None if streaming_calls.is_some() => {
4958                            return Err("streaming provider tool call has no index".to_owned());
4959                        }
4960                        None => u64::try_from(position)
4961                            .map_err(|_| "provider tool-call index overflow".to_owned())?,
4962                    };
4963                    tool_call_deltas.push(ProviderToolCallDelta {
4964                        choice_index,
4965                        index,
4966                        complete: false,
4967                        id: call.get("id").and_then(Value::as_str).map(str::to_owned),
4968                        name: call
4969                            .pointer("/function/name")
4970                            .and_then(Value::as_str)
4971                            .map(str::to_owned),
4972                        arguments: call
4973                            .pointer("/function/arguments")
4974                            .and_then(Value::as_str)
4975                            .unwrap_or_default()
4976                            .to_owned(),
4977                    });
4978                }
4979            }
4980        }
4981    }
4982    let completion_reported = completion_tokens.is_some();
4983    let completion_tokens = completion_tokens.unwrap_or_else(|| {
4984        let mut estimated = av_core::tokens::approx_tokens(&message)
4985            .saturating_add(av_core::tokens::approx_tokens(&reasoning));
4986        for call in &tool_call_deltas {
4987            estimated = estimated
4988                .saturating_add(call.name.as_deref().map_or(0, av_core::tokens::approx_tokens))
4989                .saturating_add(av_core::tokens::approx_tokens(&call.arguments));
4990        }
4991        estimated
4992    });
4993    Ok(Some(ParsedProviderChunk {
4994        message,
4995        reasoning: (!reasoning.is_empty()).then_some(reasoning),
4996        model_name,
4997        metrics: av_events::EventMetrics {
4998            prompt_tokens,
4999            completion_tokens: Some(completion_tokens),
5000            cached_tokens,
5001            pruned_tokens: None,
5002            pruning_ratio_millis: None,
5003        },
5004        usage_reported,
5005        completion_reported,
5006        finish_reason,
5007        cost_usd_micros,
5008        cost_reported,
5009        has_choices,
5010        tool_call_deltas,
5011    }))
5012}
5013
5014pub(crate) fn provider_u64(value: Option<&Value>, field: &str) -> Result<Option<u64>, String> {
5015    let Some(value) = value else {
5016        return Ok(None);
5017    };
5018    // Some OpenAI-compatible shims emit explicit nulls for usage fields
5019    // they don't track; treat them like absent keys rather than failing
5020    // the frame.
5021    if value.is_null() {
5022        return Ok(None);
5023    }
5024    let value = value
5025        .as_u64()
5026        .ok_or_else(|| format!("provider {field} is not a nonnegative integer"))?;
5027    if value > av_core::error::JCS_SAFE_MAX {
5028        return Err(format!("provider {field} exceeds JCS-safe bounds"));
5029    }
5030    Ok(Some(value))
5031}
5032
5033/// Fold the accumulated provider tool-call arguments string into
5034/// the JSON `Value` the signed ATIF step attests.
5035///
5036/// Zero-arg parity across the three provider adapters:
5037/// - OpenAI's `"arguments":"{}"` accumulates as `"{}"` on the wire.
5038/// - Gemini's `functionCall` without `args` is emitted as `"{}"` by
5039///   the adapter (its deltas are `complete: true`, so no
5040///   concatenation risk).
5041/// - Anthropic streaming ships `content_block_start` with
5042///   `"input":{}` as a PLACEHOLDER, then follows with
5043///   `input_json_delta.partial_json` chunks that this accumulator
5044///   CONCATENATES; emitting `"{}"` from the placeholder would
5045///   corrupt every non-empty streaming tool_use to `{}{"city":…}`
5046///   at the accumulator. The Anthropic adapter therefore emits `""`
5047///   from the placeholder and this helper NORMALISES an empty
5048///   accumulator to `"{}"` before parsing — so a truly-zero-arg
5049///   streaming tool_use still attests as `{}` (matching OpenAI /
5050///   Gemini) instead of the pre-R14 `{"raw":""}` fallback.
5051///
5052/// Any other unparseable raw (concatenation left corrupt bytes on
5053/// the wire, or provider sent invalid JSON) still falls through to
5054/// the `{"raw": …}` capture — the auditor sees the exact bytes.
5055pub(crate) fn normalize_tool_call_arguments(raw: &str) -> serde_json::Value {
5056    let candidate = if raw.is_empty() { "{}" } else { raw };
5057    serde_json::from_str(candidate).unwrap_or_else(|_| json!({"raw": raw}))
5058}
5059
5060pub(crate) fn map_finish_reason(native: &str) -> av_events::StopReason {
5061    match native {
5062        "stop" | "stop_sequence" | "end_turn" => av_events::StopReason::Stop,
5063        // Anthropic's `model_context_window_exceeded` is the same
5064        // semantic as OpenAI's `length` / Gemini's `MAX_TOKENS` (the
5065        // model hit a length limit). Pre-fix it fell through to Other
5066        // — a capacity dashboard grouping sessions by "Length" missed
5067        // Anthropic overflows.
5068        "length" | "max_tokens" | "model_context_window_exceeded" => av_events::StopReason::MaxTokens,
5069        "tool_calls" | "function_call" | "tool_use" => av_events::StopReason::ToolUse,
5070        // Anthropic's `pause_turn` (Claude 3.7+ extended-thinking +
5071        // tool sessions) is a natural mid-response continuation
5072        // awaiting a resume — semantically a tool-shaped stop, not a
5073        // "we're done" stop. Fold with tool_use so dashboards
5074        // grouping tool sessions catch these too, matching the
5075        // existing pattern of provider-native aliasing above.
5076        "pause_turn" => av_events::StopReason::ToolUse,
5077        // Anthropic's `refusal` (Claude Sonnet 4.5+) is a safety refusal
5078        // — the same semantic as OpenAI's `content_filter` and the
5079        // cluster Gemini folds via map_gemini_finish_reason. Pre-fix it
5080        // fell through to Other, silently splitting safety-refusal
5081        // analytics along provider lines (verified live: an Anthropic
5082        // response with stop_reason=refusal journalled stop_reason_id=99
5083        // where OpenAI/Gemini equivalents journalled id=90).
5084        "content_filter" | "refusal" => av_events::StopReason::ContentFilter,
5085        _ => av_events::StopReason::Other,
5086    }
5087}
5088
5089impl Drop for AbortFinalizingStream {
5090    fn drop(&mut self) {
5091        // Session-being-closed intentionally still captures: `wait_for_streams`
5092        // in `close_session_locked` is blocked on this stream's `SessionLease`,
5093        // so the response_capture we submit here will land before finalize
5094        // reads the chain / atif.
5095        if self.completed {
5096            // A completed EPHEMERAL one-shot (no client X-AV-Session —
5097            // the generated id only travels back in the response
5098            // header) must close NOW: OPENAI-COMPATIBILITY.md promises
5099            // "the session then closes immediately after the response —
5100            // one turn, one audit event", and before this arm the
5101            // session sat OPEN until the idle sweeper
5102            // (`session_idle_close_s`, default 900 s) — a stock SDK
5103            // caller's signed receipt was 15 minutes late. Reproduced
5104            // live pre-fix: open_count=1, zero receipts, 3 s after a
5105            // 200. The client cannot address the generated id again, so
5106            // there is no future turn to keep the session open for.
5107            if self.ephemeral && !self.session.is_closed() && self.session.active_streams_count() <= 1 {
5108                let session = Arc::clone(&self.session);
5109                let finalizer = self.finalizer.clone();
5110                let session_id = session.id.clone();
5111                if let Ok(runtime) = tokio::runtime::Handle::try_current() {
5112                    runtime.spawn(async move {
5113                        // Catch panics inside `close_session` (bridge
5114                        // publish, receipt-signing JCS overflow, a
5115                        // metric-registry collision on any lazy
5116                        // `.counter(...)`, tracing Display allocator
5117                        // failure): a panicking async body in a
5118                        // detached `spawn` is silently swallowed by
5119                        // tokio's default `UnhandledPanic::Ignore`,
5120                        // so the operator would never see the class.
5121                        // Emit the panic counter so
5122                        // `rate(av_ephemeral_close_panics_total)`
5123                        // catches it. Same discipline as the workspace's
5124                        // long-lived loops (reconciler, worker, bridge).
5125                        let outcome = std::panic::AssertUnwindSafe(
5126                            finalizer.close_session(session, StopReason::SessionClosed),
5127                        )
5128                        .catch_unwind()
5129                        .await;
5130                        match outcome {
5131                            Ok(Ok(_)) => {}
5132                            Ok(Err(error)) => {
5133                                finalizer
5134                                    .metrics()
5135                                    .counter(
5136                                        "av_ephemeral_close_failures_total",
5137                                        "Auto-close of a completed ephemeral one-shot failed; \
5138                                         the session is left open until the idle sweeper reaps it",
5139                                    )
5140                                    .inc();
5141                                tracing::warn!(
5142                                    %error,
5143                                    session = %session_id,
5144                                    "auto-close of completed ephemeral session failed"
5145                                );
5146                            }
5147                            Err(panic) => {
5148                                let msg = panic
5149                                    .downcast_ref::<&'static str>()
5150                                    .copied()
5151                                    .or_else(|| panic.downcast_ref::<String>().map(String::as_str))
5152                                    .unwrap_or("panic payload was not a string");
5153                                finalizer
5154                                    .metrics()
5155                                    .counter(
5156                                        "av_ephemeral_close_panics_total",
5157                                        "Auto-close of a completed ephemeral one-shot panicked \
5158                                         and was caught; the session is left open until the idle \
5159                                         sweeper reaps it",
5160                                    )
5161                                    .inc();
5162                                tracing::warn!(
5163                                    session = %session_id,
5164                                    panic = %msg,
5165                                    "auto-close of completed ephemeral session PANICKED (caught)"
5166                                );
5167                            }
5168                        }
5169                    });
5170                }
5171                // No runtime (shutdown-time drop): the idle sweeper /
5172                // recovery own the close, exactly as before this arm.
5173            }
5174            return;
5175        }
5176        // Capture is_closed once so the fail-closed guards below agree on
5177        // the same view. When a concurrent close is already draining this
5178        // stream via `wait_for_streams`, the finalize path is imminent and
5179        // we must not `mark_capture_failed` on the last budget check /
5180        // trailing frame — otherwise `close_session_locked`'s post-drain
5181        // `capture_failed` guard seals the session (`mark_artifact_committed`
5182        // + committed claim) and returns `CaptureIncomplete`: the close is
5183        // never retried and the session finalizes with no artifact at all.
5184        //
5185        // A client disconnect mid-budget-check
5186        // or a garbled trailing frame is a per-RESPONSE failure — the
5187        // audit chain is still consistent because we can record the
5188        // truncation reason via `submit_response_capture(Some(_))`.
5189        // Sealing the whole session (which the previous fail-closed logic
5190        // did) bricked every future turn of the agent conversation for
5191        // any transient network hiccup. `mark_capture_failed()` is now
5192        // reserved for the one genuinely fatal drop path: the audit
5193        // worker itself is unreachable, so no future response could be
5194        // safely captured either.
5195        let is_closed = self.session.is_closed();
5196        let budget_incomplete = self.pending_budget.is_some();
5197        // Abort the pending budget task. `spawn_blocking`
5198        // returns a JoinHandle whose Drop does NOT cancel the queued
5199        // closure; the blocking pool would otherwise run
5200        // `ActionBudget::try_tokens(delta)` AFTER the drop, silently
5201        // debiting the session's budget key for a request the client
5202        // never received. `abort()` is best-effort for a closure
5203        // already picked up by the blocking pool (blocking tasks
5204        // have no cancellation points) but reliably cancels a
5205        // still-queued task — closing the common case. A full fix
5206        // would thread a shutdown token into `try_tokens`; deferred
5207        // until that helper takes a cancellation argument.
5208        if let Some(pending) = self.pending_budget.take() {
5209            pending.task.abort();
5210            // The aborted check's delta was folded into
5211            // `charged_completion_tokens` by `absorb_frame` but its debit
5212            // never landed (abort reliably cancels a queued blocking
5213            // task). Roll it back so the terminal over-charge refund
5214            // below cannot credit tokens that were never debited. If the
5215            // abort raced a task already RUNNING on the blocking pool
5216            // (no cancellation points), the debit may still land — the
5217            // rollback then under-refunds by one delta, i.e. the ledger
5218            // keeps a conservative over-debit, matching the documented
5219            // principal-side posture. Never the reverse.
5220            self.charged_completion_tokens = self.charged_completion_tokens.saturating_sub(pending.delta);
5221        }
5222        let (flush_reason, budget_delta) = match self.flush_protocol_buffer() {
5223            Ok(delta) => (None, delta),
5224            Err(error) => {
5225                tracing::warn!(%error, "provider stream flush failed on drop");
5226                (Some(format!("provider stream flush failed: {error}")), 0u64)
5227            }
5228        };
5229        // The flushed tail's frames bumped `charged_completion_tokens`,
5230        // but on the drop path no `begin_budget_check` follows — the
5231        // delta is discarded, never debited. Same rollback as the
5232        // drain-loop error arms, or the terminal refund credits the
5233        // unbilled tail against debits that never happened.
5234        self.charged_completion_tokens = self.charged_completion_tokens.saturating_sub(budget_delta);
5235        // Compose a per-response failure reason. `budget_incomplete`
5236        // dominates because it always implies the client disconnected
5237        // before the last chunk was billed; a garbled frame is next
5238        // most specific; an unbilled tail (delta > 0 with no
5239        // per-chunk error) means the connection dropped between the
5240        // last successful budget check and the flush.
5241        let response_failure: Option<String> = if budget_incomplete {
5242            Some("client disconnected before response budget check completed".to_owned())
5243        } else if let Some(reason) = flush_reason {
5244            Some(reason)
5245        } else if budget_delta > 0 {
5246            Some(format!(
5247                "client disconnected with {budget_delta} unbilled response bytes"
5248            ))
5249        } else {
5250            None
5251        };
5252        if let Err(error) = self.submit_response_capture(
5253            // Client-disconnect shapes — attested Other, not enforcement.
5254            response_failure.map(|reason| (av_events::StopReason::Other, reason)),
5255        ) {
5256            tracing::warn!(%error, "response-capture submit failed on drop");
5257            if !is_closed {
5258                // The audit worker is unreachable — no future response
5259                // on this session could be captured either. This is
5260                // the one genuinely session-fatal drop path.
5261                self.session.mark_capture_failed();
5262            }
5263        }
5264        // A client disconnect must
5265        // not seal the whole conversation. Spawn the background close
5266        // ONLY when this abort left nothing capturable AND no
5267        // concurrent stream is in flight AND the session is an
5268        // ephemeral one-shot (no client X-AV-Session) — the case
5269        // where the session would otherwise linger as an empty
5270        // husk until the idle sweeper: the client never learned the
5271        // generated id, so no future turn can address it. In every
5272        // other case the session stays OPEN: the truncated turn was
5273        // recorded above via response capture, a client retrying with
5274        // the same session id keeps working, and the idle sweeper
5275        // finalizes if the client never comes back. This was the
5276        // direct trigger for the §6.1 recycled-id artifact
5277        // destruction. Without the `ephemeral` gate (mirroring the
5278        // completed-arm at the top of this fn), a multi-turn session
5279        // whose latest turn aborted before any upstream byte arrived
5280        // was force-closed with StopReason::Other; the client's next
5281        // turn silently opened a NEW session under the same string id
5282        // (get_or_open sees close_complete), fragmenting the audit
5283        // chain that OPENAI-COMPATIBILITY.md promises stays continuous
5284        // until POST /close or the idle sweeper.
5285        // NB: our own SessionLease is still held during this body
5286        // (fields drop after Drop::drop), so `> 1` means "another
5287        // stream besides ours".
5288        let nothing_captured = !self.saw_chunk && self.captured_bytes == 0;
5289        let other_streams = self.session.active_streams_count() > 1;
5290        if self.ephemeral && !is_closed && nothing_captured && !other_streams {
5291            let session = Arc::clone(&self.session);
5292            let finalizer = self.finalizer.clone();
5293            let session_id = session.id.clone();
5294            let stop_reason = StopReason::Other;
5295            match tokio::runtime::Handle::try_current() {
5296                Ok(runtime) => {
5297                    // Detach: the drop is sync and cannot await. Mirror
5298                    // the outcome to tracing *and* a Prometheus counter
5299                    // so a PromQL alert can catch the class — the
5300                    // fallback path is otherwise invisible until the
5301                    // idle sweeper reaps the "still open" session.
5302                    // `catch_unwind`: an async body panicking inside
5303                    // detached `tokio::spawn` is silently swallowed by
5304                    // tokio's default `UnhandledPanic::Ignore`. Route
5305                    // the panic to `av_stream_abort_panics_total` so
5306                    // the class shows up on `/metrics` — matches the
5307                    // discipline every other background-loop
5308                    // supervisor in this workspace applies.
5309                    runtime.spawn(async move {
5310                        let outcome =
5311                            std::panic::AssertUnwindSafe(finalizer.close_session(session, stop_reason))
5312                                .catch_unwind()
5313                                .await;
5314                        match outcome {
5315                            Ok(Ok(_)) => {}
5316                            Ok(Err(error)) => {
5317                                finalizer
5318                                    .metrics()
5319                                    .counter(
5320                                        "av_stream_abort_close_failures_total",
5321                                        "Background close after a stream abort failed; \
5322                                         the session is left open until the idle sweeper reaps it",
5323                                    )
5324                                    .inc();
5325                                tracing::warn!(
5326                                    %error,
5327                                    session = %session_id,
5328                                    "background close on stream abort failed"
5329                                );
5330                            }
5331                            Err(panic) => {
5332                                let msg = panic
5333                                    .downcast_ref::<&'static str>()
5334                                    .copied()
5335                                    .or_else(|| panic.downcast_ref::<String>().map(String::as_str))
5336                                    .unwrap_or("panic payload was not a string");
5337                                finalizer
5338                                    .metrics()
5339                                    .counter(
5340                                        "av_stream_abort_panics_total",
5341                                        "Background close after a stream abort panicked and was \
5342                                         caught; the session is left open until the idle sweeper \
5343                                         reaps it",
5344                                    )
5345                                    .inc();
5346                                tracing::warn!(
5347                                    session = %session_id,
5348                                    panic = %msg,
5349                                    "background close on stream abort PANICKED (caught)"
5350                                );
5351                            }
5352                        }
5353                    });
5354                }
5355                Err(error) => {
5356                    // Runtime is gone (shutdown, drop from a blocking
5357                    // thread) — there is no place to await the close.
5358                    // Mark capture failed so the reconciler retries on
5359                    // the next tick instead of finalising a session
5360                    // that never ran to completion.
5361                    self.finalizer
5362                        .metrics()
5363                        .counter(
5364                            "av_stream_abort_no_runtime_total",
5365                            "Stream abort observed no tokio runtime; capture marked failed for \
5366                             reconciler retry",
5367                        )
5368                        .inc();
5369                    tracing::warn!(
5370                        %error,
5371                        session = %session_id,
5372                        "no tokio runtime available for stream-abort close; marking capture failed for reconciler retry"
5373                    );
5374                    self.session.mark_capture_failed();
5375                }
5376            }
5377        }
5378    }
5379}
5380
5381#[cfg(test)]
5382mod tests {
5383    #![allow(
5384        clippy::expect_used,
5385        clippy::indexing_slicing,
5386        clippy::panic,
5387        clippy::unwrap_used
5388    )]
5389
5390    use super::*;
5391    use av_bridge::{BusError, EventBus, PublishAck, StoredEvent};
5392    use av_receipts::Ed25519Signer;
5393    use av_sandbox::{Sandbox, SandboxConfig};
5394    use av_state::{InMemoryStore, Spend, StateError, StateStore};
5395    use axum::http::Request;
5396    use std::time::Duration;
5397    use tower::ServiceExt;
5398
5399    /// Register item 25 sub-clause: OPENAI-COMPATIBILITY.md is the
5400    /// integration-facing route contract. Pass 31 audit found the
5401    /// bare `/mcp` route (registered alongside `/v1/mcp` for MCP SDK
5402    /// convention) was documented in CONFIGURATION.md's
5403    /// `max_request_bytes` but MISSING from OPENAI-COMPATIBILITY.md's
5404    /// route split. An integration engineer following the doc would
5405    /// assume MCP SDKs must POST to `/v1/mcp`; a client posting to
5406    /// the standard `/mcp` path would succeed, but the doc's
5407    /// silence about it is silently confusing.
5408    ///
5409    /// Every real client-facing agent-API route this crate registers
5410    /// must appear verbatim in OPENAI-COMPATIBILITY.md. Adding a new
5411    /// route to `build_router` without updating the doc now trips CI.
5412    #[test]
5413    fn openai_compatibility_documents_every_client_facing_route() {
5414        let doc = include_str!("../../../docs/reference/OPENAI-COMPATIBILITY.md");
5415        // The client-facing agent API surface — routes an SDK or MCP
5416        // client will POST to. Operational probes (/health, /livez,
5417        // /readyz, /metrics) and the operator/dashboard surface
5418        // (/api/v1/...) are documented separately (OPERATIONS.md /
5419        // the dashboard section) and are explicitly out of scope of
5420        // this test to avoid pinning them to the wrong doc.
5421        //
5422        // Each route is checked with distinguishing context — a bare
5423        // substring check would treat `/v1/mcp` as satisfying the
5424        // `/mcp` requirement (found live pass 31 while red-testing
5425        // this exact scenario). The context strings are what the doc
5426        // uniquely uses for each variant.
5427        for (route, must_contain) in [
5428            ("/v1/chat/completions", "`/v1/chat/completions`"),
5429            ("/v1/mcp", "`/v1/mcp`"),
5430            // Distinguish the bare route by its "bare `/mcp`" phrase
5431            // — the same character sequence used inside `/v1/mcp`
5432            // would otherwise let the substring check pass.
5433            ("/mcp (bare, MCP SDK convention)", "bare `/mcp`"),
5434            ("/v1/sessions/{id}/close", "`/v1/sessions/{id}/close`"),
5435            ("/v1/sessions/{id}/promote", "`/v1/sessions/{id}/promote`"),
5436        ] {
5437            assert!(
5438                doc.contains(must_contain),
5439                "route {route} is registered by build_router but the doc is missing \
5440                 its distinguishing marker {must_contain:?} — an integration \
5441                 engineer following the doc will not know it exists. Add a row to \
5442                 the route table (or the /v1 vs /api/v1 split section) and re-run."
5443            );
5444        }
5445    }
5446
5447    /// R285: OPENAI-COMPATIBILITY.md §Unsupported promises a 404 for
5448    /// every listed OpenAI route ("front the harness with a path-based
5449    /// router if you need them"). Executed against the real router so
5450    /// shipping any shim (a `/v1/models` stub is the classic) forces
5451    /// the doc's Unsupported list to shrink in the same commit. Each
5452    /// probe carries its distinguishing doc marker — same rationale as
5453    /// the route-table test above.
5454    #[tokio::test]
5455    async fn unsupported_openai_routes_stay_404() {
5456        let doc = include_str!("../../../docs/reference/OPENAI-COMPATIBILITY.md");
5457        let directory = tempfile::tempdir().unwrap();
5458        let (state, _reconciler) = test_state(directory.path()).await;
5459        let app = build_router(state);
5460        for (route, marker) in [
5461            ("/v1/embeddings", "`/v1/embeddings`"),
5462            ("/v1/audio/speech", "`/v1/audio/*`"),
5463            ("/v1/images/generations", "`/v1/images/*`"),
5464            ("/v1/completions", "`/v1/completions`"),
5465            ("/v1/models", "`/v1/models`"),
5466            ("/v1/files", "`/v1/files`"),
5467            ("/v1/moderations", "`/v1/moderations`"),
5468            ("/v1/assistants", "`/v1/assistants`"),
5469            ("/v1/fine_tuning/jobs", "`/v1/fine_tuning/*`"),
5470        ] {
5471            assert!(
5472                doc.contains(marker),
5473                "the Unsupported list lost {marker:?} — if {route} is now \
5474                 implemented, update OPENAI-COMPATIBILITY.md; if not, restore it"
5475            );
5476            let response = app
5477                .clone()
5478                .oneshot(
5479                    Request::builder()
5480                        .method("POST")
5481                        .uri(route)
5482                        .header("content-type", "application/json")
5483                        .body(Body::from("{}"))
5484                        .unwrap(),
5485                )
5486                .await
5487                .unwrap();
5488            assert_eq!(
5489                response.status(),
5490                StatusCode::NOT_FOUND,
5491                "{route} no longer 404s — OPENAI-COMPATIBILITY.md's Unsupported \
5492                 section promises it does; update the doc with the new behavior"
5493            );
5494        }
5495    }
5496
5497    /// Register item 25 (docs drift) — sub-clause for the product
5498    /// console at docs/app/. It ships as an offline HTML/JS
5499    /// simulation of the whole operator flow (setup, sessions,
5500    /// receipt verification, evidence layout) and is the customer-
5501    /// facing evaluation surface. Every named factual claim in the
5502    /// console must be pinned to real code: schema, topics, status
5503    /// codes, and the stop-reason taxonomy. If the code drifts and
5504    /// nobody updates the console, prospects clicking through it
5505    /// see a product that no longer exists.
5506    ///
5507    /// Verified accurate live pass 32; this test locks the pins so
5508    /// any future rename in receipt.rs, model.rs, or the topic set
5509    /// trips CI unless the console is updated too.
5510    #[test]
5511    fn product_console_pins_match_real_schema_and_taxonomy() {
5512        // The pitch/investor walkthrough moved to /app/pitch/ when the
5513        // main console became a multi-tenant SaaS app. That's the surface
5514        // that still renders sample receipts word-for-word, so this test
5515        // continues to pin against it.
5516        let console_js = include_str!("../../../docs/app/pitch/console.js");
5517        let console_html = include_str!("../../../docs/app/pitch/index.html");
5518        let combined = format!("{console_js}\n{console_html}");
5519
5520        // Receipt version — the console shows receipt_version: 2 as
5521        // the SIGNED-body claim. If av-receipts bumps to v3 the
5522        // console's tour renders a version that never existed.
5523        assert_eq!(
5524            av_receipts::RECEIPT_VERSION,
5525            2,
5526            "receipt version bumped from 2; update docs/app/pitch/console.js's \
5527             receiptHtml() to render the new version",
5528        );
5529        assert!(
5530            combined.contains(r#""receipt_version""#) && combined.contains(">2<"),
5531            "the console must render receipt_version = 2 to match \
5532             av_receipts::RECEIPT_VERSION",
5533        );
5534
5535        // Every ReceiptBody top-level key the console renders must
5536        // be a real field. A rename in receipt.rs without touching
5537        // the console makes the sample receipt structurally invalid.
5538        for key in [
5539            "receipt_version",
5540            "receipt_id",
5541            "session_id",
5542            "issued_at",
5543            "issued_at_iso",
5544            "ai_agent",
5545            "subject",
5546            "tool_calls",
5547            "cost",
5548            "stop_reason_id",
5549            "stop_reason",
5550            "key_id",
5551            "public_key_b64",
5552            "signature_b64",
5553        ] {
5554            assert!(
5555                combined.contains(&format!(r#""{key}""#)),
5556                "receipt field `{key}` is not rendered by the product \
5557                 console — either the field was renamed (update the \
5558                 console) or the console was truncated (update this test)",
5559            );
5560        }
5561
5562        // The receipt's cost sub-block: rename any of these and the
5563        // console's tampering demo (change one digit of cost_usd_micros
5564        // → signature refuses to verify) suddenly points at a field
5565        // that does not exist.
5566        for cost_key in [
5567            "prompt_tokens",
5568            "completion_tokens",
5569            "cached_tokens",
5570            "cost_usd_micros",
5571        ] {
5572            assert!(
5573                combined.contains(&format!(r#""{cost_key}""#)),
5574                "cost field `{cost_key}` is not rendered by the console",
5575            );
5576        }
5577
5578        // The tampering demo targets cost_usd_micros. Pin the
5579        // console's tamper-target field name so if we ever move the
5580        // integer field the demo doesn't silently point at a String.
5581        assert!(
5582            combined.contains("cost_usd_micros"),
5583            "console tampering demo relies on cost_usd_micros being \
5584             the integer field it changes; rename would break the demo",
5585        );
5586
5587        // Stop reason id 92 = "Budget Exceeded" — the console renders
5588        // exactly this pair for the receipt sample (the tour text
5589        // says "the receipt attests a session that hit its budget").
5590        assert_eq!(
5591            av_events::StopReason::BudgetExceeded.id(),
5592            92,
5593            "BudgetExceeded id changed from 92; update the console's \
5594             sample receipt receiptHtml()",
5595        );
5596        assert_eq!(
5597            av_events::StopReason::BudgetExceeded.caption(),
5598            "Budget Exceeded",
5599            "BudgetExceeded caption changed; update the console",
5600        );
5601        assert!(
5602            combined.contains(r#""stop_reason_id""#) && combined.contains(">92<"),
5603            "console must render stop_reason_id = 92 to match \
5604             StopReason::BudgetExceeded",
5605        );
5606        assert!(
5607            combined.contains("Budget Exceeded"),
5608            "console must render the 'Budget Exceeded' caption for the \
5609             sample receipt",
5610        );
5611
5612        // OCSF topics named by the console's evidence tree. The
5613        // bridge topic list has drifted in the past; any rename
5614        // that removes agent.tool_call or agent.session breaks the
5615        // console's "OCSF events → your SIEM" claim silently.
5616        for topic in ["agent.tool_call", "agent.session"] {
5617            assert!(
5618                combined.contains(topic),
5619                "OCSF topic `{topic}` is not rendered by the console but is \
5620                 shown as the file that lands in the operator's SIEM",
5621            );
5622        }
5623
5624        // The 403 policy-refusal HTTP status is the console's core
5625        // "money kept in the building" demo (issue_refund $8,400
5626        // refused → HTTP 403). Pinned against the pipeline's
5627        // Blocked → 403 mapping (openai_error_body_test elsewhere).
5628        assert!(
5629            combined.contains("HTTP 403"),
5630            "console must render the 403 policy-refusal status the \
5631             pipeline emits (deliberately not 429, which SDKs auto-retry)",
5632        );
5633
5634        // The default listen port shown throughout the setup + one-line
5635        // integration sections.
5636        assert!(
5637            combined.contains("127.0.0.1:8484"),
5638            "console must render the default listen address 127.0.0.1:8484; \
5639             raise or lower the default and the setup/curl blocks lie",
5640        );
5641
5642        // The wizard-preview TOML must render `max_tool_calls` in its
5643        // real SHAPE, not just its name: `BudgetSpec.max_tool_calls` is
5644        // a per-tool map (`[budget.max_tool_calls]` table), and the
5645        // console once rendered it as a bare scalar under `[budget]` —
5646        // a config that `deny_unknown_fields` + serde reject with
5647        // "invalid type: integer, expected a map" the moment a prospect
5648        // copies the "written for you, live" file. Substring name
5649        // checks alone codified that drift; pin the shape.
5650        assert!(
5651            combined.contains("budget.max_tool_calls"),
5652            "console's TOML preview must render the per-tool cap as the \
5653             `[budget.max_tool_calls]` table the real BudgetSpec requires",
5654        );
5655        assert!(
5656            !console_js.contains(r#"max_tool_calls</span> = "#),
5657            "console's TOML preview renders `max_tool_calls = <scalar>`, \
5658             which the real config parser rejects (BudgetSpec.max_tool_calls \
5659             is a BTreeMap; the valid shape is a [budget.max_tool_calls] table)",
5660        );
5661    }
5662
5663    struct NullBus;
5664
5665    struct SlowStore {
5666        inner: InMemoryStore,
5667        calls: Arc<std::sync::atomic::AtomicUsize>,
5668    }
5669
5670    impl StateStore for SlowStore {
5671        fn add(&self, key: &str, delta: u64) -> Result<u64, StateError> {
5672            self.inner.add(key, delta)
5673        }
5674
5675        fn get(&self, key: &str) -> Result<u64, StateError> {
5676            self.inner.get(key)
5677        }
5678
5679        fn try_spend(&self, key: &str, amount: u64, limit: u64) -> Result<bool, StateError> {
5680            self.calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
5681            std::thread::sleep(Duration::from_millis(100));
5682            self.inner.try_spend(key, amount, limit)
5683        }
5684
5685        fn try_spend_many(&self, spends: &[Spend]) -> Result<av_state::TrySpendOutcome, StateError> {
5686            // R68: `ActionBudget::try_tokens` now routes through
5687            // `try_spend_many` (single-key) so the reported
5688            // `remaining` comes from the atomic section that
5689            // committed the spend (R66 F3). Instrument this path
5690            // identically to `try_spend` so `SlowStore` still
5691            // observes the reactor-blocking regression test
5692            // (`completion_budget_store_does_not_block_stream_runtime`
5693            // asserts the two per-request spend calls).
5694            self.calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
5695            std::thread::sleep(Duration::from_millis(100));
5696            self.inner.try_spend_many(spends)
5697        }
5698
5699        fn remove(&self, key: &str) {
5700            self.inner.remove(key);
5701        }
5702    }
5703
5704    impl EventBus for NullBus {
5705        fn publish(&self, topic: &str, _key: &str, _value: &Value) -> Result<PublishAck, BusError> {
5706            Ok(PublishAck {
5707                topic: topic.to_owned(),
5708                partition: 0,
5709                offset: 0,
5710            })
5711        }
5712
5713        fn fetch(
5714            &self,
5715            _topic: &str,
5716            _partition: u32,
5717            _offset: u64,
5718            _max: usize,
5719        ) -> Result<Vec<StoredEvent>, BusError> {
5720            Ok(Vec::new())
5721        }
5722
5723        fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
5724            Ok(1)
5725        }
5726
5727        fn topics(&self) -> Vec<String> {
5728            av_events::EventClass::all()
5729                .iter()
5730                .map(|class| class.topic().to_owned())
5731                .collect()
5732        }
5733    }
5734
5735    async fn mock_chat(Json(payload): Json<Value>) -> Response {
5736        if payload.get("model").and_then(Value::as_str) == Some("empty-error") {
5737            let mut response = Response::new(Body::empty());
5738            *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
5739            response.headers_mut().insert(
5740                axum::http::header::CONTENT_TYPE,
5741                HeaderValue::from_static("application/json"),
5742            );
5743            return response;
5744        }
5745        if payload.get("model").and_then(Value::as_str) == Some("json-error") {
5746            return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({}))).into_response();
5747        }
5748        if payload.get("model").and_then(Value::as_str) == Some("empty-success") {
5749            return Json(json!({})).into_response();
5750        }
5751        if payload.get("model").and_then(Value::as_str) == Some("split-sse") {
5752            // One SSE frame split across two TCP chunks, cut mid-way
5753            // through the é's UTF-8 encoding (0xc3 0xa9). Exercises the
5754            // protocol_buffer reassembly path that no other SSE mock
5755            // reaches (every other SSE mock emits whole
5756            // frames per chunk).
5757            let frame = "data: {\"choices\":[{\"delta\":{\"content\":\"héllo\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"
5758                .as_bytes()
5759                .to_vec();
5760            let split = frame.iter().position(|byte| *byte == 0xc3).unwrap() + 1;
5761            let chunks = vec![
5762                Ok::<_, std::convert::Infallible>(Bytes::copy_from_slice(&frame[..split])),
5763                Ok(Bytes::copy_from_slice(&frame[split..])),
5764            ];
5765            let mut response = Response::new(Body::from_stream(futures::stream::iter(chunks)));
5766            response.headers_mut().insert(
5767                axum::http::header::CONTENT_TYPE,
5768                HeaderValue::from_static("text/event-stream"),
5769            );
5770            return response;
5771        }
5772        if payload.get("model").and_then(Value::as_str) == Some("truncated-stream") {
5773            // One delta, then a clean end: no finish_reason, no usage,
5774            // no [DONE] — the §6.4 truncation shape (provider crash,
5775            // LB idle-timeout).
5776            let mut response = Response::new(Body::from(
5777                "data: {\"choices\":[{\"delta\":{\"content\":\"partial ans\"}}]}\n\n",
5778            ));
5779            response.headers_mut().insert(
5780                axum::http::header::CONTENT_TYPE,
5781                HeaderValue::from_static("text/event-stream"),
5782            );
5783            return response;
5784        }
5785        if payload.get("model").and_then(Value::as_str) == Some("severed-stream") {
5786            // One delta, a real pause (so the pipeline's peek-before-
5787            // commit completes and the response is streaming through
5788            // `AbortFinalizingStream`), then a mid-flight stream ERROR —
5789            // the wire shape of a provider crash / LB kill (TCP RST),
5790            // distinct from `truncated-stream`'s clean body end. Without
5791            // the pause the error surfaces during the pipeline's initial
5792            // send instead, which is a different (already-Other) path.
5793            let chunks = futures::stream::unfold(0u8, |state| async move {
5794                match state {
5795                    0 => Some((
5796                        Ok::<_, std::io::Error>(Bytes::from_static(
5797                            b"data: {\"choices\":[{\"delta\":{\"content\":\"partial ans\"}}]}\n\n",
5798                        )),
5799                        1,
5800                    )),
5801                    1 => {
5802                        tokio::time::sleep(Duration::from_millis(150)).await;
5803                        Some((Err(std::io::Error::other("connection reset by peer")), 2))
5804                    }
5805                    _ => None,
5806                }
5807            });
5808            let mut response = Response::new(Body::from_stream(chunks));
5809            response.headers_mut().insert(
5810                axum::http::header::CONTENT_TYPE,
5811                HeaderValue::from_static("text/event-stream"),
5812            );
5813            return response;
5814        }
5815        if payload.get("model").and_then(Value::as_str) == Some("provisional-usage") {
5816            let chunks = vec![
5817                Ok::<_, std::convert::Infallible>(Bytes::from_static(
5818                    b"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n",
5819                )),
5820                Ok(Bytes::from_static(
5821                    b"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":2}}\n\n",
5822                )),
5823            ];
5824            let mut response = Response::new(Body::from_stream(futures::stream::iter(chunks)));
5825            response.headers_mut().insert(
5826                axum::http::header::CONTENT_TYPE,
5827                HeaderValue::from_static("text/event-stream"),
5828            );
5829            return response;
5830        }
5831        if payload.get("model").and_then(Value::as_str) == Some("tool-no-usage") {
5832            let arguments = "x".repeat(4_096);
5833            let event = format!(
5834                "data: {{\"choices\":[{{\"delta\":{{\"tool_calls\":[{{\"index\":0,\"function\":{{\"name\":\"read\",\"arguments\":\"{arguments}\"}}}}]}}}}]}}\n\n"
5835            );
5836            let mut response = Response::new(Body::from(event));
5837            response.headers_mut().insert(
5838                axum::http::header::CONTENT_TYPE,
5839                HeaderValue::from_static("text/event-stream"),
5840            );
5841            return response;
5842        }
5843        if payload.get("model").and_then(Value::as_str) == Some("tool-oob-index") {
5844            let event = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":18446744073709551615,\"function\":{\"name\":\"read\",\"arguments\":\"{}\"}}]}}]}\n\n";
5845            let mut response = Response::new(Body::from(event));
5846            response.headers_mut().insert(
5847                axum::http::header::CONTENT_TYPE,
5848                HeaderValue::from_static("text/event-stream"),
5849            );
5850            return response;
5851        }
5852        if payload.get("model").and_then(Value::as_str) == Some("tool-conflicting-id") {
5853            // One frame, two deltas landing on the same accumulator slot
5854            // (choice 0, index 0) with DIFFERENT ids — the malformed
5855            // shape the id-conflict guard must fail closed on.
5856            let event = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-a\",\"function\":{\"name\":\"read\",\"arguments\":\"{\"}},{\"index\":0,\"id\":\"call-b\",\"function\":{\"arguments\":\"}\"}}]}}]}\n\n";
5857            let mut response = Response::new(Body::from(event));
5858            response.headers_mut().insert(
5859                axum::http::header::CONTENT_TYPE,
5860                HeaderValue::from_static("text/event-stream"),
5861            );
5862            return response;
5863        }
5864        if payload.get("model").and_then(Value::as_str) == Some("tool-conflicting-name") {
5865            // Same slot, same id, DIFFERENT function names — the
5866            // name-conflict arm of the reuse guard.
5867            let event = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-a\",\"function\":{\"name\":\"read\",\"arguments\":\"{\"}},{\"index\":0,\"id\":\"call-a\",\"function\":{\"name\":\"db_write\",\"arguments\":\"}\"}}]}}]}\n\n";
5868            let mut response = Response::new(Body::from(event));
5869            response.headers_mut().insert(
5870                axum::http::header::CONTENT_TYPE,
5871                HeaderValue::from_static("text/event-stream"),
5872            );
5873            return response;
5874        }
5875        if payload.get("model").and_then(Value::as_str) == Some("overcharged-completion") {
5876            // Content chunk with `"usage": null` (estimate-charged), then an
5877            // authoritative usage frame LOWER than the estimate — the
5878            // completion-side over-charge shape.
5879            let chunks = vec![
5880                Ok::<_, std::convert::Infallible>(Bytes::from_static(
5881                    b"data: {\"choices\":[{\"delta\":{\"content\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"},\"finish_reason\":\"stop\"}]}\n\n",
5882                )),
5883                Ok(Bytes::from_static(
5884                    b"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1}}\n\n",
5885                )),
5886            ];
5887            let mut response = Response::new(Body::from_stream(futures::stream::iter(chunks)));
5888            response.headers_mut().insert(
5889                axum::http::header::CONTENT_TYPE,
5890                HeaderValue::from_static("text/event-stream"),
5891            );
5892            return response;
5893        }
5894        if payload.get("model").and_then(Value::as_str) == Some("regressive-usage") {
5895            let chunks = vec![
5896                Ok::<_, std::convert::Infallible>(Bytes::from_static(
5897                    b"data: {\"choices\":[{\"delta\":{\"content\":\"first\"}}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":100,\"prompt_tokens_details\":{\"cached_tokens\":5}},\"cost_usd\":0.01}\n\n",
5898                )),
5899                Ok(Bytes::from_static(
5900                    b"data: {\"choices\":[{\"delta\":{\"content\":\"second\"}}],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":1,\"prompt_tokens_details\":{\"cached_tokens\":4}},\"cost_usd\":0.001}\n\n",
5901                )),
5902            ];
5903            let mut response = Response::new(Body::from_stream(futures::stream::iter(chunks)));
5904            response.headers_mut().insert(
5905                axum::http::header::CONTENT_TYPE,
5906                HeaderValue::from_static("text/event-stream"),
5907            );
5908            return response;
5909        }
5910        if payload.get("model").and_then(Value::as_str) == Some("split-json") {
5911            let body = serde_json::to_vec(&json!({
5912                "model": "split-json",
5913                "choices": [{"message": {"role": "assistant", "content": "héllo"}}],
5914                "usage": {"prompt_tokens": 5, "completion_tokens": 2}
5915            }))
5916            .unwrap();
5917            let split = body.iter().position(|byte| *byte == 0xc3).unwrap() + 1;
5918            let chunks = vec![
5919                Ok::<_, std::convert::Infallible>(Bytes::copy_from_slice(&body[..split])),
5920                Ok(Bytes::copy_from_slice(&body[split..])),
5921            ];
5922            let mut response = Response::new(Body::from_stream(futures::stream::iter(chunks)));
5923            response.headers_mut().insert(
5924                axum::http::header::CONTENT_TYPE,
5925                HeaderValue::from_static("application/json"),
5926            );
5927            return response;
5928        }
5929        if payload.get("model").and_then(Value::as_str) == Some("gzip-encoded") {
5930            // Body content is irrelevant: the encoding refusal fires on
5931            // the header alone, before any byte is read.
5932            let mut response = Response::new(Body::from("compressed-bytes"));
5933            response.headers_mut().insert(
5934                axum::http::header::CONTENT_TYPE,
5935                HeaderValue::from_static("application/json"),
5936            );
5937            response.headers_mut().insert(
5938                axum::http::header::CONTENT_ENCODING,
5939                HeaderValue::from_static("gzip"),
5940            );
5941            return response;
5942        }
5943        if payload.get("model").and_then(Value::as_str) == Some("malformed-json") {
5944            let mut response = Response::new(Body::from("{\"choices\":["));
5945            response.headers_mut().insert(
5946                axum::http::header::CONTENT_TYPE,
5947                HeaderValue::from_static("application/json"),
5948            );
5949            return response;
5950        }
5951        if payload.get("stream").and_then(Value::as_bool).unwrap_or(false) {
5952            let event = "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":3,\"cost_usd\":0.00125,\"prompt_tokens_details\":{\"cached_tokens\":4}}}\n\ndata: [DONE]\n\n";
5953            let bytes = event.as_bytes();
5954            let chunks = vec![
5955                Ok::<_, std::convert::Infallible>(Bytes::copy_from_slice(&bytes[..19])),
5956                Ok(Bytes::copy_from_slice(&bytes[19..97])),
5957                Ok(Bytes::copy_from_slice(&bytes[97..])),
5958            ];
5959            let mut response = Response::new(Body::from_stream(futures::stream::iter(chunks)));
5960            response.headers_mut().insert(
5961                axum::http::header::CONTENT_TYPE,
5962                HeaderValue::from_static("text/event-stream"),
5963            );
5964            response
5965        } else {
5966            Json(json!({"choices": [{"message": {"role": "assistant", "content": "hello"}}]})).into_response()
5967        }
5968    }
5969
5970    async fn counting_tool(
5971        State(calls): State<Arc<std::sync::atomic::AtomicUsize>>,
5972        body: Bytes,
5973    ) -> Response {
5974        calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
5975        (StatusCode::OK, body).into_response()
5976    }
5977
5978    async fn redirecting_tool(State(calls): State<Arc<std::sync::atomic::AtomicUsize>>) -> Response {
5979        calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
5980        (
5981            StatusCode::TEMPORARY_REDIRECT,
5982            [(axum::http::header::LOCATION, "/effect")],
5983        )
5984            .into_response()
5985    }
5986
5987    async fn redirected_effect(State(calls): State<Arc<std::sync::atomic::AtomicUsize>>) -> Response {
5988        calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
5989        StatusCode::OK.into_response()
5990    }
5991
5992    struct HeldToolState {
5993        arrived: std::sync::atomic::AtomicBool,
5994        release: tokio::sync::Notify,
5995    }
5996
5997    async fn held_tool(State(state): State<Arc<HeldToolState>>, body: Bytes) -> Response {
5998        state.arrived.store(true, std::sync::atomic::Ordering::Release);
5999        state.release.notified().await;
6000        (StatusCode::OK, body).into_response()
6001    }
6002
6003    async fn test_state(spool: &std::path::Path) -> (AppState, tokio::task::JoinHandle<()>) {
6004        test_state_with_token_cap(spool, None).await
6005    }
6006
6007    /// Mock Anthropic Messages upstream: named SSE events, content
6008    /// blocks, cumulative usage, native stop reasons (S3 step 2).
6009    async fn mock_anthropic_chat() -> Response {
6010        let frames = concat!(
6011            "event: message_start\n",
6012            "data: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-5\",\"usage\":{\"input_tokens\":25,\"output_tokens\":1}}}\n\n",
6013            "event: ping\n",
6014            "data: {\"type\": \"ping\"}\n\n",
6015            "event: content_block_start\n",
6016            "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
6017            "event: content_block_delta\n",
6018            "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello from anthropic\"}}\n\n",
6019            "event: content_block_stop\n",
6020            "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
6021            "event: message_delta\n",
6022            "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":15}}\n\n",
6023            "event: message_stop\n",
6024            "data: {\"type\":\"message_stop\"}\n\n",
6025        );
6026        let mut response = Response::new(Body::from(frames));
6027        response.headers_mut().insert(
6028            axum::http::header::CONTENT_TYPE,
6029            HeaderValue::from_static("text/event-stream"),
6030        );
6031        response
6032    }
6033
6034    async fn test_state_anthropic(spool: &std::path::Path) -> (AppState, tokio::task::JoinHandle<()>) {
6035        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6036        let address = listener.local_addr().unwrap();
6037        let provider = Router::new().route("/v1/chat/completions", post(mock_anthropic_chat));
6038        let server = tokio::spawn(async move {
6039            axum::serve(listener, provider).await.unwrap();
6040        });
6041        let mut config = crate::config::HarnessConfig::for_tests(
6042            &format!("http://{address}"),
6043            &spool.to_string_lossy(),
6044            &spool.to_string_lossy(),
6045        );
6046        config.provider = "anthropic".to_owned();
6047        let state = AppState::new(
6048            config,
6049            Arc::new(InMemoryStore::new()),
6050            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6051            Arc::new(NullBus),
6052            None,
6053            Arc::new(Ed25519Signer::from_seed(&[11; 32])),
6054        )
6055        .unwrap();
6056        (state, server)
6057    }
6058
6059    /// The body-limit rejection must honour the JSON
6060    /// error contract and name the knob + limit — axum's default is a
6061    /// bare text/plain "length limit exceeded".
6062    #[tokio::test]
6063    async fn oversize_body_413_is_json_and_names_the_knob() {
6064        let directory = tempfile::tempdir().unwrap();
6065        let (state, provider) = test_state(directory.path()).await;
6066        let limit = state.config.max_request_bytes;
6067        let app = build_router(state);
6068        let oversize = "x".repeat(limit + 1);
6069        let response = app
6070            .oneshot(
6071                Request::builder()
6072                    .method("POST")
6073                    .uri("/v1/chat/completions")
6074                    .header("content-type", "application/json")
6075                    .header("x-av-session", "too-big")
6076                    .body(Body::from(oversize))
6077                    .unwrap(),
6078            )
6079            .await
6080            .unwrap();
6081        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
6082        let content_type = response
6083            .headers()
6084            .get(axum::http::header::CONTENT_TYPE)
6085            .and_then(|value| value.to_str().ok())
6086            .unwrap_or_default()
6087            .to_owned();
6088        assert!(
6089            content_type.starts_with("application/json"),
6090            "413 must honour the JSON error contract, got {content_type}"
6091        );
6092        let body: Value = serde_json::from_slice(
6093            &axum::body::to_bytes(response.into_body(), 64 * 1024)
6094                .await
6095                .unwrap(),
6096        )
6097        .unwrap();
6098        let message = body["error"]["message"].as_str().unwrap();
6099        assert!(
6100            message.contains("max_request_bytes") && message.contains(&limit.to_string()),
6101            "413 must name the knob and the configured limit: {message}"
6102        );
6103        provider.abort();
6104    }
6105
6106    /// Mock Gemini streamGenerateContent upstream: unnamed data
6107    /// frames, candidates/parts, cumulative usageMetadata (S3 step 3).
6108    async fn mock_gemini_chat() -> Response {
6109        let frames = concat!(
6110            "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hello from gemini\"}],\"role\":\"model\"},\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":25,\"candidatesTokenCount\":1},\"modelVersion\":\"gemini-2.0-flash\"}\n\n",
6111            "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"!\"}]},\"finishReason\":\"STOP\",\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":25,\"candidatesTokenCount\":15}}\n\n",
6112        );
6113        let mut response = Response::new(Body::from(frames));
6114        response.headers_mut().insert(
6115            axum::http::header::CONTENT_TYPE,
6116            HeaderValue::from_static("text/event-stream"),
6117        );
6118        response
6119    }
6120
6121    async fn test_state_gemini(spool: &std::path::Path) -> (AppState, tokio::task::JoinHandle<()>) {
6122        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6123        let address = listener.local_addr().unwrap();
6124        let provider = Router::new().route("/v1/chat/completions", post(mock_gemini_chat));
6125        let server = tokio::spawn(async move {
6126            axum::serve(listener, provider).await.unwrap();
6127        });
6128        let mut config = crate::config::HarnessConfig::for_tests(
6129            &format!("http://{address}"),
6130            &spool.to_string_lossy(),
6131            &spool.to_string_lossy(),
6132        );
6133        config.provider = "gemini".to_owned();
6134        let state = AppState::new(
6135            config,
6136            Arc::new(InMemoryStore::new()),
6137            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6138            Arc::new(NullBus),
6139            None,
6140            Arc::new(Ed25519Signer::from_seed(&[11; 32])),
6141        )
6142        .unwrap();
6143        (state, server)
6144    }
6145
6146    /// S3 step 3: a session against a mock Gemini
6147    /// upstream produces the same audit-chain shape as an OpenAI one —
6148    /// provider-true usage, SCREAMING_CASE stop reason folded into the
6149    /// stop taxonomy, verbatim relay.
6150    #[tokio::test]
6151    async fn gemini_session_records_the_same_audit_shape() {
6152        let directory = tempfile::tempdir().unwrap();
6153        let (state, provider) = test_state_gemini(directory.path()).await;
6154        let app = build_router(state.clone());
6155        let response = app.clone().oneshot(chat_request("gemini-flow")).await.unwrap();
6156        assert_eq!(response.status(), StatusCode::OK);
6157        let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
6158            .await
6159            .unwrap();
6160        let body = String::from_utf8_lossy(&body);
6161        assert!(
6162            body.contains("hello from gemini"),
6163            "streamed bytes must relay verbatim: {body}"
6164        );
6165
6166        let session = state.sessions.get("gemini-flow").unwrap();
6167        session.wait_for_worker_jobs().await;
6168        use std::sync::atomic::Ordering;
6169        assert_eq!(session.totals.prompt_tokens.load(Ordering::Acquire), 25);
6170        assert_eq!(session.totals.completion_tokens.load(Ordering::Acquire), 15);
6171
6172        let close = app
6173            .clone()
6174            .oneshot(
6175                Request::builder()
6176                    .method("POST")
6177                    .uri("/v1/sessions/gemini-flow/close")
6178                    .body(Body::empty())
6179                    .unwrap(),
6180            )
6181            .await
6182            .unwrap();
6183        assert_eq!(close.status(), StatusCode::OK);
6184        let promoted = app
6185            .clone()
6186            .oneshot(
6187                Request::builder()
6188                    .method("POST")
6189                    .uri("/v1/sessions/gemini-flow/promote")
6190                    .body(Body::empty())
6191                    .unwrap(),
6192            )
6193            .await
6194            .unwrap();
6195        assert_eq!(promoted.status(), StatusCode::OK);
6196        let receipt: av_receipts::Receipt = serde_json::from_slice(
6197            &axum::body::to_bytes(promoted.into_body(), 64 * 1024)
6198                .await
6199                .unwrap(),
6200        )
6201        .unwrap();
6202        receipt.verify_embedded().unwrap();
6203        assert_eq!(receipt.body.stop_reason_id, av_events::StopReason::Stop.id());
6204        assert_eq!(receipt.body.cost.prompt_tokens, 25);
6205        assert_eq!(receipt.body.cost.completion_tokens, 15);
6206        provider.abort();
6207    }
6208
6209    /// Gemini mock: functionCall part in one frame, then finishReason:STOP
6210    /// in the next. This is Gemini's actual wire shape when the model
6211    /// decides to invoke a tool — Google's API does NOT emit a dedicated
6212    /// tool-use finish reason; it uses STOP and expects the client to
6213    /// notice the functionCall part in the candidate content.
6214    async fn mock_gemini_tool_chat() -> Response {
6215        let frames = concat!(
6216            "data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"Paris\"}}}]},\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":15,\"candidatesTokenCount\":10},\"modelVersion\":\"gemini-2.0-flash\"}\n\n",
6217            "data: {\"candidates\":[{\"content\":{\"parts\":[]},\"finishReason\":\"STOP\",\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":15,\"candidatesTokenCount\":10}}\n\n",
6218        );
6219        let mut response = Response::new(Body::from(frames));
6220        response.headers_mut().insert(
6221            axum::http::header::CONTENT_TYPE,
6222            HeaderValue::from_static("text/event-stream"),
6223        );
6224        response
6225    }
6226
6227    async fn test_state_gemini_tool(spool: &std::path::Path) -> (AppState, tokio::task::JoinHandle<()>) {
6228        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6229        let address = listener.local_addr().unwrap();
6230        let provider = Router::new().route("/v1/chat/completions", post(mock_gemini_tool_chat));
6231        let server = tokio::spawn(async move {
6232            axum::serve(listener, provider).await.unwrap();
6233        });
6234        let mut config = crate::config::HarnessConfig::for_tests(
6235            &format!("http://{address}"),
6236            &spool.to_string_lossy(),
6237            &spool.to_string_lossy(),
6238        );
6239        config.provider = "gemini".to_owned();
6240        let state = AppState::new(
6241            config,
6242            Arc::new(InMemoryStore::new()),
6243            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6244            Arc::new(NullBus),
6245            None,
6246            Arc::new(Ed25519Signer::from_seed(&[13; 32])),
6247        )
6248        .unwrap();
6249        (state, server)
6250    }
6251
6252    /// Register item 28: the ProviderAdapter trait exists so downstream
6253    /// code sees ONE canonical shape regardless of the upstream. That
6254    /// includes stop-reason semantics — a session where the agent
6255    /// invoked a tool must attest `StopReason::ToolUse` regardless of
6256    /// which provider was upstream. OpenAI emits `finish_reason:
6257    /// "tool_calls"` and Anthropic emits `stop_reason: "tool_use"` in
6258    /// this case, both mapping cleanly to ToolUse. Gemini does NOT: it
6259    /// emits `finishReason: STOP` and expects the client to notice the
6260    /// `functionCall` part. Without normalization the receipt attests
6261    /// `StopReason::Stop` for the Gemini flow — an auditor grouping
6262    /// sessions by `stop_reason` would count Gemini tool sessions as
6263    /// clean stops and OpenAI/Anthropic tool sessions as tool_use.
6264    /// That is exactly the adapter-leak the trait exists to prevent.
6265    #[tokio::test]
6266    async fn gemini_tool_call_with_finish_stop_attests_tool_use() {
6267        let directory = tempfile::tempdir().unwrap();
6268        let (state, provider) = test_state_gemini_tool(directory.path()).await;
6269        let app = build_router(state.clone());
6270        let response = app.clone().oneshot(chat_request("gemini-tool")).await.unwrap();
6271        assert_eq!(response.status(), StatusCode::OK);
6272        let _ = axum::body::to_bytes(response.into_body(), 64 * 1024).await;
6273        let session = state.sessions.get("gemini-tool").unwrap();
6274        session.wait_for_worker_jobs().await;
6275
6276        // The receipt is the ground truth: a Gemini tool-call session
6277        // must record ToolUse (id 3), not Stop (id 1).
6278        let close = app
6279            .clone()
6280            .oneshot(
6281                Request::builder()
6282                    .method("POST")
6283                    .uri("/v1/sessions/gemini-tool/close")
6284                    .body(Body::empty())
6285                    .unwrap(),
6286            )
6287            .await
6288            .unwrap();
6289        assert_eq!(close.status(), StatusCode::OK);
6290        let promoted = app
6291            .clone()
6292            .oneshot(
6293                Request::builder()
6294                    .method("POST")
6295                    .uri("/v1/sessions/gemini-tool/promote")
6296                    .body(Body::empty())
6297                    .unwrap(),
6298            )
6299            .await
6300            .unwrap();
6301        assert_eq!(promoted.status(), StatusCode::OK);
6302        let receipt: av_receipts::Receipt = serde_json::from_slice(
6303            &axum::body::to_bytes(promoted.into_body(), 64 * 1024)
6304                .await
6305                .unwrap(),
6306        )
6307        .unwrap();
6308        receipt.verify_embedded().unwrap();
6309        assert_eq!(
6310            receipt.body.stop_reason_id,
6311            av_events::StopReason::ToolUse.id(),
6312            "a Gemini session that ended with a functionCall part must attest \
6313             StopReason::ToolUse (id {}), not Stop (id {}) — the adapter must \
6314             normalize provider-specific quirks so downstream stop-reason \
6315             analytics are provider-agnostic. Received: stop_reason_id={}, \
6316             stop_reason={:?}.",
6317            av_events::StopReason::ToolUse.id(),
6318            av_events::StopReason::Stop.id(),
6319            receipt.body.stop_reason_id,
6320            receipt.body.stop_reason,
6321        );
6322        provider.abort();
6323    }
6324
6325    /// Gemini parallel function calling: two COMPLETE `functionCall`
6326    /// parts arriving in SEPARATE streamed chunks. Gemini part
6327    /// positions restart at 0 in every chunk, so keying the
6328    /// accumulator on the frame-local position collided both calls at
6329    /// (choice 0, index 0): same-name calls concatenated their args
6330    /// into one unparseable record (attested as `{"raw": ...}`), and
6331    /// different-name calls hard-failed a legitimate stream on the
6332    /// name-conflict guard. Complete deltas must each get a fresh slot.
6333    async fn mock_gemini_parallel_tool_chat() -> Response {
6334        let frames = concat!(
6335            "data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"Paris\"}}}]},\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":15,\"candidatesTokenCount\":10},\"modelVersion\":\"gemini-2.0-flash\"}\n\n",
6336            "data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_time\",\"args\":{\"city\":\"London\"}}}]},\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":15,\"candidatesTokenCount\":20}}\n\n",
6337            "data: {\"candidates\":[{\"content\":{\"parts\":[]},\"finishReason\":\"STOP\",\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":15,\"candidatesTokenCount\":20}}\n\n",
6338        );
6339        let mut response = Response::new(Body::from(frames));
6340        response.headers_mut().insert(
6341            axum::http::header::CONTENT_TYPE,
6342            HeaderValue::from_static("text/event-stream"),
6343        );
6344        response
6345    }
6346
6347    #[tokio::test]
6348    async fn gemini_parallel_function_calls_across_chunks_attest_as_two_calls() {
6349        let directory = tempfile::tempdir().unwrap();
6350        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6351        let address = listener.local_addr().unwrap();
6352        let upstream = Router::new().route("/v1/chat/completions", post(mock_gemini_parallel_tool_chat));
6353        let provider = tokio::spawn(async move {
6354            axum::serve(listener, upstream).await.unwrap();
6355        });
6356        let mut config = crate::config::HarnessConfig::for_tests(
6357            &format!("http://{address}"),
6358            &directory.path().to_string_lossy(),
6359            &directory.path().to_string_lossy(),
6360        );
6361        config.provider = "gemini".to_owned();
6362        let state = AppState::new(
6363            config,
6364            Arc::new(InMemoryStore::new()),
6365            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6366            Arc::new(NullBus),
6367            None,
6368            Arc::new(Ed25519Signer::from_seed(&[17; 32])),
6369        )
6370        .unwrap();
6371        let app = build_router(state.clone());
6372        let response = app
6373            .clone()
6374            .oneshot(chat_request("gemini-parallel-tools"))
6375            .await
6376            .unwrap();
6377        assert_eq!(response.status(), StatusCode::OK, "the stream must not hard-fail");
6378        let _ = axum::body::to_bytes(response.into_body(), 64 * 1024).await;
6379        let session = state.sessions.get("gemini-parallel-tools").unwrap();
6380        session.wait_for_worker_jobs().await;
6381        let close = app
6382            .clone()
6383            .oneshot(
6384                Request::builder()
6385                    .method("POST")
6386                    .uri("/v1/sessions/gemini-parallel-tools/close")
6387                    .body(Body::empty())
6388                    .unwrap(),
6389            )
6390            .await
6391            .unwrap();
6392        assert_eq!(close.status(), StatusCode::OK);
6393
6394        // The unsigned ATIF trajectory is the attested ground truth.
6395        let digest = av_core::digest::sha256_hex(b"gemini-parallel-tools");
6396        let stem = digest.get(..32).unwrap();
6397        let trajectory: serde_json::Value =
6398            serde_json::from_slice(&std::fs::read(directory.path().join(format!("{stem}.json"))).unwrap())
6399                .unwrap();
6400        let mut calls: Vec<(String, serde_json::Value)> = Vec::new();
6401        for step in trajectory
6402            .pointer("/steps")
6403            .and_then(serde_json::Value::as_array)
6404            .cloned()
6405            .unwrap_or_default()
6406        {
6407            for call in step
6408                .pointer("/tool_calls")
6409                .and_then(serde_json::Value::as_array)
6410                .cloned()
6411                .unwrap_or_default()
6412            {
6413                calls.push((
6414                    call.pointer("/function_name")
6415                        .and_then(serde_json::Value::as_str)
6416                        .unwrap_or_default()
6417                        .to_owned(),
6418                    call.pointer("/arguments").cloned().unwrap_or_default(),
6419                ));
6420            }
6421        }
6422        assert_eq!(
6423            calls.len(),
6424            2,
6425            "two functionCall parts in separate chunks must attest as two \
6426             distinct tool calls, not merge at frame-local index 0: {trajectory}"
6427        );
6428        assert_eq!(calls[0].0, "get_weather");
6429        assert_eq!(calls[0].1, serde_json::json!({"city": "Paris"}));
6430        assert_eq!(calls[1].0, "get_time");
6431        assert_eq!(calls[1].1, serde_json::json!({"city": "London"}));
6432        provider.abort();
6433    }
6434
6435    /// Anthropic mock that emits `stop_reason: "refusal"` — the safety
6436    /// refusal stop reason Anthropic added with Claude Sonnet 4.5.
6437    /// Semantically identical to OpenAI's `content_filter` and Gemini's
6438    /// SAFETY/RECITATION/BLOCKLIST cluster.
6439    async fn mock_anthropic_refusal() -> Response {
6440        let frames = concat!(
6441            "event: message_start\n",
6442            "data: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-5\",\"usage\":{\"input_tokens\":15,\"output_tokens\":1}}}\n\n",
6443            "event: content_block_start\n",
6444            "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
6445            "event: content_block_delta\n",
6446            "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I can't help with that.\"}}\n\n",
6447            "event: content_block_stop\n",
6448            "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
6449            "event: message_delta\n",
6450            "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"refusal\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":6}}\n\n",
6451            "event: message_stop\n",
6452            "data: {\"type\":\"message_stop\"}\n\n",
6453        );
6454        let mut response = Response::new(Body::from(frames));
6455        response.headers_mut().insert(
6456            axum::http::header::CONTENT_TYPE,
6457            HeaderValue::from_static("text/event-stream"),
6458        );
6459        response
6460    }
6461
6462    async fn test_state_anthropic_refusal(
6463        spool: &std::path::Path,
6464    ) -> (AppState, tokio::task::JoinHandle<()>) {
6465        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6466        let address = listener.local_addr().unwrap();
6467        let provider = Router::new().route("/v1/chat/completions", post(mock_anthropic_refusal));
6468        let server = tokio::spawn(async move {
6469            axum::serve(listener, provider).await.unwrap();
6470        });
6471        let mut config = crate::config::HarnessConfig::for_tests(
6472            &format!("http://{address}"),
6473            &spool.to_string_lossy(),
6474            &spool.to_string_lossy(),
6475        );
6476        config.provider = "anthropic".to_owned();
6477        let state = AppState::new(
6478            config,
6479            Arc::new(InMemoryStore::new()),
6480            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6481            Arc::new(NullBus),
6482            None,
6483            Arc::new(Ed25519Signer::from_seed(&[15; 32])),
6484        )
6485        .unwrap();
6486        (state, server)
6487    }
6488
6489    /// Register item 28 — cross-provider stop-reason normalization for
6490    /// safety refusals. OpenAI emits `finish_reason: "content_filter"`,
6491    /// Gemini folds SAFETY/RECITATION/BLOCKLIST/PROHIBITED_CONTENT/SPII
6492    /// to `content_filter`, and Anthropic (Claude Sonnet 4.5 and later)
6493    /// emits `stop_reason: "refusal"`. All three name the same thing —
6494    /// the model refused to generate on safety grounds — but pre-fix
6495    /// only two of the three mapped to `StopReason::ContentFilter`;
6496    /// Anthropic's `refusal` silently fell through to Other. A safety-
6497    /// team dashboard filtering by `stop_reason == "Content Filter"`
6498    /// would count OpenAI/Gemini refusals and miss Anthropic ones.
6499    #[tokio::test]
6500    async fn anthropic_refusal_stop_reason_attests_content_filter() {
6501        let directory = tempfile::tempdir().unwrap();
6502        let (state, provider) = test_state_anthropic_refusal(directory.path()).await;
6503        let app = build_router(state.clone());
6504        let response = app
6505            .clone()
6506            .oneshot(chat_request("anthropic-refusal"))
6507            .await
6508            .unwrap();
6509        assert_eq!(response.status(), StatusCode::OK);
6510        let _ = axum::body::to_bytes(response.into_body(), 64 * 1024).await;
6511        let session = state.sessions.get("anthropic-refusal").unwrap();
6512        session.wait_for_worker_jobs().await;
6513        let close = app
6514            .clone()
6515            .oneshot(
6516                Request::builder()
6517                    .method("POST")
6518                    .uri("/v1/sessions/anthropic-refusal/close")
6519                    .body(Body::empty())
6520                    .unwrap(),
6521            )
6522            .await
6523            .unwrap();
6524        assert_eq!(close.status(), StatusCode::OK);
6525        let promoted = app
6526            .clone()
6527            .oneshot(
6528                Request::builder()
6529                    .method("POST")
6530                    .uri("/v1/sessions/anthropic-refusal/promote")
6531                    .body(Body::empty())
6532                    .unwrap(),
6533            )
6534            .await
6535            .unwrap();
6536        assert_eq!(promoted.status(), StatusCode::OK);
6537        let receipt: av_receipts::Receipt = serde_json::from_slice(
6538            &axum::body::to_bytes(promoted.into_body(), 64 * 1024)
6539                .await
6540                .unwrap(),
6541        )
6542        .unwrap();
6543        receipt.verify_embedded().unwrap();
6544        assert_eq!(
6545            receipt.body.stop_reason_id,
6546            av_events::StopReason::ContentFilter.id(),
6547            "an Anthropic session that ended with stop_reason:refusal must attest \
6548             StopReason::ContentFilter (id {}), not Other (id {}). OpenAI's \
6549             content_filter and Gemini's SAFETY cluster both map to ContentFilter; \
6550             leaving Anthropic's refusal as Other silently splits safety-refusal \
6551             analytics along provider lines. Received: stop_reason_id={}, \
6552             stop_reason={:?}.",
6553            av_events::StopReason::ContentFilter.id(),
6554            av_events::StopReason::Other.id(),
6555            receipt.body.stop_reason_id,
6556            receipt.body.stop_reason,
6557        );
6558        provider.abort();
6559    }
6560
6561    /// Anthropic mock that emits `stop_reason:
6562    /// "model_context_window_exceeded"` — Anthropic's dedicated stop
6563    /// reason for when input + output overflows the context window.
6564    /// Semantically identical to OpenAI's `length` / Gemini's
6565    /// MAX_TOKENS.
6566    async fn mock_anthropic_context_window_exceeded() -> Response {
6567        let frames = concat!(
6568            "event: message_start\n",
6569            "data: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-5\",\"usage\":{\"input_tokens\":200000,\"output_tokens\":1}}}\n\n",
6570            "event: content_block_start\n",
6571            "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
6572            "event: content_block_delta\n",
6573            "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\n",
6574            "event: content_block_stop\n",
6575            "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
6576            "event: message_delta\n",
6577            "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"model_context_window_exceeded\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":3}}\n\n",
6578            "event: message_stop\n",
6579            "data: {\"type\":\"message_stop\"}\n\n",
6580        );
6581        let mut response = Response::new(Body::from(frames));
6582        response.headers_mut().insert(
6583            axum::http::header::CONTENT_TYPE,
6584            HeaderValue::from_static("text/event-stream"),
6585        );
6586        response
6587    }
6588
6589    async fn test_state_anthropic_ctx_exceeded(
6590        spool: &std::path::Path,
6591    ) -> (AppState, tokio::task::JoinHandle<()>) {
6592        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6593        let address = listener.local_addr().unwrap();
6594        let provider = Router::new().route(
6595            "/v1/chat/completions",
6596            post(mock_anthropic_context_window_exceeded),
6597        );
6598        let server = tokio::spawn(async move {
6599            axum::serve(listener, provider).await.unwrap();
6600        });
6601        let mut config = crate::config::HarnessConfig::for_tests(
6602            &format!("http://{address}"),
6603            &spool.to_string_lossy(),
6604            &spool.to_string_lossy(),
6605        );
6606        config.provider = "anthropic".to_owned();
6607        let state = AppState::new(
6608            config,
6609            Arc::new(InMemoryStore::new()),
6610            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6611            Arc::new(NullBus),
6612            None,
6613            Arc::new(Ed25519Signer::from_seed(&[17; 32])),
6614        )
6615        .unwrap();
6616        (state, server)
6617    }
6618
6619    /// Register item 28 — cross-provider stop-reason normalization for
6620    /// context-window overflow. OpenAI emits `finish_reason: "length"`,
6621    /// Gemini emits `MAX_TOKENS`, and Anthropic emits
6622    /// `stop_reason: "model_context_window_exceeded"`. All three name
6623    /// the same thing — the model hit a length limit — but pre-fix
6624    /// Anthropic's variant silently fell through to Other. A capacity
6625    /// dashboard filtering by `stop_reason == "Length"` would count
6626    /// OpenAI/Gemini overflows and miss Anthropic ones, hiding a real
6627    /// upgrade-tier signal.
6628    #[tokio::test]
6629    async fn anthropic_context_window_exceeded_attests_max_tokens() {
6630        let directory = tempfile::tempdir().unwrap();
6631        let (state, provider) = test_state_anthropic_ctx_exceeded(directory.path()).await;
6632        let app = build_router(state.clone());
6633        let response = app.clone().oneshot(chat_request("anthropic-ctx")).await.unwrap();
6634        assert_eq!(response.status(), StatusCode::OK);
6635        let _ = axum::body::to_bytes(response.into_body(), 64 * 1024).await;
6636        let session = state.sessions.get("anthropic-ctx").unwrap();
6637        session.wait_for_worker_jobs().await;
6638        let close = app
6639            .clone()
6640            .oneshot(
6641                Request::builder()
6642                    .method("POST")
6643                    .uri("/v1/sessions/anthropic-ctx/close")
6644                    .body(Body::empty())
6645                    .unwrap(),
6646            )
6647            .await
6648            .unwrap();
6649        assert_eq!(close.status(), StatusCode::OK);
6650        let promoted = app
6651            .clone()
6652            .oneshot(
6653                Request::builder()
6654                    .method("POST")
6655                    .uri("/v1/sessions/anthropic-ctx/promote")
6656                    .body(Body::empty())
6657                    .unwrap(),
6658            )
6659            .await
6660            .unwrap();
6661        assert_eq!(promoted.status(), StatusCode::OK);
6662        let receipt: av_receipts::Receipt = serde_json::from_slice(
6663            &axum::body::to_bytes(promoted.into_body(), 64 * 1024)
6664                .await
6665                .unwrap(),
6666        )
6667        .unwrap();
6668        receipt.verify_embedded().unwrap();
6669        assert_eq!(
6670            receipt.body.stop_reason_id,
6671            av_events::StopReason::MaxTokens.id(),
6672            "an Anthropic session that ended with \
6673             stop_reason:model_context_window_exceeded must attest \
6674             StopReason::MaxTokens (id {}), not Other (id {}). OpenAI's `length` \
6675             and Gemini's MAX_TOKENS both map to MaxTokens; leaving Anthropic's \
6676             overflow variant as Other hides capacity signals from operators. \
6677             Received: stop_reason_id={}, stop_reason={:?}.",
6678            av_events::StopReason::MaxTokens.id(),
6679            av_events::StopReason::Other.id(),
6680            receipt.body.stop_reason_id,
6681            receipt.body.stop_reason,
6682        );
6683        provider.abort();
6684    }
6685
6686    /// S3 step 2: a session against a mock Anthropic
6687    /// upstream produces the same audit-chain shape as an OpenAI one —
6688    /// provider-true usage in the totals, the native stop reason folded
6689    /// into the stop taxonomy, and the streamed bytes relayed verbatim.
6690    #[tokio::test]
6691    async fn anthropic_session_records_the_same_audit_shape() {
6692        let directory = tempfile::tempdir().unwrap();
6693        let (state, provider) = test_state_anthropic(directory.path()).await;
6694        let app = build_router(state.clone());
6695        let response = app.clone().oneshot(chat_request("anthropic-flow")).await.unwrap();
6696        assert_eq!(response.status(), StatusCode::OK);
6697        let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
6698            .await
6699            .unwrap();
6700        let body = String::from_utf8_lossy(&body);
6701        assert!(
6702            body.contains("hello from anthropic"),
6703            "streamed bytes must relay verbatim: {body}"
6704        );
6705
6706        let session = state.sessions.get("anthropic-flow").unwrap();
6707        session.wait_for_worker_jobs().await;
6708        // Provider-true accounting: prompt reconciles to input_tokens
6709        // (25), completion is the cumulative output_tokens (15) — not
6710        // the heuristic estimates.
6711        use std::sync::atomic::Ordering;
6712        assert_eq!(session.totals.prompt_tokens.load(Ordering::Acquire), 25);
6713        assert_eq!(session.totals.completion_tokens.load(Ordering::Acquire), 15);
6714
6715        let close = app
6716            .clone()
6717            .oneshot(
6718                Request::builder()
6719                    .method("POST")
6720                    .uri("/v1/sessions/anthropic-flow/close")
6721                    .body(Body::empty())
6722                    .unwrap(),
6723            )
6724            .await
6725            .unwrap();
6726        assert_eq!(close.status(), StatusCode::OK);
6727        let promoted = app
6728            .clone()
6729            .oneshot(
6730                Request::builder()
6731                    .method("POST")
6732                    .uri("/v1/sessions/anthropic-flow/promote")
6733                    .body(Body::empty())
6734                    .unwrap(),
6735            )
6736            .await
6737            .unwrap();
6738        assert_eq!(promoted.status(), StatusCode::OK);
6739        let receipt: av_receipts::Receipt = serde_json::from_slice(
6740            &axum::body::to_bytes(promoted.into_body(), 64 * 1024)
6741                .await
6742                .unwrap(),
6743        )
6744        .unwrap();
6745        receipt.verify_embedded().unwrap();
6746        // `end_turn` folds into the same Stop id an OpenAI `stop` does.
6747        assert_eq!(receipt.body.stop_reason_id, av_events::StopReason::Stop.id());
6748        assert_eq!(receipt.body.cost.prompt_tokens, 25);
6749        assert_eq!(receipt.body.cost.completion_tokens, 15);
6750        provider.abort();
6751    }
6752
6753    async fn test_state_with_token_cap(
6754        spool: &std::path::Path,
6755        max_tokens: Option<u64>,
6756    ) -> (AppState, tokio::task::JoinHandle<()>) {
6757        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6758        let address = listener.local_addr().unwrap();
6759        let provider = Router::new().route("/v1/chat/completions", post(mock_chat));
6760        let server = tokio::spawn(async move {
6761            axum::serve(listener, provider).await.unwrap();
6762        });
6763        let mut config = crate::config::HarnessConfig::for_tests(
6764            &format!("http://{address}"),
6765            &spool.to_string_lossy(),
6766            &spool.to_string_lossy(),
6767        );
6768        config.budget.max_tokens = max_tokens;
6769        let state = AppState::new(
6770            config,
6771            Arc::new(InMemoryStore::new()),
6772            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6773            Arc::new(NullBus),
6774            None,
6775            Arc::new(Ed25519Signer::from_seed(&[11; 32])),
6776        )
6777        .unwrap();
6778        (state, server)
6779    }
6780
6781    fn chat_request(session: &str) -> Request<Body> {
6782        chat_request_with_payload(session, chat_payload())
6783    }
6784
6785    /// OPENAI-COMPATIBILITY.md session semantics: a request WITHOUT
6786    /// `X-AV-Session` gets a server-generated id and "the session then
6787    /// closes immediately after the response — one turn, one audit
6788    /// event". Reproduced live pre-fix: 3 s after a 200, the session
6789    /// was still OPEN with zero receipts — a stock SDK caller's signed
6790    /// receipt waited on the idle sweeper (default 900 s).
6791    #[tokio::test]
6792    async fn header_less_one_shot_closes_immediately_after_the_response() {
6793        let directory = tempfile::tempdir().unwrap();
6794        let (state, provider) = test_state(directory.path()).await;
6795        let request = Request::builder()
6796            .method("POST")
6797            .uri("/v1/chat/completions")
6798            .header(axum::http::header::CONTENT_TYPE, "application/json")
6799            // NO x-av-session: the id is server-generated (ephemeral).
6800            .header("x-av-workflow", "unsigned")
6801            .body(Body::from(serde_json::to_vec(&chat_payload()).unwrap()))
6802            .unwrap();
6803        let response = build_router(state.clone()).oneshot(request).await.unwrap();
6804        assert_eq!(response.status(), StatusCode::OK);
6805        let session_id = response
6806            .headers()
6807            .get("x-av-session")
6808            .and_then(|v| v.to_str().ok())
6809            .expect("generated session id must be echoed in the response header")
6810            .to_owned();
6811        axum::body::to_bytes(response.into_body(), 1024 * 1024)
6812            .await
6813            .unwrap();
6814
6815        let session = state.sessions.get(&session_id).expect("session registered");
6816        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
6817        while !session.is_closed() {
6818            assert!(
6819                std::time::Instant::now() < deadline,
6820                "an ephemeral one-shot must close immediately after the response,                  not wait for the idle sweeper"
6821            );
6822            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6823        }
6824        provider.abort();
6825    }
6826
6827    /// Register item 8 (bind budget to principal), completion side.
6828    /// Admission debits both ledgers, but mid-stream/terminal
6829    /// completion tokens used to debit ONLY the session ledger — under
6830    /// the session-header-rotation attack the principal ledger is the
6831    /// only one that binds, so streamed completions were invisible to
6832    /// it: measured live, a rotating caller consumed >10× the
6833    /// principal token cap (refusal came from the ~30-token admission
6834    /// heuristic alone). Two rotated requests whose completions
6835    /// (1000 reported tokens each) dwarf their admission estimates:
6836    /// the second must be refused by `principal.max_tokens`, which
6837    /// only happens if completion tokens land on the principal ledger.
6838    #[tokio::test]
6839    async fn principal_budget_charges_completion_tokens_across_rotated_sessions() {
6840        let directory = tempfile::tempdir().unwrap();
6841        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6842        let address = listener.local_addr().unwrap();
6843        let provider = Router::new().route(
6844            "/v1/chat/completions",
6845            post(|| async {
6846                Json(json!({
6847                    "model": "mock",
6848                    "choices": [{
6849                        "message": {"role": "assistant", "content": "big completion"},
6850                        "finish_reason": "stop"
6851                    }],
6852                    "usage": {"prompt_tokens": 5, "completion_tokens": 1000}
6853                }))
6854            }),
6855        );
6856        let server = tokio::spawn(async move {
6857            axum::serve(listener, provider).await.unwrap();
6858        });
6859        let spool = directory.path().to_string_lossy().into_owned();
6860        let mut config =
6861            crate::config::HarnessConfig::for_tests(&format!("http://{address}"), &spool, &spool);
6862        // Session ledger wide open: only the principal ledger can refuse.
6863        config.budget.max_tokens = Some(1_000_000);
6864        // Fits one admission (~30) + one completion (1000) + a second
6865        // admission, but NOT the second completion.
6866        config.principal_budget = Some(av_state::BudgetSpec {
6867            max_tokens: Some(1500),
6868            ..av_state::BudgetSpec::default()
6869        });
6870        let state = AppState::new(
6871            config,
6872            Arc::new(InMemoryStore::new()),
6873            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6874            Arc::new(NullBus),
6875            None,
6876            Arc::new(Ed25519Signer::from_seed(&[11; 32])),
6877        )
6878        .unwrap();
6879        let app = build_router(state);
6880        let non_streamed = json!({
6881            "model": "mock",
6882            "messages": [{"role": "user", "content": "hello"}]
6883        });
6884
6885        let first = app
6886            .clone()
6887            .oneshot(chat_request_with_payload("principal-rot-1", non_streamed.clone()))
6888            .await
6889            .unwrap();
6890        assert_eq!(
6891            first.status(),
6892            StatusCode::OK,
6893            "first rotated request fits the principal budget"
6894        );
6895        axum::body::to_bytes(first.into_body(), 1024 * 1024)
6896            .await
6897            .unwrap();
6898
6899        // Different session id, same (anonymous) principal: its virgin
6900        // session ledger cannot refuse — only the principal ledger,
6901        // and only if the FIRST request's 1000 completion tokens
6902        // actually landed there.
6903        let second = app
6904            .oneshot(chat_request_with_payload("principal-rot-2", non_streamed))
6905            .await
6906            .unwrap();
6907        let status = second.status();
6908        let body = axum::body::to_bytes(second.into_body(), 1024 * 1024)
6909            .await
6910            .unwrap();
6911        let body = String::from_utf8_lossy(&body);
6912        assert_eq!(
6913            status,
6914            StatusCode::FORBIDDEN,
6915            "rotated completion spend must be refused by the principal ledger; got {status}: {body}"
6916        );
6917        assert!(
6918            body.contains("principal.max_tokens"),
6919            "refusal must name the principal-scoped limit: {body}"
6920        );
6921        server.abort();
6922    }
6923
6924    /// Pins the upstream Authorization wire contract per posture — the
6925    /// README/doctor headline claim "the key is injected server-side".
6926    /// Verified first against the real binary with a header-capturing
6927    /// upstream (register item 17, pass 15); this test keeps the three
6928    /// postures pinned in-tree:
6929    ///   * keyless + ignore_client_authorization: the client's
6930    ///     placeholder bearer is DISCARDED (no Authorization upstream);
6931    ///   * static key file: upstream sees the SERVER key, never the
6932    ///     client's junk;
6933    ///   * passthrough: the client's own bearer travels verbatim.
6934    /// The allowlist relay (openai-beta) and non-allowlisted header
6935    /// blocking (x-custom-header) ride along in every posture.
6936    #[tokio::test]
6937    async fn upstream_authorization_contract_by_posture() {
6938        type Captured = Arc<parking_lot::Mutex<Vec<(Option<String>, Option<String>, Option<String>)>>>;
6939        for posture in ["keyless_ignore", "static_key_file", "passthrough"] {
6940            let directory = tempfile::tempdir().unwrap();
6941            let captured: Captured = Arc::default();
6942            let sink = Arc::clone(&captured);
6943            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6944            let address = listener.local_addr().unwrap();
6945            let provider = Router::new().route(
6946                "/v1/chat/completions",
6947                post(
6948                    move |headers: axum::http::HeaderMap, Json(payload): Json<Value>| {
6949                        let sink = Arc::clone(&sink);
6950                        async move {
6951                            let get = |name: &str| {
6952                                headers.get(name).and_then(|v| v.to_str().ok()).map(str::to_owned)
6953                            };
6954                            sink.lock().push((
6955                                get("authorization"),
6956                                get("openai-beta"),
6957                                get("x-custom-header"),
6958                            ));
6959                            mock_chat(Json(payload)).await
6960                        }
6961                    },
6962                ),
6963            );
6964            let server = tokio::spawn(async move {
6965                axum::serve(listener, provider).await.unwrap();
6966            });
6967            let spool = directory.path().to_string_lossy().into_owned();
6968            let mut config =
6969                crate::config::HarnessConfig::for_tests(&format!("http://{address}"), &spool, &spool);
6970            match posture {
6971                "keyless_ignore" => config.ignore_client_authorization = true,
6972                "static_key_file" => {
6973                    let key_path = directory.path().join("upstream.key");
6974                    std::fs::write(&key_path, "sk-SERVER-SECRET").unwrap();
6975                    #[cfg(unix)]
6976                    {
6977                        use std::os::unix::fs::PermissionsExt as _;
6978                        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
6979                    }
6980                    config.upstream_api_key_file = Some(key_path.to_string_lossy().into_owned());
6981                }
6982                "passthrough" => config.upstream_authorization_passthrough = true,
6983                _ => unreachable!(),
6984            }
6985            let state = AppState::new(
6986                config,
6987                Arc::new(InMemoryStore::new()),
6988                Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
6989                Arc::new(NullBus),
6990                None,
6991                Arc::new(Ed25519Signer::from_seed(&[11; 32])),
6992            )
6993            .unwrap();
6994            let request = Request::builder()
6995                .method("POST")
6996                .uri("/v1/chat/completions")
6997                .header(axum::http::header::CONTENT_TYPE, "application/json")
6998                .header("authorization", "Bearer sk-CLIENT-JUNK")
6999                .header("openai-beta", "assistants=v2")
7000                .header("x-custom-header", "evil")
7001                .header("x-av-session", format!("auth-{posture}"))
7002                .header("x-av-workflow", "unsigned")
7003                .body(Body::from(serde_json::to_vec(&chat_payload()).unwrap()))
7004                .unwrap();
7005            let response = build_router(state).oneshot(request).await.unwrap();
7006            assert_eq!(response.status(), StatusCode::OK, "posture {posture} must serve");
7007            axum::body::to_bytes(response.into_body(), 1024 * 1024)
7008                .await
7009                .unwrap();
7010            let seen = captured.lock().clone();
7011            let (auth, beta, custom) = seen
7012                .first()
7013                .cloned()
7014                .unwrap_or_else(|| panic!("posture {posture}: upstream never saw the request"));
7015            match posture {
7016                "keyless_ignore" => assert_eq!(
7017                    auth, None,
7018                    "keyless posture must DISCARD the client placeholder bearer"
7019                ),
7020                "static_key_file" => assert_eq!(
7021                    auth.as_deref(),
7022                    Some("Bearer sk-SERVER-SECRET"),
7023                    "static-key posture must inject the server key, never the client's"
7024                ),
7025                "passthrough" => assert_eq!(
7026                    auth.as_deref(),
7027                    Some("Bearer sk-CLIENT-JUNK"),
7028                    "passthrough posture must forward the client bearer verbatim"
7029                ),
7030                _ => unreachable!(),
7031            }
7032            assert_eq!(
7033                beta.as_deref(),
7034                Some("assistants=v2"),
7035                "posture {posture}: allowlisted provider header must relay"
7036            );
7037            assert_eq!(
7038                custom, None,
7039                "posture {posture}: non-allowlisted client headers must never reach the upstream"
7040            );
7041            server.abort();
7042        }
7043    }
7044
7045    /// Live-stress finding: deterministic fail-closed lifecycle
7046    /// refusals (quarantined capture, unfulfillable promotion) used to
7047    /// surface as HTTP 500 — SDKs retried them pointlessly and 5xx-rate
7048    /// pagers fired on working policy. They must map to 409 Conflict,
7049    /// mirroring the tool path's state-conflict convention.
7050    #[tokio::test]
7051    async fn quarantined_session_close_and_promote_map_to_conflict_not_500() {
7052        let directory = tempfile::tempdir().unwrap();
7053        let (state, provider) = test_state(directory.path()).await;
7054        let app = build_router(state.clone());
7055        // Open a session, then quarantine it (capture failed).
7056        let opened = app.clone().oneshot(chat_request("quarantine-409")).await.unwrap();
7057        assert_eq!(opened.status(), StatusCode::OK);
7058        axum::body::to_bytes(opened.into_body(), 1024 * 1024)
7059            .await
7060            .unwrap();
7061        let session = state.sessions.get("quarantine-409").unwrap();
7062        session.wait_for_worker_jobs().await;
7063        session.mark_capture_failed();
7064
7065        let close = app
7066            .clone()
7067            .oneshot(
7068                Request::builder()
7069                    .method("POST")
7070                    .uri("/v1/sessions/quarantine-409/close")
7071                    .body(Body::empty())
7072                    .unwrap(),
7073            )
7074            .await
7075            .unwrap();
7076        assert_eq!(
7077            close.status(),
7078            StatusCode::CONFLICT,
7079            "capture-incomplete close refusal is a state conflict, not a server fault"
7080        );
7081        let promote = app
7082            .oneshot(
7083                Request::builder()
7084                    .method("POST")
7085                    .uri("/v1/sessions/quarantine-409/promote")
7086                    .body(Body::empty())
7087                    .unwrap(),
7088            )
7089            .await
7090            .unwrap();
7091        assert_eq!(
7092            promote.status(),
7093            StatusCode::CONFLICT,
7094            "unfulfillable promotion refusal is a state conflict, not a server fault"
7095        );
7096        provider.abort();
7097    }
7098
7099    fn chat_request_with_payload(session: &str, payload: Value) -> Request<Body> {
7100        Request::builder()
7101            .method("POST")
7102            .uri("/v1/chat/completions")
7103            .header(axum::http::header::CONTENT_TYPE, "application/json")
7104            .header("x-av-session", session)
7105            .header("x-av-workflow", "unsigned")
7106            .body(Body::from(serde_json::to_vec(&payload).unwrap()))
7107            .unwrap()
7108    }
7109
7110    fn chat_payload() -> Value {
7111        json!({
7112            "model": "mock",
7113            "stream": true,
7114            "messages": [{"role": "user", "content": "hello"}],
7115        })
7116    }
7117
7118    /// `/v1/chat/completions` must
7119    /// refuse duplicate top-level or nested JSON keys, mirroring the
7120    /// MCP path's `parse_tool_call` policy. `serde_json`'s default
7121    /// last-wins semantics would otherwise let a hostile client
7122    /// send `{"messages":[safe],"messages":[hostile]}` and the
7123    /// harness would see the hostile array while any auditor reading
7124    /// the raw request bytes sees the ambiguous document. Same
7125    /// class as the receipt-null malleability, applied at
7126    /// chat ingress.
7127    #[tokio::test]
7128    async fn chat_completions_refuses_duplicate_json_keys() {
7129        let directory = tempfile::tempdir().unwrap();
7130        let (state, provider) = test_state(directory.path()).await;
7131        let app = build_router(state.clone());
7132        // Two `messages` keys in the top-level object; second wins by
7133        // `serde_json` default, but the harness must refuse ambiguity
7134        // instead of silently taking the last value.
7135        let dup = br#"{"model":"mock","messages":[{"role":"user","content":"safe"}],"messages":[{"role":"user","content":"hostile"}]}"#;
7136        let request = Request::builder()
7137            .method("POST")
7138            .uri("/v1/chat/completions")
7139            .header(axum::http::header::CONTENT_TYPE, "application/json")
7140            .header("x-av-session", "dup-key")
7141            .header("x-av-workflow", "unsigned")
7142            .body(Body::from(&dup[..]))
7143            .unwrap();
7144        let response = app.oneshot(request).await.unwrap();
7145        assert_eq!(
7146            response.status(),
7147            StatusCode::BAD_REQUEST,
7148            "duplicate `messages` key must be refused with 400, not silently taken"
7149        );
7150        let body = axum::body::to_bytes(response.into_body(), 4 * 1024)
7151            .await
7152            .unwrap();
7153        let text = std::str::from_utf8(&body).unwrap();
7154        assert!(
7155            text.contains("duplicate"),
7156            "response body must name the reason, got {text:?}"
7157        );
7158        provider.abort();
7159    }
7160
7161    fn active_records(
7162        directory: &std::path::Path,
7163        state: &AppState,
7164        session_id: &str,
7165    ) -> Vec<crate::worker::ActiveJournalRecord> {
7166        let digest = av_core::digest::sha256_hex(session_id.as_bytes());
7167        let journal =
7168            std::fs::read_to_string(directory.join(format!("{}.events.ndjson", &digest[..32]))).unwrap();
7169        journal
7170            .lines()
7171            .enumerate()
7172            .map(|(index, line)| {
7173                crate::journal::open(
7174                    &state.journal_key,
7175                    &format!("{session_id}:active"),
7176                    index as u64,
7177                    line.as_bytes(),
7178                )
7179                .unwrap()
7180            })
7181            .collect()
7182    }
7183
7184    #[tokio::test]
7185    async fn full_chat_close_and_promotion_flow() {
7186        let directory = tempfile::tempdir().unwrap();
7187        let (state, provider) = test_state(directory.path()).await;
7188        let app = build_router(state.clone());
7189
7190        let response = app.clone().oneshot(chat_request("http-flow")).await.unwrap();
7191        assert_eq!(response.status(), StatusCode::OK);
7192        assert_eq!(response.headers().get("x-av-session").unwrap(), "http-flow");
7193        let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
7194            .await
7195            .unwrap();
7196        assert!(String::from_utf8_lossy(&body).contains("[DONE]"));
7197
7198        let close = app
7199            .clone()
7200            .oneshot(
7201                Request::builder()
7202                    .method("POST")
7203                    .uri("/v1/sessions/http-flow/close")
7204                    .body(Body::empty())
7205                    .unwrap(),
7206            )
7207            .await
7208            .unwrap();
7209        let close_status = close.status();
7210        let close_bytes = axum::body::to_bytes(close.into_body(), 64 * 1024).await.unwrap();
7211        assert_eq!(
7212            close_status,
7213            StatusCode::OK,
7214            "{}",
7215            String::from_utf8_lossy(&close_bytes)
7216        );
7217        let close_body: Value = serde_json::from_slice(&close_bytes).unwrap();
7218        assert_eq!(close_body["kind"], "atif");
7219        let artifact = state
7220            .sessions
7221            .get("http-flow")
7222            .unwrap()
7223            .atif_path
7224            .lock()
7225            .clone()
7226            .unwrap();
7227        let trajectory: av_atif::Trajectory =
7228            serde_json::from_slice(&tokio::fs::read(&artifact).await.unwrap()).unwrap();
7229        if let Some(destination) = std::env::var_os("AV_HARBOR_INTEROP_OUT") {
7230            std::fs::copy(&artifact, destination).unwrap();
7231        }
7232        assert_eq!(
7233            trajectory.steps.len(),
7234            2,
7235            "request and response must both be captured"
7236        );
7237        assert_eq!(trajectory.steps[0].source, av_atif::Source::User);
7238        assert!(trajectory.steps[0].metrics.is_none());
7239        assert_eq!(trajectory.steps[1].source, av_atif::Source::Agent);
7240        assert_eq!(trajectory.steps[1].message, Value::String("hello".to_owned()));
7241        assert_eq!(
7242            trajectory.steps[1].metrics.as_ref().unwrap().cached_tokens,
7243            Some(4)
7244        );
7245
7246        let promoted = app
7247            .oneshot(
7248                Request::builder()
7249                    .method("POST")
7250                    .uri("/v1/sessions/http-flow/promote")
7251                    .body(Body::empty())
7252                    .unwrap(),
7253            )
7254            .await
7255            .unwrap();
7256        assert_eq!(promoted.status(), StatusCode::OK);
7257        let receipt: av_receipts::Receipt = serde_json::from_slice(
7258            &axum::body::to_bytes(promoted.into_body(), 64 * 1024)
7259                .await
7260                .unwrap(),
7261        )
7262        .unwrap();
7263        receipt.verify_embedded().unwrap();
7264        assert_eq!(receipt.body.stop_reason_id, av_events::StopReason::Stop.id());
7265        assert_eq!(receipt.body.cost.completion_tokens, 3);
7266        assert_eq!(receipt.body.cost.cached_tokens, 4);
7267        assert_eq!(receipt.body.cost.cost_usd_micros, 1_250);
7268        assert_eq!(
7269            receipt.body.cost.prompt_tokens, 10,
7270            "prompt total must reconcile to the provider-reported usage, \
7271             not the admission-time heuristic (and never both summed)"
7272        );
7273        provider.abort();
7274    }
7275
7276    #[tokio::test]
7277    async fn mcp_metrics_and_abort_paths_are_enforced() {
7278        let directory = tempfile::tempdir().unwrap();
7279        let (state, provider) = test_state(directory.path()).await;
7280        let app = build_router(state.clone());
7281        let allowed = app
7282            .clone()
7283            .oneshot(
7284                Request::builder()
7285                    .method("POST")
7286                    .uri("/v1/mcp")
7287                    .header("x-av-session", "mcp-flow")
7288                    .body(Body::from(
7289                        r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read","arguments":{}}}"#,
7290                    ))
7291                    .unwrap(),
7292            )
7293            .await
7294            .unwrap();
7295        assert_eq!(allowed.status(), StatusCode::OK);
7296        let allowed_body: Value = serde_json::from_slice(
7297            &axum::body::to_bytes(allowed.into_body(), 64 * 1024)
7298                .await
7299                .unwrap(),
7300        )
7301        .unwrap();
7302        // The ack is a conformant JSON-RPC
7303        // response now — id echoed, result envelope present.
7304        assert_eq!(allowed_body["jsonrpc"], "2.0");
7305        assert_eq!(allowed_body["id"], 1);
7306        assert!(allowed_body["result"]["decision_us"].as_u64().unwrap() < 5_000);
7307
7308        let blocked = app
7309            .clone()
7310            .oneshot(
7311                Request::builder()
7312                    .method("POST")
7313                    .uri("/v1/mcp")
7314                    .header("x-av-session", "mcp-flow")
7315                    .body(Body::from("not-json"))
7316                    .unwrap(),
7317            )
7318            .await
7319            .unwrap();
7320        // Invalid JSON is a protocol
7321        // failure (-32700 / HTTP 400), not a policy decision (403).
7322        assert_eq!(blocked.status(), StatusCode::BAD_REQUEST);
7323        let blocked_body: Value = serde_json::from_slice(
7324            &axum::body::to_bytes(blocked.into_body(), 64 * 1024)
7325                .await
7326                .unwrap(),
7327        )
7328        .unwrap();
7329        assert_eq!(blocked_body["error"]["code"], -32700);
7330
7331        let metrics_response = app
7332            .clone()
7333            .oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap())
7334            .await
7335            .unwrap();
7336        let metrics = String::from_utf8_lossy(
7337            &axum::body::to_bytes(metrics_response.into_body(), 128 * 1024)
7338                .await
7339                .unwrap(),
7340        )
7341        .into_owned();
7342        assert!(metrics.contains("av_stage_duration_seconds") || metrics.contains("av_sessions"));
7343
7344        let aborted = app.clone().oneshot(chat_request("abort-flow")).await.unwrap();
7345        drop(aborted);
7346        // A client abort AFTER content was captured (the
7347        // SSE peek already consumed the first frame) must NOT force-close
7348        // the conversation — the turn is recorded and the session stays
7349        // open for the client's next request; the idle sweeper owns
7350        // eventual finalization. Only a nothing-captured one-shot abort
7351        // spawns the background close.
7352        let session = tokio::time::timeout(Duration::from_secs(1), async {
7353            loop {
7354                if let Some(session) = state.sessions.get("abort-flow") {
7355                    if session.active_streams_count() == 0 {
7356                        break session;
7357                    }
7358                }
7359                tokio::task::yield_now().await;
7360            }
7361        })
7362        .await
7363        .expect("abort stream never drained");
7364        assert!(
7365            !session.is_closed(),
7366            "an abort after captured content must leave the conversation open"
7367        );
7368        provider.abort();
7369    }
7370
7371    /// The response-body stream drops mid-flight while another task is
7372    /// closing the session (idle timeout or explicit close). Recorded
7373    /// scenario: `try_close` in `close_session_locked` transitions
7374    /// `closed` 0→1, then blocks in `wait_for_streams` because the
7375    /// client still holds the response body's `SessionLease`. When
7376    /// the client eventually drops the response, `AbortFinalizingStream::drop`
7377    /// used to see `session.is_closed() == true` and return early —
7378    /// silently skipping `submit_response_capture`. The reader's
7379    /// response bytes never reached the chain, but `wait_for_streams`
7380    /// then unblocked and the finalize path signed a receipt whose
7381    /// `subject.event_count` reflects only the compression event.
7382    #[tokio::test]
7383    async fn stream_drop_during_concurrent_close_still_captures_response() {
7384        let directory = tempfile::tempdir().unwrap();
7385        let (state, provider) = test_state(directory.path()).await;
7386        let app = build_router(state.clone());
7387
7388        let request = Request::builder()
7389            .method("POST")
7390            .uri("/v1/chat/completions")
7391            .header(axum::http::header::CONTENT_TYPE, "application/json")
7392            .header("x-av-session", "stream-close-race")
7393            .header("x-av-workflow", "signed")
7394            .body(Body::from(serde_json::to_vec(&chat_payload()).unwrap()))
7395            .unwrap();
7396        let response = app.oneshot(request).await.unwrap();
7397        assert_eq!(response.status(), StatusCode::OK);
7398
7399        let session = state.sessions.get("stream-close-race").unwrap();
7400
7401        let close_task = {
7402            let finalizer = state.finalizer.clone();
7403            let session = Arc::clone(&session);
7404            tokio::spawn(async move { finalizer.close_session(session, StopReason::SessionClosed).await })
7405        };
7406
7407        tokio::time::timeout(Duration::from_secs(1), async {
7408            while !session.is_closed() {
7409                tokio::task::yield_now().await;
7410            }
7411        })
7412        .await
7413        .expect("concurrent close did not initiate before the response was dropped");
7414
7415        drop(response);
7416
7417        close_task
7418            .await
7419            .expect("close_session task panicked")
7420            .expect("close_session returned an error");
7421
7422        let final_count = session.chain.lock().count();
7423        assert!(
7424            final_count >= 2,
7425            "AbortFinalizingStream::drop must submit the response capture even when the session is already being closed — otherwise a concurrent close during an active stream discards the response and the receipt attests to fewer events than actually flowed; got chain.count() = {final_count}",
7426        );
7427
7428        provider.abort();
7429    }
7430
7431    /// Regression for the follow-on to the concurrent-close capture bug. The
7432    /// original fix removed the `is_closed()` early-return from
7433    /// `AbortFinalizingStream::drop` so the response capture would still be
7434    /// submitted while another task drained the stream via
7435    /// `wait_for_streams`. But `drop` also carried a defensive
7436    /// `if self.pending_budget.is_some() { self.session.mark_capture_failed(); }`
7437    /// intended for genuine abort-mid-flight: an in-flight completion-token
7438    /// budget task means we don't yet know if the token spend would have been
7439    /// refused, so fail-closed is safe for a normal client disconnect. When
7440    /// composed with the removal of the `is_closed()` early-return, however,
7441    /// this fail-closed mark now fires during the concurrent-close scenario
7442    /// too — even though the concurrent close is imminent and skipping the
7443    /// last budget verdict is harmless (the response bytes are captured
7444    /// internally regardless of whether we would have emitted them to the
7445    /// client). The resulting `capture_failed = 1` then makes the concurrent
7446    /// close's post-`wait_for_worker_jobs` `if session.capture_failed()` guard
7447    /// seal the session (`mark_artifact_committed` + committed claim) and
7448    /// return `Err(FinalizeError::CaptureIncomplete)`: the close is not
7449    /// retried, and the session finalizes without any artifact — the
7450    /// capture is silently lost. The fix: gate the pending-budget mark
7451    /// (and the budget-delta > 0 mark and the flush-error mark) on
7452    /// `!is_closed`, so during a concurrent close the drop submits the
7453    /// response capture without fail-closing the session.
7454    #[tokio::test]
7455    async fn drop_with_pending_budget_during_concurrent_close_does_not_stick_session() {
7456        let directory = tempfile::tempdir().unwrap();
7457        let (state, provider) = test_state(directory.path()).await;
7458
7459        let identity = av_events::AgentIdentity {
7460            version: "1".into(),
7461            charter: "c".into(),
7462            instance_uid: "i".into(),
7463            ttl_remaining_s: None,
7464        };
7465        let session = state.sessions.get_or_open(
7466            "pending-budget-close",
7467            crate::session::Workflow::Signed,
7468            &identity,
7469            &state.config.breaker,
7470        );
7471        // Reserve a full permit pair up front (worker slot + response
7472        // slot) so `submit_response_capture` in drop has somewhere to
7473        // send the job; the response permit lives on
7474        // `AbortFinalizingStream` for the stream's lifetime.
7475        let permits = state
7476            .worker
7477            .try_reserve_pair("pending-budget-close")
7478            .expect("test setup: fused worker/response permit");
7479        // The worker permit is dropped here — the test does not
7480        // actually submit a job through it; the response permit is
7481        // what the stream drop cares about.
7482        drop(permits.worker);
7483        let permit = permits.response;
7484        // Bump active_streams so a concurrent close would block in
7485        // `wait_for_streams`, mirroring the production scenario.
7486        let lease = crate::session::SessionLease::new(Arc::clone(&session));
7487
7488        // Set up an in-flight budget task. Its result is irrelevant to drop —
7489        // drop only checks `pending_budget.is_some()` — but the JoinHandle
7490        // has to be a real one so the field type-checks.
7491        let budget_task =
7492            tokio::spawn(async { Ok::<_, String>(av_state::BudgetDecision::Allowed { remaining: 100 }) });
7493
7494        let stream = AbortFinalizingStream {
7495            inner: futures::stream::empty::<Result<Bytes, std::io::Error>>().boxed(),
7496            session: Arc::clone(&session),
7497            identity: identity.clone(),
7498            response_permit: Some(permit),
7499            worker: state.worker.clone(),
7500            store: Arc::clone(&state.store),
7501            budget: state.config.budget.clone(),
7502            billed_prompt_tokens: 0,
7503            principal_budget: None,
7504            provider_adapter: Arc::clone(&state.provider_adapter),
7505            finalizer: state.finalizer.clone(),
7506            _lease: lease,
7507            response_marker: None,
7508            response_attempt_id: "pending-budget-attempt".into(),
7509            response_message: "captured content".into(),
7510            response_reasoning: String::new(),
7511            response_model: None,
7512            response_finish_reason: Some("stop".into()),
7513            upstream_status: StatusCode::OK,
7514            response_cost_usd_micros: 0,
7515            response_tool_calls: std::collections::BTreeMap::new(),
7516            response_metrics: av_events::EventMetrics::default(),
7517            charged_completion_tokens: 3,
7518            last_reported_completion_tokens: Some(3),
7519            last_reported_prompt_tokens: None,
7520            last_reported_cached_tokens: None,
7521            last_reported_cost_usd_micros: None,
7522            saw_chunk: true,
7523            capture_attempted: false,
7524            is_sse: true,
7525            protocol_buffer: Vec::new(),
7526            frame_scratch: Vec::new(),
7527            pending_output: std::collections::VecDeque::new(),
7528            pending_budget: Some(PendingBudget {
7529                task: budget_task,
7530                continuation: BudgetContinuation::FinishSse,
7531                delta: 0,
7532            }),
7533            captured_bytes: 100,
7534            completed: false,
7535            ephemeral: false,
7536        };
7537
7538        // Simulate the concurrent close's `try_close` having already fired.
7539        assert!(session.try_close(), "test setup: try_close should have won");
7540        assert!(session.is_closed());
7541
7542        // Drop the stream. This is what happens when the response body is
7543        // released while another task is blocked in `wait_for_streams`.
7544        drop(stream);
7545
7546        assert!(
7547            !session.capture_failed(),
7548            "AbortFinalizingStream::drop must not fail the capture just because a completion-token budget check was still in flight while a concurrent close is already draining this stream via wait_for_streams — otherwise close_session_locked's capture_failed guard seals the session and returns CaptureIncomplete: the receipt is never signed and the capture is lost",
7549        );
7550
7551        // Now run the concurrent close to completion. It must succeed —
7552        // wait_for_streams sees active=0 (lease dropped), wait_for_worker_jobs
7553        // drains the response job we just submitted, and the capture_failed
7554        // check must find `false` so the finalize path can proceed to
7555        // sign the receipt.
7556        let outcome = tokio::time::timeout(
7557            Duration::from_secs(3),
7558            state
7559                .finalizer
7560                .close_session(Arc::clone(&session), StopReason::SessionClosed),
7561        )
7562        .await
7563        .expect("close_session hung after drop released the stream");
7564        outcome.expect(
7565            "close_session must succeed after AbortFinalizingStream::drop submits the response capture — a session sealed without its artifact is worse than a marginally-over-budget response record, because the receipt is lost entirely",
7566        );
7567
7568        provider.abort();
7569    }
7570
7571    /// A network chunk's frame deltas are debited ONCE after the drain
7572    /// loop, but `absorb_frame` bumps `charged_completion_tokens` per
7573    /// frame. An error on a later frame in the same chunk abandons the
7574    /// never-debited delta — without a rollback, the terminal refund
7575    /// (`charged − reported`) credited tokens that were never debited,
7576    /// letting a hostile upstream replenish the session ledger at will.
7577    #[tokio::test]
7578    async fn absorb_error_rolls_back_undebited_completion_charges() {
7579        let directory = tempfile::tempdir().unwrap();
7580        let (state, provider) = test_state(directory.path()).await;
7581        let identity = av_events::AgentIdentity {
7582            version: "1".into(),
7583            charter: "c".into(),
7584            instance_uid: "i".into(),
7585            ttl_remaining_s: None,
7586        };
7587        let session = state.sessions.get_or_open(
7588            "absorb-rollback",
7589            crate::session::Workflow::Signed,
7590            &identity,
7591            &state.config.breaker,
7592        );
7593        let lease = crate::session::SessionLease::new(Arc::clone(&session));
7594        let mut stream = AbortFinalizingStream {
7595            inner: futures::stream::empty::<Result<Bytes, std::io::Error>>().boxed(),
7596            session: Arc::clone(&session),
7597            identity,
7598            response_permit: None,
7599            worker: state.worker.clone(),
7600            store: Arc::clone(&state.store),
7601            budget: state.config.budget.clone(),
7602            billed_prompt_tokens: 0,
7603            principal_budget: None,
7604            provider_adapter: Arc::clone(&state.provider_adapter),
7605            finalizer: state.finalizer.clone(),
7606            _lease: lease,
7607            response_marker: None,
7608            response_attempt_id: "absorb-rollback-attempt".into(),
7609            response_message: String::new(),
7610            response_reasoning: String::new(),
7611            response_model: None,
7612            response_finish_reason: None,
7613            upstream_status: StatusCode::OK,
7614            response_cost_usd_micros: 0,
7615            response_tool_calls: std::collections::BTreeMap::new(),
7616            response_metrics: av_events::EventMetrics::default(),
7617            charged_completion_tokens: 0,
7618            last_reported_completion_tokens: None,
7619            last_reported_prompt_tokens: None,
7620            last_reported_cached_tokens: None,
7621            last_reported_cost_usd_micros: None,
7622            saw_chunk: false,
7623            capture_attempted: true,
7624            is_sse: true,
7625            protocol_buffer: Vec::new(),
7626            frame_scratch: Vec::new(),
7627            pending_output: std::collections::VecDeque::new(),
7628            pending_budget: None,
7629            captured_bytes: 0,
7630            completed: false,
7631            ephemeral: false,
7632        };
7633        // Frame A: content without usage → the estimate branch bumps
7634        // `charged_completion_tokens`. Frame B: malformed JSON → error.
7635        // Both frames arrive in ONE network chunk, so frame A's delta
7636        // was never debited.
7637        let chunk = concat!(
7638            "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"some streamed words here\"}}]}\n\n",
7639            "data: {not-json\n\n",
7640        );
7641        let result = stream.absorb_network_chunk(chunk.as_bytes());
7642        assert!(result.is_err(), "the malformed frame must fail the chunk");
7643        assert_eq!(
7644            stream.charged_completion_tokens, 0,
7645            "an errored chunk's never-debited frame deltas must be rolled back \
7646             from charged_completion_tokens, or the terminal refund credits \
7647             tokens that were never debited"
7648        );
7649        provider.abort();
7650    }
7651
7652    /// A mid-stream budget REFUSAL never debits its delta (check-and-
7653    /// spend refuses without spending, and the task refunds the
7654    /// principal side), but `absorb_frame` already folded that delta
7655    /// into `charged_completion_tokens`. The Refused arm (and the
7656    /// backend-outage arm, and drop-abort) must roll the delta back —
7657    /// otherwise the terminal over-charge refund (`charged − reported`)
7658    /// credits tokens that were never debited, silently loosening the
7659    /// enforced budget below what the signed receipt attests.
7660    #[tokio::test]
7661    async fn refused_budget_check_rolls_back_undebited_charge() {
7662        use futures::StreamExt as _;
7663        let directory = tempfile::tempdir().unwrap();
7664        let (state, provider) = test_state(directory.path()).await;
7665        let identity = av_events::AgentIdentity {
7666            version: "1".into(),
7667            charter: "c".into(),
7668            instance_uid: "i".into(),
7669            ttl_remaining_s: None,
7670        };
7671        let session = state.sessions.get_or_open(
7672            "refused-rollback",
7673            crate::session::Workflow::Signed,
7674            &identity,
7675            &state.config.breaker,
7676        );
7677        let lease = crate::session::SessionLease::new(Arc::clone(&session));
7678        let refused = tokio::spawn(async {
7679            Ok::<_, String>(av_state::BudgetDecision::Refused {
7680                limit: "tokens".to_owned(),
7681                cap: 100,
7682            })
7683        });
7684        let mut stream = AbortFinalizingStream {
7685            inner: futures::stream::empty::<Result<Bytes, std::io::Error>>().boxed(),
7686            session: Arc::clone(&session),
7687            identity,
7688            response_permit: None,
7689            worker: state.worker.clone(),
7690            store: Arc::clone(&state.store),
7691            budget: state.config.budget.clone(),
7692            billed_prompt_tokens: 0,
7693            principal_budget: None,
7694            provider_adapter: Arc::clone(&state.provider_adapter),
7695            finalizer: state.finalizer.clone(),
7696            _lease: lease,
7697            response_marker: None,
7698            response_attempt_id: "refused-rollback-attempt".into(),
7699            response_message: String::new(),
7700            response_reasoning: String::new(),
7701            response_model: None,
7702            response_finish_reason: None,
7703            upstream_status: StatusCode::OK,
7704            response_cost_usd_micros: 0,
7705            response_tool_calls: std::collections::BTreeMap::new(),
7706            response_metrics: av_events::EventMetrics::default(),
7707            // 10 charged, of which the refused check's 4 were never
7708            // debited (6 landed via earlier Allowed checks).
7709            charged_completion_tokens: 10,
7710            last_reported_completion_tokens: None,
7711            last_reported_prompt_tokens: None,
7712            last_reported_cached_tokens: None,
7713            last_reported_cost_usd_micros: None,
7714            saw_chunk: true,
7715            // Short-circuits submit_response_capture so the test needs
7716            // no worker permits; the arithmetic under test runs first.
7717            capture_attempted: true,
7718            is_sse: true,
7719            protocol_buffer: Vec::new(),
7720            frame_scratch: Vec::new(),
7721            pending_output: std::collections::VecDeque::new(),
7722            pending_budget: Some(PendingBudget {
7723                task: refused,
7724                continuation: BudgetContinuation::FinishSse,
7725                delta: 4,
7726            }),
7727            captured_bytes: 0,
7728            completed: false,
7729            ephemeral: false,
7730        };
7731        let item = stream.next().await.expect("refusal must yield a terminal item");
7732        let error = item.expect_err("a refused budget check must surface as an error");
7733        assert_eq!(error.kind(), std::io::ErrorKind::QuotaExceeded);
7734        assert_eq!(
7735            stream.charged_completion_tokens, 6,
7736            "the refused (never-debited) delta must be rolled back from \
7737             charged_completion_tokens, or the terminal refund credits \
7738             tokens that were never spent"
7739        );
7740        assert!(
7741            stream.session.enforcement_tripped(),
7742            "a genuine mid-stream refusal still latches enforcement"
7743        );
7744        provider.abort();
7745    }
7746
7747    /// Builds an `AbortFinalizingStream` in the "aborted before any
7748    /// upstream byte" shape: not completed, nothing captured, no pending
7749    /// budget. `capture_attempted: true` short-circuits
7750    /// `submit_response_capture` in drop so the test needs no worker
7751    /// permits — the branch under test is the background auto-close spawn.
7752    fn empty_abort_stream(
7753        state: &AppState,
7754        session: &Arc<crate::session::Session>,
7755        identity: av_events::AgentIdentity,
7756        ephemeral: bool,
7757    ) -> AbortFinalizingStream {
7758        AbortFinalizingStream {
7759            inner: futures::stream::empty::<Result<Bytes, std::io::Error>>().boxed(),
7760            session: Arc::clone(session),
7761            identity,
7762            response_permit: None,
7763            worker: state.worker.clone(),
7764            store: Arc::clone(&state.store),
7765            budget: state.config.budget.clone(),
7766            billed_prompt_tokens: 0,
7767            principal_budget: None,
7768            provider_adapter: Arc::clone(&state.provider_adapter),
7769            finalizer: state.finalizer.clone(),
7770            _lease: crate::session::SessionLease::new(Arc::clone(session)),
7771            response_marker: None,
7772            response_attempt_id: "empty-abort-attempt".into(),
7773            response_message: String::new(),
7774            response_reasoning: String::new(),
7775            response_model: None,
7776            response_finish_reason: None,
7777            upstream_status: StatusCode::OK,
7778            response_cost_usd_micros: 0,
7779            response_tool_calls: std::collections::BTreeMap::new(),
7780            response_metrics: av_events::EventMetrics::default(),
7781            charged_completion_tokens: 0,
7782            last_reported_completion_tokens: None,
7783            last_reported_prompt_tokens: None,
7784            last_reported_cached_tokens: None,
7785            last_reported_cost_usd_micros: None,
7786            saw_chunk: false,
7787            capture_attempted: true,
7788            is_sse: true,
7789            protocol_buffer: Vec::new(),
7790            frame_scratch: Vec::new(),
7791            pending_output: std::collections::VecDeque::new(),
7792            pending_budget: None,
7793            captured_bytes: 0,
7794            completed: false,
7795            ephemeral,
7796        }
7797    }
7798
7799    /// A NON-ephemeral session (client sent `X-AV-Session`) whose turn
7800    /// aborts before any upstream byte arrives must stay OPEN.
7801    /// OPENAI-COMPATIBILITY.md: multi-turn sessions close only via
7802    /// explicit `POST /close` or the idle sweeper. Pre-fix, the
7803    /// abort-cleanup spawn at the bottom of `Drop` was missing the
7804    /// `ephemeral` gate its completed-arm sibling has, so an ordinary
7805    /// network blip on turn N force-closed the whole session with
7806    /// `StopReason::Other`; the client's retry with the same string id
7807    /// silently opened a NEW session (get_or_open sees close_complete),
7808    /// fragmenting the audit chain into disjoint signed records.
7809    #[tokio::test]
7810    async fn non_ephemeral_empty_abort_leaves_session_open() {
7811        let directory = tempfile::tempdir().unwrap();
7812        let (state, provider) = test_state(directory.path()).await;
7813        let identity = av_events::AgentIdentity {
7814            version: "1".into(),
7815            charter: "c".into(),
7816            instance_uid: "i".into(),
7817            ttl_remaining_s: None,
7818        };
7819        let session = state.sessions.get_or_open(
7820            "client-managed-multi-turn",
7821            crate::session::Workflow::Signed,
7822            &identity,
7823            &state.config.breaker,
7824        );
7825        let stream = empty_abort_stream(&state, &session, identity, false);
7826        drop(stream);
7827        // Give an (erroneously) spawned background close every chance to
7828        // run before asserting: yield through the runtime a few times.
7829        for _ in 0..20 {
7830            tokio::time::sleep(Duration::from_millis(10)).await;
7831            if session.is_closed() {
7832                break;
7833            }
7834        }
7835        assert!(
7836            !session.is_closed(),
7837            "a client-managed (non-ephemeral) session must NOT be auto-closed \
7838             when a turn aborts with nothing captured — multi-turn sessions \
7839             close only via POST /close or the idle sweeper, otherwise the \
7840             client's retry with the same id fragments the audit chain"
7841        );
7842        provider.abort();
7843    }
7844
7845    /// The intended reaping path still works: an EPHEMERAL one-shot whose
7846    /// turn aborts with nothing captured is closed in the background so
7847    /// it does not linger as an empty husk until the idle sweeper.
7848    #[tokio::test]
7849    async fn ephemeral_empty_abort_closes_session_in_background() {
7850        let directory = tempfile::tempdir().unwrap();
7851        let (state, provider) = test_state(directory.path()).await;
7852        let identity = av_events::AgentIdentity {
7853            version: "1".into(),
7854            charter: "c".into(),
7855            instance_uid: "i".into(),
7856            ttl_remaining_s: None,
7857        };
7858        let session = state.sessions.get_or_open(
7859            "ephemeral-empty-abort",
7860            crate::session::Workflow::Signed,
7861            &identity,
7862            &state.config.breaker,
7863        );
7864        let stream = empty_abort_stream(&state, &session, identity, true);
7865        drop(stream);
7866        let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
7867        while !session.is_closed() && tokio::time::Instant::now() < deadline {
7868            tokio::time::sleep(Duration::from_millis(10)).await;
7869        }
7870        assert!(
7871            session.is_closed(),
7872            "an ephemeral one-shot aborted before any upstream byte must be \
7873             closed by the background abort-cleanup spawn, not linger until \
7874             the idle sweeper"
7875        );
7876        provider.abort();
7877    }
7878
7879    #[tokio::test]
7880    async fn duplicate_tool_id_replays_outcome_without_reexecution() {
7881        let tool_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7882        let tool_address = tool_listener.local_addr().unwrap();
7883        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
7884        let tool_router = Router::new()
7885            .route("/mcp", post(counting_tool))
7886            .with_state(Arc::clone(&calls));
7887        let tool_server = tokio::spawn(async move {
7888            axum::serve(tool_listener, tool_router).await.unwrap();
7889        });
7890        let directory = tempfile::tempdir().unwrap();
7891        let (mut state, provider) = test_state(directory.path()).await;
7892        Arc::get_mut(&mut state.config).unwrap().tool_upstream_url =
7893            Some(format!("http://{tool_address}/mcp"));
7894        let app = build_router(state.clone());
7895        let body = r#"{"jsonrpc":"2.0","id":"once","method":"tools/call","params":{"name":"read","arguments":{"id":7}}}"#;
7896        for _ in 0..2 {
7897            let response = app
7898                .clone()
7899                .oneshot(
7900                    Request::builder()
7901                        .method("POST")
7902                        .uri("/v1/mcp")
7903                        .header("x-av-session", "tool-once")
7904                        .body(Body::from(body))
7905                        .unwrap(),
7906                )
7907                .await
7908                .unwrap();
7909            assert_eq!(response.status(), StatusCode::OK);
7910            assert_eq!(
7911                axum::body::to_bytes(response.into_body(), 64 * 1024)
7912                    .await
7913                    .unwrap(),
7914                body
7915            );
7916        }
7917        assert_eq!(calls.load(std::sync::atomic::Ordering::Acquire), 1);
7918        let session = state.sessions.get("tool-once").unwrap();
7919        assert_eq!(
7920            session
7921                .totals
7922                .tool_calls
7923                .load(std::sync::atomic::Ordering::Acquire),
7924            1
7925        );
7926        let audited_path = std::fs::read_dir(directory.path().join(crate::spool::TOOL_EXECUTIONS))
7927            .unwrap()
7928            .map(|entry| entry.unwrap().path())
7929            .find(|path| path.extension().is_some_and(|extension| extension == "audited"))
7930            .unwrap();
7931        std::fs::remove_file(audited_path).unwrap();
7932        let unaudited_replay = app
7933            .clone()
7934            .oneshot(
7935                Request::builder()
7936                    .method("POST")
7937                    .uri("/v1/mcp")
7938                    .header("x-av-session", "tool-once")
7939                    .body(Body::from(body))
7940                    .unwrap(),
7941            )
7942            .await
7943            .unwrap();
7944        assert_eq!(unaudited_replay.status(), StatusCode::OK);
7945        assert_eq!(calls.load(std::sync::atomic::Ordering::Acquire), 1);
7946        assert_eq!(
7947            session
7948                .totals
7949                .tool_calls
7950                .load(std::sync::atomic::Ordering::Acquire),
7951            1,
7952            "unaudited outcome recovery must not spend tool budget again"
7953        );
7954
7955        let changed_request = app
7956            .clone()
7957            .oneshot(
7958                Request::builder()
7959                    .method("POST")
7960                    .uri("/v1/mcp")
7961                    .header("x-av-session", "tool-once")
7962                    .body(Body::from(
7963                        r#"{"jsonrpc":"2.0","id":"once","method":"tools/call","params":{"name":"read","arguments":{"id":8}}}"#,
7964                    ))
7965                    .unwrap(),
7966            )
7967            .await
7968            .unwrap();
7969        assert_eq!(changed_request.status(), StatusCode::CONFLICT);
7970        assert_eq!(calls.load(std::sync::atomic::Ordering::Acquire), 1);
7971        tool_server.abort();
7972        provider.abort();
7973    }
7974
7975    /// End-to-end proof that a configured upstream API key is injected on
7976    /// the upstream wire: a capturing mock provider records the header the
7977    /// harness actually sent for both Bearer and Azure raw-key styles, and
7978    /// the passthrough mode relays the client's own Authorization header.
7979    #[tokio::test]
7980    async fn upstream_auth_header_reaches_provider_wire() {
7981        type Captured = Arc<parking_lot::Mutex<Vec<(Option<String>, Option<String>)>>>;
7982        async fn capture_chat(
7983            State(captured): State<Captured>,
7984            headers: HeaderMap,
7985            Json(_): Json<Value>,
7986        ) -> Response {
7987            captured.lock().push((
7988                headers
7989                    .get(axum::http::header::AUTHORIZATION)
7990                    .and_then(|value| value.to_str().ok())
7991                    .map(str::to_owned),
7992                headers
7993                    .get("api-key")
7994                    .and_then(|value| value.to_str().ok())
7995                    .map(str::to_owned),
7996            ));
7997            Json(json!({"choices": [], "usage": {"prompt_tokens": 1, "completion_tokens": 1}}))
7998                .into_response()
7999        }
8000        let captured: Captured = Arc::new(parking_lot::Mutex::new(Vec::new()));
8001        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8002        let address = listener.local_addr().unwrap();
8003        let provider_router = Router::new()
8004            .route("/v1/chat/completions", post(capture_chat))
8005            .route("/openai/deployments/d1/chat/completions", post(capture_chat))
8006            .with_state(Arc::clone(&captured));
8007        let provider = tokio::spawn(async move {
8008            axum::serve(listener, provider_router).await.unwrap();
8009        });
8010        let directory = tempfile::tempdir().unwrap();
8011        let key_path = directory.path().join("upstream.key");
8012        std::fs::write(&key_path, "sk-wire-test\n").unwrap();
8013        #[cfg(unix)]
8014        {
8015            use std::os::unix::fs::PermissionsExt as _;
8016            std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
8017        }
8018        let spool = directory.path().to_string_lossy();
8019        let build_state = |config: crate::config::HarnessConfig| {
8020            AppState::new(
8021                config,
8022                Arc::new(InMemoryStore::new()),
8023                Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
8024                Arc::new(NullBus),
8025                None,
8026                Arc::new(Ed25519Signer::from_seed(&[12; 32])),
8027            )
8028            .unwrap()
8029        };
8030        let send_chat = |state: AppState, authorization: Option<&'static str>| async move {
8031            let mut request = Request::builder()
8032                .method("POST")
8033                .uri("/v1/chat/completions")
8034                .header(axum::http::header::CONTENT_TYPE, "application/json")
8035                .header("x-av-session", "auth-wire");
8036            if let Some(value) = authorization {
8037                request = request.header(axum::http::header::AUTHORIZATION, value);
8038            }
8039            let response = build_router(state)
8040                .oneshot(request.body(Body::from(chat_payload().to_string())).unwrap())
8041                .await
8042                .unwrap();
8043            assert_eq!(response.status(), StatusCode::OK);
8044        };
8045
8046        // 1) Bearer key from an owner-only file.
8047        let mut config =
8048            crate::config::HarnessConfig::for_tests(&format!("http://{address}"), &spool, &spool);
8049        config.upstream_api_key_file = Some(key_path.to_string_lossy().into_owned());
8050        send_chat(build_state(config), None).await;
8051        assert_eq!(
8052            captured.lock().pop().unwrap(),
8053            (Some("Bearer sk-wire-test".to_owned()), None),
8054            "Bearer credential must reach the provider"
8055        );
8056
8057        // 2) Azure style: raw key in a custom header on a custom path.
8058        let mut config =
8059            crate::config::HarnessConfig::for_tests(&format!("http://{address}"), &spool, &spool);
8060        config.upstream_api_key_file = Some(key_path.to_string_lossy().into_owned());
8061        config.upstream_auth_header = "api-key".into();
8062        config.upstream_auth_scheme = String::new();
8063        config.upstream_chat_path = "/openai/deployments/d1/chat/completions".into();
8064        send_chat(build_state(config), None).await;
8065        assert_eq!(
8066            captured.lock().pop().unwrap(),
8067            (None, Some("sk-wire-test".to_owned())),
8068            "raw key must reach the provider on the configured path and header"
8069        );
8070
8071        // 3) Passthrough: the client's own Authorization header is relayed.
8072        let mut config =
8073            crate::config::HarnessConfig::for_tests(&format!("http://{address}"), &spool, &spool);
8074        config.upstream_authorization_passthrough = true;
8075        send_chat(build_state(config), Some("Bearer client-owned-key")).await;
8076        assert_eq!(
8077            captured.lock().pop().unwrap(),
8078            (Some("Bearer client-owned-key".to_owned()), None),
8079            "passthrough must relay the client credential"
8080        );
8081
8082        // 4) No identity validator configured but the client sent a
8083        //    bearer: refuse with 401 rather than record the request as
8084        //    anonymous (a repudiation vector — see resolve_identity's
8085        //    "bearer presented but validator not configured" arm).
8086        //    The response is the block; the fact that no wire capture
8087        //    landed also proves the credential does not leak.
8088        let config = crate::config::HarnessConfig::for_tests(&format!("http://{address}"), &spool, &spool);
8089        let request = Request::builder()
8090            .method("POST")
8091            .uri("/v1/chat/completions")
8092            .header(axum::http::header::CONTENT_TYPE, "application/json")
8093            .header("x-av-session", "auth-wire")
8094            .header(axum::http::header::AUTHORIZATION, "Bearer client-owned-key")
8095            .body(Body::from(chat_payload().to_string()))
8096            .unwrap();
8097        let response = build_router(build_state(config)).oneshot(request).await.unwrap();
8098        assert_eq!(
8099            response.status(),
8100            StatusCode::UNAUTHORIZED,
8101            "presenting a bearer with no validator configured must be refused rather than \
8102             silently recorded as anonymous — that would let a signed request end up \
8103             attributed to `charter=anonymous` in the receipt"
8104        );
8105        assert!(
8106            captured.lock().is_empty(),
8107            "no credential must reach upstream when the request was refused"
8108        );
8109        provider.abort();
8110    }
8111
8112    /// The tool-upstream bearer token must be injected on forwarded MCP
8113    /// calls (and must not depend on the caller's own headers).
8114    #[tokio::test]
8115    async fn tool_upstream_bearer_reaches_tool_server() {
8116        type Captured = Arc<parking_lot::Mutex<Vec<Option<String>>>>;
8117        async fn capture_tool(State(captured): State<Captured>, headers: HeaderMap, body: Bytes) -> Response {
8118            captured.lock().push(
8119                headers
8120                    .get(axum::http::header::AUTHORIZATION)
8121                    .and_then(|value| value.to_str().ok())
8122                    .map(str::to_owned),
8123            );
8124            (StatusCode::OK, body).into_response()
8125        }
8126        let captured: Captured = Arc::new(parking_lot::Mutex::new(Vec::new()));
8127        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8128        let address = listener.local_addr().unwrap();
8129        let tool_router = Router::new()
8130            .route("/mcp", post(capture_tool))
8131            .with_state(Arc::clone(&captured));
8132        let tool_server = tokio::spawn(async move {
8133            axum::serve(listener, tool_router).await.unwrap();
8134        });
8135        let directory = tempfile::tempdir().unwrap();
8136        let token_path = directory.path().join("mcp.token");
8137        std::fs::write(&token_path, "tool-secret\n").unwrap();
8138        #[cfg(unix)]
8139        {
8140            use std::os::unix::fs::PermissionsExt as _;
8141            std::fs::set_permissions(&token_path, std::fs::Permissions::from_mode(0o600)).unwrap();
8142        }
8143        let (mut state, provider) = test_state(directory.path()).await;
8144        {
8145            let config = Arc::get_mut(&mut state.config).unwrap();
8146            config.tool_upstream_url = Some(format!("http://{address}/mcp"));
8147            config.tool_upstream_bearer_file = Some(token_path.to_string_lossy().into_owned());
8148        }
8149        // test_state resolved auth from the pre-mutation config; re-resolve.
8150        state.tool_auth = crate::pipeline::resolve_tool_auth(&state.config).unwrap();
8151        let response = build_router(state)
8152            .oneshot(
8153                Request::builder()
8154                    .method("POST")
8155                    .uri("/v1/mcp")
8156                    .header("x-av-session", "tool-auth")
8157                    .body(Body::from(
8158                        r#"{"jsonrpc":"2.0","id":"t1","method":"tools/call","params":{"name":"read","arguments":{}}}"#,
8159                    ))
8160                    .unwrap(),
8161            )
8162            .await
8163            .unwrap();
8164        assert_eq!(response.status(), StatusCode::OK);
8165        assert_eq!(
8166            captured.lock().pop().unwrap().as_deref(),
8167            Some("Bearer tool-secret"),
8168            "tool bearer must reach the tool server"
8169        );
8170        tool_server.abort();
8171        provider.abort();
8172    }
8173
8174    #[tokio::test]
8175    async fn startup_refuses_pending_and_unaudited_tool_effects() {
8176        let directory = tempfile::tempdir().unwrap();
8177        let control_key = [31; 32];
8178        let mut headers = HeaderMap::new();
8179        headers.insert("x-av-session", HeaderValue::from_static("tool-recovery"));
8180        let body = br#"{"jsonrpc":"2.0","id":"recover","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
8181        let mut execution =
8182            ToolExecution::from_request(&directory.path().to_string_lossy(), &headers, body, control_key)
8183                .unwrap();
8184        execution
8185            .bind_principal(&av_events::AgentIdentity {
8186                version: "dev".to_owned(),
8187                charter: "anonymous".into(),
8188                instance_uid: "anonymous".to_owned(),
8189                ttl_remaining_s: None,
8190            })
8191            .unwrap();
8192        execution.claim().await.unwrap();
8193        assert!(
8194            ensure_no_unresolved_tool_executions(directory.path(), &control_key)
8195                .await
8196                .is_err()
8197        );
8198        execution
8199            .persist(&ToolOutcome {
8200                status: 200,
8201                body_hex: hex::encode(body),
8202                content_type: Some("application/json".to_owned()),
8203            })
8204            .await
8205            .unwrap();
8206        assert!(
8207            ensure_no_unresolved_tool_executions(directory.path(), &control_key)
8208                .await
8209                .is_err()
8210        );
8211        execution.mark_audited().await.unwrap();
8212        ensure_no_unresolved_tool_executions(directory.path(), &control_key)
8213            .await
8214            .unwrap();
8215    }
8216
8217    /// A planted oversize file in `tool-executions/` must not stall or
8218    /// OOM the recovery scan. Pre-fix the three recovery reads were
8219    /// unbounded `std::fs::read` (every other spool scan uses
8220    /// `read_capped`), and a multi-GB plant loaded fully into RAM on
8221    /// every boot. The cap-refused file must be quarantined like a torn
8222    /// intent — renamed out of the `.intent.json` glob — so the scan
8223    /// completes and never re-reads it.
8224    #[tokio::test]
8225    async fn oversize_tool_intent_is_quarantined_not_loaded() {
8226        let directory = tempfile::tempdir().unwrap();
8227        let control_key = [32; 32];
8228        let executions = directory.path().join(crate::spool::TOOL_EXECUTIONS);
8229        std::fs::create_dir_all(&executions).unwrap();
8230        let plant = executions.join("oversizekey.intent.json");
8231        // Just past the MAX_CONTROL_BYTES cap.
8232        std::fs::write(&plant, vec![b'x'; 1024 * 1024 + 1]).unwrap();
8233
8234        let unresolved = unresolved_tool_sessions(directory.path(), &control_key)
8235            .await
8236            .expect("an oversize plant must not abort the recovery scan");
8237        assert!(unresolved.is_empty(), "{unresolved:?}");
8238        assert!(
8239            !plant.exists(),
8240            "the oversize intent must be quarantined out of the scan glob"
8241        );
8242        let quarantined = std::fs::read_dir(&executions)
8243            .unwrap()
8244            .filter_map(Result::ok)
8245            .any(|entry| {
8246                entry
8247                    .file_name()
8248                    .to_str()
8249                    .is_some_and(|name| name.ends_with(".intent.torn"))
8250            });
8251        assert!(quarantined, "quarantined plant must stay on disk as evidence");
8252    }
8253
8254    /// The cap-guard quarantine arm must stay scoped to cap-class
8255    /// refusals (oversize, non-regular). A GENUINE IO fault — here
8256    /// permission-denied — must still abort the scan loudly rather
8257    /// than silently quarantining evidence the operator could repair
8258    /// (a chmod mistake would otherwise destroy every pending tool
8259    /// intent on the next boot).
8260    #[cfg(unix)]
8261    #[tokio::test]
8262    async fn unreadable_tool_intent_aborts_the_scan_not_quarantines() {
8263        use std::os::unix::fs::PermissionsExt as _;
8264        let directory = tempfile::tempdir().unwrap();
8265        let control_key = [33; 32];
8266        let executions = directory.path().join(crate::spool::TOOL_EXECUTIONS);
8267        std::fs::create_dir_all(&executions).unwrap();
8268        let unreadable = executions.join("unreadablekey.intent.json");
8269        std::fs::write(&unreadable, b"sealed-bytes").unwrap();
8270        std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).unwrap();
8271        if std::fs::read(&unreadable).is_ok() {
8272            // Running as root (CI containers): the premise doesn't hold.
8273            return;
8274        }
8275
8276        let outcome = unresolved_tool_sessions(directory.path(), &control_key).await;
8277        assert!(
8278            outcome.is_err(),
8279            "a permission fault must abort the scan, not quarantine evidence"
8280        );
8281        assert!(
8282            unreadable.exists(),
8283            "the unreadable intent must be left in place for the operator"
8284        );
8285    }
8286
8287    struct BlockingRefundStore {
8288        inner: InMemoryStore,
8289        batches: parking_lot::Mutex<Vec<Vec<av_state::Refund>>>,
8290        entered: tokio::sync::Notify,
8291        released: parking_lot::Mutex<bool>,
8292        wake: parking_lot::Condvar,
8293        fallback_used: std::sync::atomic::AtomicBool,
8294    }
8295
8296    impl StateStore for BlockingRefundStore {
8297        fn add(&self, key: &str, delta: u64) -> Result<u64, StateError> {
8298            self.inner.add(key, delta)
8299        }
8300
8301        fn get(&self, key: &str) -> Result<u64, StateError> {
8302            self.inner.get(key)
8303        }
8304
8305        fn try_spend(&self, key: &str, amount: u64, limit: u64) -> Result<bool, StateError> {
8306            self.inner.try_spend(key, amount, limit)
8307        }
8308
8309        fn try_spend_many(&self, spends: &[Spend]) -> Result<av_state::TrySpendOutcome, StateError> {
8310            self.inner.try_spend_many(spends)
8311        }
8312
8313        fn remove(&self, key: &str) {
8314            self.inner.remove(key);
8315        }
8316
8317        fn refund_many(&self, refunds: &[av_state::Refund]) {
8318            let first = {
8319                let mut batches = self.batches.lock();
8320                batches.push(refunds.to_vec());
8321                batches.len() == 1
8322            };
8323            if first {
8324                self.entered.notify_one();
8325                let deadline = std::time::Instant::now() + Duration::from_secs(3);
8326                let mut released = self.released.lock();
8327                while !*released {
8328                    let remaining = deadline.saturating_duration_since(std::time::Instant::now());
8329                    if remaining.is_zero() || self.wake.wait_for(&mut released, remaining).timed_out() {
8330                        // Keep the pre-fix test finite when this blocking call
8331                        // incorrectly occupies the only Tokio runtime thread.
8332                        self.fallback_used
8333                            .store(true, std::sync::atomic::Ordering::Release);
8334                        break;
8335                    }
8336                }
8337            }
8338            self.inner.refund_many(refunds);
8339        }
8340    }
8341
8342    #[tokio::test(flavor = "current_thread")]
8343    async fn mcp_refund_keeps_reactor_live_and_survives_caller_cancellation() {
8344        let directory = tempfile::tempdir().unwrap();
8345        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8346        let address = listener.local_addr().unwrap();
8347        // A refused connection proves that no upstream tool effect occurred.
8348        drop(listener);
8349        let mut config = crate::config::HarnessConfig::for_tests(
8350            &format!("http://{address}"),
8351            &directory.path().to_string_lossy(),
8352            &directory.path().to_string_lossy(),
8353        );
8354        config.tool_upstream_url = Some(format!("http://{address}/mcp"));
8355        config.budget.max_total_tool_calls = Some(1);
8356        config.budget.max_tool_calls.insert("read".into(), 1);
8357        config.principal_budget = Some(config.budget.clone());
8358        config.allow_anonymous_principal_budget = true;
8359        config.mcp_concurrency = 1;
8360        let store = Arc::new(BlockingRefundStore {
8361            inner: InMemoryStore::new(),
8362            batches: parking_lot::Mutex::new(Vec::new()),
8363            entered: tokio::sync::Notify::new(),
8364            released: parking_lot::Mutex::new(false),
8365            wake: parking_lot::Condvar::new(),
8366            fallback_used: std::sync::atomic::AtomicBool::new(false),
8367        });
8368        let sandbox = Arc::new(
8369            Sandbox::new(
8370                SandboxConfig {
8371                    budget: config.budget.clone(),
8372                    ..SandboxConfig::default()
8373                },
8374                Vec::new(),
8375            )
8376            .unwrap(),
8377        );
8378        let state = AppState::new(
8379            config,
8380            store.clone(),
8381            sandbox,
8382            Arc::new(NullBus),
8383            None,
8384            Arc::new(Ed25519Signer::from_seed(&[11; 32])),
8385        )
8386        .unwrap();
8387        let app = build_router(state.clone());
8388        let request = || {
8389            Request::builder()
8390                .method("POST")
8391                .uri("/v1/mcp")
8392                .header("x-av-session", "refund-cancel")
8393                .body(Body::from(
8394                    r#"{"jsonrpc":"2.0","id":"refund","method":"tools/call","params":{"name":"read","arguments":{}}}"#,
8395                ))
8396                .unwrap()
8397        };
8398        let mut expected_keys = Vec::new();
8399        for prefix in [
8400            av_state::ActionBudget::session_prefix("refund-cancel"),
8401            av_state::ActionBudget::principal_prefix("anonymous"),
8402        ] {
8403            expected_keys.push(format!("{prefix}total_calls"));
8404            expected_keys.push(format!("{prefix}tool:read"));
8405        }
8406        let first = tokio::spawn(app.clone().oneshot(request()));
8407        tokio::time::timeout(Duration::from_secs(5), store.entered.notified())
8408            .await
8409            .unwrap();
8410        // This task must be scheduled while refund_many is still blocked.
8411        // Abort the caller at that point; the detached owner must finish
8412        // both ledger compensations before releasing its admission permit.
8413        let progressed_while_refund_waited = !store.fallback_used.load(std::sync::atomic::Ordering::Acquire);
8414        let charged_before_release: Vec<_> =
8415            expected_keys.iter().map(|key| store.get(key).unwrap()).collect();
8416        first.abort();
8417        *store.released.lock() = true;
8418        store.wake.notify_all();
8419        let caller_cancelled = first.await.is_err_and(|error| error.is_cancelled());
8420        tokio::time::timeout(Duration::from_secs(5), state.mcp_inflight.wait_drained())
8421            .await
8422            .unwrap();
8423        let first_batches = store.batches.lock().clone();
8424        let after_first: Vec<_> = expected_keys.iter().map(|key| store.get(key).unwrap()).collect();
8425        let retry = app.oneshot(request()).await.unwrap();
8426        let retry_status = retry.status();
8427        tokio::time::timeout(Duration::from_secs(5), state.mcp_inflight.wait_drained())
8428            .await
8429            .unwrap();
8430        tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
8431            .await
8432            .unwrap();
8433        let batches = store.batches.lock().clone();
8434        let session = state.sessions.get("refund-cancel").unwrap();
8435
8436        assert!(
8437            progressed_while_refund_waited,
8438            "synchronous budget compensation blocked the only Tokio thread until the fallback released it"
8439        );
8440        assert!(
8441            charged_before_release.iter().all(|value| *value == 1),
8442            "both session and principal ledgers must actually be debited before compensation"
8443        );
8444        assert!(
8445            caller_cancelled,
8446            "the outer caller must be cancelled while compensation is in progress"
8447        );
8448        assert_eq!(
8449            first_batches.len(),
8450            2,
8451            "one session and one principal refund must complete despite cancellation"
8452        );
8453        assert!(
8454            after_first.iter().all(|value| *value == 0),
8455            "both ledgers must be refunded before retry"
8456        );
8457        assert_eq!(
8458            retry_status,
8459            StatusCode::BAD_GATEWAY,
8460            "retry must attempt the unexecuted call, not return a pending claim or spent-budget refusal"
8461        );
8462        assert_eq!(
8463            batches.len(),
8464            4,
8465            "each of two attempts refunds each ledger exactly once"
8466        );
8467        for key in expected_keys {
8468            let amounts: Vec<_> = batches
8469                .iter()
8470                .flatten()
8471                .filter(|refund| refund.key == key)
8472                .map(|refund| refund.amount)
8473                .collect();
8474            assert_eq!(amounts, [1, 1], "unexpected compensation for {key}");
8475            assert_eq!(store.get(&key).unwrap(), 0);
8476        }
8477        assert_eq!(state.mcp_admission.available_permits(), 1);
8478        assert_eq!(session.active_streams_count(), 0);
8479        assert_eq!(session.pending_jobs_count(), 0);
8480    }
8481
8482    async fn assert_orphan_mcp_intent_refuses_startup(unaudited: bool) {
8483        let directory = tempfile::tempdir().unwrap();
8484        let (state, provider) = test_state(directory.path()).await;
8485        let session_id = "orphan-uncertain";
8486        let mut headers = HeaderMap::new();
8487        headers.insert("x-av-session", HeaderValue::from_static("orphan-uncertain"));
8488        let body = br#"{"jsonrpc":"2.0","id":"orphan","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
8489        let mut execution =
8490            ToolExecution::from_request(&state.config.atif_spool_dir, &headers, body, state.journal_key)
8491                .unwrap();
8492        execution
8493            .bind_principal(&av_events::AgentIdentity {
8494                version: "dev".to_owned(),
8495                charter: "anonymous".into(),
8496                instance_uid: "anonymous".to_owned(),
8497                ttl_remaining_s: None,
8498            })
8499            .unwrap();
8500        // This is a valid authenticated execution claim with no first journal
8501        // record. MCP authorization queues its worker job without waiting, so
8502        // claim() and the remote effect may precede that metadata commit.
8503        execution.claim().await.unwrap();
8504        if unaudited {
8505            execution
8506                .persist(&ToolOutcome {
8507                    status: 200,
8508                    body_hex: hex::encode(b"effect happened"),
8509                    content_type: Some("text/plain".to_owned()),
8510                })
8511                .await
8512                .unwrap();
8513        }
8514        let intent_before = std::fs::read(&execution.intent_path).unwrap();
8515        assert!(state.sessions.get(session_id).is_none());
8516        assert!(!std::fs::read_dir(directory.path())
8517            .unwrap()
8518            .filter_map(Result::ok)
8519            .any(|entry| entry.file_name().to_string_lossy().ends_with(".session.json")));
8520        let recovered = tokio::time::timeout(
8521            Duration::from_secs(5),
8522            state
8523                .finalizer
8524                .recover_spooled_sessions(&state.sessions, &state.config.breaker),
8525        )
8526        .await
8527        .unwrap();
8528        let mut minted_after_new_traffic = false;
8529        if recovered.is_ok() {
8530            // Main would start accepting traffic after this successful return.
8531            // A new chat with the same external ID must not manufacture a
8532            // clean trajectory that omits the earlier uncertain tool effect.
8533            let app = build_router(state.clone());
8534            let chat = app.clone().oneshot(chat_request(session_id)).await.unwrap();
8535            axum::body::to_bytes(chat.into_body(), 64 * 1024).await.unwrap();
8536            let _ = app
8537                .clone()
8538                .oneshot(
8539                    Request::builder()
8540                        .method("POST")
8541                        .uri("/v1/sessions/orphan-uncertain/close")
8542                        .body(Body::empty())
8543                        .unwrap(),
8544                )
8545                .await
8546                .unwrap();
8547            let _ = app
8548                .oneshot(
8549                    Request::builder()
8550                        .method("POST")
8551                        .uri("/v1/sessions/orphan-uncertain/promote")
8552                        .body(Body::empty())
8553                        .unwrap(),
8554                )
8555                .await
8556                .unwrap();
8557            tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
8558                .await
8559                .unwrap();
8560            minted_after_new_traffic = state
8561                .sessions
8562                .get(session_id)
8563                .is_some_and(|session| session.receipt.lock().is_some());
8564        }
8565        provider.abort();
8566        let _ = provider.await;
8567        assert!(!minted_after_new_traffic,
8568            "startup accepted an authenticated orphan intent, then new traffic minted a receipt omitting its uncertain effect");
8569        let error = recovered.expect_err(
8570            "an authenticated unresolved intent without recoverable session metadata must refuse startup",
8571        );
8572        assert!(error.to_string().contains("unresolved tool execution"));
8573        assert_eq!(std::fs::read(&execution.intent_path).unwrap(), intent_before);
8574        assert!(
8575            state.sessions.get(session_id).is_none(),
8576            "recovery must not invent session attribution"
8577        );
8578    }
8579
8580    #[tokio::test]
8581    async fn mcp_durable_orphan_pending_intent_refuses_startup_and_new_receipt() {
8582        assert_orphan_mcp_intent_refuses_startup(false).await;
8583    }
8584
8585    #[tokio::test]
8586    async fn mcp_durable_orphan_unaudited_intent_refuses_startup_and_new_receipt() {
8587        assert_orphan_mcp_intent_refuses_startup(true).await;
8588    }
8589
8590    async fn assert_evicted_mcp_intent_refuses_receipt(workflow: &str) {
8591        let directory = tempfile::tempdir().unwrap();
8592        let (state, provider) = test_state(directory.path()).await;
8593        let session_id = "evicted-uncertain";
8594        let identity = av_events::AgentIdentity {
8595            version: "dev".to_owned(),
8596            charter: "anonymous".into(),
8597            instance_uid: "anonymous".to_owned(),
8598            ttl_remaining_s: None,
8599        };
8600        let original = state.sessions.get_or_open(
8601            session_id,
8602            crate::session::Workflow::parse(workflow).unwrap(),
8603            &identity,
8604            &state.config.breaker,
8605        );
8606        let mut headers = HeaderMap::new();
8607        headers.insert("x-av-session", HeaderValue::from_static("evicted-uncertain"));
8608        let body = br#"{"jsonrpc":"2.0","id":"evicted","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
8609        let mut execution =
8610            ToolExecution::from_request(&state.config.atif_spool_dir, &headers, body, state.journal_key)
8611                .unwrap();
8612        execution.bind_principal(&identity).unwrap();
8613        execution.claim().await.unwrap();
8614        let intent_before = std::fs::read(&execution.intent_path).unwrap();
8615        original.mark_capture_failed();
8616        // Deterministically model the existing retained-quarantine overflow
8617        // eviction, without constructing thousands of unrelated sessions.
8618        state.sessions.remove(session_id);
8619        let app = build_router(state.clone());
8620        let request = Request::builder()
8621            .method("POST")
8622            .uri("/v1/chat/completions")
8623            .header("content-type", "application/json")
8624            .header("x-av-session", session_id)
8625            .header("x-av-workflow", workflow)
8626            .body(Body::from(serde_json::to_vec(&chat_payload()).unwrap()))
8627            .unwrap();
8628        let chat = app.clone().oneshot(request).await.unwrap();
8629        assert_eq!(chat.status(), StatusCode::OK);
8630        axum::body::to_bytes(chat.into_body(), 64 * 1024).await.unwrap();
8631        tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
8632            .await
8633            .unwrap();
8634        let fresh = state.sessions.get(session_id).unwrap();
8635        assert!(!Arc::ptr_eq(&original, &fresh));
8636        assert!(
8637            !fresh.capture_failed(),
8638            "the durable guard cannot rely on the old RAM flag"
8639        );
8640        let close = app
8641            .clone()
8642            .oneshot(
8643                Request::builder()
8644                    .method("POST")
8645                    .uri("/v1/sessions/evicted-uncertain/close")
8646                    .body(Body::empty())
8647                    .unwrap(),
8648            )
8649            .await
8650            .unwrap()
8651            .status();
8652        let promote = app
8653            .oneshot(
8654                Request::builder()
8655                    .method("POST")
8656                    .uri("/v1/sessions/evicted-uncertain/promote")
8657                    .body(Body::empty())
8658                    .unwrap(),
8659            )
8660            .await
8661            .unwrap()
8662            .status();
8663        provider.abort();
8664        let _ = provider.await;
8665        assert!(
8666            fresh.receipt.lock().is_none(),
8667            "evicting the RAM quarantine must not permit a receipt omitting the durable uncertain effect"
8668        );
8669        assert_eq!(close, StatusCode::SERVICE_UNAVAILABLE);
8670        assert!(!promote.is_success());
8671        assert_eq!(std::fs::read(&execution.intent_path).unwrap(), intent_before);
8672        assert!(
8673            !fresh.capture_failed(),
8674            "the durable refusal must preserve retryable audit completion"
8675        );
8676    }
8677
8678    #[tokio::test]
8679    async fn mcp_durable_evicted_signed_quarantine_cannot_mint_receipt() {
8680        assert_evicted_mcp_intent_refuses_receipt("signed").await;
8681    }
8682
8683    #[tokio::test]
8684    async fn mcp_durable_evicted_unsigned_quarantine_cannot_promote() {
8685        assert_evicted_mcp_intent_refuses_receipt("unsigned").await;
8686    }
8687
8688    #[tokio::test]
8689    async fn mcp_durable_closed_unsigned_artifact_checks_durable_intent_before_promotion() {
8690        let directory = tempfile::tempdir().unwrap();
8691        let (state, provider) = test_state(directory.path()).await;
8692        let app = build_router(state.clone());
8693        let chat = app
8694            .clone()
8695            .oneshot(chat_request("closed-uncertain"))
8696            .await
8697            .unwrap();
8698        axum::body::to_bytes(chat.into_body(), 64 * 1024).await.unwrap();
8699        let close = app
8700            .clone()
8701            .oneshot(
8702                Request::builder()
8703                    .method("POST")
8704                    .uri("/v1/sessions/closed-uncertain/close")
8705                    .body(Body::empty())
8706                    .unwrap(),
8707            )
8708            .await
8709            .unwrap();
8710        assert_eq!(close.status(), StatusCode::OK);
8711        let session = state.sessions.get("closed-uncertain").unwrap();
8712        assert!(session.is_closed());
8713        assert!(session.receipt.lock().is_none());
8714        let mut headers = HeaderMap::new();
8715        headers.insert("x-av-session", HeaderValue::from_static("closed-uncertain"));
8716        let body = br#"{"jsonrpc":"2.0","id":"closed","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
8717        let mut execution =
8718            ToolExecution::from_request(&state.config.atif_spool_dir, &headers, body, state.journal_key)
8719                .unwrap();
8720        execution.bind_principal(&session.current_identity()).unwrap();
8721        execution.claim().await.unwrap();
8722        let before = std::fs::read(&execution.intent_path).unwrap();
8723        let promotion = app
8724            .oneshot(
8725                Request::builder()
8726                    .method("POST")
8727                    .uri("/v1/sessions/closed-uncertain/promote")
8728                    .body(Body::empty())
8729                    .unwrap(),
8730            )
8731            .await
8732            .unwrap()
8733            .status();
8734        provider.abort();
8735        let _ = provider.await;
8736        assert!(
8737            session.receipt.lock().is_none(),
8738            "a closed unsigned artifact must not bypass the durable uncertainty guard"
8739        );
8740        assert_eq!(promotion, StatusCode::SERVICE_UNAVAILABLE);
8741        assert_eq!(std::fs::read(&execution.intent_path).unwrap(), before);
8742        assert!(!session.capture_failed());
8743    }
8744
8745    #[tokio::test]
8746    async fn mcp_durable_missing_audit_marker_keeps_finalization_retryable() {
8747        let directory = tempfile::tempdir().unwrap();
8748        let (state, provider) = test_state(directory.path()).await;
8749        let app = build_router(state.clone());
8750        let chat = app.clone().oneshot(chat_request("audit-retry")).await.unwrap();
8751        axum::body::to_bytes(chat.into_body(), 64 * 1024).await.unwrap();
8752        tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
8753            .await
8754            .unwrap();
8755        let session = state.sessions.get("audit-retry").unwrap();
8756        let mut headers = HeaderMap::new();
8757        headers.insert("x-av-session", HeaderValue::from_static("audit-retry"));
8758        let body = br#"{"jsonrpc":"2.0","id":"audit","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
8759        let mut execution =
8760            ToolExecution::from_request(&state.config.atif_spool_dir, &headers, body, state.journal_key)
8761                .unwrap();
8762        execution.bind_principal(&session.current_identity()).unwrap();
8763        execution.claim().await.unwrap();
8764        execution
8765            .persist(&ToolOutcome {
8766                status: 200,
8767                body_hex: hex::encode(b"captured outcome"),
8768                content_type: None,
8769            })
8770            .await
8771            .unwrap();
8772        let close_request = || {
8773            Request::builder()
8774                .method("POST")
8775                .uri("/v1/sessions/audit-retry/close")
8776                .body(Body::empty())
8777                .unwrap()
8778        };
8779        let first = app.clone().oneshot(close_request()).await.unwrap().status();
8780        let refused_without_poison = !session.capture_failed()
8781            && !session.is_closed()
8782            && execution.intent_path.exists()
8783            && execution.outcome_path.exists();
8784        // Simulate completion of the existing replay/audit-marker retry path.
8785        execution.mark_audited().await.unwrap();
8786        let second = app.clone().oneshot(close_request()).await.unwrap().status();
8787        let promotion = app
8788            .oneshot(
8789                Request::builder()
8790                    .method("POST")
8791                    .uri("/v1/sessions/audit-retry/promote")
8792                    .body(Body::empty())
8793                    .unwrap(),
8794            )
8795            .await
8796            .unwrap()
8797            .status();
8798        provider.abort();
8799        let _ = provider.await;
8800        assert_eq!(first, StatusCode::SERVICE_UNAVAILABLE);
8801        assert!(
8802            refused_without_poison,
8803            "an unfinished audit must remain retryable with its evidence intact"
8804        );
8805        assert_eq!(second, StatusCode::OK);
8806        assert_eq!(promotion, StatusCode::OK);
8807        assert!(session.receipt.lock().is_some());
8808        assert!(!session.capture_failed());
8809    }
8810
8811    #[tokio::test]
8812    async fn mcp_durable_lifecycle_scan_io_failure_preserves_evidence_and_can_retry() {
8813        let directory = tempfile::tempdir().unwrap();
8814        let (state, provider) = test_state(directory.path()).await;
8815        let app = build_router(state.clone());
8816        let chat = app.clone().oneshot(chat_request("scan-error")).await.unwrap();
8817        axum::body::to_bytes(chat.into_body(), 64 * 1024).await.unwrap();
8818        tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
8819            .await
8820            .unwrap();
8821        let session = state.sessions.get("scan-error").unwrap();
8822        let execution_dir = directory.path().join(crate::spool::TOOL_EXECUTIONS);
8823        let evidence = b"operator recovery evidence at an invalid directory path";
8824        std::fs::write(&execution_dir, evidence).unwrap();
8825        let close_request = || {
8826            Request::builder()
8827                .method("POST")
8828                .uri("/v1/sessions/scan-error/close")
8829                .body(Body::empty())
8830                .unwrap()
8831        };
8832        let failed = app.clone().oneshot(close_request()).await.unwrap().status();
8833        let retained = std::fs::read(&execution_dir).unwrap() == evidence;
8834        let issued_on_failure = session.receipt.lock().is_some() || session.atif_path.lock().is_some();
8835        let retryable = !session.capture_failed() && !session.is_closed();
8836        std::fs::remove_file(&execution_dir).unwrap();
8837        let repaired = app.oneshot(close_request()).await.unwrap().status();
8838        provider.abort();
8839        let _ = provider.await;
8840        assert_eq!(failed, StatusCode::SERVICE_UNAVAILABLE);
8841        assert!(retained);
8842        assert!(!issued_on_failure);
8843        assert!(retryable);
8844        assert_eq!(repaired, StatusCode::OK);
8845    }
8846
8847    #[test]
8848    fn mcp_durable_readonly_scan_preserves_evidence_on_cap_and_authentication_errors() {
8849        let directory = tempfile::tempdir().unwrap();
8850        for name in ["one.evidence", "two.evidence", "three.evidence"] {
8851            std::fs::write(directory.path().join(name), b"retained evidence").unwrap();
8852        }
8853        let capped = ensure_tool_executions_resolved_with_limit(directory.path(), "session", &[7; 32], 2);
8854        assert!(capped.unwrap_err().contains("bounded scan limit"));
8855        for name in ["one.evidence", "two.evidence", "three.evidence"] {
8856            assert_eq!(
8857                std::fs::read(directory.path().join(name)).unwrap(),
8858                b"retained evidence"
8859            );
8860        }
8861        let writing_claim = directory.path().join("partial.intent.json");
8862        std::fs::write(&writing_claim, b"unfinished concurrent claim").unwrap();
8863        let unauthenticated =
8864            ensure_tool_executions_resolved_with_limit(directory.path(), "session", &[7; 32], 10);
8865        assert!(unauthenticated.unwrap_err().contains("authenticated"));
8866        assert_eq!(
8867            std::fs::read(writing_claim).unwrap(),
8868            b"unfinished concurrent claim"
8869        );
8870        assert_eq!(
8871            std::fs::read_dir(directory.path()).unwrap().count(),
8872            4,
8873            "the lifecycle scan must never rename or delete another live request's incomplete files"
8874        );
8875    }
8876
8877    struct WithheldHeadersToolState {
8878        effects: std::sync::atomic::AtomicUsize,
8879        release: tokio::sync::Notify,
8880        returned: std::sync::atomic::AtomicBool,
8881        terminated: std::sync::atomic::AtomicBool,
8882    }
8883
8884    struct WithheldHeadersGuard(Arc<WithheldHeadersToolState>);
8885
8886    impl Drop for WithheldHeadersGuard {
8887        fn drop(&mut self) {
8888            self.0
8889                .terminated
8890                .store(true, std::sync::atomic::Ordering::Release);
8891        }
8892    }
8893
8894    async fn tool_effect_before_headers(State(state): State<Arc<WithheldHeadersToolState>>) -> Response {
8895        let _guard = WithheldHeadersGuard(Arc::clone(&state));
8896        // The request has crossed the remote execution boundary, but the
8897        // gateway has received no HTTP status or response headers yet.
8898        state.effects.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
8899        let _ = tokio::time::timeout(Duration::from_secs(6), state.release.notified()).await;
8900        state.returned.store(true, std::sync::atomic::Ordering::Release);
8901        Json(json!({"ok": true})).into_response()
8902    }
8903
8904    async fn assert_mcp_header_deadline(workflow: &str, cancel_caller: bool, restart: bool) {
8905        let directory = tempfile::tempdir().unwrap();
8906        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8907        let address = listener.local_addr().unwrap();
8908        let observed = Arc::new(WithheldHeadersToolState {
8909            effects: std::sync::atomic::AtomicUsize::new(0),
8910            release: tokio::sync::Notify::new(),
8911            returned: std::sync::atomic::AtomicBool::new(false),
8912            terminated: std::sync::atomic::AtomicBool::new(false),
8913        });
8914        let tool = Router::new()
8915            .route("/mcp", post(tool_effect_before_headers))
8916            .with_state(Arc::clone(&observed));
8917        let server = tokio::spawn(async move { axum::serve(listener, tool).await.unwrap() });
8918        let mut config = crate::config::HarnessConfig::for_tests(
8919            &format!("http://{address}"),
8920            &directory.path().to_string_lossy(),
8921            &directory.path().to_string_lossy(),
8922        );
8923        config.default_workflow = workflow.to_owned();
8924        config.tool_upstream_url = Some(format!("http://{address}/mcp"));
8925        // A long read-idle timeout isolates the shorter MCP total deadline.
8926        config.upstream_read_timeout_s = Some(10);
8927        config.mcp_request_timeout_s = Some(1);
8928        config.mcp_concurrency = 1;
8929        config.budget.max_total_tool_calls = Some(2);
8930        config.budget.max_tool_calls.insert("read".into(), 2);
8931        config.principal_budget = Some(config.budget.clone());
8932        config.allow_anonymous_principal_budget = true;
8933        let make_state = |store: Arc<InMemoryStore>| {
8934            AppState::new(
8935                config.clone(),
8936                store,
8937                Arc::new(
8938                    Sandbox::new(
8939                        SandboxConfig {
8940                            budget: config.budget.clone(),
8941                            ..SandboxConfig::default()
8942                        },
8943                        Vec::new(),
8944                    )
8945                    .unwrap(),
8946                ),
8947                Arc::new(NullBus),
8948                None,
8949                Arc::new(Ed25519Signer::from_seed(&[11; 32])),
8950            )
8951            .unwrap()
8952        };
8953        let store = Arc::new(InMemoryStore::new());
8954        let state = make_state(Arc::clone(&store));
8955        let app = build_router(state.clone());
8956        let body = br#"{"jsonrpc":"2.0","id":"headers","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
8957        let request = || {
8958            Request::builder()
8959                .method("POST")
8960                .uri("/v1/mcp")
8961                .header("content-type", "application/json")
8962                .header("x-av-session", "withheld-headers")
8963                .body(Body::from(Bytes::from_static(body)))
8964                .unwrap()
8965        };
8966        let first = tokio::spawn(app.clone().oneshot(request()));
8967        tokio::time::timeout(Duration::from_secs(5), async {
8968            while observed.effects.load(std::sync::atomic::Ordering::Acquire) == 0 {
8969                tokio::time::sleep(Duration::from_millis(10)).await;
8970            }
8971        })
8972        .await
8973        .unwrap();
8974        if cancel_caller {
8975            first.abort();
8976        }
8977        tokio::time::timeout(Duration::from_secs(5), state.mcp_inflight.wait_drained())
8978            .await
8979            .unwrap();
8980        let first_status = if cancel_caller {
8981            assert!(first.await.unwrap_err().is_cancelled());
8982            None
8983        } else {
8984            Some(first.await.unwrap().unwrap().status())
8985        };
8986        assert!(!observed.returned.load(std::sync::atomic::Ordering::Acquire));
8987        let retry = app.clone().oneshot(request()).await.unwrap();
8988        let retry_status = retry.status();
8989        let retry_body = axum::body::to_bytes(retry.into_body(), 64 * 1024).await.unwrap();
8990        tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
8991            .await
8992            .unwrap();
8993        let session = state.sessions.get("withheld-headers").unwrap();
8994        let mut headers = HeaderMap::new();
8995        headers.insert("x-av-session", HeaderValue::from_static("withheld-headers"));
8996        let mut execution =
8997            ToolExecution::from_request(&state.config.atif_spool_dir, &headers, body, state.journal_key)
8998                .unwrap();
8999        execution.bind_principal(&session.current_identity()).unwrap();
9000        assert!(matches!(
9001            execution.load().await.unwrap(),
9002            ToolExecutionState::Pending
9003        ));
9004        assert!(execution.intent_path.exists());
9005        assert!(!execution.outcome_path.exists());
9006        assert!(!execution.audited_path.exists());
9007        for prefix in [
9008            av_state::ActionBudget::session_prefix("withheld-headers"),
9009            av_state::ActionBudget::principal_prefix("anonymous"),
9010        ] {
9011            for suffix in ["total_calls", "tool:read"] {
9012                assert_eq!(
9013                    store.get(&format!("{prefix}{suffix}")).unwrap(),
9014                    1,
9015                    "uncertain execution must retain its charge without charging the refused retry"
9016                );
9017            }
9018        }
9019        assert_eq!(state.mcp_inflight.count(), 0);
9020        assert_eq!(state.mcp_admission.available_permits(), 1);
9021        assert_eq!(session.active_streams_count(), 0);
9022        assert_eq!(session.pending_jobs_count(), 0);
9023        drop(session);
9024        drop(app);
9025        // A fresh registry and store ensure recovery uses authenticated disk
9026        // evidence, not the old in-memory session or a spent quota.
9027        let lifecycle_state = if restart {
9028            drop(state);
9029            let recovered = make_state(Arc::new(InMemoryStore::new()));
9030            recovered
9031                .finalizer
9032                .recover_spooled_sessions(&recovered.sessions, &recovered.config.breaker)
9033                .await
9034                .unwrap();
9035            assert!(recovered
9036                .sessions
9037                .get("withheld-headers")
9038                .unwrap()
9039                .capture_failed());
9040            recovered
9041        } else {
9042            state
9043        };
9044        let lifecycle_app = build_router(lifecycle_state.clone());
9045        let replay = lifecycle_app.clone().oneshot(request()).await.unwrap();
9046        let replay_status = replay.status();
9047        let close = lifecycle_app
9048            .clone()
9049            .oneshot(
9050                Request::builder()
9051                    .method("POST")
9052                    .uri("/v1/sessions/withheld-headers/close")
9053                    .body(Body::empty())
9054                    .unwrap(),
9055            )
9056            .await
9057            .unwrap();
9058        let close_status = close.status();
9059        let close_body = axum::body::to_bytes(close.into_body(), 64 * 1024).await.unwrap();
9060        let promote = lifecycle_app
9061            .clone()
9062            .oneshot(
9063                Request::builder()
9064                    .method("POST")
9065                    .uri("/v1/sessions/withheld-headers/promote")
9066                    .body(Body::empty())
9067                    .unwrap(),
9068            )
9069            .await
9070            .unwrap();
9071        let promote_status = promote.status();
9072        tokio::time::timeout(Duration::from_secs(5), lifecycle_state.worker.wait_idle())
9073            .await
9074            .unwrap();
9075        let session = lifecycle_state.sessions.get("withheld-headers").unwrap();
9076        let receipt_minted = session.receipt.lock().is_some();
9077        let retained_intent_after_close = execution.intent_path.exists();
9078        let retry_after_close = lifecycle_app.clone().oneshot(request()).await.unwrap().status();
9079        drop(session);
9080        drop(lifecycle_app);
9081        drop(lifecycle_state);
9082        let recovered_again = make_state(Arc::new(InMemoryStore::new()));
9083        tokio::time::timeout(
9084            Duration::from_secs(5),
9085            recovered_again
9086                .finalizer
9087                .recover_spooled_sessions(&recovered_again.sessions, &recovered_again.config.breaker),
9088        )
9089        .await
9090        .unwrap()
9091        .unwrap();
9092        let session = recovered_again.sessions.get("withheld-headers").unwrap();
9093        let still_quarantined = session.capture_failed();
9094        let recovered_receipt = session.receipt.lock().is_some();
9095        let recovered_app = build_router(recovered_again.clone());
9096        let retry_after_restart = recovered_app.clone().oneshot(request()).await.unwrap().status();
9097        let close_after_restart = recovered_app
9098            .clone()
9099            .oneshot(
9100                Request::builder()
9101                    .method("POST")
9102                    .uri("/v1/sessions/withheld-headers/close")
9103                    .body(Body::empty())
9104                    .unwrap(),
9105            )
9106            .await
9107            .unwrap();
9108        let close_after_restart_status = close_after_restart.status();
9109        let close_after_restart_body = axum::body::to_bytes(close_after_restart.into_body(), 64 * 1024)
9110            .await
9111            .unwrap();
9112        let promote_after_restart = recovered_app
9113            .oneshot(
9114                Request::builder()
9115                    .method("POST")
9116                    .uri("/v1/sessions/withheld-headers/promote")
9117                    .body(Body::empty())
9118                    .unwrap(),
9119            )
9120            .await
9121            .unwrap()
9122            .status();
9123        tokio::time::timeout(Duration::from_secs(5), recovered_again.worker.wait_idle())
9124            .await
9125            .unwrap();
9126        // Release the real remote handler and drain it even on the failing
9127        // pre-fix lifecycle path before asserting the final outcomes.
9128        observed.release.notify_one();
9129        tokio::time::timeout(Duration::from_secs(2), async {
9130            while !observed.terminated.load(std::sync::atomic::Ordering::Acquire) {
9131                tokio::time::sleep(Duration::from_millis(10)).await;
9132            }
9133        })
9134        .await
9135        .unwrap();
9136        server.abort();
9137        let _ = server.await;
9138
9139        if let Some(status) = first_status {
9140            assert_eq!(status, StatusCode::BAD_GATEWAY);
9141        }
9142        assert_eq!(retry_status, StatusCode::CONFLICT);
9143        assert!(String::from_utf8_lossy(&retry_body).contains(TOOL_OUTCOME_UNCERTAIN));
9144        assert_eq!(replay_status, StatusCode::CONFLICT);
9145        assert_eq!(observed.effects.load(std::sync::atomic::Ordering::Acquire), 1);
9146        assert!(
9147            !receipt_minted,
9148            "uncertain tool execution must never mint a complete receipt"
9149        );
9150        assert_eq!(promote_status, StatusCode::CONFLICT);
9151        assert!(
9152            retained_intent_after_close,
9153            "closing a quarantine must retain the authenticated uncertain intent"
9154        );
9155        assert_eq!(retry_after_close, StatusCode::CONFLICT);
9156        assert!(
9157            still_quarantined,
9158            "quarantine must survive closing and another restart"
9159        );
9160        assert!(
9161            !recovered_receipt,
9162            "restart must not turn an uncertain execution into a complete receipt"
9163        );
9164        assert_eq!(retry_after_restart, StatusCode::CONFLICT);
9165        assert_eq!(promote_after_restart, StatusCode::CONFLICT);
9166        if close_after_restart_status == StatusCode::OK {
9167            assert_eq!(
9168                serde_json::from_slice::<Value>(&close_after_restart_body).unwrap(),
9169                json!({"kind": "already_closed"})
9170            );
9171        } else {
9172            assert_eq!(close_after_restart_status, StatusCode::CONFLICT);
9173        }
9174        assert!(session.receipt.lock().is_none());
9175        assert!(session.atif_path.lock().is_none());
9176        // A recovered quarantine may already be closed. Its idempotent close
9177        // can acknowledge that fact, but cannot return an ATIF or receipt.
9178        if restart && close_status == StatusCode::OK {
9179            assert_eq!(
9180                serde_json::from_slice::<Value>(&close_body).unwrap(),
9181                json!({"kind": "already_closed"})
9182            );
9183        } else {
9184            assert_eq!(close_status, StatusCode::CONFLICT);
9185        }
9186    }
9187
9188    #[tokio::test]
9189    async fn mcp_header_deadline_refuses_live_signed_receipt() {
9190        assert_mcp_header_deadline("signed", false, false).await;
9191    }
9192
9193    #[tokio::test]
9194    async fn mcp_header_deadline_refuses_live_unsigned_promotion() {
9195        assert_mcp_header_deadline("unsigned", false, false).await;
9196    }
9197
9198    #[tokio::test]
9199    async fn mcp_header_deadline_cancellation_keeps_live_intent() {
9200        assert_mcp_header_deadline("unsigned", true, false).await;
9201    }
9202
9203    #[tokio::test]
9204    async fn mcp_header_deadline_restart_quarantines_signed_session() {
9205        assert_mcp_header_deadline("signed", false, true).await;
9206    }
9207
9208    #[tokio::test]
9209    async fn mcp_header_deadline_cancel_then_restart_quarantines_unsigned_session() {
9210        assert_mcp_header_deadline("unsigned", true, true).await;
9211    }
9212
9213    #[tokio::test]
9214    async fn mcp_outcome_persist_failure_refuses_receipt_and_keeps_charge() {
9215        let directory = tempfile::tempdir().unwrap();
9216        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9217        let address = listener.local_addr().unwrap();
9218        let observed = Arc::new(WithheldHeadersToolState {
9219            effects: std::sync::atomic::AtomicUsize::new(0),
9220            release: tokio::sync::Notify::new(),
9221            returned: std::sync::atomic::AtomicBool::new(false),
9222            terminated: std::sync::atomic::AtomicBool::new(false),
9223        });
9224        let tool = Router::new()
9225            .route("/mcp", post(tool_effect_before_headers))
9226            .with_state(Arc::clone(&observed));
9227        let server = tokio::spawn(async move { axum::serve(listener, tool).await.unwrap() });
9228        let mut config = crate::config::HarnessConfig::for_tests(
9229            &format!("http://{address}"),
9230            &directory.path().to_string_lossy(),
9231            &directory.path().to_string_lossy(),
9232        );
9233        config.default_workflow = "signed".to_owned();
9234        config.tool_upstream_url = Some(format!("http://{address}/mcp"));
9235        config.mcp_request_timeout_s = Some(5);
9236        config.mcp_concurrency = 1;
9237        config.budget.max_total_tool_calls = Some(2);
9238        config.principal_budget = Some(config.budget.clone());
9239        config.allow_anonymous_principal_budget = true;
9240        let store = Arc::new(InMemoryStore::new());
9241        let sandbox = Arc::new(
9242            Sandbox::new(
9243                SandboxConfig {
9244                    budget: config.budget.clone(),
9245                    ..SandboxConfig::default()
9246                },
9247                Vec::new(),
9248            )
9249            .unwrap(),
9250        );
9251        let state = AppState::new(
9252            config,
9253            store.clone(),
9254            sandbox,
9255            Arc::new(NullBus),
9256            None,
9257            Arc::new(Ed25519Signer::from_seed(&[11; 32])),
9258        )
9259        .unwrap();
9260        let app = build_router(state.clone());
9261        let body = br#"{"jsonrpc":"2.0","id":"persist","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
9262        let request = || {
9263            Request::builder()
9264                .method("POST")
9265                .uri("/v1/mcp")
9266                .header("x-av-session", "persist-failure")
9267                .body(Body::from(Bytes::from_static(body)))
9268                .unwrap()
9269        };
9270        let first = tokio::spawn(app.clone().oneshot(request()));
9271        tokio::time::timeout(Duration::from_secs(5), async {
9272            while observed.effects.load(std::sync::atomic::Ordering::Acquire) == 0 {
9273                tokio::time::sleep(Duration::from_millis(10)).await;
9274            }
9275        })
9276        .await
9277        .unwrap();
9278        let session = state.sessions.get("persist-failure").unwrap();
9279        let mut headers = HeaderMap::new();
9280        headers.insert("x-av-session", HeaderValue::from_static("persist-failure"));
9281        let mut execution =
9282            ToolExecution::from_request(&state.config.atif_spool_dir, &headers, body, state.journal_key)
9283                .unwrap();
9284        execution.bind_principal(&session.current_identity()).unwrap();
9285        // Atomic rename cannot overwrite a directory. Inject only after
9286        // the remote effect, so this cannot be a pre-execution refusal.
9287        std::fs::create_dir(&execution.outcome_path).unwrap();
9288        observed.release.notify_one();
9289        let first_status = tokio::time::timeout(Duration::from_secs(5), first)
9290            .await
9291            .unwrap()
9292            .unwrap()
9293            .unwrap()
9294            .status();
9295        tokio::time::timeout(Duration::from_secs(5), state.mcp_inflight.wait_drained())
9296            .await
9297            .unwrap();
9298        tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
9299            .await
9300            .unwrap();
9301        std::fs::remove_dir(&execution.outcome_path).unwrap();
9302        assert!(matches!(
9303            execution.load().await.unwrap(),
9304            ToolExecutionState::Pending
9305        ));
9306        let capture_failed = session.capture_failed();
9307        let retry_status = app.clone().oneshot(request()).await.unwrap().status();
9308        for prefix in [
9309            av_state::ActionBudget::session_prefix("persist-failure"),
9310            av_state::ActionBudget::principal_prefix("anonymous"),
9311        ] {
9312            assert_eq!(store.get(&format!("{prefix}total_calls")).unwrap(), 1);
9313        }
9314        let close = app
9315            .clone()
9316            .oneshot(
9317                Request::builder()
9318                    .method("POST")
9319                    .uri("/v1/sessions/persist-failure/close")
9320                    .body(Body::empty())
9321                    .unwrap(),
9322            )
9323            .await
9324            .unwrap()
9325            .status();
9326        let promote = app
9327            .oneshot(
9328                Request::builder()
9329                    .method("POST")
9330                    .uri("/v1/sessions/persist-failure/promote")
9331                    .body(Body::empty())
9332                    .unwrap(),
9333            )
9334            .await
9335            .unwrap()
9336            .status();
9337        server.abort();
9338        let _ = server.await;
9339        assert_eq!(first_status, StatusCode::INTERNAL_SERVER_ERROR);
9340        assert_eq!(retry_status, StatusCode::CONFLICT);
9341        assert_eq!(observed.effects.load(std::sync::atomic::Ordering::Acquire), 1);
9342        assert!(
9343            capture_failed,
9344            "an executed tool whose outcome cannot persist must poison complete evidence"
9345        );
9346        assert_eq!(close, StatusCode::CONFLICT);
9347        assert_eq!(promote, StatusCode::CONFLICT);
9348        assert!(session.receipt.lock().is_none());
9349        assert!(execution.intent_path.exists());
9350        assert!(!execution.outcome_path.exists());
9351        assert!(!execution.audited_path.exists());
9352        assert_eq!(state.mcp_inflight.count(), 0);
9353        assert_eq!(state.mcp_admission.available_permits(), 1);
9354        assert_eq!(session.active_streams_count(), 0);
9355        assert_eq!(session.pending_jobs_count(), 0);
9356    }
9357
9358    #[tokio::test]
9359    async fn mcp_quarantine_retains_only_unresolved_execution_evidence() {
9360        let directory = tempfile::tempdir().unwrap();
9361        let (state, provider) = test_state(directory.path()).await;
9362        let app = build_router(state.clone());
9363        let opened = app.clone().oneshot(chat_request("retention")).await.unwrap();
9364        assert_eq!(opened.status(), StatusCode::OK);
9365        axum::body::to_bytes(opened.into_body(), 64 * 1024).await.unwrap();
9366        tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
9367            .await
9368            .unwrap();
9369        let session = state.sessions.get("retention").unwrap();
9370        let mut headers = HeaderMap::new();
9371        headers.insert("x-av-session", HeaderValue::from_static("retention"));
9372        let outcome = ToolOutcome {
9373            status: 502,
9374            body_hex: hex::encode(b"partial failure evidence"),
9375            content_type: Some("text/plain".to_owned()),
9376        };
9377        let mut executions = Vec::new();
9378        for kind in [
9379            "pending",
9380            "unaudited",
9381            "corrupt-outcome",
9382            "corrupt-audit",
9383            "completed",
9384        ] {
9385            let body = serde_json::to_vec(&json!({
9386                "jsonrpc": "2.0", "id": kind, "method": "tools/call",
9387                "params": {"name": "read", "arguments": {}}
9388            }))
9389            .unwrap();
9390            let mut execution =
9391                ToolExecution::from_request(&state.config.atif_spool_dir, &headers, &body, state.journal_key)
9392                    .unwrap();
9393            execution.bind_principal(&session.current_identity()).unwrap();
9394            execution.claim().await.unwrap();
9395            if kind != "pending" {
9396                execution.persist(&outcome).await.unwrap();
9397            }
9398            if matches!(kind, "completed" | "corrupt-outcome" | "corrupt-audit") {
9399                execution.mark_audited().await.unwrap();
9400            }
9401            if kind == "corrupt-outcome" {
9402                std::fs::write(&execution.outcome_path, b"not an authenticated outcome").unwrap();
9403            }
9404            if kind == "corrupt-audit" {
9405                std::fs::write(&execution.audited_path, b"not an authenticated audit marker").unwrap();
9406            }
9407            let intent_before = std::fs::read(&execution.intent_path).unwrap();
9408            let outcome_before = std::fs::read(&execution.outcome_path).ok();
9409            executions.push((kind, execution, intent_before, outcome_before));
9410        }
9411        session.mark_capture_failed();
9412        let refused = app
9413            .clone()
9414            .oneshot(
9415                Request::builder()
9416                    .method("POST")
9417                    .uri("/v1/sessions/retention/close")
9418                    .body(Body::empty())
9419                    .unwrap(),
9420            )
9421            .await
9422            .unwrap()
9423            .status();
9424        // One quarantined session must not stop another valid session from
9425        // capturing, closing and issuing its own complete receipt.
9426        let other = app.clone().oneshot(chat_request("other-complete")).await.unwrap();
9427        let other_status = other.status();
9428        axum::body::to_bytes(other.into_body(), 64 * 1024).await.unwrap();
9429        let other_close = app
9430            .clone()
9431            .oneshot(
9432                Request::builder()
9433                    .method("POST")
9434                    .uri("/v1/sessions/other-complete/close")
9435                    .body(Body::empty())
9436                    .unwrap(),
9437            )
9438            .await
9439            .unwrap()
9440            .status();
9441        let other_promote = app
9442            .oneshot(
9443                Request::builder()
9444                    .method("POST")
9445                    .uri("/v1/sessions/other-complete/promote")
9446                    .body(Body::empty())
9447                    .unwrap(),
9448            )
9449            .await
9450            .unwrap()
9451            .status();
9452        tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
9453            .await
9454            .unwrap();
9455        provider.abort();
9456        let _ = provider.await;
9457        assert_eq!(refused, StatusCode::CONFLICT);
9458        assert!(session.receipt.lock().is_none());
9459        assert_eq!(other_status, StatusCode::OK);
9460        assert_eq!(other_close, StatusCode::OK);
9461        assert_eq!(other_promote, StatusCode::OK);
9462        for (kind, execution, intent_before, outcome_before) in executions {
9463            if kind == "completed" {
9464                assert!(!execution.intent_path.exists());
9465                assert!(!execution.outcome_path.exists());
9466                assert!(!execution.audited_path.exists());
9467                let archived_intent = std::fs::read_dir(execution.intent_path.parent().unwrap())
9468                    .unwrap()
9469                    .filter_map(Result::ok)
9470                    .find(|entry| {
9471                        entry
9472                            .file_name()
9473                            .to_string_lossy()
9474                            .starts_with(&format!("{}.intent.json.capturefailed-", execution.key))
9475                    })
9476                    .expect("completed evidence must be archived, not deleted");
9477                assert_eq!(std::fs::read(archived_intent.path()).unwrap(), intent_before);
9478            } else {
9479                assert_eq!(
9480                    std::fs::read(&execution.intent_path).unwrap(),
9481                    intent_before,
9482                    "{kind} must retain its authenticated primary intent"
9483                );
9484                assert_eq!(
9485                    std::fs::read(&execution.outcome_path).ok(),
9486                    outcome_before,
9487                    "{kind} must preserve all partial outcome evidence"
9488                );
9489            }
9490        }
9491    }
9492
9493    async fn slow_active_chat() -> Response {
9494        let frames = futures::stream::unfold(0u8, |index| async move {
9495            if index == 7 {
9496                return None;
9497            }
9498            if index != 0 {
9499                tokio::time::sleep(Duration::from_millis(250)).await;
9500            }
9501            let frame = if index == 6 {
9502                format!(
9503                    "data: {}\n\ndata: [DONE]\n\n",
9504                    json!({
9505                        "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
9506                        "usage": {"prompt_tokens": 2, "completion_tokens": 6}
9507                    })
9508                )
9509            } else {
9510                // Each frame exceeds the capture threshold and arrives well
9511                // before the one-second read-idle deadline.
9512                format!(
9513                    "data: {}\n\n",
9514                    json!({
9515                        "choices": [{"index": 0, "delta": {"content": format!("chat exceeds MCP {}", "x".repeat(280))}, "finish_reason": null}]
9516                    })
9517                )
9518            };
9519            Some((Ok::<_, std::convert::Infallible>(Bytes::from(frame)), index + 1))
9520        });
9521        let mut response = Response::new(Body::from_stream(frames));
9522        response.headers_mut().insert(
9523            axum::http::header::CONTENT_TYPE,
9524            HeaderValue::from_static("text/event-stream"),
9525        );
9526        response
9527    }
9528
9529    #[tokio::test]
9530    async fn mcp_total_deadline_does_not_limit_active_chat_streams() {
9531        let directory = tempfile::tempdir().unwrap();
9532        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9533        let address = listener.local_addr().unwrap();
9534        let provider = Router::new().route("/v1/chat/completions", post(slow_active_chat));
9535        let server = tokio::spawn(async move { axum::serve(listener, provider).await.unwrap() });
9536        let mut config = crate::config::HarnessConfig::for_tests(
9537            &format!("http://{address}"),
9538            &directory.path().to_string_lossy(),
9539            &directory.path().to_string_lossy(),
9540        );
9541        config.upstream_read_timeout_s = Some(1);
9542        config.mcp_request_timeout_s = Some(1);
9543        let state = AppState::new(
9544            config,
9545            Arc::new(InMemoryStore::new()),
9546            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
9547            Arc::new(NullBus),
9548            None,
9549            Arc::new(Ed25519Signer::from_seed(&[11; 32])),
9550        )
9551        .unwrap();
9552        let started = std::time::Instant::now();
9553        let response = build_router(state.clone())
9554            .oneshot(chat_request_with_payload("chat-idle-boundary", chat_payload()))
9555            .await
9556            .unwrap();
9557        let status = response.status();
9558        let body = tokio::time::timeout(
9559            Duration::from_secs(5),
9560            axum::body::to_bytes(response.into_body(), 64 * 1024),
9561        )
9562        .await
9563        .unwrap()
9564        .unwrap();
9565        let elapsed = started.elapsed();
9566        tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
9567            .await
9568            .unwrap();
9569        server.abort();
9570        let _ = server.await;
9571        let text = String::from_utf8(body.to_vec()).unwrap();
9572        assert_eq!(status, StatusCode::OK);
9573        assert!(elapsed > Duration::from_secs(1));
9574        assert_eq!(text.matches("chat exceeds MCP").count(), 6);
9575        assert!(text.contains("\"finish_reason\":\"stop\""));
9576        assert!(text.contains("data: [DONE]"));
9577        assert!(!text.contains("\"error\""));
9578        let session = state.sessions.get("chat-idle-boundary").unwrap();
9579        assert!(!session.capture_failed());
9580        assert_eq!(session.active_streams_count(), 0);
9581        assert_eq!(session.pending_jobs_count(), 0);
9582    }
9583
9584    struct SlowDripToolState {
9585        calls: std::sync::atomic::AtomicUsize,
9586        chunks: std::sync::atomic::AtomicUsize,
9587        dropped: std::sync::atomic::AtomicBool,
9588    }
9589
9590    struct SlowDripBodyGuard(Arc<SlowDripToolState>);
9591
9592    impl Drop for SlowDripBodyGuard {
9593        fn drop(&mut self) {
9594            self.0.dropped.store(true, std::sync::atomic::Ordering::Release);
9595        }
9596    }
9597
9598    async fn slow_drip_tool(State(state): State<Arc<SlowDripToolState>>) -> Response {
9599        state.calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
9600        // A finite three-second response keeps the one-second idle timer
9601        // alive. Keeping this finite also makes the pre-fix regression safe
9602        // to run: detached MCP work can drain before its spool is deleted.
9603        let chunks = futures::stream::unfold((0u8, SlowDripBodyGuard(state)), |(index, guard)| async move {
9604            if index == 16 {
9605                return None;
9606            }
9607            if index != 0 {
9608                tokio::time::sleep(Duration::from_millis(200)).await;
9609            }
9610            let bytes = match index {
9611                0 => Bytes::from_static(b"{\"ok\":"),
9612                15 => Bytes::from_static(b"true}"),
9613                _ => Bytes::from_static(b" "),
9614            };
9615            guard.0.chunks.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
9616            Some((Ok::<_, std::convert::Infallible>(bytes), (index + 1, guard)))
9617        });
9618        let mut response = Response::new(Body::from_stream(chunks));
9619        response.headers_mut().insert(
9620            axum::http::header::CONTENT_TYPE,
9621            HeaderValue::from_static("application/json"),
9622        );
9623        response
9624    }
9625
9626    async fn assert_mcp_slow_drip_deadline(cancel_caller: bool) {
9627        let directory = tempfile::tempdir().unwrap();
9628        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9629        let address = listener.local_addr().unwrap();
9630        let observed = Arc::new(SlowDripToolState {
9631            calls: std::sync::atomic::AtomicUsize::new(0),
9632            chunks: std::sync::atomic::AtomicUsize::new(0),
9633            dropped: std::sync::atomic::AtomicBool::new(false),
9634        });
9635        let tool = Router::new()
9636            .route("/mcp", post(slow_drip_tool))
9637            .with_state(Arc::clone(&observed));
9638        let server = tokio::spawn(async move { axum::serve(listener, tool).await.unwrap() });
9639        let mut config = crate::config::HarnessConfig::for_tests(
9640            &format!("http://{address}"),
9641            &directory.path().to_string_lossy(),
9642            &directory.path().to_string_lossy(),
9643        );
9644        config.tool_upstream_url = Some(format!("http://{address}/mcp"));
9645        config.upstream_read_timeout_s = Some(1);
9646        config.mcp_request_timeout_s = Some(1);
9647        config.mcp_concurrency = 1;
9648        let state = AppState::new(
9649            config,
9650            Arc::new(InMemoryStore::new()),
9651            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
9652            Arc::new(NullBus),
9653            None,
9654            Arc::new(Ed25519Signer::from_seed(&[11; 32])),
9655        )
9656        .unwrap();
9657        let app = build_router(state.clone());
9658        let body = br#"{"jsonrpc":"2.0","id":"slow-drip","method":"tools/call","params":{"name":"read","arguments":{}}}"#;
9659        let request = || {
9660            Request::builder()
9661                .method("POST")
9662                .uri("/v1/mcp")
9663                .header("content-type", "application/json")
9664                .header("x-av-session", "slow-drip")
9665                .body(Body::from(Bytes::from_static(body)))
9666                .unwrap()
9667        };
9668        let first = tokio::spawn(app.clone().oneshot(request()));
9669        tokio::time::timeout(Duration::from_secs(5), async {
9670            while observed.chunks.load(std::sync::atomic::Ordering::Acquire) < 2 {
9671                tokio::time::sleep(Duration::from_millis(10)).await;
9672            }
9673        })
9674        .await
9675        .unwrap();
9676        if cancel_caller {
9677            first.abort();
9678        }
9679        // The intentionally detached audit owner must finish even when the
9680        // caller disappears. Do not delete its spool before it has drained.
9681        tokio::time::timeout(Duration::from_secs(5), state.mcp_inflight.wait_drained())
9682            .await
9683            .unwrap();
9684        let first_status = if cancel_caller {
9685            assert!(first.await.unwrap_err().is_cancelled());
9686            None
9687        } else {
9688            Some(first.await.unwrap().unwrap().status())
9689        };
9690        let retry = app.oneshot(request()).await.unwrap();
9691        let retry_status = retry.status();
9692        let retry_body = axum::body::to_bytes(retry.into_body(), 64 * 1024).await.unwrap();
9693        tokio::time::timeout(Duration::from_secs(5), state.worker.wait_idle())
9694            .await
9695            .unwrap();
9696        let session = state.sessions.get("slow-drip").unwrap();
9697        let mut headers = HeaderMap::new();
9698        headers.insert("x-av-session", HeaderValue::from_static("slow-drip"));
9699        let mut execution =
9700            ToolExecution::from_request(&state.config.atif_spool_dir, &headers, body, state.journal_key)
9701                .unwrap();
9702        execution.bind_principal(&session.current_identity()).unwrap();
9703        let audited = matches!(execution.load().await.unwrap(), ToolExecutionState::Completed(_));
9704        let upstream_dropped = tokio::time::timeout(Duration::from_secs(2), async {
9705            while !observed.dropped.load(std::sync::atomic::Ordering::Acquire) {
9706                tokio::time::sleep(Duration::from_millis(10)).await;
9707            }
9708        })
9709        .await
9710        .is_ok();
9711        server.abort();
9712        let _ = server.await;
9713
9714        assert_eq!(
9715            retry_status,
9716            StatusCode::BAD_GATEWAY,
9717            "the total deadline must terminate a response that never exceeds the idle timeout"
9718        );
9719        if let Some(status) = first_status {
9720            assert_eq!(status, retry_status, "the retry must replay the original failure");
9721        }
9722        assert!(String::from_utf8_lossy(&retry_body).contains("response could not be read"));
9723        assert_eq!(
9724            observed.calls.load(std::sync::atomic::Ordering::Acquire),
9725            1,
9726            "retry must not repeat tool effects"
9727        );
9728        assert!(
9729            observed.chunks.load(std::sync::atomic::Ordering::Acquire) < 16,
9730            "the upstream response must be dropped before its finite completion"
9731        );
9732        assert!(upstream_dropped, "timing out must release the upstream body");
9733        assert!(audited, "the timeout outcome must carry its durable audit marker");
9734        assert_eq!(state.mcp_inflight.count(), 0);
9735        assert_eq!(state.mcp_admission.available_permits(), 1);
9736        assert_eq!(session.active_streams_count(), 0);
9737        assert_eq!(session.pending_jobs_count(), 0);
9738    }
9739
9740    #[tokio::test]
9741    async fn mcp_slow_drip_total_deadline_records_failure_once() {
9742        assert_mcp_slow_drip_deadline(false).await;
9743    }
9744
9745    #[tokio::test]
9746    async fn mcp_slow_drip_total_deadline_survives_caller_cancellation() {
9747        assert_mcp_slow_drip_deadline(true).await;
9748    }
9749
9750    #[tokio::test]
9751    async fn tool_redirect_is_terminal_and_never_replays_post() {
9752        let tool_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9753        let tool_address = tool_listener.local_addr().unwrap();
9754        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
9755        let tool_router = Router::new()
9756            .route("/redirect", post(redirecting_tool))
9757            .route("/effect", post(redirected_effect))
9758            .with_state(Arc::clone(&calls));
9759        let tool_server = tokio::spawn(async move {
9760            axum::serve(tool_listener, tool_router).await.unwrap();
9761        });
9762        let directory = tempfile::tempdir().unwrap();
9763        let (mut state, provider) = test_state(directory.path()).await;
9764        Arc::get_mut(&mut state.config).unwrap().tool_upstream_url =
9765            Some(format!("http://{tool_address}/redirect"));
9766        let response = build_router(state)
9767            .oneshot(
9768                Request::builder()
9769                    .method("POST")
9770                    .uri("/v1/mcp")
9771                    .header("x-av-session", "tool-redirect")
9772                    .body(Body::from(
9773                        r#"{"jsonrpc":"2.0","id":"redirect","method":"tools/call","params":{"name":"read","arguments":{}}}"#,
9774                    ))
9775                    .unwrap(),
9776            )
9777            .await
9778            .unwrap();
9779        assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
9780        assert_eq!(calls.load(std::sync::atomic::Ordering::Acquire), 1);
9781        tool_server.abort();
9782        provider.abort();
9783    }
9784
9785    #[tokio::test]
9786    async fn concurrent_duplicate_tool_claims_conflict_without_leaking_io_detail() {
9787        let tool_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9788        let tool_address = tool_listener.local_addr().unwrap();
9789        let held = Arc::new(HeldToolState {
9790            arrived: std::sync::atomic::AtomicBool::new(false),
9791            release: tokio::sync::Notify::new(),
9792        });
9793        let tool_router = Router::new()
9794            .route("/mcp", post(held_tool))
9795            .with_state(Arc::clone(&held));
9796        let tool_server = tokio::spawn(async move {
9797            axum::serve(tool_listener, tool_router).await.unwrap();
9798        });
9799        let directory = tempfile::tempdir().unwrap();
9800        let (mut state, provider) = test_state(directory.path()).await;
9801        Arc::get_mut(&mut state.config).unwrap().tool_upstream_url =
9802            Some(format!("http://{tool_address}/mcp"));
9803        let app = build_router(state);
9804        let request = || {
9805            Request::builder()
9806                .method("POST")
9807                .uri("/v1/mcp")
9808                .header("x-av-session", "claim-race")
9809                .body(Body::from(
9810                    r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"read","arguments":{}}}"#,
9811                ))
9812                .unwrap()
9813        };
9814        let winner_app = app.clone();
9815        let winner = tokio::spawn(async move { winner_app.oneshot(request()).await.unwrap() });
9816        tokio::time::timeout(Duration::from_secs(1), async {
9817            while !held.arrived.load(std::sync::atomic::Ordering::Acquire) {
9818                tokio::task::yield_now().await;
9819            }
9820        })
9821        .await
9822        .unwrap();
9823        // The winner holds the claim while its upstream call is in flight;
9824        // an identical duplicate must lose the race with the canonical
9825        // uncertainty message and no io/filesystem detail.
9826        let loser = app.oneshot(request()).await.unwrap();
9827        assert_eq!(loser.status(), StatusCode::CONFLICT);
9828        let body = axum::body::to_bytes(loser.into_body(), 64 * 1024).await.unwrap();
9829        let text = String::from_utf8(body.to_vec()).unwrap();
9830        assert!(text.contains(TOOL_OUTCOME_UNCERTAIN), "{text}");
9831        assert!(!text.contains("os error"), "leaked io detail: {text}");
9832        held.release.notify_one();
9833        assert_eq!(winner.await.unwrap().status(), StatusCode::OK);
9834        tool_server.abort();
9835        provider.abort();
9836    }
9837
9838    #[tokio::test]
9839    async fn close_waits_for_tool_execution_and_completion_capture() {
9840        let tool_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9841        let tool_address = tool_listener.local_addr().unwrap();
9842        let held = Arc::new(HeldToolState {
9843            arrived: std::sync::atomic::AtomicBool::new(false),
9844            release: tokio::sync::Notify::new(),
9845        });
9846        let tool_router = Router::new()
9847            .route("/mcp", post(held_tool))
9848            .with_state(Arc::clone(&held));
9849        let tool_server = tokio::spawn(async move {
9850            axum::serve(tool_listener, tool_router).await.unwrap();
9851        });
9852        let directory = tempfile::tempdir().unwrap();
9853        let (mut state, provider) = test_state(directory.path()).await;
9854        Arc::get_mut(&mut state.config).unwrap().tool_upstream_url =
9855            Some(format!("http://{tool_address}/mcp"));
9856        let app = build_router(state);
9857        let tool_app = app.clone();
9858        let tool_task = tokio::spawn(async move {
9859            tool_app
9860                .oneshot(
9861                    Request::builder()
9862                        .method("POST")
9863                        .uri("/v1/mcp")
9864                        .header("x-av-session", "held-tool")
9865                        .body(Body::from(
9866                            r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"read","arguments":{}}}"#,
9867                        ))
9868                        .unwrap(),
9869                )
9870                .await
9871                .unwrap()
9872        });
9873        tokio::time::timeout(Duration::from_secs(1), async {
9874            while !held.arrived.load(std::sync::atomic::Ordering::Acquire) {
9875                tokio::task::yield_now().await;
9876            }
9877        })
9878        .await
9879        .unwrap();
9880        let close_task = tokio::spawn(async move {
9881            app.oneshot(
9882                Request::builder()
9883                    .method("POST")
9884                    .uri("/v1/sessions/held-tool/close")
9885                    .body(Body::empty())
9886                    .unwrap(),
9887            )
9888            .await
9889            .unwrap()
9890        });
9891        tokio::task::yield_now().await;
9892        assert!(!close_task.is_finished(), "close overtook the executing tool");
9893        held.release.notify_one();
9894        assert_eq!(tool_task.await.unwrap().status(), StatusCode::OK);
9895        let close_response = close_task.await.unwrap();
9896        let close_status = close_response.status();
9897        let close_body = axum::body::to_bytes(close_response.into_body(), 64 * 1024)
9898            .await
9899            .unwrap();
9900        assert_eq!(
9901            close_status,
9902            StatusCode::OK,
9903            "close failed: {}",
9904            String::from_utf8_lossy(&close_body)
9905        );
9906        tool_server.abort();
9907        provider.abort();
9908    }
9909
9910    #[test]
9911    #[allow(clippy::cast_possible_truncation)] // xorshift indices; truncation is harmless fuzz entropy
9912    fn fuzz_parse_provider_chunk_is_total() {
9913        // Randomized totality check (hand-rolled xorshift so no
9914        // new dev-dependency): mutations of valid frames plus raw garbage
9915        // must never panic the parser, only return Ok/Err.
9916        let mut state = 0x9E37_79B9_7F4A_7C15u64;
9917        let mut next = move || {
9918            state ^= state << 13;
9919            state ^= state >> 7;
9920            state ^= state << 17;
9921            state
9922        };
9923        let seeds: Vec<&str> = vec![
9924            "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"usage\":null}\n\n",
9925            "data: {\"model\":\"m\",\"choices\":[{\"delta\":{\"content\":\"a\",\"reasoning_content\":\"r\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":3,\"cost_usd\":0.001,\"prompt_tokens_details\":{\"cached_tokens\":4}}}\n\n",
9926            "data: {\"choices\":[{\"index\":1,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"c\",\"function\":{\"name\":\"f\",\"arguments\":\"{}\"}}]}}]}\n\n",
9927            "data: [DONE]\n\n",
9928            "event: message\ndata: {\"choices\":[]}\n\n",
9929            "{\"choices\":[{\"message\":{\"content\":\"x\",\"tool_calls\":[{\"id\":\"a\",\"function\":{\"name\":\"n\",\"arguments\":\"{}\"}}]}}],\"usage\":{\"completion_tokens\":1}}",
9930        ];
9931        for _ in 0..200_000 {
9932            let seed = seeds[(next() as usize) % seeds.len()];
9933            let mut bytes = seed.as_bytes().to_vec();
9934            for _ in 0..=(next() % 8) {
9935                match next() % 3 {
9936                    0 if !bytes.is_empty() => {
9937                        let idx = (next() as usize) % bytes.len();
9938                        bytes[idx] = (next() & 0xFF) as u8;
9939                    }
9940                    1 if !bytes.is_empty() => {
9941                        let idx = (next() as usize) % bytes.len();
9942                        bytes.truncate(idx);
9943                    }
9944                    _ => {
9945                        let idx = if bytes.is_empty() {
9946                            0
9947                        } else {
9948                            (next() as usize) % bytes.len()
9949                        };
9950                        bytes.insert(idx, (next() & 0xFF) as u8);
9951                    }
9952                }
9953            }
9954            let raw = String::from_utf8_lossy(&bytes).into_owned();
9955            let _ = parse_provider_chunk(&raw);
9956            let _ = sse_frame_end(&bytes);
9957        }
9958    }
9959
9960    #[test]
9961    fn provider_usage_and_reasoning_are_preserved() {
9962        let raw = concat!(
9963            "data: {\"model\":\"gpt-test\",\"choices\":[{\"delta\":{",
9964            "\"content\":\"answer\",\"reasoning_content\":\"thought\"},\"finish_reason\":\"stop\"}],",
9965            "\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":3,\"cost_usd\":0.00125,",
9966            "\"prompt_tokens_details\":{\"cached_tokens\":4}}}\n\n"
9967        );
9968        let parsed = parse_provider_chunk(raw).unwrap().unwrap();
9969        assert_eq!(parsed.message, "answer");
9970        assert_eq!(parsed.reasoning.as_deref(), Some("thought"));
9971        assert_eq!(parsed.model_name.as_deref(), Some("gpt-test"));
9972        assert_eq!(parsed.metrics.prompt_tokens, Some(10));
9973        assert_eq!(parsed.metrics.completion_tokens, Some(3));
9974        assert_eq!(parsed.metrics.cached_tokens, Some(4));
9975        assert!(parsed.usage_reported);
9976        assert_eq!(parsed.finish_reason.as_deref(), Some("stop"));
9977        assert_eq!(parsed.cost_usd_micros, 1250);
9978        assert!(parsed.tool_call_deltas.is_empty());
9979    }
9980
9981    #[test]
9982    fn null_usage_chunks_are_not_reported_usage() {
9983        // OpenAI `stream_options: {"include_usage": true}` emits
9984        // `"usage": null` on every content chunk; only the final chunk
9985        // carries the real object. A null usage must behave exactly like
9986        // an absent key: usage_reported = false, completion tokens
9987        // estimated. Treating it as reported previously fed absorb_frame's
9988        // cumulative branch per-chunk estimates → spurious "usage
9989        // regressed" stream kill + session quarantine.
9990        let raw = r#"data: {"choices":[{"delta":{"content":"Hello"}}],"usage":null}"#;
9991        let parsed = parse_provider_chunk(raw).unwrap().unwrap();
9992        assert!(!parsed.usage_reported, "null usage must not count as reported");
9993        assert!(!parsed.completion_reported);
9994        assert_eq!(parsed.message, "Hello");
9995        // Estimated from the delta text, not taken from the null object.
9996        assert_eq!(
9997            parsed.metrics.completion_tokens,
9998            Some(av_core::tokens::approx_tokens("Hello"))
9999        );
10000        // Null fields inside a real usage object are tolerated like
10001        // absent keys (some OpenAI-compatible shims emit them).
10002        let raw = r#"data: {"choices":[{"delta":{"content":"x"}}],"usage":{"prompt_tokens":7,"completion_tokens":null}}"#;
10003        let parsed = parse_provider_chunk(raw).unwrap().unwrap();
10004        assert!(parsed.usage_reported);
10005        assert!(
10006            !parsed.completion_reported,
10007            "a null completion count must not drive cumulative-usage math"
10008        );
10009        assert_eq!(parsed.metrics.prompt_tokens, Some(7));
10010        assert_eq!(
10011            parsed.metrics.completion_tokens,
10012            Some(av_core::tokens::approx_tokens("x"))
10013        );
10014    }
10015
10016    #[test]
10017    fn hostile_provider_metrics_are_rejected() {
10018        for raw in [
10019            r#"{"usage":{"completion_tokens":9007199254740993}}"#,
10020            r#"{"usage":{"prompt_tokens":-1}}"#,
10021            r#"{"usage":{"prompt_tokens_details":{"cached_tokens":"many"}}}"#,
10022            r#"{"usage":{"cost_usd":1e30}}"#,
10023            r#"{"usage":{"cost_usd":-1}}"#,
10024        ] {
10025            assert!(
10026                parse_provider_chunk(raw).is_err(),
10027                "accepted hostile metrics: {raw}"
10028            );
10029        }
10030    }
10031
10032    #[test]
10033    fn streaming_tool_call_arguments_reassemble() {
10034        let first = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","function":{"name":"db_write","arguments":"{\"table\":"}}]}}]}"#;
10035        let second = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"items\",\"row\":{}}"}}]},"finish_reason":"tool_calls"}]}"#;
10036        let first_delta = parse_provider_chunk(first).unwrap().unwrap().tool_call_deltas;
10037        let second_delta = parse_provider_chunk(second).unwrap().unwrap().tool_call_deltas;
10038        assert_eq!(first_delta.len(), 1);
10039        assert_eq!(second_delta.len(), 1);
10040        let arguments = format!("{}{}", first_delta[0].arguments, second_delta[0].arguments);
10041        assert_eq!(
10042            serde_json::from_str::<Value>(&arguments).unwrap(),
10043            json!({"table": "items", "row": {}})
10044        );
10045        assert_eq!(map_finish_reason("tool_calls"), StopReason::ToolUse);
10046    }
10047
10048    #[test]
10049    fn hostile_provider_tool_call_index_is_rejected() {
10050        let raw = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":18446744073709551615,"function":{"name":"n","arguments":"{}"}}]}}]}"#;
10051        let parsed = parse_provider_chunk(raw).unwrap().unwrap();
10052        assert_eq!(parsed.tool_call_deltas.len(), 1);
10053        assert_eq!(parsed.tool_call_deltas[0].index, u64::MAX);
10054    }
10055
10056    #[test]
10057    fn non_stream_tool_calls_receive_distinct_positional_indices() {
10058        let parsed = parse_provider_chunk(
10059            r#"{"choices":[{"message":{"tool_calls":[{"id":"a","function":{"name":"read","arguments":"{}"}},{"id":"b","function":{"name":"write","arguments":"{}"}}]}}]}"#,
10060        )
10061        .unwrap()
10062        .unwrap();
10063        assert_eq!(parsed.tool_call_deltas.len(), 2);
10064        assert_eq!(parsed.tool_call_deltas[0].index, 0);
10065        assert_eq!(parsed.tool_call_deltas[1].index, 1);
10066    }
10067
10068    #[test]
10069    fn sse_frame_boundary_accepts_crlf_and_cr() {
10070        assert_eq!(sse_frame_end(b"data: {}\r\n\r"), None);
10071        assert_eq!(sse_frame_end(b"data: {}\r\n\r\nrest"), Some(12));
10072        assert_eq!(sse_frame_end(b"data: {}\r\rrest"), Some(10));
10073    }
10074
10075    /// Leading BOM (U+FEFF) on an SSE stream must be stripped per
10076    /// §9.2.4. Rust's `.trim()` and `.trim_start()` do NOT remove
10077    /// U+FEFF (char::is_whitespace returns false for it), so without
10078    /// an explicit strip the very first `data:` line would parse as
10079    /// `"\u{FEFF}data:"` and the frame would take the non-SSE path
10080    /// where `serde_json::from_str` chokes on the BOM prefix.
10081    #[test]
10082    fn parse_provider_chunk_strips_leading_bom() {
10083        let raw = "\u{feff}data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n";
10084        let parsed = parse_provider_chunk(raw)
10085            .expect("BOM-prefixed SSE frame must parse")
10086            .expect("must yield a chunk");
10087        assert_eq!(parsed.message, "hi");
10088    }
10089
10090    /// SSE §9.2.8: an SSE client dispatches events by name, so
10091    /// `event: error\ndata: {model-shaped-JSON}` is delivered to the
10092    /// browser/SDK's `error` listener — NOT the default `message`
10093    /// listener. AgentVisor AI attributes captured `data:` payloads to
10094    /// the signed receipt / ATIF as if they were model output, so a
10095    /// hostile upstream (rogue provider, TLS-MITM at egress mesh, a
10096    /// misconfigured caching proxy) could forge receipt content while
10097    /// the user's UI showed nothing suspicious. Refuse the frame with
10098    /// a diagnostic so the caller marks capture-failed rather than
10099    /// signs attributable content that never was displayed.
10100    #[test]
10101    fn parse_provider_chunk_refuses_non_message_sse_event_types() {
10102        for event in ["error", "ping", "custom_signal"] {
10103            let raw = format!(
10104                "event: {event}\ndata: {{\"choices\":[{{\"delta\":{{\"content\":\"forged\"}}}}]}}\n\n"
10105            );
10106            let err = match parse_provider_chunk(&raw) {
10107                Ok(_) => panic!("event type {event:?} must be refused"),
10108                Err(error) => error,
10109            };
10110            assert!(
10111                err.contains("unsupported event type") && err.contains(event),
10112                "expected diagnostic naming event type {event:?}, got: {err}"
10113            );
10114        }
10115        // Explicit `event: message` is accepted (spec default) — must
10116        // NOT be refused by the same guard.
10117        let ok_raw = "event: message\ndata: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n";
10118        let parsed = match parse_provider_chunk(ok_raw) {
10119            Ok(Some(chunk)) => chunk,
10120            Ok(None) => panic!("explicit event: message must yield a chunk"),
10121            Err(error) => panic!("explicit event: message must be accepted, got: {error}"),
10122        };
10123        assert_eq!(parsed.message, "hi");
10124    }
10125
10126    /// A named SSE event with EMPTY
10127    /// `data:` (or no `data:` at all) is a keepalive/heartbeat that
10128    /// carries no audit surface — refusing it (and thereby aborting
10129    /// the whole client stream + sealing the session) was
10130    /// over-restrictive. Such frames must be treated as skip
10131    /// (returning `Ok(None)`), leaving the full-refusal semantics
10132    /// intact for frames whose `data:` field would otherwise be
10133    /// attributed to the model output under the wrong event name.
10134    #[test]
10135    fn parse_provider_chunk_treats_dataless_named_events_as_keepalives() {
10136        for raw in [
10137            "event: ping\n\n",
10138            "event: heartbeat\n\n",
10139            "event: keep-alive\ndata:\n\n",
10140            "event: status\ndata:   \n\n",
10141        ] {
10142            match parse_provider_chunk(raw) {
10143                Ok(None) => {}
10144                Ok(Some(_)) => panic!("dataless named event must not yield a chunk: {raw:?}"),
10145                Err(error) => panic!("dataless named event {raw:?} must not fail the stream (D7): {error}"),
10146            }
10147        }
10148        // A named event that DOES carry attributable data must still
10149        // be refused — the security posture the original guard exists
10150        // for is preserved.
10151        let poison = "event: error\ndata: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\n";
10152        assert!(
10153            parse_provider_chunk(poison).is_err(),
10154            "named event with non-empty data must still be refused"
10155        );
10156    }
10157
10158    #[test]
10159    fn parse_provider_chunk_treats_done_variants_as_stream_end() {
10160        for raw in [
10161            "data: [DONE]\ndata: [DONE]\n\n",
10162            "data: [DONE]  \n\n",
10163            "data:\ndata: [DONE]\n\n",
10164        ] {
10165            let result = parse_provider_chunk(raw);
10166            match result {
10167                Ok(None) => {}
10168                Ok(Some(_)) => panic!("[DONE] variant must terminate stream (None) for {raw:?}"),
10169                Err(error) => panic!("[DONE] variant must not fail parse: {raw:?}: {error}"),
10170            }
10171        }
10172    }
10173
10174    /// R44 Finding 3: OpenAI-compat gateways that batch a final
10175    /// `[DONE]` under a summary event name (e.g.
10176    /// `event: summary\ndata: [DONE]\n\n`) must not fail-close the
10177    /// stream tail. `[DONE]` carries no attributable content, so the
10178    /// named-event refusal at parse_provider_chunk's line ~3465 must
10179    /// treat `data:` lines that are ALL `[DONE]` or empty as a
10180    /// keepalive, matching the Anthropic parser's ordering which
10181    /// short-circuits `[DONE]` before its event-type check.
10182    #[test]
10183    fn parse_provider_chunk_done_under_named_event_is_keepalive() {
10184        for raw in [
10185            "event: summary\ndata: [DONE]\n\n",
10186            "event: error\ndata: [DONE]\n\n",
10187            "event: custom\ndata:\ndata: [DONE]\n\n",
10188            "event: batched\ndata: [DONE]\ndata: [DONE]\n\n",
10189        ] {
10190            match parse_provider_chunk(raw) {
10191                Ok(None) => {}
10192                Ok(Some(_)) => panic!(
10193                    "[DONE] under named event {raw:?} must not yield a chunk (no attributable content)"
10194                ),
10195                Err(error) => {
10196                    panic!("[DONE] under named event {raw:?} must not fail-close the stream: {error}")
10197                }
10198            }
10199        }
10200        // A named event with a mix of [DONE] AND real content must
10201        // still refuse — the content bytes would otherwise be
10202        // attributed to the wrong SSE listener per §9.2.8.
10203        let mixed = "event: error\ndata: [DONE]\ndata: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\n";
10204        assert!(
10205            parse_provider_chunk(mixed).is_err(),
10206            "named event with mixed [DONE] + real content must still refuse: {mixed:?}"
10207        );
10208    }
10209
10210    /// SSE §9.2.6: the `data:` field strips exactly one leading
10211    /// U+0020 SPACE — no more, and no other whitespace class. A
10212    /// regression to `.trim_start()` would eat runs of Unicode
10213    /// whitespace and silently corrupt payloads whose second byte
10214    /// after `data:` is another space or a tab.
10215    ///
10216    /// Distinguishing input: `data:  hi\n\n` — two spaces then a
10217    /// literal `hi`. Non-SSE parse via serde_json would fail on
10218    /// `"hi"` unquoted, so the frame goes through the SSE path where
10219    /// the `data:` accumulator kept one leading space (spec-compliant)
10220    /// and serde_json then fails on ` hi` — this is exactly what the
10221    /// spec's "leave everything after the first space verbatim"
10222    /// behaviour dictates. A `.trim_start()` regression would silently
10223    /// consume both spaces and produce the same (still-invalid) input.
10224    /// We instead pick a JSON payload where the number of leading
10225    /// spaces changes the parse result: `{"choices":[{"delta":{"content":" hi"}}]}`
10226    /// with wire `data:  {"choices"...}` — under `strip_prefix(' ')`
10227    /// the accumulated frame starts with a space then `{`, both parse
10228    /// fine and the extracted `content` field is `" hi"`; under
10229    /// `.trim_start()` the frame starts with `{`, also parses fine and
10230    /// yields the same `" hi"`. So parse content doesn't discriminate.
10231    ///
10232    /// Instead observe the raw data buffer that the parser builds:
10233    /// use a plain non-JSON `data:` value and assert the trimmed
10234    /// prefix. Since the parser now runs serde_json on the assembled
10235    /// frame, the only way to expose the trim behaviour is a test on
10236    /// a lower-level helper. We keep this test as a smoke check on a
10237    /// case where the frame *fails* differently: two-space prefix
10238    /// with a leading space kept produces a JSON parse failure the
10239    /// old `trim_start` would not have produced.
10240    #[test]
10241    fn parse_provider_chunk_strips_exactly_one_leading_space() {
10242        // Bare non-JSON `hello` — invalid JSON either way. With
10243        // `strip_prefix(' ')` (spec-correct) the parser accumulates
10244        // ` \thello` (space+tab+hello). With `.trim_start()` (buggy)
10245        // it accumulates `hello`. Both produce `Err(...)` from
10246        // serde_json but the reported column differs — the trimmed
10247        // form reports column 1 (immediate `h`); the correctly
10248        // preserved form reports a later column because of the
10249        // retained tab.
10250        let raw = "data:  \thello\n\n";
10251        let result = parse_provider_chunk(raw);
10252        let error = match result {
10253            Ok(_) => panic!("bare hello must not parse as valid provider frame"),
10254            Err(error) => error,
10255        };
10256        assert!(
10257            error.contains("column 2") || error.contains("column 3"),
10258            "expected error to name a column past the retained leading space+tab; \
10259             got: {error}"
10260        );
10261    }
10262
10263    #[tokio::test]
10264    async fn split_non_sse_utf8_is_captured_as_one_json_document() {
10265        let directory = tempfile::tempdir().unwrap();
10266        let (state, provider) = test_state(directory.path()).await;
10267        let app = build_router(state.clone());
10268        let response = app
10269            .oneshot(chat_request_with_payload(
10270                "split-json",
10271                json!({
10272                    "model": "split-json",
10273                    "stream": false,
10274                    "messages": [{"role": "user", "content": "hello"}]
10275                }),
10276            ))
10277            .await
10278            .unwrap();
10279        let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
10280            .await
10281            .unwrap();
10282        assert_eq!(
10283            serde_json::from_slice::<Value>(&body).unwrap()["choices"][0]["message"]["content"],
10284            "héllo"
10285        );
10286        let session = state.sessions.get("split-json").unwrap();
10287        session.wait_for_worker_jobs().await;
10288        let crate::reconciler::FinalizeOutcome::Atif { path } = state
10289            .finalizer
10290            .close_session(session, StopReason::SessionClosed)
10291            .await
10292            .unwrap()
10293        else {
10294            panic!("expected ATIF")
10295        };
10296        let trajectory: av_atif::Trajectory = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
10297        assert_eq!(trajectory.steps[1].message, Value::String("héllo".to_owned()));
10298        provider.abort();
10299    }
10300
10301    #[tokio::test]
10302    async fn malformed_non_sse_json_fails_capture_before_delivery() {
10303        let directory = tempfile::tempdir().unwrap();
10304        let (state, provider) = test_state(directory.path()).await;
10305        let app = build_router(state.clone());
10306        let response = app
10307            .oneshot(chat_request_with_payload(
10308                "malformed-json",
10309                json!({
10310                    "model": "malformed-json",
10311                    "stream": false,
10312                    "messages": [{"role": "user", "content": "hello"}]
10313                }),
10314            ))
10315            .await
10316            .unwrap();
10317        // The non-SSE path drains the relay server-side, so a capture
10318        // failure surfaces as a clean 502 + JSON error instead of a
10319        // committed 200 with a severed body.
10320        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
10321        let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
10322            .await
10323            .unwrap();
10324        {
10325            // OpenAI-shaped error object — SDK
10326            // `e.code` / `e.type` dispatch must have material.
10327            let parsed = serde_json::from_slice::<Value>(&body).unwrap();
10328            assert!(parsed["error"]["message"].is_string(), "{parsed}");
10329            assert!(parsed["error"]["type"].is_string(), "{parsed}");
10330            assert!(parsed["error"]["code"].is_number(), "{parsed}");
10331        }
10332        let session = state.sessions.get("malformed-json").unwrap();
10333        // The malformed frame is scoped to THIS
10334        // response — recorded as a failed capture — instead of
10335        // sealing the whole session. The session must stay usable
10336        // for the client's next turn.
10337        assert!(
10338            !session.capture_failed(),
10339            "a single malformed provider frame must not brick the session"
10340        );
10341        provider.abort();
10342    }
10343
10344    /// Refusing an unsupported Content-Encoding must retire the durable
10345    /// in-flight response marker and terminally fail the journalled
10346    /// response attempt — otherwise `recover_spooled_sessions`
10347    /// quarantines the session forever over a request the client
10348    /// already saw fail as a clean 502.
10349    #[tokio::test]
10350    async fn unsupported_content_encoding_refusal_retires_marker_and_attempt() {
10351        let directory = tempfile::tempdir().unwrap();
10352        let (state, provider) = test_state(directory.path()).await;
10353        let response = build_router(state.clone())
10354            .oneshot(chat_request_with_payload(
10355                "gzip-encoded",
10356                json!({
10357                    "model": "gzip-encoded",
10358                    "stream": false,
10359                    "messages": [{"role": "user", "content": "hello"}]
10360                }),
10361            ))
10362            .await
10363            .unwrap();
10364        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
10365        let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
10366            .await
10367            .unwrap();
10368        let error = serde_json::from_slice::<Value>(&body).unwrap()["error"]["message"]
10369            .as_str()
10370            .unwrap()
10371            .to_owned();
10372        assert!(error.contains("Content-Encoding"), "unexpected error: {error}");
10373        let session = state.sessions.get("gzip-encoded").unwrap();
10374        session.wait_for_worker_jobs().await;
10375        crate::worker::ensure_no_inflight_responses(directory.path(), &state.journal_key)
10376            .await
10377            .expect("refusal must retire the in-flight response marker");
10378        let records = active_records(directory.path(), &state, "gzip-encoded");
10379        assert!(
10380            records
10381                .iter()
10382                .filter_map(|record| record.response_attempt.as_ref())
10383                .any(|attempt| attempt.terminal),
10384            "refusal must journal a terminal response attempt"
10385        );
10386        let event: av_events::OcsfEvent =
10387            serde_json::from_value(records.last().unwrap().event.clone()).unwrap();
10388        assert_eq!(event.class_name, av_events::EventClass::StopReason);
10389        assert_eq!(event.status_id, av_events::StatusId::Failure.id());
10390        assert_eq!(event.payload["reason"], "upstream_unsupported_content_encoding");
10391        provider.abort();
10392    }
10393
10394    #[tokio::test]
10395    async fn repeated_provider_responses_open_the_loop_breaker() {
10396        let directory = tempfile::tempdir().unwrap();
10397        let (mut state, provider) = test_state(directory.path()).await;
10398        let config = Arc::get_mut(&mut state.config).unwrap();
10399        config.breaker.min_tokens = 0;
10400        config.breaker.window = 3;
10401        let app = build_router(state.clone());
10402        for _ in 0..4 {
10403            let response = app.clone().oneshot(chat_request("response-loop")).await.unwrap();
10404            assert_eq!(response.status(), StatusCode::OK);
10405            axum::body::to_bytes(response.into_body(), 64 * 1024)
10406                .await
10407                .unwrap();
10408            state
10409                .sessions
10410                .get("response-loop")
10411                .unwrap()
10412                .wait_for_worker_jobs()
10413                .await;
10414        }
10415        let blocked = app.oneshot(chat_request("response-loop")).await.unwrap();
10416        // Loop-breaker verdicts are permanent for the current session:
10417        // 403 stops mainstream LLM-SDK auto-retry loops that would
10418        // otherwise burn budget re-hitting the same breaker (see
10419        // pipeline.rs::PipelineError::status).
10420        assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
10421        provider.abort();
10422    }
10423
10424    #[tokio::test]
10425    async fn empty_and_failed_upstream_responses_are_audited_as_failures() {
10426        let directory = tempfile::tempdir().unwrap();
10427        let (state, provider) = test_state(directory.path()).await;
10428        let app = build_router(state.clone());
10429        for (session_id, model) in [("empty-error", "empty-error"), ("json-error", "json-error")] {
10430            let response = app
10431                .clone()
10432                .oneshot(chat_request_with_payload(
10433                    session_id,
10434                    json!({
10435                        "model": model,
10436                        "stream": false,
10437                        "messages": [{"role": "user", "content": "hello"}]
10438                    }),
10439                ))
10440                .await
10441                .unwrap();
10442            assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
10443            axum::body::to_bytes(response.into_body(), 64 * 1024)
10444                .await
10445                .unwrap();
10446            let session = state.sessions.get(session_id).unwrap();
10447            session.wait_for_worker_jobs().await;
10448            assert!(!session.capture_failed());
10449            let records = active_records(directory.path(), &state, session_id);
10450            assert_eq!(records.len(), 2);
10451            let event: av_events::OcsfEvent =
10452                serde_json::from_value(records.last().unwrap().event.clone()).unwrap();
10453            assert_eq!(event.class_name, av_events::EventClass::StopReason);
10454            assert_eq!(event.status_id, av_events::StatusId::Failure.id());
10455            assert_eq!(event.payload["http_status"], 500);
10456        }
10457        provider.abort();
10458    }
10459
10460    #[tokio::test]
10461    async fn regressing_provider_usage_fails_capture_before_later_bytes() {
10462        let directory = tempfile::tempdir().unwrap();
10463        let (state, provider) = test_state(directory.path()).await;
10464        let response = build_router(state.clone())
10465            .oneshot(chat_request_with_payload(
10466                "regressive-usage",
10467                json!({
10468                    "model": "regressive-usage",
10469                    "stream": true,
10470                    "messages": [{"role": "user", "content": "hello"}]
10471                }),
10472            ))
10473            .await
10474            .unwrap();
10475        assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
10476            .await
10477            .is_err());
10478        let session = state.sessions.get("regressive-usage").unwrap();
10479        // The regressive-usage frame fails THIS
10480        // response's capture (recorded with a failure reason via
10481        // submit_response_capture) — it must not seal the session.
10482        assert!(
10483            !session.capture_failed(),
10484            "a regressive usage frame must be scoped to the response, not the session"
10485        );
10486        session.wait_for_worker_jobs().await;
10487        let records = active_records(directory.path(), &state, "regressive-usage");
10488        // Two records: the request capture and the terminally-failed
10489        // response capture — the failure record IS the audit trail
10490        // entry for this turn.
10491        assert_eq!(records.len(), 2);
10492        assert!(records.iter().any(|record| record.response_attempt.is_some()));
10493        provider.abort();
10494    }
10495
10496    #[tokio::test]
10497    async fn successful_provider_response_requires_choices() {
10498        let directory = tempfile::tempdir().unwrap();
10499        let (state, provider) = test_state(directory.path()).await;
10500        let response = build_router(state.clone())
10501            .oneshot(chat_request_with_payload(
10502                "empty-success",
10503                json!({
10504                    "model": "empty-success",
10505                    "stream": false,
10506                    "messages": [{"role": "user", "content": "hello"}]
10507                }),
10508            ))
10509            .await
10510            .unwrap();
10511        // Missing choices fails capture; the server-side drain converts
10512        // the refusal into a 502 + JSON error rather than a severed body.
10513        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
10514        let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
10515            .await
10516            .unwrap();
10517        {
10518            // OpenAI-shaped error object — SDK
10519            // `e.code` / `e.type` dispatch must have material.
10520            let parsed = serde_json::from_slice::<Value>(&body).unwrap();
10521            assert!(parsed["error"]["message"].is_string(), "{parsed}");
10522            assert!(parsed["error"]["type"].is_string(), "{parsed}");
10523            assert!(parsed["error"]["code"].is_number(), "{parsed}");
10524        }
10525        // choices-less success is a per-response
10526        // capture failure, not a session seal.
10527        assert!(
10528            !state.sessions.get("empty-success").unwrap().capture_failed(),
10529            "a choices-less response must not brick the session"
10530        );
10531        provider.abort();
10532    }
10533
10534    #[tokio::test]
10535    async fn cumulative_usage_settles_against_provisional_charge() {
10536        let directory = tempfile::tempdir().unwrap();
10537        let payload = json!({
10538            "model": "provisional-usage",
10539            "stream": true,
10540            "messages": [{"role": "user", "content": "hello"}]
10541        });
10542        let prompt_tokens = av_core::tokens::approx_tokens(&payload.to_string());
10543        let (state, provider) = test_state_with_token_cap(directory.path(), Some(prompt_tokens + 2)).await;
10544        let response = build_router(state.clone())
10545            .oneshot(chat_request_with_payload("provisional-usage", payload))
10546            .await
10547            .unwrap();
10548        assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
10549            .await
10550            .is_ok());
10551        let session = state.sessions.get("provisional-usage").unwrap();
10552        session.wait_for_worker_jobs().await;
10553        assert_eq!(
10554            session
10555                .totals
10556                .completion_tokens
10557                .load(std::sync::atomic::Ordering::Acquire),
10558            2
10559        );
10560        provider.abort();
10561    }
10562
10563    #[tokio::test]
10564    async fn provider_tool_call_index_out_of_range_fails_capture() {
10565        let directory = tempfile::tempdir().unwrap();
10566        let (state, provider) = test_state(directory.path()).await;
10567        let response = build_router(state.clone())
10568            .oneshot(chat_request_with_payload(
10569                "tool-oob-index",
10570                json!({
10571                    "model": "tool-oob-index",
10572                    "stream": true,
10573                    "messages": [{"role": "user", "content": "hello"}]
10574                }),
10575            ))
10576            .await
10577            .unwrap();
10578        // The capture failure hits the FIRST frame, before any byte was
10579        // relayed — the peek-before-commit path now surfaces it as a
10580        // clean upstream error response instead of a severed body
10581        // (which SDKs treated as a retryable network error).
10582        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
10583        assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
10584            .await
10585            .is_ok());
10586        // An out-of-range tool-call index fails the
10587        // response capture (surfaced as the clean 502 above via the
10588        // peek-before-commit path) but leaves the session usable.
10589        assert!(
10590            !state.sessions.get("tool-oob-index").unwrap().capture_failed(),
10591            "an out-of-range tool-call index must be scoped to the response"
10592        );
10593        provider.abort();
10594    }
10595
10596    /// A provider that reuses an accumulated (choice, index) tool-call
10597    /// slot with a DIFFERENT id previously had its id/name silently
10598    /// overwritten and its argument fragments concatenated — merging
10599    /// two distinct calls into one synthetic record in the attested
10600    /// trajectory. The stream must fail closed instead (mirroring
10601    /// `reject_metric_regression`), scoped to the response.
10602    #[tokio::test]
10603    async fn provider_tool_call_id_conflict_fails_capture() {
10604        let directory = tempfile::tempdir().unwrap();
10605        let (state, provider) = test_state(directory.path()).await;
10606        let response = build_router(state.clone())
10607            .oneshot(chat_request_with_payload(
10608                "tool-conflicting-id",
10609                json!({
10610                    "model": "tool-conflicting-id",
10611                    "stream": true,
10612                    "messages": [{"role": "user", "content": "hello"}]
10613                }),
10614            ))
10615            .await
10616            .unwrap();
10617        // The conflict lands in the first frame, so the
10618        // peek-before-commit path surfaces a clean upstream error.
10619        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
10620        assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
10621            .await
10622            .is_ok());
10623        assert!(
10624            !state
10625                .sessions
10626                .get("tool-conflicting-id")
10627                .unwrap()
10628                .capture_failed(),
10629            "a conflicting tool-call id must be scoped to the response"
10630        );
10631        // The NAME-conflict arm: same slot, same id, different function
10632        // names must also fail closed (a surviving mutant showed this
10633        // arm untested — `!=` → `==` passed the suite).
10634        let response = build_router(state.clone())
10635            .oneshot(chat_request_with_payload(
10636                "tool-conflicting-name",
10637                json!({
10638                    "model": "tool-conflicting-name",
10639                    "stream": true,
10640                    "messages": [{"role": "user", "content": "hello"}]
10641                }),
10642            ))
10643            .await
10644            .unwrap();
10645        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
10646        provider.abort();
10647    }
10648
10649    /// Completion-side twin of the prompt over-charge refund: content
10650    /// chunks arriving with `"usage": null` debit the ledger from
10651    /// per-chunk estimates, and an authoritative final usage LOWER than
10652    /// the accumulated estimate previously left the difference debited
10653    /// forever — the session's remaining budget shrank by tokens the
10654    /// provider never billed. The over-charge must be refunded at
10655    /// terminal capture.
10656    #[tokio::test]
10657    async fn completion_estimate_over_charge_is_refunded_after_authoritative_usage() {
10658        let directory = tempfile::tempdir().unwrap();
10659        let payload = json!({
10660            "model": "overcharged-completion",
10661            "stream": true,
10662            "messages": [{"role": "user", "content": "hello"}]
10663        });
10664        // The 32-`a` content chunk estimates well above the
10665        // authoritative completion count of 1.
10666        let estimate = av_core::tokens::approx_tokens(&"a".repeat(32));
10667        assert!(estimate > 1, "test premise: estimate must exceed usage");
10668        let prompt_tokens = av_core::tokens::approx_tokens(&payload.to_string());
10669        let cap = prompt_tokens + estimate + 8;
10670        let (state, provider) = test_state_with_token_cap(directory.path(), Some(cap)).await;
10671        let response = build_router(state.clone())
10672            .oneshot(chat_request_with_payload("overcharged-completion", payload))
10673            .await
10674            .unwrap();
10675        assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
10676            .await
10677            .is_ok());
10678        let session = state.sessions.get("overcharged-completion").unwrap();
10679        session.wait_for_worker_jobs().await;
10680        // Authoritative usage: prompt 1 + completion 1. Both refunds
10681        // (prompt and completion) must have settled the ledger to a
10682        // spend of exactly 2.
10683        let ledger = av_state::ActionBudget::new(
10684            state.store.as_ref(),
10685            "overcharged-completion",
10686            &state.config.budget,
10687        );
10688        assert!(
10689            ledger.try_tokens(cap - 2).unwrap().is_allowed(),
10690            "the completion-estimate over-charge must be refunded once authoritative usage arrives"
10691        );
10692        assert!(
10693            !ledger.try_tokens(1).unwrap().is_allowed(),
10694            "refunds must settle to the provider-billed spend, not more"
10695        );
10696        provider.abort();
10697    }
10698
10699    /// No SSE mock ever split a frame across a stream
10700    /// chunk, though `protocol_buffer` exists precisely to reassemble
10701    /// one. Split a frame mid-way through a multi-byte UTF-8 scalar
10702    /// and prove the reassembled message reaches the audit trail
10703    /// intact (the non-SSE equivalent lives at `split-json`).
10704    #[tokio::test]
10705    async fn sse_frame_split_across_chunks_reassembles_before_capture() {
10706        let directory = tempfile::tempdir().unwrap();
10707        let (state, provider) = test_state(directory.path()).await;
10708        let response = build_router(state.clone())
10709            .oneshot(chat_request_with_payload(
10710                "split-sse",
10711                json!({
10712                    "model": "split-sse",
10713                    "stream": true,
10714                    "messages": [{"role": "user", "content": "hello"}]
10715                }),
10716            ))
10717            .await
10718            .unwrap();
10719        let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
10720            .await
10721            .unwrap();
10722        // The client sees the full reassembled bytes.
10723        assert!(
10724            String::from_utf8_lossy(&body).contains("héllo"),
10725            "client bytes truncated"
10726        );
10727        let session = state.sessions.get("split-sse").unwrap();
10728        session.wait_for_worker_jobs().await;
10729        assert!(
10730            !session.capture_failed(),
10731            "a chunk-split frame must not fail capture"
10732        );
10733        let crate::reconciler::FinalizeOutcome::Atif { path } = state
10734            .finalizer
10735            .close_session(session, StopReason::SessionClosed)
10736            .await
10737            .unwrap()
10738        else {
10739            panic!("expected ATIF")
10740        };
10741        let trajectory: av_atif::Trajectory = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
10742        assert_eq!(
10743            trajectory.steps[1].message,
10744            Value::String("héllo".to_owned()),
10745            "the audit trail must carry the reassembled multi-byte content"
10746        );
10747        provider.abort();
10748    }
10749
10750    /// An SSE stream that ends
10751    /// cleanly but never carries a finish_reason chunk (provider
10752    /// crash, LB idle-timeout, worker OOM) is a TRUNCATED generation
10753    /// and must not be attested as a complete success — previously it
10754    /// recorded StatusId::Success with no stop reason, so a
10755    /// compliance reviewer could not distinguish a truncated
10756    /// response from a finished one.
10757    #[tokio::test]
10758    async fn truncated_stream_is_not_attested_as_a_complete_success() {
10759        let directory = tempfile::tempdir().unwrap();
10760        let (state, provider) = test_state(directory.path()).await;
10761        let response = build_router(state.clone())
10762            .oneshot(chat_request_with_payload(
10763                "truncated-stream",
10764                json!({
10765                    "model": "truncated-stream",
10766                    "stream": true,
10767                    "messages": [{"role": "user", "content": "hello"}]
10768                }),
10769            ))
10770            .await
10771            .unwrap();
10772        // The client still receives the partial bytes — truncation is
10773        // an audit classification, not a client-facing refusal.
10774        assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
10775            .await
10776            .is_ok());
10777        let session = state.sessions.get("truncated-stream").unwrap();
10778        session.wait_for_worker_jobs().await;
10779        let records = active_records(directory.path(), &state, "truncated-stream");
10780        let truncated = records.iter().any(|record| {
10781            record
10782                .event
10783                .get("payload")
10784                .is_some_and(|payload| payload.get("truncated").and_then(Value::as_bool) == Some(true))
10785        });
10786        assert!(
10787            truncated,
10788            "a finish_reason-less SSE end must be journalled with a truncated marker; records: {:?}",
10789            records
10790                .iter()
10791                .map(|r| r.event.get("payload").cloned())
10792                .collect::<Vec<_>>()
10793        );
10794        provider.abort();
10795    }
10796
10797    /// A provider-side mid-stream cut (TCP RST / stream error) is a
10798    /// FAILED turn attested `StopReason::Other` — never `BudgetExceeded`.
10799    /// Verified live before the fix: a socket-level RST after two SSE
10800    /// deltas journalled `stop_reason_id: 92` ("Budget Exceeded") on a
10801    /// daemon with no `[budget]` block at all, because the failure arm
10802    /// of `submit_response_capture` hard-coded the enforcement variant
10803    /// for every failure shape. A compliance reviewer reading that
10804    /// record concludes the harness cut the turn off when the provider
10805    /// crashed.
10806    #[tokio::test]
10807    async fn severed_stream_is_attested_other_not_budget_exceeded() {
10808        let directory = tempfile::tempdir().unwrap();
10809        let (state, provider) = test_state(directory.path()).await;
10810        let response = build_router(state.clone())
10811            .oneshot(chat_request_with_payload(
10812                "severed-stream",
10813                json!({
10814                    "model": "severed-stream",
10815                    "stream": true,
10816                    "messages": [{"role": "user", "content": "hello"}]
10817                }),
10818            ))
10819            .await
10820            .unwrap();
10821        // The client's copy dies with the cut; the audit side is what
10822        // this test pins.
10823        let _ = axum::body::to_bytes(response.into_body(), 64 * 1024).await;
10824        let session = state.sessions.get("severed-stream").unwrap();
10825        session.wait_for_worker_jobs().await;
10826        let records = active_records(directory.path(), &state, "severed-stream");
10827        let stop_reasons: Vec<(u64, Option<String>)> = records
10828            .iter()
10829            .filter_map(|record| {
10830                let id = record.event.get("stop_reason_id").and_then(Value::as_u64)?;
10831                let reason = record
10832                    .event
10833                    .pointer("/payload/reason")
10834                    .and_then(Value::as_str)
10835                    .map(str::to_owned);
10836                Some((id, reason))
10837            })
10838            .collect();
10839        assert!(
10840            stop_reasons.iter().all(|(id, _)| *id != 92),
10841            "a provider cut must never be attested as Budget Exceeded (92); got {stop_reasons:?}"
10842        );
10843        assert!(
10844            stop_reasons
10845                .iter()
10846                .any(|(id, reason)| *id == 99 && reason.as_deref().is_some_and(|r| !r.is_empty())),
10847            "the cut turn must be attested Other (99) with a free-text failure reason; got {stop_reasons:?}"
10848        );
10849        provider.abort();
10850    }
10851
10852    #[tokio::test]
10853    async fn tool_arguments_without_usage_are_budgeted_before_delivery() {
10854        let directory = tempfile::tempdir().unwrap();
10855        let payload = json!({
10856            "model": "tool-no-usage",
10857            "stream": true,
10858            "messages": [{"role": "user", "content": "hello"}]
10859        });
10860        let prompt_tokens = av_core::tokens::approx_tokens(&payload.to_string());
10861        let (state, provider) = test_state_with_token_cap(directory.path(), Some(prompt_tokens)).await;
10862        let response = build_router(state)
10863            .oneshot(chat_request_with_payload("tool-no-usage", payload))
10864            .await
10865            .unwrap();
10866        // The budget refusal fires on the FIRST frame; peek-before-commit
10867        // surfaces it as the deliberate 403 (not-429) refusal instead of
10868        // a severed stream.
10869        assert_eq!(response.status(), StatusCode::FORBIDDEN);
10870        assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
10871            .await
10872            .is_ok());
10873        provider.abort();
10874    }
10875
10876    #[tokio::test(flavor = "current_thread")]
10877    async fn completion_budget_store_does_not_block_stream_runtime() {
10878        let directory = tempfile::tempdir().unwrap();
10879        let (mut state, provider) = test_state_with_token_cap(directory.path(), Some(10_000)).await;
10880        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
10881        state.store = Arc::new(SlowStore {
10882            inner: InMemoryStore::new(),
10883            calls: Arc::clone(&calls),
10884        });
10885        let response = build_router(state)
10886            .oneshot(chat_request("slow-budget"))
10887            .await
10888            .unwrap();
10889        let body = tokio::spawn(async move {
10890            axum::body::to_bytes(response.into_body(), 64 * 1024)
10891                .await
10892                .unwrap()
10893        });
10894
10895        let started = std::time::Instant::now();
10896        tokio::time::sleep(Duration::from_millis(10)).await;
10897        // 250 ms allows for 25× scheduler jitter over the 10 ms timer
10898        // — still catches the ~100 ms `SlowStore` regression that
10899        // motivated this test, but tolerates loaded CI runners where
10900        // a 10 ms tokio timer can drift to 30-80 ms. The class the
10901        // test catches (a synchronous blocking dependency stalling
10902        // the reactor) shows up as SECONDS of delay, not tens of ms.
10903        assert!(
10904            started.elapsed() < Duration::from_millis(250),
10905            "completion budget storage blocked the Tokio reactor"
10906        );
10907        assert!(!body.await.unwrap().is_empty());
10908        assert_eq!(calls.load(std::sync::atomic::Ordering::Acquire), 2);
10909        provider.abort();
10910    }
10911
10912    #[tokio::test]
10913    async fn completion_tokens_are_blocked_before_delivery() {
10914        let directory = tempfile::tempdir().unwrap();
10915        let prompt_tokens = av_core::tokens::approx_tokens(&chat_payload().to_string());
10916        let (state, provider) = test_state_with_token_cap(directory.path(), Some(prompt_tokens)).await;
10917        let app = build_router(state.clone());
10918        let response = app.oneshot(chat_request("completion-budget")).await.unwrap();
10919        assert_eq!(response.status(), StatusCode::OK);
10920        // The completion-side budget refusal severs the body mid-stream.
10921        assert!(axum::body::to_bytes(response.into_body(), 64 * 1024)
10922            .await
10923            .is_err());
10924        // A budget-refused turn is recorded as a failed
10925        // response capture but must NOT seal or force-close the
10926        // session — per-minute windows refill, and the client's next
10927        // turn under the same id must be admissible. The idle sweeper
10928        // owns eventual finalization.
10929        let session = state.sessions.get("completion-budget").unwrap();
10930        session.wait_for_streams().await;
10931        assert!(
10932            !session.capture_failed(),
10933            "budget refusal must be scoped to the response, not the session"
10934        );
10935        assert!(
10936            !session.is_closed(),
10937            "budget refusal must not force-close the conversation"
10938        );
10939        provider.abort();
10940    }
10941
10942    /// Regression lock for the upstream-response header forwarding
10943    /// trust-boundary bug (CWE-346 / CWE-16). Before the fix, every
10944    /// upstream response header except `Content-Length` was blindly
10945    /// forwarded to the client — letting a hostile or MITM'd upstream
10946    ///
10947    /// - set cookies on our domain (`Set-Cookie`),
10948    /// - open CORS on our origin (`Access-Control-Allow-*`),
10949    /// - leak upstream implementation identity (`Server`, `Via`,
10950    ///   `X-Powered-By`, `X-Request-ID`),
10951    /// - inject conflicting framing/hop-by-hop metadata (`Connection`,
10952    ///   `Transfer-Encoding`, `Keep-Alive`, `Upgrade`, `TE`, `Trailer`,
10953    ///   `Proxy-Authenticate`, `Proxy-Authorization`).
10954    ///
10955    /// `is_forwardable_upstream_header` is now the sole gate on which
10956    /// upstream headers cross the proxy trust boundary — this test locks
10957    /// each dangerous class out and confirms benign headers (`Content-Type`,
10958    /// `Cache-Control`, `ETag`) still pass through.
10959    #[test]
10960    fn upstream_response_headers_do_not_cross_proxy_trust_boundary() {
10961        use axum::http::HeaderName;
10962        // Also include every well-known API-key header
10963        // name and both directions of the configured upstream_auth_header
10964        // check. `authorization` is the runtime default.
10965        let dangerous = [
10966            "set-cookie",
10967            "access-control-allow-origin",
10968            "access-control-allow-credentials",
10969            "access-control-allow-methods",
10970            "access-control-allow-headers",
10971            "access-control-expose-headers",
10972            "access-control-max-age",
10973            "connection",
10974            "keep-alive",
10975            "transfer-encoding",
10976            "upgrade",
10977            "te",
10978            "trailer",
10979            "proxy-authenticate",
10980            "proxy-authorization",
10981            "content-length",
10982            "server",
10983            "via",
10984            "x-powered-by",
10985            "x-request-id",
10986            // Provider API-key headers must never echo
10987            // back from an upstream — they carry the operator's
10988            // outbound credential.
10989            "authorization",
10990            "api-key",
10991            "x-api-key",
10992            "x-goog-api-key",
10993            "anthropic-api-key",
10994            "openai-api-key",
10995            "x-auth-token",
10996            "x-amz-security-token",
10997        ];
10998        for name in dangerous {
10999            let header = HeaderName::from_static(name);
11000            assert!(
11001                !is_forwardable_upstream_header(&header, "authorization"),
11002                "dangerous header {name:?} must not be forwarded to the client"
11003            );
11004        }
11005
11006        // The currently-configured upstream_auth_header
11007        // MUST be refused even for operator-picked odd names. Simulate
11008        // an operator using an exotic header (e.g. `x-my-secret`).
11009        let exotic = HeaderName::from_static("x-my-secret");
11010        assert!(
11011            !is_forwardable_upstream_header(&exotic, "x-my-secret"),
11012            "operator-configured upstream_auth_header must be refused",
11013        );
11014
11015        let benign = [
11016            "content-type",
11017            "content-encoding",
11018            "cache-control",
11019            "etag",
11020            "last-modified",
11021            "vary",
11022            "expires",
11023        ];
11024        for name in benign {
11025            let header = HeaderName::from_static(name);
11026            assert!(
11027                is_forwardable_upstream_header(&header, "authorization"),
11028                "benign header {name:?} must still be forwarded"
11029            );
11030        }
11031    }
11032
11033    /// Regression lock for CWE-209 information exposure on the MCP
11034    /// tool-call path — the second occurrence of pass 17's chat/completion
11035    /// leak. Before this fix, when the tool-upstream request failed
11036    /// (unroutable host, timeout, TLS error, …) `mcp_call` returned
11037    /// `lifecycle_error(format!("forward tool call: {reqwest_err}"))`
11038    /// which — because `reqwest::Error::Display` embeds the request URL —
11039    /// leaked the operator-configured tool-upstream URL (potentially an
11040    /// internal hostname) to any client that could hit `/mcp`. The
11041    /// `read_limited_tool_response` mid-stream error path had the same
11042    /// bug. The client-facing message must now be a stable, non-identifying
11043    /// category (e.g. `"upstream unreachable"`), matching pass 17's
11044    /// classifier categories.
11045    #[tokio::test]
11046    async fn mcp_tool_upstream_failure_does_not_leak_configured_url() {
11047        // Distinctive sentinel host so any regression is unmissable.
11048        let sentinel_host = "internal-mcp-sentinel-host.corp.example";
11049        let sentinel_url = format!("http://{sentinel_host}:65002/mcp");
11050        let directory = tempfile::tempdir().unwrap();
11051        let (mut state, provider) = test_state(directory.path()).await;
11052        Arc::get_mut(&mut state.config).unwrap().tool_upstream_url = Some(sentinel_url);
11053        let response = build_router(state)
11054            .oneshot(
11055                Request::builder()
11056                    .method("POST")
11057                    .uri("/v1/mcp")
11058                    .header("x-av-session", "mcp-cwe-209-check")
11059                    .body(Body::from(
11060                        r#"{"jsonrpc":"2.0","id":"leak","method":"tools/call","params":{"name":"read","arguments":{}}}"#,
11061                    ))
11062                    .unwrap(),
11063            )
11064            .await
11065            .unwrap();
11066        assert_eq!(
11067            response.status(),
11068            StatusCode::BAD_GATEWAY,
11069            "tool-upstream faults must surface as 502 like the chat relay, not 500"
11070        );
11071        let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
11072            .await
11073            .unwrap();
11074        let body = String::from_utf8(bytes.to_vec()).unwrap();
11075        assert!(
11076            !body.contains(sentinel_host),
11077            "MCP forward-tool error body {body:?} leaks the configured tool-upstream host"
11078        );
11079        assert!(
11080            !body.contains("65002"),
11081            "MCP forward-tool error body {body:?} leaks the configured tool-upstream port"
11082        );
11083        assert!(
11084            !body.contains("corp.example"),
11085            "MCP forward-tool error body {body:?} leaks the configured tool-upstream domain"
11086        );
11087        provider.abort();
11088    }
11089
11090    #[tokio::test]
11091    async fn pipeline_error_carries_retry_after_and_www_authenticate() {
11092        // Contract: 503 SHOULD carry Retry-After (RFC 7231 §7.1.3);
11093        // 401 MUST carry WWW-Authenticate (RFC 7235 §3.1). These
11094        // headers are the mechanism by which intermediaries and SDKs
11095        // decide to back off / prompt for credentials — without them,
11096        // mainstream LLM SDKs interpret 503 as "retry immediately"
11097        // and 401 as "broken proxy".
11098        use crate::pipeline::PipelineError;
11099
11100        let unavailable = pipeline_error(PipelineError::unavailable("worker queue full"));
11101        assert_eq!(unavailable.status(), StatusCode::SERVICE_UNAVAILABLE);
11102        assert_eq!(
11103            unavailable
11104                .headers()
11105                .get(axum::http::header::RETRY_AFTER)
11106                .and_then(|v| v.to_str().ok()),
11107            Some("5")
11108        );
11109
11110        let upstream = pipeline_error(PipelineError::upstream("bad gateway"));
11111        assert_eq!(upstream.status(), StatusCode::BAD_GATEWAY);
11112        assert!(upstream.headers().get(axum::http::header::RETRY_AFTER).is_some());
11113
11114        let unauthorized = pipeline_error(PipelineError::Unauthorized("bad token".into()));
11115        assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
11116        let auth = unauthorized
11117            .headers()
11118            .get(axum::http::header::WWW_AUTHENTICATE)
11119            .and_then(|v| v.to_str().ok())
11120            .unwrap_or_default();
11121        assert!(
11122            auth.starts_with("Bearer"),
11123            "WWW-Authenticate must be a Bearer challenge, got {auth:?}"
11124        );
11125
11126        // Permanent verdicts must NOT carry Retry-After — that would
11127        // encourage the very retry loop we're trying to stop.
11128        let blocked = pipeline_error(PipelineError::blocked("loop breaker"));
11129        assert_eq!(blocked.status(), StatusCode::FORBIDDEN);
11130        assert!(blocked.headers().get(axum::http::header::RETRY_AFTER).is_none());
11131        let abort = pipeline_error(PipelineError::Abort("stop retrying".into()));
11132        assert_eq!(abort.status(), StatusCode::CONFLICT);
11133        assert!(abort.headers().get(axum::http::header::RETRY_AFTER).is_none());
11134    }
11135
11136    /// SSE detection is case-insensitive and tolerates
11137    /// media-type parameters. Byte-exact `starts_with` previously
11138    /// misclassified `Text/Event-Stream` (some CDNs re-title-case)
11139    /// and any `text/event-stream; charset=utf-8` with a leading
11140    /// title-cased subtype. Misclassified SSE streams get buffered
11141    /// to the 16 MiB provider cap and either lose streaming
11142    /// semantics or 502 despite being valid.
11143    #[test]
11144    fn is_sse_content_type_is_case_insensitive_and_param_tolerant() {
11145        fn ct(value: &str) -> HeaderMap {
11146            let mut headers = HeaderMap::new();
11147            headers.insert(
11148                axum::http::header::CONTENT_TYPE,
11149                value.parse().expect("header value"),
11150            );
11151            headers
11152        }
11153        assert!(is_sse_content_type(&ct("text/event-stream")));
11154        assert!(is_sse_content_type(&ct("Text/Event-Stream")));
11155        assert!(is_sse_content_type(&ct("TEXT/EVENT-STREAM")));
11156        assert!(is_sse_content_type(&ct("text/event-stream; charset=utf-8")));
11157        assert!(is_sse_content_type(&ct("Text/Event-Stream ; charset=utf-8")));
11158        // Non-SSE and superstring both must not match.
11159        assert!(!is_sse_content_type(&ct("application/json")));
11160        assert!(!is_sse_content_type(&ct("text/event-stream-json")));
11161        // Absent header returns false.
11162        assert!(!is_sse_content_type(&HeaderMap::new()));
11163    }
11164
11165    /// OPTIONS to every mutating route replies with
11166    /// `204 No Content` and NO Access-Control-Allow-* headers.
11167    /// Browsers must interpret this as "cross-origin denied" and
11168    /// refuse the actual request — making the same-origin-only
11169    /// posture explicit at the wire. Guards against a future PR
11170    /// accidentally adding `CorsLayer::permissive()`.
11171    #[tokio::test]
11172    async fn options_returns_204_without_cors_headers() {
11173        let directory = tempfile::tempdir().unwrap();
11174        let (state, provider) = test_state(directory.path()).await;
11175        for path in [
11176            "/v1/chat/completions",
11177            "/v1/mcp",
11178            "/mcp",
11179            "/v1/sessions/some-id/close",
11180            "/v1/sessions/some-id/promote",
11181        ] {
11182            let response = build_router(state.clone())
11183                .oneshot(
11184                    Request::builder()
11185                        .method(axum::http::Method::OPTIONS)
11186                        .uri(path)
11187                        .header("origin", "http://attacker.example")
11188                        .header("access-control-request-method", "POST")
11189                        .body(Body::empty())
11190                        .unwrap(),
11191                )
11192                .await
11193                .unwrap();
11194            assert_eq!(
11195                response.status(),
11196                StatusCode::NO_CONTENT,
11197                "OPTIONS {path} should return 204, got {}",
11198                response.status()
11199            );
11200            for header in [
11201                "access-control-allow-origin",
11202                "access-control-allow-methods",
11203                "access-control-allow-headers",
11204                "access-control-allow-credentials",
11205            ] {
11206                assert!(
11207                    response.headers().get(header).is_none(),
11208                    "OPTIONS {path} must not emit {header} — got {:?}",
11209                    response.headers().get(header)
11210                );
11211            }
11212        }
11213        provider.abort();
11214    }
11215
11216    /// Cached MCP tool-outcome replays preserve the
11217    /// upstream Content-Type end-to-end (including through the
11218    /// on-disk journal roundtrip). Strict JSON-RPC 2.0 clients
11219    /// (spec: MUST be `application/json`) would previously receive
11220    /// `application/octet-stream` on both the fresh forward and the
11221    /// cached replay. This test locks in:
11222    ///   1. `ToolOutcome::into_response` honours a set content_type.
11223    ///   2. A `None` content_type defaults to `application/json`
11224    ///      (MCP is JSON-RPC 2.0 by convention).
11225    ///   3. Serialising and re-deserialising a `ToolOutcome` (i.e.
11226    ///      the on-disk journal round-trip) preserves the field.
11227    ///   4. Legacy journals without the field decode as `None` and
11228    ///      pick up the default via #1.
11229    #[test]
11230    fn round_32_f2_tool_outcome_preserves_content_type() {
11231        // (1) Custom content_type survives.
11232        let outcome = ToolOutcome {
11233            status: 200,
11234            body_hex: hex::encode(b"{\"jsonrpc\":\"2.0\"}"),
11235            content_type: Some("application/problem+json".to_owned()),
11236        };
11237        let response = outcome.into_response();
11238        assert_eq!(
11239            response
11240                .headers()
11241                .get(axum::http::header::CONTENT_TYPE)
11242                .and_then(|v| v.to_str().ok()),
11243            Some("application/problem+json")
11244        );
11245        // (2) None -> application/json default.
11246        let outcome_no_ct = ToolOutcome {
11247            status: 200,
11248            body_hex: hex::encode(b"{}"),
11249            content_type: None,
11250        };
11251        let response = outcome_no_ct.into_response();
11252        assert_eq!(
11253            response
11254                .headers()
11255                .get(axum::http::header::CONTENT_TYPE)
11256                .and_then(|v| v.to_str().ok()),
11257            Some("application/json")
11258        );
11259        // (3) Journal round-trip preserves the field.
11260        let sealed = serde_json::to_string(&ToolOutcome {
11261            status: 429,
11262            body_hex: hex::encode(b"{}"),
11263            content_type: Some("text/plain".to_owned()),
11264        })
11265        .unwrap();
11266        let recovered: ToolOutcome = serde_json::from_str(&sealed).unwrap();
11267        assert_eq!(recovered.content_type.as_deref(), Some("text/plain"));
11268        // (4) Legacy journal without the field decodes as None.
11269        let legacy = r#"{"status":200,"body_hex":"7b7d"}"#;
11270        let outcome: ToolOutcome = serde_json::from_str(legacy).unwrap();
11271        assert!(outcome.content_type.is_none());
11272    }
11273
11274    /// `/livez` and `/readyz` are the Kubernetes-style split of the
11275    /// legacy `/health` constant. Liveness stays a constant; readiness
11276    /// couples to the draining flag and to a spool WRITABILITY probe
11277    /// so a full, read-only, or missing spool volume immediately
11278    /// steers new traffic elsewhere.
11279    #[tokio::test]
11280    async fn livez_and_readyz_reflect_service_readiness() {
11281        let scratch = tempfile::tempdir().unwrap();
11282        let spool = scratch.path().join("spool");
11283        std::fs::create_dir_all(&spool).unwrap();
11284        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11285        let address = listener.local_addr().unwrap();
11286        let provider = Router::new().route("/v1/chat/completions", post(mock_chat));
11287        let _server = tokio::spawn(async move {
11288            axum::serve(listener, provider).await.unwrap();
11289        });
11290        let config = crate::config::HarnessConfig::for_tests(
11291            &format!("http://{address}"),
11292            &spool.to_string_lossy(),
11293            &spool.to_string_lossy(),
11294        );
11295        let state = AppState::new(
11296            config,
11297            Arc::new(InMemoryStore::new()),
11298            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
11299            Arc::new(NullBus),
11300            None,
11301            Arc::new(Ed25519Signer::from_seed(&[19; 32])),
11302        )
11303        .unwrap();
11304        let app = crate::build_router(state.clone());
11305
11306        // /livez must always respond 200 with a constant body — it is
11307        // decoupled from backend health by design.
11308        let live = app
11309            .clone()
11310            .oneshot(Request::builder().uri("/livez").body(Body::empty()).unwrap())
11311            .await
11312            .unwrap();
11313        assert_eq!(live.status(), StatusCode::OK);
11314
11315        // /readyz on a healthy pod: spool exists, not draining → 200.
11316        let ready = app
11317            .clone()
11318            .oneshot(Request::builder().uri("/readyz").body(Body::empty()).unwrap())
11319            .await
11320            .unwrap();
11321        assert_eq!(ready.status(), StatusCode::OK);
11322
11323        // Draining flag flipped: /readyz must return 503 immediately.
11324        state.draining.store(true, std::sync::atomic::Ordering::SeqCst);
11325        let not_ready = app
11326            .clone()
11327            .oneshot(Request::builder().uri("/readyz").body(Body::empty()).unwrap())
11328            .await
11329            .unwrap();
11330        assert_eq!(not_ready.status(), StatusCode::SERVICE_UNAVAILABLE);
11331
11332        // /livez remains 200 while draining — restart-cascade guard.
11333        let still_live = app
11334            .clone()
11335            .oneshot(Request::builder().uri("/livez").body(Body::empty()).unwrap())
11336            .await
11337            .unwrap();
11338        assert_eq!(still_live.status(), StatusCode::OK);
11339    }
11340
11341    /// The register's observed outage: a FULL DISK keeps the spool
11342    /// directory readable, so the previous `is_dir()` readiness check
11343    /// passed while every request 503'd on write — the pod stayed
11344    /// Ready, in-service, un-restarted through the whole incident.
11345    /// `/readyz` must probe WRITABILITY. A read-only spool (the
11346    /// closest deterministic stand-in for ENOSPC — both fail the
11347    /// probe-file create) must flip readiness to 503, and restoring
11348    /// write permission must restore 200 with no restart.
11349    #[cfg(unix)]
11350    #[tokio::test]
11351    async fn readyz_fails_when_the_spool_is_not_writable() {
11352        use std::os::unix::fs::PermissionsExt as _;
11353        let scratch = tempfile::tempdir().unwrap();
11354        let spool = scratch.path().join("spool");
11355        std::fs::create_dir_all(&spool).unwrap();
11356        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11357        let address = listener.local_addr().unwrap();
11358        let provider = Router::new().route("/v1/chat/completions", post(mock_chat));
11359        let _server = tokio::spawn(async move {
11360            axum::serve(listener, provider).await.unwrap();
11361        });
11362        let config = crate::config::HarnessConfig::for_tests(
11363            &format!("http://{address}"),
11364            &spool.to_string_lossy(),
11365            &spool.to_string_lossy(),
11366        );
11367        let state = AppState::new(
11368            config,
11369            Arc::new(InMemoryStore::new()),
11370            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
11371            Arc::new(NullBus),
11372            None,
11373            Arc::new(Ed25519Signer::from_seed(&[23; 32])),
11374        )
11375        .unwrap();
11376        let app = crate::build_router(state);
11377
11378        // Read-only spool: directory still readable, probe write fails.
11379        std::fs::set_permissions(&spool, std::fs::Permissions::from_mode(0o555)).unwrap();
11380        if std::fs::write(spool.join("root-probe"), b"x").is_ok() {
11381            // Running as root (CI container): the premise doesn't hold.
11382            let _ = std::fs::remove_file(spool.join("root-probe"));
11383            std::fs::set_permissions(&spool, std::fs::Permissions::from_mode(0o755)).unwrap();
11384            return;
11385        }
11386        let not_ready = app
11387            .clone()
11388            .oneshot(Request::builder().uri("/readyz").body(Body::empty()).unwrap())
11389            .await
11390            .unwrap();
11391        assert_eq!(
11392            not_ready.status(),
11393            StatusCode::SERVICE_UNAVAILABLE,
11394            "an unwritable (readable) spool must flip readiness — the full-disk incident shape"
11395        );
11396
11397        // Restore write permission: readiness recovers without restart.
11398        std::fs::set_permissions(&spool, std::fs::Permissions::from_mode(0o755)).unwrap();
11399        let ready = app
11400            .clone()
11401            .oneshot(Request::builder().uri("/readyz").body(Body::empty()).unwrap())
11402            .await
11403            .unwrap();
11404        assert_eq!(ready.status(), StatusCode::OK);
11405        // The probe cleans up after itself: no .readyz-*.tmp residue.
11406        let residue = std::fs::read_dir(&spool)
11407            .unwrap()
11408            .filter_map(Result::ok)
11409            .any(|entry| {
11410                entry
11411                    .file_name()
11412                    .to_str()
11413                    .is_some_and(|name| name.starts_with(".readyz-"))
11414            });
11415        assert!(!residue, "probe files must not accumulate in the spool");
11416    }
11417
11418    /// DNS-rebinding defense: a non-empty `allowed_hosts` must
11419    /// refuse a Host header outside the list with 403 before any
11420    /// handler runs, accept listed hosts (with or without a port,
11421    /// case-insensitively, IPv6 brackets included), and refuse an
11422    /// absent Host. Empty list = check disabled (loopback/ingress
11423    /// deployments).
11424    #[tokio::test]
11425    async fn allowed_hosts_refuses_rebound_host_headers() {
11426        let scratch = tempfile::tempdir().unwrap();
11427        let spool = scratch.path().join("spool");
11428        std::fs::create_dir_all(&spool).unwrap();
11429        let mut config = crate::config::HarnessConfig::for_tests(
11430            "http://127.0.0.1:9",
11431            &spool.to_string_lossy(),
11432            &spool.to_string_lossy(),
11433        );
11434        config.allowed_hosts = vec!["localhost".to_owned(), "[::1]".to_owned()];
11435        let state = AppState::new(
11436            config,
11437            Arc::new(InMemoryStore::new()),
11438            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
11439            Arc::new(NullBus),
11440            None,
11441            Arc::new(Ed25519Signer::from_seed(&[23; 32])),
11442        )
11443        .unwrap();
11444        let app = crate::build_router(state);
11445
11446        let probe = |host: Option<&'static str>| {
11447            let mut builder = Request::builder().uri("/health");
11448            if let Some(host) = host {
11449                builder = builder.header("host", host);
11450            }
11451            builder.body(Body::empty()).unwrap()
11452        };
11453
11454        // Listed host, with port, mixed case, IPv6 bracket form: allowed.
11455        for host in ["localhost", "localhost:8484", "LOCALHOST", "[::1]:8484"] {
11456            let response = app.clone().oneshot(probe(Some(host))).await.unwrap();
11457            assert_eq!(response.status(), StatusCode::OK, "host {host:?} must be allowed");
11458        }
11459        // Rebound / unlisted host and absent host: refused.
11460        for host in [
11461            Some("evil.attacker.example"),
11462            Some("localhost.attacker.example"),
11463            None,
11464        ] {
11465            let response = app.clone().oneshot(probe(host)).await.unwrap();
11466            assert_eq!(
11467                response.status(),
11468                StatusCode::FORBIDDEN,
11469                "host {host:?} must be refused"
11470            );
11471        }
11472    }
11473
11474    /// A `/metrics` scrape must emit both a live
11475    /// `av_open_sessions` gauge (sampled at scrape time from the
11476    /// registry) and a labelled `av_signing_key_info` gauge whose
11477    /// value is 1 whenever the process holds the signer.
11478    #[tokio::test]
11479    async fn metrics_scrape_emits_open_sessions_gauge_and_signing_key_info() {
11480        let scratch = tempfile::tempdir().unwrap();
11481        let spool = scratch.path().join("spool");
11482        std::fs::create_dir_all(&spool).unwrap();
11483        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11484        let address = listener.local_addr().unwrap();
11485        let provider = Router::new().route("/v1/chat/completions", post(mock_chat));
11486        let _server = tokio::spawn(async move {
11487            axum::serve(listener, provider).await.unwrap();
11488        });
11489        let config = crate::config::HarnessConfig::for_tests(
11490            &format!("http://{address}"),
11491            &spool.to_string_lossy(),
11492            &spool.to_string_lossy(),
11493        );
11494        let signer = Ed25519Signer::from_seed(&[31; 32]);
11495        use av_receipts::Signer as _;
11496        let key_id = signer.key_id().to_owned();
11497        let public_key_hex = hex::encode(signer.public_key_bytes());
11498        let state = AppState::new(
11499            config,
11500            Arc::new(InMemoryStore::new()),
11501            Arc::new(Sandbox::new(SandboxConfig::default(), Vec::new()).unwrap()),
11502            Arc::new(NullBus),
11503            None,
11504            Arc::new(signer),
11505        )
11506        .unwrap();
11507        // Mirror main.rs's startup registration so the gauge is
11508        // reachable from the same route.
11509        state
11510            .metrics
11511            .gauge(
11512                &format!("av_signing_key_info{{key_id=\"{key_id}\",public_key_hex=\"{public_key_hex}\"}}"),
11513                "test signing key info",
11514            )
11515            .set(1);
11516        let app = crate::build_router(state.clone());
11517
11518        // Scrape with an empty registry — gauge sample must be 0.
11519        let response = app
11520            .clone()
11521            .oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap())
11522            .await
11523            .unwrap();
11524        let body = String::from_utf8_lossy(
11525            &axum::body::to_bytes(response.into_body(), 256 * 1024)
11526                .await
11527                .unwrap(),
11528        )
11529        .into_owned();
11530        assert!(body.contains("# TYPE av_open_sessions gauge"), "{body}");
11531        assert!(body.contains("av_open_sessions 0"), "{body}");
11532        // data-plane series must exist from boot.
11533        assert!(body.contains("# TYPE av_worker_queue_depth gauge"), "{body}");
11534        assert!(body.contains("# TYPE av_spool_bytes gauge"), "{body}");
11535        assert!(body.contains("# TYPE av_requests_total counter"), "{body}");
11536        assert!(
11537            body.contains("av_requests_total{route=\"chat\",status_class=\"2xx\"}"),
11538            "{body}"
11539        );
11540        assert!(
11541            body.contains("av_upstream_errors_total{kind=\"timeout\"}"),
11542            "{body}"
11543        );
11544        assert!(body.contains("av_upstream_latency_seconds"), "{body}");
11545        assert!(
11546            body.contains(&format!("av_signing_key_info{{key_id=\"{key_id}\"")),
11547            "signing-key info gauge missing from scrape: {body}"
11548        );
11549
11550        // Register a session and scrape again — gauge must reflect 1.
11551        state.sessions.insert_recovered(crate::session::Session::new(
11552            "gauge-test".to_owned(),
11553            crate::session::Workflow::Unsigned,
11554            av_events::AgentIdentity {
11555                version: "1".to_owned(),
11556                charter: "test".into(),
11557                instance_uid: "instance-1".to_owned(),
11558                ttl_remaining_s: Some(600),
11559            },
11560            Default::default(),
11561        ));
11562        let response = app
11563            .clone()
11564            .oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap())
11565            .await
11566            .unwrap();
11567        let body = String::from_utf8_lossy(
11568            &axum::body::to_bytes(response.into_body(), 256 * 1024)
11569                .await
11570                .unwrap(),
11571        )
11572        .into_owned();
11573        assert!(body.contains("av_open_sessions 1"), "{body}");
11574    }
11575
11576    /// R49 review-of-R48: the stable client-facing error message
11577    /// for a corrupt cached tool outcome must not regress. R48 fixed
11578    /// `ToolOutcome::into_response` but missed the sibling
11579    /// `complete_tool_audit` decode site. Both are now routed through
11580    /// `decode_cached_tool_body`; a future refactor that reintroduces
11581    /// `format!("...{error}...")` on either branch would leak
11582    /// `hex::FromHexError` Display detail (offending byte offset +
11583    /// character) into the 500 body. Pin the stable message so the
11584    /// leak-free contract survives.
11585    #[tokio::test]
11586    async fn decode_cached_tool_body_returns_stable_error_message() {
11587        let helper_err = decode_cached_tool_body("zz").expect_err("`zz` must not hex-decode");
11588        let body = String::from_utf8(
11589            axum::body::to_bytes((*helper_err).into_body(), 16 * 1024)
11590                .await
11591                .unwrap()
11592                .to_vec(),
11593        )
11594        .expect("lifecycle_error body is UTF-8");
11595        assert!(
11596            body.contains("cached tool outcome is corrupt"),
11597            "leak-free stable error message expected in body: {body}"
11598        );
11599        // Must NOT contain the raw `hex::FromHexError` Display
11600        // fingerprints — byte offset ("at 0") or the offending
11601        // character (`z`).
11602        assert!(
11603            !body.contains("at 0"),
11604            "hex::FromHexError position must not leak: {body}"
11605        );
11606        // The `into_response` path must produce the same stable
11607        // body: prove both consumers share the helper.
11608        let outcome_response = ToolOutcome {
11609            status: 200,
11610            body_hex: "zz".to_owned(),
11611            content_type: None,
11612        }
11613        .into_response();
11614        let outcome_body = String::from_utf8(
11615            axum::body::to_bytes(outcome_response.into_body(), 16 * 1024)
11616                .await
11617                .unwrap()
11618                .to_vec(),
11619        )
11620        .expect("into_response body is UTF-8");
11621        assert!(
11622            outcome_body.contains("cached tool outcome is corrupt"),
11623            "ToolOutcome::into_response must route through the shared helper: {outcome_body}"
11624        );
11625    }
11626}