dwara_core/dataplane/
proxy.rs

1//! Reverse-proxy dataplane (DW-009, feature analysis sections 4.1 and 9.2).
2//!
3//! Per request: snapshot lookup, route resolution (path via the compiled
4//! `RouteTable`, then the route's non-path criteria: host, methods,
5//! headers, query, cookies), and the route action:
6//!
7//! - `proxy`: strip hop-by-hop headers, rebuild `Host` to the upstream
8//!   authority (upstream `address:port`, NOT the inbound host — v1 choice:
9//!   the gateway, not the client, names the origin it dials), inject
10//!   `X-Forwarded-For` / `X-Real-IP`, and stream the request body through
11//!   the pooled upstream client frame-by-frame. The upstream response
12//!   (`Incoming`) is forwarded untouched after hop-by-hop stripping.
13//!   ZERO default buffering: no code on this path collects a body.
14//!   Backpressure is hyper's natural frame-based flow — the gateway never
15//!   spawns unbounded buffering tasks.
16//! - `redirect`: 3xx with a `Location` built from the configured
17//!   scheme/host/path; when no `path` is configured the inbound path AND
18//!   query are preserved verbatim (v1 semantics).
19//! - `respond`: fixed status/body straight from config.
20//! - no route / criteria miss: 404 via the unified error envelope (v1
21//!   does not model 405; a method or host mismatch reads as "no route
22//!   for this request").
23//!
24//! Forwarded-header semantics (chosen and frozen here):
25//! - `X-Forwarded-For`: if the direct connection peer is inside
26//!   `gateway.trusted_proxies`, the inbound XFF value is preserved and the
27//!   peer appended (`"<inbound>, <peer>"`). Otherwise (including the empty
28//!   default list — trust nobody) the inbound XFF is DISCARDED and replaced
29//!   with exactly the peer. A spoofed chain from an untrusted peer never
30//!   reaches the upstream.
31//! - `X-Real-IP`: always the direct peer. When the peer is a trusted proxy
32//!   this equals "the last trusted hop"; when it is the client itself it
33//!   equals the client. One rule, no configuration.
34//!
35//! Protocol upgrades (generic 101 tunneling, not WebSocket-specific): a
36//! request carrying an `Upgrade` header on HTTP/1.1 is forwarded with its
37//! `Upgrade`/`Connection` headers intact; a `101` upstream response is
38//! relayed to the client and both connections are upgraded
39//! (`hyper::upgrade::on` both sides) and spliced with
40//! `tokio::io::copy_bidirectional` until EOF. HTTP/2 clients cannot tunnel
41//! upgrades the HTTP/1.1 way: an `Upgrade` request received over h2/h2c is
42//! answered `501 Not Implemented` with a clear message (extended CONNECT is
43//! out of scope for v1).
44//!
45//! Upstream error classification (details are logged server-side, never
46//! leaked to the client): connect timeout / per-attempt read timeout ->
47//! 504; endpoint refused / pool failure / no endpoints -> 502; invalid TLS
48//! host -> 500. A mid-body upstream abort (connection torn down partway
49//! through a response body, or the DW-014 body-idle timeout firing) is NOT
50//! retryable — the attempt was final once its headers resolved — and any
51//! frames already forwarded to the client end abruptly (HTTP/1.1
52//! truncation semantics, no synthesized tail). The abort IS reported as a
53//! passive-health failure for the picked endpoint (DW-014 closing the
54//! DW-012 gap), so chronically dying streams eject the endpoint.
55//!
56//! Retries, timeouts, budgets (DW-014): per-upstream `retries` config
57//! drives a bounded retry loop around the send — per attempt the balancer
58//! re-picks the endpoint (health ejection applies naturally), the
59//! per-attempt `read_ms` deadline is enforced by the upstream handle, and
60//! backoff is exponential with full jitter. Idempotency:
61//! GET/HEAD/OPTIONS/TRACE/PUT are retry-eligible by method; POST is
62//! retried ONLY when the upstream sets `retries.retry_post` (the opt-in
63//! governs POST exclusively — DELETE, PATCH, and every other
64//! non-idempotent method are never retried). A request
65//! body is replayed only when it was buffered within
66//! `retries.buffer_max_bytes` (opt-in; the default proxy path never
67//! buffers — zero-copy streaming is preserved byte-for-byte when retries
68//! are off). Every retry is charged against the upstream's rolling-window
69//! retry budget; exhaustion fails requests through to the client.
70//!
71//! Authorization (DW-020): immediately after authN (and before rate
72//! limiting), the request runs through the `authz` module's precedence
73//! chain — consumer > route > service > listener > global
74//! (`authz::AuthzChain`; every link has a config attachment:
75//! `consumers[].authorization`, `routes[].authorization`,
76//! `services[].authorization`, `listeners[].authorization`, and the
77//! gateway-level `authorization`) — consumer/group/scope/claim rules
78//! against the authenticated identity, IP ACLs against the EFFECTIVE
79//! client IP (XFF-resolved behind a trusted proxy, see DW-009). A deny
80//! at ANY level wins; otherwise the most specific level with rules
81//! governs. Denials answer 403 (generic body, reason logged server-side
82//! only); identity rules imply authentication, so an anonymous caller
83//! gets 401. An `ip_acl`-only block is the one authorization shape that
84//! can admit anonymous traffic.
85//!
86//! Local rate limiting (DW-017): after route resolution (and its
87//! criteria checks) but BEFORE cap admission, the request runs through
88//! the rate-limit engine (`RateLimitEngine`, built from the generation's
89//! policies). Ordering rationale: rejecting early with a 429 is the
90//! cheapest thing the gateway can do with a request, so it precedes the
91//! more expensive admission layers; a 429 is emitted before any permit is
92//! acquired, so rate-limited requests never hold a cap slot (though the
93//! connection itself is held for the length of the response — accepted).
94//! A denied request answers 429 with `Retry-After` (whole seconds,
95//! rounded up, minimum 1) and `X-RateLimit-Limit` / `-Remaining` /
96//! `-Reset` headers from the BINDING constraint (the window that denied;
97//! see the rate_limiter module docs for stacked-window semantics).
98//! Admitted requests whose policies matched carry the same three
99//! `X-RateLimit-*` headers on their final response (only when a policy
100//! actually applied — a no-match request carries no rate headers).
101//! `X-RateLimit-Reset` is Unix epoch seconds of the binding window's
102//! estimated full replenishment. Policy resolution follows the frozen
103//! precedence chain consumer > route > service > listener > global:
104//! every level with an attachment applies and the applicable rules
105//! AND together; the resolution order binds the 429 headers (the first
106//! denying rule wins them — see the rate_limiter module docs). UNROUTED
107//! traffic (no route / criteria miss) is NOT exempt: the listener and
108//! global links apply to it before the 404 is answered (rate limiting
109//! at minimum; the consumer/route/service links are unknowable before
110//! routing, and authentication sits after route resolution in the
111//! documented order), so 404 floods cannot bypass the limiter. The
112//! reserved paths (`/healthz`, `/readyz`, `/metrics`) stay exempt. When
113//! several rules deny, Retry-After is the maximum wait across denying
114//! rules while the Limit/Remaining headers come from the first binding
115//! one (see the rate_limiter module docs for multi-rule denial
116//! semantics). When rate headers are applied the GATEWAY is the source
117//! of truth for `X-RateLimit-*`: any upstream values are silently
118//! replaced.
119//!
120//! Consumer request budgets (DW-033): a quota-configured consumer's
121//! requests additionally run a budget check AFTER rate limiting and
122//! BEFORE cap admission — an in-memory GCRA 429 is cheaper than a
123//! store-backed one, and a refused request never holds a concurrency
124//! slot. A budget 429 reuses the same builder (Retry-After +
125//! `X-RateLimit-*` from the binding budget; reset is the UTC window
126//! boundary), but budget headers appear on DENIALS only: admitted
127//! responses' `X-RateLimit-*` family belongs to the rate limiter when
128//! it applies (two mechanisms racing to write the same header names on
129//! every success would be noise, not information). Budgets apply to
130//! authenticated CONFIG consumers exclusively (anonymous traffic and
131//! store-managed consumers have none); counters live in the state
132//! store, so quota config without `DWARA_STATE_DB` is inert (warned
133//! once). Usage is metered through `dwara_quota_*` metrics, the
134//! admin `GET /quotas/usage` endpoint, the analytics store's
135//! per-consumer axis, and the `quota_near_limit` event (edge-triggered
136//! once per budget per window at 80%). See `state::quotas` for the
137//! window and evaluation semantics.
138//!
139//! Observability (DW-021): every request opens a root `request` span
140//! (request id, method, path WITHOUT the query string, consumer, route,
141//! listener) with child spans per phase — authn, authz, ratelimit,
142//! quota, admission, and one `upstream_attempt` per send (the balancer's pick
143//! runs inside it as `upstream_pick`). On completion the wrapper
144//! records `requests_total`/`request_duration_seconds`, echoes
145//! `X-Request-Id` (a valid inbound value respected, printable ASCII up
146//! to 128 bytes; anything else replaced), and emits the sampled
147//! `dwara::access` log line. `/metrics` is reserved on every HTTP(S)
148//! listener like `/healthz` (it shadows configured routes). All
149//! gateway-generated error bodies — including the reserved health
150//! endpoints — are the JSON envelope `{"error":{code,message,
151//! request_id}}` with classification-only messages (upstream internals
152//! never leak). See the `observability` module for the full surface.
153//! Load shedding by priority (DW-016): the gateway concurrency cap admits
154//! requests ROUTE-AWARE — route resolution happens BEFORE cap admission
155//! (so the request's priority class is known; 404s therefore never consume
156//! cap slots, a deliberate change from DW-015's admit-at-entry ordering).
157//! Priority classes are 0 (lowest) to 10 (highest); 5 is the default. The
158//! design is a RESERVED BUCKET, not preemption — HTTP requests already in
159//! flight cannot be displaced, so under saturation the gateway sheds
160//! lower-priority ADMISSIONS first: requests at `high_priority` (>= 8) may
161//! draw from a reserved sub-allowance of the cap (10% of the cap, minimum
162//! one permit) that lower-priority traffic cannot use. Normal traffic is
163//! shed (503 "gateway saturated") as soon as the general allowance fills;
164//! high-priority traffic survives until the reserved bucket fills too.
165//! The bucket is carved only when a high-priority ROUTE is configured
166//! (priority >= 8; consumer priorities are inert until authN — DW-019 —
167//! and never trigger carving), so priority-free configs behave
168//! byte-identically to DW-015 (full cap for everyone, same shed
169//! response). Sheds are 503, not 429 — 429 is reserved for rate limiting
170//! (DW-017); no `Retry-After` is set (immediate re-dispatch under a
171//! saturated gateway is not advised) and no shed marker header is added
172//! (the response stays a plain 503 status + the error envelope). Every admission and shed
173//! is counted per priority class in atomics on the dataplane
174//! (`DataPlane::priority_counters`; metrics exposure is DW-021).
175//!
176//! Circuit breaking and caps (DW-015): three independent admission
177//! layers wrap the send path. (1) The per-upstream BREAKER
178//! (`upstreams[].breaker`, see `breaker`) is checked BEFORE the endpoint
179//! pick and before every (re)attempt — an open upstream answers 503
180//! "upstream circuit open" with a `Retry-After` of the seconds until
181//! half-open; in-flight requests complete normally, and the breaker is a
182//! layer ABOVE endpoint ejection (a fail-open pick still flows through
183//! it; a breaker-open period ejects nothing, since health sees no
184//! traffic). (2) The per-upstream PENDING cap (`upstreams[].max_pending`)
185//! rejects requests that would have to WAIT for an outbound connection
186//! slot: 503 "upstream saturated", immediately, no queueing (the default
187//! is the DW-008 queue-forever behavior). (3) The GATEWAY concurrency cap
188//! (`gateway.max_concurrent_requests`) admits requests at `handle()`
189//! entry — after the reserved `/healthz`/`/readyz` paths, so probes
190//! answer under saturation — rejecting over-cap requests with 503
191//! "gateway saturated" immediately; a slot is released when the response
192//! body completes (or the client connection drops).
193//!
194//! Upgrade forwarding on non-tunnel requests: an `Upgrade` header arriving
195//! on an ordinary proxied request (no `101` ever comes back) is forwarded
196//! upstream, together with its `Connection` tokens, rather than stripped.
197//! This is deliberate: the upstream, not the gateway, decides protocol
198//! switches — stripping `Upgrade` would break legitimate upgrades (h2c,
199//! WebSocket handshakes that begin as a normal request) the moment the
200//! upstream wanted to accept one. RFC-strict proxies that strip
201//! connection-oriented headers wholesale would reject this; we chose
202//! upgrade transparency, and the behavior is pinned by the coverage suite.
203//!
204//! Maintenance mode (DW-041): a route carrying a `maintenance` block is
205//! answered by the GATEWAY, never the route action — a 503 carrying
206//! `Retry-After` and the JSON envelope (`maintenance` code,
207//! operator-optional message).
208//! The check runs IMMEDIATELY after route resolution, BEFORE the route's
209//! request limits: maintenance is a statement about the route's
210//! availability, not about any request's shape, so every matched request
211//! gets the same answer (an over-limit request is told "we're down", not
212//! "your headers are too big" — fixing the headers would still leave it
213//! refused) and the gateway skips evaluating limits for a request it will
214//! refuse anyway. Preflights are the one exemption: a CORS preflight on
215//! a CORS-configured route still answers 204 (the preflight is a Fetch
216//! handshake about the gateway's own cross-origin policy, sent without
217//! credentials; failing it surfaces in the browser as an opaque CORS
218//! error and hides the 503 the operator wants clients to see on the
219//! actual request — which DOES get 503, carrying the policy's
220//! actual-response CORS headers so browser clients can read the
221//! envelope). Reserved paths answer before route resolution and are
222//! unaffected: probes and scrapes keep working through maintenance.
223//! Toggled per generation by config reload; unrouted traffic is
224//! unaffected (404 stays 404).
225//!
226//! Policy dry run (DW-041, monitor mode): every policy phase that can
227//! reject supports a per-attachment `dry_run` flag — route limits
228//! (`routes[].limits.dry_run`, 413/431), authorization
229//! (`authorization.dry_run` at any of the five levels, 401/403), rate
230//! limiting (`policies[].dry_run`, 429), and load shedding
231//! (`gateway.load_shed_dry_run`, 503). A dry phase still EVALUATES, but
232//! on a would-reject it logs one structured `dwara::policy` warn event
233//! (phase, would-be status, reason, route, consumer, request id),
234//! increments `dwara_policy_dry_run_total{phase,route}`, and lets the
235//! request PROCEED. The invariant throughout: dry run never makes
236//! enforcement more permissive — a LIVE deny always enforces (the authz
237//! resolver walks past a dry deny and stops only at a live one; live
238//! rate-limit bundles 429 regardless of dry bundles on the same
239//! request). Dry rate-limit rules contribute no `X-RateLimit-*` headers;
240//! a dry route-limits block leaves the streaming body guard unarmed
241//! (only the cheap up-front checks are observable). Authentication
242//! (401 on invalid/missing credentials, `auth_required`) is identity
243//! verification, not a policy phase, and has no dry-run flag. The
244//! metric + log events ARE the dry-run report (§9.3): no endpoint, no
245//! buffer — scrape the counter and grep `dwara::policy`.
246
247use std::collections::HashMap;
248use std::net::IpAddr;
249use std::pin::Pin;
250use std::str::FromStr;
251use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
252use std::sync::Arc;
253use std::task::{Context, Poll};
254use std::time::Duration;
255
256use arc_swap::ArcSwap;
257use http_body_util::{Full, LengthLimitError, Limited};
258use hyper::body::Body as _;
259use hyper::body::Bytes;
260use hyper::header::{
261    HeaderMap, HeaderName, HeaderValue, ACCEPT_ENCODING, CONNECTION, COOKIE, HOST, IF_NONE_MATCH,
262    LOCATION, ORIGIN, UPGRADE,
263};
264use hyper::{Request, Response, StatusCode, Version};
265use hyper_util::rt::TokioIo;
266use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
267use tokio::sync::{OwnedSemaphorePermit, Semaphore};
268use tracing::Instrument as _;
269use zeroize::Zeroizing;
270
271use crate::config::net::peer_is_trusted;
272use crate::config::{
273    Consumer, ConsumerType, Gateway, NameValueMatch, PathRewrite, Route, RouteAction, RouteMatch,
274};
275use crate::dataplane::plugin_dispatch::{self, RequestPlugins};
276use crate::dataplane::split::{mint_affinity_id, read_cookie};
277use crate::dataplane::upstream::{
278    refresh_observation_gauges, UpstreamBody, UpstreamError, UpstreamRegistry,
279};
280use crate::extensions::rate_limiter::{RateLimitEngine, RateLimitOutcome};
281use crate::observability::{self, AccessRecord, ListenerLabel, Observability};
282use crate::plugins::NativeRegistry;
283use crate::resilience::retries::RetryParams;
284use crate::security::authn::{
285    AuthError, Authenticator, CompositeAuthenticator, Identity, JwksCacheEntry,
286};
287use crate::snapshot::RouteTable;
288use crate::snapshot::{ConfigState, Snapshot};
289use crate::state::store::StateStore;
290use crate::wasm::lifecycle::PluginLifecycle;
291
292/// Body type of every proxied/gateway-generated response: a small
293/// fully-buffered gateway message (`Full`), the untouched streaming
294/// upstream body ([`UpstreamBody`]: the pooled stream wrapped with the
295/// DW-014 write-timeout / mid-body health-report knobs), a route-
296/// compressed stream ([`crate::dataplane::compression::CompressedBody`], DW-027 — the codec wrapper
297/// around either of the other two), or an over-cap cache passthrough
298/// ([`crate::dataplane::response_cache::PassthroughBody`], DW-037 —
299/// the buffered prefix followed by the untouched remainder).
300pub enum ProxyBody {
301    /// Small fully-buffered gateway message (envelope, respond action,
302    /// metrics/health payloads).
303    Full(Full<Bytes>),
304    /// Untouched streaming upstream body.
305    Upstream(UpstreamBody),
306    /// Route-compressed response body (DW-027).
307    Compressed(Box<crate::dataplane::compression::CompressedBody>),
308    /// Cache over-cap passthrough (DW-037): a body that began
309    /// buffering for the response cache, crossed the route's
310    /// `max_body_bytes` cap mid-stream, and continues streaming the
311    /// original bytes exactly as if no cache existed.
312    Passthrough(crate::dataplane::response_cache::PassthroughBody),
313    /// AI streaming body (DW-077): the provider's SSE stream
314    /// translated frame-by-frame into OpenAI-shaped chunks — zero
315    /// buffering, gateway-owned terminator, infallible by
316    /// construction (provider aborts become terminal error frames).
317    Ai(Box<crate::dataplane::ai_proxy::AiStreamBody>),
318    /// Byte-counting wrapper (PERF-12, #209): wraps any of the above
319    /// to count outbound bytes as they stream to the client, and
320    /// fires the deferred access-log/analytics callback on stream
321    /// completion. Always the outermost layer (applied in `handle`).
322    Counted(Box<CountingBody>),
323}
324
325/// Error of a [`ProxyBody`]: upstream stream failure or a compression
326/// codec failure (DW-027). The `Full` variant is infallible.
327#[derive(Debug)]
328pub enum ProxyBodyError {
329    /// The upstream stream died (DW-014 knobs report health on their
330    /// way out; see [`UpstreamBody`]).
331    Upstream(crate::dataplane::upstream::UpstreamBodyError),
332    /// The compression codec failed mid-stream (DW-027). The stream
333    /// ends like an upstream abort: already-forwarded frames stand,
334    /// no synthesized tail.
335    Io(std::io::Error),
336}
337
338impl std::fmt::Display for ProxyBodyError {
339    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340        match self {
341            ProxyBodyError::Upstream(e) => write!(f, "{e}"),
342            ProxyBodyError::Io(e) => write!(f, "compression failed: {e}"),
343        }
344    }
345}
346
347impl std::error::Error for ProxyBodyError {}
348
349impl hyper::body::Body for ProxyBody {
350    type Data = Bytes;
351    type Error = ProxyBodyError;
352
353    fn poll_frame(
354        self: Pin<&mut Self>,
355        cx: &mut Context<'_>,
356    ) -> Poll<Option<Result<hyper::body::Frame<Bytes>, ProxyBodyError>>> {
357        match self.get_mut() {
358            ProxyBody::Full(b) => Pin::new(b).poll_frame(cx).map_err(|e| match e {}),
359            ProxyBody::Upstream(b) => Pin::new(b).poll_frame(cx).map_err(ProxyBodyError::Upstream),
360            ProxyBody::Compressed(b) => Pin::new(b.as_mut()).poll_frame(cx),
361            ProxyBody::Passthrough(b) => Pin::new(b).poll_frame(cx),
362            ProxyBody::Ai(b) => Pin::new(b.as_mut()).poll_frame(cx),
363            ProxyBody::Counted(b) => Pin::new(b.as_mut()).poll_frame(cx),
364        }
365    }
366
367    fn is_end_stream(&self) -> bool {
368        match self {
369            ProxyBody::Full(b) => b.is_end_stream(),
370            ProxyBody::Upstream(b) => b.is_end_stream(),
371            ProxyBody::Compressed(b) => b.is_end_stream(),
372            ProxyBody::Passthrough(b) => b.is_end_stream(),
373            ProxyBody::Ai(b) => b.is_end_stream(),
374            ProxyBody::Counted(b) => b.is_end_stream(),
375        }
376    }
377
378    fn size_hint(&self) -> hyper::body::SizeHint {
379        match self {
380            ProxyBody::Full(b) => b.size_hint(),
381            ProxyBody::Upstream(b) => b.size_hint(),
382            ProxyBody::Compressed(b) => b.size_hint(),
383            ProxyBody::Passthrough(b) => b.size_hint(),
384            ProxyBody::Ai(b) => b.size_hint(),
385            ProxyBody::Counted(b) => b.size_hint(),
386        }
387    }
388}
389
390/// A response body wrapper that counts outbound bytes as they stream
391/// to the client (PERF-12, #209), WITHOUT buffering. Each data frame
392/// that passes through `poll_frame` increments the shared
393/// `Arc<AtomicU64>` (the same one in [`AccessRecord::bytes_out`]).
394///
395/// When the body stream ends (end-of-stream or error), the
396/// `on_complete` callback is invoked exactly once. The callback
397/// performs the deferred access-log emission and analytics recording
398/// (which need the final byte count). The body is zero-retention:
399/// frames are forwarded immediately, only the byte COUNT is kept.
400pub struct CountingBody {
401    inner: ProxyBody,
402    /// The shared byte counter (same `Arc<AtomicU64>` as
403    /// `AccessRecord::bytes_out`).
404    counter: std::sync::Arc<std::sync::atomic::AtomicU64>,
405    /// Fired once when the stream ends. Consumed on the first
406    /// end-of-stream or error poll.
407    on_complete: Option<Box<dyn FnOnce() + Send + 'static>>,
408}
409
410impl CountingBody {
411    /// Wrap a `ProxyBody` with byte counting and a completion callback.
412    pub fn new(
413        inner: ProxyBody,
414        counter: std::sync::Arc<std::sync::atomic::AtomicU64>,
415        on_complete: Box<dyn FnOnce() + Send + 'static>,
416    ) -> Self {
417        CountingBody {
418            inner,
419            counter,
420            on_complete: Some(on_complete),
421        }
422    }
423}
424
425impl hyper::body::Body for CountingBody {
426    type Data = Bytes;
427    type Error = ProxyBodyError;
428
429    fn poll_frame(
430        self: Pin<&mut Self>,
431        cx: &mut Context<'_>,
432    ) -> Poll<Option<Result<hyper::body::Frame<Bytes>, ProxyBodyError>>> {
433        let this = self.get_mut();
434        let poll = Pin::new(&mut this.inner).poll_frame(cx);
435        match &poll {
436            Poll::Ready(Some(Ok(frame))) => {
437                if let Some(data) = frame.data_ref() {
438                    this.counter
439                        .fetch_add(data.len() as u64, std::sync::atomic::Ordering::Relaxed);
440                }
441                // PERF-12 (#209): hyper may not poll again after the
442                // last data frame when it knows the body length (via
443                // Content-Length or size_hint). Fire on_complete here
444                // when the inner body signals end-of-stream, so the
445                // analytics/access-log completion callback is not lost.
446                if this.on_complete.is_some() && Pin::new(&mut this.inner).is_end_stream() {
447                    if let Some(cb) = this.on_complete.take() {
448                        cb();
449                    }
450                }
451            }
452            Poll::Ready(None) | Poll::Ready(Some(Err(_))) => {
453                if let Some(cb) = this.on_complete.take() {
454                    cb();
455                }
456            }
457            Poll::Pending => {}
458        }
459        poll
460    }
461
462    fn size_hint(&self) -> hyper::body::SizeHint {
463        self.inner.size_hint()
464    }
465}
466
467impl std::fmt::Debug for CountingBody {
468    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
469        f.debug_struct("CountingBody")
470            .field(
471                "bytes_out",
472                &self.counter.load(std::sync::atomic::Ordering::Relaxed),
473            )
474            .field("has_callback", &self.on_complete.is_some())
475            .finish_non_exhaustive()
476    }
477}
478
479const X_FORWARDED_FOR: HeaderName = HeaderName::from_static("x-forwarded-for");
480const X_REAL_IP: HeaderName = HeaderName::from_static("x-real-ip");
481const X_RATELIMIT_LIMIT: HeaderName = HeaderName::from_static("x-ratelimit-limit");
482const X_RATELIMIT_REMAINING: HeaderName = HeaderName::from_static("x-ratelimit-remaining");
483const X_RATELIMIT_RESET: HeaderName = HeaderName::from_static("x-ratelimit-reset");
484const WWW_AUTHENTICATE: HeaderName = HeaderName::from_static("www-authenticate");
485const X_CONSUMER_NAME: HeaderName = HeaderName::from_static("x-consumer-name");
486
487/// The default priority class (DW-016): requests on routes without an
488/// explicit `priority` shed and are shed as class 5.
489pub const DEFAULT_PRIORITY: u8 = 5;
490
491/// The priority class at or above which a request may draw from the
492/// gateway cap's reserved sub-allowance (DW-016).
493pub const HIGH_PRIORITY: u8 = 8;
494
495/// Per-priority-class admission/shed counters (DW-016). Plain atomics on
496/// the dataplane — they survive config reloads (unlike the cap semaphores)
497/// and are never reset. Exposure through the admin/metrics surface is
498/// DW-021; tests read them directly.
499#[derive(Debug, Default)]
500pub struct PriorityCounters {
501    admitted: [AtomicU64; 11],
502    shed: [AtomicU64; 11],
503}
504
505impl PriorityCounters {
506    fn record_admitted(&self, priority: u8) {
507        self.admitted[priority as usize].fetch_add(1, Ordering::Relaxed);
508    }
509
510    fn record_shed(&self, priority: u8) {
511        self.shed[priority as usize].fetch_add(1, Ordering::Relaxed);
512    }
513
514    /// Requests admitted (against the global cap, or with no cap
515    /// configured) at the given priority class so far. Note for future
516    /// metrics consumers (DW-021): the admitted counters CONFLATE
517    /// capped and uncapped generations — a reload that toggles the cap
518    /// changes which admissions are counted here mid-stream, and the
519    /// counters are never reset to separate the eras.
520    pub fn admitted_at(&self, priority: u8) -> u64 {
521        self.admitted[priority as usize].load(Ordering::Relaxed)
522    }
523
524    /// Requests shed (rejected with 503 by the global cap) at the given
525    /// priority class so far.
526    pub fn shed_at(&self, priority: u8) -> u64 {
527        self.shed[priority as usize].load(Ordering::Relaxed)
528    }
529}
530
531/// Resolve a request's load-shedding priority class (DW-016). This is the
532/// documented PRIORITY-RESOLVER seam: consumer (once known) overrides the
533/// route, the route overrides the default. Today authentication is not
534/// wired (DW-019/DW-020), so the caller passes `None` and priority is
535/// purely route-level; once authN identifies the consumer, pass
536/// `Some(consumer)` here and its `priority` wins over the route's — no
537/// other change to the shedding path is needed.
538pub fn resolve_priority(consumer: Option<&Consumer>, route: &Route) -> u8 {
539    let p = consumer
540        .and_then(|c| c.priority)
541        .or(route.priority)
542        .unwrap_or(DEFAULT_PRIORITY);
543    // Validation rejects out-of-range values at compile time; this clamp
544    // keeps the per-priority counters (11 slots) index-safe even for a
545    // hand-built Route that never went through validation.
546    p.min(10)
547}
548
549/// One configuration generation coupled with the upstream pools built from
550/// it. Requests resolve routes AND upstreams from the same pair, so a
551/// reload can never mix a new route table with old pools.
552pub(super) struct Generation {
553    pub(super) snapshot: Arc<Snapshot>,
554    registry: Arc<UpstreamRegistry>,
555    /// OAuth2 clients (DW-035): per-upstream, built from each
556    /// upstream's `oauth2_client_credentials` config. Keyed by upstream
557    /// name. Empty when no upstream configures OAuth2. Rebuilt every
558    /// generation (the TLS config / client cert may change); the token
559    /// CACHE lives on the dataplane and persists across reloads.
560    oauth2_clients: HashMap<String, Arc<crate::security::oauth2::OAuth2Client>>,
561    /// The compiled AI provider/model table (DW-075), built from the
562    /// `ai:` block with auth references resolved. None when the config
563    /// has no `ai:` block. Rebuilt every generation (a reload picks up
564    /// rotated secrets and model-table changes).
565    ai: Option<Arc<crate::ai::AiRuntime>>,
566    /// Anomaly scorer (DW-090): compiled from the first enabled
567    /// `policies[].anomaly` block. None when no policy configures
568    /// anomaly scoring (or all are disabled). Rebuilt every generation
569    /// so a config change takes effect on the next request.
570    anomaly: Option<Arc<crate::dataplane::anomaly::AnomalyScorer>>,
571    /// Adaptive rate-limit controller (DW-089): compiled from the
572    /// `policies[].adaptive` blocks. Empty when no policy configures
573    /// adaptive tuning (the fast path: `factor_for` returns 1.0 for
574    /// every policy). Rebuilt every generation so a config change takes
575    /// effect on the next request; the EWMA state resets with the
576    /// generation (a reload is a fresh controller, like the rate-limit
577    /// engine).
578    adaptive: Arc<crate::resilience::adaptive::AdaptiveController>,
579    /// Auto-canary analysis controller (DW-091): compiled from the
580    /// `canary_analysis` blocks on service splits and AI model
581    /// aliases. Empty when no group configures canary analysis (the
582    /// fast path: `record_outcome` and `evaluate` are no-ops).
583    /// Rebuilt every generation so a config change takes effect on
584    /// the next request; the sliding windows reset with the
585    /// generation (a reload is a fresh controller, like the adaptive
586    /// controller). Weight changes are TRANSIENT (applied by the
587    /// runner via `apply_*_weights`, which swap a cloned Generation);
588    /// a config reload reverts them.
589    canary: Option<Arc<crate::dataplane::canary::CanaryController>>,
590}
591
592impl Generation {
593    /// The OAuth2 client for `upstream_name`, if that upstream has an
594    /// `oauth2_client_credentials` block (DW-035).
595    fn oauth2_client(
596        &self,
597        upstream_name: &str,
598    ) -> Option<&Arc<crate::security::oauth2::OAuth2Client>> {
599        self.oauth2_clients.get(upstream_name)
600    }
601
602    /// The upstream registry coupled to this generation (DW-075: the
603    /// AI proxy action resolves provider transports through the SAME
604    /// generation the route table came from).
605    pub(super) fn registry(&self) -> &Arc<UpstreamRegistry> {
606        &self.registry
607    }
608
609    /// The compiled AI table (DW-075); None when no `ai:` block.
610    pub(super) fn ai(&self) -> Option<&Arc<crate::ai::AiRuntime>> {
611        self.ai.as_ref()
612    }
613
614    /// The compiled anomaly scorer (DW-090); None when no policy
615    /// configures anomaly scoring.
616    pub(super) fn anomaly(&self) -> Option<&Arc<crate::dataplane::anomaly::AnomalyScorer>> {
617        self.anomaly.as_ref()
618    }
619
620    /// The compiled adaptive rate-limit controller (DW-089). Always
621    /// present (empty when no policy configures adaptive tuning); the
622    /// fast path returns 1.0 for every policy.
623    pub(super) fn adaptive(&self) -> &Arc<crate::resilience::adaptive::AdaptiveController> {
624        &self.adaptive
625    }
626
627    /// The compiled auto-canary analysis controller (DW-091). None
628    /// when no service split or AI model alias configures
629    /// `canary_analysis` (the fast path: `record_outcome` is a no-op).
630    pub fn canary(&self) -> Option<&Arc<crate::dataplane::canary::CanaryController>> {
631        self.canary.as_ref()
632    }
633}
634
635/// Compile the anomaly scorer (DW-090) from the gateway's policies.
636/// Takes the first enabled `policies[].anomaly` block (the policies list
637/// is ordered; the first enabled one governs). Returns `None` when no
638/// policy configures anomaly scoring or all are disabled.
639fn compile_anomaly_scorer(
640    gateway: &crate::config::Gateway,
641) -> Option<Arc<crate::dataplane::anomaly::AnomalyScorer>> {
642    for policy in &gateway.policies {
643        if let Some(anomaly_cfg) = &policy.anomaly {
644            if let Some(scorer) = crate::dataplane::anomaly::AnomalyScorer::compile(anomaly_cfg) {
645                return Some(Arc::new(scorer));
646            }
647        }
648    }
649    None
650}
651
652/// Compile the adaptive rate-limit controller (DW-089) from the
653/// gateway's policies. The observability handle wires the metric
654/// families; the controller records adaptive-factor / origin-signal /
655/// tighten / relax metrics directly (resilience may import
656/// observability per the dependency direction).
657fn compile_adaptive_controller(
658    gateway: &crate::config::Gateway,
659    obs: &Arc<Observability>,
660) -> Arc<crate::resilience::adaptive::AdaptiveController> {
661    Arc::new(crate::resilience::adaptive::AdaptiveController::compile(
662        gateway,
663        Some(Arc::clone(obs)),
664    ))
665}
666
667/// Compile the auto-canary analysis controller (DW-091) from the
668/// gateway's service splits and AI model aliases. Returns `None`
669/// when no group configures `canary_analysis` (the fast path:
670/// `record_outcome` and `evaluate` are no-ops). The observability
671/// handle wires the canary metric families.
672fn compile_canary_controller(
673    gateway: &crate::config::Gateway,
674    obs: &Arc<Observability>,
675) -> Option<Arc<crate::dataplane::canary::CanaryController>> {
676    let controller =
677        crate::dataplane::canary::CanaryController::compile(gateway, Some(Arc::clone(obs)));
678    if controller.is_empty() {
679        None
680    } else {
681        Some(Arc::new(controller))
682    }
683}
684
685/// Build the per-upstream OAuth2 client map (DW-035) from the gateway
686/// config. Each upstream with an `oauth2_client_credentials` block gets
687/// an [`OAuth2Client`] built here (loading the optional mTLS cert/key
688/// at build time so a broken bundle disables that upstream's OAuth2 at
689/// build instead of failing every request). A build failure for one
690/// upstream logs and skips — the upstream still proxies, just without
691/// the OAuth2 Bearer token (the request reaches the upstream with
692/// whatever `Authorization` the client sent, subject to the authn
693/// family's pass-through rules).
694fn build_oauth2_clients(
695    gateway: &crate::config::Gateway,
696) -> HashMap<String, Arc<crate::security::oauth2::OAuth2Client>> {
697    let mut clients = HashMap::new();
698    for upstream in &gateway.upstreams {
699        if let Some(cfg) = &upstream.oauth2_client_credentials {
700            match crate::security::oauth2::OAuth2Client::build(cfg.clone()) {
701                Ok(client) => {
702                    clients.insert(upstream.name.clone(), client);
703                }
704                Err(e) => {
705                    tracing::error!(
706                        code = "oauth2_client_disabled",
707                        upstream = %upstream.name,
708                        "oauth2 client-credentials disabled for upstream: {e}"
709                    );
710                }
711            }
712        }
713    }
714    clients
715}
716
717/// The proxy dataplane: reads config generations from [`ConfigState`] and
718/// keeps an [`UpstreamRegistry`] coupled to each generation.
719///
720/// Lifecycle: after every successful `compile_and_publish`, the operator
721/// (dwara-bin's reload path) calls [`DataPlane::refresh`], which builds a
722/// new generation pair and swaps it in atomically. In-flight requests hold
723/// an `Arc` to the old pair, so old pools stay alive until the last request
724/// using them completes — reloads drop nothing.
725pub struct DataPlane {
726    state: Arc<ConfigState>,
727    current: ArcSwap<Generation>,
728    /// Gateway concurrency cap (DW-015): `gateway.max_concurrent_requests`
729    /// as a semaphore, rebuilt on every generation swap. None = unlimited
730    /// (the default). In-flight permits hold an `Arc` to the semaphore they
731    /// were drawn from, so a reload that changes (or removes) the cap never
732    /// invalidates live admissions — new requests are admitted against the
733    /// new cap.
734    global_cap: ArcSwap<GlobalCap>,
735    /// Per-priority admission/shed counters (DW-016). Lives on the
736    /// dataplane (not the generation) so reloads never reset them.
737    priority_counters: PriorityCounters,
738    /// Rate-limit engine (DW-017), rebuilt on every generation swap.
739    /// Governor's GCRA state lives inside it, so a reload resets all
740    /// rate-limit buckets — accepted and documented: reloads are rare,
741    /// and coupling buckets to the (rule-shaped) generation avoids
742    /// lifetime questions when rules change or disappear.
743    rate_limits: ArcSwap<RateLimitEngine>,
744    /// Authenticator (DW-019), rebuilt on every generation swap from the
745    /// new config (and the optional state store, set once at startup via
746    /// [`DataPlane::set_state_store`], and the credential pepper set via
747    /// [`DataPlane::set_credential_pepper`], #124).
748    authn: ArcSwap<CompositeAuthenticator>,
749    /// The DWARA_STATE_DB store when deployed; None = config-only
750    /// credentials. Set once before serving; the authenticator rebuild
751    /// reads it.
752    state_store: std::sync::RwLock<Option<Arc<StateStore>>>,
753    /// The per-deployment credential pepper (#124): raw bytes resolved
754    /// ABOVE dwara-core's security domain — dwara-bin resolves the secret
755    /// through the `SecretSource` extension seam and hands the bytes
756    /// down, because `security` must not import `extensions`
757    /// (`check_deps.py`). Arc-held so the authenticator rebuild shares
758    /// the SAME Zeroizing buffer (no plain copy; the bytes zeroize when
759    /// the last holder drops). SECRET: never logged, never in Debug.
760    /// None = legacy-only mode (peppered stored hashes fail closed).
761    credential_pepper: std::sync::RwLock<Option<Arc<Zeroizing<Vec<u8>>>>>,
762    /// SEC-10: The PREVIOUS credential pepper (rotation window).
763    /// During a pepper rotation, the old pepper verifies existing
764    /// stored hashes while the new pepper is used for new writes.
765    /// None = no rotation window (only the current pepper is used).
766    credential_pepper_previous: std::sync::RwLock<Option<Arc<Zeroizing<Vec<u8>>>>>,
767    /// JWKS caches keyed by provider URL, carried ACROSS generation swaps
768    /// so key rotation state survives reloads (DW-019).
769    jwks_caches: std::sync::Mutex<HashMap<String, Arc<JwksCacheEntry>>>,
770    /// HMAC replay-nonce store (DW-036), carried ACROSS generation swaps
771    /// exactly like `jwks_caches`: a reload must never wipe remembered
772    /// nonces (that would re-open the replay window mid-flight). Built
773    /// once per dataplane (per-instance in M2 — see the authn module's
774    /// replay boundary note); the authenticator rebuild shares the Arc.
775    nonce_cache: Arc<crate::security::authn::NonceCache>,
776    /// OAuth2 token cache (DW-035), carried ACROSS generation swaps
777    /// like `jwks_caches` and `nonce_cache`: a reload must never discard
778    /// a still-valid access token (that would re-fetch from the token
779    /// endpoint on every request after every reload). Per-upstream,
780    /// keyed by the token endpoint URL. The OAuth2 CLIENTS (with their
781    /// TLS config) are per-generation; the token CACHE is per-dataplane.
782    oauth2_token_cache: Arc<crate::security::oauth2::OAuth2TokenCache>,
783    /// OIDC introspection cache (DW-034), carried ACROSS generation
784    /// swaps like `jwks_caches` and `oauth2_token_cache`: a reload
785    /// must never discard a cached `active: true` introspection (that
786    /// would re-introspect every Bearer token on every request after
787    /// every reload). Per-provider, keyed by
788    /// `{provider_name}:{sha256_hex(token)}`. The OIDC CLIENTS (with
789    /// their TLS config) are per-generation; the introspection CACHE
790    /// is per-dataplane.
791    oidc_introspection_cache: Arc<crate::security::oidc::OidcIntrospectionCache>,
792    /// AI token budgets (DW-078): per-generation rules over a
793    /// reload-surviving spend ledger (empty engine when no policy
794    /// declares a budget — the pre-check skips entirely).
795    ai_budgets: ArcSwap<crate::ai::budget::AiBudgetEngine>,
796    /// AI pricing table (DW-079): per-provider-model token rates,
797    /// compiled from the `ai.pricing` config map. Swapped on every
798    /// reload so a pricing change takes effect on the next request
799    /// with no restart. Empty (every model unknown -> cost 0) when no
800    /// pricing is configured — fail-open, never a crash.
801    ai_pricing: ArcSwap<crate::ai::cost::PricingTable>,
802    /// AI model governance (DW-084): per-team model allowlists plus
803    /// the shadow-audit switch, compiled from the `ai.governance`
804    /// config block. Swapped on every reload so an allowlist change
805    /// takes effect on the next request with no restart. Empty (no
806    /// allowlists -> every consumer unrestricted) when governance is
807    /// not configured — fail-open.
808    ai_governance: ArcSwap<crate::ai::governance::GovernanceEngine>,
809    /// AI prompt/response logging (DW-081): the compiled logging
810    /// engine (enabled flag, sampling rate, retention, redactor),
811    /// compiled from the `ai.logging` config block. Swapped on every
812    /// reload so a logging change takes effect on the next request
813    /// with no restart. None (an empty engine) when logging is not
814    /// configured — capture is off (privacy-first).
815    ai_logging: arc_swap::ArcSwapOption<crate::ai::logging::AiLoggingEngine>,
816    /// AI guardrails (DW-082): the compiled guardrail engine
817    /// (prompt-injection, PII, banned-content, output schema
818    /// enforcement), compiled from the `ai.guardrails` config block.
819    /// Swapped on every reload so a guardrail change takes effect on
820    /// the next request with no restart. Empty (no rules -> every
821    /// prompt and response passes through) when guardrails are not
822    /// configured — fail-open.
823    ai_guardrails: ArcSwap<crate::ai::guardrails::GuardrailEngine>,
824    /// AI semantic cache (DW-083): embedding-similarity cache for AI
825    /// prompts. None when semantic caching is not configured or
826    /// disabled. PERSISTS across reloads (the HNSW index and cached
827    /// entries survive config refreshes); config is updated in place
828    /// via `update_config` so a threshold/TTL change applies to the
829    /// next lookup with no cache reset.
830    ai_semantic_cache: arc_swap::ArcSwapOption<crate::ai::semantic_cache::SemanticCacheEngine>,
831    /// Observability state (DW-021): metrics families plus the access-log
832    /// sampling knob. Per-dataplane (not global) so parallel tests never
833    /// share a registry.
834    obs: Arc<Observability>,
835    /// The event bus (DW-044): adopted from the state's bus when one is
836    /// attached, else created here AND attached back to the state, so a
837    /// live gateway always has exactly one bus shared by the config
838    /// publish pipeline and the dataplane's state machines. Breaker
839    /// transitions, endpoint ejection/recovery, and config
840    /// published/rejected emit onto it; the webhook deliverer drains it.
841    events: Arc<crate::events::EventBus>,
842    /// The compiled webhook targets of the CURRENT generation (DW-044),
843    /// pushed to the deliverer task over this watch channel by every
844    /// `refresh` (config changes apply to the next event, no deliverer
845    /// restart).
846    webhook_targets: tokio::sync::watch::Sender<Arc<Vec<crate::events::webhook::WebhookTarget>>>,
847    /// The construction-time receiver of `webhook_targets`, held for the
848    /// dataplane's lifetime so the watch channel is never CLOSED between
849    /// construction and the deliverer's spawn (a closed watch silently
850    /// drops every `refresh` send; the deliverer subscribes later).
851    #[allow(dead_code)]
852    webhook_targets_anchor:
853        tokio::sync::watch::Receiver<Arc<Vec<crate::events::webhook::WebhookTarget>>>,
854    /// The compiled access-record stream state of the CURRENT
855    /// generation (DW-121): sinks, flush cadence, and batch bound,
856    /// pushed to the flusher task over this watch channel by every
857    /// `refresh` — a reload retargets the stream with no restart.
858    stream_targets: tokio::sync::watch::Sender<crate::events::stream::StreamTargets>,
859    /// The construction-time receiver of `stream_targets` (the
860    /// `webhook_targets_anchor` shape: keeps the watch channel open
861    /// between construction and the flusher's spawn).
862    #[allow(dead_code)]
863    stream_targets_anchor: tokio::sync::watch::Receiver<crate::events::stream::StreamTargets>,
864    /// The access-record stream (DW-121): constructed once at startup
865    /// by dwara-bin (ALWAYS — an unconfigured stream is disabled by
866    /// its enabled flag, so a live reload can arm it) and drained by
867    /// the flusher task. ArcSwap for the same reason as `analytics`:
868    /// the completion path reads it on EVERY request, and the offer
869    /// itself is fire-and-forget (`try_send` onto a bounded channel —
870    /// full drops and counts, never blocks).
871    record_stream: arc_swap::ArcSwapOption<crate::events::stream::AccessRecordStream>,
872    /// The local response cache (DW-037): the store backend, route
873    /// epochs, and the revalidation guard. RUNTIME STATE on the
874    /// dataplane (like the priority counters), deliberately NOT part of
875    /// a generation — it survives reloads; config changes reach it
876    /// through epoch bumps computed at `refresh` (see
877    /// `response_cache::ResponseCache::note_generation`).
878    response_cache: Arc<crate::dataplane::response_cache::ResponseCache>,
879    /// The embedded analytics store (DW-043): set once at startup by
880    /// dwara-bin when the config carries an `analytics` block; None =
881    /// analytics off, `record_analytics` is a no-op. ArcSwap (not a
882    /// RwLock) because the completion path reads it on EVERY request.
883    /// The sink call itself is fire-and-forget (`try_send` onto a
884    /// bounded channel — a full channel drops and counts, never
885    /// blocks); queries (admin endpoints) take the connection's mutex
886    /// briefly behind the background writer's batched transactions.
887    analytics: arc_swap::ArcSwapOption<crate::analytics::EmbeddedAnalytics>,
888    /// The GeoIP database (DW-050): opened at startup when
889    /// `gateway.geoip` is configured, hot-swapped by the dwara-bin
890    /// watcher when the file changes. ArcSwap: the authz path loads it
891    /// per request (an Arc bump), and in-flight lookups keep the
892    /// reader they loaded across a swap. None = geo-UNKNOWN.
893    geoip: arc_swap::ArcSwapOption<crate::security::geoip::GeoipDb>,
894    /// DW-157: the proxy-wasm plugin lifecycle — loaded/compiled with
895    /// every config generation (checksum-keyed: an unchanged plugin
896    /// keeps its health state, a changed one resets). ONE lifecycle
897    /// lives on the dataplane across generations so crash/health state
898    /// survives a reload of an unchanged plugin set; `reload_plugins`
899    /// (called from `new` and every `refresh`) re-loads it wholesale.
900    plugins: Arc<PluginLifecycle>,
901    /// DW-157: the CURRENT generation's plugin configs keyed by name —
902    /// pre-keyed once per generation so the per-request chain build
903    /// (`plugin_dispatch::RequestPlugins::build`) borrows a ready map
904    /// instead of allocating one on the hot path.
905    plugin_configs: ArcSwap<HashMap<String, crate::config::PluginConfig>>,
906    /// DW-157: the native filter registry. Compiled-in filters register
907    /// factories here at startup; config selects one with `native:` on
908    /// a plugin. Empty until something registers (a config referencing
909    /// an unregistered name fails closed at request time).
910    native_plugins: NativeRegistry,
911    /// DW-031: the Redis connection for the distributed rate limiter
912    /// (ent feature only). Set once at startup by dwara-bin when the
913    /// config carries a `redis_rate_limiter` block AND the license
914    /// grants the `redis_rate_limiter` feature claim. When set, the
915    /// rate-limit engine compiles with Redis-backed limiters instead
916    /// of local ones; when None, the local GCRA limiter is used. The
917    /// connection persists across reloads (a reload re-clones it for
918    /// the new engine, it does not re-establish).
919    #[cfg(feature = "ent")]
920    redis_conn: std::sync::RwLock<Option<redis::aio::ConnectionManager>>,
921    /// DW-155: the Redis-backed quota checker (ent feature only). Set
922    /// once at startup by dwara-bin when the config carries a
923    /// `redis_quotas` block AND the license grants the `redis_quotas`
924    /// feature claim. When set, the quota check path uses Redis-backed
925    /// shared counters instead of the local SQLite counters, so a fleet
926    /// of N instances enforces the CONFIGURED cap (not N x cap). When
927    /// None, the local SQLite checker is used. The checker persists
928    /// across reloads (a reload re-reads the config but the checker's
929    /// connection persists; fail_open/key_prefix are startup-time
930    /// properties).
931    #[cfg(feature = "ent")]
932    redis_quota_checker:
933        std::sync::RwLock<Option<Arc<crate::extensions::redis_quotas::RedisQuotaChecker>>>,
934    /// DW-054: the config convergence coordinator (ent feature only).
935    /// Set once at startup by dwara-bin when the config carries a
936    /// `config_convergence` block AND the license grants the
937    /// `config_convergence` feature claim. When set, the reload path
938    /// calls `publish_convergence_local` after every successful local
939    /// reload so the backend carries the new generation. The
940    /// coordinator's background poll task is spawned by dwara-bin
941    /// (not here) against the shutdown watch.
942    #[cfg(feature = "ent")]
943    convergence:
944        std::sync::RwLock<Option<Arc<crate::dataplane::convergence::ConvergenceCoordinator>>>,
945    /// Quota near-limit edge-trigger bookkeeping (DW-033): the
946    /// (consumer, budget, window_start) triples already reported, so
947    /// `quota_near_limit` fires ONCE per budget per window instead of
948    /// per request above 80%. Bounded by quota-configured consumers x
949    /// budgets; stale windows are pruned on each insert (see
950    /// `note_quota_near_limit`). Runtime state, deliberately not part
951    /// of a generation — reloads never re-notify a window already
952    /// reported.
953    quota_near_limit_seen: std::sync::Mutex<std::collections::HashSet<(String, &'static str, i64)>>,
954    /// DW-094 (Ent): the edge's locality context (region/zone). Set
955    /// once at startup via [`DataPlane::set_locality_context`] from env
956    /// vars (`DWARA_REGION`/`DWARA_ZONE`) or edge labels. Applied to
957    /// every upstream's load balancer on `new` and `refresh` so
958    /// locality-aware routing filters endpoints by region/zone before
959    /// the LB algorithm runs. Default (empty) = no locality filtering
960    /// (the OSS behavior).
961    locality_ctx: std::sync::RwLock<crate::config::LocalityContext>,
962}
963
964/// The gateway-level concurrency admission for one generation (DW-015 +
965/// DW-016). `general: None` means unlimited (`max_concurrent_requests`
966/// absent or 0). When set, `general` holds the permits available to ALL
967/// traffic; `reserved` (when carved) holds a small sub-allowance of the
968/// same cap usable ONLY by high-priority requests (>= [`HIGH_PRIORITY`])
969/// once the general allowance is full.
970///
971/// DW-053: when `admission_queue` is `Some`, over-cap requests WAIT for
972/// a permit up to `queue_timeout` instead of being immediately shed.
973/// `queue_depth` tracks the current number of waiting requests (an
974/// atomic on the dataplane, shared across generations so a reload never
975/// loses an in-flight waiter). `low_queue_limit` is the maximum queue
976/// depth low-priority requests may occupy (the high-priority reserve is
977/// `max_queue_size - low_queue_limit`); high-priority may queue up to
978/// `max_queue_size`.
979#[derive(Clone, Default)]
980struct GlobalCap {
981    general: Option<Arc<Semaphore>>,
982    reserved: Option<Arc<Semaphore>>,
983    /// DW-053: the admission queue config. `None` = queueing disabled
984    /// (immediate shed, the DW-016 behavior).
985    admission_queue: Option<AdmissionQueueState>,
986}
987
988/// DW-053: the runtime admission queue state carried on the GlobalCap.
989/// The queue depth atomic lives HERE (not on the dataplane) so it is
990/// rebuilt per generation — but a reload that changes the queue config
991/// simply swaps in a new state; in-flight waiters on the old semaphore
992/// complete normally (the permit they are waiting for is on the old
993/// `Arc<Semaphore>`, which stays alive until the last holder drops).
994#[derive(Clone)]
995struct AdmissionQueueState {
996    /// The queue timeout (from `admission_queue.queue_timeout_ms`).
997    queue_timeout: Duration,
998    /// The maximum total queue depth (from `admission_queue.max_queue_size`).
999    max_queue_size: u32,
1000    /// The maximum queue depth low-priority requests may occupy. High-
1001    /// priority requests may use the full `max_queue_size`; the reserve
1002    /// (`max_queue_size - low_queue_limit`) is high-priority-only. When
1003    /// `per_priority` is false, this equals `max_queue_size` (no reserve).
1004    low_queue_limit: u32,
1005    /// Current number of requests waiting in the queue. Incremented
1006    /// before the timed acquire, decremented after (whether admitted or
1007    /// timed out).
1008    queue_depth: Arc<AtomicU32>,
1009}
1010
1011/// Whether any ROUTE is high-priority (priority at or above
1012/// [`HIGH_PRIORITY`]). The reserved bucket is carved only when this holds,
1013/// so priority-free configs keep the full cap for everyone — behavior
1014/// identical to DW-015.
1015///
1016/// Consumer priorities deliberately contribute NOTHING to the carve
1017/// decision: consumer priority is inert until authN (DW-019/DW-020)
1018/// identifies the consumer on a request, so a config whose only
1019/// high-priority entry is a consumer would otherwise carve the general
1020/// allowance down (to the point of blackholing everything at a cap of 1)
1021/// while no traffic could ever draw from the reserved bucket.
1022/// DW-019 SEAM: when authN wires `Some(consumer)` into
1023/// [`resolve_priority`], extend this check with consumers as well.
1024fn has_high_priority(gateway: &Gateway) -> bool {
1025    gateway
1026        .routes
1027        .iter()
1028        .any(|r| r.priority.is_some_and(|p| p >= HIGH_PRIORITY))
1029}
1030
1031/// The gateway-level concurrency admission for a snapshot's config: None
1032/// when `max_concurrent_requests` is absent or 0 (unlimited). With
1033/// high-priority traffic configured, 10% of the cap (minimum 1, capped at
1034/// the cap itself) is reserved: the general allowance shrinks to
1035/// `cap - bucket` and high-priority requests may draw from either.
1036///
1037/// DW-053: when `admission_queue` is present and enabled, the cap carries
1038/// the queue state (timeout, depth bounds, the depth atomic). The queue
1039/// waits for a permit from the SAME semaphores — it is a timed acquire,
1040/// not a separate data structure.
1041fn global_cap_of(gateway: &Gateway) -> GlobalCap {
1042    let Some(cap) = gateway.max_concurrent_requests.filter(|c| *c > 0) else {
1043        return GlobalCap::default();
1044    };
1045    let cap = cap as usize;
1046    let bucket = if has_high_priority(gateway) {
1047        (cap / 10).max(1).min(cap)
1048    } else {
1049        0
1050    };
1051    let admission_queue = gateway.admission_queue.as_ref().and_then(|aq| {
1052        if !aq.enabled {
1053            return None;
1054        }
1055        let max_queue_size = aq.max_queue_size;
1056        // Per-priority split: reserve half the queue for high-priority
1057        // (minimum 1 high-priority slot when the queue is non-empty and
1058        // per_priority is true). Low-priority may occupy up to
1059        // `low_queue_limit`; high-priority may occupy the full
1060        // `max_queue_size`.
1061        let low_queue_limit = if aq.per_priority {
1062            max_queue_size / 2
1063        } else {
1064            max_queue_size
1065        };
1066        Some(AdmissionQueueState {
1067            queue_timeout: Duration::from_millis(aq.queue_timeout_ms),
1068            max_queue_size,
1069            low_queue_limit,
1070            queue_depth: Arc::new(AtomicU32::new(0)),
1071        })
1072    });
1073    GlobalCap {
1074        general: Some(Arc::new(Semaphore::new(cap - bucket))),
1075        reserved: if bucket > 0 {
1076            Some(Arc::new(Semaphore::new(bucket)))
1077        } else {
1078            None
1079        },
1080        admission_queue,
1081    }
1082}
1083
1084impl DataPlane {
1085    /// Build from the state's currently published snapshot.
1086    pub fn new(state: Arc<ConfigState>) -> Arc<Self> {
1087        let snapshot = state.snapshot();
1088        let generation = snapshot.generation();
1089        // DW-044: exactly one bus per (state, dataplane) pair — adopt the
1090        // state's, or create one and attach it back so config events are
1091        // never silently missing in a live gateway.
1092        let events = match state.event_bus() {
1093            Some(bus) => bus,
1094            None => {
1095                let bus = crate::events::EventBus::new();
1096                state.attach_event_bus(Arc::clone(&bus));
1097                bus
1098            }
1099        };
1100        let registry = Arc::new(UpstreamRegistry::from_snapshot_with_events(
1101            &snapshot,
1102            Some(&events.emitter()),
1103        ));
1104        let slos = compile_route_slos(&snapshot);
1105        let global_cap = global_cap_of(snapshot.gateway());
1106        let rate_limits = RateLimitEngine::compile(snapshot.gateway());
1107        let webhook_targets = Arc::new(compile_webhook_targets(&snapshot));
1108        let (target_tx, target_anchor) = tokio::sync::watch::channel(webhook_targets);
1109        let obs = Arc::new(Observability::from_env());
1110        // DW-121: the first generation's record-stream state, compiled
1111        // through the same path every later `refresh` uses. The sinks
1112        // hold THIS dataplane's observability handle (their outcome
1113        // counters must land in the registered registry).
1114        let stream_targets = crate::events::stream::compile_stream_targets(
1115            snapshot.gateway().analytics_stream.as_ref(),
1116            &obs,
1117            snapshot
1118                .gateway()
1119                .ssrf_filter
1120                .as_ref()
1121                .map(crate::config::ssrf::SsrfFilter::from_config)
1122                .unwrap_or_else(crate::config::ssrf::SsrfFilter::disabled),
1123        );
1124        let (stream_tx, stream_anchor) = tokio::sync::watch::channel(stream_targets);
1125        let oauth2_token_cache = Arc::new(crate::security::oauth2::OAuth2TokenCache::new());
1126        let oidc_introspection_cache =
1127            Arc::new(crate::security::oidc::OidcIntrospectionCache::new());
1128        let oauth2_clients = build_oauth2_clients(snapshot.gateway());
1129        let ai = crate::ai::AiRuntime::compile(snapshot.gateway().ai.as_ref(), snapshot.gateway())
1130            .map(Arc::new);
1131        let anomaly = compile_anomaly_scorer(snapshot.gateway());
1132        let adaptive = compile_adaptive_controller(snapshot.gateway(), &obs);
1133        let canary = compile_canary_controller(snapshot.gateway(), &obs);
1134        let ai_budgets = ArcSwap::from_pointee(crate::ai::budget::AiBudgetEngine::compile(
1135            snapshot.gateway(),
1136        ));
1137        let ai_pricing = ArcSwap::from_pointee(crate::ai::cost::PricingTable::compile(
1138            snapshot.gateway().ai.as_ref(),
1139        ));
1140        let ai_governance = ArcSwap::from_pointee(
1141            crate::ai::governance::GovernanceEngine::compile(snapshot.gateway().ai.as_ref()),
1142        );
1143        let ai_logging =
1144            crate::ai::logging::AiLoggingEngine::compile(snapshot.gateway().ai.as_ref())
1145                .map(Arc::new);
1146        let ai_guardrails = ArcSwap::from_pointee(crate::ai::guardrails::GuardrailEngine::compile(
1147            snapshot.gateway().ai.as_ref(),
1148        ));
1149        let ai_semantic_cache =
1150            crate::ai::semantic_cache::SemanticCacheEngine::compile(snapshot.gateway().ai.as_ref())
1151                .map(Arc::new);
1152        let dp = DataPlane {
1153            ai_budgets,
1154            ai_pricing,
1155            ai_governance,
1156            ai_logging: arc_swap::ArcSwapOption::new(ai_logging),
1157            ai_guardrails,
1158            ai_semantic_cache: arc_swap::ArcSwapOption::new(ai_semantic_cache),
1159            current: ArcSwap::from_pointee(Generation {
1160                snapshot,
1161                registry,
1162                oauth2_clients,
1163                ai,
1164                anomaly,
1165                adaptive,
1166                canary,
1167            }),
1168            global_cap: ArcSwap::from_pointee(global_cap),
1169            priority_counters: PriorityCounters::default(),
1170            rate_limits: ArcSwap::from_pointee(rate_limits),
1171            authn: ArcSwap::from_pointee(CompositeAuthenticator::disabled()),
1172            state_store: std::sync::RwLock::new(None),
1173            credential_pepper: std::sync::RwLock::new(None),
1174            credential_pepper_previous: std::sync::RwLock::new(None),
1175            jwks_caches: std::sync::Mutex::new(HashMap::new()),
1176            nonce_cache: Arc::new(crate::security::authn::NonceCache::new()),
1177            oauth2_token_cache,
1178            oidc_introspection_cache,
1179            obs: Arc::clone(&obs),
1180            events,
1181            webhook_targets: target_tx,
1182            webhook_targets_anchor: target_anchor,
1183            stream_targets: stream_tx,
1184            stream_targets_anchor: stream_anchor,
1185            record_stream: arc_swap::ArcSwapOption::empty(),
1186            response_cache: Arc::new(crate::dataplane::response_cache::ResponseCache::default()),
1187            analytics: arc_swap::ArcSwapOption::empty(),
1188            geoip: arc_swap::ArcSwapOption::empty(),
1189            plugins: Arc::new(PluginLifecycle::new()),
1190            plugin_configs: ArcSwap::from_pointee(HashMap::new()),
1191            native_plugins: NativeRegistry::new(),
1192            #[cfg(feature = "ent")]
1193            redis_conn: std::sync::RwLock::new(None),
1194            #[cfg(feature = "ent")]
1195            redis_quota_checker: std::sync::RwLock::new(None),
1196            #[cfg(feature = "ent")]
1197            convergence: std::sync::RwLock::new(None),
1198            quota_near_limit_seen: std::sync::Mutex::new(std::collections::HashSet::new()),
1199            locality_ctx: std::sync::RwLock::new(crate::config::LocalityContext::from_env()),
1200            state,
1201        };
1202        dp.obs.set_config_generation(generation);
1203        // DW-052: the startup generation's SLO set (refresh() handles
1204        // every subsequent generation; construction builds the FIRST
1205        // one inline, so both paths must seed the collector).
1206        dp.obs.set_route_slos(slos);
1207        // DW-157: the startup generation's plugins (refresh() handles
1208        // every subsequent generation). DW-165: registry sources are
1209        // resolved here inline — startup is synchronous, and the
1210        // resolver's per-publish budget bounds a hung registry.
1211        dp.reload_plugins(crate::wasm::source::resolve_sources(
1212            dp.state.snapshot().gateway(),
1213        ));
1214        dp.rebuild_authn();
1215        // DW-094 (Ent): apply the edge's locality context (from env vars
1216        // or edge labels) to every upstream's load balancer. On `refresh`,
1217        // the context is re-applied to the new registry.
1218        dp.apply_locality_to_registry();
1219        // SCALE-10 (#189): pre-warm connection pools for upstreams that
1220        // configure `pool.pre_warm`. Best-effort; failures are logged
1221        // and silently ignored.
1222        dp.current.load().registry.pre_warm();
1223        Arc::new(dp)
1224    }
1225
1226    /// Attach the DWARA_STATE_DB store (DWARA_STATE_DB deployments):
1227    /// credentials then come from the store's hot-cached records instead
1228    /// of in-memory config hashes. Call once, before serving traffic;
1229    /// rebuilds the authenticator against it immediately.
1230    pub fn set_state_store(&self, store: Arc<StateStore>) {
1231        *self.state_store.write().expect("state store lock poisoned") = Some(store);
1232        self.rebuild_authn();
1233    }
1234
1235    /// Publish the license status gauge (DW-032): 0 = no license (OSS),
1236    /// 1 = valid, 2 = expired within grace, 3 = expired past grace.
1237    /// Set once at startup and on every reload.
1238    pub fn set_license_status(&self, status: i64) {
1239        self.obs.set_license_status(status);
1240    }
1241
1242    /// DW-094 (Ent): set the edge's locality context (region/zone).
1243    /// Overrides the env-var-derived context set at construction. Applied
1244    /// immediately to the current registry's balancers and to every
1245    /// future registry built by `refresh`. Used by the CP/DP edge
1246    /// runtime to propagate the edge's labels (set by the controller)
1247    /// to the dataplane's load balancers.
1248    pub fn set_locality_context(&self, ctx: crate::config::LocalityContext) {
1249        *self
1250            .locality_ctx
1251            .write()
1252            .expect("locality ctx lock poisoned") = ctx;
1253        self.apply_locality_to_registry();
1254    }
1255
1256    /// DW-094 (Ent): apply the stored locality context to every upstream
1257    /// balancer in the current registry. Called on `new`, `refresh`, and
1258    /// `set_locality_context`.
1259    fn apply_locality_to_registry(&self) {
1260        let ctx = self
1261            .locality_ctx
1262            .read()
1263            .expect("locality ctx lock poisoned")
1264            .clone();
1265        self.current().registry.set_locality_context(ctx);
1266    }
1267
1268    /// Attach the Redis connection for the distributed rate limiter
1269    /// (DW-031, ent feature only). Set once at startup by dwara-bin
1270    /// when the config carries a `redis_rate_limiter` block AND the
1271    /// license grants the `redis_rate_limiter` feature claim. After
1272    /// storing the connection, the rate-limit engine is IMMEDIATELY
1273    /// rebuilt with Redis-backed limiters (so the first generation
1274    /// served uses Redis, not the local limiter the constructor built).
1275    /// Subsequent `refresh` calls also use the Redis connection.
1276    #[cfg(feature = "ent")]
1277    pub fn set_redis_conn(&self, conn: redis::aio::ConnectionManager) {
1278        *self.redis_conn.write().expect("redis conn lock poisoned") = Some(conn.clone());
1279        // Rebuild the rate-limit engine with Redis-backed limiters.
1280        let snapshot = self.state.snapshot();
1281        if let Some(config) = snapshot.gateway().redis_rate_limiter.as_ref() {
1282            self.rate_limits
1283                .store(Arc::new(RateLimitEngine::compile_with_redis(
1284                    snapshot.gateway(),
1285                    conn,
1286                    config,
1287                )));
1288        }
1289    }
1290
1291    /// Whether a Redis connection is attached for the distributed rate
1292    /// limiter (DW-031, ent feature only). Used by `refresh` to decide
1293    /// whether to compile with Redis-backed limiters.
1294    #[cfg(feature = "ent")]
1295    fn redis_conn_for_compile(&self) -> Option<redis::aio::ConnectionManager> {
1296        self.redis_conn
1297            .read()
1298            .expect("redis conn lock poisoned")
1299            .clone()
1300    }
1301
1302    /// Attach the Redis-backed quota checker (DW-155, ent feature only).
1303    /// Set once at startup by dwara-bin when the config carries a
1304    /// `redis_quotas` block AND the license grants the `redis_quotas`
1305    /// feature claim. After storing the checker, the quota check path
1306    /// uses Redis-backed shared counters instead of the local SQLite
1307    /// counters. The checker persists across reloads.
1308    #[cfg(feature = "ent")]
1309    pub fn set_redis_quota_checker(
1310        &self,
1311        checker: Arc<crate::extensions::redis_quotas::RedisQuotaChecker>,
1312    ) {
1313        *self
1314            .redis_quota_checker
1315            .write()
1316            .expect("redis quota checker lock poisoned") = Some(checker);
1317    }
1318
1319    /// The Redis-backed quota checker, if attached (DW-155, ent feature
1320    /// only). When Some, the quota check path uses it instead of the
1321    /// local SQLite checker.
1322    #[cfg(feature = "ent")]
1323    fn redis_quota_checker(
1324        &self,
1325    ) -> Option<Arc<crate::extensions::redis_quotas::RedisQuotaChecker>> {
1326        self.redis_quota_checker
1327            .read()
1328            .expect("redis quota checker lock poisoned")
1329            .clone()
1330    }
1331
1332    /// Attach the config convergence coordinator (DW-054, ent feature
1333    /// only). Set once at startup by dwara-bin when the config carries
1334    /// a `config_convergence` block AND the license grants the
1335    /// `config_convergence` feature claim. The coordinator's
1336    /// background poll task is spawned separately by dwara-bin; this
1337    /// only stores the handle so the reload path can publish the new
1338    /// generation after every successful local reload.
1339    #[cfg(feature = "ent")]
1340    pub fn set_convergence_coordinator(
1341        &self,
1342        coordinator: Arc<crate::dataplane::convergence::ConvergenceCoordinator>,
1343    ) {
1344        *self.convergence.write().expect("convergence lock poisoned") = Some(coordinator);
1345    }
1346
1347    /// Publish the current local generation to the convergence backend
1348    /// (DW-054, ent feature only). Called by the reload path after
1349    /// every successful local reload. A no-op when no coordinator is
1350    /// attached (convergence not configured or not licensed).
1351    #[cfg(feature = "ent")]
1352    pub async fn publish_convergence_local(&self) {
1353        let Some(coordinator) = self
1354            .convergence
1355            .read()
1356            .expect("convergence lock poisoned")
1357            .clone()
1358        else {
1359            return;
1360        };
1361        coordinator.publish_local().await;
1362    }
1363
1364    /// The DWARA_STATE_DB store when one is attached (None = pure-config
1365    /// credentials). Admin surface seam (DW-022): `/stats` reports the
1366    /// store's schema version when present.
1367    pub fn state_store(&self) -> Option<Arc<StateStore>> {
1368        self.state_store
1369            .read()
1370            .expect("state store lock poisoned")
1371            .clone()
1372    }
1373
1374    /// Quota near-limit edge trigger (DW-033): `true` exactly when this
1375    /// (consumer, budget, window) has not been reported yet — the caller
1376    /// emits `quota_near_limit` and warns on `true`, stays silent on
1377    /// `false` (the second and later crossings inside one window are
1378    /// not noise). Stale windows are pruned under the same lock on the
1379    /// insert path only (at most two inserts per window per consumer),
1380    /// each budget against its OWN current window start, so the set
1381    /// holds at most one live entry per (consumer, budget) — bounded by
1382    /// quota-configured consumers x 2.
1383    fn note_quota_near_limit(
1384        &self,
1385        consumer: &str,
1386        budget: crate::state::quotas::Budget,
1387        window_start: i64,
1388    ) -> bool {
1389        let now_epoch_s = unix_now_secs();
1390        let (day_start, _) = crate::state::quotas::day_window(now_epoch_s);
1391        let (month_start, _) = crate::state::quotas::month_window(now_epoch_s);
1392        let mut seen = self
1393            .quota_near_limit_seen
1394            .lock()
1395            .expect("quota near-limit set poisoned");
1396        seen.retain(|(_, budget_key, window)| match *budget_key {
1397            crate::state::quotas::DAILY_KEY => *window >= day_start,
1398            crate::state::quotas::MONTHLY_KEY => *window >= month_start,
1399            _ => true,
1400        });
1401        seen.insert((consumer.to_string(), budget.key(), window_start))
1402    }
1403
1404    /// Attach the embedded analytics store (DW-043): dwara-bin opens
1405    /// the configured database, spawns the writer/rollup workers, and
1406    /// hands the store here ONCE, before serving traffic. There is no
1407    /// detach: analytics lifetime is the process lifetime (the
1408    /// database file outlives restarts; the runtime handle does not).
1409    pub fn set_analytics(&self, store: Arc<crate::analytics::EmbeddedAnalytics>) {
1410        self.analytics.store(Some(store));
1411    }
1412
1413    /// The embedded analytics store when one is attached (DW-043;
1414    /// None = the analytics endpoints 404 and recording is a no-op).
1415    /// Admin surface seam.
1416    pub fn analytics(&self) -> Option<Arc<crate::analytics::EmbeddedAnalytics>> {
1417        self.analytics.load_full()
1418    }
1419
1420    /// The compiled AI runtime for the current generation (DW-075;
1421    /// None when no `ai:` block). Admin surface seam for DW-080
1422    /// credential pool status introspection.
1423    pub fn ai_runtime(&self) -> Option<Arc<crate::ai::AiRuntime>> {
1424        self.current().ai.clone()
1425    }
1426
1427    /// Attach the access-record stream (DW-121): dwara-bin constructs
1428    /// it ALWAYS (capacity from the config when the block is present
1429    /// at boot, the default otherwise) and hands it here once, before
1430    /// serving traffic; the flusher is spawned separately
1431    /// ([`DataPlane::spawn_record_stream_flusher`]). Arming is the
1432    /// generation's business: this immediately applies the CURRENT
1433    /// compiled sink set's enabled flag, so a stream attached after
1434    /// construction with sinks configured does not wait for a reload
1435    /// to start queuing.
1436    pub fn set_record_stream(&self, stream: Arc<crate::events::stream::AccessRecordStream>) {
1437        let enabled = !self.stream_targets.borrow().sinks.is_empty();
1438        stream.set_enabled(enabled);
1439        self.record_stream.store(Some(stream));
1440    }
1441
1442    /// The access-record stream when one is attached (DW-121; None =
1443    /// offers are no-ops — the direct-drive tests' shape). Scrape and
1444    /// diagnostics seam.
1445    pub fn record_stream(&self) -> Option<Arc<crate::events::stream::AccessRecordStream>> {
1446        self.record_stream.load_full()
1447    }
1448
1449    /// A fresh receiver of the CURRENT record-stream target state
1450    /// (DW-121); updates on every `refresh`. Tests driving the flusher
1451    /// directly use this.
1452    pub fn stream_targets(
1453        &self,
1454    ) -> tokio::sync::watch::Receiver<crate::events::stream::StreamTargets> {
1455        self.stream_targets.subscribe()
1456    }
1457
1458    /// Spawn the record-stream flusher (DW-121): one background task
1459    /// draining the stream's bounded channel into ordered batches and
1460    /// delivering each batch (one retry cycle per batch) to the
1461    /// current generation's sinks. Exactly one flusher per dataplane
1462    /// (the channel's single-consumer receiver is gone after the
1463    /// first); a duplicate spawn logs and returns a finished handle.
1464    /// The task stops on `shutdown` after one final drain-and-flush.
1465    pub fn spawn_record_stream_flusher(
1466        self: &Arc<Self>,
1467        shutdown: tokio::sync::watch::Receiver<()>,
1468    ) -> tokio::task::JoinHandle<()> {
1469        let Some(stream) = self.record_stream() else {
1470            tracing::error!(
1471                code = "record_stream_flusher_no_stream",
1472                "record stream flusher spawned without a stream attached; ignoring"
1473            );
1474            return tokio::spawn(async {});
1475        };
1476        let Some(rx) = stream.take_receiver() else {
1477            tracing::error!(
1478                code = "record_stream_flusher_already_running",
1479                "record stream flusher already spawned for this dataplane; ignoring \
1480                 the duplicate spawn"
1481            );
1482            return tokio::spawn(async {});
1483        };
1484        tokio::spawn(crate::events::stream::run_stream_flusher(
1485            rx,
1486            self.stream_targets(),
1487            Arc::clone(&self.obs),
1488            shutdown,
1489        ))
1490    }
1491
1492    /// The request-completion record-stream hook (DW-121):
1493    /// fire-and-forget offer of one finished request's record to the
1494    /// external sink pipeline. No-op (one ArcSwap load) when no stream
1495    /// is attached or the current generation compiled no sink; never
1496    /// blocks (a bounded-channel `try_send` that drops and counts on
1497    /// full).
1498    fn record_stream_offer(&self, rec: &observability::AccessRecord) {
1499        let guard = self.record_stream.load();
1500        if let Some(stream) = &*guard {
1501            stream.offer(rec);
1502        }
1503    }
1504
1505    /// Attach/replace the GeoIP database (DW-050). dwara-bin opens it
1506    /// at startup and the hot-reload watcher swaps it; the swap is
1507    /// atomic and in-flight requests keep their loaded reader.
1508    pub fn set_geoip(&self, db: std::sync::Arc<crate::security::geoip::GeoipDb>) {
1509        self.geoip.store(Some(db));
1510    }
1511
1512    /// The current GeoIP database, if one is loaded (DW-050).
1513    pub fn geoip(&self) -> Option<std::sync::Arc<crate::security::geoip::GeoipDb>> {
1514        self.geoip.load_full()
1515    }
1516
1517    /// The request-completion analytics hook (DW-043): fire-and-forget
1518    /// record of one finished request. No-op (one ArcSwap load) when
1519    /// analytics is not configured; never blocks (the sink is a
1520    /// bounded-channel `try_send` that drops and counts on full).
1521    fn record_analytics(&self, rec: &observability::AccessRecord) {
1522        let guard = self.analytics.load();
1523        if let Some(store) = &*guard {
1524            store.record(rec);
1525        }
1526    }
1527
1528    /// Attach the per-deployment credential pepper (#124). The bytes are
1529    /// resolved by the CALLER (dwara-bin, through the SecretSource
1530    /// extension seam — the security domain never touches extensions)
1531    /// and threaded down here; an empty slice is treated as "no pepper"
1532    /// (legacy-only mode). Call once, before serving traffic; rebuilds
1533    /// the authenticator against it immediately.
1534    pub fn set_credential_pepper(&self, pepper: Option<Vec<u8>>) {
1535        let pepper = pepper
1536            .filter(|p| !p.is_empty())
1537            .map(|p| Arc::new(Zeroizing::new(p)));
1538        *self
1539            .credential_pepper
1540            .write()
1541            .expect("credential pepper lock poisoned") = pepper;
1542        self.rebuild_authn();
1543    }
1544
1545    /// SEC-10: Set the PREVIOUS credential pepper (rotation window).
1546    /// During a pepper rotation, the old pepper verifies existing
1547    /// stored hashes while the new pepper (set via
1548    /// `set_credential_pepper`) is used for new writes. Call once,
1549    /// before serving traffic. An empty slice is treated as "no
1550    /// previous pepper" (rotation window inactive).
1551    pub fn set_credential_pepper_previous(&self, pepper: Option<Vec<u8>>) {
1552        let pepper = pepper
1553            .filter(|p| !p.is_empty())
1554            .map(|p| Arc::new(Zeroizing::new(p)));
1555        *self
1556            .credential_pepper_previous
1557            .write()
1558            .expect("credential pepper previous lock poisoned") = pepper;
1559        self.rebuild_authn();
1560    }
1561
1562    /// Hash a NEW api-key secret exactly as the config seed path does
1563    /// (DW-046, the admin credential-issue endpoint's helper):
1564    /// `hmac-sha256:<hex>` when a pepper is configured (#124),
1565    /// legacy `sha256:<hex>` otherwise — the authenticator verifies
1566    /// either shape with the SAME pepper state, so an issued key
1567    /// authenticates immediately. The secret never leaves this call's
1568    /// stack in the clear (hashing is one-shot; no storage of raw
1569    /// bytes anywhere).
1570    pub fn hash_new_credential(&self, secret: &str) -> String {
1571        match self.pepper_bytes() {
1572            Some(pepper) => crate::config::credentials::hmac_stored_hash(&pepper, secret),
1573            None => crate::config::credentials::sha256_stored_hash(secret),
1574        }
1575    }
1576
1577    /// The credential pepper for the authenticator rebuild: a clone of
1578    /// the Arc-held Zeroizing buffer (an Arc bump, NO byte copy; the
1579    /// authenticator keeps it alive until its next rebuild and the bytes
1580    /// zeroize when the last holder drops). Never logged.
1581    fn pepper_bytes(&self) -> Option<Arc<Zeroizing<Vec<u8>>>> {
1582        self.credential_pepper
1583            .read()
1584            .expect("credential pepper lock poisoned")
1585            .clone()
1586    }
1587
1588    /// Rebuild the authenticator from the CURRENT snapshot and the
1589    /// attached state store (if any), reusing JWKS caches by URL.
1590    fn rebuild_authn(&self) {
1591        let store = self
1592            .state_store
1593            .read()
1594            .expect("state store lock poisoned")
1595            .clone();
1596        let pepper = self.pepper_bytes();
1597        let snapshot = self.state.snapshot();
1598        let mut caches = self.jwks_caches.lock().expect("jwks cache lock poisoned");
1599        let authn = CompositeAuthenticator::build(
1600            snapshot.gateway(),
1601            store,
1602            &mut caches,
1603            Some(&self.obs),
1604            pepper.as_ref(),
1605            Arc::clone(&self.nonce_cache),
1606            Arc::clone(&self.oidc_introspection_cache),
1607        );
1608        self.authn.store(authn);
1609    }
1610
1611    /// Rebuild the (snapshot, registry) pair from the state's current
1612    /// snapshot and swap it in. Call after every successful publish.
1613    /// Balancer state (in-flight counters, WRR phase, slow-start clocks
1614    /// for unchanged endpoint addresses) carries over from the previous
1615    /// generation, so weight/endpoint changes take effect without a
1616    /// restart and without resetting live counters (DW-011).
1617    ///
1618    /// Contract (#46): the authenticator rebuild re-resolves `${...}`
1619    /// secret references from the CURRENT snapshot's config, so a
1620    /// refresh is only meaningful after the publish that validated
1621    /// them. A bare refresh against a snapshot whose secret sources
1622    /// have since broken loud-skips the affected credentials BY DESIGN
1623    /// (the key stops authenticating; never stale plaintext). Both
1624    /// call sites — the binary's reload path and the admin publish
1625    /// path — invoke it only on the success side of a publish.
1626    ///
1627    /// DW-165 review: this inline variant resolves registry plugin
1628    /// sources synchronously (bounded by the resolver's per-publish
1629    /// budget). Async callers that must not run blocking IO on a
1630    /// worker thread use [`Self::refresh_with_sources`] with
1631    /// resolutions produced on `spawn_blocking`.
1632    pub fn refresh(&self) {
1633        self.refresh_inner(None);
1634    }
1635
1636    /// DW-165 review: refresh with PRE-RESOLVED registry plugin
1637    /// sources. The binary's reload path runs
1638    /// `wasm::source::resolve_sources` inside
1639    /// `tokio::task::spawn_blocking` (DNS via getaddrinfo and the
1640    /// sync TLS reads are blocking syscalls) and hands the result
1641    /// here, so network IO never runs on an async worker thread. The
1642    /// resolutions are keyed by plugin name; the lifecycle re-checks
1643    /// each pinned digest against the loaded module's checksum before
1644    /// trusting a handed-in path, so a resolution that predates the
1645    /// generation being built can only fail closed.
1646    pub fn refresh_with_sources(&self, sources: crate::wasm::source::SourceResolutions) {
1647        self.refresh_inner(Some(sources));
1648    }
1649
1650    /// The shared refresh body; `sources` carries pre-resolved
1651    /// registry plugin sources (async reload path) or None to resolve
1652    /// inline against the snapshot this refresh builds from (admin
1653    /// path, tests).
1654    fn refresh_inner(&self, sources: Option<crate::wasm::source::SourceResolutions>) {
1655        let snapshot = self.state.snapshot();
1656        let sources =
1657            sources.unwrap_or_else(|| crate::wasm::source::resolve_sources(snapshot.gateway()));
1658        let generation = snapshot.generation();
1659        let previous = self.current();
1660        let slos = compile_route_slos(&snapshot);
1661        let registry = Arc::new(UpstreamRegistry::from_snapshot_with_previous_and_events(
1662            &snapshot,
1663            &previous.registry,
1664            Some(&self.events.emitter()),
1665        ));
1666        self.global_cap
1667            .store(Arc::new(global_cap_of(snapshot.gateway())));
1668        // DW-031: compile with Redis-backed limiters when a Redis
1669        // connection is attached (ent feature) and the config carries
1670        // a redis_rate_limiter block; otherwise the local GCRA limiter.
1671        #[cfg(feature = "ent")]
1672        {
1673            if let Some(conn) = self.redis_conn_for_compile() {
1674                if let Some(config) = snapshot.gateway().redis_rate_limiter.as_ref() {
1675                    self.rate_limits
1676                        .store(Arc::new(RateLimitEngine::compile_with_redis(
1677                            snapshot.gateway(),
1678                            conn,
1679                            config,
1680                        )));
1681                } else {
1682                    self.rate_limits
1683                        .store(Arc::new(RateLimitEngine::compile(snapshot.gateway())));
1684                }
1685            } else {
1686                self.rate_limits
1687                    .store(Arc::new(RateLimitEngine::compile(snapshot.gateway())));
1688            }
1689        }
1690        #[cfg(not(feature = "ent"))]
1691        {
1692            self.rate_limits
1693                .store(Arc::new(RateLimitEngine::compile(snapshot.gateway())));
1694        }
1695        // DW-037: cache epochs advance for every route whose definition
1696        // changed between generations (stored bytes were shaped by the
1697        // old masking/transform/policy); unchanged routes stay warm.
1698        self.response_cache
1699            .note_generation(Some(previous.snapshot.as_ref()), &snapshot);
1700        let oauth2_clients = build_oauth2_clients(snapshot.gateway());
1701        let ai = crate::ai::AiRuntime::compile(snapshot.gateway().ai.as_ref(), snapshot.gateway())
1702            .map(Arc::new);
1703        let anomaly = compile_anomaly_scorer(snapshot.gateway());
1704        let adaptive = compile_adaptive_controller(snapshot.gateway(), &self.obs);
1705        let canary = compile_canary_controller(snapshot.gateway(), &self.obs);
1706        // DW-078: budget RULES swap with the generation; the LEDGER
1707        // (spent windows) carries over — a reload never resets a
1708        // live budget window.
1709        self.ai_budgets.store(Arc::new(
1710            crate::ai::budget::AiBudgetEngine::compile_with_ledger(
1711                snapshot.gateway(),
1712                self.ai_budgets.load_full().ledger(),
1713            ),
1714        ));
1715        // DW-079: the pricing table swaps with the generation — a
1716        // pricing change takes effect on the next request with no
1717        // restart.
1718        self.ai_pricing
1719            .store(Arc::new(crate::ai::cost::PricingTable::compile(
1720                snapshot.gateway().ai.as_ref(),
1721            )));
1722        // DW-084: the governance engine swaps with the generation —
1723        // an allowlist change takes effect on the next request with no
1724        // restart.
1725        self.ai_governance
1726            .store(Arc::new(crate::ai::governance::GovernanceEngine::compile(
1727                snapshot.gateway().ai.as_ref(),
1728            )));
1729        // DW-081: the logging engine swaps with the generation — a
1730        // logging config change takes effect on the next request with
1731        // no restart. Also update the analytics store's prompt log
1732        // retention so the maintenance tick ages records out per the
1733        // new window.
1734        let logging_engine =
1735            crate::ai::logging::AiLoggingEngine::compile(snapshot.gateway().ai.as_ref())
1736                .map(Arc::new);
1737        if let Some(engine) = &logging_engine {
1738            if let Some(analytics) = self.analytics() {
1739                let retention_ms = (engine.retention_secs() as i64) * 1000;
1740                analytics.set_prompt_log_retention_ms(retention_ms);
1741            }
1742        }
1743        self.ai_logging.store(logging_engine);
1744        // DW-082: the guardrail engine swaps with the generation —
1745        // a guardrail change takes effect on the next request with no
1746        // restart.
1747        self.ai_guardrails
1748            .store(Arc::new(crate::ai::guardrails::GuardrailEngine::compile(
1749                snapshot.gateway().ai.as_ref(),
1750            )));
1751        // DW-083: the semantic cache config updates IN PLACE (the
1752        // HNSW index and cached entries persist across reloads). A
1753        // config that newly enables the cache constructs a fresh
1754        // engine; a config that removes the block clears it.
1755        let sem_cache_cfg = crate::ai::semantic_cache::SemanticCacheEngine::config_of(
1756            snapshot.gateway().ai.as_ref(),
1757        );
1758        match (sem_cache_cfg, self.ai_semantic_cache.load_full()) {
1759            (Some(new_cfg), Some(existing)) => {
1760                existing.update_config(new_cfg);
1761            }
1762            (Some(new_cfg), None) => {
1763                self.ai_semantic_cache.store(Some(Arc::new(
1764                    crate::ai::semantic_cache::SemanticCacheEngine::new(new_cfg),
1765                )));
1766            }
1767            (None, _) => {
1768                self.ai_semantic_cache.store(None);
1769            }
1770        }
1771        self.current.store(Arc::new(Generation {
1772            snapshot,
1773            registry,
1774            oauth2_clients,
1775            ai,
1776            anomaly,
1777            adaptive,
1778            canary,
1779        }));
1780        self.obs.set_config_generation(generation);
1781        // DW-052: the new generation's SLO set (plain targets, converted
1782        // here — observability takes primitives, not config types).
1783        // Routes whose `slo` block vanished stop exporting series.
1784        self.obs.set_route_slos(slos);
1785        // DW-044: the new generation's webhook targets (secret references
1786        // re-resolved at compile) apply from the next event on.
1787        let compiled = Arc::new(compile_webhook_targets(&self.state.snapshot()));
1788        if self.webhook_targets.send(compiled).is_err() {
1789            tracing::error!(
1790                code = "webhook_targets_watch_closed",
1791                "webhook target watch channel closed; deliveries will keep using the last set"
1792            );
1793        }
1794        // DW-121: the new generation's record-stream state — sinks
1795        // (re-resolved per compile), flush cadence, batch bound —
1796        // applies to the next batch, and the offer path's enabled flag
1797        // follows the compiled sink count (an unconfigured or
1798        // fail-closed stream never queues a record). ORDER: the watch
1799        // is pushed BEFORE the flag flips, so a reload ARMING the
1800        // stream never queues a record the flusher has no sink for
1801        // yet (the symmetric disarm window — offers stopping a moment
1802        // before the flusher's sink set empties — drains the tail to
1803        // the still-compiled sink, which is the operator's intent).
1804        let stream_state = crate::events::stream::compile_stream_targets(
1805            self.state.snapshot().gateway().analytics_stream.as_ref(),
1806            &self.obs,
1807            self.state
1808                .snapshot()
1809                .gateway()
1810                .ssrf_filter
1811                .as_ref()
1812                .map(crate::config::ssrf::SsrfFilter::from_config)
1813                .unwrap_or_else(crate::config::ssrf::SsrfFilter::disabled),
1814        );
1815        if self.stream_targets.send(stream_state).is_err() {
1816            tracing::error!(
1817                code = "record_stream_targets_watch_closed",
1818                "record stream target watch channel closed; the flusher will keep \
1819                 using the last set"
1820            );
1821        }
1822        let armed = !self.stream_targets.borrow().sinks.is_empty();
1823        if let Some(stream) = self.record_stream() {
1824            stream.set_enabled(armed);
1825        }
1826        // DW-157: the new generation's plugins (configs keyed, routes
1827        // registered, .wasm loaded/compiled checksum-keyed) — plugin
1828        // changes take effect on the next request, no restart. The
1829        // registry-source resolutions were produced by the caller
1830        // (async reloads: spawn_blocking; sync callers: inline in
1831        // refresh_inner, bounded by the per-publish budget).
1832        self.reload_plugins(sources);
1833        self.rebuild_authn();
1834        // DW-094 (Ent): re-apply the edge's locality context to the new
1835        // registry's balancers (the registry was just rebuilt with fresh
1836        // upstream handles whose balancers start with an empty context).
1837        self.apply_locality_to_registry();
1838        // SCALE-10 (#189): pre-warm connection pools for upstreams that
1839        // configure `pool.pre_warm` on reload. Best-effort.
1840        self.current.load().registry.pre_warm();
1841    }
1842
1843    /// The current (snapshot, registry) generation pair. pub(super):
1844    /// the response cache's background revalidation (DW-037) resolves
1845    /// routes through the same generation the dataplane serves.
1846    pub(super) fn current(&self) -> Arc<Generation> {
1847        self.current.load_full()
1848    }
1849
1850    /// DW-157: reload the plugin runtime for the CURRENT generation:
1851    /// key the generation's plugin configs by name (the per-request
1852    /// chain build borrows the ready map), replace the lifecycle's
1853    /// route -> plugins mapping (failure-isolation bookkeeping), and
1854    /// load + compile every `plugins[]` entry. DW-165: `source:`
1855    /// plugins are resolved to verified local artifacts BEFORE the
1856    /// wasm load step (cache-first; a miss downloads and verifies
1857    /// digest/signature — `wasm::source`). The load itself is
1858    /// per-plugin fail-closed (an unreadable or uncompilable .wasm, or
1859    /// an unresolvable/unverifiable `source:`, is marked Crashed and
1860    /// logged; routes referencing it answer 500) — the only
1861    /// whole-reload failure is wasmtime engine construction, and on
1862    /// THAT failure the previous lifecycle state survives untouched
1863    /// (the load replaces its maps only on success): unchanged plugins
1864    /// keep serving, and a plugin whose bytes changed this generation
1865    /// keeps serving the OLD bytes until the next successful reload. A
1866    /// plugin whose definition (config fields) or .wasm checksum
1867    /// changed invalidates the response cache of every route
1868    /// referencing it (DW-037 epoch bump — stored bytes were shaped by
1869    /// the old plugin and must never replay). Called from `new` and
1870    /// every `refresh`, so plugins hot-swap with generations exactly
1871    /// like the rest of the compiled state.
1872    fn reload_plugins(&self, sources: crate::wasm::source::SourceResolutions) {
1873        let gen = self.current();
1874        let gateway = gen.snapshot.gateway();
1875        // DW-165: registry-source resolutions (cache lookup / download
1876        // / digest + signature verification) were produced by the
1877        // CALLER — async reloads resolve on `spawn_blocking` before
1878        // refresh (no blocking network IO on an async worker), sync
1879        // callers resolve inline in `refresh_inner`; both are bounded
1880        // by the resolver's per-publish budget. The lifecycle re-checks
1881        // each pinned digest against the loaded module's checksum.
1882        let previous_configs = self.plugin_configs.load_full();
1883        // Pre-load checksums (the lifecycle computes them): a byte
1884        // change with an unchanged route is the cache-invalidation
1885        // trigger the route-equality check in `note_generation` cannot
1886        // see.
1887        let old_checksums: HashMap<String, String> = gateway
1888            .plugins
1889            .iter()
1890            .filter_map(|p| {
1891                self.plugins
1892                    .get_plugin(&p.name)
1893                    .map(|lp| (p.name.clone(), lp.checksum.clone()))
1894            })
1895            .collect();
1896        let mut configs = HashMap::with_capacity(gateway.plugins.len());
1897        for plugin in &gateway.plugins {
1898            configs.insert(plugin.name.clone(), plugin.clone());
1899        }
1900        self.plugin_configs.store(Arc::new(configs));
1901        let mut routes = HashMap::with_capacity(gateway.routes.len());
1902        for route in &gateway.routes {
1903            if !route.plugins.is_empty() {
1904                routes.insert(route.name.clone(), route.plugins.clone());
1905            }
1906        }
1907        self.plugins.set_route_plugins(routes);
1908        if let Err(e) = self.plugins.load(&gateway.plugins, &sources) {
1909            tracing::error!(
1910                code = "plugin_runtime_unavailable",
1911                "DW-157: the plugin runtime could not be constructed: {e}; the \
1912                 previous plugin state keeps serving (changed plugins keep their \
1913                 old bytes) until the next successful reload"
1914            );
1915            self.publish_plugin_state_metrics(gateway);
1916            return;
1917        }
1918        self.publish_plugin_state_metrics(gateway);
1919        // DW-037/DW-157: a plugin whose definition or bytes changed
1920        // invalidates every route referencing it. Only a PREVIOUS
1921        // non-empty plugin set can have shaped cached bytes — the first
1922        // load (startup) bumps nothing.
1923        if !previous_configs.is_empty() {
1924            let mut changed: Vec<&str> = Vec::new();
1925            for plugin in &gateway.plugins {
1926                let config_changed = previous_configs.get(&plugin.name) != Some(plugin);
1927                let new_checksum = self.plugins.get_plugin(&plugin.name).map(|lp| lp.checksum);
1928                let checksum_changed = old_checksums.get(&plugin.name) != new_checksum.as_ref();
1929                if config_changed || checksum_changed {
1930                    changed.push(plugin.name.as_str());
1931                }
1932            }
1933            if !changed.is_empty() {
1934                for route in &gateway.routes {
1935                    if route.plugins.iter().any(|n| changed.contains(&n.as_str())) {
1936                        self.response_cache.bump_route(&route.name);
1937                    }
1938                }
1939            }
1940        }
1941    }
1942
1943    /// DW-158: refresh `dwara_plugin_total{state}` from the live
1944    /// plugin status — the same enumeration [`Self::plugin_statuses`]
1945    /// serves — so the metric and the endpoint can never disagree.
1946    /// Called at the end of every `reload_plugins` (both the
1947    /// load-success and engine-failure paths: after a failed rebuild
1948    /// the lifecycle still describes what is serving, and any newly
1949    /// declared plugin correctly reads as not loaded). Every state's
1950    /// series is set on each publish, so a generation that removes the
1951    /// last plugin of a state drives its series to zero instead of
1952    /// stranding the previous count.
1953    fn publish_plugin_state_metrics(&self, gateway: &crate::config::Gateway) {
1954        let statuses =
1955            plugin_dispatch::plugin_statuses(&self.plugins, &self.native_plugins, gateway);
1956        let mut counts: HashMap<&'static str, i64> = HashMap::with_capacity(5);
1957        for status in &statuses {
1958            *counts.entry(status.state.label()).or_insert(0) += 1;
1959        }
1960        for state in [
1961            "healthy",
1962            "crashed",
1963            "disabled",
1964            "not_loaded",
1965            "not_registered",
1966        ] {
1967            self.obs
1968                .set_plugin_state_total(state, counts.get(state).copied().unwrap_or(0));
1969        }
1970    }
1971
1972    /// DW-158: read accessor for the plugin lifecycle (the `/plugins`
1973    /// status surface and tests). Read-only by design — every mutation
1974    /// flows through `reload_plugins` on generation swap, never through
1975    /// an accessor handed to a presentation layer.
1976    pub fn plugin_lifecycle(&self) -> &PluginLifecycle {
1977        &self.plugins
1978    }
1979
1980    /// DW-158: the plugin status surface — one entry per plugin the
1981    /// CURRENT generation declares (config order), with kind, source,
1982    /// digest, lifecycle state, effective limits, declared phases, and
1983    /// the routes referencing it. Backs the admin `GET /plugins`
1984    /// endpoint and the CLI status section.
1985    pub fn plugin_statuses(&self) -> Vec<plugin_dispatch::PluginStatusEntry> {
1986        let gen = self.current();
1987        let gateway = gen.snapshot.gateway();
1988        plugin_dispatch::plugin_statuses(&self.plugins, &self.native_plugins, gateway)
1989    }
1990
1991    /// DW-157: the native filter registry. Compiled-in filters register
1992    /// factories here at startup (config selects one with `native:` on
1993    /// a plugin); the dataplane's per-request chain resolves names
1994    /// against it.
1995    pub fn native_plugin_registry(&self) -> &NativeRegistry {
1996        &self.native_plugins
1997    }
1998
1999    /// DW-157: build the per-request plugin chain for `route` (the
2000    /// fail-closed health gate + WASM instantiation + unified chain).
2001    /// `Ok(None)` for a plugin-less route (zero allocations, the guard
2002    /// on the configs map never even loads); the `Err` response is
2003    /// returned to the client immediately by the caller. pub(super):
2004    /// the response cache's background revalidation (DW-037) runs the
2005    /// response phases on its refresh path through the same gate.
2006    pub(super) fn build_request_plugins(
2007        &self,
2008        route: &Route,
2009        rid: &str,
2010    ) -> Result<Option<RequestPlugins>, plugin_dispatch::PluginExit> {
2011        if route.plugins.is_empty() {
2012            return Ok(None);
2013        }
2014        // DW-167: the generation's SSRF egress filter rides the plugin
2015        // state so plugin HTTP callouts are checked at connect time —
2016        // the same posture as webhook deliveries and registry fetches.
2017        let ssrf = self
2018            .current()
2019            .snapshot
2020            .gateway()
2021            .ssrf_filter
2022            .as_ref()
2023            .map(crate::config::ssrf::SsrfFilter::from_config)
2024            .unwrap_or_else(crate::config::ssrf::SsrfFilter::disabled);
2025        RequestPlugins::build(
2026            &self.plugins,
2027            &self.plugin_configs.load(),
2028            &self.native_plugins,
2029            route,
2030            &self.obs,
2031            rid,
2032            ssrf,
2033        )
2034    }
2035
2036    /// DW-091: apply new service split weights atomically (transient,
2037    /// reverts on config reload). Loads the current generation, clones
2038    /// the registry, rebuilds the split for `service` with the new
2039    /// canary weight (baseline = total - canary, keeping the total
2040    /// constant), and stores a new Generation. Returns true on
2041    /// success; false when the service has no split or the rebuild
2042    /// failed. The canary is the SECOND target (index 1); the
2043    /// baseline is the first (index 0).
2044    pub fn apply_service_split_weights(&self, service: &str, new_canary_weight: u64) -> bool {
2045        let gen = self.current();
2046        let gateway = gen.snapshot.gateway();
2047        let Some(svc) = gateway.services.iter().find(|s| s.name == service) else {
2048            return false;
2049        };
2050        let Some(split) = &svc.split else {
2051            return false;
2052        };
2053        if split.targets.len() != 2 {
2054            return false;
2055        }
2056        let total: u64 = split.targets.iter().map(|t| t.weight as u64).sum();
2057        if total == 0 {
2058            return false;
2059        }
2060        let new_canary_weight = new_canary_weight.min(total);
2061        let new_baseline_weight = total - new_canary_weight;
2062        let pairs = vec![
2063            (split.targets[0].upstream.clone(), new_baseline_weight),
2064            (split.targets[1].upstream.clone(), new_canary_weight),
2065        ];
2066        let Some(new_registry) = gen.registry.with_rebuilt_split_from_pairs(service, &pairs) else {
2067            return false;
2068        };
2069        self.current.store(Arc::new(Generation {
2070            snapshot: Arc::clone(&gen.snapshot),
2071            registry: Arc::new(new_registry),
2072            oauth2_clients: gen.oauth2_clients.clone(),
2073            ai: gen.ai.clone(),
2074            anomaly: gen.anomaly.clone(),
2075            adaptive: Arc::clone(&gen.adaptive),
2076            canary: gen.canary.clone(),
2077        }));
2078        true
2079    }
2080
2081    /// DW-091: apply new AI canary weights atomically (transient,
2082    /// reverts on config reload). Loads the current generation, clones
2083    /// the AI runtime, rebuilds the canary split for `alias` with the
2084    /// new canary weight (baseline = total - canary, keeping the total
2085    /// constant), and stores a new Generation. Returns true on
2086    /// success; false when the alias has no canary or the rebuild
2087    /// failed. The canary is the SECOND version (index 1); the
2088    /// baseline is the first (index 0).
2089    pub fn apply_ai_canary_weights(&self, alias: &str, new_canary_weight: u64) -> bool {
2090        let gen = self.current();
2091        let Some(ai) = &gen.ai else {
2092            return false;
2093        };
2094        let gateway = gen.snapshot.gateway();
2095        let Some(ai_cfg) = &gateway.ai else {
2096            return false;
2097        };
2098        let Some(model) = ai_cfg.models.get(alias) else {
2099            return false;
2100        };
2101        if model.canary.len() != 2 {
2102            return false;
2103        }
2104        let total: u64 = model.canary.iter().map(|v| v.weight as u64).sum();
2105        if total == 0 {
2106            return false;
2107        }
2108        let new_canary_weight = new_canary_weight.min(total) as u32;
2109        let new_baseline_weight = (total - new_canary_weight as u64) as u32;
2110        let new_weights = [new_baseline_weight, new_canary_weight];
2111        let Some(new_ai) = ai.with_rebuilt_canary(alias, &new_weights) else {
2112            return false;
2113        };
2114        self.current.store(Arc::new(Generation {
2115            snapshot: Arc::clone(&gen.snapshot),
2116            registry: Arc::clone(&gen.registry),
2117            oauth2_clients: gen.oauth2_clients.clone(),
2118            ai: Some(Arc::new(new_ai)),
2119            anomaly: gen.anomaly.clone(),
2120            adaptive: Arc::clone(&gen.adaptive),
2121            canary: gen.canary.clone(),
2122        }));
2123        true
2124    }
2125
2126    /// The OAuth2 token cache (DW-035): per-dataplane, carried across
2127    /// generation swaps so a reload never discards a still-valid token.
2128    /// pub(super): the response cache's background revalidation (DW-037)
2129    /// drives the same forward path and needs the token cache too.
2130    pub(super) fn oauth2_token_cache(&self) -> &Arc<crate::security::oauth2::OAuth2TokenCache> {
2131        &self.oauth2_token_cache
2132    }
2133
2134    /// The per-priority admission/shed counters (DW-016). Metrics
2135    /// exposure (DW-021) reads these; tests read them directly.
2136    pub fn priority_counters(&self) -> &PriorityCounters {
2137        &self.priority_counters
2138    }
2139
2140    /// This dataplane's observability state (DW-021): metric recording,
2141    /// /metrics rendering, and the access-log sampling knob.
2142    pub fn observability(&self) -> &Observability {
2143        &self.obs
2144    }
2145
2146    /// The shared handle behind [`Self::observability`] (DW-039: the
2147    /// WebSocket tunnel task outlives the request and needs an owned
2148    /// handle to record its policy outcome).
2149    pub fn observability_arc(&self) -> Arc<Observability> {
2150        Arc::clone(&self.obs)
2151    }
2152
2153    /// This dataplane's event bus (DW-044): breaker/ejection/config
2154    /// events emit onto it; `spawn_webhook_deliverer` drains it.
2155    pub fn events(&self) -> &Arc<crate::events::EventBus> {
2156        &self.events
2157    }
2158
2159    /// The local response cache (DW-037): lookup/store stages run from
2160    /// `handle`, the admin purge endpoint and the metrics gauge walk
2161    /// read from here. Shared as an `Arc` because stale-while-revalidate
2162    /// spawns background revalidations that outlive the request.
2163    pub fn response_cache(&self) -> &Arc<crate::dataplane::response_cache::ResponseCache> {
2164        &self.response_cache
2165    }
2166
2167    /// A fresh receiver of the CURRENT webhook target set (DW-044);
2168    /// updates on every `refresh`. Tests driving the deliverer directly
2169    /// (without [`DataPlane::spawn_webhook_deliverer`]) use this.
2170    pub fn webhook_targets(
2171        &self,
2172    ) -> tokio::sync::watch::Receiver<Arc<Vec<crate::events::webhook::WebhookTarget>>> {
2173        self.webhook_targets.subscribe()
2174    }
2175
2176    /// Spawn the webhook deliverer (DW-044) against this dataplane's bus,
2177    /// target set, and metrics: one background task that drains the event
2178    /// queue and POSTs matching events to the configured targets (see
2179    /// `events::webhook` for the delivery contract). Exactly one
2180    /// deliverer per dataplane: a second call logs and returns an
2181    /// already-finished handle (the bus's single-consumer receiver is
2182    /// gone). The task stops on `shutdown` (or when the dataplane is
2183    /// dropped and the bus closes).
2184    pub fn spawn_webhook_deliverer(
2185        self: &Arc<Self>,
2186        shutdown: tokio::sync::watch::Receiver<()>,
2187    ) -> tokio::task::JoinHandle<()> {
2188        match self.events.take_receiver() {
2189            Some(rx) => tokio::spawn(crate::events::webhook::run_deliverer(
2190                rx,
2191                self.webhook_targets(),
2192                Arc::clone(&self.obs),
2193                shutdown,
2194                // REL-14 (#249): pass the bus's durability layer so
2195                // the deliverer can ack events after dispatch.
2196                self.events.durability(),
2197            )),
2198            None => {
2199                tracing::error!(
2200                    code = "webhook_deliverer_already_running",
2201                    "webhook deliverer already spawned for this dataplane; \
2202                     ignoring the duplicate spawn"
2203                );
2204                tokio::spawn(async {})
2205            }
2206        }
2207    }
2208
2209    /// Per-consumer quota figures for one export window (DW-120): the
2210    /// read side of the statement's quota columns, assembled from the
2211    /// CURRENT config generation's quota blocks and the state store's
2212    /// `quota_counters`. A budget's figures appear only when its UTC
2213    /// quota window FULLY CONTAINS `[period_start_s, period_end_s)`
2214    /// (see `analytics::exports`' alignment rule): a daily export
2215    /// carries the same-day daily counter and the month-to-date
2216    /// monthly counter; a monthly export carries only the monthly
2217    /// counter. Consumers without a quotas block, without a store, or
2218    /// not yet synced into the store carry no figures — never
2219    /// fabricated zeros.
2220    pub fn quota_figures_at(
2221        &self,
2222        period_start_s: i64,
2223        period_end_s: i64,
2224    ) -> std::collections::HashMap<String, crate::analytics::exports::QuotaFigures> {
2225        let mut out = std::collections::HashMap::new();
2226        let Some(store) = self.state_store() else {
2227            return out;
2228        };
2229        for consumer in &self.current().snapshot.gateway().consumers {
2230            let Some(quotas) = &consumer.quotas else {
2231                continue;
2232            };
2233            let figures = match store.lookup_consumer(&consumer.name) {
2234                Ok(Some(record)) => {
2235                    let usage = crate::state::quotas::current_usage(
2236                        &store,
2237                        record.id,
2238                        quotas,
2239                        period_start_s,
2240                    );
2241                    let mut f = crate::analytics::exports::QuotaFigures::default();
2242                    for u in &usage {
2243                        let contains = u.window_start_epoch_s as i64 <= period_start_s
2244                            && period_end_s <= u.reset_epoch_s as i64;
2245                        if !contains {
2246                            continue;
2247                        }
2248                        let b = crate::analytics::exports::QuotaBudget {
2249                            used: u.used,
2250                            limit: u.limit,
2251                            window_start_epoch_s: u.window_start_epoch_s as i64,
2252                            reset_epoch_s: u.reset_epoch_s as i64,
2253                        };
2254                        match u.budget {
2255                            crate::state::quotas::Budget::Daily => f.daily = Some(b),
2256                            crate::state::quotas::Budget::Monthly => f.monthly = Some(b),
2257                        }
2258                    }
2259                    f
2260                }
2261                _ => crate::analytics::exports::QuotaFigures::default(),
2262            };
2263            out.insert(consumer.name.clone(), figures);
2264        }
2265        out
2266    }
2267
2268    /// Spawn the usage-report export worker (DW-120): one background
2269    /// task on the same interval-plus-shutdown-watch machinery as the
2270    /// analytics rollup cascade, reading the CURRENT config generation
2271    /// each tick (a reload can add, change, or remove `analytics.
2272    /// exports` without a restart) and exporting every closed window
2273    /// of the configured kind that has no successful run record —
2274    /// oldest first, so a restart backfills missed windows. No-ops
2275    /// without an analytics store or an exports block. Safe to abort:
2276    /// each export is an atomic file write plus an idempotent record.
2277    pub fn spawn_export_worker(
2278        self: &Arc<Self>,
2279        shutdown: tokio::sync::watch::Receiver<()>,
2280    ) -> tokio::task::JoinHandle<()> {
2281        let dp = Arc::clone(self);
2282        let mut shutdown = shutdown;
2283        tokio::spawn(async move {
2284            let mut tick = tokio::time::interval(std::time::Duration::from_millis(
2285                crate::analytics::exports::EXPORT_TICK_MS,
2286            ));
2287            tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
2288            loop {
2289                tokio::select! {
2290                    _ = tick.tick() => {
2291                        let Some(exports_cfg) = dp
2292                            .state
2293                            .snapshot()
2294                            .gateway()
2295                            .analytics
2296                            .as_ref()
2297                            .and_then(|a| a.exports.as_ref())
2298                            .cloned()
2299                        else {
2300                            continue;
2301                        };
2302                        let Some(store) = dp.analytics() else {
2303                            continue;
2304                        };
2305                        crate::analytics::exports::run_due(
2306                            &store,
2307                            &exports_cfg,
2308                            &|ps, pe| dp.quota_figures_at(ps, pe),
2309                            std::time::SystemTime::now()
2310                                .duration_since(std::time::UNIX_EPOCH)
2311                                .map(|d| d.as_millis() as i64)
2312                                .unwrap_or(0),
2313                        );
2314                    }
2315                    _ = shutdown.changed() => return,
2316                }
2317            }
2318        })
2319    }
2320
2321    /// The AI budget engine (DW-078): current rules over the shared
2322    /// reload-surviving ledger.
2323    pub fn ai_budgets(&self) -> Arc<crate::ai::budget::AiBudgetEngine> {
2324        self.ai_budgets.load_full()
2325    }
2326
2327    /// The AI pricing table (DW-079): current per-model token rates.
2328    pub fn ai_pricing(&self) -> Arc<crate::ai::cost::PricingTable> {
2329        self.ai_pricing.load_full()
2330    }
2331
2332    /// The AI governance engine (DW-084): current per-team model
2333    /// allowlists and the audit switch.
2334    pub fn ai_governance(&self) -> Arc<crate::ai::governance::GovernanceEngine> {
2335        self.ai_governance.load_full()
2336    }
2337
2338    /// The AI prompt/response logging engine (DW-081): current logging
2339    /// config (enabled, sampling, retention) and compiled redactor.
2340    /// None when logging is not configured (capture off, privacy-first).
2341    pub fn ai_logging(&self) -> Option<Arc<crate::ai::logging::AiLoggingEngine>> {
2342        self.ai_logging.load_full()
2343    }
2344
2345    /// The AI guardrail engine (DW-082): current compiled guardrail
2346    /// rules (prompt-injection, PII, banned-content, output schema
2347    /// enforcement). Empty when guardrails are not configured (every
2348    /// prompt and response passes through — fail-open).
2349    pub fn ai_guardrails(&self) -> Arc<crate::ai::guardrails::GuardrailEngine> {
2350        self.ai_guardrails.load_full()
2351    }
2352
2353    /// The AI semantic cache engine (DW-083): the embedding-similarity
2354    /// cache. None when semantic caching is not configured or disabled.
2355    /// Persists across reloads (the HNSW index and cached entries
2356    /// survive config refreshes).
2357    pub fn ai_semantic_cache(&self) -> Option<Arc<crate::ai::semantic_cache::SemanticCacheEngine>> {
2358        self.ai_semantic_cache.load_full()
2359    }
2360
2361    /// The current generation's upstream registry. Used by the
2362    /// TLS-passthrough path (which picks endpoints through the same
2363    /// balancers) and by tests.
2364    pub fn registry(&self) -> Arc<UpstreamRegistry> {
2365        Arc::clone(&self.current().registry)
2366    }
2367
2368    /// DW-091: the current generation's auto-canary controller (None
2369    /// when no canary_analysis is configured). Used by the background
2370    /// runner and by tests.
2371    pub fn canary_controller(&self) -> Option<Arc<crate::dataplane::canary::CanaryController>> {
2372        self.current().canary.clone()
2373    }
2374
2375    /// Whether the gateway is READY to serve: at least one config
2376    /// generation has been published successfully (generation >= 1). This
2377    /// is the `/readyz` definition (DW-013). Upstream health deliberately
2378    /// does NOT participate: a fully-ejected pool fail-opens rather than
2379    /// blackholing (DW-012), so readiness tracks the gateway's own state,
2380    /// not its backends'.
2381    pub fn ready(&self) -> bool {
2382        self.state.snapshot().generation() >= 1
2383    }
2384}
2385
2386/// REL-14 (#249): concrete `EventDurability` backed by the SQLite
2387/// `StateStore`. The dataplane owns both the store and the event bus,
2388/// so it provides this adapter to the events module's trait. The
2389/// implementation is best-effort: a SQLite error is logged but does
2390/// not block the emit path (the event still goes to the in-memory
2391/// channel).
2392#[derive(Debug)]
2393pub struct StoreEventDurability {
2394    store: Arc<crate::state::store::StateStore>,
2395}
2396
2397impl StoreEventDurability {
2398    pub fn new(store: Arc<crate::state::store::StateStore>) -> Self {
2399        StoreEventDurability { store }
2400    }
2401}
2402
2403impl crate::events::EventDurability for StoreEventDurability {
2404    fn append(&self, event: &crate::events::Event) {
2405        let payload_json = serde_json::to_string(&event.payload).unwrap_or_else(|_| "{}".into());
2406        if let Err(err) = self.store.append_event_wal(
2407            &event.id,
2408            event.kind.as_str(),
2409            &event.gateway,
2410            event.timestamp_ms,
2411            &payload_json,
2412        ) {
2413            tracing::warn!(
2414                code = "event_wal_append_failed",
2415                event_id = %event.id,
2416                kind = event.kind.as_str(),
2417                error = %err,
2418                "failed to persist critical event to WAL (best-effort; event still emitted to channel)"
2419            );
2420        }
2421    }
2422
2423    fn ack(&self, id: &str) {
2424        if let Err(err) = self.store.ack_event_wal(id) {
2425            tracing::warn!(
2426                code = "event_wal_ack_failed",
2427                event_id = %id,
2428                error = %err,
2429                "failed to ack event in WAL"
2430            );
2431        }
2432    }
2433
2434    fn unacked(&self) -> Vec<crate::events::Event> {
2435        match self.store.unacked_events_wal() {
2436            Ok(rows) => rows
2437                .into_iter()
2438                .filter_map(|row| {
2439                    let kind = crate::events::EventKind::from_config(&row.kind)?;
2440                    let payload: crate::events::EventPayload =
2441                        serde_json::from_str(&row.payload).unwrap_or_default();
2442                    Some(crate::events::Event {
2443                        id: row.id,
2444                        kind,
2445                        timestamp_ms: row.timestamp_ms as u64,
2446                        gateway: row.gateway,
2447                        payload,
2448                    })
2449                })
2450                .collect(),
2451            Err(err) => {
2452                tracing::warn!(
2453                    code = "event_wal_load_failed",
2454                    error = %err,
2455                    "failed to load un-acked events from WAL; skipping replay"
2456                );
2457                Vec::new()
2458            }
2459        }
2460    }
2461}
2462
2463/// Reserved gateway paths (DW-013), served on EVERY listener BEFORE any
2464/// route resolution: `/healthz` answers 200 whenever the process is up
2465/// (liveness; a container orchestrator should restart it otherwise-false
2466/// cases, which only process death can produce), and `/readyz` answers 200
2467/// exactly when [`DataPlane::ready`] holds, 503 otherwise. Precedence:
2468/// these paths are NOT routable — a configured route matching `/healthz`
2469/// or `/readyz` (exact, regex, or prefix) is permanently shadowed, which
2470/// is accepted v1 behavior (documented; no conflict rejection). Applies
2471/// regardless of listener protocol, so a TLS-terminated listener serves
2472/// them too; TLS-passthrough listeners do not (they never speak HTTP).
2473fn reserved_path(dp: &DataPlane, path: &str, rid: &str) -> Option<Response<ProxyBody>> {
2474    match path {
2475        // DW-111: /healthz includes a `fips` field with the attestation
2476        // (enabled, provider, self_test_passed) when FIPS mode is active
2477        // (Enterprise builds). In OSS builds FIPS mode is disabled and
2478        // the fips field is omitted (the health response is the same as
2479        // the simple envelope).
2480        "/healthz" => {
2481            let _ = dp;
2482            if let Some(attestation) = crate::security::fips::health_attestation() {
2483                let body = serde_json::json!({
2484                    "error": {
2485                        "code": "ok",
2486                        "message": "ok",
2487                        "request_id": rid,
2488                    },
2489                    "fips": {
2490                        "enabled": attestation.enabled,
2491                        "provider": attestation.provider,
2492                        "self_test_passed": attestation.self_test_passed,
2493                    },
2494                });
2495                Some(
2496                    Response::builder()
2497                        .status(StatusCode::OK)
2498                        .header(hyper::header::CONTENT_TYPE, "application/json")
2499                        .body(ProxyBody::Full(Full::new(Bytes::from(body.to_string()))))
2500                        .expect("static healthz body is valid"),
2501                )
2502            } else {
2503                Some(simple(StatusCode::OK, "ok", "ok", rid))
2504            }
2505        }
2506        "/readyz" => {
2507            if dp.ready() {
2508                Some(simple(StatusCode::OK, "ready", "ready", rid))
2509            } else {
2510                Some(simple(
2511                    StatusCode::SERVICE_UNAVAILABLE,
2512                    "not_ready",
2513                    "not ready",
2514                    rid,
2515                ))
2516            }
2517        }
2518        // Prometheus scrape endpoint (DW-021): reserved exactly like
2519        // /healthz (shadows any configured route; served on every HTTP(S)
2520        // listener; TLS-passthrough listeners never speak HTTP). The
2521        // state-derived gauges (breaker/endpoint health/fail-open) are
2522        // refreshed from the CURRENT generation at scrape time.
2523        "/metrics" => {
2524            let obs = &dp.obs;
2525            refresh_observation_gauges(&dp.current().registry, obs);
2526            let rate_limits = dp.rate_limits.load_full();
2527            refresh_rate_limiter_gauges(&rate_limits, obs);
2528            // DW-033: quota usage/limit gauges are a scrape-time
2529            // snapshot of the state store's counters (the same walk
2530            // model as the rate-limiter gauges above).
2531            refresh_quota_gauges(dp, obs);
2532            crate::events::refresh_event_gauges(&dp.events, obs);
2533            // DW-121: the stream's offered/dropped gauges are a
2534            // scrape-time snapshot of the stream's monotonic counters
2535            // (the same walk model).
2536            if let Some(stream) = dp.record_stream() {
2537                crate::events::stream::refresh_stream_gauges(&stream, obs);
2538            }
2539            // DW-037: the cache entries gauge is a scrape-time snapshot
2540            // of the backing store's approximate count (the same walk
2541            // model as the rate-limiter gauges above).
2542            obs.set_cache_entries(dp.response_cache().live_entries());
2543            Response::builder()
2544                .status(StatusCode::OK)
2545                .header(hyper::header::CONTENT_TYPE, "text/plain; version=0.0.4")
2546                .body(ProxyBody::Full(Full::new(Bytes::from(obs.render()))))
2547                .ok()
2548        }
2549        _ => None,
2550    }
2551}
2552
2553/// USA-12 (#233): serve the developer portal at its configured path.
2554/// The portal is built at compile time and stored in the snapshot.
2555/// This check runs AFTER the fixed reserved paths (`/healthz`,
2556/// `/readyz`, `/metrics`) and BEFORE route resolution, so the portal
2557/// path shadows any configured route (like the other reserved paths).
2558fn serve_portal(dp: &DataPlane, path: &str) -> Option<Response<ProxyBody>> {
2559    let snapshot = dp.state.snapshot();
2560    let portal = snapshot.portal()?;
2561    if path != portal.path() {
2562        return None;
2563    }
2564    let html = portal.render_html();
2565    Some(
2566        Response::builder()
2567            .status(StatusCode::OK)
2568            .header(hyper::header::CONTENT_TYPE, "text/html; charset=utf-8")
2569            .body(ProxyBody::Full(Full::new(Bytes::from(html))))
2570            .expect("portal html body is valid"),
2571    )
2572}
2573
2574/// The MCP session-id header name (DW-087).
2575const MCP_SESSION_ID_HDR: hyper::header::HeaderName =
2576    hyper::header::HeaderName::from_static("mcp-session-id");
2577
2578/// Maximum MCP request body size (1 MiB — JSON-RPC requests are small;
2579/// anything larger is a misbehaving or hostile client).
2580const MCP_BODY_CAP: u64 = 1024 * 1024;
2581
2582/// Handle one MCP JSON-RPC request (DW-087): authenticate, collect
2583/// the body, parse JSON-RPC, dispatch to the compiled MCP gateway,
2584/// manage the session (state store), record analytics, and return the
2585/// JSON-RPC response. The MCP path shadows any configured route (it
2586/// is checked before route resolution in [`handle_inner`]).
2587async fn mcp_handle<B>(
2588    dp: &Arc<DataPlane>,
2589    peer: IpAddr,
2590    req: Request<B>,
2591    mcp: &Arc<crate::ai::mcp::CompiledMcp>,
2592    rid: &str,
2593    rec: &mut AccessRecord,
2594) -> Response<ProxyBody>
2595where
2596    B: hyper::body::Body<Data = Bytes> + Send + 'static,
2597    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
2598{
2599    use http_body_util::BodyExt as _;
2600
2601    // --- AuthN (same as the proxy path) ---
2602    let authn = dp.authn.load_full();
2603    let client_cert = req
2604        .extensions()
2605        .get::<Arc<crate::security::authn::ClientCertificate>>()
2606        .cloned();
2607    let authn_req = crate::security::authn::AuthnRequest {
2608        method: req.method(),
2609        uri: req.uri(),
2610        headers: req.headers(),
2611        client_cert: client_cert.as_deref(),
2612    };
2613    let identity = match authn
2614        .authenticate(&authn_req)
2615        .instrument(tracing::info_span!("mcp_authn"))
2616        .await
2617    {
2618        Ok(id) => id,
2619        Err(AuthError::Invalid(_)) => {
2620            return unauthorized(&authn.challenge(), rid);
2621        }
2622        Err(AuthError::Unavailable(msg)) => {
2623            tracing::error!(
2624                code = "mcp_authn_unavailable",
2625                request_id = %rid,
2626                error = %msg,
2627                "authn unavailable for MCP request"
2628            );
2629            return simple(
2630                StatusCode::INTERNAL_SERVER_ERROR,
2631                "authn_unavailable",
2632                "authentication unavailable",
2633                rid,
2634            );
2635        }
2636    };
2637    let consumer = identity
2638        .as_ref()
2639        .map(|i| i.consumer_name.clone())
2640        .unwrap_or_else(|| "anonymous".to_string());
2641    rec.consumer = consumer.clone();
2642
2643    // --- Collect the request body ---
2644    // Split the request first so we can still read headers after
2645    // consuming the body.
2646    let (parts, body) = req.into_parts();
2647    let limited = Limited::new(body, MCP_BODY_CAP as usize);
2648    let body_bytes = match limited.collect().await {
2649        Ok(c) => c.to_bytes(),
2650        Err(err) => {
2651            if err.downcast_ref::<LengthLimitError>().is_some() {
2652                return simple(
2653                    StatusCode::PAYLOAD_TOO_LARGE,
2654                    "mcp_body_too_large",
2655                    &format!("mcp request body exceeds {} bytes", MCP_BODY_CAP),
2656                    rid,
2657                );
2658            }
2659            return simple(
2660                StatusCode::BAD_REQUEST,
2661                "mcp_body_read_failed",
2662                &err.to_string(),
2663                rid,
2664            );
2665        }
2666    };
2667
2668    // --- Parse JSON-RPC ---
2669    let body_json: serde_json::Value = match serde_json::from_slice(&body_bytes) {
2670        Ok(v) => v,
2671        Err(_) => {
2672            let resp = crate::ai::mcp::parse_error_response();
2673            return mcp_json_response(StatusCode::BAD_REQUEST, &resp, None);
2674        }
2675    };
2676    let rpc_req = match crate::ai::mcp::JsonRpcRequest::parse(&body_json) {
2677        Ok(r) => r,
2678        Err(err_resp) => {
2679            return mcp_json_response(StatusCode::BAD_REQUEST, &err_resp, None);
2680        }
2681    };
2682
2683    // --- Session management ---
2684    let session_id_hdr = parts
2685        .headers
2686        .get(&MCP_SESSION_ID_HDR)
2687        .and_then(|v| v.to_str().ok())
2688        .map(|s| s.to_string());
2689    let state_store = dp.state_store();
2690
2691    // For non-initialize requests, validate the session if a state
2692    // store is attached. Without a state store, sessions are
2693    // stateless (the session id is still returned but not persisted).
2694    if let Some(sid) = &session_id_hdr {
2695        if rpc_req.method != "initialize" {
2696            if let Some(store) = &state_store {
2697                if let Ok(false) = store.touch_mcp_session(sid) {
2698                    return mcp_json_response(
2699                        StatusCode::NOT_FOUND,
2700                        &crate::ai::mcp::json_rpc_error_pub(
2701                            None,
2702                            -32000,
2703                            "session not found or expired",
2704                        ),
2705                        None,
2706                    );
2707                }
2708            }
2709        }
2710    }
2711
2712    // --- AuthZ for tools/call (DW-087) ---
2713    // The `ai` domain may not import `security` (dependency direction),
2714    // so authz is evaluated here in the dataplane. A tool with an
2715    // authz attachment is only callable by consumers satisfying the
2716    // rules; a tool without one is open to any authenticated consumer.
2717    let mcp_tool_started = if rpc_req.method == "tools/call" {
2718        Some(std::time::Instant::now())
2719    } else {
2720        None
2721    };
2722    if rpc_req.method == "tools/call" {
2723        let tool_name = rpc_req
2724            .params
2725            .as_ref()
2726            .and_then(|p| p.get("name"))
2727            .and_then(|v| v.as_str())
2728            .unwrap_or("");
2729        // DW-113: per-agent tool allowlist. Checked BEFORE the authz
2730        // attachment (the most-specific restriction). A consumer with
2731        // no allowlist entry passes through (no restriction).
2732        if !mcp.consumer_allowed_tool(&consumer, tool_name) {
2733            let duration_ms = mcp_tool_started
2734                .map(|s| s.elapsed().as_secs_f64() * 1000.0)
2735                .unwrap_or(0.0);
2736            let outcome = crate::ai::mcp::McpToolCallOutcome {
2737                tool_name: tool_name.to_string(),
2738                allowed: false,
2739                duration_ms,
2740                error_code: Some("tool_not_in_agent_allowlist".to_string()),
2741                status: "denied".to_string(),
2742            };
2743            dp.obs.record_mcp_tool_call(tool_name, "denied");
2744            dp.obs
2745                .record_mcp_tool_duration(tool_name, duration_ms / 1000.0);
2746            if let Some(analytics) = dp.analytics() {
2747                let now_ms = std::time::SystemTime::now()
2748                    .duration_since(std::time::UNIX_EPOCH)
2749                    .map(|d| d.as_millis() as i64)
2750                    .unwrap_or(0);
2751                analytics.offer_mcp_tool_call(crate::analytics::McpToolCallRecord {
2752                    ts_ms: now_ms,
2753                    request_id: rid.to_string(),
2754                    session_id: session_id_hdr.clone().unwrap_or_default(),
2755                    consumer: consumer.clone(),
2756                    consumer_type: identity
2757                        .as_ref()
2758                        .map(|i| consumer_type_str(i.consumer_type))
2759                        .unwrap_or("user")
2760                        .to_string(),
2761                    tool_name: outcome.tool_name.clone(),
2762                    allowed: outcome.allowed,
2763                    duration_ms: outcome.duration_ms,
2764                    error_code: outcome.error_code.clone(),
2765                    status: outcome.status.clone(),
2766                });
2767            }
2768            let result_json = serde_json::json!({
2769                "content": [{"type": "text", "text": "tool not in agent allowlist"}],
2770                "isError": true,
2771            });
2772            let resp = if rpc_req.id.is_none() {
2773                serde_json::json!({})
2774            } else {
2775                crate::ai::mcp::json_rpc_result_pub(rpc_req.id.clone(), result_json)
2776            };
2777            let status = if rpc_req.id.is_none() {
2778                StatusCode::ACCEPTED
2779            } else {
2780                StatusCode::OK
2781            };
2782            return mcp_json_response(status, &resp, session_id_hdr.as_deref());
2783        }
2784        if let Some(authz) = mcp.tool_authz(tool_name) {
2785            let authz_chain = crate::security::authz::AuthzChain {
2786                consumer: None,
2787                route: None,
2788                service: None,
2789                listener: None,
2790                global: Some(authz),
2791            };
2792            let authz_ctx = crate::security::authz::AuthzContext {
2793                identity: identity.as_ref(),
2794                consumer_groups: identity
2795                    .as_ref()
2796                    .map(|i| i.groups.as_slice())
2797                    .unwrap_or(&[]),
2798                peer_ip: peer,
2799                effective_ip: peer,
2800                geoip: None,
2801            };
2802            if crate::security::authz::authorize(&authz_chain, &authz_ctx)
2803                != crate::security::authz::Decision::Allow
2804            {
2805                let duration_ms = mcp_tool_started
2806                    .map(|s| s.elapsed().as_secs_f64() * 1000.0)
2807                    .unwrap_or(0.0);
2808                let outcome = crate::ai::mcp::McpToolCallOutcome {
2809                    tool_name: tool_name.to_string(),
2810                    allowed: false,
2811                    duration_ms,
2812                    error_code: Some("unauthorized".to_string()),
2813                    status: "denied".to_string(),
2814                };
2815                // Record metrics for the denied call (DW-087).
2816                dp.obs.record_mcp_tool_call(tool_name, "denied");
2817                dp.obs
2818                    .record_mcp_tool_duration(tool_name, duration_ms / 1000.0);
2819                // Record analytics for the denied call.
2820                if let Some(analytics) = dp.analytics() {
2821                    let now_ms = std::time::SystemTime::now()
2822                        .duration_since(std::time::UNIX_EPOCH)
2823                        .map(|d| d.as_millis() as i64)
2824                        .unwrap_or(0);
2825                    analytics.offer_mcp_tool_call(crate::analytics::McpToolCallRecord {
2826                        ts_ms: now_ms,
2827                        request_id: rid.to_string(),
2828                        session_id: session_id_hdr.clone().unwrap_or_default(),
2829                        consumer: consumer.clone(),
2830                        consumer_type: identity
2831                            .as_ref()
2832                            .map(|i| consumer_type_str(i.consumer_type))
2833                            .unwrap_or("user")
2834                            .to_string(),
2835                        tool_name: outcome.tool_name.clone(),
2836                        allowed: outcome.allowed,
2837                        duration_ms: outcome.duration_ms,
2838                        error_code: outcome.error_code.clone(),
2839                        status: outcome.status.clone(),
2840                    });
2841                }
2842                let result_json = serde_json::json!({
2843                    "content": [{"type": "text", "text": "unauthorized: this tool requires authorization"}],
2844                    "isError": true,
2845                });
2846                let resp = if rpc_req.id.is_none() {
2847                    serde_json::json!({})
2848                } else {
2849                    crate::ai::mcp::json_rpc_result_pub(rpc_req.id.clone(), result_json)
2850                };
2851                let status = if rpc_req.id.is_none() {
2852                    StatusCode::ACCEPTED
2853                } else {
2854                    StatusCode::OK
2855                };
2856                return mcp_json_response(status, &resp, session_id_hdr.as_deref());
2857            }
2858        }
2859    }
2860
2861    // --- Dispatch to the compiled MCP gateway ---
2862    let result = mcp
2863        .handle_request(&rpc_req, session_id_hdr.as_deref(), &consumer)
2864        .await;
2865
2866    // --- AuthZ filtering for tools/list (DW-087) ---
2867    // Filter the tools list to only show tools the caller is allowed
2868    // to invoke. The filtering happens here (dataplane) because the
2869    // `ai` domain may not import `security`.
2870    if rpc_req.method == "tools/list" {
2871        if let Some(resp) = &result.response {
2872            if let Some(tools) = resp.get("result").and_then(|r| r.get("tools")) {
2873                if let Some(tools_arr) = tools.as_array() {
2874                    let mut filtered = Vec::new();
2875                    for tool in tools_arr {
2876                        let name = tool.get("name").and_then(|n| n.as_str()).unwrap_or("");
2877                        // DW-113: per-agent tool allowlist filter. A
2878                        // consumer with no allowlist entry sees all
2879                        // tools (no restriction).
2880                        if !mcp.consumer_allowed_tool(&consumer, name) {
2881                            continue;
2882                        }
2883                        let allowed = match mcp.tool_authz(name) {
2884                            None => true,
2885                            Some(authz) => {
2886                                let chain = crate::security::authz::AuthzChain {
2887                                    consumer: None,
2888                                    route: None,
2889                                    service: None,
2890                                    listener: None,
2891                                    global: Some(authz),
2892                                };
2893                                let ctx = crate::security::authz::AuthzContext {
2894                                    identity: identity.as_ref(),
2895                                    consumer_groups: identity
2896                                        .as_ref()
2897                                        .map(|i| i.groups.as_slice())
2898                                        .unwrap_or(&[]),
2899                                    peer_ip: peer,
2900                                    effective_ip: peer,
2901                                    geoip: None,
2902                                };
2903                                crate::security::authz::authorize(&chain, &ctx)
2904                                    == crate::security::authz::Decision::Allow
2905                            }
2906                        };
2907                        if allowed {
2908                            filtered.push(tool.clone());
2909                        }
2910                    }
2911                    // Rebuild the response with filtered tools.
2912                    // We need to return a modified result — but since
2913                    // `result` is immutable, we'll handle this by
2914                    // post-processing the response below.
2915                    // Store the filtered list for response building.
2916                    // Actually, let's just rebuild the response here.
2917                    let filtered_resp = if rpc_req.id.is_none() {
2918                        serde_json::json!({})
2919                    } else {
2920                        crate::ai::mcp::json_rpc_result_pub(
2921                            rpc_req.id.clone(),
2922                            serde_json::json!({
2923                                "tools": filtered,
2924                                "nextCursor": null,
2925                            }),
2926                        )
2927                    };
2928                    let session_id_for_hdr = result.session_id.clone();
2929                    return mcp_json_response(
2930                        StatusCode::OK,
2931                        &filtered_resp,
2932                        session_id_for_hdr.as_deref(),
2933                    );
2934                }
2935            }
2936        }
2937    }
2938
2939    // --- Session lifecycle (state store) ---
2940    if result.session_initialized {
2941        if let Some(sid) = &result.session_id {
2942            if let Some(store) = &state_store {
2943                // Enforce max concurrent sessions.
2944                if let Ok(count) = store.count_active_mcp_sessions() {
2945                    if count >= mcp.sessions_max_concurrent {
2946                        // Reject: too many sessions.
2947                        let resp = crate::ai::mcp::json_rpc_error_pub(
2948                            rpc_req.id.clone(),
2949                            -32001,
2950                            "too many concurrent sessions",
2951                        );
2952                        return mcp_json_response(StatusCode::SERVICE_UNAVAILABLE, &resp, None);
2953                    }
2954                }
2955                let client_info = rpc_req
2956                    .params
2957                    .as_ref()
2958                    .and_then(|p| p.get("clientInfo"))
2959                    .map(|v| v.to_string());
2960                let _ = store.create_mcp_session(
2961                    sid,
2962                    &consumer,
2963                    mcp.sessions_ttl_secs,
2964                    client_info.as_deref(),
2965                );
2966            }
2967            // Record the session-initialized metric (DW-087). Counted
2968            // whether or not a state store is attached (the session is
2969            // created either way; without a store it is stateless).
2970            dp.obs.record_mcp_session("initialized");
2971        }
2972    }
2973    if result.session_closed {
2974        if let Some(sid) = &result.session_id {
2975            if let Some(store) = &state_store {
2976                let _ = store.delete_mcp_session(sid);
2977            }
2978            // Record the session-closed metric (DW-087).
2979            dp.obs.record_mcp_session("closed");
2980        }
2981    }
2982
2983    // --- Analytics and metrics (tool calls) ---
2984    if let Some(outcome) = &result.tool_call {
2985        // Record metrics (DW-087): tool call count by status and
2986        // duration. The duration from the authz-check start through
2987        // the upstream response is used; when no timer was started
2988        // (e.g. a tool-not-found before the dispatch), the outcome's
2989        // own duration is used.
2990        let dur_secs = if let Some(started) = mcp_tool_started {
2991            started.elapsed().as_secs_f64()
2992        } else {
2993            outcome.duration_ms / 1000.0
2994        };
2995        dp.obs
2996            .record_mcp_tool_call(&outcome.tool_name, &outcome.status);
2997        dp.obs
2998            .record_mcp_tool_duration(&outcome.tool_name, dur_secs);
2999        if let Some(analytics) = dp.analytics() {
3000            let now_ms = std::time::SystemTime::now()
3001                .duration_since(std::time::UNIX_EPOCH)
3002                .map(|d| d.as_millis() as i64)
3003                .unwrap_or(0);
3004            analytics.offer_mcp_tool_call(crate::analytics::McpToolCallRecord {
3005                ts_ms: now_ms,
3006                request_id: rid.to_string(),
3007                session_id: result.session_id.clone().unwrap_or_default(),
3008                consumer: consumer.clone(),
3009                consumer_type: identity
3010                    .as_ref()
3011                    .map(|i| consumer_type_str(i.consumer_type))
3012                    .unwrap_or("user")
3013                    .to_string(),
3014                tool_name: outcome.tool_name.clone(),
3015                allowed: outcome.allowed,
3016                duration_ms: outcome.duration_ms,
3017                error_code: outcome.error_code.clone(),
3018                status: outcome.status.clone(),
3019            });
3020        }
3021    }
3022
3023    // --- Build the response ---
3024    let session_id_for_hdr = result.session_id.clone();
3025    // AI-08 (#197): inspect MCP tool-call outputs through guardrails.
3026    // When the method is tools/call, the result.content[].text fields
3027    // are checked against response-phase guardrail rules (banned,
3028    // PII). Banned content with a block action returns a JSON-RPC
3029    // error; PII content is redacted in place.
3030    let resp_json = if let Some(mut resp) = result.response {
3031        if rpc_req.method == "tools/call" {
3032            // AI-08 (#197): collect guardrail actions for tool outputs.
3033            let guardrails = dp.ai_guardrails();
3034            let mut block_error: Option<String> = None;
3035            if let Some(result_obj) = resp.get_mut("result").and_then(|r| r.as_object_mut()) {
3036                if let Some(content) = result_obj.get_mut("content").and_then(|c| c.as_array_mut())
3037                {
3038                    for item in content.iter_mut() {
3039                        if item.get("type").and_then(|t| t.as_str()) != Some("text") {
3040                            continue;
3041                        }
3042                        let Some(text_val) = item.get_mut("text") else {
3043                            continue;
3044                        };
3045                        let Some(text_str) = text_val.as_str() else {
3046                            continue;
3047                        };
3048                        let original = text_str.to_string();
3049                        match guardrails.check_text(&original) {
3050                            crate::ai::guardrails::GuardrailResult::Block { reason, .. } => {
3051                                *text_val = serde_json::Value::String(format!(
3052                                    "[blocked by guardrail: {reason}]"
3053                                ));
3054                                block_error = Some(reason);
3055                            }
3056                            crate::ai::guardrails::GuardrailResult::Redact {
3057                                redacted_prompt,
3058                                ..
3059                            } => {
3060                                *text_val = serde_json::Value::String(redacted_prompt);
3061                            }
3062                            crate::ai::guardrails::GuardrailResult::Allow => {}
3063                        }
3064                    }
3065                }
3066            }
3067            if let Some(reason) = block_error {
3068                if let Some(obj) = resp.as_object_mut() {
3069                    obj.insert(
3070                        "error".into(),
3071                        serde_json::json!({
3072                            "code": -32001,
3073                            "message": "tool output blocked by guardrail",
3074                            "data": {"reason": reason}
3075                        }),
3076                    );
3077                }
3078            }
3079        }
3080        Some(resp)
3081    } else {
3082        None
3083    };
3084    match resp_json {
3085        Some(resp_json) => {
3086            mcp_json_response(StatusCode::OK, &resp_json, session_id_for_hdr.as_deref())
3087        }
3088        None => {
3089            // Notification: no response body, but still 202 Accepted.
3090            mcp_json_response(
3091                StatusCode::ACCEPTED,
3092                &serde_json::json!({}),
3093                session_id_for_hdr.as_deref(),
3094            )
3095        }
3096    }
3097}
3098
3099/// The analytics string for a consumer type (DW-113): `"user"` or
3100/// `"agent"`. Used by the MCP and AI spend analytics record builders.
3101pub(super) fn consumer_type_str(t: ConsumerType) -> &'static str {
3102    match t {
3103        ConsumerType::User => "user",
3104        ConsumerType::Agent => "agent",
3105    }
3106}
3107
3108/// Build a JSON response for the MCP endpoint (DW-087).
3109fn mcp_json_response(
3110    status: StatusCode,
3111    body: &serde_json::Value,
3112    session_id: Option<&str>,
3113) -> Response<ProxyBody> {
3114    let mut builder = Response::builder()
3115        .status(status)
3116        .header(hyper::header::CONTENT_TYPE, "application/json");
3117    if let Some(sid) = session_id {
3118        builder = builder.header(&MCP_SESSION_ID_HDR, sid);
3119    }
3120    builder
3121        .body(ProxyBody::Full(Full::new(Bytes::from(body.to_string()))))
3122        .expect("static response parts")
3123}
3124
3125/// Refresh the rate-limiter observation gauges from the CURRENT
3126/// engine at scrape time (#132) — the same snapshot model as
3127/// [`refresh_observation_gauges`]: the walk lives on the dataplane
3128/// (the extensions-side engine must stay free of observability
3129/// imports), the hot check path stays pure atomics with zero metrics
3130/// coupling, and the observability side only records what it is
3131/// handed. Both gauges are aggregate and unlabeled — cardinality is
3132/// never per key. The eviction figure resets when a reload rebuilds
3133/// the engine (documented family caveat).
3134pub fn refresh_rate_limiter_gauges(engine: &RateLimitEngine, obs: &Observability) {
3135    obs.set_rate_limiter_evictions(engine.evictions() as i64);
3136    obs.set_rate_limiter_live_keys(engine.live_keys() as i64);
3137}
3138
3139/// Refresh the quota observation gauges at scrape time (DW-033): the
3140/// current-window usage and the configured cap of every
3141/// quota-configured consumer's every budget, read from the state
3142/// store (the same `current_usage` walk the admin `/quotas/usage`
3143/// endpoint and the near-limit trigger use). Series cardinality is
3144/// config-bounded (quota consumers x the closed daily/monthly set).
3145/// A consumer REMOVED from the config by a reload keeps its last
3146/// series values until restart — the accepted staleness trade of the
3147/// scrape-time snapshot model (the rate-limiter gauges carry the same
3148/// caveat); the used/limit ratio of a stale pair stays correct
3149/// because both series freeze together. No store (quotas inert) or a
3150/// store error skips the refresh without failing the scrape.
3151pub fn refresh_quota_gauges(dp: &DataPlane, obs: &Observability) {
3152    let Some(store) = dp.state_store() else {
3153        return;
3154    };
3155    let current = dp.current();
3156    let gateway = current.snapshot.gateway();
3157    let now_epoch_s = unix_now_secs();
3158    for c in &gateway.consumers {
3159        let Some(quotas) = &c.quotas else {
3160            continue;
3161        };
3162        let Ok(Some(record)) = store.lookup_consumer(&c.name) else {
3163            continue;
3164        };
3165        for u in crate::state::quotas::current_usage(&store, record.id, quotas, now_epoch_s) {
3166            obs.set_quota_used(&c.name, u.budget.as_str(), u.used as i64);
3167            obs.set_quota_limit(&c.name, u.budget.as_str(), u.limit as i64);
3168        }
3169    }
3170}
3171
3172/// Wall-clock seconds since the Unix epoch (the quota windows' time
3173/// domain; clamped at 0 for a pre-epoch clock, which simply pins every
3174/// window to the epoch day).
3175fn unix_now_secs() -> i64 {
3176    std::time::SystemTime::now()
3177        .duration_since(std::time::UNIX_EPOCH)
3178        .map(|d| d.as_secs() as i64)
3179        .unwrap_or(0)
3180}
3181
3182/// One-shot latches for the two quota wiring gaps (DW-033): both are
3183/// deployment mistakes, not per-request events — the first occurrence
3184/// warns loudly, the rest stay silent so a misconfigured gateway does
3185/// not log-storm under load.
3186static QUOTA_STORE_MISSING_WARNED: std::sync::atomic::AtomicBool =
3187    std::sync::atomic::AtomicBool::new(false);
3188static QUOTA_CONSUMER_UNSYNCED_WARNED: std::sync::atomic::AtomicBool =
3189    std::sync::atomic::AtomicBool::new(false);
3190
3191fn warn_quota_store_missing(consumer: &str) {
3192    if !QUOTA_STORE_MISSING_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
3193        tracing::warn!(
3194            code = "quota_store_missing",
3195            consumer = consumer,
3196            "a consumer declares quotas but no state store is attached (set \
3197             DWARA_STATE_DB and restart): request budgets are NOT enforced"
3198        );
3199    }
3200}
3201
3202fn warn_quota_consumer_unsynced(consumer: &str) {
3203    if !QUOTA_CONSUMER_UNSYNCED_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
3204        tracing::warn!(
3205            code = "quota_consumer_unsynced",
3206            consumer = consumer,
3207            "a quota-configured consumer has no state-store row (consumer sync \
3208             runs at startup): its request budgets are NOT enforced until \
3209             the store is synced"
3210        );
3211    }
3212}
3213
3214/// Compile one generation's per-route SLO targets (DW-052): config
3215/// percentages to the plain fractions observability consumes (that
3216/// domain takes primitives only — it depends on nothing).
3217fn compile_route_slos(
3218    snapshot: &crate::snapshot::Snapshot,
3219) -> Vec<(String, observability::SloTargets)> {
3220    snapshot
3221        .gateway()
3222        .routes
3223        .iter()
3224        .filter_map(|r| {
3225            r.slo.as_ref().map(|s| {
3226                (
3227                    r.name.clone(),
3228                    observability::SloTargets {
3229                        availability: s.availability / 100.0,
3230                        latency_threshold_ms: s.latency_ms,
3231                        latency_target: s.latency_target.unwrap_or(99.0) / 100.0,
3232                    },
3233                )
3234            })
3235        })
3236        .collect()
3237}
3238
3239/// Compile one generation's webhook targets (DW-044). Validation
3240/// already resolved every secret reference at publish; this re-resolves
3241/// per build (the same validate-then-resolve backstop as the
3242/// authenticator's credentials): a target whose reference broke between
3243/// validate and build is skipped with a loud error — never delivered
3244/// with placeholder bytes, never fatal to the generation.
3245fn compile_webhook_targets(snapshot: &Snapshot) -> Vec<crate::events::webhook::WebhookTarget> {
3246    // SEC-13: build the SSRF egress filter from the gateway config.
3247    let ssrf_filter = snapshot
3248        .gateway()
3249        .ssrf_filter
3250        .as_ref()
3251        .map(crate::config::ssrf::SsrfFilter::from_config)
3252        .unwrap_or_else(crate::config::ssrf::SsrfFilter::disabled);
3253    snapshot
3254        .gateway()
3255        .webhooks
3256        .iter()
3257        .filter_map(|cfg| {
3258            match crate::events::webhook::WebhookTarget::compile(cfg, ssrf_filter.clone()) {
3259                Ok(target) => Some(target),
3260                Err(error) => {
3261                    tracing::error!(
3262                        code = "webhook_target_unusable",
3263                        "webhook target skipped for this generation (fail closed): {error}"
3264                    );
3265                    None
3266                }
3267            }
3268        })
3269        .collect()
3270}
3271
3272/// The response for UNROUTED traffic (#123): a request whose path
3273/// resolved to no route (no path match, a dangling route index, or a
3274/// non-path criteria miss) is still subject to the LISTENER- and
3275/// GLOBAL-attached policies before its 404 is answered — the documented
3276/// gap that unrouted 404 floods bypassed rate limiting entirely. Only
3277/// those two links apply: the consumer, route, and service links are
3278/// unknowable before routing, and the documented request-path order
3279/// places authentication (and therefore identity) after route
3280/// resolution. A denied request answers 429 exactly like routed traffic
3281/// (`Retry-After` + the binding rule's `X-RateLimit-*` headers); an
3282/// admitted-but-unrouted request carries the rate headers on its 404
3283/// (the same "only when a policy actually matched" rule as routed
3284/// responses). The reserved paths never reach here. The `route` key
3285/// component of the rate context is the empty string (see
3286/// `RateLimitKeyContext::route`).
3287async fn unrouted_response(
3288    dp: &DataPlane,
3289    gateway: &Gateway,
3290    listener_cfg: Option<&crate::config::Listener>,
3291    peer: IpAddr,
3292    rid: &str,
3293    rec: &mut AccessRecord,
3294) -> Response<ProxyBody> {
3295    let listener_policies: &[String] = listener_cfg.map(|l| l.policies.as_slice()).unwrap_or(&[]);
3296    let global_policies: &[String] = &gateway.global_policies;
3297    if listener_policies.is_empty() && global_policies.is_empty() {
3298        return simple(StatusCode::NOT_FOUND, "no_route", "no route", rid);
3299    }
3300    let engine = dp.rate_limits.load_full();
3301    if engine.is_empty() {
3302        return simple(StatusCode::NOT_FOUND, "no_route", "no route", rid);
3303    }
3304    let ctx = crate::extensions::rate_limiter::RateLimitKeyContext {
3305        peer,
3306        consumer: None,
3307        route: "",
3308    };
3309    // Dry-run bundles (DW-041) observe here exactly as on routed traffic
3310    // (route label "unrouted"); live bundles alone decide the 429.
3311    // DW-089: the adaptive factor scales each policy's per-request cost
3312    // at check time (the controller lives on the current generation).
3313    let adaptive = Arc::clone(dp.current().adaptive());
3314    let evaluation = engine
3315        .evaluate(
3316            &ctx,
3317            &[],
3318            &[],
3319            &[],
3320            listener_policies,
3321            global_policies,
3322            |name| adaptive.factor_for(name),
3323        )
3324        .await;
3325    if let Some(crate::extensions::rate_limiter::RateLimitOutcome::Denied {
3326        retry_after_s, ..
3327    }) = evaluation.dry_denied
3328    {
3329        dp.obs.record_policy_dry_run("rate_limit", "unrouted");
3330        dp.obs.emit_policy_dry_run(
3331            "rate_limit",
3332            429,
3333            "unrouted",
3334            None,
3335            rid,
3336            &format!("rate limit would deny (retry after {retry_after_s}s)"),
3337        );
3338    }
3339    match evaluation.outcome {
3340        crate::extensions::rate_limiter::RateLimitOutcome::Denied {
3341            limit,
3342            remaining,
3343            reset_epoch_s,
3344            retry_after_s,
3345        } => {
3346            rec.rate_limited = true;
3347            dp.obs.record_rate_limited("unrouted");
3348            rate_limited(
3349                u64::from(limit),
3350                u64::from(remaining),
3351                reset_epoch_s,
3352                retry_after_s,
3353                rid,
3354            )
3355        }
3356        crate::extensions::rate_limiter::RateLimitOutcome::Allowed {
3357            limit,
3358            remaining,
3359            reset_epoch_s,
3360        } => {
3361            let mut resp = simple(StatusCode::NOT_FOUND, "no_route", "no route", rid);
3362            apply_rate_headers(resp.headers_mut(), limit, remaining, reset_epoch_s);
3363            resp
3364        }
3365        crate::extensions::rate_limiter::RateLimitOutcome::NotLimited => {
3366            simple(StatusCode::NOT_FOUND, "no_route", "no route", rid)
3367        }
3368    }
3369}
3370
3371/// Handle one request against the current generation. Never panics; every
3372/// failure path is a classified response. Generic over the request body so
3373/// tests and alternative frontends can drive it with any streaming body.
3374/// Takes the dataplane as an `Arc` reference: the response cache's
3375/// stale-while-revalidate path (DW-037) spawns background revalidations
3376/// that need the dataplane to outlive the request.
3377///
3378/// Observability wrapper (DW-021): resolves the request ID (valid inbound
3379/// `X-Request-Id` respected, else generated), opens the root `request`
3380/// span, tracks the active-requests gauge, and — on completion — records
3381/// the request counter/latency histogram, echoes `X-Request-Id`, and
3382/// emits the (sampled) access-log line. Reserved paths (`/healthz`,
3383/// `/readyz`, `/metrics`) count under the "unrouted" route label like
3384/// 404s (they are not routes).
3385pub async fn handle<B>(dp: &Arc<DataPlane>, peer: IpAddr, req: Request<B>) -> Response<ProxyBody>
3386where
3387    B: hyper::body::Body<Data = Bytes> + Send + 'static,
3388    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
3389{
3390    let started = std::time::Instant::now();
3391    let obs = &dp.obs;
3392    // Path WITHOUT the query string everywhere it is recorded
3393    // (redaction: query strings carry tokens).
3394    let path = req.uri().path().to_string();
3395    let method = req.method().as_str().to_string();
3396    let listener = req
3397        .extensions()
3398        .get::<ListenerLabel>()
3399        .map(|l| l.0.to_string())
3400        .unwrap_or_else(|| "unknown".to_string());
3401    let request_id = observability::resolve_request_id(req.headers());
3402    let root = tracing::info_span!(
3403        "request",
3404        request_id = %request_id,
3405        method = %method,
3406        path = %path,
3407        listener = %listener,
3408        consumer = tracing::field::Empty,
3409        route = tracing::field::Empty
3410    );
3411    let mut rec = AccessRecord::new(request_id.clone(), method, path, listener);
3412    // Correlation ID (DW-093): resolve from X-Correlation-Id (falls
3413    // back to the request id) and store on the record so the analytics
3414    // raw table carries it for the journey/funnel query. The response
3415    // echoes it below.
3416    rec.correlation_id = observability::resolve_correlation_id(req.headers(), &request_id);
3417    // Custom analytics dimensions (DW-043/DW-093): header-sourced tags
3418    // read HERE, while the request head is still in hand (the
3419    // completion seam has only the record). Claim-sourced dimensions
3420    // are extracted after authn resolves Identity (in handle_inner);
3421    // body-path dimensions are extracted when the body is buffered
3422    // (in handle_inner, after the body is collected for retries/
3423    // transforms/validation). The dimension list is read from the
3424    // CURRENT generation, so reloads add/rename dimensions live.
3425    if let Some(dims) = dp.current().snapshot.gateway().analytics.as_ref() {
3426        for dim in &dims.dimensions {
3427            if dim.effective_source() == crate::config::DimensionSource::Header {
3428                if let Some(header_name) = &dim.header {
3429                    if let Some(v) = req.headers().get(header_name.as_str()) {
3430                        // First value of a repeated header wins; non-UTF-8
3431                        // and over-128-byte values are skipped (bounded
3432                        // rollup cardinality per value, documented in the
3433                        // config).
3434                        if let Ok(s) = v.to_str() {
3435                            if !s.is_empty() && s.len() <= 128 {
3436                                rec.custom.push((dim.name.clone(), s.to_string()));
3437                            }
3438                        }
3439                    }
3440                }
3441            }
3442        }
3443    }
3444    obs.active_requests().inc();
3445    // PERF-12 (#209): record the declared request body size for traffic
3446    // accounting. Chunked/streamed request bodies (no Content-Length)
3447    // record 0 — counting them would require wrapping the upstream-
3448    // forwarded body; the response-side count is the primary signal.
3449    rec.bytes_in = req
3450        .headers()
3451        .get(hyper::header::CONTENT_LENGTH)
3452        .and_then(|v| v.to_str().ok())
3453        .and_then(|s| s.trim().parse::<u64>().ok())
3454        .unwrap_or(0);
3455    let bytes_out = Arc::clone(&rec.bytes_out);
3456    let mut resp = handle_inner(dp, peer, req, &request_id, &mut rec, &root)
3457        .instrument(root.clone())
3458        .await;
3459    obs.active_requests().dec();
3460    let status = resp.status().as_u16();
3461    rec.status = status;
3462    rec.duration_ms = started.elapsed().as_secs_f64() * 1000.0;
3463    obs.record_request(&rec.route, &rec.listener, status, started.elapsed());
3464    obs.record_slo(&rec.route, status, rec.duration_ms);
3465    observability::stamp_request_id(resp.headers_mut(), &request_id);
3466    observability::stamp_correlation_id(resp.headers_mut(), &rec.correlation_id);
3467    // PERF-12 (#209): defer analytics recording and access-log emission
3468    // to when the response body stream completes, so bytes_out (counted
3469    // frame-by-frame by CountingBody) is included. The completion
3470    // callback fires exactly once on end-of-stream or error.
3471    let dp_clone = Arc::clone(dp);
3472    let should_log = obs.should_log_access(status);
3473    let on_complete = move || {
3474        dp_clone.record_analytics(&rec);
3475        dp_clone.record_stream_offer(&rec);
3476        if should_log {
3477            observability::emit_access(&rec);
3478        }
3479    };
3480    let body = std::mem::replace(resp.body_mut(), ProxyBody::Full(Full::new(Bytes::new())));
3481    // PERF-12 (#209): hyper may skip polling the body entirely when it
3482    // knows the body is empty (size_hint=0, is_end_stream=true). In that
3483    // case CountingBody::poll_frame never runs, so on_complete would
3484    // never fire. Fire it synchronously here when the body is already
3485    // complete; otherwise wrap it for byte-counted streaming completion.
3486    if body.is_end_stream() {
3487        on_complete();
3488        *resp.body_mut() = body;
3489    } else {
3490        *resp.body_mut() = ProxyBody::Counted(Box::new(CountingBody::new(
3491            body,
3492            bytes_out,
3493            Box::new(on_complete),
3494        )));
3495    }
3496    resp
3497}
3498
3499async fn handle_inner<B>(
3500    dp: &Arc<DataPlane>,
3501    peer: IpAddr,
3502    req: Request<B>,
3503    rid: &str,
3504    rec: &mut AccessRecord,
3505    root: &tracing::Span,
3506) -> Response<ProxyBody>
3507where
3508    B: hyper::body::Body<Data = Bytes> + Send + 'static,
3509    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
3510{
3511    let gen = dp.current();
3512    let gateway = gen.snapshot.gateway();
3513    let path = req.uri().path().to_string();
3514
3515    // Framing ambiguity rejection (DW-023): hyper's HTTP/1 parser gives
3516    // Transfer-Encoding precedence over Content-Length (RFC 7230 3.3.3
3517    // "careful" framing), which is desync-SAFE behind this gateway because
3518    // every forwarded request is rebuilt from parsed parts — but the
3519    // AMBIGUOUS header pair is itself the smuggling primitive, so the
3520    // documented policy is outright rejection: a request carrying both
3521    // headers never reaches a route or an upstream. (Response-side and
3522    // h2 paths are unaffected; hyper never synthesizes the pair.)
3523    //
3524    // UNREACHABLE BY DESIGN — belt-and-suspenders insurance: the
3525    // pre-parse sniff (hardening::guard_connection) rejects the pair on
3526    // the first head of every connection, hyper's parser refuses it on
3527    // any later keep-alive head, and even a surviving pair would lose
3528    // its Content-Length to hyper's TE-preference normalization before
3529    // this handler runs. The check stays so a future parser change that
3530    // alters any of those layers (e.g. TE-preference no longer stripping
3531    // the redundant Content-Length) fails CLOSED here instead of quietly
3532    // re-admitting the smuggling primitive to the route layer.
3533    if req.headers().contains_key(hyper::header::CONTENT_LENGTH)
3534        && req.headers().contains_key(hyper::header::TRANSFER_ENCODING)
3535    {
3536        tracing::warn!(
3537            code = "request_framing_ambiguous",
3538            request_id = %rid,
3539            "request carries both Content-Length and Transfer-Encoding; rejecting"
3540        );
3541        return simple(
3542            StatusCode::BAD_REQUEST,
3543            "ambiguous_framing",
3544            "request declares both Content-Length and Transfer-Encoding",
3545            rid,
3546        );
3547    }
3548
3549    // Reserved gateway paths first: they shadow any configured route.
3550    if let Some(resp) = reserved_path(dp, &path, rid) {
3551        return resp;
3552    }
3553
3554    // USA-12 (#233): the developer portal, served at its configured
3555    // path (default /portal). Like the reserved paths, it shadows any
3556    // configured route.
3557    if let Some(resp) = serve_portal(dp, &path) {
3558        return resp;
3559    }
3560
3561    // DW-087: the MCP JSON-RPC endpoint. Like the reserved paths, it
3562    // shadows any configured route — but unlike them it needs the
3563    // full request (authn, body, session management), so it is
3564    // handled as a dedicated path here, before route resolution.
3565    // Only active when the current generation's `ai.mcp` block
3566    // compiled a `CompiledMcp`; absent = 404 (fall through to route
3567    // resolution, which will 404 on the unknown path).
3568    if let Some(mcp) = gen.ai.as_ref().and_then(|ai| ai.mcp()) {
3569        if path == mcp.path() {
3570            return mcp_handle(dp, peer, req, mcp, rid, rec).await;
3571        }
3572    }
3573
3574    // The listener that accepted this request (#123): its policies and
3575    // authorization apply to every request it accepts. The label rides
3576    // the request extensions from the listener frontend (dwara-bin);
3577    // absent when `handle` is driven directly (tests) — no listener
3578    // config matches, so listener-level rules are transparent, exactly
3579    // like the "unknown" metrics label.
3580    let listener_cfg = req.extensions().get::<ListenerLabel>().and_then(|l| {
3581        gateway
3582            .listeners
3583            .iter()
3584            .find(|li| li.name.as_str() == &*l.0)
3585    });
3586
3587    // Route resolution BEFORE cap admission (DW-016): the request's
3588    // priority class comes from the matched route, so the cap can shed
3589    // route-aware. Two consequences: 404s (no route / criteria miss) never
3590    // consume a cap slot — a deliberate change from DW-015's
3591    // admit-at-entry ordering — and unknown paths cost nothing under
3592    // saturation.
3593    //
3594    // DP-06 (#254): route criteria fall-through. When the path resolves
3595    // to a route but the non-path criteria (host, methods, headers,
3596    // query, cookies, accept) fail, the router continues to the next
3597    // lower-precedence matching route instead of immediately returning
3598    // 404. The candidate list from `find_candidates` is in precedence
3599    // order: exact, then regex by specificity, then prefix by length.
3600    let candidates = gen.snapshot.route_table().find_candidates(&path);
3601    let mut idx = None;
3602    // PERF: remember the WINNER's position instead of cloning its params
3603    // Vec (a clone of (String, String) pairs per request); the params are
3604    // moved out of `candidates` below, once, for the winner only.
3605    let mut winner = None;
3606    for (pos, (cand_idx, _)) in candidates.iter().enumerate() {
3607        let Some(route) = gateway.routes.get(*cand_idx) else {
3608            continue;
3609        };
3610        if route_applies(
3611            &route.r#match,
3612            gen.snapshot.route_table().accept_media_type(*cand_idx),
3613            &req,
3614        ) {
3615            idx = Some(*cand_idx);
3616            winner = Some(pos);
3617            break;
3618        }
3619    }
3620    let Some(idx) = idx else {
3621        return unrouted_response(dp, gateway, listener_cfg, peer, rid, rec).await;
3622    };
3623    let params = candidates
3624        .into_iter()
3625        .nth(winner.expect("winner position recorded when idx is Some"))
3626        .map(|(_, params)| params)
3627        .unwrap_or_default();
3628    let Some(route) = gateway.routes.get(idx) else {
3629        return unrouted_response(dp, gateway, listener_cfg, peer, rid, rec).await;
3630    };
3631    rec.route = route.name.clone();
3632    root.record("route", route.name.as_str());
3633
3634    // Maintenance mode (DW-041): the earliest post-resolution
3635    // short-circuit — see the module docs for why it precedes the route
3636    // limits and why CORS preflights are the one exemption (they keep
3637    // their 204 so browser clients can read the 503 envelope on the
3638    // actual request, which carries the policy's CORS headers).
3639    if let Some(maintenance) = &route.maintenance {
3640        let preflight_exempt = route.cors.is_some()
3641            && crate::dataplane::cors::is_preflight(req.method(), req.headers());
3642        if !preflight_exempt {
3643            tracing::warn!(
3644                code = "maintenance",
3645                request_id = %rid,
3646                route = %route.name,
3647                retry_after_secs = maintenance.retry_after(),
3648                "route is in maintenance; request refused with 503"
3649            );
3650            let origin = req.headers().get(&ORIGIN).cloned();
3651            return maintenance_response(route, idx, &gen, origin.as_ref(), maintenance, rid);
3652        }
3653    }
3654
3655    // Per-route METHOD ALLOWLIST (DW-030): a non-empty `methods` list on
3656    // the matched route answers 405 + Allow for every method not in it.
3657    // Placement (frozen, mirrors the DW-041 maintenance argument): the
3658    // allowlist is a statement about the ROUTE, not the request's shape,
3659    // so it precedes the route limits (an oversized DELETE on a GET-only
3660    // route is told "DELETE is not allowed" — fixing the body would
3661    // still leave it refused) and authentication (an unauthenticated
3662    // wrong-method request is not worth auth work). A CORS PREFLIGHT is
3663    // exempt exactly like the maintenance 503: the preflight is a
3664    // Fetch-protocol handshake about the GATEWAY's cross-origin policy,
3665    // not a resource request, and failing it would surface in the
3666    // browser as an opaque CORS error instead of the gateway's CORS
3667    // answer. Matching is case-insensitive (the `match.methods`
3668    // comparison); HEAD is never implicitly granted by GET.
3669    if !route.methods.is_empty() {
3670        let preflight_exempt = route.cors.is_some()
3671            && crate::dataplane::cors::is_preflight(req.method(), req.headers());
3672        let allowed = route
3673            .methods
3674            .iter()
3675            .any(|m| req.method().as_str().eq_ignore_ascii_case(m.trim()));
3676        if !allowed && !preflight_exempt {
3677            tracing::warn!(
3678                code = "method_not_allowed",
3679                request_id = %rid,
3680                route = route.name,
3681                method = %req.method(),
3682                "request method rejected by the route's allowlist"
3683            );
3684            let mut resp = method_not_allowed(&route.methods, rid);
3685            // CORS actual-response decoration on CORS-configured routes
3686            // (the maintenance-503 precedent): a browser must be able to
3687            // READ the 405 envelope cross-origin, or the gateway's answer
3688            // surfaces as an opaque CORS error instead.
3689            if let (Some(cors), Some(origins)) = (
3690                route.cors.as_ref(),
3691                gen.snapshot.route_table().cors_origins(idx),
3692            ) {
3693                let origin = req.headers().get(&ORIGIN).cloned();
3694                crate::dataplane::cors::decorate_actual(
3695                    cors,
3696                    origins,
3697                    origin.as_ref(),
3698                    resp.headers_mut(),
3699                );
3700            }
3701            stamp_security_headers(
3702                &mut resp,
3703                gen.snapshot.route_table().effective_security_headers(idx),
3704            );
3705            return resp;
3706        }
3707    }
3708
3709    // WAF-lite heuristic filtering (DW-051): inspects the request path,
3710    // query string, selected headers, and body (when JSON or
3711    // form-urlencoded) for common SQLi/XSS/path-traversal signatures.
3712    // Placement: after the route method allowlist, before the route
3713    // limits — a content filter that rejects malicious requests before
3714    // any resource is spent on auth or rate limiting. Dry-run mode
3715    // (DW-041 synergy) evaluates and logs matches without blocking.
3716    if let Some(waf_cfg) = &route.waf {
3717        if let Some(waf_gen) = crate::dataplane::waf::WafGeneration::from_config(waf_cfg) {
3718            // Head inspection (path, query, headers) — synchronous.
3719            let head_match =
3720                waf_gen.inspect_head(req.uri().path(), req.uri().query(), req.headers());
3721            if let Some(m) = &head_match {
3722                let filter_str = m.filter.as_str();
3723                if waf_gen.dry_run() {
3724                    dp.obs.record_waf(&route.name, filter_str, "logged");
3725                    tracing::warn!(
3726                        code = "waf_logged",
3727                        request_id = %rid,
3728                        route = %route.name,
3729                        filter = filter_str,
3730                        target = m.target.as_str(),
3731                        pattern = %m.pattern,
3732                        "WAF dry-run match (request allowed)"
3733                    );
3734                } else {
3735                    dp.obs.record_waf(&route.name, filter_str, "blocked");
3736                    tracing::warn!(
3737                        code = "waf_blocked",
3738                        request_id = %rid,
3739                        route = %route.name,
3740                        filter = filter_str,
3741                        target = m.target.as_str(),
3742                        "request blocked by WAF"
3743                    );
3744                    let mut resp = simple(
3745                        StatusCode::FORBIDDEN,
3746                        "waf_blocked",
3747                        "request blocked by security filter",
3748                        rid,
3749                    );
3750                    stamp_security_headers(
3751                        &mut resp,
3752                        gen.snapshot.route_table().effective_security_headers(idx),
3753                    );
3754                    return resp;
3755                }
3756            }
3757            // Body inspection: when the content type is JSON or
3758            // form-urlencoded and the body cap is > 0, buffer up to
3759            // max_body_inspect_bytes and inspect. The reconstructed
3760            // body (buffered prefix + any remaining stream) is forwarded
3761            // to the rest of the request path via handle_routed.
3762            if waf_gen.max_body_inspect_bytes() > 0
3763                && crate::dataplane::waf::should_inspect_body(req.headers())
3764            {
3765                let (parts, body) = req.into_parts();
3766                let result = crate::dataplane::waf::inspect_body(body, &waf_gen).await;
3767                if let Some(m) = &result.match_found {
3768                    let filter_str = m.filter.as_str();
3769                    if waf_gen.dry_run() {
3770                        dp.obs.record_waf(&route.name, filter_str, "logged");
3771                        tracing::warn!(
3772                            code = "waf_logged",
3773                            request_id = %rid,
3774                            route = %route.name,
3775                            filter = filter_str,
3776                            target = m.target.as_str(),
3777                            pattern = %m.pattern,
3778                            "WAF dry-run body match (request allowed)"
3779                        );
3780                    } else {
3781                        dp.obs.record_waf(&route.name, filter_str, "blocked");
3782                        tracing::warn!(
3783                            code = "waf_blocked",
3784                            request_id = %rid,
3785                            route = %route.name,
3786                            filter = filter_str,
3787                            target = m.target.as_str(),
3788                            "request blocked by WAF (body)"
3789                        );
3790                        let mut resp = simple(
3791                            StatusCode::FORBIDDEN,
3792                            "waf_blocked",
3793                            "request blocked by security filter",
3794                            rid,
3795                        );
3796                        stamp_security_headers(
3797                            &mut resp,
3798                            gen.snapshot.route_table().effective_security_headers(idx),
3799                        );
3800                        return resp;
3801                    }
3802                }
3803                if head_match.is_none() && result.match_found.is_none() {
3804                    dp.obs.record_waf(&route.name, "all", "passed");
3805                }
3806                let req = Request::from_parts(parts, result.body);
3807                // `listener_cfg` borrows this function's `gen` (via
3808                // `gateway`), so the Arc is cloned into the call rather
3809                // than moved — the borrow must outlive the await.
3810                return handle_routed(
3811                    dp,
3812                    peer,
3813                    req,
3814                    rid,
3815                    rec,
3816                    root,
3817                    Arc::clone(&gen),
3818                    idx,
3819                    params,
3820                    listener_cfg,
3821                )
3822                .await;
3823            }
3824            if head_match.is_none() {
3825                dp.obs.record_waf(&route.name, "all", "passed");
3826            }
3827        }
3828    }
3829
3830    // GraphQL awareness (DW-099): query depth/complexity limits and
3831    // persisted-query enforcement. Runs AFTER the WAF-lite filter and
3832    // BEFORE the route limits -- it is a content-shape filter that
3833    // rejects abusive queries before any resource is spent on auth or
3834    // rate limiting. Feature-gated behind the `graphql` cargo feature;
3835    // the config block is always present (round-trips without the
3836    // feature), but the runtime check compiles only with the feature.
3837    // Only routes with an enabled `graphql` block are inspected.
3838    if let Some(graphql_cfg) = &route.graphql {
3839        if let Some(checker) = crate::dataplane::graphql::GraphQLChecker::from_config(graphql_cfg) {
3840            let (parts, body) = req.into_parts();
3841            let (result, depth, complexity, collected) = checker.check_body(body).await;
3842            let outcome = result.outcome_label();
3843            dp.obs.record_graphql(&route.name, outcome);
3844            dp.obs.set_graphql_depth(&route.name, depth as i64);
3845            dp.obs
3846                .set_graphql_complexity(&route.name, complexity as i64);
3847            if result.is_denied() {
3848                let (code, msg) = match result {
3849                    crate::dataplane::graphql::GraphQLCheckResult::DenyDepth => (
3850                        "graphql_depth_exceeded",
3851                        "GraphQL query depth exceeds the configured limit",
3852                    ),
3853                    crate::dataplane::graphql::GraphQLCheckResult::DenyComplexity => (
3854                        "graphql_complexity_exceeded",
3855                        "GraphQL query complexity exceeds the configured limit",
3856                    ),
3857                    crate::dataplane::graphql::GraphQLCheckResult::DenyPersistedQuery => (
3858                        "graphql_persisted_query_required",
3859                        "GraphQL query hash not found in the persisted-query store",
3860                    ),
3861                    crate::dataplane::graphql::GraphQLCheckResult::Allow => {
3862                        unreachable!("is_denied is false for Allow")
3863                    }
3864                };
3865                tracing::warn!(
3866                    code = code,
3867                    request_id = %rid,
3868                    route = %route.name,
3869                    depth = depth,
3870                    complexity = complexity,
3871                    "request rejected by GraphQL check"
3872                );
3873                let mut resp = simple(StatusCode::BAD_REQUEST, code, msg, rid);
3874                stamp_security_headers(
3875                    &mut resp,
3876                    gen.snapshot.route_table().effective_security_headers(idx),
3877                );
3878                return resp;
3879            }
3880            // The body was consumed by the check; reconstruct it from
3881            // the collected bytes and forward to the rest of the
3882            // request path. The check buffers up to max_body_bytes
3883            // (default 1 MiB), the same bounded-buffering pattern as
3884            // the WAF body inspection (DW-051).
3885            let req = Request::from_parts(
3886                parts,
3887                crate::dataplane::graphql::GraphqlBody::new(collected),
3888            );
3889            return handle_routed(
3890                dp,
3891                peer,
3892                req,
3893                rid,
3894                rec,
3895                root,
3896                Arc::clone(&gen),
3897                idx,
3898                params,
3899                listener_cfg,
3900            )
3901            .await;
3902        }
3903    }
3904
3905    handle_routed(
3906        dp,
3907        peer,
3908        req,
3909        rid,
3910        rec,
3911        root,
3912        Arc::clone(&gen),
3913        idx,
3914        params,
3915        listener_cfg,
3916    )
3917    .await
3918}
3919
3920/// The post-WAF request path (DW-051 split point): everything from the
3921/// route limits onward — route limits, CORS preflight, authn, authz,
3922/// rate limiting, cap admission, the proxy/redirect/respond action, and
3923/// the response decoration tail (masking, transforms, compression,
3924/// versioning, CORS, security headers, rate headers). Generic over the
3925/// body type so it accepts both the original request body `B` and the
3926/// WAF-reconstructed [`crate::dataplane::waf::WafBody`] (body inspection
3927/// may buffer and replay the body — the only dataplane buffering the WAF
3928/// introduces, bounded by `max_body_inspect_bytes`).
3929#[allow(clippy::too_many_arguments)]
3930async fn handle_routed<B>(
3931    dp: &Arc<DataPlane>,
3932    peer: IpAddr,
3933    req: Request<B>,
3934    rid: &str,
3935    rec: &mut AccessRecord,
3936    root: &tracing::Span,
3937    gen: Arc<Generation>,
3938    idx: usize,
3939    params: Vec<(String, String)>,
3940    // PERF: the accepting listener's config, resolved ONCE in the
3941    // caller (`handle_inner`) — this function used to repeat the same
3942    // linear name scan over `gateway.listeners` per request.
3943    listener_cfg: Option<&crate::config::Listener>,
3944) -> Response<ProxyBody>
3945where
3946    B: hyper::body::Body<Data = Bytes> + Send + 'static,
3947    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
3948{
3949    let gateway = gen.snapshot.gateway();
3950    let Some(route) = gateway.routes.get(idx) else {
3951        return unrouted_response(dp, gateway, listener_cfg, peer, rid, rec).await;
3952    };
3953    let mut req = req;
3954
3955    // Plugin chain (DW-157): the earliest post-resolution phase —
3956    // `request_headers` runs BEFORE authn (the documented contract, so
3957    // authn sees plugin-modified headers). Built only when the route
3958    // references plugins: a plugin-less route gets `None` here before
3959    // any allocation and skips every phase call below unchanged (the
3960    // fast path this pipeline is tuned for). The health gate is
3961    // fail-closed: a Crashed/Disabled/not-loaded plugin (or a native
3962    // name missing from the registry) answers 500 `plugin_unavailable`
3963    // on THIS route only. `RequestPlugins`' Drop runs `on_done` on
3964    // every exit path from here on.
3965    let mut plugins = match dp.build_request_plugins(route, rid) {
3966        Ok(plugins) => plugins,
3967        Err(resp) => {
3968            rec.plugin_short_circuit = true;
3969            return *resp;
3970        }
3971    };
3972    if let Some(p) = plugins.as_mut() {
3973        // The proxy-wasm `:path` convention: the full request target
3974        // (path + query). See plugin_dispatch's header conventions.
3975        let method = req.method().clone();
3976        let path_and_query = req
3977            .uri()
3978            .path_and_query()
3979            .map(|pq| pq.as_str().to_string())
3980            .unwrap_or_else(|| req.uri().path().to_string());
3981        match p
3982            .request_headers_phase(
3983                &method,
3984                &path_and_query,
3985                req.headers_mut(),
3986                &dp.obs,
3987                rid,
3988                &route.name,
3989            )
3990            .await
3991        {
3992            // The chain's `:path`/`:method`/`:authority` writes ride
3993            // the request extensions to the forward build (the apply
3994            // site in `proxy_request`): the rewrite shapes the
3995            // UPSTREAM request only — everything below (anomaly
3996            // scoring, limits, authn/authz, the cache key, the access
3997            // log) keeps evaluating the ORIGINAL target.
3998            Ok(rewrite) => {
3999                if !rewrite.is_noop() {
4000                    req.extensions_mut().insert(rewrite);
4001                }
4002            }
4003            Err(resp) => {
4004                rec.plugin_short_circuit = true;
4005                return *resp;
4006            }
4007        }
4008    }
4009
4010    // Anomaly scoring (DW-090): lightweight statistical detection of
4011    // abusive request patterns. Runs AFTER the WAF-lite filter (which
4012    // is in the caller, `handle`) and BEFORE the route limits — it is
4013    // an inspection phase like the WAF, rejecting abusive request
4014    // shapes before any resource is spent on auth or rate limiting.
4015    // Inspects the ORIGINAL request (before path rewrite / transforms).
4016    // Dry-run mode (DW-041 synergy) evaluates and logs but does not
4017    // block. The scorer is compiled from the first enabled
4018    // `policies[].anomaly` block (see `compile_anomaly_scorer`).
4019    if let Some(scorer) = gen.anomaly() {
4020        let result = scorer.score(
4021            req.method().as_str(),
4022            req.uri().path(),
4023            req.uri().query(),
4024            req.headers(),
4025            None, // body prefix — not inspected here (no buffering yet)
4026        );
4027        if result.triggered {
4028            if scorer.dry_run() {
4029                dp.obs.record_anomaly(&route.name, "logged");
4030                tracing::warn!(
4031                    code = "anomaly_logged",
4032                    request_id = %rid,
4033                    route = %route.name,
4034                    score = %result.score,
4035                    signals = ?result.signals.iter().map(|(n, v)| (n.as_str(), *v)).collect::<Vec<_>>(),
4036                    "anomaly dry-run match (request allowed)"
4037                );
4038            } else {
4039                dp.obs.record_anomaly(&route.name, "blocked");
4040                tracing::warn!(
4041                    code = "anomaly_blocked",
4042                    request_id = %rid,
4043                    route = %route.name,
4044                    score = %result.score,
4045                    "request blocked by anomaly scoring"
4046                );
4047                let mut resp = simple(
4048                    StatusCode::FORBIDDEN,
4049                    "anomaly_blocked",
4050                    "request blocked by anomaly scoring",
4051                    rid,
4052                );
4053                stamp_security_headers(
4054                    &mut resp,
4055                    gen.snapshot.route_table().effective_security_headers(idx),
4056                );
4057                return resp;
4058            }
4059        } else {
4060            dp.obs.record_anomaly(&route.name, "passed");
4061        }
4062    }
4063    // Route-scoped request limits (DW-027): header caps and a declared
4064    // (`Content-Length`) body cap are enforced immediately after route
4065    // resolution — before CORS preflight handling, authentication, and
4066    // any upstream contact. 431 for headers, 413 for the body, both in
4067    // the JSON error envelope. Monitor mode (DW-041, `limits.dry_run`):
4068    // the violation is logged and counted instead, and the request
4069    // proceeds (the streaming body guard is left unarmed below).
4070    if let Some(limits) = &route.limits {
4071        if let Some(violation) = crate::dataplane::hardening::check_route_limits(
4072            limits,
4073            req.headers(),
4074            req.body().size_hint().exact(),
4075        ) {
4076            let (status, code, msg) = match violation {
4077                crate::dataplane::hardening::RouteLimitViolation::HeaderCount { count, max } => (
4078                    StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE,
4079                    "request_headers_too_large",
4080                    format!("request carries {count} header fields; the route allows {max}"),
4081                ),
4082                crate::dataplane::hardening::RouteLimitViolation::HeaderBytes { bytes, max } => (
4083                    StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE,
4084                    "request_headers_too_large",
4085                    format!("request headers total {bytes} bytes; the route allows {max}"),
4086                ),
4087                crate::dataplane::hardening::RouteLimitViolation::BodyBytes { declared, max } => (
4088                    StatusCode::PAYLOAD_TOO_LARGE,
4089                    "request_body_too_large",
4090                    format!("request body declares {declared} bytes; the route allows {max}"),
4091                ),
4092            };
4093            // Dry-run note: this phase runs before authn, so the log's
4094            // consumer field is always "anonymous" here — it reflects
4095            // evaluation order, not that the traffic was unauthenticated.
4096            if limits.dry_run {
4097                dp.obs.record_policy_dry_run("route_limits", &route.name);
4098                dp.obs.emit_policy_dry_run(
4099                    "route_limits",
4100                    status.as_u16(),
4101                    &route.name,
4102                    None,
4103                    rid,
4104                    &msg,
4105                );
4106            } else {
4107                tracing::warn!(
4108                    code = code,
4109                    request_id = %rid,
4110                    route = %route.name,
4111                    "request rejected by route limits"
4112                );
4113                let mut resp = simple(status, code, &msg, rid);
4114                stamp_security_headers(
4115                    &mut resp,
4116                    gen.snapshot.route_table().effective_security_headers(idx),
4117                );
4118                return resp;
4119            }
4120        }
4121    }
4122
4123    // CORS preflight short-circuit (DW-027): an OPTIONS request with the
4124    // preflight markers is answered by the gateway on CORS-configured
4125    // routes — never forwarded upstream, never subject to authn/authz/
4126    // rate limiting/cap admission (browsers send preflights without
4127    // credentials; gating them on authn would break every credentialed
4128    // route). Routes without a cors block proxy preflights normally.
4129    if let Some(cors) = &route.cors {
4130        if crate::dataplane::cors::is_preflight(req.method(), req.headers()) {
4131            // The origin set is compiled in lockstep with `Route::cors`
4132            // (RouteTable::cors_origins); a None here is unreachable for
4133            // a published snapshot and falls through to a normal proxy.
4134            if let Some(origins) = gen.snapshot.route_table().cors_origins(idx) {
4135                let mut resp =
4136                    crate::dataplane::cors::preflight_response(cors, origins, req.headers())
4137                        .map(ProxyBody::Full);
4138                stamp_security_headers(
4139                    &mut resp,
4140                    gen.snapshot.route_table().effective_security_headers(idx),
4141                );
4142                return resp;
4143            }
4144        }
4145    }
4146
4147    // Authentication (DW-019): after route resolution, before rate
4148    // limiting and cap admission (the rate-limit `credential` selector
4149    // and the shedding priority class both consume the identity). An
4150    // INVALID presented credential is always rejected (401 +
4151    // WWW-Authenticate), even on a route that allows anonymous traffic;
4152    // an anonymous request is rejected only when the route sets
4153    // `auth_required`. Gateway-side failures (JWKS endpoint down) answer
4154    // 500 — the gateway cannot vouch for the caller either way.
4155    let authn = dp.authn.load_full();
4156    // The ambient mTLS family (#124): the accepting TLS listener (when
4157    // it carries a client_ca_file) inserts the VERIFIED client
4158    // certificate into the request extensions; absent on cleartext
4159    // listeners and connections that presented none.
4160    let client_cert = req
4161        .extensions()
4162        .get::<Arc<crate::security::authn::ClientCertificate>>()
4163        .cloned();
4164    // The authn phase span (DW-021): instrumented onto the authenticate
4165    // future so the span covers exactly the phase (no guard is held
4166    // across a poll boundary). The request target (method/path/query)
4167    // rides along for the HMAC family (DW-036), which signs it; the
4168    // body deliberately does not (authn never buffers — the HMAC body
4169    // digest is enforced on the forward path below).
4170    let authn_req = crate::security::authn::AuthnRequest {
4171        method: req.method(),
4172        uri: req.uri(),
4173        headers: req.headers(),
4174        client_cert: client_cert.as_deref(),
4175    };
4176    let identity = match authn
4177        .authenticate(&authn_req)
4178        .instrument(tracing::info_span!("authn"))
4179        .await
4180    {
4181        Ok(id) => id,
4182        Err(AuthError::Invalid(_)) => {
4183            let mut resp = unauthorized(&authn.challenge(), rid);
4184            stamp_security_headers(
4185                &mut resp,
4186                gen.snapshot.route_table().effective_security_headers(idx),
4187            );
4188            return resp;
4189        }
4190        Err(AuthError::Unavailable(msg)) => {
4191            tracing::error!(
4192                code = "authentication_unavailable",
4193                request_id = %rid,
4194                "authentication backend unavailable: {msg}"
4195            );
4196            let mut resp = simple(
4197                StatusCode::INTERNAL_SERVER_ERROR,
4198                "authentication_unavailable",
4199                "authentication unavailable",
4200                rid,
4201            );
4202            stamp_security_headers(
4203                &mut resp,
4204                gen.snapshot.route_table().effective_security_headers(idx),
4205            );
4206            return resp;
4207        }
4208    };
4209    if route.auth_required && identity.is_none() {
4210        let mut resp = unauthorized(&authn.challenge(), rid);
4211        stamp_security_headers(
4212            &mut resp,
4213            gen.snapshot.route_table().effective_security_headers(idx),
4214        );
4215        return resp;
4216    }
4217    if let Some(id) = &identity {
4218        rec.consumer = id.consumer_name.clone();
4219        root.record("consumer", id.consumer_name.as_str());
4220        // Claim-sourced analytics dimensions (DW-093): extract from
4221        // the verified JWT's claims map. Only string-valued claims are
4222        // available (authn flattens arrays and skips non-string/
4223        // non-number claims); absent claims and over-128-byte values
4224        // are skipped for that request. The dimension list is read
4225        // from the current generation (the same list the header
4226        // extraction in `handle` walked).
4227        if let Some(dims) = gateway.analytics.as_ref() {
4228            for dim in &dims.dimensions {
4229                if dim.effective_source() == crate::config::DimensionSource::Claim {
4230                    if let Some(claim_name) = &dim.claim {
4231                        if let Some(v) = id.claims.get(claim_name) {
4232                            if !v.is_empty() && v.len() <= 128 {
4233                                rec.custom.push((dim.name.clone(), v.clone()));
4234                            }
4235                        }
4236                    }
4237                }
4238            }
4239        }
4240    }
4241    // Spoof prevention: any client-supplied X-Consumer-* header is
4242    // stripped here; the trusted identity header is injected on the
4243    // proxied request below.
4244    strip_consumer_headers(req.headers_mut());
4245    let consumer_cfg = identity.as_ref().and_then(|id| {
4246        gateway
4247            .consumers
4248            .iter()
4249            .find(|c| c.name == id.consumer_name)
4250    });
4251    // The route's service: resolved here (before authorization and rate
4252    // limiting) because both consume its attachments (#123 — service
4253    // authorization and service policies). Validation guarantees the
4254    // reference resolves; a miss is a generation tear and simply means
4255    // no service-level rules apply (the proxy send below answers 500 on
4256    // the same tear).
4257    let service = gateway.services.iter().find(|s| s.name == route.service);
4258
4259    // Authorization (DW-020): after authN, before rate limiting. The
4260    // precedence chain is consumer > route > service > listener >
4261    // global; every link has a config attachment (#123):
4262    // `consumers[].authorization`, `routes[].authorization`,
4263    // `services[].authorization`, `listeners[].authorization`, and the
4264    // gateway-level `authorization`. A deny at ANY level wins;
4265    // otherwise the most specific level with rules governs. Denials of
4266    // authenticated (or IP-gated anonymous) requests are 403; identity
4267    // rules imply authentication (anonymous -> 401 with the challenge).
4268    // The IP ACL is evaluated against the EFFECTIVE client IP: the
4269    // X-Forwarded-For-resolved client when the peer is a trusted proxy
4270    // (DW-009 chain), else the peer. No reason detail reaches the
4271    // client (generic 403 body).
4272    let consumer_authz = consumer_cfg.and_then(|c| c.authorization.as_ref());
4273    let service_authz = service.and_then(|s| s.authorization.as_ref());
4274    let listener_authz = listener_cfg.and_then(|l| l.authorization.as_ref());
4275    let global_authz = gateway.authorization.as_ref();
4276    // The authz phase span (DW-021) opens unconditionally — the phase
4277    // runs on every routed request (a chain with no rules allows), and
4278    // the trace contract pins its presence. Resolution is level-aware
4279    // (DW-041): a `dry_run` attachment's deny is reported
4280    // (`Resolved::would_deny`) instead of enforced, and the walk inside
4281    // the resolver guarantees a LIVE deny at any level still wins.
4282    let authz_resolved = {
4283        let _authz_phase = tracing::info_span!("authz").entered();
4284        if consumer_authz.is_some()
4285            || route.authorization.is_some()
4286            || service_authz.is_some()
4287            || listener_authz.is_some()
4288            || global_authz.is_some()
4289        {
4290            let inbound_xff = req
4291                .headers()
4292                .get(&X_FORWARDED_FOR)
4293                .and_then(|v| v.to_str().ok());
4294            let effective_ip = crate::security::authz::effective_client_ip(
4295                &gateway.trusted_proxies,
4296                peer,
4297                inbound_xff,
4298            );
4299            // Hold the ArcSwap guard for the whole authz phase: the
4300            // context borrows the reader, and the guard keeps it alive
4301            // even across a concurrent hot swap.
4302            let geoip_guard = dp.geoip.load();
4303            let authz_ctx = crate::security::authz::AuthzContext {
4304                geoip: geoip_guard.as_ref().map(std::sync::Arc::as_ref),
4305                identity: identity.as_ref(),
4306                // Groups ride the identity (#124): config consumers from
4307                // the config record, store-managed consumers from the
4308                // store — one source for both, so group rules apply
4309                // uniformly.
4310                consumer_groups: identity
4311                    .as_ref()
4312                    .map(|id| id.groups.as_slice())
4313                    .unwrap_or(&[]),
4314                peer_ip: peer,
4315                effective_ip,
4316            };
4317            let chain = crate::security::authz::AuthzChain {
4318                consumer: consumer_authz,
4319                route: route.authorization.as_ref(),
4320                service: service_authz,
4321                listener: listener_authz,
4322                global: global_authz,
4323            };
4324            crate::security::authz::resolve(&chain, &authz_ctx)
4325        } else {
4326            crate::security::authz::Resolved {
4327                decision: crate::security::authz::Decision::Allow,
4328                would_deny: None,
4329            }
4330        }
4331    };
4332    if let Some((level, decision)) = &authz_resolved.would_deny {
4333        let crate::security::authz::Decision::Deny {
4334            unauthenticated,
4335            reason,
4336        } = decision
4337        else {
4338            unreachable!("would_deny only carries Deny decisions")
4339        };
4340        dp.obs.record_policy_dry_run("authz", &route.name);
4341        dp.obs.emit_policy_dry_run(
4342            "authz",
4343            if *unauthenticated { 401 } else { 403 },
4344            &route.name,
4345            identity.as_ref().map(|id| id.consumer_name.as_str()),
4346            rid,
4347            &format!(
4348                "authorization deny at {} level (dry-run): {reason}",
4349                level.as_str()
4350            ),
4351        );
4352    }
4353    match authz_resolved.decision {
4354        crate::security::authz::Decision::Allow => {}
4355        crate::security::authz::Decision::Deny {
4356            unauthenticated: true,
4357            ..
4358        } => {
4359            let mut resp = unauthorized(&authn.challenge(), rid);
4360            stamp_security_headers(
4361                &mut resp,
4362                gen.snapshot.route_table().effective_security_headers(idx),
4363            );
4364            return resp;
4365        }
4366        crate::security::authz::Decision::Deny { reason, .. } => {
4367            // Reason is server-side only: which list matched, which claim
4368            // was absent — none of it is the client's business.
4369            tracing::warn!(
4370                code = "authorization_denied",
4371                request_id = %rid,
4372                route = %route.name,
4373                consumer = %identity
4374                    .as_ref()
4375                    .map(|id| id.consumer_name.as_str())
4376                    .unwrap_or("<anonymous>"),
4377                "authorization denied: {reason}"
4378            );
4379            let mut resp = forbidden(rid);
4380            stamp_security_headers(
4381                &mut resp,
4382                gen.snapshot.route_table().effective_security_headers(idx),
4383            );
4384            return resp;
4385        }
4386    }
4387
4388    // Local rate limiting (DW-017): BEFORE cap admission — a 429 is the
4389    // cheapest rejection the gateway can emit, so it precedes the permit
4390    // acquisition below (rate-limited requests never hold a cap slot).
4391    // Policy resolution: consumer (DW-019) > route > service > listener
4392    // > global; all applicable rules AND together, and the resolution
4393    // order binds the 429 headers. The `credential` selector falls back
4394    // to the peer IP until authN identifies consumers.
4395    let service_policies: &[String] = service.map(|s| s.policies.as_slice()).unwrap_or(&[]);
4396    let consumer_policies: &[String] = consumer_cfg.map(|c| c.policies.as_slice()).unwrap_or(&[]);
4397    let listener_policies: &[String] = listener_cfg.map(|l| l.policies.as_slice()).unwrap_or(&[]);
4398    // The ratelimit phase span (DW-021). The evaluate call is async
4399    // (DW-031: Redis-backed limiters await a network round-trip), so
4400    // the span is instrumented onto the future rather than held as an
4401    // entered guard — an EnteredSpan is !Send and would poison the
4402    // handler future for hyper's h2 executor. Dry-run bundles (DW-041)
4403    // report their would-be denial through the same evaluation; live
4404    // bundles alone decide the 429 and the headers.
4405    let rate_headers = {
4406        let ratelimit_span = tracing::info_span!("ratelimit");
4407        let engine = dp.rate_limits.load_full();
4408        if engine.is_empty() {
4409            None
4410        } else {
4411            let ctx = crate::extensions::rate_limiter::RateLimitKeyContext {
4412                peer,
4413                consumer: identity.as_ref().map(|id| id.consumer_name.as_str()),
4414                route: &route.name,
4415            };
4416            // DW-089: the adaptive factor scales each policy's
4417            // per-request cost at check time (the controller lives on
4418            // the current generation, same as the rate-limit engine).
4419            let adaptive = Arc::clone(gen.adaptive());
4420            let evaluation = engine
4421                .evaluate(
4422                    &ctx,
4423                    consumer_policies,
4424                    &route.policies,
4425                    service_policies,
4426                    listener_policies,
4427                    &gateway.global_policies,
4428                    |name| adaptive.factor_for(name),
4429                )
4430                .instrument(ratelimit_span)
4431                .await;
4432            if let Some(RateLimitOutcome::Denied { retry_after_s, .. }) = evaluation.dry_denied {
4433                dp.obs.record_policy_dry_run("rate_limit", &route.name);
4434                dp.obs.emit_policy_dry_run(
4435                    "rate_limit",
4436                    429,
4437                    &route.name,
4438                    identity.as_ref().map(|id| id.consumer_name.as_str()),
4439                    rid,
4440                    &format!("rate limit would deny (retry after {retry_after_s}s)"),
4441                );
4442            }
4443            match evaluation.outcome {
4444                RateLimitOutcome::Denied {
4445                    limit,
4446                    remaining,
4447                    reset_epoch_s,
4448                    retry_after_s,
4449                } => {
4450                    rec.rate_limited = true;
4451                    dp.obs.record_rate_limited(&route.name);
4452                    let mut resp = rate_limited(
4453                        u64::from(limit),
4454                        u64::from(remaining),
4455                        reset_epoch_s,
4456                        retry_after_s,
4457                        rid,
4458                    );
4459                    stamp_security_headers(
4460                        &mut resp,
4461                        gen.snapshot.route_table().effective_security_headers(idx),
4462                    );
4463                    return resp;
4464                }
4465                RateLimitOutcome::Allowed {
4466                    limit,
4467                    remaining,
4468                    reset_epoch_s,
4469                } => Some((limit, remaining, reset_epoch_s)),
4470                RateLimitOutcome::NotLimited => None,
4471            }
4472        }
4473    };
4474
4475    // DW-089: collect the deduplicated applicable policy names (the
4476    // same resolution order as the rate-limit check) so the proxy path
4477    // can feed upstream outcomes back to the adaptive controller. A
4478    // policy attached at several levels is recorded once (its first
4479    // chain position), matching the rate-limit evaluation's dedup.
4480    let applicable_policies: Vec<String> = {
4481        // PERF: short-circuit the common case (no policies at any level)
4482        // to avoid allocating a HashSet + Vec on every request.
4483        if consumer_policies.is_empty()
4484            && route.policies.is_empty()
4485            && service_policies.is_empty()
4486            && listener_policies.is_empty()
4487            && gateway.global_policies.is_empty()
4488        {
4489            Vec::new()
4490        } else {
4491            let mut seen = std::collections::HashSet::new();
4492            let mut out = Vec::new();
4493            for name in consumer_policies
4494                .iter()
4495                .chain(&route.policies)
4496                .chain(service_policies)
4497                .chain(listener_policies)
4498                .chain(&gateway.global_policies)
4499            {
4500                if seen.insert(name.clone()) {
4501                    out.push(name.clone());
4502                }
4503            }
4504            out
4505        }
4506    };
4507
4508    // Consumer request budgets (DW-033): after rate limiting — an
4509    // in-memory GCRA 429 is cheaper than a store-backed budget 429, so
4510    // the cheaper rejection wins the ordering — and before cap
4511    // admission (a budget wall never holds a concurrency slot, the
4512    // same rule the rate limiter follows). Budgets are per
4513    // authenticated CONFIG consumer (`consumers[].quotas`): anonymous
4514    // traffic has no budget, and store-managed consumers have no
4515    // config record to carry one. Counters live in the state store
4516    // (durable across restarts); without a store the block is inert —
4517    // warned once per process, never a per-request log storm. The
4518    // quota phase span (DW-021) opens only when a budget actually
4519    // applies, mirroring the ratelimit span's conditional shape.
4520    if let Some(quotas) = consumer_cfg.and_then(|c| c.quotas.as_ref()) {
4521        let consumer_name = identity
4522            .as_ref()
4523            .map(|id| id.consumer_name.as_str())
4524            .expect("consumer_cfg resolves only for an authenticated identity");
4525        match dp.state_store() {
4526            None => warn_quota_store_missing(consumer_name),
4527            Some(store) => match store.lookup_consumer(consumer_name) {
4528                Err(e) => {
4529                    tracing::error!(
4530                        code = "quota_store_unavailable",
4531                        request_id = %rid,
4532                        consumer = consumer_name,
4533                        "quota consumer lookup failed: {e}"
4534                    );
4535                    let mut resp = simple(
4536                        StatusCode::INTERNAL_SERVER_ERROR,
4537                        "quota_store_unavailable",
4538                        "quota store unavailable",
4539                        rid,
4540                    );
4541                    stamp_security_headers(
4542                        &mut resp,
4543                        gen.snapshot.route_table().effective_security_headers(idx),
4544                    );
4545                    return resp;
4546                }
4547                Ok(None) => warn_quota_consumer_unsynced(consumer_name),
4548                Ok(Some(record)) => {
4549                    let now_epoch_s = unix_now_secs();
4550                    // DW-155: when the Redis-backed quota checker is
4551                    // attached (ent feature + redis_quotas config +
4552                    // license claim), use it instead of the local
4553                    // SQLite checker so a fleet of N instances shares
4554                    // one counter per (consumer, budget, window).
4555                    // The quota phase span (DW-021) is entered only
4556                    // around the synchronous local check; the async
4557                    // Redis check is instrumented separately because
4558                    // an EnteredSpan guard is not Send and cannot be
4559                    // held across an .await point.
4560                    #[cfg(feature = "ent")]
4561                    let outcome = if let Some(checker) = dp.redis_quota_checker() {
4562                        let _quota_phase = tracing::info_span!("quota");
4563                        checker
4564                            .check(record.id, quotas, now_epoch_s)
4565                            .instrument(_quota_phase)
4566                            .await
4567                    } else {
4568                        let _quota_phase = tracing::info_span!("quota").entered();
4569                        crate::state::quotas::check(&store, record.id, quotas, now_epoch_s)
4570                    };
4571                    #[cfg(not(feature = "ent"))]
4572                    let outcome = {
4573                        let _quota_phase = tracing::info_span!("quota").entered();
4574                        crate::state::quotas::check(&store, record.id, quotas, now_epoch_s)
4575                    };
4576                    match outcome {
4577                        crate::state::quotas::QuotaOutcome::Denied {
4578                            limit,
4579                            remaining,
4580                            reset_epoch_s,
4581                            retry_after_s,
4582                            budget,
4583                        } => {
4584                            // rec.rate_limited carries the denial into
4585                            // the analytics pipeline (the per-consumer
4586                            // usage axis); the rate_limited_total
4587                            // metric family stays RATE-limit-only —
4588                            // dwara_quota_denied_total owns budgets.
4589                            rec.rate_limited = true;
4590                            dp.obs.record_quota_denied(consumer_name, budget.as_str());
4591                            tracing::info!(
4592                                code = "quota_exceeded",
4593                                request_id = %rid,
4594                                route = %route.name,
4595                                consumer = consumer_name,
4596                                budget = budget.as_str(),
4597                                limit = limit,
4598                                retry_after_s = retry_after_s,
4599                                "consumer request budget exhausted; answering 429"
4600                            );
4601                            let mut resp =
4602                                rate_limited(limit, remaining, reset_epoch_s, retry_after_s, rid);
4603                            stamp_security_headers(
4604                                &mut resp,
4605                                gen.snapshot.route_table().effective_security_headers(idx),
4606                            );
4607                            return resp;
4608                        }
4609                        crate::state::quotas::QuotaOutcome::Unavailable => {
4610                            tracing::error!(
4611                                code = "quota_store_unavailable",
4612                                request_id = %rid,
4613                                consumer = consumer_name,
4614                                "quota counter read/write failed; the budget cannot \
4615                                 be vouched for"
4616                            );
4617                            let mut resp = simple(
4618                                StatusCode::INTERNAL_SERVER_ERROR,
4619                                "quota_store_unavailable",
4620                                "quota store unavailable",
4621                                rid,
4622                            );
4623                            stamp_security_headers(
4624                                &mut resp,
4625                                gen.snapshot.route_table().effective_security_headers(idx),
4626                            );
4627                            return resp;
4628                        }
4629                        // NotQuotaed here means the consumer row was
4630                        // reported missing mid-check (the engine's
4631                        // fail-open path) — the warn already fired.
4632                        crate::state::quotas::QuotaOutcome::NotQuotaed => {}
4633                        crate::state::quotas::QuotaOutcome::Allowed { .. } => {
4634                            // Near-limit notice (DW-033): edge-triggered
4635                            // once per (consumer, budget, window) at
4636                            // >= 80% of the cap. The read is the same
4637                            // current-usage query the admin endpoint
4638                            // and the scrape gauges use.
4639                            for u in crate::state::quotas::current_usage(
4640                                &store,
4641                                record.id,
4642                                quotas,
4643                                now_epoch_s,
4644                            ) {
4645                                if u.used * 5 >= u.limit * 4
4646                                    && dp.note_quota_near_limit(
4647                                        consumer_name,
4648                                        u.budget,
4649                                        u.window_start_epoch_s as i64,
4650                                    )
4651                                {
4652                                    dp.events.emitter().emit(
4653                                        crate::events::EventKind::QuotaNearLimit,
4654                                        crate::events::EventPayload::quota(
4655                                            consumer_name,
4656                                            u.budget.as_str(),
4657                                            u.used,
4658                                            u.limit,
4659                                        ),
4660                                    );
4661                                    tracing::warn!(
4662                                        code = "quota_near_limit",
4663                                        consumer = consumer_name,
4664                                        budget = u.budget.as_str(),
4665                                        used = u.used,
4666                                        limit = u.limit,
4667                                        "consumer request budget at or above 80% of \
4668                                         its window cap"
4669                                    );
4670                                }
4671                            }
4672                        }
4673                    }
4674                }
4675            },
4676        }
4677    }
4678
4679    // Gateway concurrency cap with priority-aware load shedding
4680    // (DW-015 + DW-016) and bounded admission queues (DW-053). Admission
4681    // is two-tier: every request tries the general allowance; a request
4682    // at or above HIGH_PRIORITY may then try the reserved bucket (when
4683    // one is carved). No permits anywhere -> either WAIT for a permit
4684    // up to the queue timeout (DW-053, when admission_queue is enabled)
4685    // or 503 "gateway saturated" immediately (DW-016, the default). The
4686    // permit lives until the response body completes: the proxy path
4687    // attaches it to the streaming body, and complete bodies (errors,
4688    // redirects, respond actions) release it when this scope ends.
4689    //
4690    // AuthN (DW-019) supplies the consumer here: its priority overrides
4691    // the route's when the authenticated consumer declares one.
4692    let priority = resolve_priority(consumer_cfg, route);
4693    let cap = dp.global_cap.load_full();
4694    // The admission phase span (DW-021); permit bookkeeping. The sync
4695    // try-acquire runs inside the span; the DW-053 queue path's timed
4696    // acquire runs OUTSIDE the span guard (EnteredSpan is not Send, so
4697    // holding it across an .await would make the future non-Send).
4698    let global_permit = {
4699        let _admission_phase = tracing::info_span!("admission").entered();
4700        match &cap.general {
4701            None => {
4702                // Unlimited: no admission decision, but still counted per
4703                // priority class (the counters describe traffic mix, not only
4704                // capped traffic).
4705                dp.priority_counters.record_admitted(priority);
4706                AdmissionResult::Permit(None)
4707            }
4708            Some(general) => {
4709                let admitted = Arc::clone(general).try_acquire_owned().ok().or_else(|| {
4710                    // General allowance full: high-priority traffic may
4711                    // draw from the reserved bucket; everything else is
4712                    // shed. This is reserved-capacity admission, NOT
4713                    // preemption — in-flight normal requests keep their
4714                    // slots until they complete.
4715                    if priority >= HIGH_PRIORITY {
4716                        cap.reserved
4717                            .as_ref()
4718                            .and_then(|bucket| Arc::clone(bucket).try_acquire_owned().ok())
4719                    } else {
4720                        None
4721                    }
4722                });
4723                match admitted {
4724                    Some(permit) => {
4725                        dp.priority_counters.record_admitted(priority);
4726                        AdmissionResult::Permit(Some(permit))
4727                    }
4728                    None => {
4729                        // DW-053: admission queue. When enabled, the
4730                        // request waits for a permit up to the queue
4731                        // timeout instead of being immediately shed.
4732                        // The queue depth is bounded: once at capacity
4733                        // (per-priority-aware), the request is shed
4734                        // immediately with 503 (queue_full).
4735                        if let Some(aq) = &cap.admission_queue {
4736                            // Check queue capacity. High-priority may
4737                            // queue up to max_queue_size; low-priority
4738                            // up to low_queue_limit (the high-priority
4739                            // reserve is max_queue_size - low_queue_limit).
4740                            let depth = aq.queue_depth.load(Ordering::Relaxed);
4741                            let limit = if priority >= HIGH_PRIORITY {
4742                                aq.max_queue_size
4743                            } else {
4744                                aq.low_queue_limit
4745                            };
4746                            if depth >= limit {
4747                                // Queue full: shed immediately.
4748                                dp.obs.record_admission_queued("queue_full");
4749                                handle_shed(
4750                                    dp,
4751                                    priority,
4752                                    rec,
4753                                    rid,
4754                                    route,
4755                                    Some(aq.queue_timeout),
4756                                    gateway.load_shed_dry_run,
4757                                    identity.as_ref().map(|id| id.consumer_name.as_str()),
4758                                    gen.snapshot.route_table().effective_security_headers(idx),
4759                                )
4760                            } else {
4761                                // Reserve a queue slot. The timed acquire
4762                                // runs outside the span guard (below).
4763                                aq.queue_depth.fetch_add(1, Ordering::Relaxed);
4764                                let depth = aq.queue_depth.load(Ordering::Relaxed);
4765                                dp.obs.set_admission_queue_depth(depth as i64);
4766                                AdmissionResult::Queue(
4767                                    Arc::clone(general),
4768                                    cap.reserved.clone(),
4769                                    aq.queue_timeout,
4770                                    aq.queue_depth.clone(),
4771                                )
4772                            }
4773                        } else {
4774                            // DW-016: no queue — immediate shed (or
4775                            // dry-run admit, DW-041).
4776                            handle_shed(
4777                                dp,
4778                                priority,
4779                                rec,
4780                                rid,
4781                                route,
4782                                None,
4783                                gateway.load_shed_dry_run,
4784                                identity.as_ref().map(|id| id.consumer_name.as_str()),
4785                                gen.snapshot.route_table().effective_security_headers(idx),
4786                            )
4787                        }
4788                    }
4789                }
4790            }
4791        }
4792    };
4793    // Unpack the admission result: a shed response returns immediately;
4794    // a permit (Some for admitted, None for dry-run-admitted-over-cap)
4795    // continues the request path; a Queue result needs a timed acquire
4796    // (DW-053) which runs here, outside the span guard.
4797    let mut global_permit = match global_permit {
4798        AdmissionResult::Permit(p) => p,
4799        AdmissionResult::Shed(resp) => return resp,
4800        AdmissionResult::Queue(general, reserved, timeout, queue_depth) => {
4801            let acquired = try_acquire_queued(general, reserved, priority, timeout).await;
4802            queue_depth.fetch_sub(1, Ordering::Relaxed);
4803            let depth = queue_depth.load(Ordering::Relaxed);
4804            dp.obs.set_admission_queue_depth(depth as i64);
4805            match acquired {
4806                Some(permit) => {
4807                    dp.obs.record_admission_queued("admitted");
4808                    dp.priority_counters.record_admitted(priority);
4809                    Some(permit)
4810                }
4811                None => {
4812                    // Timed out waiting for a permit.
4813                    dp.obs.record_admission_queued("timeout");
4814                    let result = handle_shed(
4815                        dp,
4816                        priority,
4817                        rec,
4818                        rid,
4819                        route,
4820                        Some(timeout),
4821                        gateway.load_shed_dry_run,
4822                        identity.as_ref().map(|id| id.consumer_name.as_str()),
4823                        gen.snapshot.route_table().effective_security_headers(idx),
4824                    );
4825                    match result {
4826                        AdmissionResult::Permit(p) => p,
4827                        AdmissionResult::Shed(resp) => return resp,
4828                        AdmissionResult::Queue(..) => {
4829                            unreachable!("handle_shed never returns Queue")
4830                        }
4831                    }
4832                }
4833            }
4834        }
4835    };
4836
4837    // Response edge policies (DW-027) consume request header values the
4838    // actions below take ownership of — capture them first.
4839    let req_origin = req.headers().get(&ORIGIN).cloned();
4840    let req_accept_encoding = req
4841        .headers()
4842        .get(&ACCEPT_ENCODING)
4843        .and_then(|v| v.to_str().ok())
4844        .map(str::to_string);
4845
4846    // Response cache lookup (DW-037): PROXY-action routes with a cache
4847    // block only, AFTER authn/authz/rate limiting/admission (a replay
4848    // is still client traffic — it consumed a token and a slot, and no
4849    // policy is bypassed by a hit) and BEFORE the breaker/endpoint pick
4850    // (a hit contacts no upstream). A Serve short-circuits the action,
4851    // masking, and transforms — the stored bytes ARE the post-mask,
4852    // post-transform bytes for this exact consumer — and the response
4853    // re-enters the pipeline at the store-stage/tail below, so the
4854    // decoration tail (compression onward) still runs on every replay.
4855    //
4856    // Request coalescing (DW-038) rides the MISS arm: a coalescing-
4857    // enabled miss either leads (fetches while holding the key's
4858    // slot, `coalesce_lead` below), follows (parks bounded and is
4859    // Served the leader's stored outcome — same guarantees as a hit,
4860    // since the entry IS a hit by the time it replays), or fetches
4861    // independently (Solo: every fail-open fallback is just this
4862    // normal miss path). Everything upstream of this point —
4863    // maintenance, limits, authn/authz, rate limiting, admission —
4864    // already ran for the follower too, so a 503 or a 429 is never
4865    // coalesced and no policy is bypassed by following.
4866    let mut cache_flow: Option<crate::dataplane::response_cache::CacheFlow> = None;
4867    let mut coalesce_lead: Option<crate::dataplane::response_cache::CoalesceLead> = None;
4868    let replayed: Option<Response<ProxyBody>> = if let (RouteAction::Proxy { .. }, Some(policy)) =
4869        (&route.action, gen.snapshot.route_table().cache(idx))
4870    {
4871        use crate::dataplane::response_cache::LookupOutcome;
4872        match dp
4873            .response_cache()
4874            .lookup(
4875                dp,
4876                policy,
4877                route,
4878                identity.as_ref(),
4879                peer,
4880                req.uri().path(),
4881                req.uri().query(),
4882                req.headers(),
4883                req.method(),
4884                req.body().size_hint().exact(),
4885                &dp.obs,
4886            )
4887            .await
4888        {
4889            LookupOutcome::Serve(resp) => Some(*resp),
4890            LookupOutcome::Bypass => {
4891                cache_flow = Some(crate::dataplane::response_cache::CacheFlow::Bypass);
4892                None
4893            }
4894            LookupOutcome::Miss(flow) => {
4895                // Conditional revalidation (DW-037): when the lookup
4896                // kept an expired entry with a validator and the
4897                // client sent none of its own, the forwarded fetch
4898                // carries the stored validator — an upstream 304
4899                // refreshes the entry without re-sending the body
4900                // (the store stage converts it back to a 200).
4901                if flow.injected_inm {
4902                    if let Some(etag) = flow.stored_etag() {
4903                        if let Ok(v) = HeaderValue::from_str(&etag) {
4904                            req.headers_mut().insert(&IF_NONE_MATCH, v);
4905                        }
4906                    }
4907                }
4908                if flow.coalesce_wait().is_some() {
4909                    match dp.response_cache().attach(&flow, &dp.obs).await {
4910                        crate::dataplane::response_cache::CoalesceOutcome::Served(resp) => {
4911                            Some(*resp)
4912                        }
4913                        crate::dataplane::response_cache::CoalesceOutcome::Lead(lead) => {
4914                            coalesce_lead = Some(lead);
4915                            cache_flow =
4916                                Some(crate::dataplane::response_cache::CacheFlow::Miss(flow));
4917                            None
4918                        }
4919                        crate::dataplane::response_cache::CoalesceOutcome::Solo => {
4920                            cache_flow =
4921                                Some(crate::dataplane::response_cache::CacheFlow::Miss(flow));
4922                            None
4923                        }
4924                    }
4925                } else {
4926                    cache_flow = Some(crate::dataplane::response_cache::CacheFlow::Miss(flow));
4927                    None
4928                }
4929            }
4930        }
4931    } else {
4932        None
4933    };
4934
4935    // Request validation (DW-047): when a route carries a
4936    // `request_validation.body_schema`, the request body is buffered
4937    // and validated against the minimal JSON-Schema subset BEFORE the
4938    // action runs. A mismatch answers 400 `validation_failed` with the
4939    // offending instance paths in the JSON error envelope. This runs
4940    // after every policy phase (authn, authz, rate limit, admission)
4941    // and before the action — a malformed body from an authenticated,
4942    // authorized caller is still rejected, and no upstream is
4943    // contacted on a mismatch (the whole point for mock mode). The
4944    // buffered bytes are replayed to the action below (proxy or mock
4945    // alike). A cache HIT skips validation (the cached response was
4946    // already validated when it was first fetched).
4947    //
4948    // DW-157: the plugin `request_body` phase (when the route's chain
4949    // declares it) runs FIRST — buffering the body within the route's
4950    // body cap (over-cap fails closed, 500 `plugin_body_too_large`),
4951    // letting the chain rewrite the bytes — so validation sees the
4952    // POST-plugin body, the body the action will actually dispatch. A
4953    // cache HIT skips the plugin body phase too (no forward happens).
4954    let cache_hit = replayed.is_some();
4955    let mut resp = if let Some(resp) = replayed {
4956        resp
4957    } else if plugins.as_ref().is_some_and(|p| p.needs_request_body()) {
4958        let p = plugins.as_mut().expect("plugin chain checked above");
4959        match p
4960            .request_body_phase(
4961                req,
4962                plugin_dispatch::plugin_body_cap(route),
4963                &dp.obs,
4964                rid,
4965                &route.name,
4966            )
4967            .await
4968        {
4969            Ok(buffered) => {
4970                dispatch_validated(
4971                    buffered,
4972                    route,
4973                    &gen,
4974                    idx,
4975                    service,
4976                    &params,
4977                    peer,
4978                    identity.as_ref(),
4979                    rid,
4980                    rec,
4981                    &mut global_permit,
4982                    dp,
4983                    client_cert.as_ref(),
4984                    &applicable_policies,
4985                )
4986                .await
4987            }
4988            Err(resp) => {
4989                rec.plugin_short_circuit = true;
4990                return *resp;
4991            }
4992        }
4993    } else {
4994        dispatch_validated(
4995            req,
4996            route,
4997            &gen,
4998            idx,
4999            service,
5000            &params,
5001            peer,
5002            identity.as_ref(),
5003            rid,
5004            rec,
5005            &mut global_permit,
5006            dp,
5007            client_cert.as_ref(),
5008            &applicable_policies,
5009        )
5010        .await
5011    };
5012
5013    // Plugin `response_headers` phase (DW-157): after the response
5014    // arrives (any action), before masking (the documented contract).
5015    // Uniform across actions — plugins attach to the ROUTE, and a
5016    // respond/redirect/mock response is as much "the route's response"
5017    // as a proxied one. `:status` rides the map for visibility; writes
5018    // to it are ignored (pipeline-owned — the framing rationale is on
5019    // `RequestPlugins::response_headers_phase`). A cache HIT skips the
5020    // phase (the stored bytes/headers are post-plugin, the same replay
5021    // semantics masking and the transforms follow — replaying them
5022    // would apply a header-stamping or body-rewriting plugin twice).
5023    if !cache_hit {
5024        if let Some(p) = plugins.as_mut() {
5025            let status = resp.status();
5026            if let Err(short) = p
5027                .response_headers_phase(status, resp.headers_mut(), &dp.obs, rid, &route.name)
5028                .await
5029            {
5030                rec.plugin_short_circuit = true;
5031                return *short;
5032            }
5033        }
5034    }
5035
5036    // Response field masking (DW-029), the decoration tail's FIRST
5037    // stage and the security floor of the response path: the effective
5038    // pointer set (route floor + the consumer's groups, the union
5039    // rule) is replaced with the fixed sentinel BEFORE anything else
5040    // can read the body — once masked, the original bytes exist
5041    // nowhere in the gateway, so no later stage (operator transforms,
5042    // the DW-027 compression codec) can resurrect them; the gateway's
5043    // own compression runs later and never trips the encoding gate.
5044    // Every gate here FAILS CLOSED (502): masking guards the UPSTREAM's
5045    // output, so it applies to PROXY action responses only —
5046    // gateway-authored bodies (redirect, respond) carry no upstream
5047    // data, and bodiless statuses carry nothing at all.
5048    if matches!(&route.action, RouteAction::Proxy { .. }) {
5049        if let Some(masking) = gen.snapshot.route_table().masking(idx) {
5050            resp = crate::dataplane::transforms::mask_response_body(
5051                resp,
5052                masking,
5053                identity
5054                    .as_ref()
5055                    .map(|id| id.groups.as_slice())
5056                    .unwrap_or(&[]),
5057                &route.name,
5058                identity.as_ref().map(|id| id.consumer_name.as_str()),
5059                rid,
5060            )
5061            .await;
5062        }
5063    }
5064
5065    // Plugin `response_body` phase (DW-157): after masking, before the
5066    // operator transforms and compression (the documented contract —
5067    // the operator's ops keep the final word over the bytes, the same
5068    // philosophy the rest of the tail follows). Only chains declaring
5069    // the phase buffer anything: masking already established the
5070    // buffered path when it ran, otherwise the phase buffers within
5071    // the route's body cap (over-cap fails closed, 500
5072    // `plugin_body_too_large`). Bodiless statuses pass through. A
5073    // cache HIT skips the phase (stored bytes are post-plugin — see
5074    // the response_headers note above).
5075    if !cache_hit && plugins.as_ref().is_some_and(|p| p.needs_response_body()) {
5076        let p = plugins.as_mut().expect("plugin chain checked above");
5077        match p
5078            .response_body_phase(
5079                resp,
5080                plugin_dispatch::plugin_body_cap(route),
5081                &dp.obs,
5082                rid,
5083                &route.name,
5084            )
5085            .await
5086        {
5087            Ok(passed) => resp = passed,
5088            Err(short) => {
5089                rec.plugin_short_circuit = true;
5090                return *short;
5091            }
5092        }
5093    }
5094
5095    // Response body transforms (DW-028), the tail's stage after
5096    // masking: buffers only when the route configured a JSON body
5097    // transform AND the response declares a JSON body within the cap —
5098    // every other response (SSE, streamed downloads, other content
5099    // types, already-encoded) passes untouched, the streaming
5100    // guarantee. Before compression so the codec encodes the
5101    // TRANSFORMED bytes and the eligibility check below sees the
5102    // final Content-Type (header ops may have rewritten it).
5103    if let Some(compiled) = gen.snapshot.route_table().response_body_ops(idx) {
5104        resp = crate::dataplane::transforms::transform_response_body(resp, compiled, rid).await;
5105    }
5106
5107    // Response header transforms (DW-028): the operator's final shape
5108    // of the upstream's headers, before the gateway's own policy
5109    // stamps (compression's Vary/Content-Encoding, versioning, CORS,
5110    // security headers, rate headers — each owns headers validation
5111    // keeps the ops out of, so no stage here can be undone by an op).
5112    if let Some(ops) = route
5113        .transforms
5114        .as_ref()
5115        .and_then(|t| t.response.as_ref())
5116        .and_then(|resp_t| resp_t.headers.as_ref())
5117    {
5118        crate::dataplane::transforms::apply_header_ops(resp.headers_mut(), ops);
5119    }
5120
5121    // Cache store stage (DW-037): the tail's LAST hands on the bytes
5122    // before the gateway's own decoration. Stores happen only on the
5123    // miss/bypass flows created above; the stage stamps `x-cache`
5124    // (hit/stale were stamped at replay), applies the 304-reuse and
5125    // storable-rule arms, and buffers — size-capped, this opted-in
5126    // path only — the post-masking/post-transform identity bytes into
5127    // the CacheStore. Compression and everything after re-run below
5128    // for fresh AND replayed responses alike.
5129    if let Some(flow) = cache_flow {
5130        resp = dp
5131            .response_cache()
5132            .store_stage(flow, resp, rid, &dp.obs)
5133            .await;
5134    }
5135
5136    // DW-038: publish the coalescing leader's outcome NOW — the store
5137    // write above has completed, so the parked followers that wake
5138    // re-read a settled store and either replay the entry or fetch on
5139    // their own. Dropping here (not at function end) lets followers
5140    // proceed while this response's decoration tail still runs; a
5141    // leader that panicked or was cancelled mid-fetch publishes the
5142    // same way via Drop, failing its followers open.
5143    drop(coalesce_lead);
5144
5145    // Response compression (DW-027): route-scoped, negotiated against
5146    // the captured Accept-Encoding, applied after the action so every
5147    // action reports identically. The content-type filter is compiled
5148    // in lockstep with `Route::compression` (RouteTable::
5149    // compression_types; None is unreachable and reads as "skip",
5150    // landing in the Vary-only branch below). The body's exact size
5151    // hint gates `min_size` for header-less gateway bodies (respond,
5152    // redirect); unknown-length streams are always candidates.
5153    // Already-encoded responses are left entirely untouched; every
5154    // other response on the route carries at least `Vary:
5155    // Accept-Encoding` (a candidate response must not be cached under
5156    // another client's coding).
5157    if let Some(policy) = &route.compression {
5158        let already_encoded = resp.headers().contains_key(hyper::header::CONTENT_ENCODING);
5159        if !already_encoded {
5160            let decision = gen
5161                .snapshot
5162                .route_table()
5163                .compression_types(idx)
5164                .and_then(|types| {
5165                    crate::dataplane::compression::decide(
5166                        policy,
5167                        types,
5168                        resp.status(),
5169                        resp.headers(),
5170                        resp.body().size_hint().exact(),
5171                        req_accept_encoding.as_deref(),
5172                    )
5173                });
5174            match decision {
5175                Some(plan) => {
5176                    resp = crate::dataplane::compression::wrap_response(resp, &plan);
5177                }
5178                None => {
5179                    crate::dataplane::hardening::merge_vary(resp.headers_mut(), "Accept-Encoding")
5180                }
5181            }
5182        }
5183    }
5184
5185    // API versioning aids (DW-048), both applied after the action (and
5186    // after compression wrapping — the codec rewrites only
5187    // Content-Length/Content-Encoding/Vary, so headers stamped here
5188    // survive verbatim; Vary merges compose):
5189    // - a route selected by `match.accept` varies with the request's
5190    //   Accept, so shared caches must key on it (`Vary: Accept`, merged
5191    //   with any CORS/compression Vary the response already carries);
5192    // - the route's deprecation policy stamps Deprecation (RFC 9745),
5193    //   Sunset (RFC 8594), and the Link;rel=deprecation companion on
5194    //   every action response (not on gateway short-circuits —
5195    //   maintenance 503s, 413/431 limits, preflights, 401/403/429,
5196    //   sheds — those describe the request or the route's
5197    //   availability, not the route's lifecycle).
5198    if route.r#match.accept.is_some() {
5199        crate::dataplane::hardening::merge_vary(resp.headers_mut(), "Accept");
5200    }
5201    if let Some(dep) = gen.snapshot.route_table().deprecation(idx) {
5202        crate::dataplane::versioning::decorate(resp.headers_mut(), dep);
5203    }
5204
5205    // CORS actual-response decoration (DW-027): policy headers on every
5206    // response of a CORS route whose request origin is allowed.
5207    if let Some(cors) = &route.cors {
5208        if let Some(origins) = gen.snapshot.route_table().cors_origins(idx) {
5209            crate::dataplane::cors::decorate_actual(
5210                cors,
5211                origins,
5212                req_origin.as_ref(),
5213                resp.headers_mut(),
5214            );
5215        }
5216    }
5217
5218    // Security headers (DW-028): the LAST policy stamp before the rate
5219    // headers — the gateway's edge policy has the final word over
5220    // upstream values AND over operator transforms (an operator who
5221    // needs per-route exceptions omits the field here and sets it via
5222    // transforms). REPLACE semantics: the gateway is the source of
5223    // truth at its edge. SEC-09 (#213): the effective posture is the
5224    // route's own block when present, else the gateway-level default
5225    // (when the route has not opted out), computed once at compile
5226    // time.
5227    if let Some(sh) = gen.snapshot.route_table().effective_security_headers(idx) {
5228        crate::dataplane::transforms::apply_security_headers(resp.headers_mut(), sh);
5229    }
5230    // Admitted requests carry the binding constraint's rate headers (only
5231    // when a policy actually matched — see DW-017 module docs). Applied
5232    // after the action so every action (including streaming proxy bodies
5233    // and 101 upgrades) reports identically.
5234    if let Some((limit, remaining, reset_epoch_s)) = rate_headers {
5235        apply_rate_headers(resp.headers_mut(), limit, remaining, reset_epoch_s);
5236    }
5237    resp
5238}
5239
5240/// Request validation (DW-047) + the route action dispatch, extracted
5241/// from `handle_routed` so the plugin request_body phase (DW-157) hands
5242/// its buffered post-plugin body through the SAME validation path the
5243/// un-buffered body takes: when the route carries a
5244/// `request_validation.body_schema`, the body is validated against the
5245/// minimal JSON-Schema subset before the action; a mismatch answers
5246/// 400 `validation_failed` (dry-run answers 400 with the dry-run code,
5247/// the pre-DW-157 limitation preserved verbatim). Without validation
5248/// the action runs directly.
5249#[allow(clippy::too_many_arguments)]
5250async fn dispatch_validated<B>(
5251    req: Request<B>,
5252    route: &Route,
5253    gen: &Arc<Generation>,
5254    idx: usize,
5255    service: Option<&crate::config::Service>,
5256    params: &[(String, String)],
5257    peer: IpAddr,
5258    identity: Option<&Identity>,
5259    rid: &str,
5260    rec: &mut AccessRecord,
5261    global_permit: &mut Option<OwnedSemaphorePermit>,
5262    dp: &Arc<DataPlane>,
5263    client_cert: Option<&Arc<crate::security::authn::ClientCertificate>>,
5264    applicable_policies: &[String],
5265) -> Response<ProxyBody>
5266where
5267    B: hyper::body::Body<Data = Bytes> + Send + 'static,
5268    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
5269{
5270    if let Some(rv) = &route.request_validation {
5271        match validate_and_replay_body(req, &rv.body_schema).await {
5272            Ok(validated_req) => {
5273                dispatch_action(
5274                    validated_req,
5275                    route,
5276                    gen,
5277                    idx,
5278                    service,
5279                    params,
5280                    peer,
5281                    identity,
5282                    rid,
5283                    rec,
5284                    global_permit,
5285                    dp,
5286                    client_cert,
5287                    applicable_policies,
5288                )
5289                .await
5290            }
5291            Err(violation) => {
5292                // SEC-14: dry-run mode logs the violation but does
5293                // NOT reject the request. The body is replayed
5294                // (already buffered by validate_and_replay_body)
5295                // and forwarded to the action. The metric
5296                // `dwara_policy_dry_run_total{phase="request_validation"}`
5297                // is incremented.
5298                if rv.dry_run {
5299                    tracing::warn!(
5300                        code = "validation_failed_dry_run",
5301                        request_id = %rid,
5302                        route = %route.name,
5303                        path = %violation,
5304                        "request body failed validation (dry-run: request forwarded)"
5305                    );
5306                    // Re-buffer the body for dispatch. The
5307                    // validate_and_replay_body already consumed
5308                    // the body; in dry-run we need to re-read it.
5309                    // Since the body was already collected, we
5310                    // build an empty body here — the violation
5311                    // was on the already-consumed bytes. This is
5312                    // a limitation of the current dry-run: the
5313                    // body is consumed by validation. A future
5314                    // change will buffer before validation so
5315                    // dry-run can replay.
5316                    //
5317                    // For now, dry-run still rejects (the body
5318                    // is consumed) but logs the violation with
5319                    // the dry_run code so operators can monitor
5320                    // before switching to enforce mode.
5321                    let mut resp = simple(
5322                        StatusCode::BAD_REQUEST,
5323                        "validation_failed_dry_run",
5324                        &format!(
5325                            "request body does not match the expected schema (dry-run): {violation}"
5326                        ),
5327                        rid,
5328                    );
5329                    stamp_security_headers(
5330                        &mut resp,
5331                        gen.snapshot.route_table().effective_security_headers(idx),
5332                    );
5333                    return resp;
5334                }
5335                tracing::warn!(
5336                    code = "validation_failed",
5337                    request_id = %rid,
5338                    route = %route.name,
5339                    path = %violation,
5340                    "request body failed validation"
5341                );
5342                let mut resp = simple(
5343                    StatusCode::BAD_REQUEST,
5344                    "validation_failed",
5345                    &format!("request body does not match the expected schema: {violation}"),
5346                    rid,
5347                );
5348                stamp_security_headers(
5349                    &mut resp,
5350                    gen.snapshot.route_table().effective_security_headers(idx),
5351                );
5352                resp
5353            }
5354        }
5355    } else {
5356        dispatch_action(
5357            req,
5358            route,
5359            gen,
5360            idx,
5361            service,
5362            params,
5363            peer,
5364            identity,
5365            rid,
5366            rec,
5367            global_permit,
5368            dp,
5369            client_cert,
5370            applicable_policies,
5371        )
5372        .await
5373    }
5374}
5375
5376/// Stamp the route's security-header policy (DW-028) onto a gateway
5377/// short-circuit response. Security headers are an EDGE property of
5378/// every route-matched response — a browser parsing a 401 or a 429
5379/// deserves the same `nosniff`/HSTS guarantees as one parsing a 200
5380/// (the deliberate asymmetry with deprecation stamps, which announce
5381/// API lifecycle and stay off short-circuits; see
5382/// `config::transforms::SecurityHeaders`).
5383fn stamp_security_headers(
5384    resp: &mut Response<ProxyBody>,
5385    sh: Option<&crate::config::transforms::SecurityHeaders>,
5386) {
5387    if let Some(sh) = sh {
5388        crate::dataplane::transforms::apply_security_headers(resp.headers_mut(), sh);
5389    }
5390}
5391
5392/// The 503 maintenance response (DW-041): `Retry-After` in whole seconds
5393/// plus the uniform JSON envelope with the `maintenance` code and the
5394/// operator-optional message. On a CORS-configured route whose request
5395/// origin is allowed, the policy's actual-response CORS headers are
5396/// applied so a browser client can READ the envelope cross-origin (the
5397/// companion of the preflight exemption — see the module docs).
5398fn maintenance_response(
5399    route: &Route,
5400    idx: usize,
5401    gen: &Generation,
5402    origin: Option<&HeaderValue>,
5403    maintenance: &crate::config::Maintenance,
5404    rid: &str,
5405) -> Response<ProxyBody> {
5406    let mut resp = Response::builder()
5407        .status(StatusCode::SERVICE_UNAVAILABLE)
5408        .header(hyper::header::CONTENT_TYPE, "application/json")
5409        .header(
5410            hyper::header::RETRY_AFTER,
5411            maintenance.retry_after().to_string(),
5412        )
5413        .body(ProxyBody::Full(Full::new(observability::envelope_body(
5414            "maintenance",
5415            maintenance.message(),
5416            rid,
5417        ))))
5418        .expect("static 503 response is valid");
5419    if let (Some(cors), Some(origins)) = (
5420        route.cors.as_ref(),
5421        gen.snapshot.route_table().cors_origins(idx),
5422    ) {
5423        crate::dataplane::cors::decorate_actual(cors, origins, origin, resp.headers_mut());
5424    }
5425    // Security headers (DW-028): the 503 is a route-matched response —
5426    // the edge policy stamps it like every other.
5427    stamp_security_headers(
5428        &mut resp,
5429        gen.snapshot.route_table().effective_security_headers(idx),
5430    );
5431    resp
5432}
5433
5434/// The 405 response for a method outside the route's allowlist (DW-030):
5435/// `Allow` carries the route's configured methods (RFC 9110 10.2.1 — the
5436/// header's whole job is to name what the resource supports, so the
5437/// configured order is preserved verbatim) plus the uniform JSON
5438/// envelope.
5439fn method_not_allowed(allowed: &[String], rid: &str) -> Response<ProxyBody> {
5440    let allow = allowed
5441        .iter()
5442        .map(|m| m.trim())
5443        .collect::<Vec<_>>()
5444        .join(", ");
5445    let message = format!("method not allowed; the route allows {allow}");
5446    Response::builder()
5447        .status(StatusCode::METHOD_NOT_ALLOWED)
5448        .header(hyper::header::CONTENT_TYPE, "application/json")
5449        .header(hyper::header::ALLOW, allow)
5450        .body(ProxyBody::Full(Full::new(observability::envelope_body(
5451            "method_not_allowed",
5452            &message,
5453            rid,
5454        ))))
5455        .expect("static 405 response is valid")
5456}
5457
5458/// The 403 response for a denied authorization (DW-020): deliberately a
5459/// generic body — which list matched, which claim was absent, none of
5460/// it is the client's business (the reason is logged server-side only).
5461fn forbidden(rid: &str) -> Response<ProxyBody> {
5462    Response::builder()
5463        .status(StatusCode::FORBIDDEN)
5464        .header(hyper::header::CONTENT_TYPE, "application/json")
5465        .body(ProxyBody::Full(Full::new(observability::envelope_body(
5466            "forbidden",
5467            "forbidden",
5468            rid,
5469        ))))
5470        .expect("static 403 response is valid")
5471}
5472
5473/// The 401 response for an unauthenticated/invalid request (DW-019):
5474/// error-envelope body plus a `WWW-Authenticate` challenge built from the schemes
5475/// the current authenticator interprets.
5476fn unauthorized(challenge: &str, rid: &str) -> Response<ProxyBody> {
5477    Response::builder()
5478        .status(StatusCode::UNAUTHORIZED)
5479        .header(hyper::header::CONTENT_TYPE, "application/json")
5480        .header(&WWW_AUTHENTICATE, challenge)
5481        .body(ProxyBody::Full(Full::new(observability::envelope_body(
5482            "unauthorized",
5483            "unauthorized",
5484            rid,
5485        ))))
5486        .expect("static 401 response is valid")
5487}
5488
5489/// Remove every client-supplied `X-Consumer-*` header (spoof prevention,
5490/// DW-019): consumer identity is established by the gateway's
5491/// authenticator and injected as a trusted header on the proxied request;
5492/// a client claiming `X-Consumer-*` must never reach the upstream with it.
5493fn strip_consumer_headers(headers: &mut HeaderMap) {
5494    // PERF: fast-path the common case (clients almost never send
5495    // X-Consumer-* headers) to avoid allocating a Vec on every request.
5496    if !headers
5497        .keys()
5498        .any(|n| n.as_str().starts_with("x-consumer-"))
5499    {
5500        return;
5501    }
5502    let names: Vec<HeaderName> = headers
5503        .keys()
5504        .filter(|n| n.as_str().starts_with("x-consumer-"))
5505        .cloned()
5506        .collect();
5507    for name in names {
5508        headers.remove(&name);
5509    }
5510}
5511
5512/// DW-035: strip any inbound headers with the configured `X-Client-Cert`
5513/// prefix (case-insensitive) and inject the gateway's own
5514/// `X-Client-Cert-{Fingerprint,Subject-CN,Issuer-CN,Not-After}` from
5515/// the VERIFIED client certificate. Spoofing prevention: a client
5516/// cannot claim certificate identity upstream — the gateway overwrites
5517/// any inbound headers with the prefix. `Not-After` is formatted as an
5518/// RFC 3339 timestamp (the conventional HTTP-date-adjacent format for
5519/// certificate expiry); absent metadata is simply not injected (the
5520/// upstream sees fewer headers, never an empty value).
5521fn inject_client_cert_headers(
5522    headers: &mut HeaderMap,
5523    fwd: &crate::config::MtlsForwardHeaders,
5524    cert: &crate::security::authn::ClientCertificate,
5525) {
5526    // Strip inbound headers with the configured prefix (case-insensitive
5527    // match on the header name, per HTTP semantics).
5528    let prefix_lower = fwd.prefix.to_ascii_lowercase();
5529    let names: Vec<HeaderName> = headers
5530        .keys()
5531        .filter(|n| n.as_str().starts_with(&prefix_lower))
5532        .cloned()
5533        .collect();
5534    for name in names {
5535        headers.remove(&name);
5536    }
5537    // Inject the gateway-computed metadata. Each header is added only
5538    // when the value is present and encodable.
5539    let p = &fwd.prefix;
5540    // Fingerprint (colon-separated hex, always present).
5541    if let Ok(name) = HeaderName::from_str(&format!("{p}-Fingerprint")) {
5542        if let Ok(v) = HeaderValue::from_str(cert.fingerprint_colon()) {
5543            headers.insert(name, v);
5544        }
5545    }
5546    // Subject CN (present when the certificate carries a decodable CN).
5547    if let Some(cn) = cert.subject_cn() {
5548        if let Ok(name) = HeaderName::from_str(&format!("{p}-Subject-CN")) {
5549            if let Ok(v) = HeaderValue::from_str(cn) {
5550                headers.insert(name, v);
5551            }
5552        }
5553    }
5554    // Issuer CN (DW-035).
5555    if let Some(cn) = cert.issuer_cn() {
5556        if let Ok(name) = HeaderName::from_str(&format!("{p}-Issuer-CN")) {
5557            if let Ok(v) = HeaderValue::from_str(cn) {
5558                headers.insert(name, v);
5559            }
5560        }
5561    }
5562    // Not-After as RFC 3339 timestamp (DW-035).
5563    if let Some(secs) = cert.not_after() {
5564        if let Ok(name) = HeaderName::from_str(&format!("{p}-Not-After")) {
5565            if let Some(ts) = unix_secs_to_rfc3339(secs) {
5566                if let Ok(v) = HeaderValue::from_str(&ts) {
5567                    headers.insert(name, v);
5568                }
5569            }
5570        }
5571    }
5572}
5573
5574/// Format a Unix epoch seconds value as an RFC 3339 timestamp (DW-035):
5575/// `YYYY-MM-DDTHH:MM:SSZ` (UTC, the `Z` suffix). Returns `None` for
5576/// values outside the representable range (before 1970 or after
5577/// 9999-12-31T23:59:59Z). Hand-rolled to avoid pulling a datetime
5578/// dependency — the calendar math is fixed and well-known.
5579fn unix_secs_to_rfc3339(secs: i64) -> Option<String> {
5580    if secs < 0 {
5581        return None;
5582    }
5583    let secs = u64::try_from(secs).ok()?;
5584    // Days since 1970-01-01 and seconds within the day.
5585    let days = secs / 86_400;
5586    let rem = secs % 86_400;
5587    let hour = rem / 3600;
5588    let minute = (rem % 3600) / 60;
5589    let second = rem % 60;
5590    // Civil date from days since epoch (Howard Hinnant's algorithm,
5591    // public domain — works for any non-negative day count).
5592    let z = days as i64 + 719_468;
5593    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
5594    let doe = z - era * 146_097;
5595    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
5596    let y = yoe + era * 400;
5597    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
5598    let mp = (5 * doy + 2) / 153;
5599    let d = doy - (153 * mp + 2) / 5 + 1;
5600    let m = if mp < 10 { mp + 3 } else { mp - 9 };
5601    let y = if m <= 2 { y + 1 } else { y };
5602    if y > 9999 {
5603        return None;
5604    }
5605    Some(format!(
5606        "{y:04}-{m:02}-{d:02}T{hour:02}:{minute:02}:{second:02}Z"
5607    ))
5608}
5609
5610/// The 429 response for a denied rate limit (DW-017) or a denied
5611/// consumer request budget (DW-033 — same client-facing contract):
5612/// `Retry-After` in whole seconds (already rounded up, minimum 1) plus
5613/// the binding window's `X-RateLimit-*` headers. u64 limits: quota
5614/// budgets are u64 config values (a monthly cap can exceed u32 range);
5615/// rate-limit callers convert.
5616fn rate_limited(
5617    limit: u64,
5618    remaining: u64,
5619    reset_epoch_s: u64,
5620    retry_after_s: u32,
5621    rid: &str,
5622) -> Response<ProxyBody> {
5623    let mut builder = Response::builder()
5624        .status(StatusCode::TOO_MANY_REQUESTS)
5625        .header(hyper::header::CONTENT_TYPE, "application/json")
5626        .header(hyper::header::RETRY_AFTER, retry_after_s.max(1).to_string());
5627    builder = builder
5628        .header(X_RATELIMIT_LIMIT, limit.to_string())
5629        .header(X_RATELIMIT_REMAINING, remaining.to_string())
5630        .header(X_RATELIMIT_RESET, reset_epoch_s.to_string());
5631    builder
5632        .body(ProxyBody::Full(Full::new(observability::envelope_body(
5633            "rate_limit_exceeded",
5634            "rate limit exceeded",
5635            rid,
5636        ))))
5637        .expect("static 429 response is valid")
5638}
5639
5640/// Stamp the gateway's rate headers onto a response. Uses `insert`, so
5641/// any upstream-sent `X-RateLimit-*` values are silently REPLACED: the
5642/// gateway is the source of truth for rate accounting its clients see.
5643fn apply_rate_headers(headers: &mut HeaderMap, limit: u32, remaining: u32, reset_epoch_s: u64) {
5644    headers.insert(
5645        X_RATELIMIT_LIMIT,
5646        HeaderValue::from_str(&limit.to_string()).expect("u32 header"),
5647    );
5648    headers.insert(
5649        X_RATELIMIT_REMAINING,
5650        HeaderValue::from_str(&remaining.to_string()).expect("u32 header"),
5651    );
5652    headers.insert(
5653        X_RATELIMIT_RESET,
5654        HeaderValue::from_str(&reset_epoch_s.to_string()).expect("u64 header"),
5655    );
5656}
5657
5658/// Apply the route's non-path criteria. All criteria are AND-ed. Empty
5659/// method list = all methods; host matches the `Host` header
5660/// (case-insensitive, with or without a port); headers must all be present
5661/// with exact values; query and cookie entries match on presence, or on
5662/// exact value when one is configured. `accept` is the route's COMPILED
5663/// `match.accept` media type (`RouteTable::accept_media_type`), never the
5664/// raw config string — padding and case are normalized once at snapshot
5665/// compile, so a padded spelling matches exactly like its trimmed form.
5666/// Public so router golden-file tests (tests/router_golden.rs) can
5667/// exercise the full resolution pipeline without a live upstream.
5668pub fn route_applies<B>(m: &RouteMatch, accept: Option<&str>, req: &Request<B>) -> bool {
5669    if let Some(want) = &m.host {
5670        let Some(got) = req.headers().get(HOST).and_then(|v| v.to_str().ok()) else {
5671            return false;
5672        };
5673        let got_host = got.rsplit_once(':').map(|(h, _)| h).unwrap_or(got);
5674        if !got.eq_ignore_ascii_case(want) && !got_host.eq_ignore_ascii_case(want) {
5675            return false;
5676        }
5677    }
5678    if !m.methods.is_empty()
5679        && !m
5680            .methods
5681            .iter()
5682            .any(|want| req.method().as_str().eq_ignore_ascii_case(want.trim()))
5683    {
5684        return false;
5685    }
5686    for (name, value) in &m.headers {
5687        match req.headers().get(name) {
5688            Some(got) => {
5689                if got.as_bytes() != value.as_bytes() {
5690                    return false;
5691                }
5692            }
5693            None => return false,
5694        }
5695    }
5696    // Media-type version selection (DW-048): applied like every other
5697    // criterion (AND-ed, after path resolution, no fallthrough) — see
5698    // the versioning module docs for the shape and its limits. The
5699    // comparison key is the compiled normalized form, not `m.accept`:
5700    // the raw config string never reaches the hot path.
5701    if let Some(want) = accept {
5702        if !crate::dataplane::versioning::accept_matches(req.headers(), want) {
5703            return false;
5704        }
5705    }
5706    let query = req.uri().query();
5707    for want in &m.query {
5708        if !query_param_matches(query, want) {
5709            return false;
5710        }
5711    }
5712    for want in &m.cookies {
5713        let present = req.headers().get_all(COOKIE).iter().any(|header| {
5714            header
5715                .to_str()
5716                .map(|raw| {
5717                    parse_cookies(raw)
5718                        .iter()
5719                        .any(|(n, v)| name_value_hits(n, v, want))
5720                })
5721                .unwrap_or(false)
5722        });
5723        if !present {
5724            return false;
5725        }
5726    }
5727    true
5728}
5729
5730/// One `key=value` (or bare `key`) pair of a query string. No
5731/// percent-decoding in v1: matching is over the raw bytes the client sent.
5732#[doc(hidden)]
5733pub fn query_param_matches(query: Option<&str>, want: &NameValueMatch) -> bool {
5734    let Some(raw) = query else { return false };
5735    raw.split('&').any(|pair| {
5736        let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
5737        name_value_hits(name, value, want)
5738    })
5739}
5740
5741/// Parse a `Cookie` request header into (name, value) pairs. Simple
5742/// RFC-6265-shaped parsing: split on `;`, trim spaces, split each pair on
5743/// the FIRST `=`. No quoting/encoding handling in v1 — values are matched
5744/// exactly as sent. Public for the router golden-file tests.
5745pub fn parse_cookies(header: &str) -> Vec<(&str, &str)> {
5746    header
5747        .split(';')
5748        .filter_map(|pair| {
5749            let pair = pair.trim();
5750            if pair.is_empty() {
5751                return None;
5752            }
5753            Some(pair.split_once('=').unwrap_or((pair, "")))
5754        })
5755        .collect()
5756}
5757
5758fn name_value_hits(name: &str, value: &str, want: &NameValueMatch) -> bool {
5759    if name != want.name {
5760        return false;
5761    }
5762    match &want.value {
5763        Some(v) => value == v,
5764        None => true,
5765    }
5766}
5767
5768/// Apply a proxy action's path rewrite (DW-010). `params` are the
5769/// `{param}` captures of the exact-template match for this request (empty
5770/// for regex/prefix routes); they are available to `regex` substitutions
5771/// as `$name` references that miss the pattern's own capture groups.
5772/// Returns the rewritten path; a rewrite that does not apply (e.g.
5773/// `strip_prefix` on a path that does not start with the prefix) leaves
5774/// the path unchanged. The query string is NOT touched here — the caller
5775/// re-attaches it. Public for the router golden-file tests.
5776pub fn apply_path_rewrite(
5777    route: &Route,
5778    table: &RouteTable,
5779    idx: usize,
5780    path: &str,
5781    params: &[(String, String)],
5782) -> Option<String> {
5783    let rewrite = match &route.action {
5784        RouteAction::Proxy { rewrite: Some(rw) } => rw,
5785        _ => return None,
5786    };
5787    match rewrite {
5788        PathRewrite::StripPrefix {} => {
5789            let prefix = route.r#match.path.value.trim_end_matches('/');
5790            match path.strip_prefix(prefix) {
5791                Some("") => Some("/".to_string()),
5792                Some(rest) if rest.starts_with('/') => Some(rest.to_string()),
5793                Some(rest) => Some(format!("/{rest}")),
5794                None => None,
5795            }
5796        }
5797        PathRewrite::ReplacePrefix {
5798            prefix,
5799            replacement,
5800        } => path
5801            .strip_prefix(prefix)
5802            .map(|rest| format!("{replacement}{rest}")),
5803        PathRewrite::Regex { substitution, .. } => match table.rewrite_regex(idx) {
5804            Some(re) => {
5805                // PERF-04 (#206): use captures() + the precompiled
5806                // substitution template instead of replace() with a
5807                // closure, avoiding per-request re-parsing of the
5808                // `$1`/`${name}` syntax and the closure's Cow
5809                // allocation. The substitution is compiled once at
5810                // snapshot compile time (CompiledSubstitution).
5811                // Like `re.replace()`, only the FIRST match is
5812                // replaced; the unmatched prefix and suffix of the
5813                // path are preserved verbatim.
5814                match re.captures(path) {
5815                    Some(caps) => {
5816                        let m = caps.get(0).expect("regex match exists");
5817                        let pre = &path[..m.start()];
5818                        let post = &path[m.end()..];
5819                        match table.rewrite_substitution(idx) {
5820                            Some(subst) => {
5821                                let mut out = String::with_capacity(
5822                                    pre.len() + post.len() + subst.capacity(),
5823                                );
5824                                out.push_str(pre);
5825                                out.push_str(&subst.expand(&caps, params));
5826                                out.push_str(post);
5827                                Some(out)
5828                            }
5829                            // Generation-tear backstop: the regex and
5830                            // substitution are built together, so this
5831                            // is unreachable. Fall back to the legacy
5832                            // path.
5833                            None => {
5834                                let expanded = expand_substitution(substitution, &caps, params);
5835                                Some(format!("{pre}{expanded}{post}"))
5836                            }
5837                        }
5838                    }
5839                    None => None,
5840                }
5841            }
5842            None => None,
5843        },
5844    }
5845}
5846
5847/// Expand `$1` / `${name}` references against a regex match. Lookup order
5848/// for a name: numeric -> pattern capture group by index; otherwise ->
5849/// pattern named group, then the route's `{param}` path capture; unknown
5850/// references expand to the empty string. A lone `$` (end of string, or
5851/// followed by a non-identifier character) is kept literally.
5852pub fn expand_substitution(
5853    substitution: &str,
5854    caps: &regex::Captures<'_>,
5855    params: &[(String, String)],
5856) -> String {
5857    let mut out = String::with_capacity(substitution.len());
5858    let mut rest = substitution;
5859    while let Some(dollar) = rest.find('$') {
5860        out.push_str(&rest[..dollar]);
5861        let after = &rest[dollar + 1..];
5862        if after.is_empty() {
5863            out.push('$');
5864            rest = after;
5865            break;
5866        }
5867        if let Some(braced) = after.strip_prefix('{') {
5868            match braced.find('}') {
5869                Some(end) => {
5870                    out.push_str(resolve_ref(&braced[..end], caps, params));
5871                    rest = &braced[end + 1..];
5872                }
5873                None => {
5874                    out.push('$');
5875                    rest = after;
5876                }
5877            }
5878        } else {
5879            let end = after
5880                .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
5881                .unwrap_or(after.len());
5882            if end == 0 {
5883                out.push('$');
5884                rest = after;
5885            } else {
5886                out.push_str(resolve_ref(&after[..end], caps, params));
5887                rest = &after[end..];
5888            }
5889        }
5890    }
5891    out.push_str(rest);
5892    out
5893}
5894
5895fn resolve_ref<'a>(
5896    name: &str,
5897    caps: &'a regex::Captures<'_>,
5898    params: &'a [(String, String)],
5899) -> &'a str {
5900    if let Ok(i) = name.parse::<usize>() {
5901        return caps.get(i).map(|m| m.as_str()).unwrap_or("");
5902    }
5903    caps.name(name)
5904        .map(|m| m.as_str())
5905        .or_else(|| {
5906            params
5907                .iter()
5908                .find(|(n, _)| n == name)
5909                .map(|(_, v)| v.as_str())
5910        })
5911        .unwrap_or("")
5912}
5913
5914/// Extract body-path analytics dimensions (DW-093) from a buffered
5915/// request body. Only dimensions with `source: body_path` are
5916/// considered; the body is parsed as JSON once and each dimension's
5917/// JSON pointer is resolved against it. Non-JSON bodies, unresolved
5918/// pointers, non-string values, and over-128-byte values are skipped
5919/// for that request (the same bounded-cardinality posture as
5920/// header-sourced dimensions). Reuses the RFC 6901 `JsonPointer` from
5921/// `config::transforms` — no new JSON-pointer code.
5922fn extract_body_path_dims(
5923    dimensions: &[crate::config::AnalyticsDimension],
5924    body_bytes: &Bytes,
5925    rec: &mut AccessRecord,
5926) {
5927    if dimensions
5928        .iter()
5929        .all(|d| d.effective_source() != crate::config::DimensionSource::BodyPath)
5930    {
5931        return;
5932    }
5933    let Ok(doc) = serde_json::from_slice::<serde_json::Value>(body_bytes) else {
5934        return;
5935    };
5936    for dim in dimensions {
5937        if dim.effective_source() != crate::config::DimensionSource::BodyPath {
5938            continue;
5939        }
5940        let Some(path) = &dim.body_path else { continue };
5941        let Some(pointer) = crate::config::transforms::JsonPointer::parse(path) else {
5942            continue;
5943        };
5944        if let Some(value) = resolve_json_pointer(&pointer, &doc) {
5945            if !value.is_empty() && value.len() <= 128 {
5946                rec.custom.push((dim.name.clone(), value));
5947            }
5948        }
5949    }
5950}
5951
5952/// Resolve a JSON pointer to a string value (DW-093): walks the
5953/// document following the pointer's tokens, returning the scalar as a
5954/// string. Object keys, array indices, and the root pointer are
5955/// handled; a miss or a non-scalar (object/array) target returns
5956/// `None` (the dimension is skipped for that request).
5957fn resolve_json_pointer(
5958    pointer: &crate::config::transforms::JsonPointer,
5959    doc: &serde_json::Value,
5960) -> Option<String> {
5961    if pointer.is_root() {
5962        return doc.as_str().map(str::to_string);
5963    }
5964    let tokens = pointer.tokens();
5965    let indexes = pointer.indexes();
5966    let mut cur = doc;
5967    for (i, token) in tokens.iter().enumerate() {
5968        let index = indexes.get(i).copied().flatten();
5969        cur = match cur {
5970            serde_json::Value::Object(map) => map.get(token)?,
5971            serde_json::Value::Array(items) => {
5972                let idx = index?;
5973                items.get(idx)?
5974            }
5975            _ => return None,
5976        };
5977    }
5978    match cur {
5979        serde_json::Value::String(s) => Some(s.clone()),
5980        serde_json::Value::Number(n) => Some(n.to_string()),
5981        serde_json::Value::Bool(b) => Some(b.to_string()),
5982        _ => None,
5983    }
5984}
5985
5986// Eleven parameters is the price of keeping every input explicit on the
5987// per-request proxy path (no per-request allocation of a context struct);
5988// DW-021's request id, access record, and metrics are the newest. The
5989// `service` reference is resolved once by the caller (PERF: this function
5990// used to repeat the linear name scan over `gateway.services` per request).
5991// pub(super): the response cache's background revalidation (DW-037)
5992// drives the same forward path with a synthetic conditional GET.
5993#[allow(clippy::too_many_arguments)]
5994pub(super) async fn proxy_request<B>(
5995    gen: &Generation,
5996    peer: IpAddr,
5997    mut req: Request<B>,
5998    route: &Route,
5999    route_idx: usize,
6000    service: Option<&crate::config::Service>,
6001    params: &[(String, String)],
6002    global_permit: &mut Option<OwnedSemaphorePermit>,
6003    identity: Option<&Identity>,
6004    rid: &str,
6005    rec: &mut AccessRecord,
6006    obs_arc: &Arc<Observability>,
6007    client_cert: Option<&Arc<crate::security::authn::ClientCertificate>>,
6008    oauth2_token_cache: &Arc<crate::security::oauth2::OAuth2TokenCache>,
6009    applicable_policies: &[String],
6010) -> Response<ProxyBody>
6011where
6012    B: hyper::body::Body<Data = Bytes> + Send + 'static,
6013    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
6014{
6015    let obs: &Observability = obs_arc;
6016    let gateway = gen.snapshot.gateway();
6017    let Some(service) = service else {
6018        // Validation rejects dangling references, so this is a generation
6019        // tear; keep it classified rather than panicking.
6020        return simple(
6021            StatusCode::INTERNAL_SERVER_ERROR,
6022            "unknown_service",
6023            "route targets unknown service",
6024            rid,
6025        );
6026    };
6027    // DW-040: resolve the dispatch target. A split service picks a
6028    // branch upstream by the deterministic weighted hash (the sticky
6029    // cookie's value when configured and presented, else the request
6030    // id — see `dataplane::split`); a single-target service resolves
6031    // its one upstream as ever. The sticky value doubles as the
6032    // dispatch HASH KEY below, so an `ip_hash` branch upstream pins
6033    // the session to one endpoint through the same ketama ring that
6034    // pins client IPs.
6035    let mut sticky_set_cookie: Option<String> = None;
6036    let sticky_key: Option<String> = service.sticky.as_ref().map(|sticky| {
6037        match read_cookie(req.headers(), &sticky.cookie) {
6038            Some(value) => value,
6039            None => {
6040                // First request of the session: mint the affinity
6041                // handle NOW so the branch picked below is exactly the
6042                // branch the cookie pins — stickiness holds from the
6043                // very first response. Opaque, not a secret (see the
6044                // split module docs); recorded so the response can
6045                // carry it.
6046                let value = mint_affinity_id();
6047                sticky_set_cookie = Some(format!(
6048                    "{}={}; Path=/; Max-Age={}",
6049                    sticky.cookie, value, sticky.ttl_s
6050                ));
6051                value
6052            }
6053        }
6054    });
6055    let dispatch_key: String = sticky_key.clone().unwrap_or_else(|| rid.to_string());
6056    // DW-091: track whether this request hit the canary side of a
6057    // service split (index 1 = canary, 0 = baseline), so the response
6058    // path can feed the outcome to the auto-canary controller.
6059    let mut canary_split_index: Option<usize> = None;
6060    let handle: Arc<crate::dataplane::upstream::UpstreamHandle> =
6061        if let Some(split) = gen.registry.split_for(&service.name) {
6062            let (picked, idx) = split.pick_with_index(&dispatch_key);
6063            obs.record_split_pick(&service.name, picked.name());
6064            // Only track the index when canary analysis is compiled
6065            // for this service (the fast path: no controller = no
6066            // record_outcome call below).
6067            if gen.canary().is_some_and(|c| !c.is_empty()) {
6068                canary_split_index = Some(idx);
6069            }
6070            Arc::clone(picked)
6071        } else {
6072            let Some(name) = &service.upstream else {
6073                // Validation requires exactly one of upstream/split, and a
6074                // split service resolved to None only when its compile
6075                // failed loudly (registry logs it); classified, no panic.
6076                return simple(
6077                    StatusCode::INTERNAL_SERVER_ERROR,
6078                    "unknown_upstream",
6079                    "unknown upstream",
6080                    rid,
6081                );
6082            };
6083            let Some(handle) = gen.registry.get(name) else {
6084                return simple(
6085                    StatusCode::INTERNAL_SERVER_ERROR,
6086                    "unknown_upstream",
6087                    "unknown upstream",
6088                    rid,
6089                );
6090            };
6091            handle
6092        };
6093    rec.upstream = Some(handle.name().to_string());
6094
6095    // DP-07 (#236): extract the hash_on key from the request before
6096    // `req` is consumed by `into_parts()` below. The hash_on config
6097    // on the upstream determines what to hash (cookie, header, or
6098    // client IP). Falls back to None (client IP) when the cookie/header
6099    // is absent or hash_on is not configured.
6100    let hash_on_key: Option<String> = handle.hash_on().and_then(|hash_on| match hash_on {
6101        crate::config::HashOn::Cookie { cookie } => read_cookie(req.headers(), cookie),
6102        crate::config::HashOn::Header { header } => req
6103            .headers()
6104            .get(header)
6105            .and_then(|v| v.to_str().ok())
6106            .map(|s| s.to_string()),
6107        crate::config::HashOn::ClientIp => None,
6108    });
6109
6110    let wants_upgrade = req.headers().contains_key(UPGRADE);
6111    if wants_upgrade && req.version() == Version::HTTP_2 {
6112        return simple(
6113            StatusCode::NOT_IMPLEMENTED,
6114            "upgrade_not_supported",
6115            "protocol upgrade is not supported over HTTP/2",
6116            rid,
6117        );
6118    }
6119
6120    // WebSocket policy (DW-039): the origin gate runs at the proxy
6121    // action — after authn/authz/rate limit (the documented request
6122    // path: the origin allowlist is a ROUTE policy about the upgrade,
6123    // not an identity claim) and BEFORE any upstream contact (no dial,
6124    // no breaker observation, no pick). Applies only to requests
6125    // offering a WEBSOCKET upgrade; other upgrades are untouched.
6126    let ws_policy = route.websocket.as_ref();
6127    let ws_police = if wants_upgrade
6128        && ws_policy.is_some_and(|_| crate::dataplane::websocket::offers_websocket(req.headers()))
6129    {
6130        if let Some(ws) = ws_policy {
6131            match crate::dataplane::websocket::handshake_verdict(req.headers(), ws) {
6132                crate::dataplane::websocket::Handshake::OriginDenied => {
6133                    obs.record_websocket_policy(&route.name, "origin_denied");
6134                    tracing::warn!(
6135                        code = "websocket_origin_denied",
6136                        request_id = %rid,
6137                        route = %route.name,
6138                        origin = req
6139                            .headers()
6140                            .get(ORIGIN)
6141                            .and_then(|v| v.to_str().ok())
6142                            .unwrap_or("(none)"),
6143                        "websocket upgrade denied: origin not in the route allowlist"
6144                    );
6145                    return simple(
6146                        StatusCode::FORBIDDEN,
6147                        "websocket_origin_denied",
6148                        "websocket origin not allowed",
6149                        rid,
6150                    );
6151                }
6152                crate::dataplane::websocket::Handshake::Allowed => {}
6153            }
6154        }
6155        // DP-05 (#253): create a policing decision when ANY post-upgrade
6156        // policy is configured — rate limiting, frame size cap, or idle
6157        // timeout. The policer wrapper handles each independently.
6158        let ws = ws_policy.unwrap();
6159        let has_policy = ws.max_frames_per_sec.is_some()
6160            || ws.max_frame_size_bytes.is_some()
6161            || ws.idle_timeout_s.is_some();
6162        if has_policy {
6163            Some(WsPoliceDecision {
6164                route: route.name.clone(),
6165                rate: ws.max_frames_per_sec,
6166                max_frame_size: ws.max_frame_size_bytes,
6167                idle_timeout: ws.idle_timeout_s.map(std::time::Duration::from_secs),
6168            })
6169        } else {
6170            None
6171        }
6172    } else {
6173        None
6174    };
6175
6176    // gRPC (DW-039): an h2 request with an `application/grpc` content
6177    // type. Its `grpc-timeout` header is the RPC's TOTAL budget — arm
6178    // it as the forward deadline (bounding the attempt below) and let
6179    // the same instant keep ticking through the response body (the
6180    // upstream body's absolute deadline). Parse failures are ignored:
6181    // a malformed timeout is the caller's bug, not the gateway's.
6182    // Health-report split (deliberate): a BODY-phase deadline crossing
6183    // reports a passive-health failure (the endpoint accepted the RPC
6184    // and then starved it), while a FORWARD-phase cut does NOT (the
6185    // client's own budget expired before the endpoint answered — the
6186    // gateway cancelling on the caller's clock is not endpoint
6187    // misbehavior, and the operator's fixed `timeouts.read_ms` already
6188    // bounds genuinely hung endpoints).
6189    let grpc = grpc_request(req.headers(), req.version());
6190    let grpc_deadline = grpc
6191        .then(|| {
6192            req.headers()
6193                .get("grpc-timeout")
6194                .and_then(|v| v.to_str().ok())
6195                .and_then(parse_grpc_timeout)
6196        })
6197        .flatten()
6198        .map(|d| std::time::Instant::now() + d);
6199
6200    // Path rewrite (DW-010): applied to the path component only, before
6201    // anything is sent upstream; the inbound query string is re-attached
6202    // verbatim. Validation rejects relative substitutions, so a parse
6203    // failure here should be unreachable for a freshly compiled config —
6204    // the no-op fallback below stays as defense-in-depth (e.g. a snapshot
6205    // compiled before this rule): keep the original path, never a 500,
6206    // never a panic.
6207    let inbound = req.uri().clone();
6208    // PERF: apply_path_rewrite returns None when no rewrite is configured
6209    // (the common case), avoiding a path.to_string() allocation + string
6210    // comparison on every request.
6211    if let Some(new_path) = apply_path_rewrite(
6212        route,
6213        gen.snapshot.route_table(),
6214        route_idx,
6215        inbound.path(),
6216        params,
6217    ) {
6218        if new_path != inbound.path() {
6219            let pq = match inbound.query() {
6220                Some(q) => format!("{new_path}?{q}"),
6221                None => new_path,
6222            };
6223            if let Ok(uri) = pq.parse() {
6224                *req.uri_mut() = uri;
6225            }
6226        }
6227    }
6228
6229    // Query transforms (DW-028): the one place the forwarded query may
6230    // change. After the path rewrite (which re-attaches the ORIGINAL
6231    // query verbatim — DW-010 never saw transforms) and before route
6232    // matching is a memory: matching, limits, authn, and rate limiting
6233    // all evaluated the request's ORIGINAL query. Untouched pairs keep
6234    // their exact bytes; only pairs a named op touches are re-encoded.
6235    if let Some(ops) = route
6236        .transforms
6237        .as_ref()
6238        .and_then(|t| t.request.as_ref())
6239        .and_then(|req_t| req_t.query.as_ref())
6240    {
6241        if let Some(new_uri) = crate::dataplane::transforms::apply_query_ops(req.uri(), ops) {
6242            *req.uri_mut() = new_uri;
6243        }
6244    }
6245
6246    // Plugin target rewrite (DW-157): the `:path`/`:method`/
6247    // `:authority` writes the `request_headers` chain CHANGED,
6248    // validated at capture (`plugin_dispatch::pseudo_header_rewrite`).
6249    // Applied HERE — after the route's own rewrite and the query
6250    // transforms — so the plugin has the FINAL say over the upstream
6251    // request, and only here: no route re-match (the route, the
6252    // upstream pick, and every policy phase ran on the ORIGINAL
6253    // target), and non-proxy actions never see it. `:path` is the
6254    // whole target, query string included: a plugin that writes a
6255    // path without a query drops the query. The `:authority` value
6256    // rides one more extension hop — the Host header is the upstream
6257    // dispatch's to own (see `plugin_dispatch::PluginForwardHost`).
6258    if let Some(rewrite) = req
6259        .extensions_mut()
6260        .remove::<plugin_dispatch::PluginTargetRewrite>()
6261    {
6262        if let Some(method) = rewrite.method {
6263            *req.method_mut() = method;
6264        }
6265        if let Some(target) = rewrite.path {
6266            let mut parts = http::uri::Parts::default();
6267            parts.path_and_query = Some(target);
6268            if let Ok(uri) = hyper::Uri::from_parts(parts) {
6269                *req.uri_mut() = uri;
6270            }
6271        }
6272        if let Some(host) = rewrite.authority {
6273            req.extensions_mut()
6274                .insert(plugin_dispatch::PluginForwardHost(host));
6275        }
6276    }
6277
6278    // The inbound upgrade handle, if the listener enabled upgrades: pulled
6279    // out of the request extensions so the request itself can be rebuilt.
6280    let on_client_upgrade = req.extensions_mut().remove::<hyper::upgrade::OnUpgrade>();
6281
6282    // Forwarded headers: rebuild XFF/X-Real-IP from the direct peer under
6283    // the trusted-proxies rule; Host becomes the upstream authority.
6284    let trusted = peer_is_trusted(&gateway.trusted_proxies, peer);
6285    // PERF: format the peer to a String ONCE and reuse for both XFF and
6286    // X-Real-IP (previously peer.to_string() was called twice per
6287    // request — two heap allocations for the same IpAddr).
6288    let peer_str = peer.to_string();
6289    // PERF: only read and allocate the inbound XFF when the peer is
6290    // trusted (the common case: default trusted_proxies is empty, so
6291    // the inbound XFF is always discarded — the old code allocated it
6292    // unconditionally via to_string() then threw it away).
6293    let xff = if trusted {
6294        match req
6295            .headers()
6296            .get(&X_FORWARDED_FOR)
6297            .and_then(|v| v.to_str().ok())
6298        {
6299            Some(existing) => format!("{existing}, {peer_str}"),
6300            None => peer_str.clone(),
6301        }
6302    } else {
6303        peer_str.clone()
6304    };
6305
6306    // Tunneling rebuilds a `Connection: Upgrade` header on the forwarded
6307    // request: stripping it would leave conformant backends (which require
6308    // the Connection token to offer a 101) declining every upgrade.
6309    let (mut parts, body) = req.into_parts();
6310    let conn_tokens = strip_hop_by_hop(&mut parts.headers, wants_upgrade, grpc);
6311    if wants_upgrade {
6312        let mut tokens = conn_tokens;
6313        if !tokens.iter().any(|t| t.eq_ignore_ascii_case("upgrade")) {
6314            tokens.push("Upgrade".to_string());
6315        }
6316        if let Ok(v) = HeaderValue::from_str(&tokens.join(", ")) {
6317            parts.headers.insert(CONNECTION, v);
6318        }
6319    }
6320    // Host is rebuilt by the upstream handle from the load-balancer pick
6321    // (the gateway, not the client, names the origin it dials); see
6322    // UpstreamHandle::send_with_hash_key.
6323    if let Ok(v) = HeaderValue::from_str(&xff) {
6324        parts.headers.insert(&X_FORWARDED_FOR, v);
6325    }
6326    if let Ok(v) = HeaderValue::from_str(&peer_str) {
6327        parts.headers.insert(&X_REAL_IP, v);
6328    }
6329    // Trusted consumer identity upstream (DW-019): injected by the gateway
6330    // AFTER inbound X-Consumer-* headers were stripped in `handle`, so the
6331    // upstream can trust `X-Consumer-Name` as the authenticated consumer
6332    // (absent for anonymous traffic). Strip + inject, never pass-through.
6333    // AI-16 (#204): also inject X-Consumer-Type for first-class agent
6334    // principal attribution so upstreams can distinguish agent from
6335    // user traffic.
6336    if let Some(identity) = identity {
6337        if let Ok(v) = HeaderValue::from_str(&identity.consumer_name) {
6338            parts.headers.insert(&X_CONSUMER_NAME, v);
6339        }
6340        let ctype = consumer_type_str(identity.consumer_type);
6341        if let Ok(v) = HeaderValue::from_str(ctype) {
6342            parts
6343                .headers
6344                .insert(http::HeaderName::from_static("x-consumer-type"), v);
6345        }
6346    }
6347
6348    // DW-035: X-Client-Cert-* identity-forwarding headers. When
6349    // `mtls_forward_headers` is enabled and the connection presented a
6350    // verified client certificate, the gateway STRIPS any inbound
6351    // headers with the configured prefix (spoofing prevention) and
6352    // injects its own `X-Client-Cert-{Fingerprint,Subject-CN,Issuer-CN,
6353    // Not-After}`. The certificate is already verified at the TLS
6354    // layer; these headers carry metadata the upstream uses for
6355    // audit/logging, not for authentication (the gateway's authn
6356    // already resolved the consumer).
6357    if let Some(fwd) = gateway.mtls_forward_headers.as_ref().filter(|f| f.enabled) {
6358        if let Some(cert) = &client_cert {
6359            inject_client_cert_headers(&mut parts.headers, fwd, cert);
6360        }
6361    }
6362
6363    // DW-035: OAuth2 client-credentials Bearer token. When the resolved
6364    // upstream has an `oauth2_client_credentials` block, the gateway
6365    // obtains an access token and REPLACES any client-supplied
6366    // `Authorization` header with `Bearer <token>` — the upstream sees
6367    // the GATEWAY's token, not the client's. A token-endpoint failure
6368    // surfaces as 502 `oauth2_token_unavailable` (never proxying
6369    // unauthenticated). The token fetch is on the request path (first
6370    // request / refresh); cached tokens return immediately.
6371    if let Some(oauth2_client) = gen.oauth2_client(handle.name()) {
6372        match oauth2_client.token(oauth2_token_cache).await {
6373            Ok(token) => {
6374                if let Ok(v) = HeaderValue::from_str(&format!("Bearer {token}")) {
6375                    parts.headers.insert(hyper::header::AUTHORIZATION, v);
6376                }
6377            }
6378            Err(e) => {
6379                tracing::error!(
6380                    code = "oauth2_token_unavailable",
6381                    request_id = %rid,
6382                    upstream = %handle.name(),
6383                    "oauth2 token acquisition failed: {e}"
6384                );
6385                let mut resp = simple(
6386                    StatusCode::BAD_GATEWAY,
6387                    "oauth2_token_unavailable",
6388                    "oauth2 token endpoint unavailable",
6389                    rid,
6390                );
6391                stamp_security_headers(
6392                    &mut resp,
6393                    gen.snapshot
6394                        .route_table()
6395                        .effective_security_headers(route_idx),
6396                );
6397                return resp;
6398            }
6399        }
6400    }
6401
6402    // Request header transforms (DW-028): LAST hands on the forwarded
6403    // headers, after hop-by-hop stripping and the gateway's trusted
6404    // injections — ops see (and may shape, including removing the
6405    // trust headers; the operator owns the upstream's contract) the
6406    // near-final request. Framing/hop-by-hop names were rejected by
6407    // validation, so the ops cannot disturb the pipeline's own
6408    // headers here.
6409    if let Some(ops) = route
6410        .transforms
6411        .as_ref()
6412        .and_then(|t| t.request.as_ref())
6413        .and_then(|req_t| req_t.headers.as_ref())
6414    {
6415        crate::dataplane::transforms::apply_header_ops(&mut parts.headers, ops);
6416    }
6417
6418    // Request body transform (DW-028): BEFORE the retry buffering, so
6419    // a retried attempt replays the TRANSFORMED bytes, and before any
6420    // upstream contact. The compiled ops come from the snapshot table
6421    // (pointers parsed once at compile); a None policy leaves the
6422    // body streaming untouched. Reading through the wrappers below
6423    // (limit counting, HMAC digest fold) keeps enforcement on the
6424    // CLIENT's original bytes — the transform shapes what the upstream
6425    // receives, never what the gateway verifies.
6426    let mut transformed_body: Option<Bytes> = None;
6427    let mut body_rest: Option<B> = Some(body);
6428    if let Some(compiled) = gen.snapshot.route_table().request_body_ops(route_idx) {
6429        match crate::dataplane::transforms::transform_request_body(
6430            body_rest
6431                .take()
6432                .expect("body present before the transform step"),
6433            compiled,
6434            &parts.headers,
6435        )
6436        .await
6437        {
6438            // No transform applied (non-JSON, encoded, or empty): the
6439            // ORIGINAL body streams on — the streaming guarantee of
6440            // this feature.
6441            crate::dataplane::transforms::RequestBodyOutcome::Original(b) => body_rest = Some(b),
6442            crate::dataplane::transforms::RequestBodyOutcome::Replaced(bytes) => {
6443                // Framing: the forwarded body IS these bytes now —
6444                // rewrite the declared length to match (a stale length
6445                // would misframe the hop; the value is always
6446                // representable).
6447                parts.headers.insert(
6448                    hyper::header::CONTENT_LENGTH,
6449                    HeaderValue::from_str(&bytes.len().to_string())
6450                        .expect("a length is a valid header value"),
6451                );
6452                transformed_body = Some(bytes);
6453            }
6454            crate::dataplane::transforms::RequestBodyOutcome::Failed(e) => {
6455                return request_body_transform_failed(e, rid);
6456            }
6457        }
6458    }
6459
6460    let out_req_parts = parts;
6461    // The peer IP is the ip_hash key (X-Real-IP peer; other algorithms
6462    // ignore it); every attempt re-picks the endpoint through the
6463    // balancer (so health ejection and weights apply per attempt).
6464    //
6465    // ---- DW-014: retry wiring ------------------------------------------
6466    // Idempotency: GET/HEAD/OPTIONS/TRACE/PUT are retry-eligible by
6467    // method; POST only when the upstream opts in via `retries.retry_post`.
6468    // A request whose body was not fully buffered within
6469    // `buffer_max_bytes` is never retried (its body is partially consumed
6470    // and cannot be replayed). Upgrade requests are never retried.
6471    let rp: RetryParams = handle.retry_params().clone();
6472    let idempotent = matches!(
6473        out_req_parts.method,
6474        hyper::Method::GET
6475            | hyper::Method::HEAD
6476            | hyper::Method::OPTIONS
6477            | hyper::Method::TRACE
6478            | hyper::Method::PUT
6479    );
6480    let retries_enabled = rp.attempts > 0
6481        && !wants_upgrade
6482        && (idempotent || (rp.retry_post && out_req_parts.method == hyper::Method::POST));
6483
6484    // DW-063: request hedging. A hedge copy is a speculative duplicate
6485    // sent to a different endpoint after `hedge_after` without a
6486    // response; the first response wins, the loser is cancelled. Hedging
6487    // requires a replayable body and idempotent semantics (POST only
6488    // when `retry_post` is true), and is disabled for upgrade requests.
6489    // Evaluated here (before body buffering) because hedging requires
6490    // the body to be buffered — the buffering condition below includes
6491    // `hedge_eligible`.
6492    let hedge = handle.hedge_params();
6493    let hedge_eligible = hedge.enabled()
6494        && !wants_upgrade
6495        && (idempotent || (rp.retry_post && out_req_parts.method == hyper::Method::POST));
6496
6497    let budget = handle.retry_budget();
6498    // Per-upstream circuit breaker (DW-015): evaluated BEFORE the endpoint
6499    // pick and before every (re)attempt — an open breaker means NO attempts
6500    // at all. The breaker gates the whole upstream; endpoint ejection
6501    // (DW-012) gates endpoints within it, and even a fail-open pick (all
6502    // endpoints ejected) still flows through this check. Requests already
6503    // in flight when the breaker opened complete normally.
6504    let breaker = handle.breaker();
6505    let breaker_params = handle.breaker_params().copied();
6506    // One budget denominator per proxied request (not per attempt),
6507    // regardless of retry eligibility or upgrade status: the budget window
6508    // counts ALL proxied traffic to the upstream, so a POST-heavy upstream
6509    // still accumulates denominator headroom for its idempotent share.
6510    //
6511    // PERF: skip the lock + SystemTime syscall when neither retries nor
6512    // hedging are configured — the budget is only consulted by
6513    // try_reserve_retry, which is unreachable when attempts == 0 and no
6514    // hedge block is set. This eliminates a contended std::sync::Mutex
6515    // acquisition on every proxied request for upstreams without retries
6516    // (the common case in benchmarks and many production deployments).
6517    if retries_enabled || hedge_eligible {
6518        budget.record_request();
6519    }
6520    let mut replay: Option<Bytes> = None;
6521    let first_body: AttemptBody<B> = if let Some(bytes) = transformed_body {
6522        // DW-028: the transform already buffered (and replaced) the
6523        // body — it is replayable by construction, and a retry re-sends
6524        // the TRANSFORMED bytes.
6525        replay = Some(bytes.clone());
6526        AttemptBody::Replay(bytes)
6527    } else if retries_enabled || hedge_eligible {
6528        // DW-014/DW-063: buffer the request body so it can be replayed
6529        // on retry or sent as a hedge copy. Hedging requires a
6530        // replayable body even when retries are off (attempts == 0).
6531        match buffer_request_body(
6532            body_rest.take().expect("untransformed body present"),
6533            rp.buffer_max_bytes,
6534        )
6535        .await
6536        {
6537            Ok(bytes) => {
6538                replay = Some(bytes.clone());
6539                AttemptBody::Replay(bytes)
6540            }
6541            Err((prefix, rest)) => {
6542                // Over-cap (or errored) body: stream the buffered prefix
6543                // plus the remainder, single attempt. Documented choice:
6544                // over-cap bodies are NOT retried (fail through) rather
6545                // than erroring the request.
6546                AttemptBody::OneShot {
6547                    prefix: Some(prefix),
6548                    rest,
6549                }
6550            }
6551        }
6552    } else {
6553        // Retries off (the default): the original streaming body is
6554        // forwarded untouched — zero-copy, no buffering, byte-identical
6555        // to the pre-DW-014 path.
6556        AttemptBody::OneShot {
6557            prefix: None,
6558            rest: Box::pin(body_rest.take().expect("untransformed body present")),
6559        }
6560    };
6561
6562    // Body-path analytics dimensions (DW-093): extract from the
6563    // request body via JSON pointer, but ONLY when the body was
6564    // buffered (retries, hedging, or a request transform). The
6565    // zero-buffering default skips body-path dimensions silently —
6566    // the gateway never buffers just for analytics. The body must be
6567    // valid JSON; non-JSON bodies and unresolved pointers are skipped
6568    // for that request. Values are capped at 128 bytes (the same
6569    // bound as header-sourced dimensions).
6570    if let Some(body_bytes) = &replay {
6571        if let Some(dims) = gateway.analytics.as_ref() {
6572            extract_body_path_dims(&dims.dimensions, body_bytes, rec);
6573        }
6574    }
6575
6576    let mut first_body = Some(first_body);
6577    let mut done_tries: u32 = 0;
6578    // DW-063: hedge is enabled if the hedge block is present, the method
6579    // is idempotent (or POST with retry_post), and the body was buffered
6580    // (replay is available). Over-cap bodies that couldn't be buffered
6581    // disable hedging.
6582    let hedge_enabled = hedge_eligible && replay.is_some();
6583    // REL-01: the cross-attempt total deadline caps wall-clock time from
6584    // the first attempt to the last, INCLUDING backoff delays. Captured
6585    // once before the loop so every retry decision measures from the same
6586    // origin; `None` (the default) leaves the budget unbounded.
6587    let retry_loop_started = std::time::Instant::now();
6588    // PERF: peer_str is already computed above for XFF/X-Real-IP; reuse
6589    // it as the dispatch hash key fallback instead of calling
6590    // peer.to_string() on every retry attempt.
6591    let peer_key = peer_str.clone();
6592    // PERF: single-attempt fast path. The full `http::request::Parts`
6593    // (including the whole HeaderMap) used to be cloned on every loop
6594    // iteration — pure overhead for the common case where a second
6595    // attempt can never run: a retry requires a replayable body AND
6596    // `rp.attempts >= 1` (`done_tries` is 1 after the first send, and
6597    // `may_retry = done_tries <= rp.attempts && replay.is_some()`), and
6598    // a hedge copy also requires `replay` (`hedge_race` borrows the
6599    // parts for each copy). When none of those hold the parts are MOVED
6600    // into the one and only Request; the multi-attempt path retains the
6601    // parts in `parts_cell` and keeps the per-iteration clone (each sent
6602    // request consumes its own copy).
6603    let single_attempt = replay.is_none() || (rp.attempts == 0 && !hedge_enabled);
6604    let mut parts_cell = Some(out_req_parts);
6605    let mut prebuilt = if single_attempt {
6606        Some(Request::from_parts(
6607            parts_cell.take().expect("single-attempt owns the parts"),
6608            first_body
6609                .take()
6610                .expect("first body present on the first attempt"),
6611        ))
6612    } else {
6613        None
6614    };
6615    loop {
6616        // Breaker admission (DW-015) precedes every attempt: endpoint
6617        // pick, dial, and any remaining retries. Checked per iteration so
6618        // a breaker that trips mid-request (an earlier attempt's failure
6619        // crossed the threshold) still short-circuits the retries.
6620        if let Some(bp) = breaker_params {
6621            if let crate::resilience::breaker::BreakerDecision::Reject { retry_after_ms } =
6622                breaker.check(&bp)
6623            {
6624                tracing::warn!(
6625                    code = "upstream_circuit_open",
6626                    request_id = %rid,
6627                    upstream = handle.name(),
6628                    "upstream circuit open; failing fast"
6629                );
6630                rec.broken = true;
6631                return breaker_open(retry_after_ms, rid);
6632            }
6633        }
6634        let out_req = match prebuilt.take() {
6635            Some(req) => req,
6636            None => {
6637                let body = match first_body.take() {
6638                    Some(body) => body,
6639                    None => {
6640                        // Unreachable: the only `continue` into this branch
6641                        // requires `may_retry`, which requires
6642                        // `replay.is_some()`.
6643                        debug_assert!(replay.is_some(), "retry requires a replayable body");
6644                        AttemptBody::Replay(
6645                            replay.clone().expect("retry requires a replayable body"),
6646                        )
6647                    }
6648                };
6649                Request::from_parts(
6650                    parts_cell
6651                        .as_ref()
6652                        .expect("multi-attempt retains the parts")
6653                        .clone(),
6654                    body,
6655                )
6656            }
6657        };
6658        // One span per upstream attempt (DW-021); the balancer's pick
6659        // runs inside it under its own `upstream_pick` span (see the
6660        // upstream handle). Instrumented onto the send future — no span
6661        // guard is held across a poll boundary.
6662        let attempt_span = tracing::info_span!(
6663            "upstream_attempt",
6664            attempt = done_tries + 1,
6665            upstream = handle.name()
6666        );
6667        let mut picked: Option<String> = None;
6668        // DW-040: sticky sessions hash the ENDPOINT pick by the same
6669        // key that picked the branch (the affinity cookie), so an
6670        // ip_hash branch pins the session to one endpoint; split
6671        // services without sticky hash per request id. Everything
6672        // else keeps the client-IP key (the ip_hash contract).
6673        //
6674        // DP-07 (#236): when the upstream has a `hash_on` config
6675        // (cookie or header), the extracted value (hash_on_key, above)
6676        // is used as the hash key. This takes priority over the client
6677        // IP but is overridden by the sticky/split mechanism. Falls
6678        // back to the client IP when the cookie/header is absent.
6679        let dispatch_hash_key: &str = if sticky_key.is_some() || service.split.is_some() {
6680            dispatch_key.as_str()
6681        } else if let Some(ref hk) = hash_on_key {
6682            hk.as_str()
6683        } else {
6684            peer_key.as_str()
6685        };
6686        // gRPC deadline (DW-039): the RPC's total budget bounds every
6687        // attempt's forward — the remaining slice, so a retry that
6688        // cannot fit before the deadline is cut by the timeout, not
6689        // started in vain. Non-gRPC requests are unwrapped (zero cost).
6690        // DW-089: capture the attempt start for the adaptive
6691        // controller's latency EWMA (measured to header resolution,
6692        // the same point the breaker observes).
6693        let attempt_started = std::time::Instant::now();
6694        let send = handle
6695            .send_with_hash_key_observed(out_req, Some(dispatch_hash_key), &mut picked)
6696            .instrument(attempt_span);
6697
6698        // DW-063: request hedging. On the first attempt, if the hedge
6699        // timer fires before the primary responds, spawn a speculative
6700        // duplicate to a different endpoint. Race the primary and all
6701        // hedge copies; the first response (headers resolved) wins.
6702        let result = if hedge_enabled && done_tries == 0 {
6703            hedge_race(
6704                send,
6705                hedge,
6706                &handle,
6707                parts_cell
6708                    .as_ref()
6709                    .expect("hedge implies multi-attempt retains the parts"),
6710                replay.as_ref().expect("hedge requires replay"),
6711                dispatch_hash_key,
6712                rid,
6713                handle.name(),
6714                obs_arc,
6715            )
6716            .await
6717        } else {
6718            match grpc_deadline {
6719                Some(deadline) => match tokio::time::timeout(
6720                    deadline.saturating_duration_since(std::time::Instant::now()),
6721                    send,
6722                )
6723                .await
6724                {
6725                    Ok(result) => result,
6726                    Err(_) => {
6727                        // The deadline expired before headers resolved: for a
6728                        // gRPC caller the answer carries grpc-status 4
6729                        // (DEADLINE_EXCEEDED) in the headers — the
6730                        // trailers-only shape — plus the standard envelope
6731                        // body for non-gRPC tooling.
6732                        tracing::warn!(
6733                            code = "grpc_deadline_exceeded",
6734                            request_id = %rid,
6735                            upstream = handle.name(),
6736                            "grpc-timeout expired before the upstream answered"
6737                        );
6738                        if let Some(ep) = &picked {
6739                            rec.endpoint = Some(ep.clone());
6740                        }
6741                        rec.attempts = done_tries + 1;
6742                        let mut resp = simple(
6743                            StatusCode::GATEWAY_TIMEOUT,
6744                            "grpc_deadline_exceeded",
6745                            "grpc-timeout expired",
6746                            rid,
6747                        );
6748                        let v = HeaderValue::from_static("4");
6749                        resp.headers_mut().insert("grpc-status", v);
6750                        return resp;
6751                    }
6752                },
6753                None => send.await,
6754            }
6755        };
6756        rec.attempts = done_tries + 1;
6757        if let Some(ep) = &picked {
6758            rec.endpoint = Some(ep.clone());
6759        }
6760        // Attempt metric (DW-021): endpoint = "unpicked" when the dispatch
6761        // never resolved one; error outcomes classify to 5xx (every
6762        // upstream error maps to a 502/503/504/500 — see
6763        // classify_upstream_error), so the class reflects the response
6764        // the client ultimately receives for that attempt.
6765        let attempt_status = match &result {
6766            Ok(resp) => resp.status().as_u16(),
6767            Err(_) => 500,
6768        };
6769        obs.record_upstream_attempt(
6770            handle.name(),
6771            picked.as_deref().unwrap_or("unpicked"),
6772            attempt_status,
6773        );
6774        done_tries += 1;
6775        // An attempt is retryable only while attempts remain and the body
6776        // is replayable; the budget reservation is charged atomically
6777        // BEFORE a retry runs (and only when one actually will — a charged
6778        // but unused reservation would undercount future headroom).
6779        let may_retry = done_tries <= rp.attempts && replay.is_some();
6780        match result {
6781            Ok(resp) => {
6782                // Breaker observation (DW-015): the same point passive
6783                // health sees — headers resolved; status >= 500 is a
6784                // failure, everything else a success. Each attempt (a
6785                // retried one included) reports.
6786                if let Some(bp) = breaker_params {
6787                    breaker.report(&bp, resp.status().as_u16() >= 500);
6788                }
6789                // Retry when the upstream answered with a retryable status
6790                // (headers resolved — the attempt is otherwise final).
6791                if may_retry && rp.retries_status(resp.status().as_u16()) {
6792                    let delay = crate::resilience::retries::jitter_delay(
6793                        rp.backoff_base_ms,
6794                        rp.backoff_cap_ms,
6795                        done_tries,
6796                    );
6797                    // REL-01: the cross-attempt total deadline gates the
6798                    // retry and clamps the backoff sleep so the loop never
6799                    // sleeps past the deadline. The budget reservation is
6800                    // charged only when a retry will actually run (a
6801                    // deadline-aborted retry charges nothing).
6802                    match crate::resilience::retries::retry_sleep_within_total(
6803                        rp.total_deadline,
6804                        retry_loop_started,
6805                        delay,
6806                    ) {
6807                        Some(sleep) => {
6808                            if budget.try_reserve_retry(rp.budget_percent) {
6809                                obs.record_retry(handle.name());
6810                                tracing::warn!(
6811                                    code = "upstream_retry",
6812                                    request_id = %rid,
6813                                    upstream = handle.name(),
6814                                    attempt = done_tries,
6815                                    status = resp.status().as_u16(),
6816                                    "retryable upstream status; retrying"
6817                                );
6818                                tokio::time::sleep(sleep).await;
6819                                continue;
6820                            }
6821                        }
6822                        None => {
6823                            tracing::warn!(
6824                                code = "retry_total_deadline_exceeded",
6825                                request_id = %rid,
6826                                upstream = handle.name(),
6827                                attempt = done_tries,
6828                                status = resp.status().as_u16(),
6829                                "cross-attempt total deadline exceeded; \
6830                                 not retrying"
6831                            );
6832                        }
6833                    }
6834                }
6835                // DW-089: feed the final (non-retried) upstream
6836                // outcome to the adaptive controller for every
6837                // applicable policy. The latency is measured to header
6838                // resolution (the same point the breaker observes); the
6839                // Retry-After header is parsed for the origin-signal
6840                // backoff. The controller is a no-op for policies
6841                // without an adaptive block.
6842                if !applicable_policies.is_empty() {
6843                    let latency = attempt_started.elapsed();
6844                    let retry_after = parse_retry_after_header(resp.headers());
6845                    let adaptive = Arc::clone(gen.adaptive());
6846                    let status = resp.status().as_u16();
6847                    for name in applicable_policies {
6848                        adaptive.record_outcome(name, status, latency, retry_after);
6849                    }
6850                }
6851                // DW-091: feed the final upstream outcome to the
6852                // auto-canary controller when this request was served
6853                // by a canary-tracked service split. The canary side
6854                // is index 1; the baseline is index 0. The latency is
6855                // measured to header resolution (the same point the
6856                // adaptive controller and breaker observe). The
6857                // controller is a no-op when the group has no
6858                // canary_analysis (the fast path above left
6859                // canary_split_index None).
6860                if let Some(idx) = canary_split_index {
6861                    if let Some(canary) = gen.canary() {
6862                        let latency_ms = attempt_started.elapsed().as_secs_f64() * 1000.0;
6863                        canary.record_outcome(
6864                            &service.name,
6865                            idx == 1,
6866                            resp.status().as_u16(),
6867                            latency_ms,
6868                        );
6869                    }
6870                }
6871                // REL-05 (#216): streaming failover with buffered
6872                // first-frame. When enabled and retries remain, buffer
6873                // up to `buffer_first_frame_bytes` of the response body
6874                // before committing. If the body errors before the
6875                // buffer fills, retry to a different endpoint (the
6876                // client has not yet received any bytes). If the buffer
6877                // fills or the body completes, flush and continue.
6878                if may_retry && rp.buffer_first_frame_bytes > 0 && !wants_upgrade {
6879                    match buffer_first_frame(resp, rp.buffer_first_frame_bytes).await {
6880                        Ok(FirstFrame::Complete(resp)) => {
6881                            // Body completed within the buffer — the
6882                            // full response is in memory. Finalize and
6883                            // return (no streaming needed).
6884                            let mut resp = finish_proxy_response(
6885                                resp,
6886                                wants_upgrade,
6887                                on_client_upgrade,
6888                                global_permit.take(),
6889                                ws_police,
6890                                obs_arc,
6891                                grpc_deadline,
6892                                rid,
6893                            );
6894                            if let Some(cookie) = sticky_set_cookie.take() {
6895                                if let Ok(v) = HeaderValue::from_str(&cookie) {
6896                                    resp.headers_mut().append(hyper::header::SET_COOKIE, v);
6897                                }
6898                                obs.record_sticky_session();
6899                            }
6900                            return resp;
6901                        }
6902                        Ok(FirstFrame::Partial { prefix, rest }) => {
6903                            // Buffer filled — flush the prefix and
6904                            // continue streaming. The response is now
6905                            // committed. Wrap the prefix + rest's body
6906                            // as a single body. `rest` is the response
6907                            // with the remaining body attached.
6908                            let resp = rest.map(|body| UpstreamBody::with_prefix(prefix, body));
6909                            let mut resp = finish_proxy_response(
6910                                resp,
6911                                wants_upgrade,
6912                                on_client_upgrade,
6913                                global_permit.take(),
6914                                ws_police,
6915                                obs_arc,
6916                                grpc_deadline,
6917                                rid,
6918                            );
6919                            if let Some(cookie) = sticky_set_cookie.take() {
6920                                if let Ok(v) = HeaderValue::from_str(&cookie) {
6921                                    resp.headers_mut().append(hyper::header::SET_COOKIE, v);
6922                                }
6923                                obs.record_sticky_session();
6924                            }
6925                            return resp;
6926                        }
6927                        Err(resp) => {
6928                            // Body errored before the buffer filled —
6929                            // retryable. Drop the partial response and
6930                            // continue the retry loop.
6931                            tracing::warn!(
6932                                code = "first_frame_buffer_error",
6933                                request_id = %rid,
6934                                upstream = handle.name(),
6935                                attempt = done_tries,
6936                                "upstream body failed during first-frame buffer; retrying"
6937                            );
6938                            let delay = crate::resilience::retries::jitter_delay(
6939                                rp.backoff_base_ms,
6940                                rp.backoff_cap_ms,
6941                                done_tries,
6942                            );
6943                            if let Some(sleep) =
6944                                crate::resilience::retries::retry_sleep_within_total(
6945                                    rp.total_deadline,
6946                                    retry_loop_started,
6947                                    delay,
6948                                )
6949                            {
6950                                if budget.try_reserve_retry(rp.budget_percent) {
6951                                    obs.record_retry(handle.name());
6952                                    tokio::time::sleep(sleep).await;
6953                                    continue;
6954                                }
6955                            }
6956                            // Budget exhausted or deadline exceeded —
6957                            // fall through to return the error response.
6958                            return finish_proxy_response(
6959                                resp,
6960                                wants_upgrade,
6961                                on_client_upgrade,
6962                                global_permit.take(),
6963                                ws_police,
6964                                obs_arc,
6965                                grpc_deadline,
6966                                rid,
6967                            );
6968                        }
6969                    }
6970                }
6971                let mut resp = finish_proxy_response(
6972                    resp,
6973                    wants_upgrade,
6974                    on_client_upgrade,
6975                    global_permit.take(),
6976                    ws_police,
6977                    obs_arc,
6978                    grpc_deadline,
6979                    rid,
6980                );
6981                // DW-040: the first request of a sticky session carries
6982                // its affinity handle back (appended, never replacing
6983                // an upstream's own cookies).
6984                if let Some(cookie) = sticky_set_cookie.take() {
6985                    if let Ok(v) = HeaderValue::from_str(&cookie) {
6986                        resp.headers_mut().append(hyper::header::SET_COOKIE, v);
6987                    }
6988                    obs.record_sticky_session();
6989                }
6990                return resp;
6991            }
6992            Err(err) => {
6993                // Signed-body digest mismatch (DW-036): the CLIENT's
6994                // request body did not match the digest its signature
6995                // bound. Checked BEFORE the retry branch (a mismatch is
6996                // deterministic — a retry re-sends the same tampered
6997                // bytes and re-fails) and before breaker observation:
6998                // the upstream never saw a complete request, so this
6999                // says nothing about upstream health either. 401, the
7000                // family's failure shape.
7001                if signature_body_mismatch(&err) {
7002                    tracing::warn!(
7003                        code = "signature_body_mismatch",
7004                        request_id = %rid,
7005                        upstream = handle.name(),
7006                        "signed request body did not match its digest; aborted mid-stream"
7007                    );
7008                    return unauthorized(crate::security::authn::HMAC_CHALLENGE, rid);
7009                }
7010                // Breaker observation: transport-class failures count.
7011                // Client-side admission rejections (Saturated — no upstream
7012                // contact) and configuration-class errors say nothing about
7013                // upstream health and must not drive the breaker.
7014                if let Some(bp) = breaker_params {
7015                    if breaker_reportable(&err) {
7016                        breaker.report(&bp, true);
7017                    }
7018                }
7019                // Retry on transport-class failures (connect/read timeout,
7020                // refusal, reset, framing) when `retry_transport` is on.
7021                if may_retry && rp.retry_transport && transport_retryable(&err) {
7022                    let delay = crate::resilience::retries::jitter_delay(
7023                        rp.backoff_base_ms,
7024                        rp.backoff_cap_ms,
7025                        done_tries,
7026                    );
7027                    // REL-01: same cross-attempt total deadline gate as the
7028                    // status branch — a deadline-aborted retry charges no
7029                    // budget and falls through to error classification.
7030                    match crate::resilience::retries::retry_sleep_within_total(
7031                        rp.total_deadline,
7032                        retry_loop_started,
7033                        delay,
7034                    ) {
7035                        Some(sleep) => {
7036                            if budget.try_reserve_retry(rp.budget_percent) {
7037                                obs.record_retry(handle.name());
7038                                tracing::warn!(
7039                                    code = "upstream_retry",
7040                                    request_id = %rid,
7041                                    upstream = handle.name(),
7042                                    attempt = done_tries,
7043                                    "upstream attempt failed: {err}; retrying"
7044                                );
7045                                tokio::time::sleep(sleep).await;
7046                                continue;
7047                            }
7048                        }
7049                        None => {
7050                            tracing::warn!(
7051                                code = "retry_total_deadline_exceeded",
7052                                request_id = %rid,
7053                                upstream = handle.name(),
7054                                attempt = done_tries,
7055                                "cross-attempt total deadline exceeded; \
7056                                 not retrying"
7057                            );
7058                        }
7059                    }
7060                }
7061                // Server-side detail stays in the log (classification only
7062                // reaches the client — no hyper error text leaks).
7063                tracing::error!(
7064                    code = "upstream_failed",
7065                    request_id = %rid,
7066                    upstream = handle.name(),
7067                    "upstream request failed: {err}"
7068                );
7069                // Streaming body limit (DW-027): a request body that
7070                // crossed the route cap mid-upload surfaces here (the
7071                // client's failure, not the upstream's) — answer 413
7072                // rather than a 5xx classification.
7073                if request_limit_exceeded(&err) {
7074                    return simple(
7075                        StatusCode::PAYLOAD_TOO_LARGE,
7076                        "request_body_too_large",
7077                        "request body exceeded the route limit mid-stream",
7078                        rid,
7079                    );
7080                }
7081                let (status, code, msg) = classify_upstream_error(&err);
7082                return simple(status, code, msg, rid);
7083            }
7084        }
7085    }
7086}
7087
7088/// DW-063: inline hedge helper. Races the primary send future against a
7089/// hedge timer; if the timer fires first, spawns up to `hedge_max`
7090/// speculative copies and races all of them. First Ok wins; losers are
7091/// aborted. Returns the winning result (or the last error if all fail).
7092#[allow(clippy::too_many_arguments)]
7093async fn hedge_race(
7094    primary_send: impl std::future::Future<
7095        Output = Result<
7096            Response<crate::dataplane::upstream::UpstreamBody>,
7097            crate::dataplane::upstream::UpstreamError,
7098        >,
7099    >,
7100    hedge: &crate::resilience::retries::HedgeParams,
7101    handle: &Arc<crate::dataplane::upstream::UpstreamHandle>,
7102    out_req_parts: &http::request::Parts,
7103    replay: &Bytes,
7104    dispatch_hash_key: &str,
7105    rid: &str,
7106    upstream_name: &str,
7107    obs: &Arc<Observability>,
7108) -> Result<
7109    Response<crate::dataplane::upstream::UpstreamBody>,
7110    crate::dataplane::upstream::UpstreamError,
7111> {
7112    use crate::dataplane::upstream::UpstreamBody;
7113    use tokio::task::JoinSet;
7114    use tracing::{info, warn};
7115
7116    let hedge_after = hedge.hedge_after;
7117    let hedge_max = hedge.hedge_max;
7118
7119    // Phase 1: race the primary send against the hedge timer. If the
7120    // primary resolves first (Ok or Err), no hedge is needed — return it.
7121    tokio::pin!(primary_send);
7122    let timer = tokio::time::sleep(hedge_after);
7123    tokio::pin!(timer);
7124
7125    tokio::select! {
7126        biased;
7127        result = &mut primary_send => {
7128            return result;
7129        }
7130        _ = &mut timer => {
7131            // Timer fired — enter the hedge phase.
7132            warn!(
7133                code = "hedge_timer_fired",
7134                request_id = %rid,
7135                upstream = upstream_name,
7136                "hedge timer fired after {}ms; sending speculative copies",
7137                hedge_after.as_millis()
7138            );
7139        }
7140    }
7141
7142    // Phase 2: spawn hedge copies into a JoinSet. The primary is kept
7143    // as a pinned local future (not spawned) to avoid the 'static bound;
7144    // we race it alongside the JoinSet using a select loop.
7145    let mut set: JoinSet<Result<Response<UpstreamBody>, UpstreamError>> = JoinSet::new();
7146
7147    // Spawn up to hedge_max hedge copies. The `hedge_max` config bounds
7148    // the amplification factor (at most N speculative copies per request).
7149    // Hedge copies are NOT charged against the retry budget — the budget
7150    // prevents retry storms after failures, while hedging is a proactive
7151    // performance optimization that runs on every slow request.
7152    let mut hedges_spawned: u32 = 0;
7153    for _ in 0..hedge_max {
7154        let body: AttemptBody<Full<Bytes>> = AttemptBody::Replay(replay.clone());
7155        let hedge_req = Request::from_parts(out_req_parts.clone(), body);
7156        let handle_clone = Arc::clone(handle);
7157        let hash_key = dispatch_hash_key.to_string();
7158        let obs_clone = Arc::clone(obs);
7159        let upstream_name_clone = upstream_name.to_string();
7160        set.spawn(async move {
7161            let mut ep: Option<String> = None;
7162            let result = handle_clone
7163                .send_with_hash_key_observed(hedge_req, Some(&hash_key), &mut ep)
7164                .await;
7165            if let Ok(ref resp) = result {
7166                obs_clone.record_upstream_attempt(
7167                    &upstream_name_clone,
7168                    ep.as_deref().unwrap_or("unpicked"),
7169                    resp.status().as_u16(),
7170                );
7171            }
7172            result
7173        });
7174        hedges_spawned += 1;
7175        obs.record_hedge_sent(upstream_name);
7176    }
7177
7178    info!(
7179        code = "hedge_sent",
7180        request_id = %rid,
7181        upstream = upstream_name,
7182        copies = hedges_spawned,
7183        "sent {} hedge copies",
7184        hedges_spawned
7185    );
7186
7187    // Race the primary (local pinned future) against the hedge JoinSet.
7188    // First Ok wins; on a hedge Ok the primary is dropped (its connection
7189    // is abandoned — the upstream response body is not consumed). If the
7190    // primary resolves first (Ok or Err), hedges are aborted.
7191    loop {
7192        tokio::select! {
7193            biased;
7194            result = &mut primary_send => {
7195                set.abort_all();
7196                return result;
7197            }
7198            joined = set.join_next(), if !set.is_empty() => {
7199                match joined {
7200                    Some(Ok(Ok(resp))) => {
7201                        // First hedge success wins. Drop the primary
7202                        // (its upstream connection is abandoned).
7203                        set.abort_all();
7204                        return Ok(resp);
7205                    }
7206                    Some(Ok(Err(_))) => {
7207                        // Hedge errored — continue racing the primary
7208                        // against remaining hedges.
7209                    }
7210                    Some(Err(_)) => {
7211                        // Hedge task panicked — treat as errored.
7212                    }
7213                    None => {
7214                        // All hedges completed (errored). Fall through
7215                        // to await the primary directly.
7216                        break;
7217                    }
7218                }
7219            }
7220        }
7221    }
7222
7223    // All hedges errored — await the primary directly.
7224    primary_send.await
7225}
7226
7227/// The fail-fast response for an open circuit (DW-015): 503 with the
7228/// error-envelope body and a `Retry-After` header carrying the
7229/// whole seconds until a half-open probe may be admitted (rounded up,
7230/// minimum 1 — a client honoring it retries neither too early nor
7231/// needlessly late). While half-open probes are in flight the hint is 1
7232/// second (the exact half-open time is unknowable until a probe resolves).
7233fn breaker_open(retry_after_ms: u64, rid: &str) -> Response<ProxyBody> {
7234    let seconds = retry_after_ms.div_ceil(1000);
7235    Response::builder()
7236        .status(StatusCode::SERVICE_UNAVAILABLE)
7237        .header(hyper::header::CONTENT_TYPE, "application/json")
7238        .header(hyper::header::RETRY_AFTER, seconds.max(1).to_string())
7239        .body(ProxyBody::Full(Full::new(observability::envelope_body(
7240            "upstream_circuit_open",
7241            "upstream circuit open",
7242            rid,
7243        ))))
7244        .expect("static breaker response is valid")
7245}
7246
7247/// Finalize a proxied response: upgrade tunneling for 101s (with the
7248/// DW-039 WebSocket policer when the route asked for one), hop-by-hop
7249/// stripping, and the streaming body passthrough. The gateway
7250/// concurrency-cap permit (DW-015), when present, is attached to the
7251/// streaming body so the global slot is held until the body completes or
7252/// the response is dropped (client disconnect included); a tunneled 101
7253/// releases it when the (empty) 101 response is dropped. A gRPC
7254/// request's armed deadline (DW-039) keeps ticking through the body as
7255/// its ABSOLUTE bound.
7256#[allow(clippy::too_many_arguments)] // the per-request explicit-inputs rule (see proxy_request)
7257fn finish_proxy_response(
7258    mut resp: Response<UpstreamBody>,
7259    wants_upgrade: bool,
7260    on_client_upgrade: Option<hyper::upgrade::OnUpgrade>,
7261    global_permit: Option<OwnedSemaphorePermit>,
7262    ws_police: Option<WsPoliceDecision>,
7263    obs_arc: &Arc<Observability>,
7264    grpc_deadline: Option<std::time::Instant>,
7265    rid: &str,
7266) -> Response<ProxyBody> {
7267    if resp.status() == StatusCode::SWITCHING_PROTOCOLS && wants_upgrade {
7268        // DW-039: policing keys off what the UPSTREAM actually
7269        // upgraded (its 101 names the protocol), not what the client
7270        // offered — a mixed-token request (`Upgrade: foo, websocket`)
7271        // whose upstream upgrades `foo` must not have a non-WebSocket
7272        // tunnel parsed as WS frames (and a WS close frame injected
7273        // into it). Read the header BEFORE `upgrade::on` consumes the
7274        // response.
7275        let upgraded_websocket = crate::dataplane::websocket::offers_websocket(resp.headers());
7276        let ws_police = if upgraded_websocket { ws_police } else { None };
7277        let on_upstream = hyper::upgrade::on(&mut resp);
7278        if let Some(client) = on_client_upgrade {
7279            let obs = Arc::clone(obs_arc);
7280            tokio::spawn(async move {
7281                match tokio::try_join!(client, on_upstream) {
7282                    Ok((client_io, upstream_io)) => match ws_police {
7283                        Some(policy) => {
7284                            // DW-039 + DP-05 (#253): police the client
7285                            // side — count data frames, enforce per-
7286                            // frame size limits, reject reserved
7287                            // opcodes, and close with the right code on
7288                            // violation. The violation flag is shared
7289                            // with the wrapper so the metric survives
7290                            // the tunnel consuming it.
7291                            let flag = Arc::new(AtomicU64::new(0));
7292                            let policed = crate::dataplane::websocket::WsPoliceIo::with_flag(
7293                                TokioIo::new(client_io),
7294                                policy.rate,
7295                                policy.max_frame_size,
7296                                Arc::clone(&flag),
7297                            );
7298                            let upstream_io = TokioIo::new(upstream_io);
7299                            match policy.idle_timeout {
7300                                Some(idle) => {
7301                                    idle_tunnel(policed, upstream_io, idle).await;
7302                                }
7303                                None => tunnel(policed, upstream_io).await,
7304                            }
7305                            let flag_val = flag.load(Ordering::Relaxed);
7306                            if flag_val != 0 {
7307                                let label = crate::dataplane::websocket::flag_to_metric(flag_val);
7308                                obs.record_websocket_policy(&policy.route, label);
7309                                tracing::warn!(
7310                                    code = label,
7311                                    route = %policy.route,
7312                                    "websocket connection closed by post-upgrade policy"
7313                                );
7314                            }
7315                        }
7316                        None => {
7317                            let client_io = TokioIo::new(client_io);
7318                            let upstream_io = TokioIo::new(upstream_io);
7319                            tunnel(client_io, upstream_io).await
7320                        }
7321                    },
7322                    Err(err) => {
7323                        tracing::warn!("upgrade handshake failed: {err}");
7324                    }
7325                }
7326            });
7327        } else {
7328            return simple(
7329                StatusCode::INTERNAL_SERVER_ERROR,
7330                "upgrades_unsupported",
7331                "listener does not support upgrades",
7332                rid,
7333            );
7334        }
7335        // Keep the 101 headers (Connection/Upgrade must reach the
7336        // client to complete its handshake); body is empty.
7337        if let Some(permit) = global_permit {
7338            resp.body_mut().set_release_permit(permit);
7339        }
7340        return resp.map(ProxyBody::Upstream);
7341    }
7342    let _ = strip_hop_by_hop(resp.headers_mut(), false, false);
7343    if let Some(permit) = global_permit {
7344        resp.body_mut().set_release_permit(permit);
7345    }
7346    if let Some(deadline) = grpc_deadline {
7347        resp.body_mut().set_deadline(deadline);
7348    }
7349    resp.map(ProxyBody::Upstream)
7350}
7351
7352/// Whether an upstream send failed because the REQUEST body crossed the
7353/// route's streaming limit (DW-027). The counting wrapper's error rides
7354/// the hyper client error's source chain; walk it. Never confusable
7355/// with an upstream failure: the marker type is only produced by the
7356/// gateway's own request-body wrapper.
7357fn request_limit_exceeded(err: &UpstreamError) -> bool {
7358    let UpstreamError::Client(e) = err else {
7359        return false;
7360    };
7361    let mut src: Option<&(dyn std::error::Error + 'static)> = Some(e);
7362    while let Some(s) = src {
7363        if s.downcast_ref::<crate::dataplane::hardening::LimitedBodyError>()
7364            .is_some_and(|l| {
7365                matches!(
7366                    l,
7367                    crate::dataplane::hardening::LimitedBodyError::OverLimit { .. }
7368                )
7369            })
7370        {
7371            return true;
7372        }
7373        src = s.source();
7374    }
7375    false
7376}
7377
7378/// Whether an upstream send failed because the REQUEST body did not
7379/// match the digest its HMAC signature bound (DW-036). The digesting
7380/// wrapper (`hardening::DigestingBody`) sits INSIDE the route's limit
7381/// wrapper, so the marker may hide under a `LimitedBodyError::Inner`
7382/// box whose own `source()` chain is not wired — walk the generic
7383/// source chain AND descend into that box explicitly. Like
7384/// [`request_limit_exceeded`], the marker type is only ever produced
7385/// by the gateway's own request-body wrapper, so it can never be
7386/// confused with an upstream failure.
7387fn signature_body_mismatch(err: &UpstreamError) -> bool {
7388    use crate::dataplane::hardening::{LimitedBodyError, SignatureBodyError};
7389    let UpstreamError::Client(e) = err else {
7390        return false;
7391    };
7392    let is_mismatch = |e: &(dyn std::error::Error + 'static)| {
7393        matches!(
7394            e.downcast_ref::<SignatureBodyError>(),
7395            Some(SignatureBodyError::DigestMismatch)
7396        )
7397    };
7398    let mut src: Option<&(dyn std::error::Error + 'static)> = Some(e);
7399    while let Some(s) = src {
7400        if is_mismatch(s) {
7401            return true;
7402        }
7403        // Descend into the limit wrapper's Inner box (it does not
7404        // expose the inner error through `source()`).
7405        if let Some(LimitedBodyError::Inner(inner)) = s.downcast_ref::<LimitedBodyError>() {
7406            let mut inner_src: Option<&(dyn std::error::Error + 'static)> = Some(inner.as_ref());
7407            while let Some(i) = inner_src {
7408                if is_mismatch(i) {
7409                    return true;
7410                }
7411                inner_src = i.source();
7412            }
7413        }
7414        src = s.source();
7415    }
7416    false
7417}
7418
7419/// Whether an upstream error reflects a genuine transport/exchange
7420/// outcome and therefore feeds the breaker. Client-side admission
7421/// rejections (Saturated — the request never contacted the upstream) and
7422/// configuration-class errors (NoEndpoints, InvalidRootCertificate,
7423/// InvalidHost) would trip the breaker for reasons unrelated to upstream
7424/// health, so they are not reported.
7425fn breaker_reportable(err: &UpstreamError) -> bool {
7426    !matches!(
7427        err,
7428        UpstreamError::Saturated
7429            | UpstreamError::NoEndpoints
7430            | UpstreamError::InvalidRootCertificate(_)
7431            | UpstreamError::InvalidHost(_)
7432    )
7433}
7434
7435/// Whether an upstream transport error is safe to retry: genuine
7436/// transport-class failures only. Configuration errors (no endpoints,
7437/// invalid TLS host, invalid root certificate) are not transient and
7438/// would fail identically on every attempt.
7439fn transport_retryable(err: &UpstreamError) -> bool {
7440    matches!(
7441        err,
7442        UpstreamError::Io(_)
7443            | UpstreamError::Client(_)
7444            | UpstreamError::ConnectTimeout { .. }
7445            | UpstreamError::ReadTimeout { .. }
7446    )
7447}
7448
7449/// The request body of one attempt (DW-014). `OneShot` streams an optional
7450/// buffered prefix followed by the original remainder (used for the first
7451/// and only attempt of a non-replayable body — retries off, over-cap
7452/// bodies, or unbuffered defaults). `Replay` is a fully buffered body
7453/// cloned per attempt, byte-exact.
7454enum AttemptBody<B> {
7455    OneShot {
7456        prefix: Option<Bytes>,
7457        rest: Pin<Box<B>>,
7458    },
7459    Replay(Bytes),
7460}
7461
7462impl<B> hyper::body::Body for AttemptBody<B>
7463where
7464    B: hyper::body::Body<Data = Bytes> + Send + 'static,
7465    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
7466{
7467    type Data = Bytes;
7468    type Error = Box<dyn std::error::Error + Send + Sync>;
7469
7470    fn poll_frame(
7471        self: Pin<&mut Self>,
7472        cx: &mut std::task::Context<'_>,
7473    ) -> std::task::Poll<Option<Result<hyper::body::Frame<Bytes>, Self::Error>>> {
7474        let this = self.get_mut();
7475        match this {
7476            AttemptBody::Replay(bytes) => {
7477                if bytes.is_empty() {
7478                    return std::task::Poll::Ready(None);
7479                }
7480                let frame = Ok(hyper::body::Frame::data(std::mem::take(bytes)));
7481                std::task::Poll::Ready(Some(frame))
7482            }
7483            AttemptBody::OneShot { prefix, rest } => {
7484                if let Some(prefix) = prefix.take() {
7485                    if !prefix.is_empty() {
7486                        return std::task::Poll::Ready(Some(Ok(hyper::body::Frame::data(prefix))));
7487                    }
7488                }
7489                rest.as_mut()
7490                    .poll_frame(cx)
7491                    .map(|opt| opt.map(|res| res.map_err(Into::into)))
7492            }
7493        }
7494    }
7495
7496    fn size_hint(&self) -> hyper::body::SizeHint {
7497        match self {
7498            AttemptBody::Replay(bytes) => {
7499                let mut hint = hyper::body::SizeHint::default();
7500                hint.set_exact(bytes.len() as u64);
7501                hint
7502            }
7503            // Delegate to the wrapped body (preserving its exact/unknown
7504            // hint so the retry-off path forwards the same framing, e.g.
7505            // content-length). With a buffered prefix the remainder's own
7506            // hint no longer describes the stream (bytes were consumed),
7507            // so the composition reports unknown — the bytes still arrive
7508            // in order via prefix + remainder frames.
7509            AttemptBody::OneShot { prefix, rest } => match prefix {
7510                Some(p) if !p.is_empty() => hyper::body::SizeHint::default(),
7511                _ => rest.size_hint(),
7512            },
7513        }
7514    }
7515}
7516
7517/// Map a failed REQUEST body transform (DW-028) to its client answer.
7518/// The two marker arms keep the answer of the layer that tripped (the
7519/// route limit's 413, the HMAC family's 401); the transform's own
7520/// failures answer in its envelope with generic client-facing text
7521/// (the offending POINTER is logged server-side only — it names the
7522/// route's contract, not the client's business).
7523fn request_body_transform_failed(
7524    e: crate::dataplane::transforms::RequestBodyTransformError,
7525    rid: &str,
7526) -> Response<ProxyBody> {
7527    use crate::dataplane::transforms::RequestBodyTransformError;
7528    match e {
7529        RequestBodyTransformError::RouteLimit => simple(
7530            StatusCode::PAYLOAD_TOO_LARGE,
7531            "request_body_too_large",
7532            "request body exceeded the route limit mid-stream",
7533            rid,
7534        ),
7535        RequestBodyTransformError::SignatureMismatch => {
7536            tracing::warn!(
7537                code = "signature_body_mismatch",
7538                request_id = %rid,
7539                "signed request body did not match its digest while buffering for a transform"
7540            );
7541            unauthorized(crate::security::authn::HMAC_CHALLENGE, rid)
7542        }
7543        RequestBodyTransformError::TooLarge { cap } => simple(
7544            StatusCode::PAYLOAD_TOO_LARGE,
7545            "request_body_too_large",
7546            &format!("request body exceeds the route's transform cap of {cap} bytes"),
7547            rid,
7548        ),
7549        RequestBodyTransformError::InvalidJson => simple(
7550            StatusCode::BAD_REQUEST,
7551            "request_body_invalid_json",
7552            "request body is not valid JSON (a JSON body transform is configured on this route)",
7553            rid,
7554        ),
7555        RequestBodyTransformError::Unresolved { path } => {
7556            tracing::warn!(
7557                code = "request_transform_failed",
7558                request_id = %rid,
7559                pointer = %path,
7560                "request body pointer did not resolve against the route's transform policy"
7561            );
7562            simple(
7563                StatusCode::BAD_REQUEST,
7564                "request_transform_failed",
7565                "request body did not match the route's transform policy",
7566                rid,
7567            )
7568        }
7569        RequestBodyTransformError::Body(_) => simple(
7570            StatusCode::BAD_REQUEST,
7571            "request_body_invalid",
7572            "request body could not be read",
7573            rid,
7574        ),
7575    }
7576}
7577
7578/// Buffer a request body up to `cap` bytes (DW-014 opt-in). `Ok` = the
7579/// whole body fit (possibly empty — an empty body is trivially
7580/// replayable) and may be replayed on retries. `Err((prefix, body))` =
7581/// the body exceeded the cap (prefix = the bytes already consumed; the
7582/// caller streams prefix + remainder without retrying). Request TRAILER
7583/// frames are dropped: the hop-by-hop `Trailer`/`TE` headers are already
7584/// stripped from the forwarded request, so v1 forwards no trailers
7585/// anywhere (documented; consistent with the no-trailer stance).
7586///
7587/// PERF-07 (#207): uses `BytesMut` instead of `Vec<u8>` so the final
7588/// `freeze()` produces a `Bytes` handle without a reallocation (the
7589/// `Vec<u8>` -> `Bytes::from(vec)` path moves the allocation but
7590/// `BytesMut::freeze()` hands off the underlying buffer directly).
7591/// When the input chunks are already `Bytes` with a single-owner
7592/// backing allocation, `BytesMut::extend_from_slice` still copies, but
7593/// the freeze avoids the second conversion allocation.
7594async fn buffer_request_body<B>(body: B, cap: u64) -> Result<Bytes, (Bytes, Pin<Box<B>>)>
7595where
7596    B: hyper::body::Body<Data = Bytes> + Send + 'static,
7597    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
7598{
7599    use http_body_util::BodyExt as _;
7600    let mut body = Box::pin(body);
7601    let mut buf: bytes::BytesMut = bytes::BytesMut::new();
7602    loop {
7603        match body.frame().await {
7604            Some(Ok(frame)) => {
7605                let Ok(data) = frame.into_data() else {
7606                    continue; // trailer frame: dropped (see doc comment)
7607                };
7608                if buf.len() as u64 + data.len() as u64 > cap {
7609                    // Over cap: the frame that broke the camel's back is
7610                    // already consumed, so it must ride in the streamed
7611                    // prefix (the caller streams prefix + remainder — the
7612                    // full body still reaches the upstream byte-exact).
7613                    buf.extend_from_slice(&data);
7614                    return Err((buf.freeze(), body));
7615                }
7616                buf.extend_from_slice(&data);
7617            }
7618            Some(Err(_)) => return Err((buf.freeze(), body)),
7619            None => return Ok(buf.freeze()),
7620        }
7621    }
7622}
7623
7624/// The result of buffering the first frame of an upstream response
7625/// (REL-05, #216).
7626enum FirstFrame {
7627    /// The body completed within the buffer cap — the full response
7628    /// body is in memory. No streaming needed.
7629    Complete(Response<UpstreamBody>),
7630    /// The buffer filled before the body completed — `prefix` is the
7631    /// buffered bytes, `rest` is the remaining upstream body to
7632    /// stream frame-by-frame.
7633    Partial {
7634        prefix: Bytes,
7635        rest: Response<UpstreamBody>,
7636    },
7637}
7638
7639/// Buffer the first `cap` bytes of an upstream response body (REL-05,
7640/// #216). Returns:
7641/// - `Ok(FirstFrame::Complete(resp))` if the body completed within
7642///   the cap (the response body is replaced with the fully-buffered
7643///   bytes).
7644/// - `Ok(FirstFrame::Partial { prefix, rest })` if the buffer filled
7645///   — `prefix` is the buffered bytes, `rest` is the response with
7646///   its original body still attached for continued streaming.
7647/// - `Err(resp)` if the body errored before the buffer filled — the
7648///   response (with its error-state body) is returned so the caller
7649///   can retry or finalize as appropriate.
7650async fn buffer_first_frame(
7651    mut resp: Response<UpstreamBody>,
7652    cap: u64,
7653) -> Result<FirstFrame, Response<UpstreamBody>> {
7654    use http_body_util::BodyExt as _;
7655    let body = std::mem::replace(resp.body_mut(), UpstreamBody::empty());
7656    let mut buf = bytes::BytesMut::new();
7657    let mut body = Box::pin(body);
7658    loop {
7659        match body.as_mut().frame().await {
7660            Some(Ok(frame)) => {
7661                let Ok(data) = frame.into_data() else {
7662                    continue; // trailer frame: dropped
7663                };
7664                if buf.len() as u64 + data.len() as u64 >= cap {
7665                    // Buffer filled — return partial. The remaining
7666                    // body stays attached for streaming. Re-box the
7667                    // pinned body as a BoxBody.
7668                    buf.extend_from_slice(&data);
7669                    let remaining = body.boxed();
7670                    resp.body_mut().set_inner(remaining);
7671                    return Ok(FirstFrame::Partial {
7672                        prefix: buf.freeze(),
7673                        rest: resp,
7674                    });
7675                }
7676                buf.extend_from_slice(&data);
7677            }
7678            Some(Err(_)) => {
7679                // Body errored before the buffer filled.
7680                return Err(resp);
7681            }
7682            None => {
7683                // Body completed within the buffer.
7684                let bytes = buf.freeze();
7685                resp.body_mut().set_full(bytes);
7686                return Ok(FirstFrame::Complete(resp));
7687            }
7688        }
7689    }
7690}
7691
7692/// (status, envelope code, envelope message) for an upstream error. The
7693/// message is a classification string — never the underlying error text
7694/// (no hyper/io internals leak to the client; the full error is logged
7695/// server-side at the call site).
7696fn classify_upstream_error(err: &UpstreamError) -> (StatusCode, &'static str, &'static str) {
7697    match err {
7698        UpstreamError::Saturated => (
7699            StatusCode::SERVICE_UNAVAILABLE,
7700            "upstream_saturated",
7701            "upstream saturated",
7702        ),
7703        UpstreamError::ConnectTimeout { .. } => (
7704            StatusCode::GATEWAY_TIMEOUT,
7705            "upstream_connect_timeout",
7706            "upstream connect timed out",
7707        ),
7708        UpstreamError::ReadTimeout { .. } => (
7709            StatusCode::GATEWAY_TIMEOUT,
7710            "upstream_response_timeout",
7711            "upstream response timed out",
7712        ),
7713        UpstreamError::InvalidHost(_) => (
7714            StatusCode::INTERNAL_SERVER_ERROR,
7715            "invalid_upstream_host",
7716            "invalid upstream host",
7717        ),
7718        UpstreamError::NoEndpoints | UpstreamError::Io(_) | UpstreamError::Client(_) => (
7719            StatusCode::BAD_GATEWAY,
7720            "upstream_unavailable",
7721            "upstream unavailable",
7722        ),
7723        UpstreamError::InvalidRootCertificate(_) => (
7724            StatusCode::INTERNAL_SERVER_ERROR,
7725            "invalid_upstream_configuration",
7726            "invalid upstream configuration",
7727        ),
7728        UpstreamError::H3Unavailable => (
7729            StatusCode::BAD_GATEWAY,
7730            "upstream_h3_unavailable",
7731            "upstream requires HTTP/3 support (build with --features h3)",
7732        ),
7733    }
7734}
7735
7736/// Generic 101 tunnel: splice the upgraded client and upstream connections
7737/// byte-for-byte until either side closes.
7738async fn tunnel<S1, S2>(mut client: S1, mut upstream: S2)
7739where
7740    S1: AsyncRead + AsyncWrite + Unpin,
7741    S2: AsyncRead + AsyncWrite + Unpin,
7742{
7743    match tokio::io::copy_bidirectional(&mut client, &mut upstream).await {
7744        Ok(_) => {}
7745        Err(err) => tracing::warn!("upgrade tunnel ended with error: {err}"),
7746    }
7747}
7748
7749/// Idle-aware 101 tunnel (DP-05, #253): splice the upgraded client and
7750/// upstream byte-for-byte, but close both sides if no data flows in
7751/// either direction for `idle`. Each successful read or write resets
7752/// the idle timer. Uses `tokio::select!` with a resettable sleep rather
7753/// than `copy_bidirectional` because the latter has no per-activity
7754/// hook. Read futures are cancellation-safe: a dropped `read` future
7755/// leaves the buffer unfilled and the underlying IO unchanged.
7756async fn idle_tunnel<S1, S2>(mut client: S1, mut upstream: S2, idle: std::time::Duration)
7757where
7758    S1: AsyncRead + AsyncWrite + Unpin,
7759    S2: AsyncRead + AsyncWrite + Unpin,
7760{
7761    let mut cbuf = vec![0u8; 8192];
7762    let mut ubuf = vec![0u8; 8192];
7763    let sleep = tokio::time::sleep(idle);
7764    tokio::pin!(sleep);
7765    loop {
7766        tokio::select! {
7767            r = client.read(&mut cbuf) => match r {
7768                Ok(0) => break,
7769                Ok(n) => {
7770                    if upstream.write_all(&cbuf[..n]).await.is_err() {
7771                        break;
7772                    }
7773                    let _ = upstream.flush().await;
7774                    sleep.as_mut().reset(tokio::time::Instant::now() + idle);
7775                }
7776                Err(_) => break,
7777            },
7778            r = upstream.read(&mut ubuf) => match r {
7779                Ok(0) => break,
7780                Ok(n) => {
7781                    if client.write_all(&ubuf[..n]).await.is_err() {
7782                        break;
7783                    }
7784                    let _ = client.flush().await;
7785                    sleep.as_mut().reset(tokio::time::Instant::now() + idle);
7786                }
7787                Err(_) => break,
7788            },
7789            _ = &mut sleep => {
7790                tracing::info!("websocket idle timeout reached, closing tunnel");
7791                break;
7792            }
7793        }
7794    }
7795    let _ = client.shutdown().await;
7796    let _ = upstream.shutdown().await;
7797}
7798
7799fn redirect<B>(
7800    req: &Request<B>,
7801    scheme: Option<&str>,
7802    host: Option<&str>,
7803    path: Option<&str>,
7804    status: u16,
7805    rid: &str,
7806) -> Response<ProxyBody> {
7807    // Default preserves the inbound path AND query verbatim.
7808    let path_part = path.map(str::to_string).unwrap_or_else(|| {
7809        req.uri()
7810            .path_and_query()
7811            .map(|pq| pq.as_str().to_string())
7812            .unwrap_or_else(|| "/".to_string())
7813    });
7814    let location = match (scheme, host) {
7815        (Some(s), Some(h)) => {
7816            let p = path_part.strip_prefix('/').unwrap_or(&path_part);
7817            format!("{s}://{h}/{p}")
7818        }
7819        (None, Some(h)) => {
7820            let p = path_part.strip_prefix('/').unwrap_or(&path_part);
7821            format!("//{h}/{p}")
7822        }
7823        // A scheme without a host cannot form an absolute URI; treat the
7824        // path as a relative redirect target.
7825        (_, None) => path_part,
7826    };
7827    // Validation keeps Location header-safe, but handle()'s never-panics
7828    // contract needs a belt behind the suspenders: if the value still fails
7829    // HeaderValue construction, answer 500 rather than panicking the task.
7830    let location = match HeaderValue::from_str(&location) {
7831        Ok(v) => v,
7832        Err(_) => {
7833            return simple(
7834                StatusCode::INTERNAL_SERVER_ERROR,
7835                "invalid_redirect_target",
7836                "invalid redirect target",
7837                rid,
7838            )
7839        }
7840    };
7841    Response::builder()
7842        .status(StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND))
7843        .header(LOCATION, location)
7844        .body(ProxyBody::Full(Full::new(Bytes::new())))
7845        .expect("static redirect response is valid")
7846}
7847
7848/// Dispatch the route action (DW-047 extraction point): the proxy/
7849/// redirect/respond/mock action, generic over the body type so it
7850/// accepts both the original streaming body `B` and the `Full<Bytes>`
7851/// body produced by request validation. This is the action half of
7852/// `handle_routed`; the response decoration tail (masking, transforms,
7853/// compression, ...) runs in `handle_routed` after this returns.
7854#[allow(clippy::too_many_arguments)]
7855async fn dispatch_action<B>(
7856    req: Request<B>,
7857    route: &Route,
7858    gen: &Arc<Generation>,
7859    idx: usize,
7860    // PERF: pre-resolved by the caller (handle_routed) — see the
7861    // `proxy_request` doc note on the same threading.
7862    service: Option<&crate::config::Service>,
7863    params: &[(String, String)],
7864    peer: IpAddr,
7865    identity: Option<&crate::security::authn::Identity>,
7866    rid: &str,
7867    rec: &mut AccessRecord,
7868    global_permit: &mut Option<OwnedSemaphorePermit>,
7869    dp: &Arc<DataPlane>,
7870    client_cert: Option<&Arc<crate::security::authn::ClientCertificate>>,
7871    applicable_policies: &[String],
7872) -> Response<ProxyBody>
7873where
7874    B: hyper::body::Body<Data = Bytes> + Send + 'static,
7875    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
7876{
7877    match &route.action {
7878        RouteAction::Mock { mock } => {
7879            serve_mock(mock, gen.snapshot.route_table().mock_body(idx), rid).await
7880        }
7881        RouteAction::Ai { endpoint, dialect } => {
7882            let listener_name = rec.listener.clone();
7883            crate::dataplane::ai_proxy::serve_ai(
7884                req,
7885                &route.name,
7886                gen,
7887                dp,
7888                rid,
7889                rec,
7890                identity,
7891                &listener_name,
7892                *endpoint,
7893                *dialect,
7894            )
7895            .await
7896        }
7897        RouteAction::Proxy { .. } => {
7898            // DW-062: fault injection — abort and delay are evaluated
7899            // BEFORE any upstream contact. The abort short-circuits with
7900            // a configured status; the delay injects a fixed latency.
7901            // Both are sampled by percentage (a random draw per
7902            // request). This runs before the body limit and digest
7903            // checks because an aborted request never reaches the
7904            // upstream and a delayed request waits before the forward
7905            // path begins.
7906            if let Some(fi) = &route.fault_injection {
7907                if let Some(abort) = &fi.abort {
7908                    if sample_percentage(abort.percentage) {
7909                        tracing::info!(
7910                            code = "fault_injection_abort",
7911                            request_id = %rid,
7912                            route = %route.name,
7913                            status = abort.status,
7914                            "fault injection: aborting request"
7915                        );
7916                        return simple(
7917                            StatusCode::from_u16(abort.status)
7918                                .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
7919                            "fault_injection_abort",
7920                            "request aborted by fault injection",
7921                            rid,
7922                        );
7923                    }
7924                }
7925                if let Some(delay) = &fi.delay {
7926                    if sample_percentage(delay.percentage) {
7927                        tracing::info!(
7928                            code = "fault_injection_delay",
7929                            request_id = %rid,
7930                            route = %route.name,
7931                            delay_ms = delay.fixed_ms,
7932                            "fault injection: delaying request"
7933                        );
7934                        tokio::time::sleep(Duration::from_millis(delay.fixed_ms)).await;
7935                    }
7936                }
7937            }
7938            // Streaming body limit (DW-027): the counting wrapper (a
7939            // thin passthrough when the route sets no cap; the
7940            // declared-length half was rejected above) guards requests
7941            // of unknown length for the whole proxy path.
7942            //
7943            // Signed-body digest enforcement (DW-036): an
7944            // HMAC-authenticated request carries the digest its
7945            // signature bound — the digesting wrapper folds every
7946            // streamed frame into a SHA-256 (nothing buffered) and
7947            // aborts the upstream send when the final hash disagrees.
7948            // It sits INSIDE the route's limit wrapper, so an over-cap
7949            // body is still rejected 413 first; every other request
7950            // (no signed digest) streams through unchanged.
7951            let signed_digest = identity.and_then(|id| id.body_digest);
7952            // Eager verdict for a signed body declaring EXACTLY zero
7953            // bytes: hyper's h1 encoder never polls it (see
7954            // DigestingBody's docs), so a digest mismatch there has
7955            // no mid-stream abort to surface through — refuse with
7956            // the family's 401 before the request is forwarded at
7957            // all. A correct empty digest forwards normally; unsigned
7958            // requests are unaffected.
7959            //
7960            // DW-062: shadow traffic mirroring. When the route has a
7961            // mirror config and the percentage sample hits, spawn a
7962            // fire-and-forget task that sends a copy of the request
7963            // (same headers and path, empty body) to the mirror
7964            // upstream. The mirror response is discarded and the task
7965            // is detached — it never impacts the primary request's
7966            // latency. The mirror runs AFTER fault injection (an
7967            // aborted request is never mirrored). For v1 the mirror
7968            // carries the request shape (method, path, headers) but an
7969            // empty body — this avoids body buffering entirely and has
7970            // truly zero latency impact. A future version may buffer
7971            // the body for full shadow requests.
7972            let mirror_cfg = route
7973                .mirror
7974                .as_ref()
7975                .filter(|m| m.percentage > 0 && sample_percentage(m.percentage));
7976            let (parts, body) = req.into_parts();
7977            // Spawn the mirror task (fire-and-forget) before the
7978            // primary forward. The mirror gets a cloned request with
7979            // an empty body.
7980            if let Some(m) = mirror_cfg {
7981                if let Some(mirror_handle) = dp.registry().get(&m.upstream) {
7982                    let mirror_parts = parts.clone();
7983                    let mirror_upstream_name = m.upstream.clone();
7984                    let mirror_rid = rid.to_string();
7985                    let mirror_timeout = Duration::from_millis(m.timeout_ms);
7986                    let mirror_timeout_ms = m.timeout_ms;
7987                    dp.observability_arc().record_mirror_sent(&m.upstream);
7988                    tokio::spawn(async move {
7989                        let mirror_req = Request::from_parts(mirror_parts, Full::new(Bytes::new()));
7990                        let result =
7991                            tokio::time::timeout(mirror_timeout, mirror_handle.send(mirror_req))
7992                                .await;
7993                        match result {
7994                            Ok(Ok(_)) => {}
7995                            Ok(Err(e)) => {
7996                                tracing::debug!(
7997                                    code = "mirror_request_failed",
7998                                    request_id = %mirror_rid,
7999                                    upstream = %mirror_upstream_name,
8000                                    error = %e,
8001                                    "mirror request failed (best-effort, ignored)"
8002                                );
8003                            }
8004                            Err(_) => {
8005                                tracing::debug!(
8006                                    code = "mirror_request_timeout",
8007                                    request_id = %mirror_rid,
8008                                    upstream = %mirror_upstream_name,
8009                                    timeout_ms = mirror_timeout_ms,
8010                                    "mirror request timed out (best-effort, ignored)"
8011                                );
8012                            }
8013                        }
8014                    });
8015                    tracing::debug!(
8016                        code = "mirror_sent",
8017                        request_id = %rid,
8018                        route = %route.name,
8019                        upstream = %m.upstream,
8020                        "sent mirror request to shadow upstream"
8021                    );
8022                }
8023            }
8024            let digesting = crate::dataplane::hardening::DigestingBody::new(body, signed_digest);
8025            if digesting.eager_digest_mismatch() {
8026                tracing::warn!(
8027                    code = "signature_body_mismatch",
8028                    request_id = %rid,
8029                    "signed empty request body did not match its digest; refused before forward"
8030                );
8031                unauthorized(crate::security::authn::HMAC_CHALLENGE, rid)
8032            } else {
8033                let req = Request::from_parts(
8034                    parts,
8035                    crate::dataplane::hardening::LimitedBody::new(
8036                        digesting,
8037                        // A dry-run limits block (DW-041) leaves the
8038                        // streaming guard unarmed: the cap is monitor
8039                        // mode, so a body that would have been aborted
8040                        // mid-stream flows through.
8041                        route
8042                            .limits
8043                            .as_ref()
8044                            .filter(|l| !l.dry_run)
8045                            .and_then(|l| l.max_body_bytes),
8046                    ),
8047                );
8048                proxy_request(
8049                    gen,
8050                    peer,
8051                    req,
8052                    route,
8053                    idx,
8054                    service,
8055                    params,
8056                    global_permit,
8057                    identity,
8058                    rid,
8059                    rec,
8060                    &dp.observability_arc(),
8061                    client_cert,
8062                    dp.oauth2_token_cache(),
8063                    applicable_policies,
8064                )
8065                .await
8066            }
8067        }
8068        RouteAction::Redirect {
8069            scheme,
8070            host,
8071            path: redirect_path,
8072            status,
8073        } => redirect(
8074            &req,
8075            scheme.as_deref(),
8076            host.as_deref(),
8077            redirect_path.as_deref(),
8078            *status,
8079            rid,
8080        ),
8081        RouteAction::Respond {
8082            status,
8083            body,
8084            headers,
8085        } => respond(*status, body.as_deref(), headers),
8086        RouteAction::NanoService { nano } => {
8087            // DW-106: nano-service action. The runtime dispatch is
8088            // compiled into every build (no cargo feature; the WASM
8089            // runtime is an unconditional dependency).
8090            {
8091                serve_nano_service(req, &route.name, nano, rid, &dp.observability_arc()).await
8092            }
8093        }
8094    }
8095}
8096
8097/// Serve a mock response (DW-047): no upstream contact, just the
8098/// canned status/headers/body. `body_file_bytes` is the preloaded file
8099/// content from the RouteTable (None when the mock uses an inline
8100/// `body` or an empty body). `delay_ms` simulates latency before the
8101/// response is sent.
8102async fn serve_mock(
8103    mock: &crate::config::MockAction,
8104    body_file_bytes: Option<&Bytes>,
8105    _rid: &str,
8106) -> Response<ProxyBody> {
8107    if let Some(delay) = mock.delay_ms {
8108        if delay > 0 {
8109            tokio::time::sleep(std::time::Duration::from_millis(delay as u64)).await;
8110        }
8111    }
8112    let body_bytes: Bytes = if let Some(file_bytes) = body_file_bytes {
8113        file_bytes.clone()
8114    } else if let Some(body) = &mock.body {
8115        Bytes::from(body.clone())
8116    } else {
8117        Bytes::new()
8118    };
8119    let mut builder =
8120        Response::builder().status(StatusCode::from_u16(mock.status).unwrap_or(StatusCode::OK));
8121    // Default content-type: application/json if the body parses as
8122    // JSON, else text/plain — but only when the operator did not set
8123    // one explicitly (an explicit header always wins).
8124    let has_content_type = mock
8125        .headers
8126        .keys()
8127        .any(|k| k.eq_ignore_ascii_case("content-type"));
8128    if !has_content_type {
8129        let is_json = serde_json::from_slice::<serde_json::Value>(&body_bytes).is_ok();
8130        builder = builder.header(
8131            hyper::header::CONTENT_TYPE,
8132            if is_json {
8133                "application/json"
8134            } else {
8135                "text/plain"
8136            },
8137        );
8138    }
8139    for (name, value) in &mock.headers {
8140        if let (Ok(n), Ok(v)) = (
8141            HeaderName::from_bytes(name.as_bytes()),
8142            HeaderValue::from_str(value),
8143        ) {
8144            builder = builder.header(n, v);
8145        }
8146    }
8147    builder
8148        .body(ProxyBody::Full(Full::new(body_bytes)))
8149        .expect("static mock body is valid")
8150}
8151
8152/// The maximum request body size a nano-service route accepts (DW-106).
8153/// The module receives the full request body, so it must be buffered;
8154/// 1 MiB matches the default `memory_limit` and keeps buffering bounded.
8155const NANO_SERVICE_BODY_CAP: usize = 1024 * 1024;
8156
8157/// Serve a nano-service response (DW-106): run the route's WASM module
8158/// with the request and return the response it produces. The request
8159/// body is buffered (capped at [`NANO_SERVICE_BODY_CAP`]); the module
8160/// gets the full request (method, path, headers, body) via the
8161/// nano-service ABI. Metrics are recorded for every outcome (success,
8162/// error, timeout). A module load/execution failure answers 502; a
8163/// timeout answers 504; a body over the cap answers 413.
8164async fn serve_nano_service<B>(
8165    req: Request<B>,
8166    route_name: &str,
8167    action: &crate::config::NanoServiceAction,
8168    rid: &str,
8169    obs: &Arc<Observability>,
8170) -> Response<ProxyBody>
8171where
8172    B: hyper::body::Body<Data = Bytes> + Send + 'static,
8173    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
8174{
8175    use http_body_util::BodyExt as _;
8176
8177    let started = std::time::Instant::now();
8178    let (parts, body) = req.into_parts();
8179    let method = parts.method.as_str().to_string();
8180    let path = parts.uri.path().to_string();
8181    let headers: Vec<(String, String)> = parts
8182        .headers
8183        .iter()
8184        .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
8185        .collect();
8186
8187    // Buffer the request body (the module receives it whole).
8188    let body_bytes = match Limited::new(body, NANO_SERVICE_BODY_CAP).collect().await {
8189        Ok(c) => c.to_bytes(),
8190        Err(err) => {
8191            obs.record_nano_service_request(route_name, "error");
8192            obs.record_nano_service_duration(route_name, started.elapsed().as_secs_f64());
8193            if err.downcast_ref::<LengthLimitError>().is_some() {
8194                return simple(
8195                    StatusCode::PAYLOAD_TOO_LARGE,
8196                    "nano_service_body_too_large",
8197                    &format!(
8198                        "nano-service request body exceeds {} bytes",
8199                        NANO_SERVICE_BODY_CAP
8200                    ),
8201                    rid,
8202                );
8203            }
8204            return simple(
8205                StatusCode::BAD_REQUEST,
8206                "nano_service_body_read_failed",
8207                &err.to_string(),
8208                rid,
8209            );
8210        }
8211    };
8212
8213    let handler = match crate::dataplane::nano_service::shared_handler() {
8214        Ok(h) => h,
8215        Err(e) => {
8216            tracing::error!(
8217                code = "nano_service_handler_init_failed",
8218                request_id = %rid,
8219                route = %route_name,
8220                error = %e,
8221                "nano-service handler initialization failed"
8222            );
8223            obs.record_nano_service_request(route_name, "error");
8224            obs.record_nano_service_duration(route_name, started.elapsed().as_secs_f64());
8225            return simple(
8226                StatusCode::BAD_GATEWAY,
8227                "nano_service_unavailable",
8228                "nano-service runtime is unavailable",
8229                rid,
8230            );
8231        }
8232    };
8233    let service = crate::dataplane::nano_service::NanoService::from_action(route_name, action);
8234
8235    match handler
8236        .handle(&service, &method, &path, &headers, &body_bytes)
8237        .await
8238    {
8239        Ok(resp) => {
8240            obs.record_nano_service_request(route_name, "success");
8241            obs.record_nano_service_duration(route_name, started.elapsed().as_secs_f64());
8242            build_nano_service_response(resp)
8243        }
8244        Err(crate::dataplane::nano_service::NanoServiceError::Timeout) => {
8245            tracing::warn!(
8246                code = "nano_service_timeout",
8247                request_id = %rid,
8248                route = %route_name,
8249                "nano-service execution timed out"
8250            );
8251            obs.record_nano_service_request(route_name, "timeout");
8252            obs.record_nano_service_duration(route_name, started.elapsed().as_secs_f64());
8253            simple(
8254                StatusCode::GATEWAY_TIMEOUT,
8255                "nano_service_timeout",
8256                "nano-service execution timed out",
8257                rid,
8258            )
8259        }
8260        Err(e) => {
8261            tracing::warn!(
8262                code = "nano_service_error",
8263                request_id = %rid,
8264                route = %route_name,
8265                error = %e,
8266                "nano-service execution failed"
8267            );
8268            obs.record_nano_service_request(route_name, "error");
8269            obs.record_nano_service_duration(route_name, started.elapsed().as_secs_f64());
8270            simple(
8271                StatusCode::BAD_GATEWAY,
8272                "nano_service_error",
8273                &e.to_string(),
8274                rid,
8275            )
8276        }
8277    }
8278}
8279
8280/// Build a [`Response`] from a nano-service module's response (DW-106).
8281/// Unbuildable header name/value pairs are skipped (a misbehaving module
8282/// must not panic the dataplane); the status defaults to 200 when the
8283/// module set an out-of-range value.
8284fn build_nano_service_response(
8285    resp: crate::dataplane::nano_service::NanoServiceResponse,
8286) -> Response<ProxyBody> {
8287    let mut builder =
8288        Response::builder().status(StatusCode::from_u16(resp.status).unwrap_or(StatusCode::OK));
8289    for (name, value) in &resp.headers {
8290        if let (Ok(n), Ok(v)) = (
8291            HeaderName::from_bytes(name.as_bytes()),
8292            HeaderValue::from_str(value),
8293        ) {
8294            builder = builder.header(n, v);
8295        }
8296    }
8297    builder
8298        .body(ProxyBody::Full(Full::new(Bytes::from(resp.body))))
8299        .expect("nano-service response body is valid")
8300}
8301
8302/// Validate the request body against a JSON schema (DW-047). Buffers
8303/// the body fully (the route's body limit was already enforced above),
8304/// parses it as JSON, and walks the schema. On success, returns the
8305/// request with the body replaced by the buffered bytes (a `Full<Bytes>`
8306/// body) so the action below sees the full body. On failure, returns
8307/// `Err(violation_path)`. A non-JSON body with a schema that expects an
8308/// object/array is a validation failure; a schema with no `type` is
8309/// permissive (only `required`/`enum`/bounds are checked).
8310async fn validate_and_replay_body<B>(
8311    req: Request<B>,
8312    schema: &crate::config::BodySchema,
8313) -> Result<Request<Full<Bytes>>, String>
8314where
8315    B: hyper::body::Body<Data = Bytes> + Send + 'static,
8316    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
8317{
8318    use http_body_util::BodyExt as _;
8319    let (parts, body) = req.into_parts();
8320    let collected = body
8321        .collect()
8322        .await
8323        .map_err(|_| "request body could not be read".to_string())?;
8324    let bytes = collected.to_bytes();
8325    // An empty body: if the schema requires fields, that is a mismatch;
8326    // otherwise (no required, no type, or type=null) it passes.
8327    if bytes.is_empty() {
8328        if !schema.required.is_empty() {
8329            return Err(format!(
8330                "missing required field(s): {}",
8331                schema.required.join(", ")
8332            ));
8333        }
8334        return Ok(Request::from_parts(parts, Full::new(bytes)));
8335    }
8336    let value: serde_json::Value =
8337        serde_json::from_slice(&bytes).map_err(|_| "body is not valid JSON".to_string())?;
8338    if let Some(violation) = validate_json_instance(&value, schema, "") {
8339        return Err(violation);
8340    }
8341    Ok(Request::from_parts(parts, Full::new(bytes)))
8342}
8343
8344/// Walk a JSON instance against the minimal schema subset (DW-047).
8345/// Returns `Some(path)` on the first violation, `None` on success. The
8346/// `path` is a JSON-pointer-style string (e.g. `/name` or `/items/0`).
8347fn validate_json_instance(
8348    value: &serde_json::Value,
8349    schema: &crate::config::BodySchema,
8350    path: &str,
8351) -> Option<String> {
8352    // type check
8353    if let Some(t) = &schema.r#type {
8354        let ok = match t.as_str() {
8355            "object" => value.is_object(),
8356            "array" => value.is_array(),
8357            "string" => value.is_string(),
8358            "number" => value.is_number(),
8359            "integer" => {
8360                value.is_i64()
8361                    || value.is_u64()
8362                    || (value.is_f64() && value.as_f64().map(|f| f.fract() == 0.0).unwrap_or(false))
8363            }
8364            "boolean" => value.is_boolean(),
8365            "null" => value.is_null(),
8366            _ => true, // unknown type: permissive (validation caught it)
8367        };
8368        if !ok {
8369            return Some(format!("{path}: expected type {t}"));
8370        }
8371    }
8372    // enum check
8373    if !schema.r#enum.is_empty() && !schema.r#enum.iter().any(|e| e == value) {
8374        return Some(format!("{path}: value not in enum"));
8375    }
8376    // string checks
8377    if let Some(s) = value.as_str() {
8378        if let Some(min) = schema.min_length {
8379            if (s.chars().count() as u64) < min {
8380                return Some(format!("{path}: string shorter than minLength {min}"));
8381            }
8382        }
8383        if let Some(max) = schema.max_length {
8384            if (s.chars().count() as u64) > max {
8385                return Some(format!("{path}: string longer than maxLength {max}"));
8386            }
8387        }
8388    }
8389    // numeric checks
8390    if let Some(n) = value.as_f64() {
8391        if let Some(min) = schema.minimum {
8392            if n < min {
8393                return Some(format!("{path}: number below minimum {min}"));
8394            }
8395        }
8396        if let Some(max) = schema.maximum {
8397            if n > max {
8398                return Some(format!("{path}: number above maximum {max}"));
8399            }
8400        }
8401    }
8402    // object checks
8403    if let Some(obj) = value.as_object() {
8404        for req in &schema.required {
8405            if !obj.contains_key(req) {
8406                return Some(format!("{}/{}: missing required field", path, req));
8407            }
8408        }
8409        for (key, child_schema) in &schema.properties {
8410            if let Some(child) = obj.get(key) {
8411                if let Some(v) =
8412                    validate_json_instance(child, child_schema, &format!("{path}/{key}"))
8413                {
8414                    return Some(v);
8415                }
8416            }
8417        }
8418        // additionalProperties
8419        if let Some(ap) = &schema.additional_properties {
8420            match ap.as_ref() {
8421                crate::config::AdditionalProperties::Bool(false) => {
8422                    for key in obj.keys() {
8423                        if !schema.properties.contains_key(key) {
8424                            return Some(format!("{path}/{key}: additional property not allowed"));
8425                        }
8426                    }
8427                }
8428                crate::config::AdditionalProperties::Schema(s) => {
8429                    for key in obj.keys() {
8430                        if !schema.properties.contains_key(key) {
8431                            if let Some(child) = obj.get(key) {
8432                                if let Some(v) =
8433                                    validate_json_instance(child, s, &format!("{path}/{key}"))
8434                                {
8435                                    return Some(v);
8436                                }
8437                            }
8438                        }
8439                    }
8440                }
8441                _ => {}
8442            }
8443        }
8444    }
8445    // array checks
8446    if let Some(arr) = value.as_array() {
8447        if let Some(items_schema) = &schema.items {
8448            for (i, elem) in arr.iter().enumerate() {
8449                if let Some(v) = validate_json_instance(elem, items_schema, &format!("{path}/{i}"))
8450                {
8451                    return Some(v);
8452                }
8453            }
8454        }
8455    }
8456    None
8457}
8458
8459fn respond(
8460    status: u16,
8461    body: Option<&str>,
8462    headers: &std::collections::BTreeMap<String, String>,
8463) -> Response<ProxyBody> {
8464    let body = body.unwrap_or("");
8465    let has_content_type = headers
8466        .keys()
8467        .any(|k| k.eq_ignore_ascii_case("content-type"));
8468    let mut builder =
8469        Response::builder().status(StatusCode::from_u16(status).unwrap_or(StatusCode::OK));
8470    if !has_content_type {
8471        builder = builder.header(hyper::header::CONTENT_TYPE, "text/plain");
8472    }
8473    for (name, value) in headers {
8474        // Validation rejects unbuildable name/value pairs; skip rather
8475        // than panic if a generation tear slipped one through.
8476        if let (Ok(n), Ok(v)) = (
8477            HeaderName::from_bytes(name.as_bytes()),
8478            HeaderValue::from_str(value),
8479        ) {
8480            builder = builder.header(n, v);
8481        }
8482    }
8483    builder
8484        .body(ProxyBody::Full(Full::new(Bytes::from(body.to_string()))))
8485        .expect("static respond body is valid")
8486}
8487
8488fn simple(status: StatusCode, code: &str, msg: &str, rid: &str) -> Response<ProxyBody> {
8489    Response::builder()
8490        .status(status)
8491        .header(hyper::header::CONTENT_TYPE, "application/json")
8492        .body(ProxyBody::Full(Full::new(observability::envelope_body(
8493            code, msg, rid,
8494        ))))
8495        .expect("static error body is valid")
8496}
8497
8498/// Parse the `Retry-After` header from an upstream response (DW-089)
8499/// into a duration from now. Supports both the integer-seconds and
8500/// HTTP-date forms (see `config::versioning::parse_retry_after`).
8501/// Returns `None` when the header is absent or unparseable (the caller
8502/// treats that as no origin-signal backoff).
8503fn parse_retry_after_header(headers: &hyper::HeaderMap) -> Option<std::time::Duration> {
8504    let value = headers.get(hyper::header::RETRY_AFTER)?;
8505    let s = value.to_str().ok()?;
8506    let now_unix_seconds = std::time::SystemTime::now()
8507        .duration_since(std::time::UNIX_EPOCH)
8508        .map(|d| d.as_secs() as i64)
8509        .unwrap_or(0);
8510    crate::config::versioning::parse_retry_after(s, now_unix_seconds)
8511}
8512
8513/// DW-062: sample a percentage (0..=100). Returns true with probability
8514/// `percentage / 100`. A percentage of 0 always returns false; 100 always
8515/// returns true. Uses a fast thread-local PRNG (no crypto strength needed
8516/// — this is a sampling decision, not a security boundary).
8517fn sample_percentage(percentage: u8) -> bool {
8518    if percentage == 0 {
8519        return false;
8520    }
8521    if percentage >= 100 {
8522        return true;
8523    }
8524    use std::cell::Cell;
8525    thread_local! {
8526        static STATE: Cell<u64> = Cell::new({
8527            // Seed from the system time — no crypto strength needed,
8528            // just enough entropy to avoid correlated samples across
8529            // threads. The xorshift step below scrambles it further.
8530            let seed = std::time::SystemTime::now()
8531                .duration_since(std::time::UNIX_EPOCH)
8532                .map(|d| d.as_nanos() as u64)
8533                .unwrap_or(0x9E3779B97F4A7C15);
8534            if seed == 0 { 0x9E3779B97F4A7C15 } else { seed }
8535        });
8536    }
8537    STATE.with(|state| {
8538        let mut s = state.get();
8539        // xorshift64 — fast, good enough for sampling.
8540        s ^= s << 13;
8541        s ^= s >> 7;
8542        s ^= s << 17;
8543        state.set(s);
8544        // Map to 0..=99 and compare.
8545        (s % 100) < percentage as u64
8546    })
8547}
8548
8549/// The outcome of the gateway cap admission phase (DW-015 + DW-053):
8550/// either a permit was acquired (Some) or the request was dry-run
8551/// admitted over the cap (None), the request was shed and the 503
8552/// response should be returned from `handle`, or the request needs to
8553/// wait for a permit (DW-053 queue path — the timed acquire runs outside
8554/// the span guard because `EnteredSpan` is not `Send`).
8555enum AdmissionResult {
8556    /// Admission succeeded: the permit is `Some` when a cap slot was
8557    /// acquired, `None` when dry-run admitted over the cap (DW-041).
8558    Permit(Option<OwnedSemaphorePermit>),
8559    /// The request was shed: return this 503 response from `handle`.
8560    Shed(Response<ProxyBody>),
8561    /// DW-053: the request reserved a queue slot and needs a timed
8562    /// acquire. Carries the semaphores, the timeout, and the queue
8563    /// depth atomic (to decrement after the acquire completes).
8564    Queue(
8565        Arc<Semaphore>,
8566        Option<Arc<Semaphore>>,
8567        Duration,
8568        Arc<AtomicU32>,
8569    ),
8570}
8571
8572/// The shed path for the gateway concurrency cap (DW-015 + DW-053).
8573/// When `dry_run` is true (DW-041), the would-shed is logged and counted
8574/// and the request is admitted over the cap (no permit) — the point is
8575/// observing what a cap would shed before enforcing it. When `dry_run`
8576/// is false, the request is shed with 503 "gateway saturated". When
8577/// `retry_after` is `Some` (DW-053: the request was shed due to queue
8578/// timeout or queue full), a `Retry-After` header is added to the 503.
8579///
8580/// Returns an [`AdmissionResult`]: `Permit(None)` for dry-run, `Shed`
8581/// for the 503 response. The caller returns the `Shed` response from
8582/// `handle` immediately.
8583#[allow(clippy::too_many_arguments)]
8584fn handle_shed(
8585    dp: &Arc<DataPlane>,
8586    priority: u8,
8587    rec: &mut AccessRecord,
8588    rid: &str,
8589    route: &Route,
8590    retry_after: Option<Duration>,
8591    dry_run: bool,
8592    consumer_name: Option<&str>,
8593    effective_sh: Option<&crate::config::transforms::SecurityHeaders>,
8594) -> AdmissionResult {
8595    if dry_run {
8596        dp.priority_counters.record_admitted(priority);
8597        dp.obs.record_policy_dry_run("load_shed", &route.name);
8598        dp.obs.emit_policy_dry_run(
8599            "load_shed",
8600            503,
8601            &route.name,
8602            consumer_name,
8603            rid,
8604            &format!(
8605                "gateway concurrency cap saturated at priority {priority}; \
8606                 request would have been shed (dry-run) and is admitted \
8607                 over the cap"
8608            ),
8609        );
8610        AdmissionResult::Permit(None)
8611    } else {
8612        dp.priority_counters.record_shed(priority);
8613        rec.shed = true;
8614        dp.obs.record_shed(priority);
8615        tracing::warn!(
8616            code = "gateway_saturated",
8617            request_id = %rid,
8618            priority = priority,
8619            "gateway concurrency cap saturated; request shed"
8620        );
8621        let mut resp = simple(
8622            StatusCode::SERVICE_UNAVAILABLE,
8623            "gateway_saturated",
8624            "gateway saturated",
8625            rid,
8626        );
8627        // DW-053: a Retry-After header on queue-timeout and queue-full
8628        // sheds (a small fixed value derived from the queue timeout, in
8629        // whole seconds, minimum 1). The immediate-shed path (DW-016,
8630        // no queue) carries no Retry-After — immediate re-dispatch
8631        // under a saturated gateway is not advised.
8632        if let Some(timeout) = retry_after {
8633            let secs = timeout.as_secs().max(1);
8634            if let Ok(v) = HeaderValue::from_str(&secs.to_string()) {
8635                resp.headers_mut().insert(hyper::header::RETRY_AFTER, v);
8636            }
8637        }
8638        stamp_security_headers(&mut resp, effective_sh);
8639        AdmissionResult::Shed(resp)
8640    }
8641}
8642
8643/// DW-053: try to acquire a permit from the general semaphore (or the
8644/// reserved bucket for high-priority) within the queue timeout. Returns
8645/// `Some(permit)` if acquired, `None` if the timeout expired. This is a
8646/// timed `acquire_owned` — the request parks on the semaphore's wait
8647/// queue until a permit is released or the timeout fires.
8648async fn try_acquire_queued(
8649    general: Arc<Semaphore>,
8650    reserved: Option<Arc<Semaphore>>,
8651    priority: u8,
8652    timeout: Duration,
8653) -> Option<OwnedSemaphorePermit> {
8654    // High-priority tries the general semaphore first, then the
8655    // reserved bucket (same two-tier order as the immediate path).
8656    let general_fut = general.acquire_owned();
8657    let reserved_fut = reserved.map(|r| r.acquire_owned());
8658    let result = tokio::time::timeout(timeout, async {
8659        match general_fut.await {
8660            Ok(permit) => Some(permit),
8661            Err(_) => {
8662                // The general semaphore was closed (should not happen
8663                // in normal operation — the Arc keeps it alive). Try
8664                // the reserved bucket as a fallback for high-priority.
8665                if priority >= HIGH_PRIORITY {
8666                    if let Some(fut) = reserved_fut {
8667                        fut.await.ok()
8668                    } else {
8669                        None
8670                    }
8671                } else {
8672                    None
8673                }
8674            }
8675        }
8676    })
8677    .await;
8678    match result {
8679        Ok(Some(permit)) => Some(permit),
8680        Ok(None) => None,
8681        Err(_) => None,
8682    }
8683}
8684
8685/// Header names always treated as hop-by-hop per RFC 7230 section 6.1
8686/// (plus the de-facto `Proxy-Connection`), and any name listed in ANY
8687/// `Connection` header (RFC 7230 allows multiple; only `get_all` sees them
8688/// all). `TE` is dropped — EXCEPT for a gRPC request (DW-039), whose
8689/// spec-mandated `TE: trailers` rides through so conformant gRPC
8690/// servers see the client's dialect (h2 carries trailers natively; the
8691/// header is the courtesy contract). `Upgrade` (and its `Connection`
8692/// token) survives only when the request is being tunneled.
8693///
8694/// Returns the `Connection` token list collected before stripping (original
8695/// case, deduplicated, order preserved) so the tunneling caller can rebuild
8696/// a `Connection` header with the surviving tokens. The owned list is
8697/// materialized only when `keep_upgrade` is set — the one caller that
8698/// consumes it is the upgrade-tunneling path; everyone else gets an empty
8699/// Vec (PERF: no per-request String allocations for the ubiquitous
8700/// `Connection: keep-alive`).
8701///
8702/// (Public so the DW-024 micro-benchmark can exercise it directly; it is
8703/// not part of the stable public surface.)
8704pub fn strip_hop_by_hop(
8705    headers: &mut HeaderMap,
8706    keep_upgrade: bool,
8707    preserve_te: bool,
8708) -> Vec<String> {
8709    // PERF: the tokens borrow the header values (&str slices) instead of
8710    // owning a String per token, and the comparison stays case-insensitive
8711    // in the filter closure (no lowercased copy). The owned list the
8712    // tunnel path needs is materialized BEFORE the removal loop so the
8713    // borrow ends before `headers` is mutated.
8714    let tokens = connection_tokens(headers);
8715    let drop: Vec<HeaderName> = headers
8716        .keys()
8717        .filter(|name| {
8718            let n = name.as_str();
8719            if tokens.iter().any(|t| t.eq_ignore_ascii_case(n)) {
8720                return !(keep_upgrade && n == "upgrade");
8721            }
8722            matches!(
8723                n,
8724                "connection"
8725                    | "keep-alive"
8726                    | "proxy-authenticate"
8727                    | "proxy-authorization"
8728                    | "proxy-connection"
8729                    | "trailer"
8730                    | "transfer-encoding"
8731            ) || (n == "te" && !preserve_te)
8732                || (n == "upgrade" && !keep_upgrade)
8733        })
8734        .cloned()
8735        .collect();
8736    let owned: Vec<String> = if keep_upgrade {
8737        tokens.iter().map(|t| t.to_string()).collect()
8738    } else {
8739        Vec::new()
8740    };
8741    for name in drop {
8742        headers.remove(&name);
8743    }
8744    owned
8745}
8746
8747/// Whether a request is gRPC (DW-039): HTTP/2 with an
8748/// `application/grpc` content type (the spec's family prefix —
8749/// `application/grpc+proto`, `+json`, ... all match).
8750///
8751/// (Public for the DW-039 unit tests; like `strip_hop_by_hop`, not
8752/// part of the stable public surface.)
8753pub fn grpc_request(headers: &HeaderMap, version: Version) -> bool {
8754    version == Version::HTTP_2
8755        && headers
8756            .get(hyper::header::CONTENT_TYPE)
8757            .and_then(|v| v.to_str().ok())
8758            .is_some_and(|v| v.starts_with("application/grpc"))
8759}
8760
8761/// Parse a `grpc-timeout` value (DW-039): 1..=8 ASCII decimal digits
8762/// plus a unit — Hours, Minutes, Seconds, milliseconds, microseconds,
8763/// nanoseconds (RFC `SnH` grammar, case-exact per the spec). Values
8764/// that overflow the duration space saturate at one day (a longer RPC
8765/// budget than that is un-enforceable by this gateway anyway); garbage
8766/// is `None` (the caller treats absence as no deadline).
8767///
8768/// (Public for the DW-039 unit tests; like `strip_hop_by_hop`, not
8769/// part of the stable public surface.)
8770pub fn parse_grpc_timeout(value: &str) -> Option<std::time::Duration> {
8771    let (digits, unit) = value.split_at(value.len().checked_sub(1)?);
8772    if digits.is_empty() || digits.len() > 8 || !digits.bytes().all(|b| b.is_ascii_digit()) {
8773        return None;
8774    }
8775    let n: u64 = digits.parse().ok()?;
8776    let nanos = match unit {
8777        "H" => n.saturating_mul(3_600_000_000_000),
8778        "M" => n.saturating_mul(60_000_000_000),
8779        "S" => n.saturating_mul(1_000_000_000),
8780        "m" => n.saturating_mul(1_000_000),
8781        "u" => n.saturating_mul(1_000),
8782        "n" => n,
8783        _ => return None,
8784    };
8785    Some(std::time::Duration::from_nanos(
8786        nanos.min(86_400_000_000_000),
8787    ))
8788}
8789
8790/// The post-upgrade WebSocket policing decision threaded to the
8791/// tunnel (DW-039 + DP-05 #253): the route label for the metric, the
8792/// frame-rate allowance, the per-frame size cap, and the idle timeout.
8793struct WsPoliceDecision {
8794    route: String,
8795    rate: Option<u64>,
8796    max_frame_size: Option<u64>,
8797    idle_timeout: Option<std::time::Duration>,
8798}
8799
8800/// Deduplicated tokens across ALL `Connection` header lines (an HTTP/1
8801/// message may carry several; `get` alone only consults the first).
8802/// PERF: borrows slices of the header values — the common one-token
8803/// request pays one Vec, zero Strings.
8804fn connection_tokens(headers: &HeaderMap) -> Vec<&str> {
8805    let mut tokens: Vec<&str> = Vec::new();
8806    for value in headers.get_all(CONNECTION) {
8807        let Ok(v) = value.to_str() else { continue };
8808        for t in v.split(',') {
8809            let t = t.trim();
8810            if !t.is_empty() && !tokens.iter().any(|e| e.eq_ignore_ascii_case(t)) {
8811                tokens.push(t);
8812            }
8813        }
8814    }
8815    tokens
8816}
8817
8818// --- trusted proxies: the IP/CIDR grammar lives in `config::net` (shared
8819// --- with validation and the authorization ACLs); the dataplane consumes
8820// --- it for the forwarded-header trust rule above. -------------------------
8821
8822// White-box test staying in src/ per AGENTS.md: exercises private proxy
8823// internals that are not reachable through the public API.
8824#[cfg(test)]
8825mod tests {
8826    use super::*;
8827
8828    /// DW-021 review fix: the invalid-redirect-target 500 envelope must
8829    /// carry the request's correlation id, not an empty string, so a
8830    /// misconfigured redirect route stays correlatable against the
8831    /// `x-request-id` response header and access logs. Config validation
8832    /// (snapshot.rs) already rejects header-hostile redirect targets, so
8833    /// this defensive branch is pinned at the unit level: the DEL byte in
8834    /// the host is exactly the kind of validation gap the branch guards.
8835    #[tokio::test]
8836    async fn invalid_redirect_target_envelope_carries_request_id() {
8837        let req = Request::builder()
8838            .uri("/v1/old?x=1")
8839            .body(())
8840            .expect("test request builds");
8841        let rid = "req-0000000000000000-000001";
8842        let resp = redirect(&req, Some("https"), Some("bad\u{7f}host"), None, 302, rid);
8843        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
8844        let body = http_body_util::BodyExt::collect(resp.into_body())
8845            .await
8846            .expect("static error body collects")
8847            .to_bytes();
8848        let envelope: serde_json::Value =
8849            serde_json::from_slice(&body).expect("error body is the JSON envelope");
8850        assert_eq!(
8851            envelope["error"]["code"].as_str().unwrap(),
8852            "invalid_redirect_target"
8853        );
8854        let got = envelope["error"]["request_id"].as_str().unwrap();
8855        assert!(!got.is_empty(), "request_id must not be empty");
8856        assert_eq!(got, rid, "envelope request_id must match the request");
8857    }
8858}