dwara_core/config/
mod.rs

1//! Configuration schema v1 for the gateway (frozen domain vocabulary).
2//!
3//! The types in this module model the source form of a dwara configuration
4//! file. Compilation into a runtime snapshot is a later concern
5//! (config-compile pipeline); here we define strict, self-describing types:
6//!
7//! - every struct rejects unknown fields (`deny_unknown_fields`),
8//! - field order follows declaration order on serialization,
9//! - the root type [`Gateway`] exports a JSON Schema via `schemars`.
10//!
11//! YAML <-> typed conversions are provided by [`parse_gateway`] (YAML text to
12//! typed value, with path-precise error messages) and [`gateway_to_yaml`]
13//! (typed value back to normalized YAML text). Round-trip guarantee:
14//! `parse_gateway(gateway_to_yaml(cfg))` always succeeds and yields a value
15//! that serializes to the identical normalized text (stable normalization,
16//! not byte-identity with the original input).
17//!
18//! Submodules carry the parts of the config CONTRACT that are more than
19//! serde shapes: [`credentials`] defines the credential selector/stored-hash
20//! formats every credential holder must agree on plus the `${...}`
21//! secret-reference grammar (DW-045), [`limits`] the numeric
22//! bounds validation enforces, [`net`] the trusted-proxy IP/CIDR
23//! grammar shared by validation and the runtime matchers,
24//! [`versioning`] the HTTP-date and media-type grammar of the API
25//! versioning aids (DW-048), and [`transforms`] the JSON-pointer
26//! grammar and shapes of the request/response transforms and
27//! security-header injection (DW-028). [`cache`] carries the
28//! route-scoped response-caching grammar (DW-037), and [`ai`] the
29//! AI provider/model grammar of the provider-adapter pack (DW-075).
30
31pub mod ai;
32pub mod cache;
33pub mod credentials;
34pub mod includes;
35pub mod limits;
36pub mod mesh;
37pub mod net;
38pub mod quotas;
39pub mod ssrf;
40pub mod transforms;
41pub mod versioning;
42
43use schemars::JsonSchema;
44use serde::{Deserialize, Serialize};
45
46use cache::RouteCache;
47use transforms::{Masking, SecurityHeaders, Transforms};
48
49/// Error produced when a configuration document fails to parse.
50///
51/// Carries the dotted path of the offending node (e.g. `listeners[0].port`)
52/// and the underlying serde message.
53#[derive(Debug)]
54pub struct ConfigError {
55    pub path: String,
56    pub message: String,
57}
58
59impl std::fmt::Display for ConfigError {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        write!(f, "config error at {}: {}", self.path, self.message)
62    }
63}
64
65impl std::error::Error for ConfigError {}
66
67/// CFG-03 (#239): the current config schema version. Increment when
68/// the schema gains a breaking change (removed fields, renamed
69/// fields, changed semantics). The `dwara lint` command warns when a
70/// config's `version` is older than this; `dwara migrate` upgrades
71/// configs to this version.
72pub const CURRENT_CONFIG_VERSION: u32 = 1;
73
74/// Default config version (assumed when `version` is absent from the
75/// YAML). The initial schema is version 1.
76fn default_config_version() -> u32 {
77    1
78}
79
80/// Whether the config version is the default (1). Used by
81/// `skip_serializing_if` to omit the field when it's the default.
82fn is_default_config_version(v: &u32) -> bool {
83    *v == 1
84}
85
86/// Root of a dwara configuration: one gateway process, N listeners, and one
87/// compiled config generation.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct Gateway {
91    /// CFG-03 (#239): config schema version. Defaults to 1 when
92    /// absent (the initial schema). The `dwara lint` command warns
93    /// when the version is older than the current schema version; the
94    /// `dwara migrate` command upgrades the config to the current
95    /// version.
96    #[serde(
97        default = "default_config_version",
98        skip_serializing_if = "is_default_config_version"
99    )]
100    pub version: u32,
101    /// Entry points the gateway binds.
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub listeners: Vec<Listener>,
104    /// Match rules routing requests to services.
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub routes: Vec<Route>,
107    /// Logical APIs being exposed.
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub services: Vec<Service>,
110    /// Load-balancing pools.
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub upstreams: Vec<Upstream>,
113    /// API caller identities.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub consumers: Vec<Consumer>,
116    /// Named reusable rule bundles.
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub policies: Vec<Policy>,
119    /// Gateway-level (global) policy attachment (#123): names of policies
120    /// from the `policies` list that apply to EVERY request the gateway
121    /// serves — the LEAST specific link of the frozen precedence chain
122    /// consumer > route > service > listener > global. Named
123    /// `global_policies` (not `policies`, which is the policy REGISTRY at
124    /// this level) so the two roles cannot be confused. All applicable
125    /// levels' rules AND together (see the rate-limiter module docs), so a
126    /// global policy is an additional constraint on every request, not a
127    /// default that more specific levels replace. Global policies also
128    /// apply to UNROUTED traffic: a request whose path matches no route
129    /// is rate-limited by them before the 404 is answered (the reserved
130    /// paths /healthz, /readyz, and /metrics stay exempt).
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub global_policies: Vec<String>,
133    /// Gateway-level (global) authorization rules (#123): the least
134    /// specific link of the authorization precedence chain
135    /// consumer > route > service > listener > global (see [`Authz`] for
136    /// the rule semantics and the `authz` module for the merge: a deny at
137    /// ANY level wins; otherwise the most specific level with rules
138    /// governs). Applies to every request that resolved a route
139    /// (authorization runs after route resolution per the documented
140    /// request-path order). Same validation rules as route-level
141    /// authorization; see [`Authz`].
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub authorization: Option<Authz>,
144    /// Gateway-level default security headers baseline (SEC-09, #213):
145    /// applied to EVERY route that does not carry its own
146    /// `security_headers` block and has not set
147    /// `security_headers_opt_out: true`. Fail-secure defaults: a
148    /// route with no `security_headers` block inherits this posture
149    /// rather than shipping without HSTS/nosniff. A route that needs
150    /// to disable a header from the default sets its own
151    /// `security_headers` block (which fully replaces the default
152    /// for that route), or sets `security_headers_opt_out: true` to
153    /// opt out entirely. See [`SecurityHeaders`].
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub default_security_headers: Option<SecurityHeaders>,
156    /// SEC-08 (#212): Global WAF policy with CRS-compatible rules.
157    /// When enabled, the rules are evaluated on every request
158    /// (regardless of route-level WAF config). The anomaly score is
159    /// accumulated across all matching rules; the request is blocked
160    /// when the score exceeds the threshold. See [`GlobalWaf`].
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub waf: Option<GlobalWaf>,
163    /// IP addresses / CIDR ranges of proxies whose `X-Forwarded-For` claims
164    /// are trusted (gateway-level; the direct connection peer must be in
165    /// this list for an inbound XFF chain to be preserved and extended).
166    /// Each entry must be an IP address (e.g. `10.1.2.3`) or a CIDR
167    /// (e.g. `10.0.0.0/8`); anything else fails validation. Empty (the
168    /// default) trusts nobody: every proxied request carries an XFF of
169    /// exactly the direct peer, and inbound XFF values are discarded.
170    #[serde(default, skip_serializing_if = "Vec::is_empty")]
171    pub trusted_proxies: Vec<String>,
172    /// Gateway-level concurrency cap (DW-015): the maximum number of
173    /// requests admitted concurrently across the WHOLE gateway. Absent
174    /// (the default) is unlimited; 0 is invalid (validation rejects it —
175    /// omit the field for unlimited). Over-cap requests are rejected
176    /// immediately with 503 "gateway saturated" (no queueing). A slot is
177    /// reserved at request admission and released when the response body
178    /// completes (or the connection drops). The reserved paths
179    /// `/healthz` and `/readyz` bypass the cap so liveness/readiness
180    /// probes still answer under saturation.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub max_concurrent_requests: Option<u32>,
183    /// Monitor mode (DW-041) for the gateway concurrency cap's load
184    /// shedding: when the cap is saturated and a request would be shed,
185    /// the would-shed is LOGGED and counted
186    /// (`dwara_policy_dry_run_total{phase="load_shed"}` + a
187    /// `dwara::policy` warn event) and the request is ADMITTED over the
188    /// cap instead — deliberately exceeding `max_concurrent_requests` so
189    /// an operator can observe what a cap would shed (and at which
190    /// priorities) before enforcing it. Only meaningful with a cap set;
191    /// validation rejects it on an uncapped gateway (it would be a
192    /// no-op flag that reads as coverage).
193    #[serde(default, skip_serializing_if = "is_false")]
194    pub load_shed_dry_run: bool,
195    /// JWT verification providers (DW-019): trusted token issuers whose
196    /// keys are fetched from a JWKS endpoint. Each provider independently
197    /// verifies `Authorization: Bearer` tokens (alg allowlist, iss/aud,
198    /// exp with leeway) and maps the token to a consumer — via the
199    /// provider's explicit `consumer` binding, or by matching a
200    /// consumer's `jwt` credential `issuer` against the token's `iss`
201    /// claim. Empty (the default): the gateway does not interpret Bearer
202    /// tokens and forwards `Authorization` upstream untouched.
203    #[serde(default, skip_serializing_if = "Vec::is_empty")]
204    pub jwt_providers: Vec<JwtProvider>,
205    /// Admin API listener (DW-022, decision 6): mTLS-ONLY management
206    /// endpoint. Absent (the default): no admin listener is started at
207    /// all — the gateway is admin-silent until an operator configures
208    /// one. When present, `tls` must carry all three files (server
209    /// cert, key, and the CA that client certificates must chain to);
210    /// a missing `client_ca_file` is rejected by validation — plaintext
211    /// admin is not a supported production shape. The dev fallback
212    /// (`DWARA_ADMIN_DEV=1`, dwara-bin) allows loopback-only plaintext
213    /// and is loudly dev-only. The admin listener's bind set is fixed
214    /// at startup; changes take effect on restart.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub admin: Option<AdminConfig>,
217    /// Deliberate opt-in to running a gateway with ZERO routes (#129,
218    /// maintainer decision). Default false: validation rejects a
219    /// route-less config. An empty route set is schema-valid (every
220    /// collection defaults empty), and a truncated or torn config
221    /// write (truncate-then-save, common in naive editors) is
222    /// schema-valid too — publishing it would drop ALL routing
223    /// mid-run (every request 404s while the file "looks fine"). The
224    /// guard applies to cold start AND hot reload alike; a rejected
225    /// reload keeps the running generation serving. Set this to true
226    /// only for deliberate route-less shapes (e.g. a gateway whose
227    /// sole job is the admin API): with the flag set, unrouted
228    /// requests answer 404 after listener/global policy checks, per
229    /// the documented request-path order.
230    #[serde(default, skip_serializing_if = "is_false")]
231    pub allow_empty_routes: bool,
232    /// HMAC request-signing verification policy (DW-036): the
233    /// gateway-wide clock-skew window applied to the
234    /// `X-Dwara-Timestamp` header of every signed request. Absent
235    /// (the default) = [`DEFAULT_HMAC_CLOCK_SKEW_SECS`] (±5 minutes).
236    /// See the `security::authn` module docs for the full
237    /// signing contract (canonical string, headers, replay window).
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub hmac_auth: Option<HmacAuth>,
240    /// Alert/event webhook targets (DW-044): every gateway event whose
241    /// kind appears in a target's `events` list is POSTed to that
242    /// target's `url` as one JSON envelope (id, kind, timestamp,
243    /// gateway instance, payload). Delivery runs on a background task,
244    /// never on the request path; emission is a bounded non-blocking
245    /// hand-off (a full queue drops the event and counts the drop), so
246    /// webhook failures can never stall the dataplane. Header values
247    /// may be inline or `${...}` secret references (DW-045) — inline
248    /// values are redacted in every config echo. See the `events`
249    /// module docs for the envelope, the retry/budget shape, and the
250    /// egress posture. Empty (the default): no webhooks, and events
251    /// are still counted (emitted/dropped) but delivered nowhere.
252    #[serde(default, skip_serializing_if = "Vec::is_empty")]
253    pub webhooks: Vec<Webhook>,
254    /// The embedded analytics store (DW-043). Absent (the default):
255    /// no analytics database, request records go nowhere beyond the
256    /// Prometheus families and the sampled access log, and the admin
257    /// API's analytics endpoints answer 404. When present, the
258    /// gateway opens a SEPARATE SQLite file (never the state store's
259    /// database) at `path`, feeds every completed request's access
260    /// record to it on a fire-and-forget channel (a full channel
261    /// DROPS and counts — analytics can never slow the dataplane),
262    /// rolls raw records up through 1m/5m/1h/1d additive tables with
263    /// per-granularity retention, and serves the dashboard/Top-N/
264    /// structured-query endpoints from the admin listener. See the
265    /// `analytics` module docs for the write path and the bounded-disk
266    /// story. Part of startup wiring (the database opens once); the
267    /// DIMENSIONS list is read live from the current generation so a
268    /// reload can add or rename dimensions without a restart.
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub analytics: Option<AnalyticsConfig>,
271    /// Real-time access-record stream (DW-121, the opt-in firehose out):
272    /// every completed request's access record — not rollups, not the
273    /// discrete DW-044 ops events — streamed to an external sink in
274    /// flushed batches. Complements, never replaces, the embedded
275    /// analytics store: the two are configured independently and a
276    /// deployment can run either, both, or neither. Absent (the
277    /// default): no stream. The pipeline is fire-and-forget end to end
278    /// (a bounded channel that drops and counts — it can never slow or
279    /// block the dataplane). See the `events::stream` module docs for
280    /// the wire format, the batching contract, and the failure
281    /// isolation story. Startup wiring arms the pipeline (the channel
282    /// capacity is fixed at boot); the sink set — including the
283    /// enabled/disabled state — is live per config generation.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub analytics_stream: Option<AnalyticsStreamConfig>,
286    /// The GeoIP database (DW-050): MaxMind-format .mmdb file backing
287    /// `authorization.geoip` country/ASN predicates. Absent: geo rules
288    /// are rejected by validation (an unevaluable gate is an authoring
289    /// error, not a silent pass).
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub geoip: Option<GeoipConfig>,
292    /// Bounded admission queues (DW-053): when the gateway concurrency
293    /// cap (`max_concurrent_requests`) is saturated, requests WAIT for a
294    /// permit up to `queue_timeout_ms` instead of being immediately
295    /// shed. This produces a graceful degradation curve (latency rises
296    /// before shedding begins) rather than the cliff of immediate
297    /// shedding. Absent (the default): the DW-016 behavior stands —
298    /// over-cap requests are shed immediately with 503. Requires
299    /// `max_concurrent_requests` (validation rejects an enabled queue
300    /// on an uncapped gateway, the same rule as `load_shed_dry_run`).
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub admission_queue: Option<AdmissionQueue>,
303    /// mTLS client-certificate consumer mapping (DW-035): maps a
304    /// VERIFIED client certificate (its SHA-256 fingerprint or subject
305    /// CN) to a consumer name, so a client presenting a valid mTLS
306    /// certificate is authenticated WITHOUT an API key, JWT, or other
307    /// header credential — the certificate IS the credential. The
308    /// certificate must still chain to a listener's `client_ca_file`
309    /// (verified at the TLS layer); this mapping only resolves an
310    /// ALREADY-VERIFIED certificate to a consumer. Distinct from the
311    /// per-consumer `mtls` credential (which binds a cert to ONE
312    /// consumer's credential list): this is a gateway-level table that
313    /// can map certificates independent of the consumer credential
314    /// registry. Absent (the default): mTLS certificates are matched
315    /// only through consumers' `mtls` credentials (the DW-019 path).
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub mtls_consumer_mapping: Option<MtlsConsumerMapping>,
318    /// Identity-forwarding headers (DW-035): when mTLS client auth is
319    /// used, the gateway forwards client certificate metadata to the
320    /// upstream as `X-Client-Cert-*` headers. These headers are
321    /// GATEWAY-SET: any inbound headers with these names are STRIPPED
322    /// from the client request before the gateway adds its own
323    /// (spoofing prevention — the "without headers spoofing" part of
324    /// the done-when). Absent (the default): no `X-Client-Cert-*`
325    /// headers are forwarded.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub mtls_forward_headers: Option<MtlsForwardHeaders>,
328    /// Enterprise license configuration (DW-032). Absent (the default):
329    /// the gateway runs in OSS mode — all features available are
330    /// OSS-only and no license verification occurs. When present, the
331    /// gateway verifies the license file at startup (if compiled with
332    /// the `ent` cargo feature) and gates enterprise features behind
333    /// the license's feature claims. The public key is NOT configured
334    /// here — it comes from the `DWARA_LICENSE_PUBLIC_KEY` env var (or
335    /// the compiled-in development key when unset), never
336    /// user-configurable, so an operator cannot substitute their own
337    /// key to forge a license. When the `ent` feature is NOT compiled
338    /// in, the block is accepted but inert (the gate is always
339    /// `LicenseGate::none()`).
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub license: Option<LicenseConfig>,
342    /// OIDC providers (DW-034): trusted OpenID Connect issuers whose
343    /// Bearer tokens are validated by token introspection (RFC 7662)
344    /// rather than local JWT signature verification. Each provider
345    /// independently introspects `Authorization: Bearer` tokens that did
346    /// not verify against any JWT provider (DW-019) and maps the
347    /// introspection result to a consumer via the provider's explicit
348    /// `consumer` binding. The gateway also supports the
349    /// authorization-code + PKCE flow (acting as an OIDC relying party),
350    /// token exchange (RFC 8693), and token revocation (RFC 7009). Empty
351    /// (the default): the gateway does not introspect Bearer tokens and
352    /// OIDC is inert. See the `security::oidc` module docs for the full
353    /// flow, caching, and fail-open/fail-closed posture.
354    #[serde(default, skip_serializing_if = "Vec::is_empty")]
355    pub oidc_providers: Vec<OidcProvider>,
356    /// Distributed Redis rate limiter (DW-031). Absent (the default):
357    /// the local in-memory GCRA limiter is used (one limit per
358    /// process). When present, the `ent` cargo feature is compiled in,
359    /// AND a valid license with the `redis_rate_limiter` feature claim
360    /// is loaded, the gateway uses a Redis-backed GCRA limiter so two
361    /// or more instances share one rate limit. The same GCRA algorithm
362    /// runs, but the bucket state lives in Redis and is updated
363    /// atomically via a Lua script in a single round-trip. When the
364    /// `ent` feature is NOT compiled in, or the license lacks the
365    /// claim, the block is accepted but inert (the local limiter is
366    /// used and a warning is logged). When Redis is unreachable,
367    /// `fail_open` (default true) lets requests through; `false`
368    /// rejects with 429.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub redis_rate_limiter: Option<RedisRateLimiterConfig>,
371    /// Distributed Redis-backed consumer request quotas (DW-155, ent
372    /// feature). Absent (the default): the local SQLite-backed quota
373    /// checker is used (one counter per instance, so a fleet of N
374    /// instances enforces N x the configured cap). When present, the
375    /// `ent` cargo feature is compiled in, AND a valid license with the
376    /// `redis_quotas` feature claim is loaded, the gateway uses a
377    /// Redis-backed quota checker so two or more instances share one
378    /// counter per (consumer, budget, window) — a fleet of N instances
379    /// enforces the CONFIGURED cap. The same daily/monthly budget
380    /// semantics run, but the counters live in Redis and are updated
381    /// atomically via a Lua script in a single round-trip. When the
382    /// `ent` feature is NOT compiled in, or the license lacks the
383    /// claim, the block is accepted but inert (the local SQLite checker
384    /// is used and a warning is logged). When Redis is unreachable,
385    /// `fail_open` (default true) lets requests through; `false`
386    /// rejects with 429.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub redis_quotas: Option<RedisQuotaConfig>,
389    /// Distributed Redis-backed response cache (SCALE-04, #183, ent
390    /// feature). Absent (the default): the local in-memory moka cache
391    /// is used (one cache per instance, so a fleet of N instances has
392    /// N x cold caches). When present, the `ent` cargo feature is
393    /// compiled in, AND a valid license with the `redis_cache` feature
394    /// claim is loaded, the gateway uses a Redis-backed response cache
395    /// so two or more instances share one cache — fleet-wide hit
396    /// ratios. A local moka tier can front Redis (two-tier with Pub/Sub
397    /// invalidation) or be disabled for a pure Redis cache. When the
398    /// `ent` feature is NOT compiled in, or the license lacks the
399    /// claim, the block is accepted but inert (the local moka cache is
400    /// used and a one-line notice is logged at startup).
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub redis_cache: Option<RedisCacheConfig>,
403    /// Config convergence (DW-054, enterprise feature). Absent (the
404    /// default): each gateway instance serves only its local config
405    /// generation and never watches remote instances. When present,
406    /// the `ent` cargo feature is compiled in, AND a valid license
407    /// with the `config_convergence` feature claim is loaded, the
408    /// gateway publishes its config generation to a shared backend
409    /// (Redis in v1; etcd/Consul are deferred behind the
410    /// `ConfigConvergenceBackend` trait) and polls for generations
411    /// published by other instances, converging to the highest
412    /// generation within the poll interval. A drift report is emitted
413    /// at `drift_check_interval_ms` when instances diverge. When the
414    /// `ent` feature is NOT compiled in, or the license lacks the
415    /// claim, the block is accepted but inert (the local file watcher
416    /// runs alone) and a one-line notice is logged at startup.
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub config_convergence: Option<ConfigConvergenceConfig>,
419    /// Proxy-wasm plugin definitions (DW-055). Each plugin is a .wasm
420    /// module loaded and compiled with every config generation
421    /// (checksum-keyed hot-swap) and run on the request pipeline
422    /// phases it declares. Routes reference plugins by name via their
423    /// `plugins` field; the dataplane dispatches them through the
424    /// unified plugin chain (DW-157). A plugin whose .wasm cannot be
425    /// read or compiled is marked crashed at load time (logged) and
426    /// routes referencing it answer 500 fail-closed.
427    #[serde(default, skip_serializing_if = "Vec::is_empty")]
428    pub plugins: Vec<PluginConfig>,
429    /// SCALE-12 (#192): remote/signed plugin registry configuration.
430    /// When present, plugin `source.url` references are resolved
431    /// against the registry's base URL and signatures are verified
432    /// against the registry's pinned public keys. See
433    /// [`PluginRegistryConfig`].
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub plugin_registry: Option<PluginRegistryConfig>,
436    /// SCALE-12 (#191): global filter-chain ordering and dry-run
437    /// configuration. Per-route `filter_chain` overrides take
438    /// precedence. See [`FilterChainConfig`].
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub filter_chain: Option<FilterChainConfig>,
441    /// The AI provider-adapter pack (DW-075): the provider pool and the
442    /// model alias table behind every `ai` route action. Absent (the
443    /// default): no AI surface — an `ai` route action is rejected by
444    /// validation. When present, clients send OpenAI chat-completions
445    /// shaped requests to an `ai` route and the gateway translates to
446    /// the target provider's wire format (OpenAI, Anthropic, Gemini),
447    /// normalizing responses (and streaming deltas) back to the OpenAI
448    /// shape. See [`ai::AiConfig`] and the `ai` domain docs.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub ai: Option<ai::AiConfig>,
451    /// Fleet operations (DW-098, enterprise feature). Absent (the
452    /// default): no fleet skew policy is enforced — the controller
453    /// accepts edges regardless of version. When present, the `ent`
454    /// cargo feature is compiled in, AND a valid license with the
455    /// `fleet_operations` feature claim is loaded, the controller
456    /// checks each connecting edge's version against the skew policy
457    /// and the admin API exposes `GET /fleet/skew` (per-edge skew
458    /// status) and `GET /fleet/status` (fleet-wide convergence +
459    /// version distribution). When the `ent` feature is NOT compiled
460    /// in, or the license lacks the claim, the block is accepted but
461    /// inert (the controller accepts all edges).
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub fleet: Option<FleetConfig>,
464    /// API lifecycle management (DW-110): the developer portal
465    /// (auto-generated from OpenAPI specs), environment profiles
466    /// (dev/staging/prod config overlays), and the API journey
467    /// recorder (request flow visualization for debugging). Absent
468    /// (the default): no lifecycle surface. The portal serving, profile
469    /// overlay application, and journey recording are scaffolded: the
470    /// block is accepted in every build and is inert until the runtime
471    /// wiring lands (validation
472    /// warns). See [`LifecycleConfig`].
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub lifecycle: Option<LifecycleConfig>,
475    /// Service mesh mode (DW-107, enterprise feature). Absent (the
476    /// default): the gateway runs as a regular reverse proxy. When
477    /// present, the `mesh` cargo feature is compiled in, AND a valid
478    /// license with the `service_mesh` feature claim is loaded, the
479    /// gateway runs as a sidecar in each pod: an init container
480    /// configures iptables/TPROXY redirects so all traffic to and from
481    /// the local application flows through the sidecar, which
482    /// terminates mTLS (inbound) and wraps mTLS (outbound) using
483    /// SPIFFE/SPIRE X.509 SVIDs fetched from the SPIRE Workload API.
484    /// When the `mesh` feature is NOT compiled in, or the license
485    /// lacks the claim, the block is accepted but inert (validation
486    /// warns, no sidecar listeners or SPIFFE client are wired). Ent-
487    /// only: validation warns when mesh is configured without the
488    /// `ent` feature. See [`mesh::MeshConfig`] and the `mesh` domain
489    /// module docs.
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub mesh: Option<mesh::MeshConfig>,
492    /// SSRF egress filter (SEC-13): when enabled, outbound connections
493    /// from webhook deliveries and OPA callouts are checked against a
494    /// deny set of private/loopback/link-local/metadata IP ranges.
495    /// An optional allowlist exempts specific CIDRs. Default disabled
496    /// (the v1 posture trusts operator-configured endpoints). See
497    /// [`ssrf::SsrfFilterConfig`] and [`ssrf::SsrfFilter`].
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub ssrf_filter: Option<ssrf::SsrfFilterConfig>,
500}
501
502/// Bounded admission queue config (DW-053, `gateway.admission_queue`).
503///
504/// When enabled and the concurrency cap is full, requests wait for a
505/// permit up to `queue_timeout_ms` instead of being immediately shed.
506/// The queue depth is bounded by `max_queue_size`; once full, further
507/// requests are shed immediately with 503 (the `queue_full` outcome).
508/// Per-priority splitting (`per_priority: true`, the default) reserves
509/// a fraction of queue capacity for high-priority requests so they are
510/// not starved by a low-priority queue fill.
511#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
512#[serde(deny_unknown_fields)]
513pub struct AdmissionQueue {
514    /// Master switch. Default false: the queue is inert and the
515    /// gateway sheds immediately on cap saturation (DW-016 behavior).
516    /// Requires `max_concurrent_requests` (validation rejects an
517    /// enabled queue on an uncapped gateway).
518    #[serde(default, skip_serializing_if = "is_false")]
519    pub enabled: bool,
520    /// Total number of requests that may wait for a permit across all
521    /// priority classes (1..=10000). Once the queue is at capacity,
522    /// further over-cap requests are shed immediately with 503
523    /// (the `queue_full` outcome).
524    pub max_queue_size: u32,
525    /// Maximum time a request waits for a permit, in milliseconds
526    /// (1..=10000). If the timeout expires before a permit becomes
527    /// available, the request is shed with 503 (the `timeout`
528    /// outcome) and a `Retry-After` header.
529    pub queue_timeout_ms: u64,
530    /// Split queue capacity across priority classes (default true).
531    /// When true, a fraction of `max_queue_size` is reserved for
532    /// high-priority requests (priority >= 8) so they are not starved
533    /// by a low-priority queue fill. When false, the queue is a single
534    /// shared pool (first-come-first-served).
535    #[serde(default = "default_true", skip_serializing_if = "is_false")]
536    pub per_priority: bool,
537}
538
539fn default_true() -> bool {
540    true
541}
542
543/// Default freshness target for live sketches (DW-092): 500 ms —
544/// sub-second freshness for the in-process rolling window.
545fn default_freshness_target() -> u64 {
546    500
547}
548
549/// Default baseline window count for the insights engine (DW-092):
550/// 1440 = 24 hours of 1-minute windows (one minute-of-day seasonal
551/// cycle).
552fn default_baseline_windows() -> u64 {
553    1440
554}
555
556/// mTLS client-certificate consumer mapping (DW-035,
557/// `gateway.mtls_consumer_mapping`).
558///
559/// Maps a VERIFIED client certificate to a consumer name by its
560/// SHA-256 fingerprint (lowercase colon-separated hex of the DER) or
561/// by subject CommonName. When enabled and a client presents a
562/// certificate that chains to a listener's `client_ca_file` but
563/// matches NO entry in this table, the gateway returns 401
564/// `mtls_consumer_not_mapped` (the certificate was verified but is not
565/// a known caller). A client that presents NO certificate on a
566/// listener with this mapping enabled falls through to the other
567/// authentication families (or 401 if the route requires auth and no
568/// family resolves).
569#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
570#[serde(deny_unknown_fields)]
571pub struct MtlsConsumerMapping {
572    /// Master switch. Default false: the mapping is inert and mTLS
573    /// certificates are matched only through consumers' `mtls`
574    /// credentials (the DW-019 path).
575    #[serde(default, skip_serializing_if = "is_false")]
576    pub enabled: bool,
577    /// Fingerprint-to-consumer entries. Each `fingerprint` is the
578    /// SHA-256 of the client certificate DER as lowercase
579    /// colon-separated hex (e.g. `ab:cd:ef:...`). The fingerprint is
580    /// the EXACT bytes the gateway computes from the presented
581    /// certificate, so a re-issued certificate needs a new entry.
582    #[serde(default, skip_serializing_if = "Vec::is_empty")]
583    pub consumers: Vec<MtlsFingerprintMapping>,
584    /// Subject-CN-to-consumer mapping (alternative to `consumers`):
585    /// maps the certificate's subject CommonName to a consumer name.
586    /// Binding by subject CN survives certificate re-issue under the
587    /// same CN; the certificate must still chain to a listener's
588    /// `client_ca_file`. Keys are exact, case-sensitive CN strings.
589    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
590    pub subject_cn_mapping: std::collections::BTreeMap<String, String>,
591}
592
593/// One fingerprint-to-consumer mapping entry (DW-035,
594/// `mtls_consumer_mapping.consumers[]`).
595#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
596#[serde(deny_unknown_fields)]
597pub struct MtlsFingerprintMapping {
598    /// SHA-256 fingerprint of the client certificate DER as lowercase
599    /// colon-separated hex (e.g. `ab:cd:ef:...`). Validated to be
600    /// 64 hex digits with optional colon separators.
601    pub fingerprint: String,
602    /// The consumer name this certificate resolves to. Must reference
603    /// a consumer in the `consumers` list (validation rejects an
604    /// unknown name).
605    pub consumer: String,
606}
607
608/// Identity-forwarding headers for mTLS client auth (DW-035,
609/// `gateway.mtls_forward_headers`).
610///
611/// When enabled, the gateway adds `X-Client-Cert-*` headers to the
612/// upstream request carrying metadata from the VERIFIED client
613/// certificate. These headers are GATEWAY-SET: any inbound headers
614/// whose names match the configured prefix are STRIPPED from the
615/// client request before the gateway adds its own, so a client cannot
616/// spoof certificate identity upstream.
617#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
618#[serde(deny_unknown_fields)]
619pub struct MtlsForwardHeaders {
620    /// Master switch. Default false: no `X-Client-Cert-*` headers are
621    /// forwarded.
622    #[serde(default, skip_serializing_if = "is_false")]
623    pub enabled: bool,
624    /// Header name prefix (default `X-Client-Cert`). The gateway adds
625    /// `<prefix>-Fingerprint`, `<prefix>-Subject-CN`, `<prefix>-Issuer-CN`,
626    /// and `<prefix>-Not-After`. Inbound headers starting with this
627    /// prefix (case-insensitive) are stripped before injection.
628    #[serde(
629        default = "default_mtls_forward_prefix",
630        skip_serializing_if = "is_default_mtls_forward_prefix"
631    )]
632    pub prefix: String,
633}
634
635fn default_mtls_forward_prefix() -> String {
636    "X-Client-Cert".to_string()
637}
638
639fn is_default_mtls_forward_prefix(p: &str) -> bool {
640    p == "X-Client-Cert"
641}
642
643/// Enterprise license configuration (DW-032, `gateway.license`).
644///
645/// When present, the gateway verifies the license file at startup (if
646/// compiled with the `ent` cargo feature) and gates enterprise features
647/// behind the license's feature claims. The public key is NOT in this
648/// block — it comes from the `DWARA_LICENSE_PUBLIC_KEY` env var (or the
649/// compiled-in development key when unset), never user-configurable, so
650/// an operator cannot substitute their own key to forge a license. When
651/// the `ent` feature is NOT compiled in, the block is accepted but inert
652/// (the gate is always `LicenseGate::none()`).
653#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
654#[serde(deny_unknown_fields)]
655pub struct LicenseConfig {
656    /// Filesystem path of the license file (JSON: claims + Ed25519
657    /// signature). The file is read and verified at startup and on
658    /// every config reload. A missing file refuses to start at cold
659    /// start (exit 1); on reload, a missing file keeps the running
660    /// generation serving.
661    pub file: String,
662    /// Grace period in days after the license expires before the gate
663    /// degrades to OSS (default 7; validated to 0..=30). During the
664    /// grace window enterprise features still work and a warning is
665    /// logged. 0 means no grace (immediate degradation on expiry).
666    #[serde(
667        default = "default_license_grace_period_days",
668        skip_serializing_if = "is_default_license_grace_period_days"
669    )]
670    pub grace_period_days: u32,
671}
672
673fn default_license_grace_period_days() -> u32 {
674    crate::config::limits::DEFAULT_LICENSE_GRACE_PERIOD_DAYS
675}
676
677fn is_default_license_grace_period_days(v: &u32) -> bool {
678    *v == crate::config::limits::DEFAULT_LICENSE_GRACE_PERIOD_DAYS
679}
680
681/// Redis deployment topology (SCALE-11, #248). Controls how the
682/// gateway connects to Redis for the distributed rate limiter,
683/// quotas, and shared cache. The default `single` mode uses one
684/// Redis URL (the existing behavior). `sentinel` mode resolves the
685/// master through a Sentinel deployment. `cluster` mode connects
686/// to a Redis Cluster.
687#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
688#[serde(rename_all = "lowercase", deny_unknown_fields)]
689pub enum RedisTopology {
690    /// Single Redis instance (the default). Uses the `url` field
691    /// directly. No HA — if the instance is down, the fail-open
692    /// policy applies.
693    #[default]
694    Single,
695    /// Redis Sentinel: resolves the master via the Sentinel nodes
696    /// listed in `nodes`, using `master_name` to identify the
697    /// monitored instance. The gateway connects to the resolved
698    /// master via a `ConnectionManager` (automatic reconnection on
699    /// failover is handled by re-resolving on disconnect).
700    Sentinel,
701    /// Redis Cluster: connects to the cluster via the node URLs in
702    /// `nodes` (or `url` if `nodes` is empty). Uses the redis crate's
703    /// `ClusterClient` which handles slot mapping, MOVED/ASK
704    /// redirections, and cross-slot routing automatically. Lua
705    /// scripts use hash tags (`{key}`) to ensure all keys in a script
706    /// land on the same slot.
707    Cluster,
708}
709
710/// Redis HA and multi-region config (SCALE-11, #248). Embedded in
711/// each Redis-backed config block (rate limiter, quotas, cache) to
712/// control the connection topology and per-request timeout.
713#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
714#[serde(deny_unknown_fields)]
715pub struct RedisHaConfig {
716    /// Deployment topology (default `single`).
717    #[serde(default, skip_serializing_if = "is_default_redis_topology")]
718    pub topology: RedisTopology,
719    /// Additional node URLs for `sentinel` or `cluster` mode. For
720    /// `sentinel`, these are the Sentinel node URLs (e.g.
721    /// `redis://sentinel-1:26379`). For `cluster`, these are the
722    /// cluster node URLs (any subset of the cluster's nodes; the
723    /// client discovers the rest). When empty, the `url` field on
724    /// the parent config is used as the sole node.
725    #[serde(default, skip_serializing_if = "Vec::is_empty")]
726    pub nodes: Vec<String>,
727    /// The master name for `sentinel` mode (the `master_name` in
728    /// Sentinel config). Required when `topology = sentinel`; ignored
729    /// otherwise.
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    pub master_name: Option<String>,
732    /// Per-request timeout in milliseconds (default 500; validated to
733    /// 50..=30 000). Applied to each Redis command (Lua script calls,
734    /// GET/SET for cache). A timed-out command is treated as a backend
735    /// error and the fail-open policy applies. 0 means no per-request
736    /// timeout (use the connection's default).
737    #[serde(
738        default = "default_redis_request_timeout_ms",
739        skip_serializing_if = "is_default_redis_request_timeout_ms"
740    )]
741    pub request_timeout_ms: u64,
742}
743
744impl Default for RedisHaConfig {
745    fn default() -> Self {
746        RedisHaConfig {
747            topology: RedisTopology::Single,
748            nodes: Vec::new(),
749            master_name: None,
750            request_timeout_ms: default_redis_request_timeout_ms(),
751        }
752    }
753}
754
755fn is_default_redis_topology(t: &RedisTopology) -> bool {
756    *t == RedisTopology::default()
757}
758
759fn default_redis_request_timeout_ms() -> u64 {
760    500
761}
762
763fn is_default_redis_request_timeout_ms(v: &u64) -> bool {
764    *v == default_redis_request_timeout_ms()
765}
766
767/// Distributed Redis-backed GCRA rate limiter config (DW-031,
768/// `gateway.redis_rate_limiter`).
769///
770/// When present and the `ent` cargo feature is compiled in AND a valid
771/// license with the `redis_rate_limiter` feature claim is loaded, the gateway
772/// uses a Redis-backed GCRA rate limiter instead of the local in-memory
773/// one — so two or more gateway instances share one rate limit per key. The
774/// same stacked-window GCRA semantics run, but the per-key TAT lives in
775/// Redis and is updated atomically via a Lua script in a single round-trip.
776/// When the `ent` feature is NOT compiled in, or the license lacks the
777/// claim, the block is accepted but inert (the local GCRA limiter is used).
778/// When Redis is unreachable, `fail_open` (default true) lets requests
779/// through; set it to false to reject with 429 instead.
780#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
781#[serde(deny_unknown_fields)]
782pub struct RedisRateLimiterConfig {
783    /// Redis connection URL (e.g. `redis://127.0.0.1:6379` or
784    /// `redis-cluster://...`). The connection is established once at
785    /// startup and pooled via a multiplexed `ConnectionManager`.
786    pub url: String,
787    /// Fail-open behavior when Redis is unreachable (default true):
788    /// `true` allows the request (no rate limiting); `false` rejects
789    /// with 429. Fail-open is the safer default for availability — a
790    /// Redis outage should not take down the gateway — but operators
791    /// who need hard limits can set this to `false`.
792    #[serde(default = "default_true", skip_serializing_if = "is_true")]
793    pub fail_open: bool,
794    /// Prefix for rate-limit keys in Redis (default `dwara:rl:`). Each
795    /// rule's per-key bucket is stored as `{prefix}{key}:{window_index}`.
796    #[serde(
797        default = "default_redis_key_prefix",
798        skip_serializing_if = "is_default_redis_key_prefix"
799    )]
800    pub key_prefix: String,
801    /// Connection timeout in milliseconds (default 1000; validated to
802    /// 100..=30 000). The timeout applies to the initial connection
803    /// establishment at startup, not to per-request Lua script calls
804    /// (those use the pooled connection's own timeout).
805    #[serde(
806        default = "default_redis_connection_timeout_ms",
807        skip_serializing_if = "is_default_redis_connection_timeout_ms"
808    )]
809    pub connection_timeout_ms: u64,
810    /// TTL for rate-limit keys in Redis, in seconds (default 3600).
811    /// Stale keys auto-expire so a key that stops receiving traffic
812    /// does not accumulate forever. The per-window GCRA Lua script
813    /// also sets an EXPIRE based on the burst tolerance; this value is
814    /// a floor that ensures cleanup even for long-burst windows.
815    #[serde(
816        default = "default_redis_key_ttl_s",
817        skip_serializing_if = "is_default_redis_key_ttl_s"
818    )]
819    pub key_ttl_s: u64,
820    /// HA and topology config (SCALE-11, #248). Controls whether the
821    /// gateway connects to a single Redis, a Sentinel deployment, or a
822    /// Redis Cluster. Defaults to single-instance mode (the existing
823    /// behavior).
824    #[serde(default, skip_serializing_if = "is_default_redis_ha")]
825    pub ha: RedisHaConfig,
826}
827
828/// Sentinel: returns true when the `RedisHaConfig` is at its default
829/// (single topology, no nodes, no master name, default request
830/// timeout). Used by `skip_serializing_if` to keep the config output
831/// clean for the common single-Redis case.
832fn is_default_redis_ha(ha: &RedisHaConfig) -> bool {
833    ha == &RedisHaConfig::default()
834}
835
836/// Distributed Redis-backed consumer request quotas config (DW-155,
837/// `gateway.redis_quotas`).
838///
839/// When present and the `ent` cargo feature is compiled in AND a valid
840/// license with the `redis_quotas` feature claim is loaded, the gateway
841/// uses a Redis-backed quota checker instead of the local SQLite-backed
842/// one — so two or more gateway instances share one quota counter per
843/// (consumer, budget, window). The same daily/monthly budget semantics
844/// run, but the counters live in Redis and are updated atomically via a
845/// Lua script in a single round-trip. When the `ent` feature is NOT
846/// compiled in, or the license lacks the claim, the block is accepted
847/// but inert (the local SQLite checker is used). When Redis is
848/// unreachable, `fail_open` (default true) lets requests through; set
849/// it to false to reject with 429 instead.
850#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
851#[serde(deny_unknown_fields)]
852pub struct RedisQuotaConfig {
853    /// Redis connection URL (e.g. `redis://127.0.0.1:6379` or
854    /// `redis-cluster://...`). The connection is established once at
855    /// startup and pooled via a multiplexed `ConnectionManager`.
856    /// May be the same Redis instance as the rate limiter
857    /// (`redis_rate_limiter.url`) — the key prefixes are distinct.
858    pub url: String,
859    /// Fail-open behavior when Redis is unreachable (default true):
860    /// `true` allows the request (no quota enforcement); `false`
861    /// rejects with 429. Fail-open is the safer default for
862    /// availability — a Redis outage should not take down the gateway
863    /// — but operators who need hard quotas can set this to `false`.
864    #[serde(default = "default_true", skip_serializing_if = "is_true")]
865    pub fail_open: bool,
866    /// Prefix for quota keys in Redis (default `dwara:quota:`). Each
867    /// (consumer, budget, window) counter is stored as
868    /// `{prefix}{consumer_id}:{budget}:{window_start}`.
869    #[serde(
870        default = "default_redis_quota_key_prefix",
871        skip_serializing_if = "is_default_redis_quota_key_prefix"
872    )]
873    pub key_prefix: String,
874    /// Connection timeout in milliseconds (default 1000; validated to
875    /// 100..=30 000). The timeout applies to the initial connection
876    /// establishment at startup, not to per-request Lua script calls.
877    #[serde(
878        default = "default_redis_connection_timeout_ms",
879        skip_serializing_if = "is_default_redis_connection_timeout_ms"
880    )]
881    pub connection_timeout_ms: u64,
882    /// HA and topology config (SCALE-11, #248). Same shape as
883    /// `redis_rate_limiter.ha`.
884    #[serde(default, skip_serializing_if = "is_default_redis_ha")]
885    pub ha: RedisHaConfig,
886}
887
888fn default_redis_quota_key_prefix() -> String {
889    "dwara:quota:".to_string()
890}
891
892fn is_default_redis_quota_key_prefix(p: &str) -> bool {
893    p == "dwara:quota:"
894}
895
896fn default_redis_key_prefix() -> String {
897    "dwara:rl:".to_string()
898}
899
900fn is_default_redis_key_prefix(p: &str) -> bool {
901    p == "dwara:rl:"
902}
903
904fn default_redis_connection_timeout_ms() -> u64 {
905    1000
906}
907
908fn is_default_redis_connection_timeout_ms(v: &u64) -> bool {
909    *v == 1000
910}
911
912fn default_redis_key_ttl_s() -> u64 {
913    3600
914}
915
916fn is_default_redis_key_ttl_s(v: &u64) -> bool {
917    *v == 3600
918}
919
920/// Distributed Redis-backed response cache config (SCALE-04, #183,
921/// `gateway.redis_cache`, ent feature).
922///
923/// When present and the `ent` cargo feature is compiled in AND a valid
924/// license with the `redis_cache` feature claim is loaded, the gateway
925/// uses a Redis-backed response cache instead of (or in addition to)
926/// the local in-memory moka cache — so two or more gateway instances
927/// share one response cache. Fleet-wide cache hit ratios replace N x
928/// per-instance cold caches, particularly impactful for canary and
929/// rolling-deploy scenarios where new instances start empty. When the
930/// `ent` feature is NOT compiled in, or the license lacks the claim,
931/// the block is accepted but inert (the local moka cache is used and a
932/// one-line notice is logged at startup).
933#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
934#[serde(deny_unknown_fields)]
935pub struct RedisCacheConfig {
936    /// Redis connection URL (e.g. `redis://127.0.0.1:6379` or
937    /// `redis-cluster://...`). The connection is established once at
938    /// startup with the configured timeout.
939    pub url: String,
940    /// Prefix for cache keys in Redis (default `dwara:cache:`). Each
941    /// cached response is stored as `{prefix}{key}`.
942    #[serde(
943        default = "default_redis_cache_key_prefix",
944        skip_serializing_if = "is_default_redis_cache_key_prefix"
945    )]
946    pub key_prefix: String,
947    /// Connection timeout in milliseconds (default 1000; validated to
948    /// 100..=30 000).
949    #[serde(
950        default = "default_redis_connection_timeout_ms",
951        skip_serializing_if = "is_default_redis_connection_timeout_ms"
952    )]
953    pub connection_timeout_ms: u64,
954    /// Whether to keep a local fronting cache (two-tier: local moka +
955    /// Redis). Default true: the local cache fronts Redis for hot keys,
956    /// and Redis Pub/Sub invalidation evicts local entries when another
957    /// instance purges a key. Set to false for a pure Redis cache (no
958    /// local tier).
959    #[serde(default = "default_true", skip_serializing_if = "is_true")]
960    pub local_tier: bool,
961    /// HA and topology config (SCALE-11, #248). Same shape as
962    /// `redis_rate_limiter.ha`.
963    #[serde(default, skip_serializing_if = "is_default_redis_ha")]
964    pub ha: RedisHaConfig,
965}
966
967fn default_redis_cache_key_prefix() -> String {
968    "dwara:cache:".to_string()
969}
970
971fn is_default_redis_cache_key_prefix(p: &str) -> bool {
972    p == "dwara:cache:"
973}
974
975/// Config convergence config (DW-054, `gateway.config_convergence`).
976///
977/// When present and the `ent` cargo feature is compiled in AND a valid
978/// license with the `config_convergence` feature claim is loaded, the
979/// gateway publishes its config generation to a shared backend and
980/// polls for generations published by other instances, converging to
981/// the highest generation within `poll_interval_ms`. The backend is
982/// the seam for etcd/Consul (deferred); v1 ships a Redis
983/// implementation. The coordinator runs ALONGSIDE the local file
984/// watcher: a local file change triggers a local reload plus a publish
985/// to the backend; a remote change (detected by polling) triggers a
986/// remote reload. Both go through `compile_and_publish`. When the
987/// `ent` feature is NOT compiled in, or the license lacks the claim,
988/// the block is accepted but inert (the local file watcher runs
989/// alone).
990#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
991#[serde(deny_unknown_fields)]
992pub struct ConfigConvergenceConfig {
993    /// Master switch. Default false: convergence is inert and the
994    /// gateway serves only its local config (the local file watcher
995    /// still runs). When true (and licensed), the coordinator
996    /// publishes and polls.
997    #[serde(default, skip_serializing_if = "is_false")]
998    pub enabled: bool,
999    /// Convergence backend type. v1 ships `"redis"`; etcd and Consul
1000    /// are deferred behind the `ConfigConvergenceBackend` trait (the
1001    /// value is the seam a future backend plugs into). Validation
1002    /// rejects any value other than `"redis"`.
1003    pub backend: String,
1004    /// Redis connection URL (e.g. `redis://127.0.0.1:6379`), required
1005    /// when `backend = "redis"`. The connection is established once at
1006    /// startup and pooled via a multiplexed `ConnectionManager`.
1007    #[serde(default, skip_serializing_if = "Option::is_none")]
1008    pub redis_url: Option<String>,
1009    /// Prefix for convergence keys in Redis (default `dwara:config`).
1010    /// Instance generations live under `{prefix}:instances` and config
1011    /// bodies under `{prefix}:config`.
1012    #[serde(
1013        default = "default_config_convergence_key_prefix",
1014        skip_serializing_if = "is_default_config_convergence_key_prefix"
1015    )]
1016    pub key_prefix: String,
1017    /// How often to poll the backend for generations published by
1018    /// other instances, in milliseconds (default 1000; validated to
1019    /// 100..=60 000). The done-when targets sub-second convergence;
1020    /// lower values converge faster at the cost of backend load.
1021    #[serde(
1022        default = "default_config_convergence_poll_interval_ms",
1023        skip_serializing_if = "is_default_config_convergence_poll_interval_ms"
1024    )]
1025    pub poll_interval_ms: u64,
1026    /// How often to read all instances' generations and report drift,
1027    /// in milliseconds (default 5000; validated to 1 000..=300 000).
1028    /// Drift is reported (structured log + metric) when instances
1029    /// serve different config hashes.
1030    #[serde(
1031        default = "default_config_convergence_drift_check_interval_ms",
1032        skip_serializing_if = "is_default_config_convergence_drift_check_interval_ms"
1033    )]
1034    pub drift_check_interval_ms: u64,
1035    /// Fail-open behavior when the convergence backend is unreachable
1036    /// (default true): `true` keeps serving the local config
1037    /// generation (convergence pauses until the backend recovers);
1038    /// `false` refuses to start at cold start when the backend cannot
1039    /// be reached. Fail-open is the safer default for availability --
1040    /// a backend outage should not take down the gateway.
1041    #[serde(default = "default_true", skip_serializing_if = "is_true")]
1042    pub fail_open: bool,
1043}
1044
1045fn default_config_convergence_key_prefix() -> String {
1046    "dwara:config".to_string()
1047}
1048
1049fn is_default_config_convergence_key_prefix(p: &str) -> bool {
1050    p == "dwara:config"
1051}
1052
1053fn default_config_convergence_poll_interval_ms() -> u64 {
1054    1000
1055}
1056
1057fn is_default_config_convergence_poll_interval_ms(v: &u64) -> bool {
1058    *v == 1000
1059}
1060
1061fn default_config_convergence_drift_check_interval_ms() -> u64 {
1062    5000
1063}
1064
1065fn is_default_config_convergence_drift_check_interval_ms(v: &u64) -> bool {
1066    *v == 5000
1067}
1068
1069/// Fleet operations config (DW-098, `gateway.fleet`).
1070///
1071/// Configures the version-skew policy and rolling-upgrade order for a
1072/// fleet of edge instances managed by a controller (CP/DP split,
1073/// DW-066). When present and the `ent` cargo feature is compiled in
1074/// AND a valid license with the `fleet_operations` feature claim is
1075/// loaded, the controller checks each connecting edge's version
1076/// against the skew policy and the admin API exposes fleet status
1077/// endpoints. When the `ent` feature is NOT compiled in, or the
1078/// license lacks the claim, the block is accepted but inert.
1079#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1080#[serde(deny_unknown_fields)]
1081pub struct FleetConfig {
1082    /// Master switch. Default false: fleet operations are inert and
1083    /// the controller accepts edges regardless of version. When true
1084    /// (and licensed), the skew policy is enforced.
1085    #[serde(default, skip_serializing_if = "is_false")]
1086    pub enabled: bool,
1087    /// The rolling-upgrade and version-skew policy.
1088    #[serde(default, skip_serializing_if = "Option::is_none")]
1089    pub upgrade: Option<FleetUpgradeConfig>,
1090    /// The controller's own version (the reference version edges are
1091    /// checked against). When absent, the gateway binary's
1092    /// `CARGO_PKG_VERSION` is used.
1093    #[serde(default, skip_serializing_if = "Option::is_none")]
1094    pub controller_version: Option<String>,
1095    /// How long an edge can go without being seen (no ack, no
1096    /// re-registration) before it is flagged stale, in seconds
1097    /// (default 60; validated to 5..=3600). Stale edges are reported
1098    /// by `GET /fleet/status` but are NOT automatically removed — the
1099    /// operator decides whether to drain or wait.
1100    #[serde(
1101        default = "default_fleet_stale_timeout_secs",
1102        skip_serializing_if = "is_default_fleet_stale_timeout_secs"
1103    )]
1104    pub stale_timeout_secs: u64,
1105}
1106
1107/// API lifecycle management config (DW-110, `gateway.lifecycle`).
1108///
1109/// Three sub-concerns, each additive and independently enabled:
1110///
1111/// - `portal` -- the developer portal: a read-only static HTML page
1112///   auto-generated from the configured OpenAPI spec sources, served
1113///   at a reserved path.
1114/// - `profiles` -- environment profiles: dev/staging/prod config
1115///   overlays. The profile is selected via the `DWARA_PROFILE` env
1116///   var; the selected profile's patch is merged onto the base config
1117///   at load time.
1118/// - `journey` -- the API journey recorder: records the request flow
1119///   through the gateway as a JSON document for debugging, stored via
1120///   the existing analytics raw table.
1121///
1122/// The block is accepted in every build and is inert until the
1123/// runtime wiring lands (validation warns).
1124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1125#[serde(deny_unknown_fields)]
1126pub struct LifecycleConfig {
1127    /// The developer portal config. Absent (the default): no portal.
1128    #[serde(default, skip_serializing_if = "Option::is_none")]
1129    pub portal: Option<LifecyclePortalConfig>,
1130    /// Environment profile overlays. Absent (the default): no
1131    /// overlays; the base config is used as-is.
1132    #[serde(default, skip_serializing_if = "Option::is_none")]
1133    pub profiles: Option<LifecycleProfilesConfig>,
1134    /// The journey recorder config. Absent (the default): no
1135    /// journeys are recorded.
1136    #[serde(default, skip_serializing_if = "Option::is_none")]
1137    pub journey: Option<LifecycleJourneyConfig>,
1138}
1139
1140/// Developer portal config (DW-110, `gateway.lifecycle.portal`).
1141///
1142/// The portal is a read-only static HTML page that aggregates the
1143/// configured OpenAPI specs. `enabled` defaults to false: even with
1144/// the `api_lifecycle` feature compiled in, the portal is inert until
1145/// `enabled: true`.
1146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1147#[serde(deny_unknown_fields)]
1148pub struct LifecyclePortalConfig {
1149    /// Master switch. Default false: the portal is not served.
1150    #[serde(default, skip_serializing_if = "is_false")]
1151    pub enabled: bool,
1152    /// The URL path the portal is served at (a reserved HTTP path,
1153    /// shadowing any configured route, like `/healthz`). Must start
1154    /// with `/`. Default `/portal`.
1155    #[serde(
1156        default = "default_lifecycle_portal_path",
1157        skip_serializing_if = "is_default_lifecycle_portal_path"
1158    )]
1159    pub path: String,
1160    /// The OpenAPI spec sources to aggregate. Each source is either a
1161    /// `file` path or a `url` (an upstream `/openapi.json` endpoint).
1162    /// Empty (the default): the portal renders an empty listing.
1163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1164    pub specs: Vec<LifecyclePortalSpec>,
1165}
1166
1167fn default_lifecycle_portal_path() -> String {
1168    "/portal".to_string()
1169}
1170
1171fn is_default_lifecycle_portal_path(p: &String) -> bool {
1172    p == "/portal"
1173}
1174
1175/// One OpenAPI spec source for the developer portal (DW-110,
1176/// `gateway.lifecycle.portal.specs[]`).
1177///
1178/// Either a `file` path (read from disk) or a `url` (an upstream
1179/// `/openapi.json` endpoint, fetched at render time). Exactly one of
1180/// `file`/`url` must be set; validation rejects a spec with neither
1181/// or both.
1182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1183#[serde(deny_unknown_fields)]
1184pub struct LifecyclePortalSpec {
1185    /// A file path to an OpenAPI 3.x JSON document on disk. The file
1186    /// must exist and be readable (validation checks this at config
1187    /// publish time).
1188    #[serde(skip_serializing_if = "Option::is_none")]
1189    pub file: Option<String>,
1190    /// An upstream `/openapi.json` endpoint URL (http or https). The
1191    /// portal fetches the spec from this URL at render time. NOT
1192    /// fetched at validation time (the upstream may be down at publish
1193    /// time but up at render time).
1194    #[serde(skip_serializing_if = "Option::is_none")]
1195    pub url: Option<String>,
1196    /// An optional display name for the spec. When absent, the portal
1197    /// derives the name from the spec's `info.title` (or the file name
1198    /// / URL host when the spec cannot be parsed).
1199    #[serde(skip_serializing_if = "Option::is_none")]
1200    pub name: Option<String>,
1201}
1202
1203/// Environment profile overlays config (DW-110,
1204/// `gateway.lifecycle.profiles`).
1205///
1206/// Carries a base config (as a YAML string) plus per-profile config
1207/// patches. The profile is selected via the `DWARA_PROFILE` env var;
1208/// the selected profile's patch is merged onto the base at load time.
1209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1210#[serde(deny_unknown_fields)]
1211pub struct LifecycleProfilesConfig {
1212    /// The base config, as a YAML string. This is the config that
1213    /// applies when no profile is selected (or the selected profile
1214    /// has no patch).
1215    #[serde(default, skip_serializing_if = "String::is_empty")]
1216    pub base_config: String,
1217    /// Per-profile config patches, keyed by profile name (`dev`,
1218    /// `staging`, `prod`). Each value is a YAML string merged onto the
1219    /// base config when that profile is selected.
1220    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1221    pub profile_overrides: std::collections::BTreeMap<String, String>,
1222    /// Directory of file-based profile patches (CFG-01, #180). When
1223    /// set, the selected profile's patch is loaded from
1224    /// `<profiles_dir>/<profile>.yaml` instead of the inline
1225    /// `profile_overrides` map. Relative paths resolve against the
1226    /// config file's directory. Takes precedence over
1227    /// `profile_overrides` when both are set.
1228    #[serde(default, skip_serializing_if = "Option::is_none")]
1229    pub profiles_dir: Option<String>,
1230}
1231
1232/// Journey recorder config (DW-110, `gateway.lifecycle.journey`).
1233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1234#[serde(deny_unknown_fields)]
1235pub struct LifecycleJourneyConfig {
1236    /// Master switch. Default false: no journeys are recorded.
1237    #[serde(default, skip_serializing_if = "is_false")]
1238    pub enabled: bool,
1239    /// The retention window for in-memory journeys, in hours. Must be
1240    /// greater than 0 (validation rejects 0). The durable copy's
1241    /// retention is the raw table's own retention; this cap only bounds
1242    /// the in-memory ring buffer. Default 24.
1243    #[serde(
1244        default = "default_lifecycle_journey_retention",
1245        skip_serializing_if = "is_default_lifecycle_journey_retention"
1246    )]
1247    pub retention_hours: u64,
1248}
1249
1250fn default_lifecycle_journey_retention() -> u64 {
1251    24
1252}
1253
1254fn is_default_lifecycle_journey_retention(h: &u64) -> bool {
1255    *h == 24
1256}
1257
1258/// Rolling-upgrade and version-skew policy (DW-098,
1259/// `gateway.fleet.upgrade`).
1260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1261#[serde(deny_unknown_fields)]
1262pub struct FleetUpgradeConfig {
1263    /// The version-skew policy: how tolerant the controller is of
1264    /// edges running a different version.
1265    ///
1266    /// - `"allow"` — accept any version (no skew check).
1267    /// - `"allow_minor_skew"` (default) — edges must match the
1268    ///   controller's major version and be within 1 minor version
1269    ///   (N-1 or N+1).
1270    /// - `"require_exact"` — edges must match the controller's version
1271    ///   exactly.
1272    #[serde(default, skip_serializing_if = "is_default_version_skew_policy")]
1273    pub skew: VersionSkewPolicyConfig,
1274    /// The upgrade order: which edges to upgrade first, identified by
1275    /// label selector. Edges matching earlier entries are upgraded
1276    /// before edges matching later entries. An edge matches an entry
1277    /// when it has ALL the labels in the entry's `labels` map. Empty
1278    /// (the default) means no ordering — all edges are upgraded
1279    /// simultaneously (canary/zone-based rollouts are configured by
1280    /// adding entries here).
1281    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1282    pub order: Vec<FleetUpgradeOrderEntry>,
1283    /// The maximum number of edges that may be upgraded
1284    /// simultaneously (a rolling-upgrade concurrency cap). 0 (the
1285    /// default) means no cap — all matching edges are upgraded at
1286    /// once. A typical canary rollout sets this to 1.
1287    #[serde(default, skip_serializing_if = "is_zero_u32")]
1288    pub max_concurrent: u32,
1289    /// Whether to fail the rollout if any edge reports a failed ack
1290    /// (applied: false). Default true: the controller stops pushing
1291    /// to further edges when one fails. false: the controller
1292    /// continues pushing to all matching edges regardless of
1293    /// individual failures (the operator monitors and rolls back
1294    /// manually).
1295    #[serde(default = "default_true", skip_serializing_if = "is_true")]
1296    pub halt_on_failure: bool,
1297}
1298
1299/// A label-selector entry in the upgrade order list.
1300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1301#[serde(deny_unknown_fields)]
1302pub struct FleetUpgradeOrderEntry {
1303    /// A name for this upgrade wave (for logging and the status
1304    /// endpoint). Must be non-empty.
1305    pub name: String,
1306    /// The label selector: an edge matches this entry when it has
1307    /// ALL of these labels (key=value). Must be non-empty.
1308    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
1309    pub labels: std::collections::HashMap<String, String>,
1310}
1311
1312/// The version-skew policy, mirroring the CP/DP
1313/// `cluster_sync::VersionSkewPolicy` but living in the config schema
1314/// (so the gateway config carries it, not just the controller
1315/// runtime).
1316#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
1317#[serde(rename_all = "snake_case", deny_unknown_fields)]
1318pub enum VersionSkewPolicyConfig {
1319    /// Allow any version skew. The edge accepts the config regardless
1320    /// of version.
1321    Allow,
1322    /// Allow skew within a minor version (e.g. 1.2.x can talk to
1323    /// 1.3.x, but not 2.0.x). This is the default.
1324    #[default]
1325    AllowMinorSkew,
1326    /// Require exact version match. The edge rejects the config if
1327    /// its version differs from the controller's.
1328    RequireExact,
1329}
1330
1331fn default_fleet_stale_timeout_secs() -> u64 {
1332    60
1333}
1334
1335fn is_default_fleet_stale_timeout_secs(v: &u64) -> bool {
1336    *v == 60
1337}
1338
1339fn is_default_version_skew_policy(p: &VersionSkewPolicyConfig) -> bool {
1340    *p == VersionSkewPolicyConfig::AllowMinorSkew
1341}
1342
1343fn is_zero_u32(v: &u32) -> bool {
1344    *v == 0
1345}
1346
1347/// The embedded analytics store config (DW-043, `gateway.analytics`).
1348#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1349#[serde(deny_unknown_fields)]
1350pub struct AnalyticsConfig {
1351    /// Filesystem path of the analytics SQLite database. The file is
1352    /// created (and migrated) on first open. A separate file from the
1353    /// state store on purpose: retention deletes and incremental
1354    /// vacuum churn must never compact the identity store.
1355    pub path: String,
1356    /// Maximum batch latency of the background writer, in
1357    /// milliseconds (default 1000; validated to [100, 60 000]). Not a
1358    /// correctness knob — the rollup grace covers multiples of this.
1359    #[serde(default, skip_serializing_if = "Option::is_none")]
1360    pub flush_ms: Option<u64>,
1361    /// Per-store retention, in milliseconds (defaults: raw 24 h, 1m
1362    /// 48 h, 5m 7 d, 1h 30 d, 1d 365 d). Validation enforces each
1363    /// floor (a granularity must outlive its own window comfortably)
1364    /// and monotonicity (no coarser table may expire before a finer
1365    /// one).
1366    #[serde(default, skip_serializing_if = "Option::is_none")]
1367    pub retention: Option<AnalyticsRetention>,
1368    /// Custom dimensions (DW-043): request-header-sourced tags that
1369    /// become analytics dimensions — `x-plan` as dimension `plan`,
1370    /// for consumer tier / plan / feature-flag style cuts the fixed
1371    /// dimensions cannot express. Extracted at request completion
1372    /// capture; analytics-only (never added to the access log). At
1373    /// most 16 (cardinality guard); values are capped at 128 bytes
1374    /// (longer values are skipped for that request).
1375    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1376    pub dimensions: Vec<AnalyticsDimension>,
1377    /// Scheduled usage-report exports (DW-120): durable CSV/JSON
1378    /// dumps of the per-consumer usage statement, one file set per
1379    /// closed UTC calendar window, written by a background worker
1380    /// that reuses the analytics store's scheduling machinery. Absent
1381    /// (the default): no exports run and the admin export endpoints
1382    /// answer that exports are not configured. See the
1383    /// `analytics::exports` module docs for the schedule, the
1384    /// statement's reconciliation contract with the query API, and
1385    /// the quota-column window-alignment rule.
1386    #[serde(default, skip_serializing_if = "Option::is_none")]
1387    pub exports: Option<AnalyticsExports>,
1388    /// Live in-process sketches (DW-092): sub-second-freshness
1389    /// aggregation maintained synchronously on the request-completion
1390    /// hot path (per-route rolling window with counts, errors, and
1391    /// latency samples). Never blocks the dataplane — the sketch is
1392    /// updated in place under atomics and a short mutex over a capped
1393    /// sample vector. Absent (the default): no live sketches; the
1394    /// `GET /analytics/live` endpoint answers
1395    /// `analytics_not_configured`.
1396    #[serde(default, skip_serializing_if = "Option::is_none")]
1397    pub live_sketches: Option<AnalyticsLiveSketches>,
1398    /// ML traffic insights (DW-092): EWMA-based capacity forecasting
1399    /// and seasonal-baseline anomaly detection over the live sketch
1400    /// window rotations. The insights engine is in-process and
1401    /// hand-rolled (no ML crates): a minute-of-day ring buffer holds
1402    /// the seasonal pattern, an EWMA over recent windows carries the
1403    /// trend, and anomaly detection compares the current window's
1404    /// shape to the seasonal baseline. Absent (the default): no
1405    /// forecasting or anomaly detection; the
1406    /// `GET /analytics/forecast` and `GET /analytics/anomalies`
1407    /// endpoints answer `analytics_not_configured`.
1408    #[serde(default, skip_serializing_if = "Option::is_none")]
1409    pub insights: Option<AnalyticsInsights>,
1410    /// Replay time-travel debugging capture (DW-102): opt-in capture of
1411    /// request detail (redacted headers, auth identity) into the raw
1412    /// analytics table for offline replay against candidate configs.
1413    /// Absent (the default): no replay capture; the raw table's
1414    /// `request_headers_redacted` and `auth_identity` columns stay
1415    /// NULL. When present with `enabled: true`, the fire-and-forget
1416    /// analytics writer records the redacted headers and auth identity
1417    /// alongside the existing access fields, bounded by `retain_hours`.
1418    /// See [`AnalyticsReplayCapture`].
1419    #[serde(default, skip_serializing_if = "Option::is_none")]
1420    pub replay_capture: Option<AnalyticsReplayCapture>,
1421    /// REL-10 (#221): sampled degradation under channel pressure. When
1422    /// the analytics channel's fill ratio exceeds `high_watermark`,
1423    /// the offer path probabilistically drops records (keeping 1 in
1424    /// `sample_rate`) BEFORE the channel fills, so a sustained burst
1425    /// degrades to sampled telemetry rather than a hard drop cliff.
1426    /// Below `low_watermark` every record is kept. Defaults to off
1427    /// (no sampling); when enabled, `high_watermark` must be >
1428    /// `low_watermark` and both are in (0.0, 1.0].
1429    #[serde(default, skip_serializing_if = "Option::is_none")]
1430    pub sampled_degradation: Option<AnalyticsSampledDegradation>,
1431}
1432
1433/// Sampled degradation config (REL-10, #221). See
1434/// [`AnalyticsConfig::sampled_degradation`].
1435#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1436#[serde(deny_unknown_fields)]
1437pub struct AnalyticsSampledDegradation {
1438    /// Channel fill ratio above which sampling kicks in. Must be in
1439    /// (0.0, 1.0) and > `low_watermark`.
1440    #[serde(default = "default_sampled_high_watermark")]
1441    pub high_watermark: f64,
1442    /// Channel fill ratio below which sampling stops (every record
1443    /// kept). Must be in (0.0, 1.0) and < `high_watermark`.
1444    #[serde(default = "default_sampled_low_watermark")]
1445    pub low_watermark: f64,
1446    /// Fraction of records to KEEP when sampling (1 in N). 1.0 keeps
1447    /// all (no sampling); 0.1 keeps 10%. Must be in (0.0, 1.0].
1448    #[serde(default = "default_sampled_keep_rate")]
1449    pub keep_rate: f64,
1450}
1451
1452fn default_sampled_high_watermark() -> f64 {
1453    0.8
1454}
1455
1456fn default_sampled_low_watermark() -> f64 {
1457    0.5
1458}
1459
1460fn default_sampled_keep_rate() -> f64 {
1461    0.1
1462}
1463
1464/// Live in-process sketches config (DW-092, `analytics.live_sketches`).
1465///
1466/// The sketches are maintained inside the embedded analytics runtime
1467/// and updated on every `record()` call — a per-route rolling window
1468/// (size = `freshness_target_ms`) with request/error counts and a capped
1469/// latency-sample vector. Percentiles are computed at snapshot time
1470/// (sort-and-pick, O(n log n) with n capped at 1000), never on the hot
1471/// path. No new dependencies: the sketches are hand-rolled with atomics
1472/// and a short mutex, the same approach as the load-generator's
1473/// histogram.
1474#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1475#[serde(deny_unknown_fields)]
1476pub struct AnalyticsLiveSketches {
1477    /// Whether live sketches are maintained (default true when the
1478    /// block is present). Setting false disables the in-process
1479    /// sketches while keeping the block present (a no-op shape that
1480    /// reads as coverage).
1481    #[serde(default = "default_true", skip_serializing_if = "is_false")]
1482    pub enabled: bool,
1483    /// The rolling window size in milliseconds (default 500). Also the
1484    /// freshness target: a snapshot reflects at most this much
1485    /// staleness. Validated to [100, 5000] — below 100 is timer churn,
1486    /// above 5000 is no longer "live".
1487    #[serde(
1488        default = "default_freshness_target",
1489        skip_serializing_if = "is_default_freshness_target"
1490    )]
1491    pub freshness_target_ms: u64,
1492}
1493
1494fn is_default_freshness_target(v: &u64) -> bool {
1495    *v == 500
1496}
1497
1498/// ML traffic insights config (DW-092, `analytics.insights`).
1499///
1500/// The insights engine is hand-rolled (no ML crates): an EWMA over
1501/// recent sketch-window rotations carries the trend, a minute-of-day
1502/// ring buffer (1440 entries) holds the seasonal baseline, and anomaly
1503/// detection compares the current window's shape to the seasonal
1504/// baseline with a configurable factor. Forecasting combines the
1505/// seasonal average for the next minute with the EWMA trend
1506/// adjustment.
1507#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1508#[serde(deny_unknown_fields)]
1509pub struct AnalyticsInsights {
1510    /// Whether capacity forecasting is enabled (default false). When
1511    /// true, the `GET /analytics/forecast` endpoint returns a
1512    /// prediction for the next window.
1513    #[serde(default, skip_serializing_if = "is_false")]
1514    pub forecast: bool,
1515    /// Whether seasonal-baseline anomaly detection is enabled (default
1516    /// false). When true, the `GET /analytics/anomalies` endpoint
1517    /// returns the current window's anomaly status.
1518    #[serde(default, skip_serializing_if = "is_false")]
1519    pub anomaly_baseline: bool,
1520    /// The number of recent windows retained for the EWMA trend and
1521    /// the seasonal baseline ring (default 1440 = 24 hours of 1-minute
1522    /// windows). Validated to be > 0 when forecast or anomaly_baseline
1523    /// is enabled.
1524    #[serde(
1525        default = "default_baseline_windows",
1526        skip_serializing_if = "is_default_baseline_windows"
1527    )]
1528    pub baseline_windows: u64,
1529}
1530
1531fn is_default_baseline_windows(v: &u64) -> bool {
1532    *v == 1440
1533}
1534
1535/// Replay time-travel debugging capture config (DW-102,
1536/// `analytics.replay_capture`). Opt-in capture of request detail
1537/// (redacted headers, auth identity) into the raw analytics table for
1538/// offline replay against candidate configs. The capture reuses the
1539/// existing fire-and-forget analytics writer path and the existing PII
1540/// redaction patterns (`ai::redaction`); body capture is OFF by
1541/// default (PII risk).
1542#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1543#[serde(deny_unknown_fields)]
1544pub struct AnalyticsReplayCapture {
1545    /// Whether replay capture is active (default false — opt-in). When
1546    /// false the raw table's `request_headers_redacted` and
1547    /// `auth_identity` columns stay NULL even when the block is
1548    /// present (a no-op shape that reads as coverage).
1549    #[serde(default, skip_serializing_if = "is_false")]
1550    pub enabled: bool,
1551    /// Time-bounded retention for replay-captured raw rows, in hours
1552    /// (default 24). Rows older than the retention window are deleted
1553    /// by the existing raw retention sweep. Validated to be > 0.
1554    #[serde(
1555        default = "default_replay_retain_hours",
1556        skip_serializing_if = "is_default_replay_retain_hours"
1557    )]
1558    pub retain_hours: u64,
1559    /// Whether to capture redacted request headers (default true when
1560    /// the block is present). Headers are scrubbed via the existing PII
1561    /// redaction patterns before storage.
1562    #[serde(default = "default_true", skip_serializing_if = "is_true")]
1563    pub capture_headers: bool,
1564    /// Whether to capture the request body (default false — PII risk).
1565    /// Body capture is off by default; enabling it is an explicit
1566    /// operator decision to accept the PII exposure of stored bodies
1567    /// (even redacted bodies may carry residual sensitive content).
1568    #[serde(default, skip_serializing_if = "is_false")]
1569    pub capture_body: bool,
1570}
1571
1572fn default_replay_retain_hours() -> u64 {
1573    24
1574}
1575
1576fn is_default_replay_retain_hours(v: &u64) -> bool {
1577    *v == 24
1578}
1579
1580/// Scheduled usage-report exports (DW-120, `analytics.exports`).
1581///
1582/// The exports worker ticks on the same background machinery as the
1583/// rollup cascade, closes each UTC calendar window of the configured
1584/// [`AnalyticsExportWindow`] kind once its data has settled, and
1585/// writes one file per configured format into `directory` (created on
1586/// demand; unwritable directories fail the run LOUD in the run record
1587/// and logs — never the dataplane). Parquet is deliberately NOT a v1
1588/// format: the arrow/parquet dependency weight is deferred (see the
1589/// DW-156 backlog issue).
1590#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1591#[serde(deny_unknown_fields)]
1592pub struct AnalyticsExports {
1593    /// Directory the export files are written into (created on demand
1594    /// if missing). One deterministic file per (window, format); a
1595    /// re-export of the same window overwrites it (idempotent output,
1596    /// the rollup recompute philosophy).
1597    pub directory: String,
1598    /// The schedule/window kind (fixed UTC calendar windows). Default
1599    /// `daily` (midnight-to-midnight UTC — aligned with the quota
1600    /// daily budget's window, so a daily statement's quota column is
1601    /// exactly that day's counter).
1602    #[serde(default, skip_serializing_if = "Option::is_none")]
1603    pub window: Option<AnalyticsExportWindow>,
1604    /// Output formats, a subset of `csv`/`json`; omitted or empty
1605    /// means BOTH csv and json (the default). Validation rejects only
1606    /// duplicates.
1607    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1608    pub formats: Vec<AnalyticsExportFormat>,
1609}
1610
1611/// One export schedule kind (DW-120, `analytics.exports.window`).
1612/// Closed set; fixed UTC calendar windows.
1613#[derive(
1614    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
1615)]
1616#[serde(rename_all = "lowercase")]
1617pub enum AnalyticsExportWindow {
1618    Hourly,
1619    Daily,
1620    Monthly,
1621}
1622
1623/// One export output format (DW-120, `analytics.exports.formats[]`).
1624/// Closed set: `csv`, `json`, `parquet_csv`.
1625///
1626/// SCALE-12 (#190): `parquet_csv` writes a CSV file with a
1627/// `.parquet.csv` extension and a JSON metadata header describing
1628/// the schema in Parquet-compatible terms. The operator converts it
1629/// to true Parquet using an external tool (e.g., `duckdb -c "COPY
1630/// input.parquet.csv TO output.parquet (FORMAT PARQUET)"`). This
1631/// avoids adding the heavy `arrow`/`parquet` crate stack while
1632/// providing a clear path to Parquet output.
1633#[derive(
1634    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
1635)]
1636#[serde(rename_all = "lowercase")]
1637pub enum AnalyticsExportFormat {
1638    Csv,
1639    Json,
1640    /// SCALE-12 (#190): CSV with Parquet-compatible metadata header.
1641    /// The file extension is `.parquet.csv`.
1642    ParquetCsv,
1643}
1644
1645/// Per-granularity retention (DW-043, `analytics.retention`).
1646#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1647#[serde(deny_unknown_fields)]
1648pub struct AnalyticsRetention {
1649    /// Raw access records.
1650    #[serde(default, skip_serializing_if = "Option::is_none")]
1651    pub raw_ms: Option<i64>,
1652    /// The 1-minute rollup.
1653    #[serde(default, skip_serializing_if = "Option::is_none")]
1654    pub m1_ms: Option<i64>,
1655    /// The 5-minute rollup.
1656    #[serde(default, skip_serializing_if = "Option::is_none")]
1657    pub m5_ms: Option<i64>,
1658    /// The 1-hour rollup.
1659    #[serde(default, skip_serializing_if = "Option::is_none")]
1660    pub h1_ms: Option<i64>,
1661    /// The 1-day rollup.
1662    #[serde(default, skip_serializing_if = "Option::is_none")]
1663    pub d1_ms: Option<i64>,
1664}
1665
1666/// Default per-granularity retention (ms), indexed [raw, 1m, 5m, 1h,
1667/// 1d] — a week of traffic at every granularity a week query wants,
1668/// bounded disk at the 1-minute grain (DW-043). Lives in `config`
1669/// (the lowest consuming domain) so both validation and the analytics
1670/// store read ONE definition.
1671pub const ANALYTICS_DEFAULT_RETENTION_MS: [i64; 5] = [
1672    86_400_000,     // raw
1673    172_800_000,    // 1m
1674    604_800_000,    // 5m
1675    2_592_000_000,  // 1h
1676    31_536_000_000, // 1d
1677];
1678
1679impl AnalyticsRetention {
1680    /// Resolve to the effective [raw, m1, m5, h1, d1] millisecond
1681    /// set, defaulting absent entries (and clamping each to its
1682    /// floor so programmatic builders cannot under-retain below a
1683    /// window's own lifetime).
1684    pub fn effective(&self) -> [i64; 5] {
1685        let d = ANALYTICS_DEFAULT_RETENTION_MS;
1686        let floors = [
1687            5 * 60_000,      // raw: five fine windows
1688            10 * 60_000,     // m1
1689            60 * 60_000,     // m5
1690            24 * 3_600_000,  // h1
1691            30 * 86_400_000, // d1
1692        ];
1693        [
1694            self.raw_ms.unwrap_or(d[0]).max(floors[0]),
1695            self.m1_ms.unwrap_or(d[1]).max(floors[1]),
1696            self.m5_ms.unwrap_or(d[2]).max(floors[2]),
1697            self.h1_ms.unwrap_or(d[3]).max(floors[3]),
1698            self.d1_ms.unwrap_or(d[4]).max(floors[4]),
1699        ]
1700    }
1701}
1702
1703/// GeoIP allow/deny rules on the effective client IP (DW-050,
1704/// `authorization.geoip`). Countries are ISO 3166-1 alpha-2 codes
1705/// (case-insensitive in config, compared uppercase); ASNs are plain
1706/// numbers. Evaluation mirrors `allowed_consumers`/`denied_consumers`:
1707/// a denied match rejects (403) regardless of the allow lists; a
1708/// NON-EMPTY allow list admits only matches; empty allow lists
1709/// constrain nothing. The UNKNOWN case (unresolvable address, no
1710/// database) matches neither side.
1711#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1712#[serde(deny_unknown_fields)]
1713pub struct GeoipRules {
1714    /// Countries allowed (empty = any not denied).
1715    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1716    pub allowed_countries: Vec<String>,
1717    /// Countries rejected, even when otherwise allowed.
1718    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1719    pub denied_countries: Vec<String>,
1720    /// Autonomous system numbers allowed (empty = any not denied).
1721    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1722    pub allowed_asns: Vec<u32>,
1723    /// Autonomous system numbers rejected.
1724    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1725    pub denied_asns: Vec<u32>,
1726}
1727
1728/// The GeoIP database config (DW-050, `gateway.geoip`). One MaxMind-
1729/// format database serves both country and ASN predicates (a
1730/// GeoLite2-Country, GeoLite2-ASN, or combined database — whichever
1731/// subtrees it carries are the ones the rules can use). The file is
1732/// opened at startup (a failed open is LOUD but non-fatal: the gateway
1733/// serves with geo lookups resolving UNKNOWN) and hot-reloaded: the
1734/// watcher swaps the reader when the file changes, with no restart.
1735#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1736#[serde(deny_unknown_fields)]
1737pub struct GeoipConfig {
1738    /// Path to the .mmdb database file.
1739    pub path: String,
1740}
1741
1742/// The source of a custom analytics dimension value (DW-093). The
1743/// default (when `source` is absent) is [`DimensionSource::Header`],
1744/// preserving the original DW-043 header-only config shape.
1745#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1746#[serde(rename_all = "snake_case")]
1747pub enum DimensionSource {
1748    /// Read the value from a request header (the original DW-043
1749    /// source; requires the `header` field).
1750    Header,
1751    /// Read the value from a verified JWT claim (requires the `claim`
1752    /// field; the claim must be present in the resolved `Identity`'s
1753    /// claims map — only string- and number-valued top-level claims
1754    /// are available, the same subset authn exposes).
1755    Claim,
1756    /// Read the value from the request body via an RFC 6901 JSON
1757    /// pointer (requires the `body_path` field; only works when the
1758    /// body is buffered — retries, hedging, request transforms, or
1759    /// request validation opt the route into buffering; the
1760    /// zero-buffering default skips body-path dimensions).
1761    BodyPath,
1762}
1763
1764/// One custom analytics dimension (DW-043,
1765/// `analytics.dimensions[]`).
1766///
1767/// DW-093 extends the source beyond headers: a `source` of `claim`
1768/// extracts from the verified JWT's claims, and `body_path` extracts
1769/// from the request body via a JSON pointer (only when the body is
1770/// buffered). The original header-only shape (`header` without
1771/// `source`) is preserved for backward compatibility.
1772#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1773#[serde(deny_unknown_fields)]
1774pub struct AnalyticsDimension {
1775    /// The dimension name: lowercase `[a-z0-9_]`, at most 32 bytes —
1776    /// it becomes the rollup table's `dim` key and the query field.
1777    pub name: String,
1778    /// The dimension source (DW-093). Absent defaults to `header`
1779    /// (the original DW-043 behavior). When `header`, the `header`
1780    /// field is required; when `claim`, the `claim` field is
1781    /// required; when `body_path`, the `body_path` field is required.
1782    #[serde(default, skip_serializing_if = "Option::is_none")]
1783    pub source: Option<DimensionSource>,
1784    /// The request header whose value is captured (case-insensitive,
1785    /// as HTTP header names are). Required when `source` is `header`
1786    /// (or absent). The FIRST value of a repeated header wins;
1787    /// non-UTF-8 and over-128-byte values are skipped.
1788    #[serde(default, skip_serializing_if = "Option::is_none")]
1789    pub header: Option<String>,
1790    /// The JWT claim name whose value is captured (DW-093). Required
1791    /// when `source` is `claim`. The claim must be a string- or
1792    /// number-valued top-level claim in the verified token; other
1793    /// types and absent claims are skipped for that request.
1794    #[serde(default, skip_serializing_if = "Option::is_none")]
1795    pub claim: Option<String>,
1796    /// An RFC 6901 JSON pointer into the request body (DW-093).
1797    /// Required when `source` is `body_path`. Only works when the
1798    /// body is buffered (retries, hedging, request transforms, or
1799    /// request validation); the zero-buffering default skips
1800    /// body-path dimensions silently. The body must be valid JSON;
1801    /// non-JSON bodies and unresolved pointers are skipped.
1802    #[serde(default, skip_serializing_if = "Option::is_none")]
1803    pub body_path: Option<String>,
1804}
1805
1806impl AnalyticsDimension {
1807    /// The effective source (defaults to [`DimensionSource::Header`]
1808    /// when `source` is absent — the backward-compatible DW-043
1809    /// behavior).
1810    pub fn effective_source(&self) -> DimensionSource {
1811        self.source.unwrap_or(DimensionSource::Header)
1812    }
1813}
1814
1815/// One alert/event webhook target (DW-044, `gateway.webhooks[]`).
1816/// Compiled per generation into an `events::webhook::WebhookTarget`
1817/// (secret references resolved at compile time, URL decomposed); see
1818/// that module for the delivery contract.
1819#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1820#[serde(deny_unknown_fields)]
1821pub struct Webhook {
1822    /// Absolute `http://` or `https://` URL the envelope is POSTed to.
1823    /// Host and port come from the URL; the path (and query, if any) is
1824    /// preserved. `https://` verifies against the public webpki root set
1825    /// (no `trusted_ca_file` override for webhooks in v1).
1826    pub url: String,
1827    /// Event kinds this target receives (subset of the kinds the gateway
1828    /// emits; validation rejects unknown kinds). At least one entry.
1829    pub events: Vec<String>,
1830    /// Extra headers sent on every delivery. Values may be inline or
1831    /// `${ENV_NAME}` / `${file:/path}` secret references (DW-045),
1832    /// resolved at config-compile time; inline values are redacted in
1833    /// every config echo. Use a reference for bearer tokens and signing
1834    /// secrets.
1835    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1836    pub headers: std::collections::BTreeMap<String, String>,
1837    /// TOTAL budget for one delivery (all retry attempts share it):
1838    /// connect, write, and response-head read per attempt plus the
1839    /// backoff waits between attempts. A slow or hung target can never
1840    /// occupy more than this. Default 2000; validation enforces
1841    /// 1..=[`limits::MAX_WEBHOOK_TIMEOUT_MS`].
1842    #[serde(
1843        default = "default_webhook_timeout_ms",
1844        skip_serializing_if = "is_default_webhook_timeout_ms"
1845    )]
1846    pub timeout_ms: u64,
1847    /// Total attempts per delivery (the first try plus retries). Retries
1848    /// happen for transport failures and the transient status set
1849    /// (429/502/503/504), honoring a seconds-form `Retry-After` when the
1850    /// target sends one. Default 3; validation enforces
1851    /// 1..=[`limits::MAX_WEBHOOK_ATTEMPTS`].
1852    #[serde(
1853        default = "default_webhook_attempts",
1854        skip_serializing_if = "is_default_webhook_attempts"
1855    )]
1856    pub max_attempts: u32,
1857    /// First backoff between delivery attempts, doubling per retry up to
1858    /// `backoff_cap_ms` (a `Retry-After` answer replaces the computed
1859    /// value for that wait). Default 100.
1860    #[serde(
1861        default = "default_webhook_backoff_base_ms",
1862        skip_serializing_if = "is_default_webhook_backoff_base_ms"
1863    )]
1864    pub backoff_base_ms: u64,
1865    /// Upper bound on the computed backoff. Default 1000; must be >=
1866    /// `backoff_base_ms`.
1867    #[serde(
1868        default = "default_webhook_backoff_cap_ms",
1869        skip_serializing_if = "is_default_webhook_backoff_cap_ms"
1870    )]
1871    pub backoff_cap_ms: u64,
1872}
1873
1874/// Default `gateway.webhooks[].timeout_ms` (DW-044): one shared
1875/// delivery budget, generous for a remote alerting endpoint, small
1876/// enough that a hung target cannot pile up deliveries.
1877pub const DEFAULT_WEBHOOK_TIMEOUT_MS: u64 = 2_000;
1878/// Default `gateway.webhooks[].max_attempts` (DW-044).
1879pub const DEFAULT_WEBHOOK_ATTEMPTS: u32 = 3;
1880/// Default `gateway.webhooks[].backoff_base_ms` (DW-044).
1881pub const DEFAULT_WEBHOOK_BACKOFF_BASE_MS: u64 = 100;
1882/// Default `gateway.webhooks[].backoff_cap_ms` (DW-044).
1883pub const DEFAULT_WEBHOOK_BACKOFF_CAP_MS: u64 = 1_000;
1884
1885fn default_webhook_timeout_ms() -> u64 {
1886    DEFAULT_WEBHOOK_TIMEOUT_MS
1887}
1888fn default_webhook_attempts() -> u32 {
1889    DEFAULT_WEBHOOK_ATTEMPTS
1890}
1891fn default_webhook_backoff_base_ms() -> u64 {
1892    DEFAULT_WEBHOOK_BACKOFF_BASE_MS
1893}
1894fn default_webhook_backoff_cap_ms() -> u64 {
1895    DEFAULT_WEBHOOK_BACKOFF_CAP_MS
1896}
1897fn is_default_webhook_timeout_ms(v: &u64) -> bool {
1898    *v == DEFAULT_WEBHOOK_TIMEOUT_MS
1899}
1900fn is_default_webhook_attempts(v: &u32) -> bool {
1901    *v == DEFAULT_WEBHOOK_ATTEMPTS
1902}
1903fn is_default_webhook_backoff_base_ms(v: &u64) -> bool {
1904    *v == DEFAULT_WEBHOOK_BACKOFF_BASE_MS
1905}
1906fn is_default_webhook_backoff_cap_ms(v: &u64) -> bool {
1907    *v == DEFAULT_WEBHOOK_BACKOFF_CAP_MS
1908}
1909
1910/// Real-time access-record stream (DW-121,
1911/// `gateway.analytics_stream`).
1912///
1913/// Every completed request's access record is copied onto a bounded
1914/// channel at request completion (drop-and-count on full — the stream
1915/// can never slow the dataplane), and a background flusher turns the
1916/// queue into ordered batches delivered to the configured sink: one
1917/// delivery per flushed BATCH, not per record. The channel capacity is
1918/// fixed at boot; the sink set, flush cadence, and batch bound are read
1919/// live from the current generation, so a reload can retarget or
1920/// disable the stream without a restart.
1921#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1922#[serde(deny_unknown_fields)]
1923pub struct AnalyticsStreamConfig {
1924    /// Where flushed batches go. One sink in v1 (`type: webhook`); the
1925    /// set is closed so a second implementation (a Kafka producer is
1926    /// the documented slot) is an additive config change, not a silent
1927    /// behavior change.
1928    pub sink: AnalyticsStreamSink,
1929    /// Capacity of the bounded record channel (default 8192; validated
1930    /// to [64, 65536]). The queue's entire memory story: every queued
1931    /// record is an owned copy, and a full queue DROPS (and counts)
1932    /// rather than blocking the request path. Startup wiring: the
1933    /// capacity is fixed when the gateway boots (a live reload changes
1934    /// the sink and the cadence, not this).
1935    #[serde(default, skip_serializing_if = "Option::is_none")]
1936    pub buffer: Option<u64>,
1937    /// Maximum batch latency in milliseconds (default 1000; validated
1938    /// to [100, 60000]): a batch is flushed when it holds
1939    /// `batch_max` records, reaches the batch byte cap, or this much
1940    /// time has passed since its first record — whichever comes first.
1941    /// Read live per flush cycle.
1942    #[serde(default, skip_serializing_if = "Option::is_none")]
1943    pub flush_ms: Option<u64>,
1944    /// Maximum records per flushed batch (default 512; validated to
1945    /// [1, 4096]). With the batch byte cap, whichever comes first. One
1946    /// batch is one delivery, so this is also the largest single
1947    /// delivery's record count.
1948    #[serde(default, skip_serializing_if = "Option::is_none")]
1949    pub batch_max: Option<u64>,
1950}
1951
1952/// The access-record stream's sink (DW-121,
1953/// `gateway.analytics_stream.sink`). Closed set, internally tagged
1954/// (`type: webhook` or `type: kafka`): `webhook` ships today; `kafka`
1955/// (SCALE-08, #187) produces NDJSON batches to a Kafka topic via a
1956/// Kafka REST Proxy (HTTP-based, no native client dependency). The
1957/// variant payloads carry their own `deny_unknown_fields`, so a
1958/// misspelled knob inside a sink is still a rejected config.
1959#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1960#[serde(tag = "type", rename_all = "lowercase")]
1961pub enum AnalyticsStreamSink {
1962    /// POST each flushed batch as one NDJSON body (one JSON object per
1963    /// line, one line per record) to the configured URL, reusing the
1964    /// DW-044 webhook delivery engine's retry/budget shape — one
1965    /// delivery (with its retries) per batch.
1966    Webhook(AnalyticsStreamWebhook),
1967    /// Produce each flushed batch as NDJSON messages to a Kafka topic
1968    /// via a Kafka REST Proxy (SCALE-08, #187). The REST Proxy
1969    /// endpoint receives the batch as an HTTP POST with the
1970    /// `Content-Type: application/vnd.kafka.binary.v2+json` media
1971    /// type; each NDJSON line becomes a Kafka message value (base64
1972    /// encoded per the REST Proxy spec). TLS is via `https://`; SASL
1973    /// auth is via headers. This avoids the native `rdkafka`
1974    /// dependency while providing a production-usable Kafka
1975    /// integration.
1976    Kafka(AnalyticsStreamKafka),
1977}
1978
1979/// The webhook batch sink (DW-121,
1980/// `gateway.analytics_stream.sink.webhook`). Same URL/header/retry
1981/// grammar as `gateway.webhooks[]` (DW-044) — the delivery engine is
1982/// shared — minus the `events` filter (a record stream has no kinds to
1983/// filter: every record goes to the sink).
1984#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1985#[serde(deny_unknown_fields)]
1986pub struct AnalyticsStreamWebhook {
1987    /// Absolute `http://` or `https://` URL the batch is POSTed to.
1988    /// `https://` verifies against the public webpki root set (no
1989    /// `trusted_ca_file` override — v1 scope, same as alert webhooks).
1990    pub url: String,
1991    /// Headers sent on every batch delivery (e.g. the collector's auth
1992    /// token). Values may be inline or `${ENV_NAME}` / `${file:/path}`
1993    /// secret references (DW-045), resolved at config-compile time;
1994    /// inline values are redacted in every config echo. Use a
1995    /// reference for bearer tokens and signing secrets.
1996    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1997    pub headers: std::collections::BTreeMap<String, String>,
1998    /// TOTAL budget for one batch delivery (all retry attempts share
1999    /// it), in milliseconds. Default 5000: a BATCH is heavier than a
2000    /// single alert envelope, and the flusher delivers batches strictly
2001    /// in order, so this bounds how long one unlucky batch can hold
2002    /// back the queue. Validation enforces
2003    /// 1..=[`limits::MAX_WEBHOOK_TIMEOUT_MS`] (the shared engine's
2004    /// bound).
2005    #[serde(
2006        default = "default_stream_webhook_timeout_ms",
2007        skip_serializing_if = "is_default_stream_webhook_timeout_ms"
2008    )]
2009    pub timeout_ms: u64,
2010    /// Total attempts per batch delivery (first try plus retries);
2011    /// retries cover transport failures and the transient status set
2012    /// (429/502/503/504), honoring a seconds-form `Retry-After`.
2013    /// Default 3; validation enforces
2014    /// 1..=[`limits::MAX_WEBHOOK_ATTEMPTS`].
2015    #[serde(
2016        default = "default_stream_webhook_attempts",
2017        skip_serializing_if = "is_default_stream_webhook_attempts"
2018    )]
2019    pub max_attempts: u32,
2020    /// First backoff between batch attempts, doubling per retry up to
2021    /// `backoff_cap_ms` (a `Retry-After` answer replaces the computed
2022    /// value for that wait). Default 250 — a batch retry is heavier
2023    /// than an alert retry, so it starts slower.
2024    #[serde(
2025        default = "default_stream_webhook_backoff_base_ms",
2026        skip_serializing_if = "is_default_stream_webhook_backoff_base_ms"
2027    )]
2028    pub backoff_base_ms: u64,
2029    /// Upper bound on the computed backoff. Default 4000; must be >=
2030    /// `backoff_base_ms`.
2031    #[serde(
2032        default = "default_stream_webhook_backoff_cap_ms",
2033        skip_serializing_if = "is_default_stream_webhook_backoff_cap_ms"
2034    )]
2035    pub backoff_cap_ms: u64,
2036}
2037
2038/// The Kafka batch sink (SCALE-08, #187,
2039/// `gateway.analytics_stream.sink.kafka`). Produces each flushed
2040/// batch as NDJSON messages to a Kafka topic via a Kafka REST Proxy
2041/// (HTTP-based, no native client dependency). The REST Proxy
2042/// endpoint receives the batch as an HTTP POST with the
2043/// `Content-Type: application/vnd.kafka.binary.v2+json` media type;
2044/// each NDJSON line becomes a Kafka message value (base64 encoded
2045/// per the REST Proxy spec). TLS is via `https://`; SASL auth is via
2046/// headers (e.g. `Authorization: Basic ...`).
2047#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2048#[serde(deny_unknown_fields)]
2049pub struct AnalyticsStreamKafka {
2050    /// Absolute `http://` or `https://` URL of the Kafka REST Proxy
2051    /// endpoint for producing to the topic, e.g.
2052    /// `https://kafka-rest:8082/topics/<topic>`. `https://` verifies
2053    /// against the public webpki root set.
2054    pub rest_proxy_url: String,
2055    /// Headers sent on every batch delivery (e.g. the REST Proxy's
2056    /// auth token, `Authorization: Basic ...` for SASL). Values may
2057    /// be inline or `${ENV_NAME}` / `${file:/path}` secret references
2058    /// (DW-045), resolved at config-compile time; inline values are
2059    /// redacted in every config echo.
2060    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
2061    pub headers: std::collections::BTreeMap<String, String>,
2062    /// TOTAL budget for one batch delivery (all retry attempts share
2063    /// it), in milliseconds. Default 5000. Validation enforces
2064    /// 1..=[`limits::MAX_WEBHOOK_TIMEOUT_MS`] (the shared engine's
2065    /// bound).
2066    #[serde(
2067        default = "default_stream_webhook_timeout_ms",
2068        skip_serializing_if = "is_default_stream_webhook_timeout_ms"
2069    )]
2070    pub timeout_ms: u64,
2071    /// Max delivery attempts per batch. Default 3.
2072    #[serde(
2073        default = "default_stream_webhook_attempts",
2074        skip_serializing_if = "is_default_stream_webhook_attempts"
2075    )]
2076    pub max_attempts: u32,
2077    /// First backoff between batch attempts, doubling per retry up to
2078    /// `backoff_cap_ms`. Default 250.
2079    #[serde(
2080        default = "default_stream_webhook_backoff_base_ms",
2081        skip_serializing_if = "is_default_stream_webhook_backoff_base_ms"
2082    )]
2083    pub backoff_base_ms: u64,
2084    /// Upper bound on the computed backoff. Default 4000; must be >=
2085    /// `backoff_base_ms`.
2086    #[serde(
2087        default = "default_stream_webhook_backoff_cap_ms",
2088        skip_serializing_if = "is_default_stream_webhook_backoff_cap_ms"
2089    )]
2090    pub backoff_cap_ms: u64,
2091}
2092
2093/// Default `analytics_stream.buffer` (DW-121): the bounded record
2094/// channel's capacity when the block is absent at boot (the stream is
2095/// always constructed so a live reload can arm it). Lives in `config`
2096/// with the retention defaults — the lowest consuming domain — so
2097/// validation docs, the stream, and the binary read one definition.
2098pub const DEFAULT_STREAM_BUFFER: u64 = 8_192;
2099/// Default `analytics_stream.flush_ms` (DW-121): maximum batch latency.
2100pub const DEFAULT_STREAM_FLUSH_MS: u64 = 1_000;
2101/// Default `analytics_stream.batch_max` (DW-121): records per flushed
2102/// batch.
2103pub const DEFAULT_STREAM_BATCH_MAX: u64 = 512;
2104/// Default `analytics_stream.sink.webhook.timeout_ms` (DW-121): a
2105/// batch carries up to `batch_max` records, so its budget starts
2106/// heavier than the alert webhook's 2000 but stays far under the
2107/// one-minute ceiling.
2108pub const DEFAULT_STREAM_WEBHOOK_TIMEOUT_MS: u64 = 5_000;
2109/// Default `analytics_stream.sink.webhook.max_attempts` (DW-121).
2110pub const DEFAULT_STREAM_WEBHOOK_ATTEMPTS: u32 = 3;
2111/// Default `analytics_stream.sink.webhook.backoff_base_ms` (DW-121).
2112pub const DEFAULT_STREAM_WEBHOOK_BACKOFF_BASE_MS: u64 = 250;
2113/// Default `analytics_stream.sink.webhook.backoff_cap_ms` (DW-121).
2114pub const DEFAULT_STREAM_WEBHOOK_BACKOFF_CAP_MS: u64 = 4_000;
2115
2116fn default_stream_webhook_timeout_ms() -> u64 {
2117    DEFAULT_STREAM_WEBHOOK_TIMEOUT_MS
2118}
2119fn default_stream_webhook_attempts() -> u32 {
2120    DEFAULT_STREAM_WEBHOOK_ATTEMPTS
2121}
2122fn default_stream_webhook_backoff_base_ms() -> u64 {
2123    DEFAULT_STREAM_WEBHOOK_BACKOFF_BASE_MS
2124}
2125fn default_stream_webhook_backoff_cap_ms() -> u64 {
2126    DEFAULT_STREAM_WEBHOOK_BACKOFF_CAP_MS
2127}
2128fn is_default_stream_webhook_timeout_ms(v: &u64) -> bool {
2129    *v == DEFAULT_STREAM_WEBHOOK_TIMEOUT_MS
2130}
2131fn is_default_stream_webhook_attempts(v: &u32) -> bool {
2132    *v == DEFAULT_STREAM_WEBHOOK_ATTEMPTS
2133}
2134fn is_default_stream_webhook_backoff_base_ms(v: &u64) -> bool {
2135    *v == DEFAULT_STREAM_WEBHOOK_BACKOFF_BASE_MS
2136}
2137fn is_default_stream_webhook_backoff_cap_ms(v: &u64) -> bool {
2138    *v == DEFAULT_STREAM_WEBHOOK_BACKOFF_CAP_MS
2139}
2140
2141/// Gateway-wide HMAC request-signing policy (DW-036). The credential
2142/// itself (key id + secret) lives on a consumer
2143/// (`credentials: [{type: hmac, ...}]`); this block carries the
2144/// VERIFICATION policy that applies to every signed request.
2145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2146#[serde(deny_unknown_fields)]
2147pub struct HmacAuth {
2148    /// Maximum accepted absolute difference, in seconds, between the
2149    /// request's `X-Dwara-Timestamp` and the gateway clock. A timestamp
2150    /// outside the window is rejected 401 BEFORE any HMAC computation
2151    /// (an expired window is not a MAC problem). The window is also the
2152    /// replay window: nonces are remembered for twice this duration.
2153    /// Default 300 (±5 minutes, the §4.6 recommendation); validation
2154    /// enforces 1..=3600 (a sub-second window rejects every request
2155    /// with any clock drift; an unbounded one pins nonce-cache memory
2156    /// forever).
2157    #[serde(default = "default_hmac_clock_skew_secs")]
2158    pub max_clock_skew_secs: u64,
2159}
2160
2161/// Default `hmac_auth.max_clock_skew_secs` (±5 minutes, DW-036 §4.6).
2162pub const DEFAULT_HMAC_CLOCK_SKEW_SECS: u64 = 300;
2163
2164fn default_hmac_clock_skew_secs() -> u64 {
2165    DEFAULT_HMAC_CLOCK_SKEW_SECS
2166}
2167
2168impl Gateway {
2169    /// A display-safe copy of this config (DW-045): every INLINE
2170    /// `api_key` value is replaced by the
2171    /// [`credentials::redact_inline_secret`] placeholder
2172    /// (`${redacted:sha256:<prefix>}`); `${...}` references pass through
2173    /// unchanged (an env-var name or file path is not secret bytes —
2174    /// the config file itself carries it). This is the TYPED redaction
2175    /// behind every surface that echoes configuration (admin
2176    /// `GET /config`); it is deliberately a transform, not a custom
2177    /// serializer, so it cannot drift from the schema — a future
2178    /// secret-bearing field fails visibly (its value reaches the dump
2179    /// unredacted) instead of silently escaping an allowlist regex.
2180    pub fn redacted(&self) -> Gateway {
2181        let mut redacted = self.clone();
2182        for consumer in &mut redacted.consumers {
2183            for credential in &mut consumer.credentials {
2184                match credential {
2185                    Credential::ApiKey { key } => {
2186                        *key = credentials::redact_inline_secret(key);
2187                    }
2188                    // DW-036: the HMAC secret is inline secret material
2189                    // exactly like an api key — same placeholder, same
2190                    // unresolvable-by-design round-trip rejection.
2191                    Credential::Hmac { secret, .. } => {
2192                        *secret = credentials::redact_inline_secret(secret);
2193                    }
2194                    Credential::Jwt { .. } | Credential::Mtls { .. } => {}
2195                }
2196            }
2197        }
2198        // DW-044: webhook header values are secret-bearing config fields
2199        // exactly like api keys (bearer tokens, signing secrets) — the
2200        // same placeholder, the same reference passthrough, the same
2201        // unresolvable-by-design round-trip rejection.
2202        for webhook in &mut redacted.webhooks {
2203            for value in webhook.headers.values_mut() {
2204                *value = credentials::redact_inline_secret(value);
2205            }
2206        }
2207        // DW-075: AI provider auth values are inline secret material
2208        // exactly like api keys — the same placeholder, the same
2209        // reference passthrough.
2210        if let Some(ai) = &mut redacted.ai {
2211            for provider in &mut ai.providers {
2212                if let Some(auth) = &mut provider.auth {
2213                    auth.value = credentials::redact_inline_secret(&auth.value);
2214                }
2215                // DW-080: redact credential pool entry values too.
2216                if let Some(pool) = &mut provider.credential_pool {
2217                    for cred in &mut pool.credentials {
2218                        cred.value = credentials::redact_inline_secret(&cred.value);
2219                    }
2220                }
2221            }
2222        }
2223        redacted
2224    }
2225}
2226
2227/// Admin listener configuration (DW-022).
2228#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2229#[serde(deny_unknown_fields)]
2230pub struct AdminConfig {
2231    /// Bind address of the admin listener. Default `127.0.0.1:2019`
2232    /// (loopback-only; frozen decision 6) — override only to place the
2233    /// admin API on a dedicated management interface, and rely on mTLS
2234    /// for access control: there is no token layer in v1 (the client
2235    /// certificate IS the auth).
2236    #[serde(default = "default_admin_bind")]
2237    pub bind: String,
2238    /// mTLS material for the admin listener; all three files are
2239    /// required.
2240    pub tls: AdminTlsConfig,
2241    /// SEC-01: RBAC bindings for mTLS-authenticated admin clients.
2242    /// Maps client certificate fingerprints (SHA-256 of the cert
2243    /// DER) to roles. When present, a valid client certificate with
2244    /// NO binding is denied (fail-closed: no implicit admin). When
2245    /// absent (the default), any CA-valid client certificate is
2246    /// treated as full admin (the v1 behavior — backward compatible).
2247    #[serde(default, skip_serializing_if = "Option::is_none")]
2248    pub rbac: Option<AdminRbacConfig>,
2249    /// SEC-01: API token authentication. When present, the admin API
2250    /// accepts an `Authorization: Bearer <token>` header in addition
2251    /// to mTLS. Tokens are hashed (SHA-256) at rest; the config lists
2252    /// token hashes, not plaintext tokens. When absent (the default),
2253    /// only mTLS is accepted (the v1 behavior).
2254    #[serde(default, skip_serializing_if = "Option::is_none")]
2255    pub api_tokens: Option<AdminApiTokensConfig>,
2256    /// SEC-01: Admin audit log. When present, all mutating admin
2257    /// actions (PATCH /config, purge) are recorded in an append-only
2258    /// audit table in the state store. Each entry includes the actor
2259    /// (cert fingerprint or token hash), the action, the before/after
2260    /// config hash, and a timestamp. When absent (the default), no
2261    /// audit log is kept.
2262    #[serde(default, skip_serializing_if = "Option::is_none")]
2263    pub audit: Option<AdminAuditConfig>,
2264}
2265
2266/// SEC-01: Admin RBAC configuration. Maps client certificate
2267/// fingerprints to roles. A valid certificate with no binding is
2268/// denied (fail-closed).
2269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2270#[serde(deny_unknown_fields)]
2271pub struct AdminRbacConfig {
2272    /// Certificate-fingerprint-to-role bindings. Each entry maps one
2273    /// SHA-256 fingerprint (lowercase hex, 64 chars) to a role.
2274    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2275    pub bindings: Vec<AdminRbacBinding>,
2276}
2277
2278/// One certificate-fingerprint-to-role binding.
2279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2280#[serde(deny_unknown_fields)]
2281pub struct AdminRbacBinding {
2282    /// SHA-256 fingerprint of the client certificate (lowercase hex,
2283    /// 64 chars). Computed over the full DER encoding of the cert.
2284    pub cert_fingerprint: String,
2285    /// The role assigned to this certificate: `admin` (full access)
2286    /// or `readonly` (GET only; PATCH/purge denied).
2287    pub role: AdminRole,
2288}
2289
2290/// Admin roles.
2291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
2292#[serde(rename_all = "lowercase")]
2293pub enum AdminRole {
2294    /// Full admin access: all admin API endpoints.
2295    #[default]
2296    Admin,
2297    /// Read-only access: GET endpoints only. PATCH /config and purge
2298    /// are denied (403).
2299    Readonly,
2300}
2301
2302/// SEC-01: API token authentication for the admin API. Tokens are
2303/// hashed (SHA-256) at rest; the config lists token hashes, not
2304/// plaintext tokens. A client presents a token via
2305/// `Authorization: Bearer <token>`.
2306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2307#[serde(deny_unknown_fields)]
2308pub struct AdminApiTokensConfig {
2309    /// The hashed API tokens (SHA-256 of the plaintext token, as
2310    /// lowercase hex, 64 chars). A request with a matching bearer
2311    /// token is authenticated as the token's role.
2312    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2313    pub tokens: Vec<AdminApiToken>,
2314}
2315
2316/// One hashed API token with a role.
2317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2318#[serde(deny_unknown_fields)]
2319pub struct AdminApiToken {
2320    /// SHA-256 hash of the plaintext token (lowercase hex, 64 chars).
2321    pub token_hash: String,
2322    /// The role assigned to this token.
2323    pub role: AdminRole,
2324}
2325
2326/// SEC-01: Admin audit log configuration.
2327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2328#[serde(deny_unknown_fields)]
2329pub struct AdminAuditConfig {
2330    /// Whether the audit log is enabled. Default true (when the block
2331    /// is present, the audit log is on).
2332    #[serde(
2333        default = "default_audit_enabled",
2334        skip_serializing_if = "is_default_audit_enabled"
2335    )]
2336    pub enabled: bool,
2337}
2338
2339fn default_audit_enabled() -> bool {
2340    true
2341}
2342
2343fn is_default_audit_enabled(b: &bool) -> bool {
2344    *b
2345}
2346
2347/// mTLS material for the admin listener (DW-022). Unlike dataplane
2348/// listeners there is no mode: the admin listener always terminates TLS
2349/// and always requires a client certificate chaining to `client_ca_file`.
2350#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2351#[serde(deny_unknown_fields)]
2352pub struct AdminTlsConfig {
2353    /// Path to the PEM certificate chain the admin server presents.
2354    pub cert_file: String,
2355    /// Path to the PEM private key for `cert_file`.
2356    pub key_file: String,
2357    /// Path to the PEM CA bundle client certificates must chain to.
2358    /// Required (mTLS-only): validation rejects an admin block without
2359    /// it rather than silently serving no-auth TLS.
2360    pub client_ca_file: String,
2361}
2362
2363fn default_admin_bind() -> String {
2364    "127.0.0.1:2019".to_string()
2365}
2366
2367/// One JWT verification provider (DW-019).
2368#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2369#[serde(deny_unknown_fields)]
2370pub struct JwtProvider {
2371    pub name: String,
2372    /// JWKS endpoint (`http://` or `https://`). Keys are fetched lazily on
2373    /// the first Bearer request, refreshed after `refresh_secs`, and
2374    /// re-fetched on an unknown `kid` (key rotation mid-flight).
2375    pub jwks_url: String,
2376    /// Path to a PEM file of CA certificates the JWKS fetcher trusts for
2377    /// its `https://` connection INSTEAD of the default public (webpki)
2378    /// root set (#121): for JWKS endpoints served over TLS by a private
2379    /// CA. The file may carry several certificates (a typical CA bundle).
2380    /// Only meaningful with an `https://` jwks_url — no TLS is negotiated
2381    /// toward an `http://` endpoint, so validation rejects that
2382    /// combination. The file must exist and be readable at config compile
2383    /// time; validation names this field otherwise.
2384    #[serde(default, skip_serializing_if = "Option::is_none")]
2385    pub trusted_ca_file: Option<String>,
2386    /// Required token issuer (`iss` claim). Absent: any issuer accepted.
2387    #[serde(skip_serializing_if = "Option::is_none")]
2388    pub issuer: Option<String>,
2389    /// Required audience (`aud` claim). Audience is validated ONLY when
2390    /// this is configured (#124, maintainer decision): a provider
2391    /// without an `audience` ACCEPTS tokens that carry any (or no) `aud`
2392    /// claim — `aud` is not interpreted unless the provider names one.
2393    /// All other validations (exp, nbf, iss when configured, the
2394    /// algorithm allowlist) are identical either way.
2395    #[serde(skip_serializing_if = "Option::is_none")]
2396    pub audience: Option<String>,
2397    /// Allowed signature algorithms (default `["RS256", "ES256"]`).
2398    /// `none` and HMAC (`HS*`) algorithms are never allowed implicitly —
2399    /// they must not appear in this list; validation rejects them
2400    /// (asymmetric verification only: the gateway holds no shared
2401    /// secrets with issuers).
2402    #[serde(default = "default_jwt_algorithms")]
2403    pub algorithms: Vec<String>,
2404    /// JWKS cache staleness bound in seconds (default 300): a cached key
2405    /// set older than this is refreshed before use.
2406    #[serde(
2407        default = "default_jwt_refresh_secs",
2408        skip_serializing_if = "is_default_jwt_refresh_secs"
2409    )]
2410    pub refresh_secs: u64,
2411    /// exp/nbf clock-skew leeway in seconds (default 30).
2412    #[serde(
2413        default = "default_jwt_leeway_secs",
2414        skip_serializing_if = "is_default_jwt_leeway_secs"
2415    )]
2416    pub leeway_secs: u64,
2417    /// Consumer this provider's tokens authenticate. Absent: the token's
2418    /// `iss` claim is matched against consumers' `jwt` credentials.
2419    #[serde(skip_serializing_if = "Option::is_none")]
2420    pub consumer: Option<String>,
2421    /// How long a key REMOVED from a fresh JWKS fetch keeps verifying
2422    /// (DW-046), in seconds (default 86400 = 24 h; 0 disables). The
2423    /// dual-validity window for JWKS rotation: issuers drop the old
2424    /// key from their JWKS the moment the new one appears, while
2425    /// previously-issued tokens still carry the old `kid` — during
2426    /// this grace the gateway honors the immediately-previous key set
2427    /// for kids the fresh set no longer carries. Only ONE previous set
2428    /// is retained (rotation is one step at a time). Validation caps
2429    /// the value at 7 days (604800).
2430    #[serde(default, skip_serializing_if = "Option::is_none")]
2431    pub retired_key_grace_secs: Option<u64>,
2432    /// How long to serve stale (cached) keys after a JWKS fetch failure
2433    /// (SEC-12, #215), in seconds (default: same as `refresh_secs`,
2434    /// capped at 300). When a JWKS fetch fails, the cached key set
2435    /// keeps serving for this duration before a new fetch is attempted,
2436    /// so a transient JWKS provider outage does not cascade into auth
2437    /// failures. 0 disables stale-on-error (a fetch failure surfaces
2438    /// immediately when the cache is empty; when the cache is non-empty
2439    /// the existing degraded-mode behavior applies).
2440    #[serde(default, skip_serializing_if = "Option::is_none")]
2441    pub stale_on_error_secs: Option<u64>,
2442}
2443
2444impl JwtProvider {
2445    /// `retired_key_grace_secs` resolved to its effective value
2446    /// (default 86 400; the DW-046 dual-validity window).
2447    pub fn retired_key_grace_secs(&self) -> u64 {
2448        self.retired_key_grace_secs.unwrap_or(86_400)
2449    }
2450    /// `stale_on_error_secs` resolved to its effective value (SEC-12,
2451    /// #215): the default is `min(refresh_secs, 300)` — a failed fetch
2452    /// backs off for at most one refresh interval (capped at 5 min) so
2453    /// a down JWKS endpoint does not chain every Bearer request through
2454    /// a doomed fetch attempt.
2455    pub fn stale_on_error_secs(&self) -> u64 {
2456        self.stale_on_error_secs
2457            .unwrap_or_else(|| self.refresh_secs.min(300))
2458    }
2459}
2460
2461fn default_jwt_algorithms() -> Vec<String> {
2462    vec!["RS256".to_string(), "ES256".to_string()]
2463}
2464
2465fn default_jwt_refresh_secs() -> u64 {
2466    300
2467}
2468
2469fn default_jwt_leeway_secs() -> u64 {
2470    30
2471}
2472
2473fn is_default_jwt_refresh_secs(v: &u64) -> bool {
2474    *v == 300
2475}
2476
2477fn is_default_jwt_leeway_secs(v: &u64) -> bool {
2478    *v == 30
2479}
2480
2481/// One OIDC provider (DW-034): an OpenID Connect issuer whose Bearer
2482/// tokens are validated by token introspection (RFC 7662) rather than
2483/// local JWT signature verification. The gateway fetches the issuer's
2484/// discovery document (`{issuer}/.well-known/openid-configuration`) to
2485/// learn the introspection, revocation, authorization, and token
2486/// endpoints, then introspects presented Bearer tokens (caching the
2487/// result keyed by token hash with a configurable TTL). The provider
2488/// also backs the authorization-code + PKCE relying-party flow, token
2489/// exchange (RFC 8693), and token revocation (RFC 7009).
2490#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2491#[serde(deny_unknown_fields)]
2492pub struct OidcProvider {
2493    pub name: String,
2494    /// The OIDC issuer URL (e.g. `https://idp.example.com`). The
2495    /// discovery document is fetched from
2496    /// `{issuer}/.well-known/openid-configuration`. Must be an absolute
2497    /// `http(s)://` URL; validation rejects anything else. The
2498    /// introspection result's `iss` (when present) is checked against
2499    /// this issuer for defense against token confusion.
2500    pub issuer: String,
2501    /// The gateway's client identifier at the IdP (RFC 6749 section
2502    /// 2.2). Sent as the username of the HTTP Basic auth header to the
2503    /// introspection and token endpoints, and as `client_id` in the
2504    /// authorization-code and token-exchange request bodies.
2505    pub client_id: String,
2506    /// The gateway's client secret (RFC 6749 section 2.3.1). Either the
2507    /// value INLINE (redacted in every config echo, DW-045) or a
2508    /// `${...}` secret reference (env or file) resolved at config-compile
2509    /// time and re-read on every reload. Sent as the password of the
2510    /// HTTP Basic auth header. SECRET: never logged, never in Debug.
2511    pub client_secret: String,
2512    /// Path to a PEM file of CA certificates the OIDC HTTP client
2513    /// trusts for its `https://` connections INSTEAD of the default
2514    /// public (webpki) root set: for an IdP served over TLS by a private
2515    /// CA. The file may carry several certificates (a typical CA bundle).
2516    /// Only meaningful with an `https://` issuer; validation rejects the
2517    /// combination with an `http://` issuer. The file must exist and be
2518    /// readable at config compile time.
2519    #[serde(default, skip_serializing_if = "Option::is_none")]
2520    pub trusted_ca_file: Option<String>,
2521    /// Scopes to request (default `["openid"]`). Sent in the
2522    /// authorization-code flow's authorization request and the
2523    /// token-exchange request body (space-joined).
2524    #[serde(
2525        default = "default_oidc_scopes",
2526        skip_serializing_if = "is_default_oidc_scopes"
2527    )]
2528    pub scopes: Vec<String>,
2529    /// Cache TTL for introspection results, in seconds (default 60). A
2530    /// cached `active: true` result short-circuits the IdP call for this
2531    /// duration; a cached `active: false` result is NOT cached (it is
2532    /// re-checked on the next request so a revoked token is noticed
2533    /// promptly). Must be > 0; validation rejects 0.
2534    #[serde(
2535        default = "default_oidc_introspection_cache_ttl_s",
2536        skip_serializing_if = "is_default_oidc_introspection_cache_ttl_s"
2537    )]
2538    pub introspection_cache_ttl_s: u64,
2539    /// Override the discovery-discovered introspection endpoint. Absent
2540    /// (the default): the endpoint is taken from the discovery document.
2541    /// Useful when the IdP's discovery document is incorrect or the
2542    /// endpoint is behind a different host.
2543    #[serde(default, skip_serializing_if = "Option::is_none")]
2544    pub introspection_endpoint: Option<String>,
2545    /// Override the discovery-discovered revocation endpoint. Absent
2546    /// (the default): the endpoint is taken from the discovery document.
2547    #[serde(default, skip_serializing_if = "Option::is_none")]
2548    pub revocation_endpoint: Option<String>,
2549    /// Consumer this provider's tokens authenticate (like
2550    /// [`JwtProvider::consumer`]). Absent: the introspection result's
2551    /// `sub` claim is used as the consumer name. When set, every
2552    /// successfully introspected token resolves to this consumer
2553    /// regardless of the `sub` claim.
2554    #[serde(default, skip_serializing_if = "Option::is_none")]
2555    pub consumer: Option<String>,
2556    /// Fail-open posture when the IdP is unreachable (network error,
2557    /// non-2xx, malformed response). Default false (fail closed = 401):
2558    /// the gateway refuses to authenticate a token it cannot introspect.
2559    /// When true, the gateway treats an IdP failure as anonymous
2560    /// (pass-through) rather than rejecting the request. Fail-open
2561    /// trades security for availability; use with care.
2562    #[serde(default, skip_serializing_if = "is_false")]
2563    pub fail_open: bool,
2564}
2565
2566fn default_oidc_scopes() -> Vec<String> {
2567    vec!["openid".to_string()]
2568}
2569
2570fn is_default_oidc_scopes(v: &[String]) -> bool {
2571    v.len() == 1 && v[0] == "openid"
2572}
2573
2574fn default_oidc_introspection_cache_ttl_s() -> u64 {
2575    60
2576}
2577
2578fn is_default_oidc_introspection_cache_ttl_s(v: &u64) -> bool {
2579    *v == 60
2580}
2581
2582/// Entry point: bind address + port + TLS termination (or passthrough) config.
2583#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2584#[serde(deny_unknown_fields)]
2585pub struct Listener {
2586    pub name: String,
2587    /// Bind address, e.g. `0.0.0.0` or `127.0.0.1`.
2588    pub address: String,
2589    pub port: u16,
2590    #[serde(default = "default_listener_protocol")]
2591    pub protocol: ListenerProtocol,
2592    #[serde(default, skip_serializing_if = "Option::is_none")]
2593    pub tls: Option<ListenerTls>,
2594    /// Accept a PROXY protocol v1 or v2 header as the FIRST bytes of
2595    /// every connection on this listener (DW-030), replacing the peer
2596    /// address the gateway uses everywhere the socket peer is used
2597    /// today: the authz IP ACL's effective-client-IP base, rate-limit
2598    /// keying, `X-Forwarded-For` / `X-Real-IP` on the forwarded
2599    /// request. OPT-IN (default false): a plaintext listener that does
2600    /// not expect a PROXY line must never interpret the first request
2601    /// bytes as one, and a client that spoofs a PROXY line on a listener
2602    /// without this flag is simply serving garbage to the HTTP parser.
2603    /// The header is read BEFORE the TLS handshake (the L4 LB in front
2604    /// wraps the whole stream, TLS included) and a MALFORMED header
2605    /// fails closed: the connection is answered with a `400` error
2606    /// envelope and closed, never handed to HTTP parsing. A v2 `LOCAL`
2607    /// command or a v1 `UNKNOWN` line keeps the real peer address (the
2608    /// spec's own fallback for connections the LB did not originate).
2609    /// Validation rejects the combination with `tls.mode passthrough`:
2610    /// a passthrough listener splices raw bytes and never runs the
2611    /// HTTP pipeline that consumes the client address. Part of the
2612    /// restart-only bind set (like address/port) — toggling it takes a
2613    /// restart, not a reload.
2614    #[serde(default, skip_serializing_if = "is_false")]
2615    pub proxy_protocol: bool,
2616    /// Policies attached to this listener (#123): names from the
2617    /// gateway's `policies` list, applying to every request the listener
2618    /// accepts (the second-least specific link of the frozen chain
2619    /// consumer > route > service > listener > global; all applicable
2620    /// levels' rules AND together — see the rate-limiter module docs).
2621    /// Listener policies also apply to UNROUTED traffic accepted by this
2622    /// listener: a request whose path matches no route is rate-limited by
2623    /// them before the 404 is answered (reserved paths stay exempt).
2624    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2625    pub policies: Vec<String>,
2626    /// Listener-level authorization rules (#123): applies to every
2627    /// request this listener accepts that resolved a route (authorization
2628    /// runs after route resolution per the documented request-path
2629    /// order). Link of the frozen chain consumer > route > service >
2630    /// listener > global; see [`Authz`] for the rule semantics and the
2631    /// `authz` module for the merge.
2632    #[serde(default, skip_serializing_if = "Option::is_none")]
2633    pub authorization: Option<Authz>,
2634    /// Alt-Svc header value advertised on H1/H2 responses for this
2635    /// listener (DW-088). When set, an `Alt-Svc` response header is
2636    /// added telling clients that HTTP/3 is available. The value is
2637    /// the alt-svc payload (e.g. `h3=":8443"; ma=86400`). Only
2638    /// meaningful on `http` and `https` listeners (an H3 listener
2639    /// advertising itself is a no-op and rejected by validation).
2640    /// The header is added after all other response processing (it
2641    /// is a transport-level advertisement, not a route-level
2642    /// transform).
2643    #[serde(default, skip_serializing_if = "Option::is_none")]
2644    pub alt_svc: Option<String>,
2645    /// L4 proxying configuration (DW-103). Only meaningful on
2646    /// `protocol: tcp` and `protocol: udp` listeners; rejected by
2647    /// validation on other protocols. The `upstream` field names the
2648    /// upstream that receives the spliced stream (or, when
2649    /// `sni_routing` is true, the upstream selected by the TLS
2650    /// ClientHello SNI via the upstream's `tls.sni_routes` -- reusing
2651    /// the SNI extraction from DW-008 TLS passthrough). Compiled into
2652    /// every build (no cargo feature).
2653    #[serde(default, skip_serializing_if = "Option::is_none")]
2654    pub l4: Option<L4Config>,
2655    /// HTTP/2 flow-control tuning for this listener (PERF-11, #244).
2656    /// When set, the h2 server builder uses these values instead of
2657    /// the process-wide defaults from `DWARA_H2_*` env vars. Only
2658    /// meaningful on `http` and `https` listeners (h2 is negotiated
2659    /// via ALPN or prior knowledge); ignored on `h3` (HTTP/3 has its
2660    /// own transport-level flow control) and `tcp`/`udp`.
2661    #[serde(default, skip_serializing_if = "Option::is_none")]
2662    pub http2: Option<Http2ListenerConfig>,
2663}
2664
2665/// HTTP/2 flow-control tuning for a listener (PERF-11, #244,
2666/// `listeners[].http2`). Every field is optional; an omitted field
2667/// keeps the process-wide default from `DWARA_H2_*` env vars. This
2668/// allows per-listener tuning for streaming (SSE) workloads where
2669/// larger windows reduce flow-control stalls on long-lived streams.
2670#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2671#[serde(deny_unknown_fields)]
2672pub struct Http2ListenerConfig {
2673    /// Initial stream-level window size in bytes (hyper
2674    /// `initial_stream_window_size`). Absent: process default
2675    /// (1 MiB via `DWARA_H2_STREAM_WINDOW_KIB`). Larger values
2676    /// reduce stalls on streaming responses but increase memory
2677    /// per stream. Bounds: at most 16 MiB.
2678    #[serde(default, skip_serializing_if = "Option::is_none")]
2679    pub initial_stream_window_size: Option<u32>,
2680    /// Initial connection-level window size in bytes (hyper
2681    /// `initial_connection_window_size`). Absent: process default
2682    /// (4 MiB via `DWARA_H2_CONNECTION_WINDOW_KIB`). Bounds: at
2683    /// most 64 MiB.
2684    #[serde(default, skip_serializing_if = "Option::is_none")]
2685    pub initial_connection_window_size: Option<u32>,
2686    /// Enable adaptive flow-control window (hyper
2687    /// `adaptive_window`). Absent: false. When true, the
2688    /// connection window grows and shrinks based on throughput,
2689    /// which helps streaming workloads on high-BDP paths.
2690    #[serde(default, skip_serializing_if = "is_false")]
2691    pub adaptive_window: bool,
2692    /// Maximum number of concurrent streams per connection (hyper
2693    /// `max_concurrent_streams`). Absent: process default (128 via
2694    /// `DWARA_H2_MAX_CONCURRENT_STREAMS`). Bounds: at most 1_000_000.
2695    #[serde(default, skip_serializing_if = "Option::is_none")]
2696    pub max_concurrent_streams: Option<u32>,
2697}
2698
2699fn default_listener_protocol() -> ListenerProtocol {
2700    ListenerProtocol::Http
2701}
2702
2703#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2704#[serde(rename_all = "lowercase")]
2705pub enum ListenerProtocol {
2706    Http,
2707    Https,
2708    /// HTTP/3 (QUIC) ingress (DW-088). A UDP socket is bound on the
2709    /// listener's address:port; the QUIC handshake uses the `tls`
2710    /// block's certificates (terminate mode only — passthrough is
2711    /// rejected). Compiled into every build (no cargo feature; QUIC
2712    /// via the unconditional `quinn`/`h3` deps). The `alt_svc` field on
2713    /// sibling H1/H2 listeners advertises this listener's H3 port to
2714    /// clients for protocol upgrade discovery.
2715    H3,
2716    /// L4 TCP proxying (DW-103). A raw TCP socket is bound on the
2717    /// listener's address:port; accepted connections are spliced
2718    /// byte-for-byte to an upstream endpoint (optionally selected by
2719    /// peeking the TLS ClientHello SNI, reusing the SNI extraction
2720    /// from DW-008 TLS passthrough). Compiled into every build (no
2721    /// cargo feature). The `l4` block configures the
2722    /// upstream and SNI-routing flag.
2723    Tcp,
2724    /// L4 UDP proxying (DW-103). A UDP socket is bound on the
2725    /// listener's address:port; datagrams are forwarded to an upstream
2726    /// endpoint. STUBBED: the UDP dispatcher returns Unimplemented --
2727    /// UDP session semantics and NAT timeouts are a follow-up. Compiled
2728    /// into every build (no cargo feature). The `l4` block configures
2729    /// the upstream.
2730    Udp,
2731}
2732
2733/// TLS handling for a listener: terminate at the edge or pass through.
2734#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2735#[serde(deny_unknown_fields)]
2736pub struct ListenerTls {
2737    #[serde(default = "default_tls_mode")]
2738    pub mode: TlsMode,
2739    /// Path to the PEM certificate chain (termination mode only). Serves as
2740    /// the default/fallback certificate when `certificates` is also set.
2741    #[serde(skip_serializing_if = "Option::is_none")]
2742    pub cert_file: Option<String>,
2743    /// Path to the PEM private key (termination mode only).
2744    #[serde(skip_serializing_if = "Option::is_none")]
2745    pub key_file: Option<String>,
2746    /// Additional SNI-scoped certificate pairs (termination mode only).
2747    /// Each entry serves the listed `server_names`; the single
2748    /// cert_file/key_file pair (if present) is the fallback for unmatched
2749    /// or absent SNI. With no single pair, the first entry is the fallback.
2750    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2751    pub certificates: Vec<TlsCertificate>,
2752    /// SNI routing rules for passthrough mode: each entry maps its
2753    /// `server_names` to an upstream (by name) that receives the raw TLS
2754    /// bytes. Rejected in terminate mode.
2755    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2756    pub sni_routes: Vec<SniRoute>,
2757    /// PEM file of CA certificates that CLIENT certificates must chain
2758    /// to (#124, termination mode only). When set, the listener
2759    /// REQUESTS a client certificate and verifies any presented one
2760    /// against this bundle; a connection without a certificate is still
2761    /// accepted (other credential families apply) — the verified
2762    /// certificate is matched against consumers' `mtls` credentials to
2763    /// resolve an identity, and an UNVERIFIED certificate fails the
2764    /// handshake (mTLS authn never sees it). Rejected in passthrough
2765    /// mode (the TLS layer is not terminated, so client certificates
2766    /// cannot be verified). The file must exist and be readable at
2767    /// config compile time; validation names this field otherwise.
2768    #[serde(default, skip_serializing_if = "Option::is_none")]
2769    pub client_ca_file: Option<String>,
2770    /// 0-RTT (early data) policy for HTTP/3 listeners (DW-088). When
2771    /// `accept`, the QUIC handshake accepts 0-RTT early data from
2772    /// clients with a saved session ticket — at the cost of replay
2773    /// risk. Non-idempotent requests (POST, PUT, PATCH, DELETE) are
2774    /// rejected under 0-RTT with a 425 Too Early response (the client
2775    /// retries over a 1-RTT connection). When `reject` (the default),
2776    /// 0-RTT early data is refused and the client must complete a
2777    /// full 1-RTT handshake before sending a request. Only meaningful
2778    /// on `protocol: h3` listeners; ignored (and rejected by
2779    /// validation) on H1/H2 listeners.
2780    #[serde(default, skip_serializing_if = "is_default_zero_rtt")]
2781    pub zero_rtt: ZeroRttPolicy,
2782    /// DW-105: opt in to post-quantum hybrid key exchange
2783    /// (X25519+ML-KEM) for this listener. When `true`, the
2784    /// X25519+ML-KEM hybrid kx group is
2785    /// prepended to the rustls provider's kx group list, PREFERRING
2786    /// the hybrid group while keeping the classical X25519 fallback
2787    /// for non-PQ clients. Compiled into every build (no cargo
2788    /// feature). EXPERIMENTAL: the rustls PQ
2789    /// API is not yet stable. Rejected when combined with FIPS mode
2790    /// (ML-KEM is not on the FIPS-validated list for aws-lc-rs).
2791    /// Terminate mode only (passthrough does not terminate TLS, so
2792    /// the kx group list is irrelevant); validation rejects `pq: true`
2793    /// on a passthrough listener.
2794    #[serde(default, skip_serializing_if = "is_false")]
2795    pub pq: bool,
2796    /// SEC-02: ACME / Let's Encrypt automation. When present, the
2797    /// gateway automatically obtains and renews certificates for the
2798    /// configured domains from the ACME directory. Terminates mode
2799    /// only (passthrough does not terminate TLS). When `acme` is
2800    /// present, `cert_file`/`key_file`/`certificates` are optional
2801    /// (ACME-managed certs serve as the fallback); manual certs still
2802    /// take precedence for their SNI names. See [`AcmeConfig`].
2803    #[serde(default, skip_serializing_if = "Option::is_none")]
2804    pub acme: Option<AcmeConfig>,
2805}
2806
2807/// SEC-02: ACME (Let's Encrypt) certificate automation configuration.
2808/// The gateway acts as an ACME client: orders certificates for the
2809/// configured domains, completes the challenge (HTTP-01 or TLS-ALPN-01),
2810/// and installs the issued certificates into the hot-reloadable TLS
2811/// termination/SNI resolver. Renewal runs automatically before expiry.
2812#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2813#[serde(deny_unknown_fields)]
2814pub struct AcmeConfig {
2815    /// The domains to obtain certificates for. At least one must be
2816    /// present (validation rejects an empty list).
2817    pub domains: Vec<String>,
2818    /// The ACME directory URL. Defaults to Let's Encrypt production
2819    /// (`https://acme-v02.api.letsencrypt.org/directory`). Set to the
2820    /// staging directory (`https://acme-staging-v02.api.letsencrypt.org/directory`)
2821    /// for testing to avoid rate limits.
2822    #[serde(
2823        default = "default_acme_directory",
2824        skip_serializing_if = "is_default_acme_directory"
2825    )]
2826    pub directory_url: String,
2827    /// Contact email addresses registered with the ACME account. Let's
2828    /// Encrypt requires at least one for production issuance.
2829    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2830    pub contact: Vec<String>,
2831    /// Challenge type: `http-01` (serves a token over HTTP on port 80)
2832    /// or `tls-alpn-01` (serves a challenge cert over TLS-ALPN on port
2833    /// 443). Default `tls-alpn-01` (no separate HTTP listener needed).
2834    #[serde(
2835        default = "default_acme_challenge",
2836        skip_serializing_if = "is_default_acme_challenge"
2837    )]
2838    pub challenge: AcmeChallenge,
2839    /// Use the staging directory instead of production. When true,
2840    /// `directory_url` is overridden to the Let's Encrypt staging
2841    /// endpoint. Staging certs are NOT trusted by browsers — use for
2842    /// testing only. Default false.
2843    #[serde(default, skip_serializing_if = "is_false")]
2844    pub staging: bool,
2845    /// Path to the directory where ACME state is persisted (account
2846    /// key, issued certificates). The gateway must have read/write
2847    /// access. Defaults to `./acme-state`.
2848    #[serde(
2849        default = "default_acme_state_dir",
2850        skip_serializing_if = "is_default_acme_state_dir"
2851    )]
2852    pub state_dir: String,
2853}
2854
2855/// ACME challenge type.
2856#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
2857#[serde(rename_all = "kebab-case")]
2858pub enum AcmeChallenge {
2859    /// HTTP-01 challenge: serves a token over HTTP on port 80. Requires
2860    /// a separate HTTP listener bound to port 80 (or port forwarding).
2861    Http01,
2862    /// TLS-ALPN-01 challenge: serves a challenge certificate over TLS
2863    /// with the `acme-tls/1` ALPN protocol on port 443. No separate
2864    /// listener needed (the default). Default.
2865    #[default]
2866    TlsAlpn01,
2867}
2868
2869fn default_acme_directory() -> String {
2870    "https://acme-v02.api.letsencrypt.org/directory".to_string()
2871}
2872
2873fn is_default_acme_directory(s: &str) -> bool {
2874    s == default_acme_directory()
2875}
2876
2877fn default_acme_challenge() -> AcmeChallenge {
2878    AcmeChallenge::TlsAlpn01
2879}
2880
2881fn is_default_acme_challenge(c: &AcmeChallenge) -> bool {
2882    *c == AcmeChallenge::TlsAlpn01
2883}
2884
2885fn default_acme_state_dir() -> String {
2886    "./acme-state".to_string()
2887}
2888
2889fn is_default_acme_state_dir(s: &str) -> bool {
2890    s == default_acme_state_dir()
2891}
2892
2893fn is_default_zero_rtt(p: &ZeroRttPolicy) -> bool {
2894    *p == ZeroRttPolicy::Reject
2895}
2896
2897/// 0-RTT (early data) acceptance policy for HTTP/3 (DW-088).
2898#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
2899#[serde(rename_all = "lowercase")]
2900pub enum ZeroRttPolicy {
2901    /// Accept 0-RTT early data. Non-idempotent requests are rejected
2902    /// with 425 Too Early (replay-safe).
2903    Accept,
2904    /// Reject 0-RTT early data (default). The client must complete a
2905    /// full 1-RTT handshake before sending a request.
2906    #[default]
2907    Reject,
2908}
2909
2910/// One SNI-scoped certificate pair for TLS termination.
2911#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2912#[serde(deny_unknown_fields)]
2913pub struct TlsCertificate {
2914    /// Server names (SNI values) this certificate answers. Exact match,
2915    /// case-insensitive per rustls.
2916    pub server_names: Vec<String>,
2917    /// Path to the PEM certificate chain.
2918    pub cert_file: String,
2919    /// Path to the PEM private key.
2920    pub key_file: String,
2921}
2922
2923/// One SNI-to-upstream routing rule for TLS passthrough.
2924#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2925#[serde(deny_unknown_fields)]
2926pub struct SniRoute {
2927    /// Server names (SNI values) routed to `upstream`. Exact match.
2928    pub server_names: Vec<String>,
2929    /// Name of the upstream that receives the spliced TLS stream.
2930    pub upstream: String,
2931}
2932
2933/// L4 proxying configuration for a `tcp` or `udp` listener (DW-103).
2934///
2935/// The listener accepts raw L4 connections and splices them byte-for-byte
2936/// to an upstream endpoint. When `sni_routing` is true (TCP only), the
2937/// dispatcher peeks the TLS ClientHello SNI (reusing the SNI extraction
2938/// from DW-008 TLS passthrough) and selects the upstream from the
2939/// listener's `tls.sni_routes` -- the same routing table passthrough
2940/// uses. When `sni_routing` is false, the configured `upstream` receives
2941/// every connection.
2942#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2943#[serde(deny_unknown_fields)]
2944pub struct L4Config {
2945    /// Name of the upstream that receives the spliced stream. Required
2946    /// when `sni_routing` is false; when `sni_routing` is true the
2947    /// upstream is selected per-connection from `tls.sni_routes`, and
2948    /// this field is the fallback for connections with no SNI or an
2949    /// unmatched name (absent = close the connection on no match).
2950    #[serde(default, skip_serializing_if = "Option::is_none")]
2951    pub upstream: Option<String>,
2952    /// Peek the TLS ClientHello SNI and route the connection to the
2953    /// upstream matched by the listener's `tls.sni_routes` (reusing the
2954    /// SNI extraction from DW-008 TLS passthrough). TCP only; rejected
2955    /// on UDP listeners by validation (UDP has no byte-stream handshake
2956    /// to peek). When false, every connection is spliced to the
2957    /// configured `upstream`.
2958    #[serde(default, skip_serializing_if = "is_false")]
2959    pub sni_routing: bool,
2960    /// Idle timeout in seconds for an established splice. When neither
2961    /// side sends data for this duration, the splice is closed. Absent
2962    /// (or 0) means no idle timeout (the splice runs until either side
2963    /// closes). Bounded by validation to at most 1 hour.
2964    #[serde(default, skip_serializing_if = "Option::is_none")]
2965    pub idle_timeout_s: Option<u64>,
2966    /// DP-01 (#234): UDP session idle timeout in seconds. A UDP session
2967    /// (per-client upstream socket) that receives no datagrams for this
2968    /// duration is evicted from the session table. Default 30s. UDP
2969    /// only; ignored for TCP listeners.
2970    #[serde(default, skip_serializing_if = "Option::is_none")]
2971    pub session_timeout_s: Option<u64>,
2972    /// DP-01 (#234): maximum concurrent UDP sessions. When the session
2973    /// table is full, the oldest idle session is evicted (LRU). Default
2974    /// 4096. UDP only; ignored for TCP listeners.
2975    #[serde(default, skip_serializing_if = "Option::is_none")]
2976    pub max_sessions: Option<usize>,
2977}
2978
2979fn default_tls_mode() -> TlsMode {
2980    TlsMode::Terminate
2981}
2982
2983#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
2984#[serde(rename_all = "lowercase")]
2985pub enum TlsMode {
2986    Terminate,
2987    Passthrough,
2988}
2989
2990/// Match rules (path/host/method/header) plus action and attached policies.
2991#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
2992#[serde(deny_unknown_fields)]
2993pub struct Route {
2994    pub name: String,
2995    /// Name of the service this route targets.
2996    pub service: String,
2997    pub r#match: RouteMatch,
2998    pub action: RouteAction,
2999    /// Attached policy names. All applicable levels' rules AND together
3000    /// (see the rate-limiter module docs); the resolution order is
3001    /// consumer > route > service > listener > global.
3002    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3003    pub policies: Vec<String>,
3004    /// Request priority class for load shedding (DW-016): 0 (lowest) to 10
3005    /// (highest); absent means the default 5. When the gateway concurrency
3006    /// cap is saturated, requests at `high_priority` (>= 8) draw from a
3007    /// small reserved sub-allowance of the cap that lower-priority traffic
3008    /// cannot use (see `proxy` module docs); preemption is impossible, so
3009    /// normal traffic is shed first rather than displaced. Validation
3010    /// rejects values above 10.
3011    #[serde(default, skip_serializing_if = "Option::is_none")]
3012    pub priority: Option<u8>,
3013    /// Require authenticated requests (DW-019): a request that arrives
3014    /// WITHOUT a recognized credential (or with an invalid one) is
3015    /// rejected with 401 and a `WWW-Authenticate` challenge. Absent
3016    /// (the default) allows anonymous traffic through; note that an
3017    /// INVALID presented credential is always rejected with 401
3018    /// regardless of this flag.
3019    #[serde(default, skip_serializing_if = "is_false")]
3020    pub auth_required: bool,
3021    /// Route-scoped CORS policy (DW-027, feature analysis 4.14): controls
3022    /// how the gateway answers browser cross-origin requests on this
3023    /// route. When present, a CORS PREFLIGHT (`OPTIONS` carrying both
3024    /// `Origin` and `Access-Control-Request-Method`) is answered by the
3025    /// gateway itself, before authentication and never forwarded
3026    /// upstream; actual (non-preflight) responses carry the policy's
3027    /// CORS headers. Non-CORS requests are untouched. A preflight on a
3028    /// route whose `match.methods` list excludes `OPTIONS` never matches
3029    /// the route (the documented request-path order applies first) —
3030    /// include `OPTIONS` in the method list of CORS routes.
3031    #[serde(default, skip_serializing_if = "Option::is_none")]
3032    pub cors: Option<Cors>,
3033    /// Route-scoped response compression (DW-027, feature analysis 4.13):
3034    /// negotiates gzip/brotli/zstd against the request's `Accept-Encoding`
3035    /// and compresses the response body per policy. Streaming-safe: the
3036    /// body is compressed chunk-by-chunk with a per-chunk flush, never
3037    /// buffered whole. Disabled (absent) by default — the dataplane
3038    /// forwards bytes untouched.
3039    #[serde(default, skip_serializing_if = "Option::is_none")]
3040    pub compression: Option<Compression>,
3041    /// Route-scoped request size limits (DW-027, feature analysis 4.16):
3042    /// per-route body and header caps enforced after route resolution.
3043    /// Over-limit requests are rejected by the gateway with the JSON
3044    /// error envelope (413 for bodies, 431 for headers) before any
3045    /// upstream contact when the size is declared up front
3046    /// (`Content-Length`); streaming bodies of unknown length are
3047    /// aborted as soon as they cross the cap.
3048    #[serde(default, skip_serializing_if = "Option::is_none")]
3049    pub limits: Option<RequestLimits>,
3050    /// Route-level authorization rules (DW-020, feature analysis 4.7).
3051    /// Absent (the default) imposes no authorization on the route. A
3052    /// PRESENT-but-entirely-empty block (no consumers, groups, scopes,
3053    /// claims, or `ip_acl`) is likewise a no-op at evaluation time —
3054    /// it imposes nothing, exactly like an absent one — but it is
3055    /// always a config-authoring mistake (a rule block with no rules),
3056    /// so validation REJECTS it: omit the block instead of emptying it.
3057    /// When present (and non-empty), the rules are evaluated after
3058    /// authentication and BEFORE
3059    /// rate limiting; see [`Authz`] for the rule semantics. Presence of
3060    /// any identity rule implies authentication (an anonymous request is
3061    /// rejected 401); an `ip_acl`-only block is the one case that can
3062    /// permit anonymous access (from an allowed IP). Precedence across
3063    /// levels (consumer > route > service > listener > global) is
3064    /// resolved by the `authz` module's resolver; every level has a
3065    /// config attachment point (`consumers[].authorization`,
3066    /// `services[].authorization`, `listeners[].authorization`, and the
3067    /// gateway-level `authorization`).
3068    #[serde(default, skip_serializing_if = "Option::is_none")]
3069    pub authorization: Option<Authz>,
3070    /// API deprecation policy (DW-048): emits the standard deprecation
3071    /// signal headers on this route's responses. See [`Deprecation`].
3072    #[serde(default, skip_serializing_if = "Option::is_none")]
3073    pub deprecation: Option<Deprecation>,
3074    /// Route maintenance mode (DW-041): when present, every request the
3075    /// route matches is answered by the GATEWAY with a 503 carrying
3076    /// `Retry-After` and the JSON error envelope (`maintenance` code) —
3077    /// before route limits, CORS preflight handling (preflights
3078    /// themselves are exempt, see [`Maintenance`]), authentication, and
3079    /// the route action (proxy, redirect, and respond actions alike
3080    /// never run). Toggled by config reload like every route field:
3081    /// publish a generation with the block to enter maintenance and one
3082    /// without it to leave. See [`Maintenance`].
3083    #[serde(default, skip_serializing_if = "Option::is_none")]
3084    pub maintenance: Option<Maintenance>,
3085    /// Request/response transforms (DW-028, feature analysis 4.12):
3086    /// header and query manipulation on the forwarded request and the
3087    /// route's responses, plus the size-capped JSON-pointer body
3088    /// transform — the transforms surface's ONE explicitly buffering
3089    /// piece. Absent (the default): bytes and headers flow untouched.
3090    /// See [`Transforms`].
3091    #[serde(default, skip_serializing_if = "Option::is_none")]
3092    pub transforms: Option<Transforms>,
3093    /// Security-header injection (DW-028, feature analysis 5-Security):
3094    /// HSTS, `X-Content-Type-Options: nosniff`,
3095    /// `Content-Security-Policy`, and `X-Frame-Options`, stamped on
3096    /// EVERY response the route emits (action responses and gateway
3097    /// short-circuits alike), replacing any upstream-sent values — the
3098    /// gateway is the source of truth at its edge. See
3099    /// [`SecurityHeaders`].
3100    #[serde(default, skip_serializing_if = "Option::is_none")]
3101    pub security_headers: Option<SecurityHeaders>,
3102    /// Opt out of the gateway-level `default_security_headers`
3103    /// baseline (SEC-09, #213): when true, a route with no
3104    /// `security_headers` block ships WITHOUT the gateway's default
3105    /// security headers. A route that carries its own
3106    /// `security_headers` block always uses it (the block fully
3107    /// replaces the default); this flag is for the rare route that
3108    /// deliberately needs no security headers at all. Default:
3109    /// false (inherit the default when present).
3110    #[serde(default, skip_serializing_if = "is_false")]
3111    pub security_headers_opt_out: bool,
3112    /// Response field masking (DW-029, feature analysis 5-Security):
3113    /// redacts the named RFC 6901 JSON pointers from the route's
3114    /// responses — the floor `fields` for every consumer, plus the
3115    /// `groups` additions for members of each consumer group — before
3116    /// any other body-handling stage. The redaction is fail-closed: a
3117    /// response the gateway cannot prove clean (encoded, non-JSON,
3118    /// over the cap, unparseable, or missing a configured pointer)
3119    /// answers 502, never a passthrough. See [`Masking`].
3120    #[serde(default, skip_serializing_if = "Option::is_none")]
3121    pub masking: Option<Masking>,
3122    /// Route-scoped response caching (DW-037, feature analysis
3123    /// 5-Protocol): opts the route's cacheable GET traffic into the
3124    /// local response cache behind the `CacheStore` extension seam.
3125    /// Absent (the default): responses are never cached or buffered.
3126    /// The cache key folds in the route, the consumer, the inbound
3127    /// path + query, and the effective vary set — masked (DW-029) and
3128    /// consumer-group-specific variants never cross consumers. Stored
3129    /// bodies are the POST-masking/transform bytes (replay consistency
3130    /// under route-scoped policies), stored identity (never compressed;
3131    /// compression re-negotiates per request), and the decoration tail
3132    /// (compression onward) re-runs on every replay. See
3133    /// [`RouteCache`] and `dataplane::response_cache`.
3134    #[serde(default, skip_serializing_if = "Option::is_none")]
3135    pub cache: Option<RouteCache>,
3136    /// Per-route METHOD ALLOWLIST (DW-030): when non-empty, a request
3137    /// that resolved this route but whose method is not in the list is
3138    /// answered by the gateway with `405 Method Not Allowed` plus an
3139    /// `Allow` header listing the configured methods (RFC 9110 10.2.1).
3140    /// Distinct from `match.methods`, which gates ROUTE RESOLUTION (a
3141    /// miss falls through to 404 / other routes); this list gates the
3142    /// ALREADY-matched route. Placement (frozen): after route
3143    /// resolution and the maintenance 503, before the route limits and
3144    /// CORS preflight short-circuit and authentication — the allowlist
3145    /// is a statement about the route, not the request's shape (the
3146    /// DW-041 maintenance ordering argument), and a CORS PREFLIGHT
3147    /// (`OPTIONS` + preflight markers) is exempt exactly like the
3148    /// maintenance 503 (the preflight asks about the GATEWAY's
3149    /// cross-origin policy, not the resource; failing it would hide the
3150    /// CORS answer from the browser). Matching is case-insensitive, the
3151    /// same comparison `match.methods` uses. HEAD is NOT implicitly
3152    /// granted by GET — a route supporting HEAD alongside GET lists
3153    /// both (the allowlist is exhaustive by design; implicit grants
3154    /// would leak methods the operator never named). Empty (the
3155    /// default) allows every method, exactly like pre-DW-030.
3156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3157    pub methods: Vec<String>,
3158    /// Service-level objectives (DW-052, feature analysis 4.17): the
3159    /// route's availability target and optional latency objective,
3160    /// exported as `dwara_slo_burn_rate` / `dwara_slo_target` metrics
3161    /// over process-local 5m and 1h sliding windows for multiwindow
3162    /// burn-rate alerting. Availability counts a request BAD only when
3163    /// the GATEWAY answers 5xx (client errors are the caller's policy,
3164    /// not this route's availability). Absent (the default): the route
3165    /// records nothing and exports no SLO series. See [`RouteSlo`].
3166    #[serde(default, skip_serializing_if = "Option::is_none")]
3167    pub slo: Option<RouteSlo>,
3168    /// WebSocket policy (DW-039, feature analysis 4.13): origin
3169    /// allowlisting before an upgrade is offered and post-upgrade
3170    /// frame-rate policing on the tunneled connection. Absent (the
3171    /// default): upgrades are transparent — any origin, unlimited
3172    /// frame rate, the generic 101 tunnel unchanged. See
3173    /// [`RouteWebsocket`].
3174    #[serde(default, skip_serializing_if = "Option::is_none")]
3175    pub websocket: Option<RouteWebsocket>,
3176    /// WAF-lite heuristic filtering (DW-051): pattern-matching
3177    /// inspection of the request path, query string, selected
3178    /// headers, and body (when JSON or form-urlencoded) for common
3179    /// SQL-injection, XSS, and path-traversal signatures. Absent (the
3180    /// default): no inspection. When `enabled` is true, the WAF runs
3181    /// after the route method allowlist and before the route limits —
3182    /// a content filter that rejects malicious requests before any
3183    /// resource is spent on auth or rate limiting. `dry_run` evaluates
3184    /// and logs matches without blocking (audit-log-only mode). See
3185    /// [`RouteWaf`].
3186    #[serde(default, skip_serializing_if = "Option::is_none")]
3187    pub waf: Option<RouteWaf>,
3188    /// GraphQL awareness (DW-099): query depth/complexity limits and
3189    /// optional persisted-query enforcement for routes that front a
3190    /// GraphQL server. When enabled, the gateway rejects abusive
3191    /// queries before the route limits and authentication. Absent
3192    /// (the default): no GraphQL checks. Compiled into every build
3193    /// (no cargo feature). See [`RouteGraphql`].
3194    #[serde(default, skip_serializing_if = "Option::is_none")]
3195    pub graphql: Option<RouteGraphql>,
3196    /// gRPC-Web framing translation + JSON-to-gRPC transcoding (DW-101):
3197    /// when enabled, the gateway translates gRPC-Web framing to native
3198    /// gRPC for the upstream and wraps the response back. When
3199    /// transcoding is enabled, JSON requests are translated to/from
3200    /// protobuf using .proto descriptors supplied via config. Absent
3201    /// (the default): no translation. The block parses and validates in
3202    /// every build (no cargo feature); the runtime translation is
3203    /// scaffolded and not yet dispatched from the request path. See
3204    /// [`GrpcWeb`].
3205    #[serde(default, skip_serializing_if = "Option::is_none")]
3206    pub grpc_web: Option<GrpcWeb>,
3207    /// Protocol translation (DW-100): translates the request/response
3208    /// bodies between two wire protocols on this route's forward and
3209    /// response paths. When enabled, the gateway converts the client's
3210    /// body to the upstream's wire format and the upstream's response
3211    /// back to the client's wire format. Absent (the default): no
3212    /// translation. The block parses and validates in every build (no
3213    /// cargo feature); the runtime translation is scaffolded and not
3214    /// yet dispatched from the request path.
3215    /// See [`Translation`].
3216    #[serde(default, skip_serializing_if = "Option::is_none")]
3217    pub translation: Option<Translation>,
3218    /// Request validation (DW-047): an optional JSON-schema block the
3219    /// gateway validates the request body against before the route
3220    /// action runs. A mismatch answers 400 `validation_failed` with the
3221    /// offending paths in the JSON error envelope; a match proceeds to
3222    /// the action (proxy, mock, ...). This is a SIMPLER surface than
3223    /// full OpenAPI parameter/header/query validation — it covers the
3224    /// body only, the common case for write endpoints. The schema is a
3225    /// minimal JSON-Schema subset (type, required, properties, items,
3226    /// enum, minimum, maximum, minLength, maxLength); `$ref` is not
3227    /// supported. See [`RequestValidation`].
3228    #[serde(default, skip_serializing_if = "Option::is_none")]
3229    pub request_validation: Option<RequestValidation>,
3230    /// OpenAPI import metadata (DW-047): when this route was generated
3231    /// by `dwara import openapi`, this block records the source
3232    /// operation's `operationId`, `summary`, and `tags` for
3233    /// traceability. It has no runtime effect — the gateway ignores it
3234    /// — but it survives round-trips so the operator can audit which
3235    /// spec operation a route came from. Absent for hand-authored
3236    /// routes.
3237    #[serde(default, skip_serializing_if = "Option::is_none")]
3238    pub openapi: Option<OpenApiMeta>,
3239    /// Shadow traffic mirroring (DW-062): fire-and-forget duplicate
3240    /// requests to a mirror upstream. The mirror response is discarded
3241    /// and never impacts the primary request's latency. A percentage
3242    /// controls sampling (0 = never, 100 = always). Absent (the
3243    /// default): no mirroring. See [`MirrorConfig`].
3244    #[serde(default, skip_serializing_if = "Option::is_none")]
3245    pub mirror: Option<MirrorConfig>,
3246    /// Fault injection (DW-062): percentage-based delays and aborts for
3247    /// chaos testing. A delay injects a fixed latency before the
3248    /// request is forwarded; an abort short-circuits with a configured
3249    /// status without contacting the upstream. Both are sampled by
3250    /// percentage (0 = never, 100 = always). Absent (the default): no
3251    /// fault injection. See [`FaultInjection`].
3252    #[serde(default, skip_serializing_if = "Option::is_none")]
3253    pub fault_injection: Option<FaultInjection>,
3254    /// Plugin names to run on this route (DW-055). Each name must
3255    /// reference a plugin defined in the top-level `plugins` list.
3256    /// Plugins run in declaration order at their declared phases
3257    /// (request-path dispatch, DW-157). Routes without plugins take
3258    /// the untouched fast path.
3259    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3260    pub plugins: Vec<String>,
3261    /// SCALE-12 (#191): per-route filter-chain ordering and dry-run
3262    /// overrides. When absent, the global `filter_chain` config (or
3263    /// the default order) applies. See [`FilterChainConfig`].
3264    #[serde(default, skip_serializing_if = "Option::is_none")]
3265    pub filter_chain: Option<FilterChainConfig>,
3266    /// SEC-05: OIDC browser login flow as a route auth mode. When
3267    /// present, the gateway acts as an OIDC relying party: unauthenticated
3268    /// browser requests are redirected to the IdP's authorization
3269    /// endpoint, and the callback (`redirect_uri`) exchanges the
3270    /// authorization code for tokens, sets a session cookie, and
3271    /// redirects the user back to the original URL. Subsequent requests
3272    /// carry the session cookie (validated by the gateway). Absent (the
3273    /// default): no browser login flow (the route uses its normal auth
3274    /// mode). See [`OidcLoginConfig`].
3275    #[serde(default, skip_serializing_if = "Option::is_none")]
3276    pub oidc_login: Option<OidcLoginConfig>,
3277}
3278
3279/// SEC-05: OIDC browser login flow configuration for a route. The
3280/// gateway redirects unauthenticated browser requests to the IdP,
3281/// exchanges the authorization code for tokens, sets a session cookie,
3282/// and validates the cookie on subsequent requests.
3283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3284#[serde(deny_unknown_fields)]
3285pub struct OidcLoginConfig {
3286    /// The OIDC provider name (must match an entry in
3287    /// `gateway.oidc_providers`).
3288    pub provider: String,
3289    /// The redirect URI the IdP sends the authorization code to. This
3290    /// must be a path on the gateway (e.g. `/auth/callback`) and must
3291    /// be registered with the IdP. The gateway handles the callback
3292    /// automatically when this route is matched.
3293    pub redirect_uri: String,
3294    /// The session cookie name. Default `dwara_session`. The cookie is
3295    /// `HttpOnly`, `Secure` (when the listener is TLS), `SameSite=Lax`,
3296    /// and carries a signed session ID.
3297    #[serde(
3298        default = "default_oidc_session_cookie",
3299        skip_serializing_if = "is_default_oidc_session_cookie"
3300    )]
3301    pub session_cookie: String,
3302    /// Session lifetime in seconds (default 3600 = 1 hour). After this
3303    /// duration the cookie expires and the user must re-authenticate.
3304    #[serde(
3305        default = "default_oidc_session_ttl_s",
3306        skip_serializing_if = "is_default_oidc_session_ttl_s"
3307    )]
3308    pub session_ttl_s: u64,
3309    /// The post-logout redirect URL. When the user visits the logout
3310    /// path (the redirect URI with `/logout` appended, e.g.
3311    /// `/auth/callback/logout`), the session cookie is cleared and the
3312    /// user is redirected to this URL. Default: the IdP's
3313    /// `end_session_endpoint` (from discovery) with
3314    /// `post_logout_redirect_uri` set to the gateway's root.
3315    #[serde(default, skip_serializing_if = "Option::is_none")]
3316    pub post_logout_url: Option<String>,
3317}
3318
3319fn default_oidc_session_cookie() -> String {
3320    "dwara_session".to_string()
3321}
3322
3323fn is_default_oidc_session_cookie(s: &str) -> bool {
3324    s == default_oidc_session_cookie()
3325}
3326
3327fn default_oidc_session_ttl_s() -> u64 {
3328    3600
3329}
3330
3331fn is_default_oidc_session_ttl_s(v: &u64) -> bool {
3332    *v == 3600
3333}
3334
3335/// OpenAPI import metadata attached to a route (DW-047). No runtime
3336/// effect; purely for traceability from the generated config back to
3337/// the source spec operation.
3338#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3339#[serde(deny_unknown_fields)]
3340pub struct OpenApiMeta {
3341    /// The `operationId` from the source OpenAPI operation (may be
3342    /// absent if the operation had none — the route name is then a
3343    /// path+method fallback).
3344    #[serde(default, skip_serializing_if = "Option::is_none")]
3345    pub operation_id: Option<String>,
3346    /// The `summary` from the source OpenAPI operation.
3347    #[serde(default, skip_serializing_if = "Option::is_none")]
3348    pub summary: Option<String>,
3349    /// The `tags` from the source OpenAPI operation.
3350    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3351    pub tags: Vec<String>,
3352    /// The HTTP method of the source operation (uppercased).
3353    pub method: String,
3354    /// The path template from the source OpenAPI spec (e.g.
3355    /// `/pets/{id}`).
3356    pub path: String,
3357}
3358
3359/// Shadow traffic mirroring configuration (DW-062,
3360/// `routes[].mirror`). Fire-and-forget duplicate requests to a named
3361/// mirror upstream; the mirror response is discarded and never impacts
3362/// the primary request's latency. A percentage controls sampling.
3363#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3364#[serde(deny_unknown_fields)]
3365pub struct MirrorConfig {
3366    /// Name of the upstream to receive mirrored (shadow) requests. The
3367    /// mirror upstream is separate from the route's service upstream —
3368    /// it exists solely to receive shadow traffic. The upstream must
3369    /// exist in the `upstreams` list.
3370    pub upstream: String,
3371    /// Percentage of requests to mirror (0..=100). 0 mirrors nothing;
3372    /// 100 mirrors every request. The sampling is per-request (a
3373    /// random draw against this percentage).
3374    pub percentage: u8,
3375    /// Hard timeout for the mirror request in milliseconds. If the
3376    /// mirror upstream does not respond within this duration, the
3377    /// mirror request is abandoned. Default 2000 (2 seconds). The
3378    /// mirror is fire-and-forget, so this timeout never affects the
3379    /// primary request.
3380    #[serde(
3381        default = "default_mirror_timeout_ms",
3382        skip_serializing_if = "is_default_mirror_timeout_ms"
3383    )]
3384    pub timeout_ms: u64,
3385}
3386
3387fn default_mirror_timeout_ms() -> u64 {
3388    2000
3389}
3390
3391fn is_default_mirror_timeout_ms(v: &u64) -> bool {
3392    *v == 2000
3393}
3394
3395/// Fault injection configuration (DW-062, `routes[].fault_injection`).
3396/// Percentage-based delays and aborts for chaos testing. A delay
3397/// injects a fixed latency before the request is forwarded; an abort
3398/// short-circuits with a configured status without contacting the
3399/// upstream. Both may be present simultaneously — the abort is
3400/// evaluated first, and if it does not fire, the delay is applied.
3401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3402#[serde(deny_unknown_fields)]
3403pub struct FaultInjection {
3404    /// Abort injection: return a configured status without forwarding.
3405    #[serde(default, skip_serializing_if = "Option::is_none")]
3406    pub abort: Option<FaultAbort>,
3407    /// Delay injection: inject a fixed delay before forwarding.
3408    #[serde(default, skip_serializing_if = "Option::is_none")]
3409    pub delay: Option<FaultDelay>,
3410}
3411
3412/// Abort fault injection (DW-062): short-circuit the request with a
3413/// configured HTTP status, sampled by percentage.
3414#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3415#[serde(deny_unknown_fields)]
3416pub struct FaultAbort {
3417    /// Percentage of requests to abort (0..=100).
3418    pub percentage: u8,
3419    /// HTTP status code to return (100..=599).
3420    pub status: u16,
3421}
3422
3423/// Delay fault injection (DW-062): inject a fixed delay before
3424/// forwarding the request, sampled by percentage.
3425#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3426#[serde(deny_unknown_fields)]
3427pub struct FaultDelay {
3428    /// Percentage of requests to delay (0..=100).
3429    pub percentage: u8,
3430    /// Fixed delay in milliseconds (1..=300000, i.e. up to 5 minutes).
3431    pub fixed_ms: u64,
3432}
3433
3434/// Route-scoped request-body validation (DW-047,
3435/// `routes[].request_validation`).
3436///
3437/// A minimal JSON-Schema subset the gateway validates the request body
3438/// against before the route action runs. A mismatch answers 400
3439/// `validation_failed` (the JSON error envelope carries the offending
3440/// instance paths); a match proceeds normally. The schema is compiled
3441/// at config publish time (an invalid schema fails validation); the
3442/// body is buffered up to the route's `limits.max_body_bytes` (or a
3443/// 1 MiB default when no limit is set) for validation, then replayed
3444/// to the action. See [`JsonSchema`] for the supported keywords.
3445#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3446#[serde(deny_unknown_fields)]
3447pub struct RequestValidation {
3448    /// The JSON Schema the request body must satisfy. A minimal subset
3449    /// of JSON Schema Draft 2020-12 is supported: `type`, `required`,
3450    /// `properties`, `items`, `enum`, `minimum`, `maximum`,
3451    /// `minLength`, `maxLength`, and `additionalProperties` (bool or a
3452    /// schema). `$ref` is NOT supported (inline your schemas). An
3453    /// invalid schema fails config validation at publish time.
3454    pub body_schema: BodySchema,
3455    /// SEC-14: dry-run mode. When true, the gateway evaluates the
3456    /// schema and records violations (log + metric) but does NOT reject
3457    /// the request — the body is forwarded to the upstream even when it
3458    /// fails validation. Useful for rolling out a new schema without
3459    /// breaking existing callers. Default false (reject on mismatch).
3460    #[serde(default, skip_serializing_if = "is_false")]
3461    pub dry_run: bool,
3462}
3463
3464/// A minimal JSON-Schema subset (DW-047) for request-body validation.
3465///
3466/// Supports the keywords sufficient for typical OpenAPI-derived body
3467/// schemas: `type`, `required`, `properties`, `items`, `enum`,
3468/// `minimum`, `maximum`, `minLength`, `maxLength`, and
3469/// `additionalProperties`. Nested objects and arrays compose
3470/// recursively. Unknown keywords are IGNORED (forward compatibility
3471/// with richer specs) EXCEPT at the root `RequestValidation.body_schema`
3472/// where `deny_unknown_fields` on the surrounding config struct already
3473/// rejects typos in the wrapper; this struct itself is permissive so a
3474/// real-world schema carrying `description`, `example`, or `$schema`
3475/// does not fail to parse.
3476#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3477pub struct BodySchema {
3478    /// The JSON type: `object`, `array`, `string`, `number`,
3479    /// `integer`, `boolean`, or `null`.
3480    #[serde(default, skip_serializing_if = "Option::is_none")]
3481    pub r#type: Option<String>,
3482    /// For `object`: the required property names.
3483    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3484    pub required: Vec<String>,
3485    /// For `object`: named property schemas.
3486    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
3487    pub properties: std::collections::BTreeMap<String, BodySchema>,
3488    /// For `object`: `true` (default, allows extra properties), `false`
3489    /// (rejects any property not in `properties`), or a schema every
3490    /// extra property must satisfy.
3491    #[serde(
3492        default,
3493        skip_serializing_if = "Option::is_none",
3494        rename = "additionalProperties"
3495    )]
3496    pub additional_properties: Option<Box<AdditionalProperties>>,
3497    /// For `array`: the schema every element must satisfy.
3498    #[serde(default, skip_serializing_if = "Option::is_none")]
3499    pub items: Option<Box<BodySchema>>,
3500    /// An enum of allowed values (compared by JSON equality).
3501    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3502    pub r#enum: Vec<serde_json::Value>,
3503    /// Inclusive numeric minimum (for `number`/`integer`).
3504    #[serde(default, skip_serializing_if = "Option::is_none")]
3505    pub minimum: Option<f64>,
3506    /// Inclusive numeric maximum (for `number`/`integer`).
3507    #[serde(default, skip_serializing_if = "Option::is_none")]
3508    pub maximum: Option<f64>,
3509    /// Minimum string length (for `string`).
3510    #[serde(default, skip_serializing_if = "Option::is_none", rename = "minLength")]
3511    pub min_length: Option<u64>,
3512    /// Maximum string length (for `string`).
3513    #[serde(default, skip_serializing_if = "Option::is_none", rename = "maxLength")]
3514    pub max_length: Option<u64>,
3515}
3516
3517/// The `additionalProperties` keyword accepts either a boolean or a
3518/// nested schema; this union models both.
3519#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3520#[serde(untagged)]
3521pub enum AdditionalProperties {
3522    /// A boolean: `true` allows any extra property, `false` rejects
3523    /// any property not listed in `properties`.
3524    Bool(bool),
3525    /// A schema every extra property must satisfy.
3526    Schema(BodySchema),
3527}
3528
3529/// Route-scoped WAF-lite config (DW-051, `routes[].waf`).
3530///
3531/// A lightweight, heuristic-based web application filter: regex pattern
3532/// matching for SQL injection, XSS, and path traversal signatures across
3533/// the request path, query string, selected headers (User-Agent, Referer,
3534/// Cookie, X-Forwarded-For), and body (when the content type is JSON or
3535/// form-urlencoded, up to `max_body_inspect_bytes`). This is NOT a full
3536/// WAF — it is a first-line traffic filter that rejects obvious attack
3537/// signatures before authentication or rate limiting. Per-route opt-in;
3538/// routes without a `waf` block are never inspected.
3539#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3540#[serde(deny_unknown_fields)]
3541pub struct RouteWaf {
3542    /// Master switch. Default false: the WAF block is inert even when
3543    /// present (allows staged rollout). When true, the configured
3544    /// filter categories run on every request the route matches.
3545    #[serde(default, skip_serializing_if = "is_false")]
3546    pub enabled: bool,
3547    /// Audit-log-only mode (dry-run synergy, DW-041): when true, the
3548    /// WAF evaluates every filter and LOGS matches (a `dwara::policy`
3549    /// warn event + the `dwara_waf_total{outcome="logged"}` counter)
3550    /// but does NOT block — the request continues to the upstream.
3551    /// Lets an operator observe what the WAF would catch before
3552    /// enforcing it. Default false: matches are blocked with 403.
3553    #[serde(default, skip_serializing_if = "is_false")]
3554    pub dry_run: bool,
3555    /// Which filter categories to run. An empty or absent list
3556    /// defaults to all three (`sqli`, `xss`, `path_traversal`).
3557    /// Validation rejects an enabled WAF with an explicit non-empty
3558    /// list containing an unknown category name.
3559    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3560    pub filters: Vec<String>,
3561    /// Maximum number of body bytes to inspect (0 = do not inspect
3562    /// the body at all; 1..=1048576 = inspect up to this many bytes).
3563    /// Default 131072 (128 KiB). Bodies larger than this limit are
3564    /// inspected only up to the limit (a malicious payload beyond the
3565    /// limit is not caught — the trade-off for a bounded inspection
3566    /// cost). Validation rejects values > 1048576 (1 MiB).
3567    #[serde(
3568        default = "default_waf_max_body_inspect_bytes",
3569        skip_serializing_if = "is_default_waf_max_body_inspect_bytes"
3570    )]
3571    pub max_body_inspect_bytes: u64,
3572    /// Optional custom regex pattern overrides (additional patterns
3573    /// appended to the built-in signatures for each filter category).
3574    /// Each entry is a regex string compiled at config publish time;
3575    /// an invalid regex fails validation. When absent, only the
3576    /// built-in signatures are used.
3577    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3578    pub custom_patterns: Vec<String>,
3579}
3580
3581fn default_waf_max_body_inspect_bytes() -> u64 {
3582    131_072
3583}
3584
3585fn is_default_waf_max_body_inspect_bytes(v: &u64) -> bool {
3586    *v == 131_072
3587}
3588
3589/// SEC-08 (#212): A single OWASP CRS-compatible WAF rule. Each rule
3590/// has a numeric ID (CRS convention: 9xxxx), a severity level, a
3591/// phase (1 = request headers, 2 = request body), a list of tags, a
3592/// regex pattern, and the inspection targets to apply the pattern
3593/// to. Rules are evaluated in order; matching rules add their
3594/// severity to the request's anomaly score. The request is blocked
3595/// when the score exceeds the configured threshold.
3596#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3597#[serde(deny_unknown_fields)]
3598pub struct CrsRule {
3599    /// Unique rule ID (CRS convention: 9xxxx). Used for exclusions
3600    /// and logging.
3601    pub id: u32,
3602    /// Rule severity: 1 = critical, 2 = warning, 3 = notice, 4 = info.
3603    /// Higher severity contributes more to the anomaly score.
3604    #[serde(default = "default_crs_severity")]
3605    pub severity: u8,
3606    /// Evaluation phase: 1 = request headers (path, query, headers),
3607    /// 2 = request body. Default 1.
3608    #[serde(default = "default_crs_phase")]
3609    pub phase: u8,
3610    /// Tags for grouping and exclusion (e.g. "OWASP_CRS", "SQL_INJECTION").
3611    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3612    pub tags: Vec<String>,
3613    /// The regex pattern to match against the target values.
3614    pub pattern: String,
3615    /// Which request parts to inspect: "path", "query", "header",
3616    /// "headers" (all selected headers), "body". Default: ["path", "query"].
3617    #[serde(default = "default_crs_targets")]
3618    pub targets: Vec<String>,
3619    /// Optional list of transformations to apply before matching
3620    /// (e.g. "lowercase", "url_decode", "html_entity_decode"). Default: [].
3621    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3622    pub transformations: Vec<String>,
3623}
3624
3625fn default_crs_severity() -> u8 {
3626    2
3627}
3628
3629fn default_crs_phase() -> u8 {
3630    1
3631}
3632
3633fn default_crs_targets() -> Vec<String> {
3634    vec!["path".into(), "query".into()]
3635}
3636
3637/// SEC-08 (#212): Global WAF policy with CRS-compatible rules. When
3638/// enabled, the rules are evaluated on every request (regardless of
3639/// route-level WAF config). The anomaly score is accumulated across
3640/// all matching rules; the request is blocked when the score exceeds
3641/// the threshold. Rule exclusions can disable specific rules by ID or
3642/// tag. The paranoia level controls which rules are active (1 =
3643/// default, 2 = more aggressive, 3 = paranoid, 4 = very paranoid).
3644#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3645#[serde(deny_unknown_fields)]
3646pub struct GlobalWaf {
3647    /// Master switch. Default false.
3648    #[serde(default = "default_false", skip_serializing_if = "is_false")]
3649    pub enabled: bool,
3650    /// Audit-log-only mode (same semantics as RouteWaf::dry_run).
3651    #[serde(default = "default_false", skip_serializing_if = "is_false")]
3652    pub dry_run: bool,
3653    /// Paranoia level (1-4). Rules with a higher paranoia level than
3654    /// this are skipped. Default 1.
3655    #[serde(
3656        default = "default_crs_paranoia",
3657        skip_serializing_if = "is_default_crs_paranoia"
3658    )]
3659    pub paranoia_level: u8,
3660    /// Anomaly score threshold for blocking. A request is blocked
3661    /// when the sum of matching rule severities reaches this value.
3662    /// Default 5 (critical rules block immediately, warnings need
3663    /// multiple hits).
3664    #[serde(
3665        default = "default_crs_threshold",
3666        skip_serializing_if = "is_default_crs_threshold"
3667    )]
3668    pub anomaly_threshold: u32,
3669    /// Maximum body bytes to inspect (same semantics as
3670    /// RouteWaf::max_body_inspect_bytes). Default 131072.
3671    #[serde(
3672        default = "default_waf_max_body_inspect_bytes",
3673        skip_serializing_if = "is_default_waf_max_body_inspect_bytes"
3674    )]
3675    pub max_body_inspect_bytes: u64,
3676    /// CRS-compatible rules to evaluate.
3677    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3678    pub rules: Vec<CrsRule>,
3679    /// Rule IDs to exclude (skip even if the rule is in the rules
3680    /// list). Useful for tuning false positives.
3681    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3682    pub exclude_rule_ids: Vec<u32>,
3683    /// Tags to exclude (skip all rules carrying any of these tags).
3684    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3685    pub exclude_tags: Vec<String>,
3686}
3687
3688fn default_crs_paranoia() -> u8 {
3689    1
3690}
3691
3692fn is_default_crs_paranoia(v: &u8) -> bool {
3693    *v == 1
3694}
3695
3696fn default_crs_threshold() -> u32 {
3697    5
3698}
3699
3700fn is_default_crs_threshold(v: &u32) -> bool {
3701    *v == 5
3702}
3703
3704/// Route-scoped GraphQL awareness config (DW-099, `routes[].graphql`).
3705///
3706/// When enabled (compiled into every build; no cargo feature), the
3707/// gateway enforces query depth/complexity limits and optional
3708/// persisted-query enforcement on every request the route matches,
3709/// BEFORE the route limits and authentication. A query whose depth or
3710/// complexity exceeds the configured limits is rejected with 400
3711/// (`graphql_depth_exceeded` or `graphql_complexity_exceeded`); a
3712/// query whose SHA-256 hash is not in the persisted-query store is
3713/// rejected with 400 (`graphql_persisted_query_required`).
3714///
3715/// The check is compiled into every build (no cargo feature): a
3716/// config carrying the block behaves identically in any build.
3717///
3718/// Depth is the maximum brace-nesting level of the query (the
3719/// top-level operation body is depth 1). Complexity is the sum of
3720/// per-field costs: each field's cost is looked up in
3721/// `cost_per_field`, falling back to `complexity_coefficient` (default
3722/// 1) when the field name is not in the map.
3723///
3724/// Both are computed by a
3725/// bounded hand-rolled scanner (no external GraphQL parser dependency).
3726#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3727#[serde(deny_unknown_fields)]
3728pub struct RouteGraphql {
3729    /// Master switch. Default false: the GraphQL block is inert even
3730    /// when present (allows staged rollout). When true, the depth and
3731    /// complexity checks run on every request the route matches.
3732    #[serde(default, skip_serializing_if = "is_false")]
3733    pub enabled: bool,
3734    /// Maximum query depth (max brace-nesting level). A query whose
3735    /// depth exceeds this is rejected with 400
3736    /// `graphql_depth_exceeded`. Must be > 0 when the block is
3737    /// enabled (validation rejects 0 or absent). The top-level
3738    /// operation body is depth 1, so a value of 1 permits only a
3739    /// flat selection set with no nested fields.
3740    pub depth_limit: usize,
3741    /// Maximum query complexity (sum of per-field costs). A query
3742    /// whose complexity exceeds this is rejected with 400
3743    /// `graphql_complexity_exceeded`. Must be > 0 when the block is
3744    /// enabled.
3745    pub complexity_limit: u64,
3746    /// Default cost per field when the field name is not in
3747    /// `cost_per_field` (default 1). The complexity total is
3748    /// `sum(cost_per_field[name].unwrap_or(complexity_coefficient))`.
3749    #[serde(
3750        default = "default_graphql_complexity_coefficient",
3751        skip_serializing_if = "is_default_graphql_complexity_coefficient"
3752    )]
3753    pub complexity_coefficient: u64,
3754    /// Per-field cost overrides. Maps a field name to its explicit
3755    /// cost; any field not in this map uses
3756    /// `complexity_coefficient`. Empty (the default): every field
3757    /// costs `complexity_coefficient`.
3758    #[serde(default, skip_serializing_if = "is_graphql_cost_per_field_empty")]
3759    pub cost_per_field: std::collections::HashMap<String, u64>,
3760    /// Persisted-query enforcement (Apollo APQ + GET-by-hash variant).
3761    /// When enabled, every request's query SHA-256 hash must be in the
3762    /// configured `store` (a map of hash -> query text). A hash not in
3763    /// the store is rejected with 400
3764    /// `graphql_persisted_query_required`. Absent (the default): no
3765    /// persisted-query enforcement.
3766    #[serde(default, skip_serializing_if = "Option::is_none")]
3767    pub persisted_queries: Option<GraphqlPersistedQueries>,
3768    /// Federation passthrough config (DP-11, #256). When enabled, the
3769    /// gateway treats the route as a federation passthrough: it
3770    /// forwards GraphQL requests to a single federated upstream
3771    /// (subgraph or supergraph router) while preserving
3772    /// federation-specific headers and allowing introspection queries
3773    /// (`_service`, `__schema`) to bypass depth/complexity checks.
3774    #[serde(default, skip_serializing_if = "Option::is_none")]
3775    pub federation: Option<GraphqlFederation>,
3776}
3777
3778/// Federation passthrough config (DP-11, #256,
3779/// `routes[].graphql.federation`).
3780///
3781/// When `passthrough` is true, the gateway:
3782/// - Preserves Apollo federation headers on the proxied request
3783///   (`Apollo-Require-Preflight`, `x-apollo-operation-name`,
3784///   `graphql-preflight`, `x-apollo-operation-id`).
3785/// - Allows federation introspection queries (`_service { sdl }`,
3786///   `__schema`, `__type`) to bypass depth and complexity checks.
3787/// - Still applies persisted-query enforcement if configured.
3788///
3789/// This is **passthrough mode**: the gateway does NOT implement query
3790/// planning or subgraph stitching. It proxies to a single upstream
3791/// that is expected to be either a federation router (subgraph-aware)
3792/// or a single subgraph.
3793#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3794#[serde(deny_unknown_fields)]
3795pub struct GraphqlFederation {
3796    /// Master switch. Default false.
3797    #[serde(default, skip_serializing_if = "is_false")]
3798    pub passthrough: bool,
3799}
3800
3801/// Persisted-query enforcement config (DW-099,
3802/// `routes[].graphql.persisted_queries`).
3803///
3804/// When enabled, the gateway enforces that every request's query
3805/// SHA-256 hash is in the config-supplied `store`. This is the Apollo
3806/// APQ (Automatic Persisted Queries) + GET-by-hash variant: the
3807/// client sends the query text, the gateway computes its SHA-256, and
3808/// verifies the hash is known. An external store (Redis, etc.) is a
3809/// future extension point; v1 uses a config-supplied map.
3810#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3811#[serde(deny_unknown_fields)]
3812pub struct GraphqlPersistedQueries {
3813    /// Master switch. Default false: the persisted-query block is
3814    /// inert even when present.
3815    #[serde(default, skip_serializing_if = "is_false")]
3816    pub enabled: bool,
3817    /// The persisted-query store: a map of SHA-256 hash (lowercase
3818    /// hex) to query text. Every key must be a non-empty string
3819    /// (validation rejects empty keys). The query text is stored for
3820    /// future use (e.g. GET-by-hash resolution); v1 only checks hash
3821    /// membership.
3822    #[serde(default, skip_serializing_if = "is_graphql_persisted_store_empty")]
3823    pub store: std::collections::HashMap<String, String>,
3824}
3825
3826fn default_graphql_complexity_coefficient() -> u64 {
3827    1
3828}
3829
3830fn is_default_graphql_complexity_coefficient(v: &u64) -> bool {
3831    *v == 1
3832}
3833
3834fn is_graphql_cost_per_field_empty(v: &std::collections::HashMap<String, u64>) -> bool {
3835    v.is_empty()
3836}
3837
3838fn is_graphql_persisted_store_empty(v: &std::collections::HashMap<String, String>) -> bool {
3839    v.is_empty()
3840}
3841
3842/// Route-scoped gRPC-Web policy (DW-101, `routes[].grpc_web`).
3843///
3844/// When enabled, the gateway translates gRPC-Web framing (the browser-
3845/// friendly variant that replaces HTTP/2 trailers with a trailing
3846/// frame) to native gRPC for the upstream, and wraps the native gRPC
3847/// response back into gRPC-Web framing for the browser. Streaming
3848/// (server-streaming and bidi) is supported: each upstream data chunk
3849/// becomes its own gRPC-Web data frame, and the trailers become a
3850/// final gRPC-Web trailer frame.
3851///
3852/// When `transcoding` is enabled, the gateway additionally accepts
3853/// plain JSON requests (Content-Type: application/json) and translates
3854/// them to protobuf wire bytes for the upstream, and translates the
3855/// protobuf response back to JSON. The mapping is driven by .proto
3856/// descriptors supplied via config.
3857///
3858/// The block parses and validates in every build (no cargo feature);
3859/// the runtime framing/transcoding translation is scaffolded and not
3860/// yet dispatched from the request path. See [`GrpcWebTranscoding`] and
3861/// [`GrpcWebDescriptor`].
3862#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3863#[serde(deny_unknown_fields)]
3864pub struct GrpcWeb {
3865    /// Master switch. Default false: the gRPC-Web block is inert even
3866    /// when present (allows staged rollout). When true, the gateway
3867    /// translates gRPC-Web framing to native gRPC on the forward path
3868    /// and back on the response path.
3869    #[serde(default, skip_serializing_if = "is_false")]
3870    pub enabled: bool,
3871    /// JSON-to-gRPC transcoding configuration. When enabled, the
3872    /// gateway accepts JSON requests and translates them to protobuf
3873    /// using the supplied .proto descriptors. Absent (the default):
3874    /// only gRPC-Web framing translation runs (no JSON transcoding).
3875    #[serde(default, skip_serializing_if = "Option::is_none")]
3876    pub transcoding: Option<GrpcWebTranscoding>,
3877}
3878
3879/// gRPC-Web JSON transcoding configuration (DW-101,
3880/// `routes[].grpc_web.transcoding`).
3881///
3882/// Supplies the .proto descriptor files that map HTTP/JSON requests to
3883/// gRPC methods. Each descriptor names a file (a FileDescriptorSet in
3884/// binary protobuf format), a package, and a service. At config publish
3885/// the gateway loads the descriptors, resolves the service's methods,
3886/// and builds a method map keyed by the fully-qualified gRPC method
3887/// path. The google.api.http annotation on each RPC maps an HTTP path +
3888/// verb to the gRPC method.
3889#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3890#[serde(deny_unknown_fields)]
3891pub struct GrpcWebTranscoding {
3892    /// Master switch for transcoding. Default false: even when the
3893    /// `transcoding` block is present, transcoding is inert unless
3894    /// this is true. (Allows the descriptor list to be staged before
3895    /// activation.)
3896    #[serde(default, skip_serializing_if = "is_false")]
3897    pub enabled: bool,
3898    /// The .proto descriptor files to load. Each entry names a
3899    /// FileDescriptorSet file (binary protobuf format, as produced by
3900    /// `protoc --descriptor_set_out=...`), the package, and the
3901    /// service within that package. At least one descriptor is
3902    /// required when transcoding is enabled.
3903    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3904    pub descriptors: Vec<GrpcWebDescriptor>,
3905}
3906
3907/// A .proto descriptor file entry (DW-101,
3908/// `routes[].grpc_web.transcoding.descriptors[]`).
3909#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3910#[serde(deny_unknown_fields)]
3911pub struct GrpcWebDescriptor {
3912    /// Path to the FileDescriptorSet file (binary protobuf format, as
3913    /// produced by `protoc --descriptor_set_out=...`). The file must
3914    /// exist and be readable at config publish time; a missing or
3915    /// unparseable file fails validation.
3916    pub file: String,
3917    /// The protobuf package name (e.g. `my.api`). Used to locate the
3918    /// service's methods in the descriptor set.
3919    pub package: String,
3920    /// The service name within the package (e.g. `MyService`).
3921    pub service: String,
3922}
3923
3924/// Route-scoped protocol translation policy (DW-100,
3925/// `routes[].translation`).
3926///
3927/// When enabled, the gateway translates the request/response bodies
3928/// between two wire protocols on this route's forward and response
3929/// paths. The `kind` selects the translation direction; the `graphql`
3930/// and `soap` sub-blocks carry the direction-specific config.
3931///
3932/// The block parses and validates in every build (no cargo feature);
3933/// the runtime translators are scaffolded and not yet dispatched from
3934/// the request path.
3935#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3936#[serde(deny_unknown_fields)]
3937pub struct Translation {
3938    /// The translation direction. Selects which translator runs and
3939    /// which sub-block (`graphql` or `soap`) carries the config.
3940    pub kind: TranslationKind,
3941    /// REST <-> GraphQL translation config. Required when `kind` is
3942    /// `rest_to_graphql` or `graphql_to_rest`; ignored otherwise.
3943    #[serde(default, skip_serializing_if = "Option::is_none")]
3944    pub graphql: Option<GraphqlTranslation>,
3945    /// SOAP/XML translation config. Required when `kind` is
3946    /// `rest_to_soap` or `soap_to_rest`; ignored otherwise.
3947    #[serde(default, skip_serializing_if = "Option::is_none")]
3948    pub soap: Option<SoapTranslation>,
3949}
3950
3951/// The protocol translation direction (DW-100,
3952/// `routes[].translation.kind`).
3953#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3954#[serde(rename_all = "snake_case")]
3955pub enum TranslationKind {
3956    /// A REST/JSON client -> GraphQL upstream. The gateway builds a
3957    /// GraphQL query from a config-supplied query template and the
3958    /// REST JSON body.
3959    RestToGraphql,
3960    /// A GraphQL client -> REST upstream. The gateway unwraps the
3961    /// GraphQL `data` envelope into a REST JSON body on the response
3962    /// path.
3963    GraphqlToRest,
3964    /// A REST/JSON client -> SOAP/XML upstream. The gateway wraps the
3965    /// JSON body in a SOAP envelope with the configured operation name
3966    /// and namespace.
3967    RestToSoap,
3968    /// A SOAP/XML client -> REST/JSON upstream. The gateway parses the
3969    /// SOAP envelope and converts the Body's payload element to JSON.
3970    SoapToRest,
3971}
3972
3973/// REST <-> GraphQL translation config (DW-100,
3974/// `routes[].translation.graphql`).
3975#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
3976#[serde(deny_unknown_fields)]
3977pub struct GraphqlTranslation {
3978    /// The GraphQL query template. `$variable` references are resolved
3979    /// from the REST JSON body's top-level fields and substituted into
3980    /// the query (the resolved values are also sent as the GraphQL
3981    /// `variables` map). Required for `rest_to_graphql`; ignored for
3982    /// `graphql_to_rest` (which only unwraps the response envelope).
3983    #[serde(default, skip_serializing_if = "String::is_empty")]
3984    pub query_template: String,
3985    /// The path to send the GraphQL upstream (REST-to-GraphQL rewrites
3986    /// every request to this path). Default `/graphql`.
3987    #[serde(
3988        default = "default_graphql_upstream_path",
3989        skip_serializing_if = "is_default_graphql_upstream_path"
3990    )]
3991    pub upstream_path: String,
3992}
3993
3994fn default_graphql_upstream_path() -> String {
3995    "/graphql".to_string()
3996}
3997
3998fn is_default_graphql_upstream_path(s: &str) -> bool {
3999    s == "/graphql"
4000}
4001
4002/// SOAP/XML translation config (DW-100, `routes[].translation.soap`).
4003#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4004#[serde(deny_unknown_fields)]
4005pub struct SoapTranslation {
4006    /// The SOAP operation name: the Body's first child element name
4007    /// (e.g. `GetUser`). Required for both directions.
4008    pub operation: String,
4009    /// The XML namespace for the operation element
4010    /// (e.g. `http://example.com/api`). Required for `rest_to_soap`
4011    /// (the envelope's payload element carries the namespace); used
4012    /// for `soap_to_rest` only to validate the payload element's
4013    /// namespace when present.
4014    #[serde(default, skip_serializing_if = "String::is_empty")]
4015    pub namespace: String,
4016}
4017
4018/// Route-scoped WebSocket policy (DW-039, `routes[].websocket`).
4019///
4020/// Both knobs are opt-in and independent: an `origins` list gates the
4021/// upgrade handshake (403 before any upstream contact), and
4022/// `max_frames_per_sec` polices the ESTABLISHED tunnel (a client
4023/// sending data frames faster than the allowance is closed with a
4024/// 1008 policy-violation close frame and disconnected). The policing
4025/// works on the client-to-upstream direction only — the gateway
4026/// protects upstreams from abusive clients, not the reverse (an
4027/// abusive upstream is the operator's backend problem).
4028#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4029#[serde(deny_unknown_fields)]
4030pub struct RouteWebsocket {
4031    /// Allowed `Origin` header values for the upgrade handshake
4032    /// (case-sensitive exact match, e.g. `https://app.example.com`).
4033    /// Empty or absent: every origin is allowed (the transparent
4034    /// default). Non-empty: ONLY listed origins upgrade — a missing
4035    /// `Origin` header is DENIED (browsers always send one on
4036    /// WebSocket handshakes; a request without one is not a browser
4037    /// and must be named explicitly or fail closed). The literal
4038    /// string `null` matches the sandboxed-document origin by exact
4039    /// match.
4040    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4041    pub origins: Vec<String>,
4042    /// Post-upgrade policing: the sustained allowance of DATA frames
4043    /// (text, binary, continuation — control frames are free) the
4044    /// CLIENT may send per second, with a one-second burst of the same
4045    /// size. A client past its allowance is closed with close code
4046    /// 1008 (policy violation) and disconnected. Absent: no policing
4047    /// (unlimited, the transparent default). Validation enforces
4048    /// 1..=[`limits::MAX_WEBSOCKET_FRAMES_PER_SEC`].
4049    #[serde(default, skip_serializing_if = "Option::is_none")]
4050    pub max_frames_per_sec: Option<u64>,
4051    /// Post-upgrade idle timeout in seconds (DP-05, #253). If no data
4052    /// flows in either direction for this duration, the tunnel is
4053    /// closed. Absent: no idle timeout (the tunnel stays open until
4054    /// one side closes, the prior default). Validation enforces
4055    /// 1..=86400 (1 second to 24 hours).
4056    #[serde(default, skip_serializing_if = "Option::is_none")]
4057    pub idle_timeout_s: Option<u64>,
4058    /// Maximum payload size of a single WebSocket DATA frame in bytes
4059    /// (DP-05, #253). A frame exceeding this limit causes the
4060    /// connection to be closed with close code 1009 (message too big).
4061    /// Absent: no frame size limit (the prior default). Validation
4062    /// enforces 1..=16777216 (1 byte to 16 MiB).
4063    #[serde(default, skip_serializing_if = "Option::is_none")]
4064    pub max_frame_size_bytes: Option<u64>,
4065}
4066
4067/// Per-route SLO objectives (DW-052, `routes[].slo`).
4068#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4069#[serde(deny_unknown_fields)]
4070pub struct RouteSlo {
4071    /// Availability target as a PERCENTAGE in (0, 100]: the share of
4072    /// requests the gateway must answer outside the 5xx class.
4073    /// `99.9` allows one bad request per thousand.
4074    pub availability: f64,
4075    /// Latency objective threshold in milliseconds: a request is BAD
4076    /// for the latency objective when its end-to-end duration exceeds
4077    /// this. Absent: no latency objective (only availability is
4078    /// exported for the route).
4079    #[serde(default, skip_serializing_if = "Option::is_none")]
4080    pub latency_ms: Option<f64>,
4081    /// Latency target as a PERCENTAGE in (0, 100): the share of
4082    /// requests that must complete within `latency_ms` (default 99).
4083    /// Requires `latency_ms`.
4084    #[serde(default, skip_serializing_if = "Option::is_none")]
4085    pub latency_target: Option<f64>,
4086}
4087
4088/// Route maintenance mode (DW-041): a per-route availability state that
4089/// short-circuits every matched request with a gateway-generated 503.
4090///
4091/// Frozen semantics (enforcement lives in `dataplane::proxy`):
4092///
4093/// - Checked IMMEDIATELY after route resolution, BEFORE the route's
4094///   request limits: maintenance is a statement about the route's
4095///   availability, not about any one request's shape, so every matched
4096///   request gets the same 503 + `Retry-After` answer (an over-limit
4097///   request during maintenance is told "we're down", not "your headers
4098///   are too big" — fixing the headers would still leave it refused).
4099/// - The response is `503` with `Retry-After: <retry_after_secs>` (a
4100///   whole-seconds delay-suggestion, NOT a promise of recovery) and the
4101///   uniform JSON error envelope; `message` replaces the envelope's
4102///   default human text (it never leaks upstream internals — it is
4103///   operator-authored).
4104/// - CORS: an actual (non-preflight) request on a CORS-configured route
4105///   gets the policy's actual-response headers on the 503, so a browser
4106///   client can READ the maintenance envelope cross-origin. A CORS
4107///   PREFLIGHT is exempt: it still answers 204 exactly as without
4108///   maintenance. The preflight is a Fetch-protocol handshake about the
4109///   GATEWAY's cross-origin policy, answered from static config and sent
4110///   without credentials — failing it would surface in the browser as an
4111///   opaque CORS error and hide the very 503 the operator wants clients
4112///   to see on the subsequent actual request.
4113/// - Reserved paths (`/healthz`, `/readyz`, `/metrics`) never match a
4114///   route, so probes and scrapes keep answering through maintenance —
4115///   an orchestrator must not restart a deliberately idling gateway.
4116/// - Unrouted traffic is unaffected (maintenance is per-route; a 404
4117///   stays a 404).
4118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4119#[serde(deny_unknown_fields)]
4120pub struct Maintenance {
4121    /// `Retry-After` value, in whole seconds, sent with the 503. Absent:
4122    /// 60 (one minute — long enough to shed load, short enough that a
4123    /// client polling for recovery notices the route return). Validation
4124    /// rejects 0 (it would invite an immediate retry stampede against a
4125    /// route the operator just took down).
4126    #[serde(default, skip_serializing_if = "Option::is_none")]
4127    pub retry_after_secs: Option<u64>,
4128    /// Human-readable text for the error envelope's `message` field.
4129    /// Absent: "route under maintenance". Validation rejects an
4130    /// empty/whitespace string (omit the field for the default).
4131    #[serde(default, skip_serializing_if = "Option::is_none")]
4132    pub message: Option<String>,
4133}
4134
4135/// Default `maintenance.retry_after_secs` (DW-041).
4136pub const DEFAULT_MAINTENANCE_RETRY_AFTER_SECS: u64 = 60;
4137
4138impl Maintenance {
4139    /// The `Retry-After` seconds to send (the configured value or the
4140    /// default; validation has rejected 0, so this is always >= 1).
4141    pub fn retry_after(&self) -> u64 {
4142        self.retry_after_secs
4143            .unwrap_or(DEFAULT_MAINTENANCE_RETRY_AFTER_SECS)
4144            .max(1)
4145    }
4146
4147    /// The envelope `message` text (the configured value or the default).
4148    pub fn message(&self) -> &str {
4149        self.message.as_deref().unwrap_or("route under maintenance")
4150    }
4151}
4152
4153/// Route-level API deprecation policy (DW-048): automates the RFC
4154/// deprecation signal headers on every response of the route.
4155///
4156/// What the block emits (computed once at snapshot compile; see
4157/// [`CompiledDeprecation`] and `dataplane::versioning`):
4158///
4159/// - `since` (header emitted only when set): `Deprecation:
4160///   @<unix-seconds>` — the RFC 9745 structured-date form (RFC 9651
4161///   dates). RFC 9745 carries no URI inside the `Deprecation` field;
4162///   the human-readable notice travels in the `Link` below.
4163/// - `sunset` (optional): `Sunset: <HTTP-date>` verbatim — RFC 8594
4164///   requires an HTTP-date, and validation accepts exactly the
4165///   IMF-fixdate form generators must send (see `config::versioning`).
4166/// - `uri` (optional, requires `since`): `Link: <uri>;
4167///   rel="deprecation"` appended — the RFC 9745 companion link to the
4168///   migration documentation.
4169///
4170/// Semantics (frozen):
4171///
4172/// - Headers are stamped on the route's ACTION responses (proxy,
4173///   redirect, respond) in the response decoration tail — after
4174///   compression wrapping (the codec only rewrites `Content-Length`,
4175///   `Content-Encoding`, and `Vary`, so these headers pass through
4176///   verbatim) and beside the CORS headers (independent families).
4177///   Gateway-generated short-circuits on the route (413/431 limit
4178///   rejections, CORS preflights, authn/authz/rate-limit rejections,
4179///   503 sheds) do NOT carry them: those responses describe the
4180///   gateway's opinion of the REQUEST, not the route's lifecycle.
4181///   (The eager HMAC body-digest 401 is an action-path response and
4182///   DOES carry them.) Unrouted traffic (404) never matches the route
4183///   at all.
4184/// - The gateway is the source of truth for the headers it is configured
4185///   to emit: an upstream-sent `Deprecation`/`Sunset` on a route WITH a
4186///   `deprecation` block is replaced; on a route WITHOUT one, upstream
4187///   values pass through untouched. `Link` is appended (a list header —
4188///   upstream links survive).
4189/// - Validation rejects: a block with neither `since` nor `sunset` (no
4190///   effect — omit it), dates that are not IMF-fixdate, a `sunset`
4191///   already in the past (advertising a removal that already happened;
4192///   remove the route or extend the date — this also stops a long-lived
4193///   generation from silently re-publishing a stale sunset), a `sunset`
4194///   before `since` (removed before deprecated), and a `uri` without
4195///   `since` (the link documents the `Deprecation` header).
4196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4197#[serde(deny_unknown_fields)]
4198pub struct Deprecation {
4199    /// When this API version was (or will be) deprecated, as an
4200    /// IMF-fixdate HTTP-date (`Sun, 06 Nov 1994 08:49:37 GMT`). Emits
4201    /// `Deprecation: @<unix-seconds>` (RFC 9745). A past date is normal
4202    /// (the deprecation is in effect); a date before 1970 cannot form
4203    /// the structured date and is rejected.
4204    #[serde(skip_serializing_if = "Option::is_none")]
4205    pub since: Option<String>,
4206    /// When this API version is expected to stop working, as an
4207    /// IMF-fixdate HTTP-date. Emits `Sunset` (RFC 8594) verbatim.
4208    /// Validation rejects a date already in the past and a date before
4209    /// `since`.
4210    #[serde(skip_serializing_if = "Option::is_none")]
4211    pub sunset: Option<String>,
4212    /// URI of the deprecation/migration notice (absolute `http(s)` URL).
4213    /// Requires `since` — the RFC 9745 `Link; rel="deprecation"` it
4214    /// emits documents the `Deprecation` header.
4215    #[serde(skip_serializing_if = "Option::is_none")]
4216    pub uri: Option<String>,
4217}
4218
4219/// Snapshot-compiled form of a [`Deprecation`] block (DW-048): the
4220/// emitted header VALUES, precomputed at snapshot-compile time — the
4221/// HTTP-date strings are parsed once here (never per response) and the
4222/// RFC 9745 structured date is rendered from the parsed seconds. Lives
4223/// in `config` beside [`CompiledCorsOrigins`] for the same reason:
4224/// `snapshot` builds it into the route table and `dataplane::versioning`
4225/// consumes it. Validation has already rejected unparseable dates, so
4226/// compilation drops nothing on any publishable config; entries that
4227/// would fail are skipped exactly like the CORS matcher's fallback.
4228#[derive(Debug, Clone, PartialEq, Eq)]
4229pub struct CompiledDeprecation {
4230    deprecation: Option<String>,
4231    sunset: Option<String>,
4232    link: Option<String>,
4233}
4234
4235impl CompiledDeprecation {
4236    /// Compile a (validated) policy's header values.
4237    pub fn compile(dep: &Deprecation) -> Self {
4238        CompiledDeprecation {
4239            deprecation: dep
4240                .since
4241                .as_deref()
4242                .and_then(versioning::parse_http_date)
4243                .filter(|d| d.unix_seconds() >= 0)
4244                .map(|d| format!("@{}", d.unix_seconds())),
4245            sunset: dep
4246                .sunset
4247                .as_deref()
4248                .filter(|s| versioning::parse_http_date(s).is_some())
4249                .map(str::to_string),
4250            link: dep
4251                .uri
4252                .as_deref()
4253                .map(|uri| format!("<{uri}>; rel=\"deprecation\"")),
4254        }
4255    }
4256
4257    /// The `Deprecation` header value (`@<unix-seconds>`), if the policy
4258    /// sets `since`.
4259    pub fn deprecation_header(&self) -> Option<&str> {
4260        self.deprecation.as_deref()
4261    }
4262
4263    /// The `Sunset` header value (the validated config string, verbatim),
4264    /// if the policy sets `sunset`.
4265    pub fn sunset_header(&self) -> Option<&str> {
4266        self.sunset.as_deref()
4267    }
4268
4269    /// The `Link` header value (`<uri>; rel="deprecation"`), if the
4270    /// policy sets `uri`.
4271    pub fn link_header(&self) -> Option<&str> {
4272        self.link.as_deref()
4273    }
4274}
4275
4276/// Route-level authorization rules (DW-020, feature analysis 4.7).
4277///
4278/// Rule semantics (frozen here; evaluation lives in the `authz` module):
4279///
4280/// - `denied_consumers` / `denied_groups` beat `allowed_*` at the SAME
4281///   level: within one [`Authz`], a deny always wins a tie.
4282/// - `allowed_consumers`, when non-empty, is a closed set: the
4283///   authenticated consumer must be listed. Empty = any authenticated
4284///   consumer passes the consumer rule.
4285/// - `allowed_groups`, when non-empty, requires the consumer to be a
4286///   member of at least one listed group. Group membership comes from
4287///   the CONFIG consumer's `groups` field; store-only consumers
4288///   (DWARA_STATE_DB deployments whose consumer has no config entry)
4289///   have no groups and therefore never satisfy an `allowed_groups`
4290///   rule (documented limitation until the state store carries groups).
4291/// - `required_scopes`: every listed scope must appear in the JWT
4292///   `scope` claim. The claim may be a space-separated string
4293///   (`"read write"`, the OAuth convention) or a JSON array of strings
4294///   (`["read", "write"]`, joined to its space-separated form when the
4295///   identity's claims are captured in `authn`); non-JWT identities
4296///   (API key / Basic) carry no claims and never satisfy scope rules.
4297/// - `required_claims`: exact string equality on the stringified claim
4298///   value; a claim absent from the token fails the match. Only
4299///   string- and number-valued claims are captured on the identity
4300///   (see `authn`), so a claim that is a JSON `true`/`false`, `null`,
4301///   object, or nested structure can NEVER satisfy a `required_claims`
4302///   entry — there is no stringified form to compare. Comparisons are
4303///   CASE-SENSITIVE throughout: consumer names, groups, scopes, and
4304///   claim values must match byte-for-byte.
4305/// - `ip_acl`: evaluated against the EFFECTIVE client IP — the
4306///   `X-Forwarded-For`-resolved client when the direct peer is inside
4307///   `gateway.trusted_proxies` (DW-009 chain), otherwise the direct
4308///   peer. See [`IpAcl`].
4309///
4310/// Authentication implication: an [`Authz`] carrying ANY identity rule
4311/// (consumer/group/scope/claim) rejects anonymous requests with 401; an
4312/// `ip_acl`-only [`Authz`] is the one authorization shape that can
4313/// ADMIT anonymous traffic (from an IP the ACL allows). A denial of an
4314/// AUTHENTICATED request is 403 (forbidden), never 401.
4315#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4316#[serde(deny_unknown_fields)]
4317pub struct Authz {
4318    /// Consumers allowed to call the route (empty = any authenticated).
4319    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4320    pub allowed_consumers: Vec<String>,
4321    /// Consumers explicitly rejected, even when otherwise allowed.
4322    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4323    pub denied_consumers: Vec<String>,
4324    /// Groups allowed to call the route (empty = no group constraint).
4325    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4326    pub allowed_groups: Vec<String>,
4327    /// Groups explicitly rejected, even when otherwise allowed.
4328    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4329    pub denied_groups: Vec<String>,
4330    /// JWT scopes (from the `scope` claim) every request must carry.
4331    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4332    pub required_scopes: Vec<String>,
4333    /// Claims (name -> exact stringified value) every request must
4334    /// carry. A listed claim absent from the token's claims fails.
4335    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
4336    pub required_claims: std::collections::BTreeMap<String, String>,
4337    /// IP allow/deny gate on the effective client IP.
4338    #[serde(default, skip_serializing_if = "Option::is_none")]
4339    pub ip_acl: Option<IpAcl>,
4340    /// GeoIP gate on the effective client IP (DW-050): country/ASN
4341    /// allow and deny lists resolved through `gateway.geoip`'s
4342    /// database. An address the database does not resolve (private
4343    /// ranges, not-in-DB, no database loaded) is UNKNOWN and matches
4344    /// NEITHER list: deny-lists keep passing unknowns, allow-lists
4345    /// keep rejecting them. Requires a `gateway.geoip` block —
4346    /// validation rejects the predicate without one. See
4347    /// [`GeoipRules`].
4348    #[serde(default, skip_serializing_if = "Option::is_none")]
4349    pub geoip: Option<GeoipRules>,
4350    /// Monitor mode (DW-041): evaluate this block's rules, LOG and count
4351    /// every would-be denial (the `dwara_policy_dry_run_total{phase=
4352    /// "authz"}` counter plus a `dwara::policy` warn event), but let the
4353    /// request PROCEED as if allowed. The flag is per ATTACHMENT and
4354    /// mutes only this block's OWN denials: a LIVE deny at any other
4355    /// level of the chain still enforces (dry run never makes
4356    /// enforcement more permissive — the resolver walks past a dry deny
4357    /// and stops only at a live one). Route `auth_required` is an
4358    /// authentication-phase check, not an authorization rule, and is
4359    /// never muted by this flag.
4360    #[serde(default, skip_serializing_if = "is_false")]
4361    pub dry_run: bool,
4362}
4363
4364/// IP access control on the effective client IP (DW-020, feature
4365/// analysis 4.15). Entries are IP addresses (e.g. `10.1.2.3`) or CIDRs
4366/// (e.g. `10.0.0.0/8`); anything else fails config validation (the same
4367/// parser as `gateway.trusted_proxies`). Evaluation order: the `deny`
4368/// list first (a match rejects with 403 regardless of the allow list),
4369/// then the `allow` list, then `default` for IPs matched by neither.
4370///
4371/// A `/0` (all-addresses) entry such as `0.0.0.0/0` or `::/0` is
4372/// REJECTED by validation in the `allow` list: an allow-all entry
4373/// filters nothing and is always a mistake — the intended shape is an
4374/// empty allow list with `default: allow` (the ACL's default mode).
4375/// A `/0` in the `deny` list is meaningful (deny-all) and is accepted.
4376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4377#[serde(deny_unknown_fields)]
4378pub struct IpAcl {
4379    /// CIDRs/IPs allowed through the gate.
4380    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4381    pub allow: Vec<String>,
4382    /// CIDRs/IPs rejected; a deny match wins over any allow match.
4383    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4384    pub deny: Vec<String>,
4385    /// What happens to an IP matched by NEITHER list: `allow` (the
4386    /// default — the lists are exceptions) or `deny` (closed mode — only
4387    /// allow-listed IPs pass).
4388    #[serde(
4389        default = "default_ip_acl_default",
4390        skip_serializing_if = "is_default_ip_acl_default"
4391    )]
4392    pub default: IpAclDefault,
4393}
4394
4395/// Fallback decision of an [`IpAcl`] for IPs matched by neither list.
4396#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4397#[serde(rename_all = "lowercase")]
4398pub enum IpAclDefault {
4399    /// Unmatched IPs pass the IP gate (the default).
4400    Allow,
4401    /// Unmatched IPs are rejected: only the allow list passes.
4402    Deny,
4403}
4404
4405fn default_ip_acl_default() -> IpAclDefault {
4406    IpAclDefault::Allow
4407}
4408
4409fn is_default_ip_acl_default(d: &IpAclDefault) -> bool {
4410    *d == IpAclDefault::Allow
4411}
4412
4413/// Route-scoped CORS policy (DW-027, feature analysis 4.14).
4414///
4415/// Semantics (frozen; evaluation lives in `dataplane::cors`):
4416///
4417/// - `allowed_origins` is a closed set of exact origins
4418///   (`https://api.example.com`, scheme + host + optional port, compared
4419///   in normalized form: lowercase scheme/host, default port dropped) or
4420///   the single entry `*` (any origin). It must be non-empty; `*`
4421///   combined with `allow_credentials: true` is rejected by validation
4422///   (the Fetch spec forbids wildcard-credentialed responses).
4423/// - A PREFLIGHT (`OPTIONS` + `Origin` + `Access-Control-Request-Method`)
4424///   on a CORS-configured route is answered 204 by the gateway —
4425///   validated against the policy, never forwarded upstream, and never
4426///   subject to authn/authz/rate limiting (browsers send preflights
4427///   without credentials). A preflight that fails validation (origin,
4428///   method, or requested headers not allowed) still short-circuits:
4429///   204 with NO CORS headers, which the browser reads as a failed
4430///   preflight. A plain `OPTIONS` without the preflight markers is NOT
4431///   intercepted; it proxies normally.
4432/// - ACTUAL responses on the route carry `Access-Control-Allow-Origin`
4433///   (`*`, or the echoed request origin when the list is specific),
4434///   `Access-Control-Allow-Credentials: true` when configured,
4435///   `Access-Control-Expose-Headers` when configured, and `Vary: Origin`
4436///   (merged into any existing `Vary`). A request whose `Origin` is not
4437///   allowed simply gets no CORS headers — the response passes through
4438///   as-is (same-origin reads do not consult CORS at all).
4439#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4440#[serde(deny_unknown_fields)]
4441pub struct Cors {
4442    /// Origins allowed to call this route cross-origin: exact origin
4443    /// strings (`https://api.example.com`, `http://localhost:8080`) or
4444    /// `*` (any origin; never together with `allow_credentials`).
4445    pub allowed_origins: Vec<String>,
4446    /// Methods allowed for cross-origin requests (preflight echo and
4447    /// actual-request check). Default: the common API set
4448    /// GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS. Each entry must be a
4449    /// valid HTTP method token; comparison is case-insensitive.
4450    #[serde(default = "default_cors_methods")]
4451    pub allowed_methods: Vec<String>,
4452    /// Request headers clients may send: exact header names, or `*` to
4453    /// allow any requested header (never `*` together with
4454    /// `allow_credentials`). Validated against the preflight's
4455    /// `Access-Control-Request-Headers` list; empty means no headers
4456    /// beyond the CORS-safelisted set are permitted in preflight checks.
4457    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4458    pub allowed_headers: Vec<String>,
4459    /// Response headers exposed to the browser (readable by cross-origin
4460    /// client script). Absent: only the CORS-safelisted response headers
4461    /// are exposed.
4462    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4463    pub expose_headers: Vec<String>,
4464    /// Allow credentialed cross-origin requests (cookies, TLS client
4465    /// certificates). Default false. When true, the origin list must be
4466    /// specific (`*` is rejected by validation) and responses echo the
4467    /// request origin rather than sending `*`.
4468    #[serde(default, skip_serializing_if = "is_false")]
4469    pub allow_credentials: bool,
4470    /// `Access-Control-Max-Age` for preflight responses, in seconds (how
4471    /// long the browser may cache the preflight result). Absent: the
4472    /// header is omitted (each preflight hits the gateway).
4473    #[serde(default, skip_serializing_if = "Option::is_none")]
4474    pub max_age_secs: Option<u64>,
4475}
4476
4477fn default_cors_methods() -> Vec<String> {
4478    ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
4479        .iter()
4480        .map(|s| s.to_string())
4481        .collect()
4482}
4483
4484/// Canonical form of one origin (DW-027): lowercase scheme and host,
4485/// default port dropped, no userinfo, no path/query — the exact string
4486/// the runtime CORS matcher compares request `Origin` values against
4487/// and validation checks config entries with. `None` when the input is
4488/// not a well-formed `http(s)` origin. Lives in `config` (the lowest
4489/// consuming domain) so validation (`snapshot::validate`) and matching
4490/// (`dataplane::cors`) share one grammar, the same way `net.rs` owns the
4491/// IP/CIDR grammar.
4492pub fn normalize_origin(origin: &str) -> Option<String> {
4493    let uri: hyper::Uri = origin.parse().ok()?;
4494    let scheme = uri.scheme()?;
4495    if !matches!(scheme.as_str(), "http" | "https") {
4496        return None;
4497    }
4498    // Browsers never serialize userinfo into an Origin (the grammar is
4499    // scheme "://" host [":" port]); authority userinfo is a config
4500    // authoring error or a smuggle attempt, never a real origin.
4501    if uri.authority().is_some_and(|a| a.as_str().contains('@')) {
4502        return None;
4503    }
4504    let host = uri.host()?;
4505    let default_port = match scheme.as_str() {
4506        "http" => 80,
4507        _ => 443,
4508    };
4509    let port = uri
4510        .port_u16()
4511        .and_then(|p| (p != default_port).then_some(p));
4512    // An origin carries no path, query, or fragment; tolerate only the
4513    // empty path "/" (browsers serialize the origin WITHOUT it, but
4514    // configs hand-writing "https://host/" are unambiguous).
4515    match uri.path_and_query() {
4516        None => {}
4517        Some(pq) if pq.as_str() == "/" => {}
4518        _ => return None,
4519    }
4520    Some(match port {
4521        Some(p) => format!("{}://{}:{}", scheme.as_str(), host.to_lowercase(), p),
4522        None => format!("{}://{}", scheme.as_str(), host.to_lowercase()),
4523    })
4524}
4525
4526/// Snapshot-compiled form of a `cors.allowed_origins` list (DW-027): the
4527/// wildcard flag plus the normalized form of every specific entry,
4528/// computed once at snapshot-compile time so the request path never
4529/// re-parses config strings (per-request work is the request's own
4530/// origin only). Lives in `config` beside [`normalize_origin`] because
4531/// the compiled set is the grammar's runtime consumer: `snapshot`
4532/// builds it into the route table (`snapshot::RouteTable`) and
4533/// `dataplane::cors` matches against it. Validation has already
4534/// rejected entries that do not normalize, so compilation drops
4535/// nothing on any publishable config; entries that would fail
4536/// normalization are skipped exactly as the pre-compiled matcher was.
4537#[derive(Debug, Clone, PartialEq, Eq)]
4538pub struct CompiledCorsOrigins {
4539    wildcard: bool,
4540    origins: Vec<String>,
4541}
4542
4543impl CompiledCorsOrigins {
4544    /// Compile a (validated) policy's origin list.
4545    pub fn compile(cors: &Cors) -> Self {
4546        CompiledCorsOrigins {
4547            wildcard: cors.allowed_origins.iter().any(|o| o == "*"),
4548            origins: cors
4549                .allowed_origins
4550                .iter()
4551                .filter(|o| *o != "*")
4552                .filter_map(|o| normalize_origin(o))
4553                .collect(),
4554        }
4555    }
4556
4557    /// Does a request `Origin` value match this set? `*` matches any
4558    /// origin string; otherwise normalized exact match (the same
4559    /// comparison the pre-compiled matcher ran, without re-normalizing
4560    /// the config side per request).
4561    pub fn allows(&self, origin: &str) -> bool {
4562        if self.wildcard {
4563            return true;
4564        }
4565        let Some(request_origin) = normalize_origin(origin) else {
4566            return false;
4567        };
4568        self.origins.iter().any(|o| o == &request_origin)
4569    }
4570
4571    /// Was the list the single entry `*`?
4572    pub fn wildcard(&self) -> bool {
4573        self.wildcard
4574    }
4575}
4576
4577/// Route-scoped response compression policy (DW-027, feature analysis
4578/// 4.13).
4579///
4580/// Semantics (frozen; negotiation and encoding live in
4581/// `dataplane::compression`):
4582///
4583/// - `algorithms` is the PREFERENCE order: the first entry the request's
4584///   `Accept-Encoding` accepts wins. `q=0` entries in `Accept-Encoding`
4585///   are treated as refusal.
4586/// - `level` is clamped per algorithm at encode time (gzip 0-9, brotli
4587///   0-11, zstd 0-22; validation bounds the field to 0-22). Absent: the
4588///   algorithm's library default.
4589/// - `min_size` skips compression of responses whose size is known and
4590///   below it — a declared `Content-Length` or the exact size of the
4591///   gateway's own respond/redirect bodies. Responses of UNKNOWN length
4592///   (streaming) are always candidates.
4593/// - `content_types` (when non-empty) restricts compression to responses
4594///   whose `Content-Type` starts with one of the entries; entries are
4595///   lowercase prefix matches (e.g. `text/`). `excluded_content_types`
4596///   is checked after the include list and wins (the way to express
4597///   "compress everything text/ except `text/event-stream`").
4598/// - Responses carrying an existing `Content-Encoding`, 1xx/204/304
4599///   statuses, zero-length bodies, and 101 upgrade responses are never
4600///   compressed. `Vary: Accept-Encoding` is merged into every response
4601///   on a compression-configured route that is not already encoded —
4602///   compressed or not — so caches key correctly.
4603/// - The compressed body streams chunk-by-chunk with a flush per chunk
4604///   (streaming/SSE-friendly, at a small compression-ratio cost); the
4605///   gateway never buffers the whole body to compress it. The frame
4606///   codec's internal buffer is bounded by the chunk size.
4607#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4608#[serde(deny_unknown_fields)]
4609pub struct Compression {
4610    /// Enabled algorithms in preference order (default gzip, brotli,
4611    /// zstd). Must be non-empty and duplicate-free (validation).
4612    #[serde(default = "default_compression_algorithms")]
4613    pub algorithms: Vec<CompressionAlgorithm>,
4614    /// Compression level; clamped per algorithm (gzip <= 9, brotli <= 11,
4615    /// zstd <= 22). Absent: library default per algorithm.
4616    #[serde(default, skip_serializing_if = "Option::is_none")]
4617    pub level: Option<u8>,
4618    /// Minimum response size (bytes) to compress, applied whenever the
4619    /// size is known: a declared `Content-Length` or the body's exact
4620    /// size (the gateway's own respond/redirect bodies, which carry no
4621    /// `Content-Length`). Below it the response passes through (still
4622    /// carrying `Vary: Accept-Encoding`). Default 1024. Responses of
4623    /// unknown length (streaming) are always candidates.
4624    #[serde(default = "default_compression_min_size")]
4625    pub min_size: u64,
4626    /// Content-Type prefix include list (lowercase, e.g. `text/`,
4627    /// `application/json`); empty = every content type is a candidate.
4628    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4629    pub content_types: Vec<String>,
4630    /// Content-Type prefix exclude list, checked after `content_types`;
4631    /// a match passes the response through uncompressed.
4632    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4633    pub excluded_content_types: Vec<String>,
4634}
4635
4636fn default_compression_algorithms() -> Vec<CompressionAlgorithm> {
4637    vec![
4638        CompressionAlgorithm::Gzip,
4639        CompressionAlgorithm::Brotli,
4640        CompressionAlgorithm::Zstd,
4641    ]
4642}
4643
4644fn default_compression_min_size() -> u64 {
4645    1024
4646}
4647
4648/// One response compression algorithm (DW-027).
4649#[derive(
4650    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
4651)]
4652#[serde(rename_all = "lowercase")]
4653pub enum CompressionAlgorithm {
4654    Gzip,
4655    Brotli,
4656    Zstd,
4657}
4658
4659impl CompressionAlgorithm {
4660    /// The `Accept-Encoding` / `Content-Encoding` token of the algorithm.
4661    pub fn encoding_token(self) -> &'static str {
4662        match self {
4663            CompressionAlgorithm::Gzip => "gzip",
4664            CompressionAlgorithm::Brotli => "br",
4665            CompressionAlgorithm::Zstd => "zstd",
4666        }
4667    }
4668}
4669
4670/// Snapshot-compiled form of a compression policy's content-type prefix
4671/// lists (DW-027): every include/exclude entry trimmed and lowercased
4672/// once at snapshot-compile time; the request path compares the
4673/// response's (lowercased) media type against the precomputed prefixes
4674/// instead of re-normalizing config strings per response. Same
4675/// placement rationale as [`CompiledCorsOrigins`].
4676#[derive(Debug, Clone, PartialEq, Eq)]
4677pub struct CompiledContentTypeFilter {
4678    include: Vec<String>,
4679    exclude: Vec<String>,
4680}
4681
4682impl CompiledContentTypeFilter {
4683    /// Compile a (validated) policy's prefix lists.
4684    pub fn compile(policy: &Compression) -> Self {
4685        fn normalize_list(list: &[String]) -> Vec<String> {
4686            list.iter().map(|p| p.trim().to_lowercase()).collect()
4687        }
4688        CompiledContentTypeFilter {
4689            include: normalize_list(&policy.content_types),
4690            exclude: normalize_list(&policy.excluded_content_types),
4691        }
4692    }
4693
4694    /// Is `media_type` (lowercase, parameters stripped) a compression
4695    /// candidate? An empty include list admits every type; the exclude
4696    /// list is checked after it and wins.
4697    pub fn allows(&self, media_type: &str) -> bool {
4698        (self.include.is_empty() || self.include.iter().any(|p| media_type.starts_with(p)))
4699            && !self.exclude.iter().any(|p| media_type.starts_with(p))
4700    }
4701}
4702
4703/// Route-scoped request size limits (DW-027, feature analysis 4.16).
4704///
4705/// Semantics (frozen; enforcement lives in `dataplane::hardening`):
4706///
4707/// - Header limits (`max_header_count`, `max_header_bytes`) are checked
4708///   immediately after route resolution, before CORS preflight handling
4709///   and authentication; a violation answers 431 with the JSON error
4710///   envelope. `max_header_bytes` is the SUM of all header name and
4711///   value bytes of the request (hyper's per-connection buffer limits
4712///   still apply beneath this, process-wide).
4713/// - `max_body_bytes`: a request whose `Content-Length` declares more is
4714///   rejected 413 before any upstream contact. A body of UNKNOWN length
4715///   (chunked / h2 without content-length) is wrapped in a counting
4716///   guard: crossing the cap aborts the request (the upstream attempt
4717///   fails; the client receives 413 when the failure precedes the
4718///   upstream's response headers, a torn stream otherwise).
4719/// - A block with NO field set is rejected by validation (always an
4720///   authoring mistake; omit the block).
4721#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4722#[serde(deny_unknown_fields)]
4723pub struct RequestLimits {
4724    /// Maximum request body size in bytes (declared via `Content-Length`
4725    /// or enforced streaming). Absent: unlimited.
4726    #[serde(default, skip_serializing_if = "Option::is_none")]
4727    pub max_body_bytes: Option<u64>,
4728    /// Maximum number of header fields on the request. Absent:
4729    /// unlimited (hyper's parser bound applies).
4730    #[serde(default, skip_serializing_if = "Option::is_none")]
4731    pub max_header_count: Option<u32>,
4732    /// Maximum total size of request headers (names + values, bytes).
4733    /// Absent: unlimited.
4734    #[serde(default, skip_serializing_if = "Option::is_none")]
4735    pub max_header_bytes: Option<u64>,
4736    /// Monitor mode (DW-041): evaluate the limits, LOG and count every
4737    /// would-be rejection (`dwara_policy_dry_run_total{phase="route_
4738    /// limits"}` + a `dwara::policy` warn event), but let the request
4739    /// PROCEED — including its body: the streaming `max_body_bytes` guard
4740    /// is not armed, so a chunked body that would have been aborted mid-
4741    /// stream streams through (only the cheap up-front checks — header
4742    /// count/bytes, a declared `Content-Length` — are observable in dry
4743    /// run). The staging tool for turning limits on against live traffic
4744    /// without breaking clients.
4745    #[serde(default, skip_serializing_if = "is_false")]
4746    pub dry_run: bool,
4747}
4748
4749/// Matching rules for incoming requests.
4750#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4751#[serde(deny_unknown_fields)]
4752pub struct RouteMatch {
4753    pub path: PathMatch,
4754    /// Exact host match (e.g. `api.example.com`).
4755    #[serde(skip_serializing_if = "Option::is_none")]
4756    pub host: Option<String>,
4757    /// Allowed HTTP methods; empty means all methods.
4758    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4759    pub methods: Vec<String>,
4760    /// Exact header matches.
4761    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
4762    pub headers: std::collections::BTreeMap<String, String>,
4763    /// Query-parameter matches; every entry must match (AND). Name-only
4764    /// entries match on presence; a `value` requires an exact raw match (no
4765    /// percent-decoding in v1).
4766    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4767    pub query: Vec<NameValueMatch>,
4768    /// Cookie matches (parsed from the `Cookie` header); every entry must
4769    /// match (AND). Name-only entries match on presence; a `value` requires
4770    /// an exact match (no cookie-unquoting in v1).
4771    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4772    pub cookies: Vec<NameValueMatch>,
4773    /// Media-type version selection (DW-048): a bare `type/subtype` the
4774    /// request's `Accept` header must name explicitly, e.g.
4775    /// `application/vnd.acme.v2+json`. A comma-separated Accept list
4776    /// matches on ANY entry; parameters and q-values on the request side
4777    /// are ignored; wildcard entries (`*/*`, `type/*`) and a missing
4778    /// Accept header never match — version selection requires the client
4779    /// to NAME the version, so unconstrained clients fall through to the
4780    /// route without this criterion (the unversioned default). Like every
4781    /// non-path criterion this is AND-ed and applied AFTER path
4782    /// resolution: a miss does not fall through to another route (404),
4783    /// and two routes cannot share one path to offer multiple versions —
4784    /// version families use distinct paths (`/v1/`, `/v2/`) or this
4785    /// criterion on a versioned path. Validation rejects anything but a
4786    /// bare lowercase-able `type/subtype` (`config::versioning` owns the
4787    /// grammar). Responses of a matched route carry `Vary: Accept`
4788    /// (merged), so shared caches key on the negotiated representation.
4789    #[serde(default, skip_serializing_if = "Option::is_none")]
4790    pub accept: Option<String>,
4791}
4792
4793/// One query-parameter or cookie criterion: the parameter/cookie must be
4794/// present; when `value` is given it must equal that exact string.
4795#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4796#[serde(deny_unknown_fields)]
4797pub struct NameValueMatch {
4798    pub name: String,
4799    /// Exact value required; `None` means "present is enough".
4800    #[serde(skip_serializing_if = "Option::is_none")]
4801    pub value: Option<String>,
4802}
4803
4804/// How a route's path pattern is interpreted.
4805#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4806#[serde(deny_unknown_fields)]
4807pub struct PathMatch {
4808    #[serde(rename = "type")]
4809    pub kind: PathMatchKind,
4810    /// The pattern value, e.g. `/v1/users` or `/v1/.*`.
4811    pub value: String,
4812}
4813
4814#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4815#[serde(rename_all = "lowercase")]
4816pub enum PathMatchKind {
4817    Exact,
4818    Prefix,
4819    Regex,
4820}
4821
4822/// What the gateway does when a route matches.
4823#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4824#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
4825pub enum RouteAction {
4826    /// Forward to the route's service (its upstream).
4827    ///
4828    /// `rewrite` (at most ONE per action in v1) is applied to the inbound
4829    /// path before the request is sent upstream; the query string is always
4830    /// preserved verbatim.
4831    Proxy {
4832        #[serde(skip_serializing_if = "Option::is_none")]
4833        rewrite: Option<PathRewrite>,
4834    },
4835    Redirect {
4836        #[serde(skip_serializing_if = "Option::is_none")]
4837        scheme: Option<String>,
4838        #[serde(skip_serializing_if = "Option::is_none")]
4839        host: Option<String>,
4840        #[serde(skip_serializing_if = "Option::is_none")]
4841        path: Option<String>,
4842        status: u16,
4843    },
4844    Respond {
4845        status: u16,
4846        #[serde(skip_serializing_if = "Option::is_none")]
4847        body: Option<String>,
4848        /// Extra response headers (name -> value), emitted verbatim.
4849        #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
4850        headers: std::collections::BTreeMap<String, String>,
4851    },
4852    /// Serve a canned response without contacting any upstream (DW-047
4853    /// mock mode). The route's `service` is still required by the
4854    /// schema (the frozen vocabulary), but a mock route never dials
4855    /// it — the whole point is serving without a backend. Pair with
4856    /// `request_validation` to mock a write endpoint that still
4857    /// rejects malformed bodies. See [`MockAction`].
4858    Mock {
4859        #[serde(flatten)]
4860        mock: MockAction,
4861    },
4862    /// AI provider translation (DW-075): the request body is parsed as
4863    /// an OpenAI chat-completions request, its `model` resolved through
4864    /// the gateway's `ai:` block to a provider, translated to that
4865    /// provider's wire format (OpenAI/Anthropic/Gemini), sent through
4866    /// the provider's upstream, and the response translated back to the
4867    /// OpenAI shape. The route's `service` is still required by the
4868    /// schema but never dialed. Requires an `ai:` block (validation
4869    /// rejects the pairing otherwise). `stream: true` requests are
4870    /// served via zero-buffer SSE pass-through (DW-077).
4871    ///
4872    /// AI-02: the `endpoint` field selects which AI endpoint the route
4873    /// serves. The default is `chat` (the existing behavior). Other
4874    /// endpoints (`embeddings`, `images`, `audio`, `moderation`) are
4875    /// proxied as a passthrough: the request body is forwarded to the
4876    /// provider's upstream as-is (no adapter translation), and the
4877    /// response is returned as-is. Model governance (allowlist check)
4878    /// still applies using the `model` field from the request body.
4879    Ai {
4880        #[serde(default)]
4881        endpoint: AiEndpoint,
4882        /// AI-05 (#195): The client-facing request dialect. Defaults
4883        /// to `openai` (the historical behavior). When set to
4884        /// `anthropic` or `gemini`, the gateway parses the incoming
4885        /// request using that dialect's native format and translates
4886        /// to the canonical `ChatRequest`. When set to `passthrough`,
4887        /// the request body is forwarded to the provider as-is (no
4888        /// translation, still metered/governed).
4889        #[serde(default)]
4890        dialect: AiIngressDialect,
4891    },
4892    /// WASM route handler (nano-service, DW-106): instead of proxying
4893    /// to an upstream, the route action runs a WASM module that
4894    /// generates the response directly. The module implements a simple
4895    /// request->response handler ABI over the existing wasmtime runtime
4896    /// (compiled into every build; no cargo feature). The route's
4897    /// `service` is still
4898    /// required by the schema (the frozen vocabulary) but never dialed
4899    /// -- the whole point is serving without a backend. See
4900    /// [`NanoServiceAction`].
4901    NanoService {
4902        #[serde(flatten)]
4903        nano: NanoServiceAction,
4904    },
4905}
4906
4907/// AI-05 (#195): The client-facing request dialect for an AI route.
4908/// Determines how the gateway parses the incoming request body.
4909#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4910#[serde(rename_all = "snake_case")]
4911pub enum AiIngressDialect {
4912    /// OpenAI chat-completions shape (the historical default). The
4913    /// request is parsed by `openai_compat::parse_chat_request`.
4914    #[default]
4915    Openai,
4916    /// Anthropic Messages API shape. The request is parsed by
4917    /// `ingress::parse_anthropic_request` and translated to the
4918    /// canonical `ChatRequest`.
4919    Anthropic,
4920    /// Gemini generateContent shape. The request is parsed by
4921    /// `ingress::parse_gemini_request` and translated to the
4922    /// canonical `ChatRequest`.
4923    Gemini,
4924    /// Passthrough mode: the request body is forwarded to the
4925    /// provider as-is (no translation, still metered/governed).
4926    /// The response is returned as-is.
4927    Passthrough,
4928}
4929
4930/// AI-02: which AI endpoint a route serves. The `chat` endpoint (the
4931/// default) uses the full adapter translation pipeline (DW-075). The
4932/// other endpoints are proxied as a passthrough: the request body is
4933/// forwarded to the provider's upstream as-is, and the response is
4934/// returned as-is. Model governance (allowlist check) still applies
4935/// using the `model` field from the request body.
4936#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
4937#[serde(rename_all = "snake_case")]
4938pub enum AiEndpoint {
4939    /// OpenAI chat-completions (`POST /v1/chat/completions`). The
4940    /// full adapter translation pipeline applies (DW-075). This is the
4941    /// default.
4942    #[default]
4943    Chat,
4944    /// OpenAI embeddings (`POST /v1/embeddings`). Proxied as a
4945    /// passthrough; the request body `{model, input}` is forwarded
4946    /// as-is. Token usage from the response is recorded for budget
4947    /// accounting.
4948    Embeddings,
4949    /// OpenAI images (`POST /v1/images/generations`). Proxied as a
4950    /// passthrough; no token usage (image generation is priced per
4951    /// image, not per token).
4952    Images,
4953    /// OpenAI audio (TTS `POST /v1/audio/speech`, transcription
4954    /// `POST /v1/audio/transcriptions`). Proxied as a passthrough.
4955    Audio,
4956    /// OpenAI moderation (`POST /v1/moderations`). Proxied as a
4957    /// passthrough.
4958    Moderation,
4959    /// AI-04 (#194): OpenAI Batch API (`POST /v1/batches`). Proxied
4960    /// as a passthrough for batch creation/status/cancel. When batch
4961    /// results are retrieved (`GET /v1/batches/{id}/output`), the
4962    /// JSONL result file is parsed and each line's `usage` is metered
4963    /// at the batch (50% discount) rate.
4964    Batch,
4965}
4966
4967/// A WASM route handler (nano-service) action (DW-106,
4968/// `routes[].action` with `type: nano_service`). The gateway loads the
4969/// named WASM module, calls its `handle` export with the serialized
4970/// request (method, path, headers, body), and returns the response the
4971/// module produces (status, headers, body). No upstream is contacted.
4972///
4973/// `module` is the filesystem path to the `.wasm` module (checked for
4974/// existence and readability at config publish time). `memory_limit` is
4975/// the maximum linear memory in bytes the module may allocate (must be
4976/// positive and at most 64 MiB). `execution_timeout_ms` is the maximum
4977/// wall-clock time the `handle` call may run (must be positive and at
4978/// most 5000ms); a module that exceeds it is interrupted and the route
4979/// answers 504.
4980#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
4981#[serde(deny_unknown_fields)]
4982pub struct NanoServiceAction {
4983    /// Filesystem path to the `.wasm` module. Must exist and be
4984    /// readable at config publish time.
4985    pub module: String,
4986    /// Maximum linear memory the module may allocate, in bytes. Must be
4987    /// > 0 and <= 67108864 (64 MiB). Default: 1048576 (1 MiB).
4988    #[serde(default = "default_nano_memory_limit")]
4989    pub memory_limit: usize,
4990    /// Maximum wall-clock execution time for the `handle` call, in
4991    /// milliseconds. Must be > 0 and <= 5000. Default: 100.
4992    #[serde(default = "default_nano_execution_timeout_ms")]
4993    pub execution_timeout_ms: u64,
4994}
4995
4996fn default_nano_memory_limit() -> usize {
4997    1_048_576
4998}
4999
5000fn default_nano_execution_timeout_ms() -> u64 {
5001    100
5002}
5003
5004/// A canned response served without any upstream contact (DW-047,
5005/// `routes[].action.mock`). The body comes from either `body` (an
5006/// inline string) or `body_file` (a path relative to the config file
5007/// directory); exactly one of the two may be set, or neither for an
5008/// empty body. `delay_ms` simulates latency (useful for testing
5009/// timeouts and retries against a mock). Validation rejects a status
5010/// outside 100..=599, a `delay_ms` above 30000, and setting both
5011/// `body` and `body_file`.
5012#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5013#[serde(deny_unknown_fields)]
5014pub struct MockAction {
5015    /// HTTP status code (100..=599).
5016    pub status: u16,
5017    /// Extra response headers (name -> value), emitted verbatim. When
5018    /// `content-type` is not set here, the gateway defaults it to
5019    /// `application/json` if the body parses as JSON, else `text/plain`.
5020    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
5021    pub headers: std::collections::BTreeMap<String, String>,
5022    /// Inline response body. Mutually exclusive with `body_file`.
5023    #[serde(default, skip_serializing_if = "Option::is_none")]
5024    pub body: Option<String>,
5025    /// Path to a file whose contents are served as the body, read at
5026    /// config publish time (the bytes are held in the snapshot). The
5027    /// path is relative to the config file directory. Mutually
5028    /// exclusive with `body`.
5029    #[serde(default, skip_serializing_if = "Option::is_none")]
5030    pub body_file: Option<String>,
5031    /// Simulated latency in milliseconds (0..=30000). The response is
5032    /// delayed by this long before the status/headers/body are sent.
5033    #[serde(default, skip_serializing_if = "Option::is_none")]
5034    pub delay_ms: Option<u32>,
5035}
5036
5037/// Path rewrite applied before proxying (DW-010). Exactly one variant per
5038/// proxy action; there is no rewrite chaining in v1. All variants operate
5039/// on the path component only — the inbound query string is re-attached
5040/// untouched.
5041#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5042#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
5043pub enum PathRewrite {
5044    /// Strip the route's matched prefix (the `match.path.value` with
5045    /// trailing slashes trimmed) from the front of the request path.
5046    /// Meaningful for prefix-kind routes; for other kinds it strips the
5047    /// pattern's byte length when the path starts with the pattern value
5048    /// and is a no-op otherwise. If nothing remains (or the remainder
5049    /// lacks a leading `/`), the result is normalized to `/rest`.
5050    StripPrefix {},
5051    /// Replace a literal prefix: when the request path starts with
5052    /// `prefix`, that prefix is replaced by `replacement`; otherwise this
5053    /// rewrite is a no-op (the path is forwarded unchanged).
5054    ReplacePrefix { prefix: String, replacement: String },
5055    /// Replace the FIRST regex match on the request path with
5056    /// `substitution`. Substitution references: `$1`..`$9` / `${n}` for
5057    /// capture groups of `pattern`; `$name` / `${name}` for named capture
5058    /// groups of `pattern`, falling back to path parameters captured by
5059    /// the route's `{param}` template. Unknown references expand to the
5060    /// empty string. The pattern must compile — checked at config compile
5061    /// time, never at request time.
5062    Regex {
5063        pattern: String,
5064        substitution: String,
5065    },
5066}
5067
5068/// The logical API being exposed: base path, version, default policies;
5069/// targets exactly one upstream, or a weighted split of upstreams
5070/// since DW-040 (canary / blue-green).
5071#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5072#[serde(deny_unknown_fields)]
5073pub struct Service {
5074    pub name: String,
5075    /// Name of the upstream this service targets. Exactly one of
5076    /// `upstream` and [`Service::split`] must be set: a single-target
5077    /// service names its pool here; a canary/blue-green service uses
5078    /// `split` instead and leaves this unset. Optional since DW-040
5079    /// (it was the only shape before).
5080    #[serde(default, skip_serializing_if = "Option::is_none")]
5081    pub upstream: Option<String>,
5082    /// Weighted traffic split across upstreams (DW-040, canary and
5083    /// blue-green): requests for this service are dispatched to one of
5084    /// the named upstreams by a deterministic weighted pick (the
5085    /// sticky cookie's value when [`Service::sticky`] is configured
5086    /// and the request carries one — sessions stay on their branch;
5087    /// otherwise the request id — per-request distribution whose
5088    /// ratios converge on the weights statistically). A weight of 0
5089    /// parks an upstream in the split without serving traffic (the
5090    /// blue-green switch: flip the weights and re-publish — the next
5091    /// request dispatches by the new generation, no restart). Ramp
5092    /// rule: change weights by RE-BALANCING (keep the total constant,
5093    /// e.g. 95/5 -> 90/10) — growing one side alone changes the pick's
5094    /// modulus and reshuffles every session's branch.
5095    #[serde(default, skip_serializing_if = "Option::is_none")]
5096    pub split: Option<ServiceSplit>,
5097    /// Cookie affinity (DW-040): when set, the gateway sets a cookie
5098    /// on responses for this service whose VALUE consistently selects
5099    /// the split branch (and, when the branch upstream runs the
5100    /// `ip_hash` balancer, the endpoint — the cookie value becomes the
5101    /// ring key, reusing the consistent-hash machinery). The value is
5102    /// an opaque affinity handle: not a secret, carrying no identity,
5103    /// and never trusted as one.
5104    #[serde(default, skip_serializing_if = "Option::is_none")]
5105    pub sticky: Option<StickyAffinity>,
5106    /// Base path prefix the API is served under (e.g. `/v1`).
5107    #[serde(skip_serializing_if = "Option::is_none")]
5108    pub base_path: Option<String>,
5109    #[serde(skip_serializing_if = "Option::is_none")]
5110    pub version: Option<String>,
5111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5112    pub policies: Vec<String>,
5113    /// Service-level authorization rules (#123): applies to every request
5114    /// whose route targets this service. Link of the frozen chain
5115    /// consumer > route > service > listener > global (a deny at any
5116    /// level wins; otherwise the most specific level with rules governs —
5117    /// see [`Authz`] and the `authz` module). Same validation rules as
5118    /// route-level authorization.
5119    #[serde(default, skip_serializing_if = "Option::is_none")]
5120    pub authorization: Option<Authz>,
5121}
5122
5123/// Weighted traffic split across upstreams (DW-040,
5124/// `services[].split`): canary releases and blue-green switches at the
5125/// SERVICE level — each target is a full upstream (its own endpoints,
5126/// protocol, health, and balancer), so a canary pool is isolated from
5127/// the stable one by construction. The pick is a deterministic
5128/// weighted hash (see [`Service::split`]): ratios hold statistically
5129/// per request and exactly per session when sticky affinity is on.
5130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5131#[serde(deny_unknown_fields)]
5132pub struct ServiceSplit {
5133    /// The weighted targets. Validation enforces 2..=8 entries (one
5134    /// target is what `services[].upstream` expresses), existing
5135    /// upstream names, no duplicates, and a positive total weight (a
5136    /// total of 0 would route nowhere; individual zeros are the
5137    /// parked side of a blue-green pair).
5138    pub targets: Vec<SplitTarget>,
5139    /// Auto-canary analysis (DW-091): when set, a background
5140    /// controller compares the canary (second target) vs the baseline
5141    /// (first target) on error rate or latency and adjusts the split
5142    /// weights automatically. Only valid when the split has exactly 2
5143    /// targets (baseline + canary). Weight changes are transient
5144    /// (Generation swap) and revert on config reload.
5145    #[serde(default, skip_serializing_if = "Option::is_none")]
5146    pub canary_analysis: Option<CanaryAnalysis>,
5147}
5148
5149/// One weighted target of a service split (DW-040).
5150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5151#[serde(deny_unknown_fields)]
5152pub struct SplitTarget {
5153    /// Name of an upstream in this config.
5154    pub upstream: String,
5155    /// Relative share of requests (default 1). `0` parks the target
5156    /// (compiled, validated, serving nothing — the blue-green
5157    /// shape); validation caps the sum at a sane bound.
5158    #[serde(
5159        default = "default_split_weight",
5160        skip_serializing_if = "is_default_split_weight"
5161    )]
5162    pub weight: u32,
5163}
5164
5165fn default_split_weight() -> u32 {
5166    1
5167}
5168fn is_default_split_weight(w: &u32) -> bool {
5169    *w == 1
5170}
5171
5172/// Auto-canary analysis (DW-091): metrics-driven promotion/rollback of
5173/// canary split weights. A background controller compares the canary
5174/// side against the baseline on error rate or latency; on regression it
5175/// rolls the canary weight back (toward 0), on success it promotes
5176/// (toward the total weight). Severe regression (>2x the rollback
5177/// threshold) triggers an immediate rollback to 0. The total weight
5178/// stays constant: when the canary weight changes, the baseline
5179/// absorbs the difference, preserving the hash distribution for
5180/// existing sessions (the DW-040 invariant). Weight changes are
5181/// TRANSIENT (a Generation swap) and revert on config reload.
5182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5183#[serde(deny_unknown_fields)]
5184pub struct CanaryAnalysis {
5185    /// Master switch. Default true: an analysis block is active unless
5186    /// explicitly disabled.
5187    #[serde(default = "default_true")]
5188    pub enabled: bool,
5189    /// Look-back window for metrics, in seconds. The controller
5190    /// evaluates canary health over the recent window of observations.
5191    pub window_seconds: u64,
5192    /// Weight delta per adjustment (in weight units). Promote:
5193    /// increase canary weight by `step`. Rollback: decrease by `step`.
5194    /// Severe regression (>2x the rollback threshold) rolls back to 0
5195    /// immediately, ignoring the step.
5196    pub step: u32,
5197    /// Minimum total requests (baseline + canary) before the
5198    /// controller acts. Prevents acting on insufficient data.
5199    pub min_requests: u64,
5200    /// Cooldown between adjustments, in seconds. Prevents rapid
5201    /// oscillation: after one adjustment, the next is suppressed until
5202    /// the cooldown elapses.
5203    pub cooldown_seconds: u64,
5204    /// The promotion rule: when the canary metric is BELOW this
5205    /// threshold (good), the controller promotes (increases canary
5206    /// weight by `step`).
5207    pub promote: CanaryRule,
5208    /// The rollback rule: when the canary metric is ABOVE this
5209    /// threshold (bad), the controller rolls back (decreases canary
5210    /// weight by `step`). When the canary metric exceeds 2x this
5211    /// threshold (severe regression), the controller rolls back to 0
5212    /// immediately.
5213    pub rollback: CanaryRule,
5214}
5215
5216/// One canary analysis rule (DW-091): a metric to compare and the
5217/// threshold that triggers the rule.
5218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5219#[serde(deny_unknown_fields)]
5220pub struct CanaryRule {
5221    /// The metric to compare canary vs baseline.
5222    pub metric: CanaryMetric,
5223    /// The threshold. For `promote`: the canary metric must be BELOW
5224    /// this (good) to promote. For `rollback`: the canary metric must
5225    /// be ABOVE this (bad) to roll back.
5226    pub threshold: f64,
5227}
5228
5229/// The metric a canary rule compares (DW-091).
5230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5231#[serde(rename_all = "snake_case")]
5232pub enum CanaryMetric {
5233    /// HTTP error rate (status >= 500 / total requests).
5234    ErrorRate,
5235    /// P99 latency in milliseconds.
5236    LatencyP99,
5237    /// P95 latency in milliseconds.
5238    LatencyP95,
5239    /// P50 (median) latency in milliseconds.
5240    LatencyP50,
5241}
5242
5243/// Cookie affinity for a service (DW-040, `services[].sticky`).
5244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5245#[serde(deny_unknown_fields)]
5246pub struct StickyAffinity {
5247    /// The cookie name the gateway reads and sets. Must be a valid
5248    /// cookie-name token (RFC 6265: the RFC 2616 `token` grammar);
5249    /// validation rejects anything else.
5250    pub cookie: String,
5251    /// `Max-Age` of the set cookie, in seconds (default 3600 — one
5252    /// hour; validated to 1..=2_592_000, 30 days).
5253    #[serde(
5254        default = "default_sticky_ttl_s",
5255        skip_serializing_if = "is_default_sticky_ttl_s"
5256    )]
5257    pub ttl_s: u64,
5258}
5259
5260fn default_sticky_ttl_s() -> u64 {
5261    3_600
5262}
5263fn is_default_sticky_ttl_s(t: &u64) -> bool {
5264    *t == 3_600
5265}
5266
5267/// DNS-based dynamic upstream discovery (DW-042): when present on an
5268/// upstream, a background discovery task resolves `hostname` via DNS,
5269/// watches the record TTL, and re-resolves when the TTL expires (or at
5270/// `refresh_interval_s`, whichever comes first), updating the upstream's
5271/// endpoint set live — without a restart. The `endpoints` field becomes
5272/// the INITIAL/fallback set: used until the first resolution completes
5273/// and as a fallback when DNS fails and `fail_open` is true. When
5274/// absent, endpoints remain static (the current behavior — no change).
5275///
5276/// Only A and SRV records are supported. A records pair each resolved
5277/// address with `port`; SRV records carry their own port. Consul watch
5278/// and Kubernetes EndpointSlice watch are deferred to a future
5279/// milestone — DNS is the first discovery source.
5280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5281#[serde(deny_unknown_fields)]
5282pub struct DnsDiscovery {
5283    /// The DNS hostname to resolve (e.g. `my-service.example.com`).
5284    pub hostname: String,
5285    /// The port to pair with each resolved A-record address. Ignored
5286    /// for SRV records (they carry their own port).
5287    pub port: u16,
5288    /// How often to re-resolve, in seconds (default 30; bounded to
5289    /// 1..=3600). The actual refresh interval is
5290    /// `min(refresh_interval_s, record_ttl)` so a short-TTL record is
5291    /// refreshed sooner.
5292    #[serde(
5293        default = "default_dns_discovery_refresh_s",
5294        skip_serializing_if = "is_default_dns_discovery_refresh_s"
5295    )]
5296    pub refresh_interval_s: u64,
5297    /// DNS record type: `"A"` (default) or `"SRV"`.
5298    #[serde(
5299        default = "default_dns_discovery_record_type",
5300        skip_serializing_if = "is_default_dns_discovery_record_type"
5301    )]
5302    pub record_type: String,
5303    /// If true (default), keep the last resolved endpoint set when DNS
5304    /// fails. If false, clear endpoints on failure (the upstream
5305    /// answers 503 until DNS recovers).
5306    #[serde(
5307        default = "default_dns_discovery_fail_open",
5308        skip_serializing_if = "is_default_dns_discovery_fail_open"
5309    )]
5310    pub fail_open: bool,
5311    /// Minimum endpoints to keep serving (default 1). If a resolution
5312    /// yields fewer than this, the previous set is kept (the upstream
5313    /// does not shrink below the floor).
5314    #[serde(
5315        default = "default_dns_discovery_min_endpoints",
5316        skip_serializing_if = "is_default_dns_discovery_min_endpoints"
5317    )]
5318    pub min_endpoints: u32,
5319}
5320
5321fn default_dns_discovery_refresh_s() -> u64 {
5322    30
5323}
5324fn is_default_dns_discovery_refresh_s(v: &u64) -> bool {
5325    *v == 30
5326}
5327fn default_dns_discovery_record_type() -> String {
5328    "A".to_string()
5329}
5330fn is_default_dns_discovery_record_type(v: &String) -> bool {
5331    v == "A"
5332}
5333fn default_dns_discovery_fail_open() -> bool {
5334    true
5335}
5336fn is_default_dns_discovery_fail_open(v: &bool) -> bool {
5337    *v
5338}
5339fn default_dns_discovery_min_endpoints() -> u32 {
5340    1
5341}
5342fn is_default_dns_discovery_min_endpoints(v: &u32) -> bool {
5343    *v == 1
5344}
5345
5346/// Load-balancing pool: algorithm, protocol, endpoints.
5347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5348#[serde(deny_unknown_fields)]
5349pub struct Upstream {
5350    pub name: String,
5351    #[serde(default = "default_load_balancer")]
5352    pub load_balancer: LoadBalancer,
5353    /// DP-07 (#236): what to hash for consistent-hash load balancers
5354    /// (`ip_hash` and `maglev`). Defaults to `client_ip` (the pre-#236
5355    /// behavior). When set to `cookie` or `header`, the gateway extracts
5356    /// the value from the request and uses it as the hash key; if the
5357    /// cookie/header is absent, it falls back to the client IP.
5358    #[serde(default, skip_serializing_if = "Option::is_none")]
5359    pub hash_on: Option<HashOn>,
5360    /// Protocol used toward upstream endpoints.
5361    #[serde(default = "default_upstream_protocol")]
5362    pub protocol: UpstreamProtocol,
5363    /// Path to a PEM file of CA certificates this upstream's TLS
5364    /// connections trust INSTEAD of the default public (webpki) root set
5365    /// (#121): the way to proxy an `https`/`http2` upstream whose server
5366    /// certificate chains to a private CA. The file may carry several
5367    /// certificates (a typical CA bundle). Only meaningful for the TLS
5368    /// protocols — no TLS is negotiated toward `http1` endpoints, so
5369    /// validation rejects that combination. Active `http` health probes
5370    /// for this upstream verify against the same roots. The file must
5371    /// exist and be readable at config compile time; validation names
5372    /// this field otherwise.
5373    #[serde(default, skip_serializing_if = "Option::is_none")]
5374    pub trusted_ca_file: Option<String>,
5375    /// REL-08 (#219): when true, the upstream's TLS connections trust the
5376    /// OS-native root CA store (loaded via `rustls-native-certs`) INSTEAD
5377    /// of the bundled webpki root set. Useful in deployments whose
5378    /// private CAs are provisioned through the host's trust store (e.g.
5379    /// corporate PKI, cloud metadata CAs). Mutually exclusive with
5380    /// `trusted_ca_file` (validation rejects a configuration that sets
5381    /// both). Only meaningful for the TLS protocols (`https`/`http2`/`h3`)
5382    /// — no TLS is negotiated toward `http1` endpoints, so validation
5383    /// rejects that combination. Active health probes for this upstream
5384    /// verify against the same roots. Defaults to `false` (webpki roots).
5385    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
5386    pub use_system_roots: bool,
5387    /// Static endpoint list for this upstream. When `dns_discovery` is
5388    /// present, this becomes the initial/fallback set (used until the
5389    /// first DNS resolution completes and as a fallback when DNS fails
5390    /// and `fail_open` is true); it may be empty. When `dns_discovery`
5391    /// is absent, this is the complete endpoint set and must be
5392    /// non-empty (validation rejects an empty list).
5393    #[serde(default)]
5394    pub endpoints: Vec<Endpoint>,
5395    /// Maximum number of concurrent outbound connections to this upstream
5396    /// (active plus pooled idle). Defaults to 256 when absent. Enforced by
5397    /// the upstream client (DW-008); excess connection attempts wait for a
5398    /// slot rather than fail. Raised from 64 to 256 (#270) to avoid
5399    /// unbounded memory growth under high concurrency.
5400    #[serde(default, skip_serializing_if = "Option::is_none")]
5401    pub connection_cap: Option<u32>,
5402    /// Slow-start window in milliseconds (DW-011): an endpoint entering the
5403    /// upstream's set ramps its effective load-balancing weight from ~0 up
5404    /// to its configured weight over this window. Absent (or 0) disables
5405    /// the ramp. Applies to the weighted algorithms (round_robin; ip_hash
5406    /// vnode counts stay fixed so ring consistency is preserved).
5407    /// Validation bounds the value to at most 10 minutes.
5408    #[serde(default, skip_serializing_if = "Option::is_none")]
5409    pub slow_start_ms: Option<u64>,
5410    /// Passive health checking / outlier detection (DW-012): eject
5411    /// endpoints that fail real traffic (transport errors and 5xx), let
5412    /// them back via half-open trial probes after `eject_ms`. Absent
5413    /// disables passive health entirely (no ejections).
5414    #[serde(default, skip_serializing_if = "Option::is_none")]
5415    pub health: Option<PassiveHealth>,
5416    /// Active health checks (DW-013): synthetic HTTP/TCP probes per
5417    /// endpoint on a fixed interval with full jitter. Probe results report
5418    /// into the SAME per-endpoint ejection machinery as passive health, so
5419    /// an endpoint failing its probes leaves load-balancer rotation and a
5420    /// success streak returns it. Requires the passive `health` block
5421    /// (which owns the ejection/recovery windows); rejected by validation
5422    /// otherwise. Absent disables active probing.
5423    #[serde(default, skip_serializing_if = "Option::is_none")]
5424    pub active_health: Option<ActiveHealth>,
5425    /// Upstream retries (DW-014): bounded per-request retry attempts with
5426    /// exponential backoff + full jitter, a retry budget, and opt-in
5427    /// size-capped request-body buffering. Absent (or `attempts` left at
5428    /// its default 0) disables retries entirely: every request gets exactly
5429    /// one attempt and the proxy path keeps its zero-copy streaming body.
5430    #[serde(default, skip_serializing_if = "Option::is_none")]
5431    pub retries: Option<RetryConfig>,
5432    /// Per-upstream circuit breaker (DW-015): opens the WHOLE upstream on
5433    /// consecutive failures or a rolling error ratio, fails fast with 503
5434    /// while open, probes half-open after `breaker.open_ms`. Absent
5435    /// disables the breaker entirely (no fail-fast, behavior identical to
5436    /// pre-DW-015).
5437    #[serde(default, skip_serializing_if = "Option::is_none")]
5438    pub breaker: Option<BreakerConfig>,
5439    /// Maximum number of requests WAITING for an outbound connection slot
5440    /// to this upstream (DW-015). 0/absent (the default) means a bounded
5441    /// queue of 256 — the default bounds memory under high concurrency
5442    /// (#270). A positive value rejects excess requests IMMEDIATELY with
5443    /// 503 "upstream saturated" instead of letting them wait. To restore
5444    /// the pre-#270 unbounded queueing behavior, set a very large value.
5445    #[serde(default, skip_serializing_if = "Option::is_none")]
5446    pub max_pending: Option<u32>,
5447    #[serde(skip_serializing_if = "Option::is_none")]
5448    pub timeouts: Option<Timeouts>,
5449    /// OAuth2 client-credentials proxying (DW-035): the gateway acts as
5450    /// an OAuth2 client itself, obtaining an access token from an
5451    /// external token endpoint via the client-credentials flow and
5452    /// forwarding it to this upstream as a `Bearer` token (replacing
5453    /// any client-supplied `Authorization` header). For service-to-
5454    /// service calls where the gateway authenticates to the upstream
5455    /// with a token it obtains from an OAuth2 token endpoint. Absent
5456    /// (the default): no token acquisition; the upstream sees whatever
5457    /// `Authorization` the client sent (subject to the authn family's
5458    /// pass-through rules). See the `security::oauth2` module docs for
5459    /// the flow, caching, and the mTLS-to-the-token-endpoint option.
5460    #[serde(default, skip_serializing_if = "Option::is_none")]
5461    pub oauth2_client_credentials: Option<OAuth2ClientCredentials>,
5462    /// DNS-based dynamic upstream discovery (DW-042): when present, a
5463    /// background task resolves `hostname` via DNS and updates the
5464    /// endpoint set live as records change. The `endpoints` field
5465    /// becomes the initial/fallback set. Absent (the default): endpoints
5466    /// remain static (the current behavior — no change).
5467    #[serde(default, skip_serializing_if = "Option::is_none")]
5468    pub dns_discovery: Option<DnsDiscovery>,
5469    /// Peak-EWMA tuning (DW-090): only meaningful when
5470    /// `load_balancer: peak_ewma`. Absent (the default) uses the built-in
5471    /// defaults (10 s decay tau, 250 ms initial cost). Ignored for the
5472    /// other algorithms.
5473    #[serde(default, skip_serializing_if = "Option::is_none")]
5474    pub peak_ewma: Option<PeakEwmaConfig>,
5475    /// DW-094 (Ent): locality-aware routing and data residency. When
5476    /// present, the balancer filters or prefers endpoints by region/zone
5477    /// before applying the configured `load_balancer` algorithm. Ent-only:
5478    /// rejected at validation when the `ent` cargo feature is off.
5479    #[serde(default, skip_serializing_if = "Option::is_none")]
5480    pub locality: Option<UpstreamLocality>,
5481    /// DW-105: opt in to post-quantum hybrid key exchange
5482    /// (X25519+ML-KEM) for this upstream's TLS connections. When
5483    /// `true`, the X25519+ML-KEM
5484    /// hybrid kx group is prepended to the rustls client config's kx
5485    /// group list, PREFERRING the hybrid group while keeping the
5486    /// classical X25519 fallback for non-PQ upstreams. Compiled into
5487    /// every build (no cargo feature).
5488    /// EXPERIMENTAL: the rustls PQ API is not yet stable. Only
5489    /// meaningful for the TLS protocols (`https`, `http2`); validation
5490    /// rejects `pq: true` on an `http1` upstream (no TLS is
5491    /// negotiated). Rejected when combined with FIPS mode (ML-KEM is
5492    /// not on the FIPS-validated list for aws-lc-rs).
5493    #[serde(default, skip_serializing_if = "is_false")]
5494    pub pq: bool,
5495    /// DW-109 / SEC-04: upstream TLS certificate pinning by SPKI
5496    /// SHA-256 hash. When present, the gateway's outbound TLS client
5497    /// installs a custom certificate verifier that extracts the peer
5498    /// cert's SubjectPublicKeyInfo, computes its SHA-256, and compares
5499    /// against the configured pins. A mismatch rejects the connection
5500    /// (fail-closed: no fallback to CA-based verification). Compiled
5501    /// into every build (no cargo feature). Only meaningful for the
5502    /// TLS protocols
5503    /// (`https`, `http2`); validation rejects it on an `http1`
5504    /// upstream.
5505    #[serde(default, skip_serializing_if = "Option::is_none")]
5506    pub cert_pinning: Option<CertPinningConfig>,
5507    /// SEC-03: upstream mTLS client certificates. When present, the
5508    /// gateway presents this client certificate to the upstream during
5509    /// the TLS handshake (in addition to verifying the upstream's
5510    /// server cert against `trusted_ca_file` or the public roots).
5511    /// Required for upstreams that demand client authentication
5512    /// (banking/payment APIs, mesh-internal services). Only meaningful
5513    /// for the TLS protocols (`https`, `http2`); validation rejects it
5514    /// on an `http1` upstream.
5515    #[serde(default, skip_serializing_if = "Option::is_none")]
5516    pub mtls: Option<UpstreamMtls>,
5517    /// DP-03: hyper connection-pool and HTTP/2 tuning for this
5518    /// upstream's outbound client. Absent (the default): every knob
5519    /// falls back to hyper-util's built-in defaults (the prior
5520    /// behavior — only the pool timer was wired). When present, the
5521    /// configured knobs are applied to the per-upstream hyper-util
5522    /// legacy client builder; omitted sub-fields keep their hyper
5523    /// defaults. The pool timer is ALWAYS installed (it is required
5524    /// for idle-timeout eviction to fire), so this block only needs to
5525    /// carry the knobs an operator wants to change. Meaningful for
5526    /// every protocol: `http1`/`https` connections reuse the pool's
5527    /// idle-timeout and max-idle-per-host knobs, while `http2`
5528    /// connections additionally honor the keep-alive and
5529    /// concurrent-streams knobs.
5530    #[serde(default, skip_serializing_if = "Option::is_none")]
5531    pub pool: Option<UpstreamPoolConfig>,
5532}
5533
5534/// DP-03: hyper connection-pool and HTTP/2 tuning for an upstream
5535/// (`upstreams[].pool`). Every field is optional; an omitted field
5536/// keeps hyper-util's built-in default (the same value the gateway
5537/// used before this block existed), so the block is purely additive.
5538/// Validation rejects zero/negative-forbidden values; the runtime
5539/// clamps nothing beyond the documented bounds.
5540#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5541#[serde(deny_unknown_fields)]
5542pub struct UpstreamPoolConfig {
5543    /// How long an idle connection may sit in the pool before it is
5544    /// closed (hyper-util `pool_idle_timeout`). In milliseconds.
5545    /// Absent: hyper-util's default (90 s). `0` is rejected at
5546    /// validation (use a very small positive value to evict
5547    /// aggressively, or omit the block for the default). Bounds: at
5548    /// most 10 minutes (600000 ms).
5549    #[serde(default, skip_serializing_if = "Option::is_none")]
5550    pub pool_idle_timeout_ms: Option<u64>,
5551    /// Maximum number of idle connections retained per host
5552    /// (hyper-util `pool_max_idle_per_host`). Absent: hyper-util's
5553    /// default. The per-upstream `connection_cap` still bounds the
5554    /// total (active + idle) connections, so this only shapes the
5555    /// idle fraction. Bounds: at most 1024.
5556    #[serde(default, skip_serializing_if = "Option::is_none")]
5557    pub pool_max_idle_per_host: Option<u32>,
5558    /// HTTP/2 PING keep-alive interval (hyper-util
5559    /// `http2_keep_alive_interval`). In milliseconds. Absent: disabled
5560    /// (hyper's default — no PINGs). A positive value sends a PING
5561    /// this often on an otherwise-idle h2 connection so proxies/ALB
5562    /// in front of the upstream do not reap it. Only meaningful for
5563    /// the `http2` protocol; ignored for `http1`/`https`. Bounds: at
5564    /// most 10 minutes (600000 ms).
5565    #[serde(default, skip_serializing_if = "Option::is_none")]
5566    pub http2_keep_alive_interval_ms: Option<u64>,
5567    /// HTTP/2 keep-alive timeout (hyper-util
5568    /// `http2_keep_alive_timeout`): the time the client waits for a
5569    /// PING ACK before closing the connection. In milliseconds.
5570    /// Absent: hyper's default (20 s). Does nothing when
5571    /// `http2_keep_alive_interval_ms` is absent. Only meaningful for
5572    /// the `http2` protocol. Bounds: at most 10 minutes (600000 ms).
5573    #[serde(default, skip_serializing_if = "Option::is_none")]
5574    pub http2_keep_alive_timeout_ms: Option<u64>,
5575    /// Enable HTTP/2 adaptive flow-control window (hyper-util
5576    /// `http2_adaptive_window`). Absent: false (hyper's default —
5577    /// fixed initial window). When true, the connection window grows
5578    /// and shrinks based on throughput, which helps large/long
5579    /// responses on high-BDP paths. Only meaningful for the `http2`
5580    /// protocol; ignored otherwise.
5581    #[serde(default, skip_serializing_if = "is_false")]
5582    pub http2_adaptive_window: bool,
5583    /// Initial cap on the number of concurrent HTTP/2 streams the
5584    /// client will send over a single connection (hyper-util
5585    /// `http2_initial_max_send_streams`). This is an *initial* value
5586    /// only: hyper-util's default is 100, and the effective cap is
5587    /// further bounded by the server's advertised
5588    /// SETTINGS_MAX_CONCURRENT_STREAMS once the peer's SETTINGS frame
5589    /// arrives. A finite value bounds head-of-line blocking when one
5590    /// stream stalls. Only meaningful for the `http2` protocol.
5591    /// Bounds: at least 1, at most 1_000_000.
5592    #[serde(default, skip_serializing_if = "Option::is_none")]
5593    pub max_concurrent_streams: Option<u32>,
5594    /// SCALE-10 (#189): number of connections to pre-establish per
5595    /// endpoint on startup/reload. Pre-warming removes cold-start
5596    /// latency spikes after reloads and upgrade hand-offs. `0` or
5597    /// absent: no pre-warming (the default — connections are
5598    /// established on first use). Bounds: at most 64 per endpoint.
5599    #[serde(default, skip_serializing_if = "Option::is_none")]
5600    pub pre_warm: Option<u32>,
5601    /// SCALE-10 (#189): per-endpoint connection cap, bounding
5602    /// concurrent connections to a SINGLE endpoint (address:port)
5603    /// within the upstream. The per-upstream `connection_cap` still
5604    /// bounds the total across all endpoints; this knob prevents one
5605    /// busy endpoint from monopolizing the upstream's connection
5606    /// budget. Absent: no per-endpoint cap (only the per-upstream
5607    /// cap applies). Bounds: at least 1, at most 1024.
5608    #[serde(default, skip_serializing_if = "Option::is_none")]
5609    pub per_endpoint_cap: Option<u32>,
5610    /// SCALE-10 (#189): maximum age of a pooled connection before it
5611    /// is recycled. In milliseconds. Absent: no max-age (connections
5612    /// live until the idle timeout or a peer-initiated close). A
5613    /// positive value sets the pool idle timeout to the minimum of
5614    /// this and `pool_idle_timeout_ms`, so connections are evicted
5615    /// before they become stale. Bounds: at most 10 minutes
5616    /// (600000 ms).
5617    #[serde(default, skip_serializing_if = "Option::is_none")]
5618    pub max_connection_age_ms: Option<u64>,
5619    /// Initial stream-level window size in bytes for the upstream h2
5620    /// client (hyper `http2_initial_stream_window_size`). Absent:
5621    /// hyper's default. Larger values reduce flow-control stalls on
5622    /// streaming (SSE) responses from the upstream but increase
5623    /// memory per stream. Only meaningful for the `http2` protocol.
5624    /// Bounds: at most 16 MiB. (PERF-11, #244)
5625    #[serde(default, skip_serializing_if = "Option::is_none")]
5626    pub http2_initial_stream_window_size: Option<u32>,
5627    /// Initial connection-level window size in bytes for the upstream
5628    /// h2 client (hyper `http2_initial_connection_window_size`).
5629    /// Absent: hyper's default. Bounds: at most 64 MiB.
5630    /// (PERF-11, #244)
5631    #[serde(default, skip_serializing_if = "Option::is_none")]
5632    pub http2_initial_connection_window_size: Option<u32>,
5633}
5634
5635/// SEC-03: upstream mTLS client certificate configuration. Reuses the
5636/// two-path shape (cert file + key file) proven by the OAuth2 mTLS
5637/// config (`OAuth2Mtls`).
5638#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5639#[serde(deny_unknown_fields)]
5640pub struct UpstreamMtls {
5641    /// Path to the PEM client certificate chain to present to the
5642    /// upstream during the TLS handshake.
5643    pub client_cert_file: String,
5644    /// Path to the PEM private key for `client_cert_file`.
5645    pub client_key_file: String,
5646}
5647
5648/// DW-109 / SEC-04: certificate pinning configuration for an upstream.
5649/// Holds a list of allowed SPKI SHA-256 hashes (lowercase hex, 64
5650/// chars). A peer cert whose SPKI hash matches ANY entry is accepted;
5651/// otherwise the connection is rejected (fail-closed).
5652#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5653#[serde(deny_unknown_fields)]
5654pub struct CertPinningConfig {
5655    /// The allowed SPKI SHA-256 hashes (lowercase hex, 64 chars each).
5656    /// Must be non-empty (validation rejects an empty pin list — a
5657    /// pin-less pinning block is an authoring mistake, not "pin
5658    /// nothing").
5659    pub pins: Vec<CertPin>,
5660}
5661
5662/// A single certificate pin: the SHA-256 hash of the upstream
5663/// certificate's SubjectPublicKeyInfo (SPKI), as lowercase hex (64
5664/// chars). Pinning the SPKI (rather than the full certificate) allows
5665/// rotation of the leaf certificate as long as the key pair is
5666/// unchanged.
5667#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
5668#[serde(deny_unknown_fields)]
5669pub struct CertPin {
5670    /// The SHA-256 hash of the SPKI, as lowercase hex (64 chars).
5671    pub spki_sha256: String,
5672}
5673
5674/// DW-094 (Ent): locality-aware routing and data residency configuration
5675/// for an upstream. Controls how the balancer uses endpoint `region`/
5676/// `zone` fields to filter or prefer endpoints.
5677#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5678#[serde(deny_unknown_fields)]
5679pub struct UpstreamLocality {
5680    /// The routing mode (DW-094):
5681    /// - `prefer`: prefer endpoints in the edge's region; fall back to
5682    ///   other regions if none are available (healthy).
5683    /// - `strict`: only route to endpoints in the edge's region; fail
5684    ///   with 503 if none are available (no cross-region fallback).
5685    /// - `failover`: prefer the edge's region, fall back to other
5686    ///   regions only when all local endpoints are ejected (like
5687    ///   `prefer` but with a stricter local-first policy — local
5688    ///   endpoints are always tried first even if unhealthy).
5689    #[serde(default)]
5690    pub mode: LocalityMode,
5691    /// Data residency enforcement: when true, the balancer drops
5692    /// endpoints whose `region` is not in `allowed_regions` BEFORE
5693    /// applying the locality mode. This is a hard constraint —
5694    /// endpoints outside the allowed set are never used, even as a
5695    /// fallback. Use this to comply with data sovereignty requirements
5696    /// (e.g. EU traffic must stay in EU regions).
5697    #[serde(default)]
5698    pub enforce_data_residency: bool,
5699    /// The allowed regions when `enforce_data_residency` is true. An
5700    /// endpoint with no `region` field is always allowed (locality-
5701    /// agnostic). An endpoint with a `region` not in this list is
5702    /// dropped. Must be non-empty when `enforce_data_residency` is true.
5703    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5704    pub allowed_regions: Vec<String>,
5705    /// The denied regions (optional). An endpoint with a `region` in
5706    /// this list is always dropped, regardless of other settings. Use
5707    /// this to exclude specific regions without enumerating all
5708    /// allowed ones.
5709    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5710    pub denied_regions: Vec<String>,
5711}
5712
5713/// DW-094 (Ent): locality routing mode.
5714#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
5715#[serde(rename_all = "snake_case")]
5716pub enum LocalityMode {
5717    /// Prefer endpoints in the edge's region; fall back to other
5718    /// regions if none are available (healthy). The default.
5719    #[default]
5720    Prefer,
5721    /// Only route to endpoints in the edge's region; fail with 503 if
5722    /// none are available (no cross-region fallback).
5723    Strict,
5724    /// Prefer the edge's region, fall back to other regions only when
5725    /// all local endpoints are ejected (local-first with failover).
5726    Failover,
5727}
5728
5729/// DW-094 (Ent): the edge's locality context — its region and zone.
5730/// Threaded into the balancer at request time to enable locality-aware
5731/// endpoint selection. When both `region` and `zone` are `None`, the
5732/// balancer skips locality filtering (the OSS behavior).
5733///
5734/// The context is set from `EdgeState.labels` (the `region` and `zone`
5735/// keys) when running as an edge, or from the `DWARA_REGION`/
5736/// `DWARA_ZONE` environment variables in embedded mode.
5737#[derive(Debug, Clone, Default, PartialEq, Eq)]
5738pub struct LocalityContext {
5739    /// The edge's region (e.g. `us-east-1`).
5740    pub region: Option<String>,
5741    /// The edge's zone (e.g. `us-east-1a`).
5742    pub zone: Option<String>,
5743}
5744
5745impl LocalityContext {
5746    /// Create a new locality context.
5747    pub fn new(region: Option<String>, zone: Option<String>) -> Self {
5748        Self { region, zone }
5749    }
5750
5751    /// Whether locality-aware routing should be applied (at least one
5752    /// of region/zone is set).
5753    pub fn is_set(&self) -> bool {
5754        self.region.is_some() || self.zone.is_some()
5755    }
5756
5757    /// Whether an endpoint region matches this context's region.
5758    /// An endpoint with no region is always a match (locality-agnostic).
5759    pub fn region_matches(&self, endpoint_region: Option<&str>) -> bool {
5760        match (&self.region, endpoint_region) {
5761            (Some(ctx_region), Some(ep_region)) => ctx_region == ep_region,
5762            _ => true,
5763        }
5764    }
5765
5766    /// Whether an endpoint zone matches this context's zone.
5767    /// An endpoint with no zone is always a match (zone-agnostic).
5768    pub fn zone_matches(&self, endpoint_zone: Option<&str>) -> bool {
5769        match (&self.zone, endpoint_zone) {
5770            (Some(ctx_zone), Some(ep_zone)) => ctx_zone == ep_zone,
5771            _ => true,
5772        }
5773    }
5774
5775    /// Load the locality context from environment variables
5776    /// (`DWARA_REGION`, `DWARA_ZONE`). Returns a context with `None`
5777    /// values when the variables are not set.
5778    pub fn from_env() -> Self {
5779        Self {
5780            region: std::env::var("DWARA_REGION").ok(),
5781            zone: std::env::var("DWARA_ZONE").ok(),
5782        }
5783    }
5784}
5785
5786/// Peak-EWMA load-balancing tuning (DW-090, `upstreams[].peak_ewma`).
5787/// Only consulted when `load_balancer: peak_ewma`; ignored otherwise.
5788#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5789#[serde(deny_unknown_fields)]
5790pub struct PeakEwmaConfig {
5791    /// Decay tau in milliseconds (default 10 000 = 10 s). The EWMA weight
5792    /// is `exp(-td / tau)` where `td` is the time since the last update.
5793    /// A larger tau makes the cost track slow endpoints more sluggishly
5794    /// (more stable, less reactive); a smaller tau reacts faster to
5795    /// latency changes. Must be > 0.
5796    #[serde(default, skip_serializing_if = "Option::is_none")]
5797    pub decay_ms: Option<u64>,
5798    /// Initial cost in milliseconds (default 250). A fresh endpoint's
5799    /// EWMA cost starts here (converted to nanoseconds) so the first
5800    /// picks have a reasonable baseline before any real RTT is observed.
5801    /// Must be > 0.
5802    #[serde(default, skip_serializing_if = "Option::is_none")]
5803    pub default_rtt_ms: Option<u64>,
5804}
5805
5806/// OAuth2 client-credentials configuration for an upstream (DW-035,
5807/// `upstreams[].oauth2_client_credentials`).
5808///
5809/// The gateway obtains an access token from `token_endpoint` using the
5810/// client-credentials grant (RFC 6749 section 4.4) and forwards it to
5811/// the upstream as `Authorization: Bearer <token>`. The client
5812/// authenticates to the token endpoint with HTTP Basic auth
5813/// (`client_id:client_secret`, RFC 6749 section 2.3.1). Tokens are
5814/// cached per upstream (keyed by upstream name on the dataplane) and
5815/// refreshed lazily on the first request after expiry — no background
5816/// refresh task. An optional `mtls` block configures a client
5817/// certificate for the TLS handshake to the token endpoint itself
5818/// (RFC 8705, the `tls_client_auth` method).
5819#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5820#[serde(deny_unknown_fields)]
5821pub struct OAuth2ClientCredentials {
5822    /// The token endpoint URL (RFC 6749 section 3.2). Must be an
5823    /// absolute `http(s)://` URL; validation rejects anything else.
5824    pub token_endpoint: String,
5825    /// The client identifier presented to the token endpoint
5826    /// (RFC 6749 section 2.2). Sent as the username of the HTTP Basic
5827    /// auth header.
5828    pub client_id: String,
5829    /// The client secret presented to the token endpoint (RFC 6749
5830    /// section 2.3.1). Either the value INLINE (redacted in every
5831    /// config echo, DW-045) or a `${...}` secret reference (env or
5832    /// file) resolved at config-compile time and re-read on every
5833    /// reload. Sent as the password of the HTTP Basic auth header.
5834    pub client_secret: String,
5835    /// Optional scope string (RFC 6749 section 3.3): a space-delimited
5836    /// list of requested scopes sent in the token request body. Absent
5837    /// = no scope parameter (the endpoint's default scopes apply).
5838    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5839    pub scopes: Vec<String>,
5840    /// Optional mTLS client certificate for the TLS handshake to the
5841    /// token endpoint itself (RFC 8705 `tls_client_auth`). Absent: the
5842    /// token endpoint's TLS is negotiated with no client certificate
5843    /// (the default webpki root set, or a `trusted_ca_file` when the
5844    /// token endpoint is behind a private CA — note the token endpoint
5845    /// trust is NOT the upstream's `trusted_ca_file`; the token
5846    /// endpoint is a separate entity).
5847    #[serde(default, skip_serializing_if = "Option::is_none")]
5848    pub mtls: Option<OAuth2Mtls>,
5849    /// Token cache TTL override in seconds. Absent (the default): use
5850    /// the token's own `expires_in` (minus a refresh skew margin). When
5851    /// set, the cache TTL is `min(token_expires_in - skew,
5852    /// token_cache_ttl_s)` so a shorter override never extends a
5853    /// token's real lifetime. Must be > 0.
5854    #[serde(default, skip_serializing_if = "Option::is_none")]
5855    pub token_cache_ttl_s: Option<u64>,
5856}
5857
5858/// mTLS client certificate for an OAuth2 token endpoint (DW-035,
5859/// `oauth2_client_credentials.mtls`).
5860#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5861#[serde(deny_unknown_fields)]
5862pub struct OAuth2Mtls {
5863    /// Path to a PEM file containing the client certificate chain.
5864    pub client_cert: String,
5865    /// Path to a PEM file containing the matching private key.
5866    pub client_key: String,
5867}
5868
5869/// Upstream retry knobs (DW-014). All fields default; a `retries:` block
5870/// with no keys is equivalent to retries off (`attempts` defaults to 0).
5871///
5872/// Frozen semantics (see `upstream`/`proxy`/`retries` module docs):
5873/// - `attempts` is the maximum number of RETRIES beyond the first attempt
5874///   (0 = off). Validation caps it at 10.
5875/// - Only requests whose body was fully buffered within `buffer_max_bytes`
5876///   may be retried; an over-cap body streams without retry. Buffering is
5877///   opt-in: the default (0) buffers only empty bodies, so the default
5878///   proxy path stays unbuffered.
5879/// - Retries happen strictly BEFORE response headers arrive on the final
5880///   attempt; a response body that dies mid-stream is never retried (its
5881///   failure is reported to passive health instead).
5882/// - Every retried attempt is charged against the upstream's rolling-window
5883///   retry budget (`budget_percent`).
5884/// - `total_deadline_ms` (REL-01) caps the wall-clock time from the first
5885///   attempt to the last, INCLUDING backoff delays between attempts. Absent
5886///   leaves the cross-attempt budget unbounded (the v1 default, kept for
5887///   backwards compatibility). When set, a retry whose backoff would cross
5888///   the deadline is aborted and the last response/error is returned to the
5889///   client; the per-attempt `read_ms` timeout still bounds each individual
5890///   attempt. Standard in Envoy and NGINX.
5891#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5892#[serde(deny_unknown_fields)]
5893pub struct RetryConfig {
5894    /// Maximum retries beyond the first attempt (default 0 = off).
5895    #[serde(default = "default_retry_attempts")]
5896    pub attempts: u32,
5897    /// Retry non-idempotent POST requests. Default false: POST is never
5898    /// retried unless an operator explicitly opts in here (a retried POST
5899    /// may replay a body the upstream already partially processed).
5900    #[serde(default = "default_retry_post", skip_serializing_if = "is_false")]
5901    pub retry_post: bool,
5902    /// Exponential backoff base in milliseconds (default 25): the nominal
5903    /// delay before retry n is `min(base * 2^(n-1), backoff_cap_ms)`.
5904    #[serde(default = "default_retry_backoff_base_ms")]
5905    pub backoff_base_ms: u64,
5906    /// Exponential backoff ceiling in milliseconds (default 250). Must be
5907    /// >= `backoff_base_ms`.
5908    #[serde(default = "default_retry_backoff_cap_ms")]
5909    pub backoff_cap_ms: u64,
5910    /// Response statuses that trigger a retry when received as the upstream
5911    /// response status (default `[502, 503, 504]`). Each entry must be a
5912    /// valid 4xx/5xx status. An empty list disables status-based retries.
5913    #[serde(default = "default_retry_statuses")]
5914    pub retry_statuses: Vec<u16>,
5915    /// Retry on transport errors (connect timeout/refusal/reset/framing)
5916    /// and per-attempt read timeouts (default true).
5917    #[serde(default = "default_retry_transport", skip_serializing_if = "is_true")]
5918    pub retry_transport: bool,
5919    /// Retry budget: the maximum percentage of requests to this upstream,
5920    /// in a rolling window, that may be retries (default 10). Must be in
5921    /// (0, 100]. When the budget is exhausted, failing requests fail
5922    /// through to the client instead of retrying.
5923    #[serde(default = "default_retry_budget_percent")]
5924    pub budget_percent: u32,
5925    /// Request-body buffering cap in bytes (default 0 = no buffering). A
5926    /// request body is buffered (and becomes replayable) only while it fits
5927    /// within this cap; larger bodies stream and are never retried.
5928    #[serde(default)]
5929    pub buffer_max_bytes: u64,
5930    /// Request hedging (DW-063): after `hedge_after_ms` milliseconds
5931    /// without a response, a speculative duplicate request is sent to a
5932    /// different endpoint; the first response wins and the loser is
5933    /// cancelled. Requires `buffer_max_bytes > 0` (the body must be
5934    /// replayable to send a hedge copy) and idempotent methods (GET,
5935    /// HEAD, OPTIONS, TRACE, PUT) — POST is hedged only when
5936    /// `retry_post` is also true. Absent (or `hedge_after_ms` = 0)
5937    /// disables hedging. At most `hedge_max` speculative copies are
5938    /// sent per request (default 1).
5939    #[serde(default, skip_serializing_if = "Option::is_none")]
5940    pub hedge: Option<HedgeConfig>,
5941    /// Cross-attempt total retry deadline in milliseconds (REL-01): caps
5942    /// the wall-clock time from the first attempt to the last, INCLUDING
5943    /// backoff delays between attempts. Absent leaves the cross-attempt
5944    /// budget unbounded (the v1 default, kept for backwards
5945    /// compatibility). When set, a retry whose backoff would cross the
5946    /// deadline is aborted and the last response/error is returned to the
5947    /// client; the per-attempt `read_ms` timeout still bounds each
5948    /// individual attempt. Must be in (0, `MAX_RETRY_TOTAL_DEADLINE_MS`];
5949    /// `0` is rejected (omit the field for unbounded).
5950    #[serde(default, skip_serializing_if = "Option::is_none")]
5951    pub total_deadline_ms: Option<u64>,
5952    /// Streaming failover first-frame buffer cap in bytes (REL-05, #216).
5953    /// When > 0, the proxy buffers up to this many bytes of the upstream
5954    /// response body BEFORE forwarding any bytes to the client. If the
5955    /// upstream fails (connection reset, read timeout, framing error)
5956    /// before the buffer fills or the body completes, the proxy can
5957    /// still retry the request to a different endpoint — the client has
5958    /// not yet received any bytes, so the response is not committed.
5959    /// Once the buffer fills (or the body completes), the buffered
5960    /// prefix is flushed to the client and the rest of the body streams
5961    /// frame-by-frame (the stream is then committed; a later mid-body
5962    /// failure is terminal, as in v1). Default 0 = no first-frame
5963    /// buffering (v1 behavior: the response is committed as soon as
5964    /// headers resolve). Requires `attempts > 0` and
5965    /// `buffer_max_bytes > 0` (the request body must be replayable).
5966    #[serde(default)]
5967    pub buffer_first_frame_bytes: u64,
5968}
5969
5970impl Default for RetryConfig {
5971    fn default() -> Self {
5972        RetryConfig {
5973            attempts: default_retry_attempts(),
5974            retry_post: default_retry_post(),
5975            backoff_base_ms: default_retry_backoff_base_ms(),
5976            backoff_cap_ms: default_retry_backoff_cap_ms(),
5977            retry_statuses: default_retry_statuses(),
5978            retry_transport: default_retry_transport(),
5979            budget_percent: default_retry_budget_percent(),
5980            buffer_max_bytes: 0,
5981            hedge: None,
5982            total_deadline_ms: None,
5983            buffer_first_frame_bytes: 0,
5984        }
5985    }
5986}
5987
5988/// Request hedging configuration (DW-063, `upstreams[].retries.hedge`).
5989///
5990/// After `hedge_after_ms` without a response, a speculative duplicate
5991/// request is sent to a different endpoint. The first response (headers
5992/// resolved) wins; the loser is cancelled. Hedging requires a replayable
5993/// body (`buffer_max_bytes > 0`) and idempotent semantics (POST is hedged
5994/// only when `retry_post` is true).
5995#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
5996#[serde(deny_unknown_fields)]
5997pub struct HedgeConfig {
5998    /// Milliseconds to wait before sending a hedge copy (the "tail
5999    /// latency" threshold). Must be > 0. A typical value is the p90
6000    /// latency of the upstream — requests slower than this get a
6001    /// speculative parallel copy.
6002    pub hedge_after_ms: u64,
6003    /// Maximum number of speculative hedge copies per request (default
6004    /// 1). Must be in [1, 4]. Hedge copies are NOT charged against the
6005    /// retry budget — the budget prevents retry storms after failures,
6006    /// while hedging is a proactive performance optimization.
6007    #[serde(default = "default_hedge_max")]
6008    pub hedge_max: u32,
6009}
6010
6011fn default_hedge_max() -> u32 {
6012    1
6013}
6014
6015fn default_retry_attempts() -> u32 {
6016    0
6017}
6018
6019fn default_retry_post() -> bool {
6020    false
6021}
6022
6023fn default_retry_backoff_base_ms() -> u64 {
6024    25
6025}
6026
6027fn default_retry_backoff_cap_ms() -> u64 {
6028    250
6029}
6030
6031fn default_retry_statuses() -> Vec<u16> {
6032    vec![502, 503, 504]
6033}
6034
6035fn default_retry_transport() -> bool {
6036    true
6037}
6038
6039fn default_retry_budget_percent() -> u32 {
6040    10
6041}
6042
6043fn is_false(b: &bool) -> bool {
6044    !*b
6045}
6046
6047/// Per-upstream circuit breaker knobs (DW-015). All fields default; a
6048/// `breaker:` block with no keys enables the breaker with the defaults.
6049///
6050/// Frozen semantics (see the `breaker` module docs):
6051/// - The breaker gates the WHOLE upstream (all endpoints); per-endpoint
6052///   ejection (DW-012) is an independent layer beneath it.
6053/// - It opens on `consecutive_failures` consecutive failures (5xx or
6054///   transport) OR an in-window error ratio >= `error_ratio` once at least
6055///   `error_volume` observations are in the 60 s window.
6056/// - While open, requests fail fast with 503 and a `Retry-After` header
6057///   (seconds until half-open); in-flight requests complete normally.
6058/// - After `open_ms` a half-open probe (`half_open_probes` concurrent
6059///   trials) closes the breaker on success or re-opens it on failure.
6060#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
6061#[serde(deny_unknown_fields)]
6062pub struct BreakerConfig {
6063    /// Consecutive failures (5xx + transport) that open the breaker
6064    /// (default 5).
6065    #[serde(default = "default_breaker_consecutive_failures")]
6066    pub consecutive_failures: u32,
6067    /// In-window error ratio in (0, 1] that opens the breaker once
6068    /// `error_volume` observations exist (default 0.5).
6069    #[serde(default = "default_breaker_error_ratio")]
6070    pub error_ratio: f64,
6071    /// Minimum observations in the 60 s window before the ratio is
6072    /// evaluated (default 20).
6073    #[serde(default = "default_breaker_error_volume")]
6074    pub error_volume: u32,
6075    /// Cooling-off period in milliseconds before a half-open probe is
6076    /// admitted (default 30000).
6077    #[serde(default = "default_breaker_open_ms")]
6078    pub open_ms: u64,
6079    /// Concurrent trial requests admitted in half-open (default 1).
6080    #[serde(default = "default_breaker_half_open_probes")]
6081    pub half_open_probes: u32,
6082}
6083
6084impl Default for BreakerConfig {
6085    fn default() -> Self {
6086        BreakerConfig {
6087            consecutive_failures: default_breaker_consecutive_failures(),
6088            error_ratio: default_breaker_error_ratio(),
6089            error_volume: default_breaker_error_volume(),
6090            open_ms: default_breaker_open_ms(),
6091            half_open_probes: default_breaker_half_open_probes(),
6092        }
6093    }
6094}
6095
6096fn default_breaker_consecutive_failures() -> u32 {
6097    5
6098}
6099
6100fn default_breaker_error_ratio() -> f64 {
6101    0.5
6102}
6103
6104fn default_breaker_error_volume() -> u32 {
6105    20
6106}
6107
6108fn default_breaker_open_ms() -> u64 {
6109    30_000
6110}
6111
6112fn default_breaker_half_open_probes() -> u32 {
6113    1
6114}
6115
6116fn is_true(b: &bool) -> bool {
6117    *b
6118}
6119
6120fn default_false() -> bool {
6121    false
6122}
6123
6124/// Passive health / outlier detection knobs (DW-012). All fields default;
6125/// a `health:` block with no keys enables ejection with the defaults.
6126///
6127/// Failure classification: transport errors (connect timeout, refusal,
6128/// reset) and HTTP statuses >= 500 are failures; 1xx-4xx are successes.
6129/// 429/408 are deliberately successes in v1 (they describe the caller or
6130/// queueing, not endpoint health).
6131#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
6132#[serde(deny_unknown_fields)]
6133pub struct PassiveHealth {
6134    /// Rolling observation window for the failure ratio, in milliseconds
6135    /// (default 60000).
6136    #[serde(default = "default_health_window_ms")]
6137    pub window_ms: u64,
6138    /// Eject after this many consecutive failures (default 5).
6139    #[serde(default = "default_health_consecutive_failures")]
6140    pub consecutive_failures: u32,
6141    /// Eject when the in-window failure share is >= this ratio AND volume
6142    /// is >= `failure_min_volume`. Must be in (0, 1] (default 0.5).
6143    #[serde(default = "default_health_failure_ratio")]
6144    pub failure_ratio: f64,
6145    /// Minimum observations in the window before `failure_ratio` applies
6146    /// (default 20).
6147    #[serde(default = "default_health_failure_min_volume")]
6148    pub failure_min_volume: u32,
6149    /// How long an ejected endpoint stays out of rotation, in milliseconds
6150    /// (default 30000).
6151    #[serde(default = "default_health_eject_ms")]
6152    pub eject_ms: u64,
6153    /// Trial requests allowed through per half-open recovery attempt
6154    /// (default 1). A successful probe restores health; a failed probe
6155    /// re-ejects for another `eject_ms`.
6156    #[serde(default = "default_health_half_open_probes")]
6157    pub half_open_probes: u32,
6158    /// Recovery ramp window in milliseconds (REL-06, #217). When > 0,
6159    /// an endpoint that recovers from ejection (a half-open probe
6160    /// succeeds) does NOT immediately return to full traffic. Instead
6161    /// it ramps its effective weight from a floor (1) to its
6162    /// configured weight over this window, reducing the blast radius
6163    /// of a partial recovery (the endpoint may still be slow or
6164    /// partially degraded). Default 0 = no ramp (v1 behavior: a
6165    /// successful probe restores full weight immediately).
6166    #[serde(default)]
6167    pub recovery_ramp_ms: u64,
6168    /// Whether the in-flight counter is held until the response body
6169    /// completes (REL-06, #217). Default false: the in-flight counter
6170    /// is released when response headers resolve (v1 behavior). When
6171    /// true, the counter is held until the body stream ends or is
6172    /// dropped, giving `least_requests`, `random`, and `peak_ewma` a
6173    /// more accurate view of actual endpoint load. The trade-off is a
6174    /// slightly higher in-flight count under streaming workloads.
6175    #[serde(default = "default_false", skip_serializing_if = "is_false")]
6176    pub body_completion_inflight: bool,
6177}
6178
6179impl Default for PassiveHealth {
6180    fn default() -> Self {
6181        PassiveHealth {
6182            window_ms: 60_000,
6183            consecutive_failures: 5,
6184            failure_ratio: 0.5,
6185            failure_min_volume: 20,
6186            eject_ms: 30_000,
6187            half_open_probes: 1,
6188            recovery_ramp_ms: 0,
6189            body_completion_inflight: false,
6190        }
6191    }
6192}
6193
6194fn default_health_window_ms() -> u64 {
6195    60_000
6196}
6197
6198fn default_health_consecutive_failures() -> u32 {
6199    5
6200}
6201
6202fn default_health_failure_ratio() -> f64 {
6203    0.5
6204}
6205
6206fn default_health_failure_min_volume() -> u32 {
6207    20
6208}
6209
6210fn default_health_eject_ms() -> u64 {
6211    30_000
6212}
6213
6214fn default_health_half_open_probes() -> u32 {
6215    1
6216}
6217
6218/// Active health check knobs (DW-013). A block with no keys enables HTTP
6219/// probes with the defaults.
6220///
6221/// Probe semantics (frozen):
6222/// - `http` probes issue `GET {path}` over HTTP/1.1 DIRECTLY to the
6223///   endpoint (bypassing load balancing and the pooled client); success is
6224///   a 2xx status. Redirects (3xx) are NOT followed — a health endpoint
6225///   answering 3xx is treated as a failure (a load balancer must not chase
6226///   redirects to decide health).
6227/// - `tcp` probes succeed when a TCP connection completes within
6228///   `timeout_ms`.
6229/// - Full jitter: each loop sleeps `interval_ms` plus a uniform random
6230///   `0..jitter_ms` before the next probe.
6231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
6232#[serde(deny_unknown_fields)]
6233pub struct ActiveHealth {
6234    /// Probe kind (default `http`).
6235    #[serde(default = "default_probe_kind")]
6236    pub kind: ProbeKind,
6237    /// Path probed by `http` checks (default `/healthz`). Ignored by `tcp`.
6238    #[serde(
6239        default = "default_probe_path",
6240        skip_serializing_if = "is_default_probe_path"
6241    )]
6242    pub path: String,
6243    /// Time between probe attempts in milliseconds (default 5000). Must be
6244    /// >= `timeout_ms` and >= `jitter_ms`.
6245    #[serde(default = "default_probe_interval_ms")]
6246    pub interval_ms: u64,
6247    /// Per-probe timeout in milliseconds (default 2000), covering connect
6248    /// plus the response for http probes.
6249    #[serde(default = "default_probe_timeout_ms")]
6250    pub timeout_ms: u64,
6251    /// Consecutive probe SUCCESSES required to (re)admit an ejected
6252    /// endpoint (default 2).
6253    #[serde(default = "default_probe_success_threshold")]
6254    pub success_threshold: u32,
6255    /// Consecutive probe FAILURES required to eject a healthy endpoint
6256    /// (default 3). Reports the same per-endpoint streak the passive
6257    /// checker uses; see the active-health module docs for precedence.
6258    #[serde(default = "default_probe_failure_threshold")]
6259    pub failure_threshold: u32,
6260    /// Full-jitter bound in milliseconds (default 500): each loop sleeps a
6261    /// uniform random `0..jitter_ms` in addition to `interval_ms`. Must be
6262    /// <= `interval_ms`.
6263    #[serde(default = "default_probe_jitter_ms")]
6264    pub jitter_ms: u64,
6265}
6266
6267impl Default for ActiveHealth {
6268    fn default() -> Self {
6269        ActiveHealth {
6270            kind: default_probe_kind(),
6271            path: default_probe_path(),
6272            interval_ms: default_probe_interval_ms(),
6273            timeout_ms: default_probe_timeout_ms(),
6274            success_threshold: default_probe_success_threshold(),
6275            failure_threshold: default_probe_failure_threshold(),
6276            jitter_ms: default_probe_jitter_ms(),
6277        }
6278    }
6279}
6280
6281fn default_probe_kind() -> ProbeKind {
6282    ProbeKind::Http
6283}
6284
6285fn default_probe_path() -> String {
6286    "/healthz".to_string()
6287}
6288
6289fn is_default_probe_path(p: &str) -> bool {
6290    p == "/healthz"
6291}
6292
6293fn default_probe_interval_ms() -> u64 {
6294    5_000
6295}
6296
6297fn default_probe_timeout_ms() -> u64 {
6298    2_000
6299}
6300
6301fn default_probe_success_threshold() -> u32 {
6302    2
6303}
6304
6305fn default_probe_failure_threshold() -> u32 {
6306    3
6307}
6308
6309fn default_probe_jitter_ms() -> u64 {
6310    500
6311}
6312
6313/// Kind of active health probe.
6314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6315#[serde(rename_all = "lowercase")]
6316pub enum ProbeKind {
6317    /// HTTP/1.1 GET to `path`; success = 2xx.
6318    Http,
6319    /// HTTP/2 GET to `path` over a pooled H2 connection (REL-07, #218);
6320    /// for HTTP/2-only and gRPC upstreams that refuse HTTP/1.1 on a
6321    /// separate connection. Success = 2xx.
6322    Http2,
6323    /// TCP connect within the timeout.
6324    Tcp,
6325}
6326
6327fn default_load_balancer() -> LoadBalancer {
6328    LoadBalancer::RoundRobin
6329}
6330
6331#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6332#[serde(rename_all = "snake_case")]
6333pub enum LoadBalancer {
6334    RoundRobin,
6335    LeastRequests,
6336    Random,
6337    IpHash,
6338    /// Maglev consistent hashing (DP-07, #236): Google's Maglev algorithm
6339    /// uses a fixed-size lookup table (default 65537 entries) populated
6340    /// by each endpoint's permutation. Provides better distribution than
6341    /// ketama for large endpoint pools with tight balance bounds. The
6342    /// hash key is determined by `hash_on` (client IP, cookie, or header).
6343    Maglev,
6344    /// Peak-EWMA latency-aware load balancing (DW-090, Finagle-style):
6345    /// favors low-latency endpoints and degrades outliers. Each endpoint
6346    /// carries an atomic EWMA cost tracker (nanosecond resolution) updated
6347    /// with the Finagle peak-EWMA formula: a new RTT that EXCEEDS the
6348    /// current cost replaces it outright (the "peak"), otherwise the cost
6349    /// decays toward the new RTT at rate `exp(-td / tau)`. Selection picks
6350    /// the endpoint with the lowest `cost_ns * (inflight + 1)` score, so a
6351    /// slow endpoint with few in-flight requests can still win a pick
6352    /// while a fast one is saturated. Tunable via `upstreams[].peak_ewma`
6353    /// (decay tau and the initial cost); defaults are 10 s decay and a
6354    /// 250 ms initial cost. Carried across rebuilds by `address:port`,
6355    /// exactly like in-flight counters and health trackers.
6356    PeakEwma,
6357}
6358
6359/// What to hash for consistent-hash load balancers (`ip_hash` and
6360/// `maglev`). DP-07 (#236): extends the hash key source beyond the
6361/// client IP to support cookie-based sticky sessions and header-based
6362/// affinity.
6363#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6364#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
6365pub enum HashOn {
6366    /// Hash the client IP (the default behavior, backward-compatible
6367    /// with pre-#236 configs).
6368    #[default]
6369    ClientIp,
6370    /// Hash a cookie value. If the cookie is not present in the request,
6371    /// the gateway falls back to the client IP. Used for sticky sessions
6372    /// without the `services[].sticky` split mechanism.
6373    Cookie {
6374        /// The cookie name to read.
6375        cookie: String,
6376    },
6377    /// Hash a request header value. If the header is not present, falls
6378    /// back to the client IP. Common for routing by `X-User-Id` or
6379    /// `X-Session-Id` for session affinity.
6380    Header {
6381        /// The header name to read (case-insensitive).
6382        header: String,
6383    },
6384}
6385
6386fn default_upstream_protocol() -> UpstreamProtocol {
6387    UpstreamProtocol::Http1
6388}
6389
6390#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6391#[serde(rename_all = "snake_case")]
6392pub enum UpstreamProtocol {
6393    Http1,
6394    Http2,
6395    /// Cleartext HTTP/2 with prior knowledge (h2c, DP-02 #235). No TLS;
6396    /// the client speaks HTTP/2 directly over TCP. Common for internal
6397    /// east-west gRPC traffic without TLS.
6398    H2c,
6399    Https,
6400    /// HTTP/3 over QUIC (DW-108). QUIC mandates TLS 1.3, so an `h3`
6401    /// upstream always negotiates TLS with ALPN `h3`; the
6402    /// `trusted_ca_file` field selects the trust roots (the default
6403    /// webpki public set, or a private-CA bundle). Requires the `h3`
6404    /// cargo feature on dwara-bin to actually proxy: when the feature is
6405    /// off the protocol is accepted at validation but the upstream is
6406    /// inert (every dispatch fails closed with a clear error rather than
6407    /// silently falling back to a wrong transport).
6408    H3,
6409}
6410
6411/// One `address:port` inside an upstream.
6412#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
6413#[serde(deny_unknown_fields)]
6414pub struct Endpoint {
6415    pub address: String,
6416    pub port: u16,
6417    /// Relative traffic weight (default 1 for all endpoints).
6418    #[serde(
6419        default = "default_endpoint_weight",
6420        skip_serializing_if = "is_default_weight"
6421    )]
6422    pub weight: u32,
6423    /// DW-094 (Ent): the region this endpoint belongs to (e.g.
6424    /// `us-east-1`, `eu-west-1`). Used by locality-aware load balancing
6425    /// and data residency enforcement. Optional — endpoints without a
6426    /// region are treated as locality-agnostic (always eligible).
6427    #[serde(default, skip_serializing_if = "Option::is_none")]
6428    pub region: Option<String>,
6429    /// DW-094 (Ent): the zone within a region (e.g. `us-east-1a`).
6430    /// Used for zone-aware routing within a region. Optional.
6431    #[serde(default, skip_serializing_if = "Option::is_none")]
6432    pub zone: Option<String>,
6433}
6434
6435fn default_endpoint_weight() -> u32 {
6436    1
6437}
6438
6439fn is_default_weight(w: &u32) -> bool {
6440    *w == 1
6441}
6442
6443/// Timeout hints, in milliseconds.
6444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6445#[serde(deny_unknown_fields)]
6446pub struct Timeouts {
6447    /// Connect timeout in milliseconds.
6448    #[serde(skip_serializing_if = "Option::is_none")]
6449    pub connect_ms: Option<u64>,
6450    /// Read timeout in milliseconds.
6451    #[serde(skip_serializing_if = "Option::is_none")]
6452    pub read_ms: Option<u64>,
6453    /// Write timeout in milliseconds.
6454    #[serde(skip_serializing_if = "Option::is_none")]
6455    pub write_ms: Option<u64>,
6456    /// Happy-eyeballs inter-connection delay in milliseconds (DW-030,
6457    /// RFC 8305): when an endpoint's address resolves to multiple
6458    /// addresses, the first is dialed immediately and each subsequent
6459    /// address — alternating address families after the resolver's
6460    /// first — is diailed this long after the previous START; the first
6461    /// successful connection wins and cancels the losers. Absent: the
6462    /// RFC 8305 recommended 250 ms. `0` disables racing (addresses are
6463    /// tried strictly in resolver order, one at a time). The upstream's
6464    /// `connect_ms` remains the bound over the WHOLE dial: resolution
6465    /// plus every interleaved attempt plus, for TLS upstreams, the
6466    /// handshake. Only the overall dial's single outcome reaches
6467    /// breaker/passive-health accounting — the losing arms of one dial
6468    /// are never counted as endpoint failures. Validation bounds the
6469    /// value to at most 10 minutes.
6470    #[serde(skip_serializing_if = "Option::is_none")]
6471    pub happy_eyeballs_ms: Option<u64>,
6472}
6473
6474/// The principal kind of a consumer (DW-113): `user` (the default —
6475/// a human or application calling the gateway) or `agent` (a bot
6476/// principal with bot credentials, per-agent quotas/token budgets,
6477/// tool allowlists, and a session audit trail). Agent traffic is
6478/// identifiable, governable, and billable; the type threads through
6479/// Identity into analytics for typed attribution.
6480#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
6481#[serde(rename_all = "snake_case")]
6482pub enum ConsumerType {
6483    /// A human or application caller (the default).
6484    #[default]
6485    User,
6486    /// A bot principal: tool allowlists, per-agent token budgets,
6487    /// and typed analytics attribution apply.
6488    Agent,
6489}
6490
6491/// Identity of an API caller (app/team/user); owns credentials, quotas, and
6492/// the analytics identity.
6493#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
6494#[serde(deny_unknown_fields)]
6495pub struct Consumer {
6496    pub name: String,
6497    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6498    pub credentials: Vec<Credential>,
6499    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6500    pub policies: Vec<String>,
6501    /// The principal kind (DW-113): `user` (default) or `agent`.
6502    /// Agent consumers gain a tool allowlist, a per-agent token
6503    /// budget, and typed analytics attribution. The field is renamed
6504    /// to `type` in YAML (`type` is a Rust keyword).
6505    #[serde(default, rename = "type")]
6506    pub consumer_type: ConsumerType,
6507    /// MCP tool allowlist (DW-113): when non-empty, the consumer may
6508    /// only call the named tools through the MCP gateway. Empty (the
6509    /// default) = no restriction. Validation checks that every name
6510    /// references a configured `ai.mcp.tools` entry.
6511    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6512    pub tool_allowlist: Vec<String>,
6513    /// Per-agent AI token budget (DW-113): when set, this consumer's
6514    /// AI traffic is bounded by this budget BEFORE the policy chain
6515    /// (the most-specific budget). Same shape as a policy's
6516    /// `token_budget`. `None` (the default) = no per-agent budget;
6517    /// the policy chain governs as before.
6518    #[serde(default, skip_serializing_if = "Option::is_none")]
6519    pub token_budget: Option<TokenBudget>,
6520    /// Consumer priority class for load shedding (DW-016): 0 (lowest) to 10
6521    /// (highest). Stored and validated now; it takes effect only once
6522    /// authentication (DW-019/DW-020) identifies the consumer on a request —
6523    /// until then, shedding priority comes from the matched route (or the
6524    /// default 5). Consumer priority overrides the route's when known, but
6525    /// it does NOT trigger reserved-bucket carving today — only a
6526    /// high-priority route does.
6527    #[serde(default, skip_serializing_if = "Option::is_none")]
6528    pub priority: Option<u8>,
6529    /// Consumer group memberships (DW-020): group names this consumer
6530    /// belongs to, consulted by authorization `allowed_groups` /
6531    /// `denied_groups` rules. Empty (the default) = no groups. Group
6532    /// names are free-form strings; validation checks that authorization
6533    /// rules referencing groups resolve against at least one consumer's
6534    /// membership. Store-managed consumers carry their own groups in the
6535    /// state store (#124) — the group NAMESPACE is shared, but
6536    /// config-time validation can only see config consumers, so a rule
6537    /// referencing a group that exists solely on store consumers is
6538    /// flagged (grant at least one config consumer the same group name,
6539    /// or accept the issue is a false positive for store-only groups).
6540    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6541    pub groups: Vec<String>,
6542    /// Consumer-level authorization rules (#123): the MOST specific link
6543    /// of the frozen chain consumer > route > service > listener >
6544    /// global — applies to every request authenticated as this consumer
6545    /// (naturally only once authentication has identified it). A deny at
6546    /// any level wins; otherwise the most specific level with rules
6547    /// governs (see [`Authz`] and the `authz` module). Same validation
6548    /// rules as route-level authorization.
6549    #[serde(default, skip_serializing_if = "Option::is_none")]
6550    pub authorization: Option<Authz>,
6551    /// Request budgets for this consumer (DW-033): a daily and/or
6552    /// monthly request cap counted in fixed UTC calendar windows
6553    /// (midnight-to-midnight UTC; the first through the last instant of
6554    /// the UTC month). Budgets are DISTINCT from rate limits: a rate
6555    /// limit shapes traffic inside seconds or minutes, a budget bounds
6556    /// total volume across a day or month, and the two mechanisms run
6557    /// independently (both apply when both are configured). Enforcement
6558    /// needs the state store (`DWARA_STATE_DB`) for durable counters —
6559    /// without one, quota config is inert (logged at request time, see
6560    /// the `state::quotas` module docs). Store-managed consumers have no
6561    /// config record, so budgets are config-consumer-only in this
6562    /// edition; a distributed shared-counter variant is the Ent
6563    /// follow-up (issue "DW-155").
6564    #[serde(default, skip_serializing_if = "Option::is_none")]
6565    pub quotas: Option<ConsumerQuotas>,
6566    /// AI prompt/response logging override (DW-081). None = inherit
6567    /// the global `ai.logging.enabled` setting; Some(false) = disable
6568    /// capture for this consumer even if global is on; Some(true) =
6569    /// enable capture for this consumer even if global is off
6570    /// (requires the `ai.logging` block to exist — the redactor and
6571    /// retention are always needed). The per-consumer toggle respects
6572    /// tenant preference: a consumer with capture disabled produces
6573    /// no stored prompts/responses.
6574    #[serde(default, skip_serializing_if = "Option::is_none")]
6575    pub ai_logging: Option<bool>,
6576    /// AI-16 (#204): agent principal metadata. Only meaningful when
6577    /// `consumer_type` is `agent`; ignored otherwise. Carries the
6578    /// agent's display name, description, owner, and permission level
6579    /// for first-class agent principal attribution.
6580    #[serde(default, skip_serializing_if = "Option::is_none")]
6581    pub agent: Option<AgentPrincipal>,
6582}
6583
6584/// AI-16 (#204): First-class agent principal metadata. Attached to a
6585/// `Consumer` with `consumer_type: agent` to carry agent-specific
6586/// attribution and permission data.
6587#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6588#[serde(deny_unknown_fields)]
6589pub struct AgentPrincipal {
6590    /// The agent's display name (human-readable). Defaults to the
6591    /// consumer name when not set.
6592    #[serde(default, skip_serializing_if = "Option::is_none")]
6593    pub display_name: Option<String>,
6594    /// A human-readable description of the agent's purpose.
6595    #[serde(default, skip_serializing_if = "Option::is_none")]
6596    pub description: Option<String>,
6597    /// The owner of this agent (a user or team name). Used for
6598    /// attribution and audit.
6599    #[serde(default, skip_serializing_if = "Option::is_none")]
6600    pub owner: Option<String>,
6601    /// The agent's permission level. One of: `read_only`,
6602    /// `read_write`, `admin`. Default `read_only`.
6603    #[serde(default, skip_serializing_if = "Option::is_none")]
6604    pub permissions: Option<AgentPermissionLevel>,
6605}
6606
6607/// AI-16 (#204): Agent permission levels for first-class agent
6608/// principals. Mirrors the `Permission` enum in the MCP module but
6609/// lives in config for declarative assignment.
6610#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
6611#[serde(rename_all = "snake_case")]
6612pub enum AgentPermissionLevel {
6613    /// Read-only access (list, get, stats, health, config).
6614    #[default]
6615    ReadOnly,
6616    /// Read-write access (read + create, update, delete).
6617    ReadWrite,
6618    /// Full admin access (all tools including purge).
6619    Admin,
6620}
6621
6622/// Per-consumer request budgets (DW-033): daily and/or monthly request
6623/// caps in fixed UTC calendar windows, counted by the state store's
6624/// quota counters. At least one budget must be set and every set budget
6625/// must be > 0 (validation); a budget of 0 would deny the consumer's
6626/// first request and is expressed by omitting the field instead.
6627#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6628#[serde(deny_unknown_fields)]
6629pub struct ConsumerQuotas {
6630    /// Maximum requests per UTC calendar day (midnight to midnight).
6631    #[serde(skip_serializing_if = "Option::is_none")]
6632    pub daily_requests: Option<u64>,
6633    /// Maximum requests per UTC calendar month.
6634    #[serde(skip_serializing_if = "Option::is_none")]
6635    pub monthly_requests: Option<u64>,
6636    /// SEC-14 / CFG-14: dry-run mode. When true, quota counters are
6637    /// evaluated and would-be rejections are recorded
6638    /// (`dwara_policy_dry_run_total{phase="quota"}` + a structured
6639    /// log) but the request is NOT rejected — it proceeds even when
6640    /// the quota is exceeded. Useful for calibrating quota limits
6641    /// before enforcing. Default false (reject on quota exceeded).
6642    #[serde(default, skip_serializing_if = "is_false")]
6643    pub dry_run: bool,
6644}
6645
6646/// One authenticator bound to a consumer: API key, JWT issuer/audience
6647/// binding, mTLS client-certificate match, or HMAC signing key (DW-036).
6648///
6649/// `Debug` is manual (DW-045): the api-key and hmac-secret values are
6650/// SECRET and a derived impl would print them — this keeps the config
6651/// tree safe to Debug-log as a whole (`Gateway` and every holder derive
6652/// `Debug` over this type).
6653#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
6654#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
6655pub enum Credential {
6656    ApiKey {
6657        /// The API key; hashed at rest by the state layer, never logged.
6658        /// Either the value INLINE (accepted, but redacted to a
6659        /// `${redacted:...}` placeholder in every config echo — admin
6660        /// `GET /config`, DW-045) or a reference resolved at
6661        /// config-compile time: `${ENV_NAME}` (an environment variable)
6662        /// or `${file:/path/to/secret}` (one trailing newline trimmed).
6663        /// References are re-read on every reload; an unresolvable
6664        /// reference fails validation naming this field.
6665        key: String,
6666    },
6667    /// HMAC request-signing credential (DW-036): the consumer presents
6668    /// per-request signatures over the canonical string documented in
6669    /// the `security::authn` module docs, carried in the
6670    /// `X-Dwara-Signature` header family. Unlike API keys the secret
6671    /// cannot be hash-at-rest stored — recomputing an HMAC needs the
6672    /// RAW key bytes — so this credential is config-declared only (the
6673    /// state store's hashed rows cannot serve it) and the resolved
6674    /// bytes live solely in the authenticator's memory, zeroized on
6675    /// drop. The pepper (#124) deliberately does NOT apply here: it
6676    /// guards stored hashes, and there is no stored hash to guard.
6677    Hmac {
6678        /// Public key identifier: the `X-Dwara-Key-Id` header value the
6679        /// client presents to SELECT this credential (the AWS
6680        /// access-key-id shape — it names the key, it is not secret).
6681        /// Non-empty visible ASCII, at most 128 bytes; unique across
6682        /// every consumer (validation rejects duplicates — an ambiguous
6683        /// selector cannot pick a consumer deterministically).
6684        key_id: String,
6685        /// The HMAC-SHA256 signing secret. INLINE values are accepted
6686        /// (redacted in every config echo, like api keys) but
6687        /// `${ENV_NAME}` / `${file:/path/to/secret}` references are the
6688        /// recommended shape; resolved at config-compile time and
6689        /// re-read on every reload. Must be non-empty; use at least 32
6690        /// bytes of entropy (shorter keys weaken the MAC construction
6691        /// — documented, not enforced, the same posture as the pepper).
6692        secret: String,
6693    },
6694    Jwt {
6695        issuer: String,
6696        #[serde(default, skip_serializing_if = "Vec::is_empty")]
6697        audiences: Vec<String>,
6698    },
6699    Mtls {
6700        /// SHA-256 fingerprint (lowercase hex of the DER) of the client
6701        /// certificate. Optional when `subject` is set (#124); a
6702        /// fingerprint match binds the credential to ONE exact
6703        /// certificate (a re-issued cert needs a new credential).
6704        #[serde(default, skip_serializing_if = "Option::is_none")]
6705        fingerprint: Option<String>,
6706        /// Subject CommonName of the client certificate (#124): the
6707        /// credential matches any verified client certificate whose
6708        /// subject CN is exactly this string (case-sensitive). Binding
6709        /// by subject survives certificate re-issue under the same CN;
6710        /// the certificate must still chain to a listener's
6711        /// `client_ca_file` (or be otherwise verified) at the TLS layer
6712        /// — the matcher only maps an ALREADY-VERIFIED certificate to a
6713        /// consumer. Exactly one of `subject` / `fingerprint` must be
6714        /// set (validation rejects both-empty and both-set).
6715        #[serde(default, skip_serializing_if = "Option::is_none")]
6716        subject: Option<String>,
6717    },
6718}
6719
6720impl std::fmt::Debug for Credential {
6721    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6722        // DW-045: the api-key and hmac-secret values are the inline
6723        // secret material in the schema; everything else is binding
6724        // metadata (issuer, fingerprint, subject, key id) that
6725        // validation matches, not a secret. Redact exactly the secrets
6726        // and keep the variant shape so debug output stays useful.
6727        match self {
6728            Credential::ApiKey { .. } => f
6729                .debug_struct("ApiKey")
6730                .field("key", &"[redacted]")
6731                .finish(),
6732            Credential::Hmac { key_id, .. } => f
6733                .debug_struct("Hmac")
6734                .field("key_id", key_id)
6735                .field("secret", &"[redacted]")
6736                .finish(),
6737            Credential::Jwt { issuer, audiences } => f
6738                .debug_struct("Jwt")
6739                .field("issuer", issuer)
6740                .field("audiences", audiences)
6741                .finish(),
6742            Credential::Mtls {
6743                subject,
6744                fingerprint,
6745            } => f
6746                .debug_struct("Mtls")
6747                .field("subject", subject)
6748                .field("fingerprint", fingerprint)
6749                .finish(),
6750        }
6751    }
6752}
6753
6754/// Named reusable rule bundle (rate limit, timeouts, ...); attachable at
6755/// several scopes. Plugin-backed phases arrive with the M3 plugin system.
6756#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
6757#[serde(deny_unknown_fields)]
6758pub struct Policy {
6759    pub name: String,
6760    #[serde(skip_serializing_if = "Option::is_none")]
6761    pub rate_limit: Option<RateLimit>,
6762    /// Stacked GCRA rate-limit rules (DW-017). Each rule stacks one or
6763    /// more windows (e.g. `s` AND `hour`); a request is admitted only if
6764    /// EVERY window of EVERY applicable rule allows it. The legacy
6765    /// single-window `rate_limit` field above still applies when set (see
6766    /// its mapping in the rate-limiter module docs); use `rate_limits`
6767    /// for new configs.
6768    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6769    pub rate_limits: Vec<RateLimitRule>,
6770    #[serde(skip_serializing_if = "Option::is_none")]
6771    pub timeouts: Option<Timeouts>,
6772    /// Monitor mode (DW-041) for this bundle's rate-limit rules: they
6773    /// still EVALUATE (their GCRA buckets advance exactly as if
6774    /// enforcing, so denial rates reflect what enforcement would do),
6775    /// but a request they would deny is LOGGED and counted
6776    /// (`dwara_policy_dry_run_total{phase="rate_limit"}` + a
6777    /// `dwara::policy` warn event) and PROCEEDS — no 429, and none of
6778    /// the rule's `X-RateLimit-*` headers on the response. LIVE policies
6779    /// attached to the same request still enforce: the flag mutes only
6780    /// this bundle's own denials. The flag sits on the named bundle (not
6781    /// on each `policies: [...]` attachment list entry) because rate
6782    /// limits attach BY NAME at the five precedence levels — marking the
6783    /// bundle dries every attachment of it uniformly. Timeouts carry no
6784    /// rejection and are unaffected.
6785    #[serde(default, skip_serializing_if = "is_false")]
6786    pub dry_run: bool,
6787    /// AI token budget (DW-078): caps provider-reported tokens per
6788    /// minute and/or priced cost per UTC day for the budget holder.
6789    /// Distinct from every rule above: those count REQUESTS, this
6790    /// counts TOKENS. Resolution follows the frozen precedence chain
6791    /// (consumer > route > service > listener > global) with the MOST
6792    /// SPECIFIC level's budget governing (a limit-of-totals, not an
6793    /// AND-composed rule). `scope: consumer` (default) keys the budget
6794    /// to each attaching consumer; `scope: policy` makes one SHARED
6795    /// (team) budget keyed by the policy name. Consumers with no
6796    /// binding budget are unlimited.
6797    #[serde(default, skip_serializing_if = "Option::is_none")]
6798    pub token_budget: Option<TokenBudget>,
6799    /// Anomaly scoring (DW-090): lightweight statistical detection of
6800    /// abusive request patterns (header entropy, request-shape outliers).
6801    /// When enabled, every matched request is scored against the
6802    /// configured signals; a score at or above `threshold` is blocked
6803    /// (403 `anomaly_blocked`) unless `dry_run` is set (scored and
6804    /// logged, request proceeds). Resolution follows the frozen
6805    /// precedence chain (consumer > route > service > listener > global);
6806    /// every level with an attachment applies and the applicable rules
6807    /// AND together. Absent (the default): no anomaly scoring.
6808    #[serde(default, skip_serializing_if = "Option::is_none")]
6809    pub anomaly: Option<AnomalyPolicy>,
6810    /// Adaptive + origin-driven rate-limit tuning (DW-089): an EWMA of
6811    /// upstream error rate and latency dynamically scales this policy's
6812    /// rate-limit quotas, and upstream-originated signals (`Retry-After`)
6813    /// trigger an immediate backoff for the advertised window. The
6814    /// adaptive factor is bounded `[min_factor, max_factor]` (never 0,
6815    /// never unbounded); tightening under stress is faster than relaxing
6816    /// when healthy (asymmetric). Absent (the default): no adaptive
6817    /// tuning, the policy's quotas apply as configured.
6818    #[serde(default, skip_serializing_if = "Option::is_none")]
6819    pub adaptive: Option<AdaptiveRateLimit>,
6820}
6821
6822/// An AI token budget (DW-078, `policies[].token_budget`): limits of
6823/// TOTALS over windows — provider-reported tokens per minute and/or
6824/// cost per UTC calendar day, enforced per consumer or shared per
6825/// policy (team). See the `ai::budget` module docs for the
6826/// check-then-spend contract.
6827#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6828#[serde(deny_unknown_fields)]
6829pub struct TokenBudget {
6830    /// Maximum provider-reported tokens per fixed 60-second window
6831    /// (epoch-minute aligned). Spent tokens come from the provider's
6832    /// own usage reports — the gateway never estimates.
6833    #[serde(default, skip_serializing_if = "Option::is_none")]
6834    pub tokens_per_min: Option<u64>,
6835    /// Maximum spend per UTC calendar day, in integer micro-USD
6836    /// (1_000_000 = $1.00 — no floating-point money). Enforced end to
6837    /// end; the pricing input arrives with the DW-079 pricing tables,
6838    /// so the cost window is enforced-but-inert until then.
6839    #[serde(default, skip_serializing_if = "Option::is_none")]
6840    pub cost_per_day_micros: Option<u64>,
6841    /// Whose budget this is: `consumer` (default — each attaching
6842    /// consumer gets its own window) or `policy` (one SHARED team
6843    /// budget keyed by the policy name).
6844    #[serde(
6845        default = "default_token_budget_scope",
6846        skip_serializing_if = "is_default_token_budget_scope"
6847    )]
6848    pub scope: TokenBudgetScope,
6849}
6850
6851/// The budget's key scope (DW-078).
6852#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6853#[serde(rename_all = "lowercase")]
6854pub enum TokenBudgetScope {
6855    /// One budget per attaching consumer.
6856    Consumer,
6857    /// One shared (team) budget per policy.
6858    Policy,
6859}
6860
6861fn default_token_budget_scope() -> TokenBudgetScope {
6862    TokenBudgetScope::Consumer
6863}
6864
6865fn is_default_token_budget_scope(s: &TokenBudgetScope) -> bool {
6866    *s == TokenBudgetScope::Consumer
6867}
6868
6869/// One rate-limit rule (DW-017): a key selector plus one or more stacked
6870/// windows. `selector` names the request attributes that form the
6871/// counter key (all listed attributes are joined into ONE key, so
6872/// `[ip, route]` limits each (client IP, route) pair independently);
6873/// `requests_per` carries the sustained rates per window (at least one
6874/// window must be present); `burst` is the bucket size (defaults to the
6875/// window's request count).
6876#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
6877#[serde(deny_unknown_fields)]
6878pub struct RateLimitRule {
6879    /// Optional label (documentation only; not part of the key).
6880    #[serde(skip_serializing_if = "Option::is_none")]
6881    pub name: Option<String>,
6882    /// Key components: `ip`, `credential`, and/or `route` (at least one;
6883    /// order does not matter). `credential` falls back to the client IP
6884    /// until authentication (DW-019) identifies consumers.
6885    pub selector: Vec<RateLimitSelector>,
6886    pub requests_per: RateRequestsPer,
6887    /// Bucket size (burst capacity); must be >= 1 when present. Defaults
6888    /// to the window's request count.
6889    #[serde(skip_serializing_if = "Option::is_none")]
6890    pub burst: Option<u32>,
6891}
6892
6893/// One attribute of a rate-limit key (DW-017).
6894#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6895#[serde(rename_all = "lowercase")]
6896pub enum RateLimitSelector {
6897    /// The direct connection peer (the same IP used for X-Real-IP).
6898    Ip,
6899    /// The authenticated consumer; until DW-019 this falls back to `ip`.
6900    Credential,
6901    /// The matched route's name.
6902    Route,
6903}
6904
6905/// Sustained rates per window (DW-017). At least one field must be set
6906/// and every set field must be > 0; each set field becomes one stacked
6907/// GCRA cell (a request must satisfy ALL set windows).
6908#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
6909#[serde(deny_unknown_fields)]
6910pub struct RateRequestsPer {
6911    /// Requests per second.
6912    #[serde(rename = "s", skip_serializing_if = "Option::is_none")]
6913    pub per_second: Option<u32>,
6914    /// Requests per minute.
6915    #[serde(skip_serializing_if = "Option::is_none")]
6916    pub minute: Option<u32>,
6917    /// Requests per hour.
6918    #[serde(skip_serializing_if = "Option::is_none")]
6919    pub hour: Option<u32>,
6920}
6921
6922/// Local GCRA-style rate limit.
6923#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
6924#[serde(deny_unknown_fields)]
6925pub struct RateLimit {
6926    /// Maximum requests allowed per window.
6927    pub requests: u64,
6928    /// Window length in seconds.
6929    pub window_seconds: u64,
6930}
6931
6932/// Parse a YAML configuration document into a [`Gateway`], rejecting invalid
6933/// input with path-precise error messages.
6934///
6935/// Error-path guarantee: serde-level failures (type mismatches, unknown
6936/// fields, missing fields) carry a precise dotted path from
6937/// `serde_path_to_error`, e.g. `routes[0].action`. Raw YAML syntax errors
6938/// are detected by the scanner before path tracking applies, so their path
6939/// is coarse (often the root); the precise location is still available as
6940/// line/column inside the message text itself.
6941pub fn parse_gateway(text: &str) -> Result<Gateway, ConfigError> {
6942    let de = serde_path_to_error::deserialize(serde_yaml_ng::Deserializer::from_str(text))
6943        .map_err(|e| ConfigError {
6944            path: e.path().to_string(),
6945            message: e.inner().to_string(),
6946        })?;
6947    Ok(de)
6948}
6949
6950/// Serialize a [`Gateway`] to normalized YAML text.
6951///
6952/// Field order follows struct declaration order; defaulted-empty collections
6953/// are omitted, so output is stable for a given typed value.
6954pub fn gateway_to_yaml(gateway: &Gateway) -> Result<String, serde_yaml_ng::Error> {
6955    serde_yaml_ng::to_string(gateway)
6956}
6957
6958/// Build the JSON Schema for the root [`Gateway`] type.
6959pub fn json_schema() -> schemars::Schema {
6960    schemars::schema_for!(Gateway)
6961}
6962
6963// --- DW-055: proxy-wasm plugin config -------------------------------------
6964
6965/// A plugin definition (DW-055 / DW-119). Each plugin is either a
6966/// proxy-wasm `.wasm` module loaded at startup (`wasm`) or a compiled-in
6967/// native Rust filter (`native`), run on the request pipeline phases it
6968/// declares. Routes reference plugins by name. Exactly one of `wasm` or
6969/// `native` must be set (validation enforces this).
6970#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
6971#[serde(deny_unknown_fields)]
6972pub struct PluginConfig {
6973    /// Unique plugin name. Referenced by routes' `plugins` field.
6974    pub name: String,
6975    /// Path to the .wasm module file. Must be readable at startup.
6976    /// Mutually exclusive with `native`; exactly one of the two must be
6977    /// set. Absent when this is a native filter.
6978    #[serde(default, skip_serializing_if = "Option::is_none")]
6979    pub wasm: Option<String>,
6980    /// Registered native filter implementation name (DW-119). The
6981    /// `plugins` cargo feature must be enabled and a factory registered
6982    /// under this name at startup. Mutually exclusive with `wasm`;
6983    /// exactly one of the two must be set. Absent when this is a WASM
6984    /// plugin.
6985    #[serde(default, skip_serializing_if = "Option::is_none")]
6986    pub native: Option<String>,
6987    /// SCALE-12 (#192)/DW-165: remote source for this plugin. When
6988    /// set, the gateway resolves the artifact at every publish
6989    /// (startup and reload): cache-first, downloading on a miss, then
6990    /// verifying the mandatory SHA-256 digest and any configured
6991    /// Ed25519 signature BEFORE the wasm load step. A source that
6992    /// cannot be resolved or fails verification never loads (the
6993    /// plugin reads Crashed; its routes fail closed). Mutually
6994    /// exclusive with `native` and `wasm`. See
6995    /// [`PluginSourceConfig`] and the plugin registry guide for the
6996    /// cache layout and the registry spec.
6997    #[serde(default, skip_serializing_if = "Option::is_none")]
6998    pub source: Option<PluginSourceConfig>,
6999    /// Phases this plugin hooks. Must be a non-empty subset of:
7000    /// `request_headers`, `request_body`, `response_headers`,
7001    /// `response_body`. The host calls the plugin's corresponding
7002    /// `proxy_on_*` export at each declared phase (WASM) or the
7003    /// `NativeFilter` trait method (native).
7004    #[serde(default, skip_serializing_if = "Vec::is_empty")]
7005    pub phases: Vec<PluginPhase>,
7006    /// Plugin-specific configuration, passed to the plugin's
7007    /// `proxy_on_configure` as a byte string (WASM) or to the native
7008    /// filter's factory (native). Typically a JSON or YAML blob the
7009    /// plugin parses itself.
7010    #[serde(default, skip_serializing_if = "Option::is_none")]
7011    pub config: Option<String>,
7012    /// Resource limits for this plugin (fuel, memory, time). Absent
7013    /// uses the defaults: 1M fuel, 32MB memory, 100ms timeout. Only
7014    /// meaningful for WASM plugins (native filters run in-process and
7015    /// are bounded by the gateway's own resource limits).
7016    #[serde(default, skip_serializing_if = "Option::is_none")]
7017    pub limits: Option<PluginLimitsConfig>,
7018}
7019
7020/// SCALE-12 (#192)/DW-165: remote/signed plugin source configuration.
7021/// The gateway resolves the .wasm artifact at every publish: cache
7022/// lookup first, then a download from `url` on a miss, then SHA-256
7023/// `digest` verification (mandatory) and Ed25519 `signature`
7024/// verification (when configured) — only then is the artifact loaded.
7025/// Resolution failures are fail-closed per plugin (Crashed at publish,
7026/// its routes answer 500), never a partial load.
7027#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7028#[serde(deny_unknown_fields)]
7029pub struct PluginSourceConfig {
7030    /// The registry URL to fetch the .wasm artifact from. Must be
7031    /// `https://`; fetched with one HTTP/1.1 GET (redirects are not
7032    /// followed — configure the artifact's final URL). `oci://`
7033    /// references are not supported in this version: publish the
7034    /// artifact over https or pre-fetch it with `dwara-cli plugin
7035    /// install` and reference the local file with `wasm`.
7036    pub url: String,
7037    /// Expected SHA-256 digest of the artifact, hex-encoded (64
7038    /// chars, case-insensitive). Verified against the cached AND the
7039    /// downloaded bytes before loading; a mismatch fails the plugin
7040    /// (never a partial load). Required — there are no unsigned
7041    /// remote artifacts (digest-pinning is the floor; signatures are
7042    /// the ceiling).
7043    pub digest: String,
7044    /// Optional Ed25519 signature over the artifact bytes, hex-encoded
7045    /// (64 bytes / 128 chars). When set, it must verify against
7046    /// `public_key` and, when `plugin_registry.public_keys` pins
7047    /// keys, against a pinned key too. Absent means digest-only
7048    /// verification — rejected when the registry pins public keys.
7049    #[serde(default, skip_serializing_if = "Option::is_none")]
7050    pub signature: Option<String>,
7051    /// Ed25519 public key (32 bytes, hex-encoded) for signature
7052    /// verification. Required when `signature` is set unless
7053    /// `plugin_registry.public_keys` pins keys. A `public_key` set
7054    /// WITHOUT a `signature` is rejected (a key that verifies nothing
7055    /// is a config mistake — sign the artifact or remove the key).
7056    #[serde(default, skip_serializing_if = "Option::is_none")]
7057    pub public_key: Option<String>,
7058    /// Local cache path for the resolved artifact. When absent, the
7059    /// artifact is cached content-addressed as
7060    /// `<cache_dir>/<digest>.wasm` (`plugin_registry.cache_dir`,
7061    /// default `./plugin-cache`). When set, the path must be a
7062    /// RELATIVE path under the cache dir with no `..` parent
7063    /// segments (containment: the resolver creates directories and
7064    /// writes artifact bytes at this path, so it must not be able to
7065    /// point outside the cache). The cached file is re-verified
7066    /// (digest and signature) on every publish; a cached file that
7067    /// does not match the pinned digest fails closed instead of being
7068    /// silently re-fetched.
7069    #[serde(default, skip_serializing_if = "Option::is_none")]
7070    pub cache_path: Option<String>,
7071}
7072
7073impl PluginSourceConfig {
7074    /// Why a `cache_path` value violates cache containment, or `None`
7075    /// when it is acceptable. The rule is deliberately simple: a
7076    /// non-empty RELATIVE path with no `..` parent segments — the
7077    /// resolver creates directories and writes artifact bytes at this
7078    /// path, so it must stay inside `plugin_registry.cache_dir`.
7079    /// Shared by snapshot validation (the operator-facing, earliest
7080    /// rejection) and the resolver (defense in depth); lives here
7081    /// because both may import `config` and nothing below it.
7082    pub fn cache_path_violation(cache_path: &str) -> Option<&'static str> {
7083        if cache_path.trim().is_empty() {
7084            Some("the path is empty")
7085        } else if std::path::Path::new(cache_path).is_absolute() {
7086            Some("the path is absolute")
7087        } else if std::path::Path::new(cache_path)
7088            .components()
7089            .any(|c| matches!(c, std::path::Component::ParentDir))
7090        {
7091            Some("the path contains '..' parent segments")
7092        } else {
7093            None
7094        }
7095    }
7096}
7097
7098/// SCALE-12 (#192)/DW-165: plugin registry configuration. When
7099/// present, signatures of `source:`-referenced artifacts are verified
7100/// against the registry's pinned public keys and resolved artifacts
7101/// are cached in the registry's cache directory. This enables
7102/// fleet-consistent plugin pinning: all gateways in a fleet reference
7103/// the same registry and get the same signed artifacts.
7104#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
7105#[serde(deny_unknown_fields)]
7106pub struct PluginRegistryConfig {
7107    /// Registry base URL (e.g., `https://registry.example.com/plugins`).
7108    /// Informational for fleet tooling (`dwara-cli plugin
7109    /// search/install` default); each plugin's `source.url` is a full
7110    /// URL.
7111    #[serde(default, skip_serializing_if = "Option::is_none")]
7112    pub base_url: Option<String>,
7113    /// Pinned Ed25519 public keys (hex-encoded, 32 bytes each). When
7114    /// set, plugin signatures are verified against these keys in
7115    /// addition to any per-plugin `public_key`, and a plugin with no
7116    /// signature is rejected (unsigned artifacts cannot ride a
7117    /// pinned-key registry).
7118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
7119    pub public_keys: Vec<String>,
7120    /// Local cache directory for resolved artifacts, content-addressed
7121    /// as `<cache_dir>/<digest>.wasm`. Default: `./plugin-cache`
7122    /// (relative to the gateway working directory, the same posture
7123    /// as the ACME state directory).
7124    #[serde(default, skip_serializing_if = "Option::is_none")]
7125    pub cache_dir: Option<String>,
7126}
7127
7128/// The phases a plugin can hook (DW-055, §9.3 phase contract).
7129#[derive(
7130    Clone,
7131    Copy,
7132    Debug,
7133    PartialEq,
7134    Eq,
7135    std::hash::Hash,
7136    serde::Serialize,
7137    serde::Deserialize,
7138    schemars::JsonSchema,
7139)]
7140#[serde(rename_all = "snake_case")]
7141pub enum PluginPhase {
7142    /// After route resolution, before authn.
7143    RequestHeaders,
7144    /// After authn/authz/rate-limit, before upstream.
7145    RequestBody,
7146    /// After the upstream responds, before masking.
7147    ResponseHeaders,
7148    /// After masking, before compression.
7149    ResponseBody,
7150}
7151
7152impl PluginPhase {
7153    /// The phase's stable wire label (the serde name), for the plugin
7154    /// status surface (`GET /plugins`, DW-158).
7155    pub fn as_str(self) -> &'static str {
7156        match self {
7157            PluginPhase::RequestHeaders => "request_headers",
7158            PluginPhase::RequestBody => "request_body",
7159            PluginPhase::ResponseHeaders => "response_headers",
7160            PluginPhase::ResponseBody => "response_body",
7161        }
7162    }
7163}
7164
7165/// SCALE-12 (#191): formalized request-pipeline filter phases. The
7166/// default order is the order the variants are declared (the same
7167/// order the dataplane has always executed them). Per-route
7168/// `filter_chain` overrides can reorder a subset of these phases;
7169/// phases not listed in an override keep their default relative
7170/// order.
7171#[derive(
7172    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
7173)]
7174#[serde(rename_all = "snake_case")]
7175pub enum FilterPhase {
7176    /// IP ACL and consumer ACL checks.
7177    Acl,
7178    /// Rate limiting (per-consumer and per-route limiters).
7179    RateLimit,
7180    /// Authentication (API key, JWT, OIDC, mTLS).
7181    Authn,
7182    /// Authorization (route/service/listener/global policy chain).
7183    Authz,
7184    /// Request body validation (JSON schema, size limits).
7185    Validate,
7186    /// Request transforms (path rewrite, header/query transforms).
7187    Transform,
7188    /// Response cache lookup.
7189    Cache,
7190    /// Route dispatch (proxy, redirect, respond, ai, etc.).
7191    Route,
7192}
7193
7194impl FilterPhase {
7195    /// The default pipeline order (the order the dataplane has
7196    /// always executed the phases).
7197    pub const DEFAULT_ORDER: [FilterPhase; 8] = [
7198        FilterPhase::Acl,
7199        FilterPhase::RateLimit,
7200        FilterPhase::Authn,
7201        FilterPhase::Authz,
7202        FilterPhase::Validate,
7203        FilterPhase::Transform,
7204        FilterPhase::Cache,
7205        FilterPhase::Route,
7206    ];
7207
7208    pub fn as_str(self) -> &'static str {
7209        match self {
7210            FilterPhase::Acl => "acl",
7211            FilterPhase::RateLimit => "rate_limit",
7212            FilterPhase::Authn => "authn",
7213            FilterPhase::Authz => "authz",
7214            FilterPhase::Validate => "validate",
7215            FilterPhase::Transform => "transform",
7216            FilterPhase::Cache => "cache",
7217            FilterPhase::Route => "route",
7218        }
7219    }
7220}
7221
7222/// SCALE-12 (#191): per-phase dry-run configuration. When dry-run is
7223/// enabled for a phase, the phase executes its checks but does NOT
7224/// enforce (reject/block). Failures are logged as warnings and the
7225/// request continues. This enables safe policy rollout: enable a
7226/// phase in dry-run mode, observe the would-be rejections, then
7227/// switch to enforcement.
7228#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
7229#[serde(deny_unknown_fields)]
7230pub struct FilterChainConfig {
7231    /// Phases to run in dry-run mode (log but don't enforce). Each
7232    /// entry is a phase name from [`FilterPhase`]. A phase in
7233    /// dry-run mode still executes its logic; the difference is that
7234    /// a rejection is logged as a warning and the request continues.
7235    #[serde(default, skip_serializing_if = "Vec::is_empty")]
7236    pub dry_run: Vec<FilterPhase>,
7237    /// Optional ordering override: a list of phase names in the
7238    /// order they should execute. Phases not listed keep their
7239    /// default relative order. The override must contain exactly the
7240    /// same set of phases as the default (no duplicates, no unknown
7241    /// phases); validation rejects invalid overrides.
7242    #[serde(default, skip_serializing_if = "Option::is_none")]
7243    pub order: Option<Vec<FilterPhase>>,
7244}
7245
7246/// Resource limits for a proxy-wasm plugin (DW-055 decision 4; §9.3).
7247#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
7248#[serde(deny_unknown_fields)]
7249pub struct PluginLimitsConfig {
7250    /// Maximum fuel (wasmtime operations). Default: 1,000,000.
7251    #[serde(default, skip_serializing_if = "Option::is_none")]
7252    pub fuel: Option<u64>,
7253    /// Maximum linear memory in MB. Default: 32.
7254    #[serde(default, skip_serializing_if = "Option::is_none")]
7255    pub memory_mb: Option<usize>,
7256    /// Maximum execution time in milliseconds. Default: 100.
7257    #[serde(default, skip_serializing_if = "Option::is_none")]
7258    pub timeout_ms: Option<u64>,
7259}
7260
7261/// Anomaly scoring policy (DW-090, `policies[].anomaly`): lightweight
7262/// statistical detection of abusive request patterns. Each enabled
7263/// signal produces a normalized [0, 1] score from the request shape
7264/// (header entropy, header count/bytes, path length/depth, query count,
7265/// body size, unusual method); the overall score is the average of the
7266/// configured signals' scores. A score at or above `threshold` is
7267/// blocked (403 `anomaly_blocked`) unless `dry_run` is set.
7268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
7269#[serde(deny_unknown_fields)]
7270pub struct AnomalyPolicy {
7271    /// Whether anomaly scoring is enabled (default true). A `policies[].anomaly`
7272    /// block with `enabled: false` is a no-op (the scorer is not compiled).
7273    #[serde(default = "default_true")]
7274    pub enabled: bool,
7275    /// Monitor mode (DW-041 synergy): the score is computed and the
7276    /// would-block is logged + counted, but the request proceeds. Default
7277    /// false (enforce).
7278    #[serde(default)]
7279    pub dry_run: bool,
7280    /// The signals to score. Must be non-empty when `enabled` is true.
7281    /// Each signal contributes one normalized [0, 1] sub-score; the
7282    /// overall score is their average.
7283    pub signals: Vec<AnomalySignal>,
7284    /// Block threshold in [0, 1] (default 0.8). A score >= threshold is
7285    /// blocked (or logged in dry-run mode). Must be in (0, 1].
7286    #[serde(default = "default_anomaly_threshold")]
7287    pub threshold: f64,
7288    /// Maximum body bytes to inspect for the `body_size` signal (default
7289    /// 4096). The signal scores `min(body_len, cap) / cap`, so a body
7290    /// at or beyond the cap scores 1.0. Must be > 0.
7291    #[serde(default = "default_max_body_inspect")]
7292    pub max_body_inspect_bytes: u64,
7293}
7294
7295/// One anomaly signal (DW-090). Each produces a normalized [0, 1]
7296/// sub-score from one request-shape dimension.
7297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7298#[serde(rename_all = "snake_case")]
7299pub enum AnomalySignal {
7300    /// Shannon entropy of concatenated header values (normalized by 8,
7301    /// the max entropy for byte values). High entropy can indicate
7302    /// randomized/obfuscated header payloads.
7303    HeaderEntropy,
7304    /// Header count outlier: `count / 50`, capped at 1.0.
7305    HeaderCount,
7306    /// Total header bytes outlier: `bytes / 8192`, capped at 1.0.
7307    HeaderBytes,
7308    /// Path length outlier: `len / 1024`, capped at 1.0.
7309    PathLength,
7310    /// Path depth outlier: segment count / 20, capped at 1.0.
7311    PathDepth,
7312    /// Query parameter count outlier: `count / 50`, capped at 1.0.
7313    QueryCount,
7314    /// Body size outlier: `min(body_len, cap) / cap`, capped at 1.0.
7315    BodySize,
7316    /// Unusual HTTP method: 1.0 if the method is not one of GET, POST,
7317    /// PUT, DELETE, PATCH, HEAD, OPTIONS; 0.0 otherwise.
7318    MethodUnusual,
7319}
7320
7321fn default_anomaly_threshold() -> f64 {
7322    0.8
7323}
7324
7325fn default_max_body_inspect() -> u64 {
7326    4096
7327}
7328
7329// --- DW-089: adaptive + origin-driven rate-limit tuning -------------------
7330
7331/// Adaptive + origin-driven rate-limit tuning (DW-089,
7332/// `policies[].adaptive`): an EWMA of upstream error rate and latency
7333/// dynamically scales this policy's rate-limit quotas at check time
7334/// (the factor multiplies the effective cost of each request against
7335/// the GCRA bucket), and upstream-originated signals trigger an
7336/// immediate backoff. The factor is bounded `[min_factor, max_factor]`
7337/// (never 0, never unbounded); tightening under stress is faster than
7338/// relaxing when healthy (asymmetric, so the gateway errs toward
7339/// protecting upstreams). `origin_signals` selects which upstream
7340/// response signals the controller honors (currently `retry_after`).
7341#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
7342#[serde(deny_unknown_fields)]
7343pub struct AdaptiveRateLimit {
7344    /// Whether adaptive tuning is enabled (default true). A
7345    /// `policies[].adaptive` block with `enabled: false` is a no-op
7346    /// (the controller is not compiled for that policy).
7347    #[serde(default = "default_true")]
7348    pub enabled: bool,
7349    /// EWMA decay window in seconds for error-rate and latency tracking
7350    /// (default 60). A larger window smooths transient spikes; a smaller
7351    /// window reacts faster. Must be > 0.
7352    #[serde(default = "default_ewma_window_secs")]
7353    pub ewma_window_secs: u64,
7354    /// Minimum adaptive factor (default 0.1): the tightest the quotas
7355    /// can shrink to (10% of the configured rate). Must be > 0 and <=
7356    /// `max_factor`. The factor is never 0.
7357    #[serde(default = "default_adaptive_min_factor")]
7358    pub min_factor: f64,
7359    /// Maximum adaptive factor (default 2.0): the loosest the quotas
7360    /// can grow to (200% of the configured rate). Must be > 0 and >=
7361    /// `min_factor`. The factor is never unbounded.
7362    #[serde(default = "default_adaptive_max_factor")]
7363    pub max_factor: f64,
7364    /// Error-rate EWMA threshold that triggers tightening (default 0.05
7365    /// = 5% 5xx rate). When the error EWMA exceeds this, the factor
7366    /// decreases toward `min_factor`. Must be in (0, 1].
7367    #[serde(default = "default_adaptive_error_threshold")]
7368    pub error_threshold: f64,
7369    /// Latency EWMA threshold in milliseconds that triggers tightening
7370    /// (default 500). When the latency EWMA exceeds this, the factor
7371    /// decreases toward `min_factor`. Must be > 0.
7372    #[serde(default = "default_adaptive_latency_threshold_ms")]
7373    pub latency_threshold_ms: u64,
7374    /// Upstream-originated signals the controller honors (default
7375    /// empty: EWMA-only tuning). Currently supported: `retry_after`
7376    /// (parse the upstream `Retry-After` header and back off for the
7377    /// advertised window).
7378    #[serde(default, skip_serializing_if = "Vec::is_empty")]
7379    pub origin_signals: Vec<OriginSignal>,
7380}
7381
7382/// One upstream-originated signal the adaptive controller honors
7383/// (DW-089).
7384#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7385#[serde(rename_all = "snake_case")]
7386pub enum OriginSignal {
7387    /// Parse the upstream `Retry-After` header (integer seconds or
7388    /// HTTP-date) and back off for the advertised window: the factor
7389    /// drops to `min_factor` immediately and stays there until the
7390    /// window elapses.
7391    RetryAfter,
7392}
7393
7394fn default_ewma_window_secs() -> u64 {
7395    60
7396}
7397
7398fn default_adaptive_min_factor() -> f64 {
7399    0.1
7400}
7401
7402fn default_adaptive_max_factor() -> f64 {
7403    2.0
7404}
7405
7406fn default_adaptive_error_threshold() -> f64 {
7407    0.05
7408}
7409
7410fn default_adaptive_latency_threshold_ms() -> u64 {
7411    500
7412}