dwara_core/snapshot/
mod.rs

1//! Config compile pipeline: validate -> compile -> publish (feature analysis
2//! section 9.2).
3//!
4//! The pipeline is split into a pure half and an effectful half:
5//!
6//! - [`validate`] checks semantic integrity that the schema deliberately
7//!   deferred (reference resolution, duplicate names, port conflicts,
8//!   credential well-formedness). It returns ALL issues, never fail-fast.
9//! - [`compile`] turns a validated [`Gateway`] into a [`Compiled`] value:
10//!   compiled route structures plus a content hash. This is where schema-valid
11//!   config can still fail (invalid regex, conflicting route templates).
12//! - [`ConfigState::compile_and_publish`] stamps a monotonic generation id and
13//!   atomically installs the result behind an `ArcSwap`. Rollback semantics
14//!   are "atomic not-publish": on ANY validation or compile failure the swap
15//!   never happens and the currently-published snapshot remains untouched.
16//!
17//! Route matching model (v1):
18//!
19//! - `exact` routes are mounted in a `matchit` radix router; path parameters
20//!   like `/users/{id}` are supported by the router itself.
21//! - `regex` routes are compiled into a shared `regex::RegexSet`; the first
22//!   matching pattern (declaration order) wins.
23//! - `prefix` routes are kept in an ordered list and matched by
24//!   longest-prefix at lookup time. `matchit` is exact-template matching, so
25//!   prefix semantics are modeled explicitly rather than by mounting a
26//!   trailing catch-all; that keeps parameter capture out of prefix routes.
27//! - Lookup precedence: exact, then regex, then longest prefix.
28//!
29//! Content hash: a fast non-cryptographic `DefaultHasher` (SipHash-1-3) over
30//! the normalized YAML serialization of the gateway. Its purpose is change
31//! detection / generation identity, NOT cryptographic integrity; it is not
32//! suitable for adversarial settings. The same hashing step backs a public
33//! per-entity variant ([`entity_content_hash`]) so tooling can compare
34//! single entities the way the gateway compares whole generations.
35
36use std::collections::hash_map::DefaultHasher;
37use std::hash::{Hash, Hasher};
38use std::str::FromStr as _;
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::sync::{Arc, Mutex};
41
42use arc_swap::ArcSwap;
43
44use crate::config::{
45    gateway_to_yaml, Credential, Gateway, ListenerProtocol, PathMatchKind, PathRewrite, Route,
46    RouteAction, TlsMode, TranslationKind, ZeroRttPolicy,
47};
48
49/// One semantic-validation finding. Operators get every issue at once, not a
50/// fail-fast first error.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ValidationIssue {
53    /// Entity kind, e.g. `route`, `service`, `listener`.
54    pub entity: String,
55    /// Name of the offending entity.
56    pub name: String,
57    /// Field (or near-field locator) the issue concerns.
58    pub field: String,
59    pub message: String,
60}
61
62impl std::fmt::Display for ValidationIssue {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        write!(
65            f,
66            "{} '{}.{}': {}",
67            self.entity, self.name, self.field, self.message
68        )
69    }
70}
71
72/// Error produced by the compile pipeline. Validation failures carry every
73/// issue; compile failures name the route and pattern at fault.
74#[derive(Debug)]
75pub enum CompileError {
76    Validation(Vec<ValidationIssue>),
77    InvalidRegex {
78        route: String,
79        pattern: String,
80        message: String,
81    },
82    RouteConflict {
83        route: String,
84        pattern: String,
85        message: String,
86    },
87    Internal(String),
88}
89
90impl std::fmt::Display for CompileError {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        match self {
93            CompileError::Validation(issues) => {
94                write!(f, "config validation failed ({} issues)", issues.len())?;
95                for i in issues {
96                    write!(f, "\n  - {i}")?;
97                }
98                Ok(())
99            }
100            CompileError::InvalidRegex {
101                route,
102                pattern,
103                message,
104            } => write!(f, "route '{route}': invalid regex '{pattern}': {message}"),
105            CompileError::RouteConflict {
106                route,
107                pattern,
108                message,
109            } => write!(
110                f,
111                "route '{route}': conflicting path template '{pattern}': {message}"
112            ),
113            CompileError::Internal(m) => write!(f, "internal compile error: {m}"),
114        }
115    }
116}
117
118impl std::error::Error for CompileError {}
119
120fn issue(entity: &str, name: &str, field: &str, message: impl Into<String>) -> ValidationIssue {
121    ValidationIssue {
122        entity: entity.to_string(),
123        name: name.to_string(),
124        field: field.to_string(),
125        message: message.into(),
126    }
127}
128
129/// DW-103: human-readable name for an [`UpstreamProtocol`] (used in L4
130/// SNI-routing validation messages).
131fn upstream_protocol_name(p: crate::config::UpstreamProtocol) -> &'static str {
132    match p {
133        crate::config::UpstreamProtocol::Http1 => "http1",
134        crate::config::UpstreamProtocol::Http2 => "http2",
135        crate::config::UpstreamProtocol::H2c => "h2c",
136        crate::config::UpstreamProtocol::Https => "https",
137        crate::config::UpstreamProtocol::H3 => "h3",
138    }
139}
140
141/// Compile-time check for a `trusted_ca_file` (#121): the file must
142/// exist, be readable, and PARSE to at least one PEM certificate NOW, or
143/// the config generation is rejected naming the field. A missing,
144/// unreadable, or certificate-free trust bundle would otherwise publish
145/// fine and fail every TLS dial to the affected upstream at request time
146/// — and, the sharp case, DISABLE a JWT provider at authenticator build,
147/// after which Bearer tokens pass through UNVERIFIED. Closing that torn
148/// state here, at validation, is the whole point of the PEM dimension:
149/// the runtime fail-closed paths (empty root store / provider disabled,
150/// security/tls.rs and authn.rs) remain as a microsecond-race backstop
151/// for a bundle that breaks between validate and build. PEM parsing uses
152/// rustls-pki-types directly — an EXTERNAL crate, so this module's
153/// position in the facade's `crate::` dependency order is untouched —
154/// while anchor USABILITY (a parseable certificate the root store
155/// rejects) stays enforced where the rustls root store lives, at
156/// registry build.
157/// Check that a file path is readable (DW-035): used for OAuth2 mTLS
158/// client cert/key files. Unlike `check_trusted_ca_file`, this does NOT
159/// parse the file contents — the OAuth2 client builder does that at
160/// build time and logs a disabling error on a parse failure (the
161/// upstream still proxies, just without the OAuth2 Bearer token).
162fn check_file_readable(
163    entity: &str,
164    name: &str,
165    field: &str,
166    path: &str,
167    issues: &mut Vec<ValidationIssue>,
168) {
169    if !std::path::Path::new(path).is_file() {
170        issues.push(issue(
171            entity,
172            name,
173            field,
174            format!("{field} '{path}' is not a readable file"),
175        ));
176    }
177}
178
179fn check_trusted_ca_file(
180    entity: &str,
181    name: &str,
182    field: &str,
183    path: &str,
184    issues: &mut Vec<ValidationIssue>,
185) {
186    use rustls_pki_types::pem::PemObject;
187
188    if let Err(e) = std::fs::File::open(path) {
189        issues.push(issue(
190            entity,
191            name,
192            field,
193            format!("{field} '{path}' is not a readable file: {e}"),
194        ));
195        return;
196    }
197    // Non-PEM text between blocks is skipped (real bundles carry
198    // comments), so `Ok([])` means the file holds no CERTIFICATE
199    // sections at all; an Err is a read failure (e.g. a directory:
200    // File::open succeeds on Unix, the read does not) or a malformed
201    // PEM block.
202    let parsed = rustls_pki_types::CertificateDer::pem_file_iter(path).and_then(|iter| {
203        iter.collect::<Result<Vec<rustls_pki_types::CertificateDer<'static>>, _>>()
204    });
205    match parsed {
206        Ok(certs) if !certs.is_empty() => {}
207        Ok(_) => issues.push(issue(
208            entity,
209            name,
210            field,
211            format!(
212                "{field} '{path}' holds no usable CA certificates \
213                 (the PEM bundle must list at least one CERTIFICATE)"
214            ),
215        )),
216        Err(e) => issues.push(issue(
217            entity,
218            name,
219            field,
220            format!(
221                "{field} '{path}' could not be parsed as a PEM \
222                 certificate bundle: {e}"
223            ),
224        )),
225    }
226}
227
228/// Validate one `authorization` block (#123: shared by every attachment
229/// level — route, service, listener, consumer, and gateway/global). The
230/// checks are identical at every level: consumer/group references must
231/// resolve against the configured consumers, every IP ACL entry must
232/// parse as an IP/CIDR (the trusted-proxies parser), a block carrying NO
233/// rules at all is rejected (always an authoring mistake — omit the
234/// block), and a `/0` entry in the ALLOW list is rejected (allow-all
235/// filters nothing; the intended shape is `default: allow`; `/0` in the
236/// DENY list is meaningful — deny-all — and accepted).
237fn validate_authz(
238    entity: &str,
239    name: &str,
240    authz: &crate::config::Authz,
241    consumers: &std::collections::BTreeSet<&str>,
242    consumer_groups: &std::collections::BTreeSet<&str>,
243    issues: &mut Vec<ValidationIssue>,
244) {
245    let field_prefix = "authorization".to_string();
246    let empty = authz.allowed_consumers.is_empty()
247        && authz.denied_consumers.is_empty()
248        && authz.allowed_groups.is_empty()
249        && authz.denied_groups.is_empty()
250        && authz.required_scopes.is_empty()
251        && authz.required_claims.is_empty()
252        && authz.ip_acl.is_none()
253        && authz.geoip.is_none();
254    if empty {
255        issues.push(issue(
256            entity,
257            name,
258            "authorization",
259            "carries no rules (no consumers, groups, scopes, claims, ip_acl, \
260             or geoip) and is always a mistake: omit the authorization block \
261             entirely",
262        ));
263    }
264    for (side, entries) in [
265        ("allowed_consumers", &authz.allowed_consumers),
266        ("denied_consumers", &authz.denied_consumers),
267    ] {
268        for entry in entries.iter() {
269            if !consumers.contains(entry.as_str()) {
270                issues.push(issue(
271                    entity,
272                    name,
273                    &format!("{field_prefix}.{side}"),
274                    format!("references unknown consumer '{entry}'"),
275                ));
276            }
277        }
278    }
279    for (side, entries) in [
280        ("allowed_groups", &authz.allowed_groups),
281        ("denied_groups", &authz.denied_groups),
282    ] {
283        for group in entries.iter() {
284            if !consumer_groups.contains(group.as_str()) {
285                issues.push(issue(
286                    entity,
287                    name,
288                    &format!("{field_prefix}.{side}"),
289                    format!("references group '{group}' that no consumer is a member of"),
290                ));
291            }
292        }
293    }
294    if let Some(acl) = &authz.ip_acl {
295        for (side, entries) in [("ip_acl.allow", &acl.allow), ("ip_acl.deny", &acl.deny)] {
296            for (i, entry) in entries.iter().enumerate() {
297                if crate::config::net::parse_ip_or_cidr(entry).is_none() {
298                    issues.push(issue(
299                        entity,
300                        name,
301                        &format!("{field_prefix}.{side}[{i}]"),
302                        format!(
303                            "'{entry}' is not an IP address or CIDR (e.g. 10.0.0.0/8 \
304                             or 2001:db8::/32)"
305                        ),
306                    ));
307                } else if side == "ip_acl.allow"
308                    && crate::config::net::parse_ip_or_cidr(entry)
309                        .is_some_and(|(_, prefix)| prefix == 0)
310                {
311                    issues.push(issue(
312                        entity,
313                        name,
314                        &format!("{field_prefix}.{side}[{i}]"),
315                        format!(
316                            "'{entry}' allows every address and filters nothing; \
317                             use 'default: allow' instead of an allow-all entry"
318                        ),
319                    ));
320                }
321            }
322        }
323    }
324}
325
326/// Validate one `cors` block (DW-027). The origin list must be a
327/// non-empty closed set of well-formed `http(s)` origins (or `*`);
328/// wildcard origins/headers cannot combine with credentials (Fetch spec:
329/// `*` is not a valid allow-origin/allow-headers value on a credentialed
330/// response); methods must be valid HTTP tokens; header lists must hold
331/// valid header names (or `*` for `allowed_headers` only).
332fn validate_cors(name: &str, cors: &crate::config::Cors, issues: &mut Vec<ValidationIssue>) {
333    if cors.allowed_origins.is_empty() {
334        issues.push(issue(
335            "route",
336            name,
337            "cors.allowed_origins",
338            "allowed_origins is empty: a cors block with no origins filters nothing \
339             and is always a mistake; omit the cors block to disable CORS",
340        ));
341    }
342    let wildcard = cors.allowed_origins.iter().any(|o| o == "*");
343    if wildcard && cors.allow_credentials {
344        issues.push(issue(
345            "route",
346            name,
347            "cors.allowed_origins",
348            "'*' cannot be combined with allow_credentials (the Fetch spec forbids \
349             wildcard credentialed responses); list explicit origins instead",
350        ));
351    }
352    for (i, origin) in cors.allowed_origins.iter().enumerate() {
353        if origin == "*" {
354            continue;
355        }
356        // Normalize exactly like the runtime matcher: lowercase scheme
357        // and host, default port dropped. Anything the matcher would not
358        // itself accept is an authoring error caught here.
359        if crate::config::normalize_origin(origin).is_none() {
360            issues.push(issue(
361                "route",
362                name,
363                &format!("cors.allowed_origins[{i}]"),
364                format!(
365                    "'{origin}' is not a well-formed origin (expected scheme://host[:port] \
366                     with an http or https scheme and no path, e.g. https://api.example.com, \
367                     or the single entry '*')"
368                ),
369            ));
370        }
371    }
372    if wildcard && cors.allowed_origins.len() > 1 {
373        issues.push(issue(
374            "route",
375            name,
376            "cors.allowed_origins",
377            "'*' cannot be combined with other origins ('*' already matches everything)",
378        ));
379    }
380    for (i, m) in cors.allowed_methods.iter().enumerate() {
381        if hyper::Method::from_bytes(m.as_bytes()).is_err() {
382            issues.push(issue(
383                "route",
384                name,
385                &format!("cors.allowed_methods[{i}]"),
386                format!("'{m}' is not a valid HTTP method token"),
387            ));
388        }
389    }
390    let header_lists = [
391        ("cors.allowed_headers", &cors.allowed_headers, true),
392        ("cors.expose_headers", &cors.expose_headers, false),
393    ];
394    for (field, list, allows_wildcard) in header_lists {
395        for (i, h) in list.iter().enumerate() {
396            if h == "*" {
397                if !allows_wildcard {
398                    issues.push(issue(
399                        "route",
400                        name,
401                        &format!("{field}[{i}]"),
402                        "'*' is only valid in allowed_headers (expose_headers must name \
403                         the headers to expose)",
404                    ));
405                } else if cors.allow_credentials {
406                    issues.push(issue(
407                        "route",
408                        name,
409                        &format!("{field}[{i}]"),
410                        "'*' cannot be combined with allow_credentials (the Fetch spec \
411                         forbids wildcard allow-headers on credentialed preflights); \
412                         list explicit header names instead",
413                    ));
414                }
415                continue;
416            }
417            if hyper::header::HeaderName::from_bytes(h.as_bytes()).is_err() {
418                issues.push(issue(
419                    "route",
420                    name,
421                    &format!("{field}[{i}]"),
422                    format!("'{h}' is not a valid HTTP header name"),
423                ));
424            }
425        }
426    }
427}
428
429/// Validate one `compression` block (DW-027): non-empty duplicate-free
430/// algorithm list, level within 0-22 (per-algorithm clamping happens at
431/// encode time), and non-empty content-type prefixes.
432fn validate_compression(
433    name: &str,
434    compression: &crate::config::Compression,
435    issues: &mut Vec<ValidationIssue>,
436) {
437    if compression.algorithms.is_empty() {
438        issues.push(issue(
439            "route",
440            name,
441            "compression.algorithms",
442            "algorithms is empty: a compression block with no algorithms compresses \
443             nothing and is always a mistake; omit the compression block to disable it",
444        ));
445    }
446    let mut seen = std::collections::BTreeSet::new();
447    for (i, a) in compression.algorithms.iter().enumerate() {
448        if !seen.insert(a) {
449            issues.push(issue(
450                "route",
451                name,
452                &format!("compression.algorithms[{i}]"),
453                format!("algorithm '{a:?}' is listed more than once"),
454            ));
455        }
456    }
457    if let Some(level) = compression.level {
458        if level > 22 {
459            issues.push(issue(
460                "route",
461                name,
462                "compression.level",
463                format!(
464                    "level {level} is out of range: 0-22 (clamped per algorithm: \
465                         gzip 0-9, brotli 0-11, zstd 0-22)"
466                ),
467            ));
468        }
469    }
470    for (field, entries) in [
471        ("compression.content_types", &compression.content_types),
472        (
473            "compression.excluded_content_types",
474            &compression.excluded_content_types,
475        ),
476    ] {
477        for (i, entry) in entries.iter().enumerate() {
478            let t = entry.trim().to_lowercase();
479            if t.is_empty() || t.contains(char::is_whitespace) {
480                issues.push(issue(
481                    "route",
482                    name,
483                    &format!("{field}[{i}]"),
484                    format!(
485                        "'{entry}' is not a content-type prefix (expected a non-empty \
486                         token like 'text/' or 'application/json')"
487                    ),
488                ));
489            }
490        }
491    }
492}
493
494/// Validate one `request_limits` block (DW-027): at least one limit set,
495/// every set limit >= 1.
496fn validate_request_limits(
497    name: &str,
498    limits: &crate::config::RequestLimits,
499    issues: &mut Vec<ValidationIssue>,
500) {
501    if limits.max_body_bytes.is_none()
502        && limits.max_header_count.is_none()
503        && limits.max_header_bytes.is_none()
504    {
505        issues.push(issue(
506            "route",
507            name,
508            "limits",
509            "carries no limits (no max_body_bytes, max_header_count, or max_header_bytes) \
510             and is always a mistake: omit the limits block entirely",
511        ));
512    }
513    if let Some(0) = limits.max_body_bytes {
514        issues.push(issue(
515            "route",
516            name,
517            "limits.max_body_bytes",
518            "max_body_bytes must be > 0 (0 would reject every request with a body)",
519        ));
520    }
521    if let Some(0) = limits.max_header_count {
522        issues.push(issue(
523            "route",
524            name,
525            "limits.max_header_count",
526            "max_header_count must be > 0",
527        ));
528    }
529    if let Some(0) = limits.max_header_bytes {
530        issues.push(issue(
531            "route",
532            name,
533            "limits.max_header_bytes",
534            "max_header_bytes must be > 0",
535        ));
536    }
537}
538
539/// Validate one `deprecation` block (DW-048): a block with no dates has
540/// no effect (omit it); dates must be IMF-fixdate HTTP-dates (the only
541/// form RFC 9110 generators may send — see `config::versioning`); a
542/// sunset already in the past advertises a removal that already happened
543/// (rejected: remove the route or extend the date — this also stops a
544/// long-lived deployment from silently re-publishing a stale sunset;
545/// a rejected hot reload keeps the running generation, so the operator
546/// must fix the date to publish ANY new config carrying it, which is the
547/// fail-closed intent); sunset must not precede since; and `uri`
548/// documents the `Deprecation` header, so it requires `since`. The
549/// past-sunset check reads the wall clock: it is a compile-time policy
550/// gate, not a per-request one.
551fn validate_deprecation(
552    name: &str,
553    dep: &crate::config::Deprecation,
554    issues: &mut Vec<ValidationIssue>,
555) {
556    let since = dep
557        .since
558        .as_deref()
559        .and_then(crate::config::versioning::parse_http_date);
560    let sunset = dep
561        .sunset
562        .as_deref()
563        .and_then(crate::config::versioning::parse_http_date);
564    // The no-dates check reads the RAW fields: a garbage `since` with no
565    // `sunset` is a date-format error, not an empty block (reporting it
566    // as both would be noise).
567    if dep.since.is_none() && dep.sunset.is_none() {
568        issues.push(issue(
569            "route",
570            name,
571            "deprecation",
572            "carries no dates (no since or sunset) and emits nothing: omit the \
573             deprecation block entirely",
574        ));
575    }
576    for (field, value) in [
577        ("deprecation.since", &dep.since),
578        ("deprecation.sunset", &dep.sunset),
579    ] {
580        if let Some(v) = value {
581            if crate::config::versioning::parse_http_date(v).is_none() {
582                issues.push(issue(
583                    "route",
584                    name,
585                    field,
586                    format!(
587                        "'{v}' is not a valid HTTP-date: use the IMF-fixdate form \
588                         (e.g. 'Sun, 06 Nov 1994 08:49:37 GMT')"
589                    ),
590                ));
591            }
592        }
593    }
594    if let Some(s) = since {
595        if s.unix_seconds() < 0 {
596            issues.push(issue(
597                "route",
598                name,
599                "deprecation.since",
600                "since must be 1970 or later (the RFC 9745 Deprecation header renders it \
601                 as a Unix-time structured date)",
602            ));
603        }
604    }
605    if let Some(s) = sunset {
606        let now = std::time::SystemTime::now()
607            .duration_since(std::time::UNIX_EPOCH)
608            .map(|d| d.as_secs() as i64)
609            .unwrap_or(0);
610        if s.unix_seconds() < now {
611            issues.push(issue(
612                "route",
613                name,
614                "deprecation.sunset",
615                format!(
616                    "sunset date '{}' is in the past: the route should be removed or the \
617                     date extended, not advertise a removal that already happened",
618                    dep.sunset.as_deref().unwrap_or_default()
619                ),
620            ));
621        }
622        if let Some(since_date) = since {
623            if s.unix_seconds() < since_date.unix_seconds() {
624                issues.push(issue(
625                    "route",
626                    name,
627                    "deprecation.sunset",
628                    "sunset is before since (the route would be removed before it is \
629                     deprecated)",
630                ));
631            }
632        }
633    }
634    if let Some(uri) = &dep.uri {
635        if since.is_none() {
636            issues.push(issue(
637                "route",
638                name,
639                "deprecation.uri",
640                "uri requires since: the Link rel=\"deprecation\" it emits documents the \
641                 Deprecation header, which only since produces",
642            ));
643        }
644        // The Link header is built as `<uri>; rel="deprecation"` — the
645        // URI must parse AND carry no byte that breaks out of the angle
646        // brackets or the quoted param.
647        let parseable = match uri.parse::<hyper::Uri>() {
648            Ok(u) => matches!(u.scheme_str(), Some("http") | Some("https")) && u.host().is_some(),
649            Err(_) => false,
650        };
651        if !parseable || uri.bytes().any(|b| matches!(b, b'<' | b'>' | b'"')) {
652            issues.push(issue(
653                "route",
654                name,
655                "deprecation.uri",
656                format!(
657                    "'{uri}' must be an absolute http(s) URL with no '<', '>', or '\"' \
658                         (it is emitted inside a Link header)"
659                ),
660            ));
661        }
662    }
663}
664
665/// Validate one `maintenance` block (DW-041): `retry_after_secs` 0 would
666/// invite an immediate retry stampede against the route the operator just
667/// took down (the whole point of the header is "come back later"), and an
668/// empty `message` is indistinguishable from an absent one (omit it for
669/// the default text).
670fn validate_maintenance(
671    name: &str,
672    maintenance: &crate::config::Maintenance,
673    issues: &mut Vec<ValidationIssue>,
674) {
675    if maintenance.retry_after_secs == Some(0) {
676        issues.push(issue(
677            "route",
678            name,
679            "maintenance.retry_after_secs",
680            "retry_after_secs must be > 0 (0 tells clients to retry immediately against a \
681             route that is down; omit the field for the 60s default)",
682        ));
683    }
684    if maintenance
685        .message
686        .as_deref()
687        .is_some_and(|m| m.trim().is_empty())
688    {
689        issues.push(issue(
690            "route",
691            name,
692            "maintenance.message",
693            "message is empty: omit the field for the default 'route under maintenance' text",
694        ));
695    }
696}
697
698/// Validate one `transforms` block (DW-028): no empty containers (a
699/// transforms block that transforms nothing is an authoring mistake),
700/// representable header names and values, no framing/hop-by-hop header
701/// names (the body pipeline owns framing; `deny`-listing them here is
702/// the request-smuggling guard — see `config::transforms`), sane
703/// query-component bytes, and JSON pointers that parse (the shared
704/// grammar in `config::transforms`, the same agreement validation
705/// holds with every other runtime grammar).
706fn validate_transforms(
707    name: &str,
708    transforms: &crate::config::transforms::Transforms,
709    issues: &mut Vec<ValidationIssue>,
710) {
711    if transforms.request.is_none() && transforms.response.is_none() {
712        issues.push(issue(
713            "route",
714            name,
715            "transforms",
716            "carries neither request nor response (nothing to transform) and is always a \
717             mistake: omit the transforms block entirely",
718        ));
719    }
720    if let Some(req) = &transforms.request {
721        if req.headers.is_none() && req.query.is_none() && req.body.is_none() {
722            issues.push(issue(
723                "route",
724                name,
725                "transforms.request",
726                "carries no headers, query, or body block and is always a mistake: omit it",
727            ));
728        }
729        if let Some(headers) = &req.headers {
730            validate_header_ops(name, "transforms.request.headers", headers, true, issues);
731        }
732        if let Some(query) = &req.query {
733            validate_query_ops(name, "transforms.request.query", query, issues);
734        }
735        if let Some(body) = &req.body {
736            validate_json_body(name, "transforms.request.body", &body.json, issues);
737        }
738    }
739    if let Some(resp) = &transforms.response {
740        if resp.headers.is_none() && resp.body.is_none() {
741            issues.push(issue(
742                "route",
743                name,
744                "transforms.response",
745                "carries no headers or body block and is always a mistake: omit it",
746            ));
747        }
748        if let Some(headers) = &resp.headers {
749            validate_header_ops(name, "transforms.response.headers", headers, false, issues);
750        }
751        if let Some(body) = &resp.body {
752            validate_json_body(name, "transforms.response.body", &body.json, issues);
753        }
754    }
755}
756
757/// Validate one header-ops block. `request_side` selects the forbidden
758/// set: the request side forbids `host` (the gateway names the origin
759/// it dials) and the framing pair; the response side forbids the
760/// framing pair plus `content-encoding` (only the compression pipeline
761/// may manage it — an op that stripped it without decoding would
762/// corrupt the body, and one that added it would misdescribe bytes it
763/// did not encode).
764fn validate_header_ops(
765    route: &str,
766    field: &str,
767    ops: &crate::config::transforms::HeaderOps,
768    request_side: bool,
769    issues: &mut Vec<ValidationIssue>,
770) {
771    use crate::config::transforms as t;
772    if ops.set.is_empty() && ops.add.is_empty() && ops.remove.is_empty() && ops.rename.is_empty() {
773        issues.push(issue(
774            "route",
775            route,
776            field,
777            "carries no set, add, remove, or rename entries and is always a mistake: omit it",
778        ));
779    }
780    let names: Vec<(&str, String)> = ops
781        .set
782        .keys()
783        .map(|k| ("set", k.clone()))
784        .chain(ops.add.keys().map(|k| ("add", k.clone())))
785        .chain(ops.remove.iter().map(|k| ("remove", k.clone())))
786        .chain(ops.rename.keys().map(|k| ("rename", k.clone())))
787        .chain(ops.rename.values().map(|k| ("rename (to)", k.clone())))
788        .collect();
789    for (op, header) in &names {
790        if hyper::header::HeaderName::from_bytes(header.as_bytes()).is_err() {
791            issues.push(issue(
792                "route",
793                route,
794                field,
795                format!("'{header}' ({op}) is not a valid HTTP header name"),
796            ));
797            continue;
798        }
799        let forbidden = if request_side {
800            t::is_forbidden_request_header(header)
801        } else {
802            t::is_forbidden_response_header(header)
803        };
804        if forbidden {
805            issues.push(issue(
806                "route",
807                route,
808                field,
809                format!(
810                    "'{header}' ({op}) is a framing/hop-by-hop header the gateway's body \
811                     pipeline owns: transforms may not touch it (request smuggling and \
812                     body-corruption guard)"
813                ),
814            ));
815        }
816    }
817    for (op, value) in ops
818        .set
819        .values()
820        .map(|v| ("set", v))
821        .chain(ops.add.values().map(|v| ("add", v)))
822    {
823        if hyper::header::HeaderValue::from_str(value).is_err() {
824            issues.push(issue(
825                "route",
826                route,
827                field,
828                format!(
829                    "the '{op}' value '{value}' is not representable in an HTTP header \
830                     (control characters and non-visible bytes are rejected)"
831                ),
832            ));
833        }
834    }
835    for (from, to) in &ops.rename {
836        if from == to {
837            issues.push(issue(
838                "route",
839                route,
840                field,
841                format!("rename '{from}' -> '{to}' is a no-op: rename to a different name"),
842            ));
843        }
844    }
845}
846
847/// Validate one query-ops block (DW-028): names and values must be
848/// plain ASCII without the query structural bytes (`&` separates
849/// pairs, `=` separates key from value, `#` would start a fragment).
850/// Everything else the runtime percent-encodes on emit.
851fn validate_query_ops(
852    route: &str,
853    field: &str,
854    ops: &crate::config::transforms::QueryOps,
855    issues: &mut Vec<ValidationIssue>,
856) {
857    if ops.set.is_empty() && ops.add.is_empty() && ops.remove.is_empty() && ops.rename.is_empty() {
858        issues.push(issue(
859            "route",
860            route,
861            field,
862            "carries no set, add, remove, or rename entries and is always a mistake: omit it",
863        ));
864    }
865    let check = |kind: &str, key: &str, value: Option<&str>, issues: &mut Vec<ValidationIssue>| {
866        let bad_name = key.is_empty()
867            || key.bytes().any(|b| {
868                !b.is_ascii() || b.is_ascii_control() || matches!(b, b'&' | b'=' | b'#' | b' ')
869            });
870        if bad_name {
871            issues.push(issue(
872                "route",
873                route,
874                field,
875                format!(
876                    "the {kind} name '{key}' is not a plain query name (non-empty ASCII, no \
877                     '&', '=', '#', or spaces)"
878                ),
879            ));
880        }
881        if let Some(v) = value {
882            if v.bytes()
883                .any(|b| !b.is_ascii() || b.is_ascii_control() || matches!(b, b'&' | b'#'))
884            {
885                issues.push(issue(
886                    "route",
887                    route,
888                    field,
889                    format!(
890                        "the {kind} value '{v}' is not representable in a query string \
891                         (ASCII without '&' or '#')"
892                    ),
893                ));
894            }
895        }
896    };
897    for (k, v) in &ops.set {
898        check("set", k, Some(v), issues);
899    }
900    for (k, v) in &ops.add {
901        check("add", k, Some(v), issues);
902    }
903    for k in &ops.remove {
904        check("remove", k, None, issues);
905    }
906    for (from, to) in &ops.rename {
907        check("rename", from, None, issues);
908        check("rename (to)", to, None, issues);
909        if from == to {
910            issues.push(issue(
911                "route",
912                route,
913                field,
914                format!("rename '{from}' -> '{to}' is a no-op: rename to a different key"),
915            ));
916        }
917    }
918}
919
920/// Validate one JSON body transform (DW-028): non-empty ops, a positive
921/// cap, pointers that parse (the shared `config::transforms` grammar),
922/// and no `remove` of the whole document.
923fn validate_json_body(
924    route: &str,
925    field: &str,
926    json: &crate::config::transforms::JsonBodyTransform,
927    issues: &mut Vec<ValidationIssue>,
928) {
929    use crate::config::transforms as t;
930    if json.ops.is_empty() {
931        issues.push(issue(
932            "route",
933            route,
934            &format!("{field}.json.ops"),
935            "ops is empty: a body transform that transforms nothing is always a mistake \
936             (and still pays the content-type gate); omit the body block",
937        ));
938    }
939    if json.max_bytes == 0 {
940        issues.push(issue(
941            "route",
942            route,
943            &format!("{field}.json.max_bytes"),
944            "max_bytes must be > 0 (0 would fail every body against the transform cap)",
945        ));
946    }
947    for (i, op) in json.ops.iter().enumerate() {
948        let path = match op {
949            t::JsonOp::Set { path, .. } => path,
950            t::JsonOp::Remove { path } => path,
951        };
952        match t::JsonPointer::parse(path) {
953            None => issues.push(issue(
954                "route",
955                route,
956                &format!("{field}.json.ops[{i}].path"),
957                format!(
958                    "'{path}' is not an RFC 6901 JSON pointer (start with '/', use ~0 and ~1 \
959                     for '~' and '/', e.g. /items/0/id; '' addresses the whole document)"
960                ),
961            )),
962            Some(pointer) => {
963                if matches!(op, t::JsonOp::Remove { .. }) && pointer.is_root() {
964                    issues.push(issue(
965                        "route",
966                        route,
967                        &format!("{field}.json.ops[{i}].path"),
968                        "remove at the root pointer ('') would delete the whole document — \
969                         there is no 'no body' state to forward; use set at the root to \
970                         replace it",
971                    ));
972                }
973            }
974        }
975    }
976}
977
978/// Validate one `security_headers` block (DW-028): at least one header
979/// opted in, HSTS directives that compose (0 is the RFC 6797 deletion
980/// signal this policy cannot express; the subdomains/preload
981/// directives are meaningless without a max-age, and the preload list
982/// requires includeSubDomains), and a CSP that is non-empty and
983/// representable in an HTTP header (the runtime's silent-skip backstop
984/// must stay unreachable).
985fn validate_security_headers(
986    name: &str,
987    sh: &crate::config::transforms::SecurityHeaders,
988    issues: &mut Vec<ValidationIssue>,
989) {
990    if sh.hsts_max_age_secs.is_none()
991        && !sh.nosniff
992        && sh.content_security_policy.is_none()
993        && sh.frame_options.is_none()
994    {
995        issues.push(issue(
996            "route",
997            name,
998            "security_headers",
999            "carries no policy (no HSTS, nosniff, CSP, or frame_options) and is always a \
1000             mistake: omit the security_headers block to disable injection",
1001        ));
1002    }
1003    if sh.hsts_max_age_secs == Some(0) {
1004        issues.push(issue(
1005            "route",
1006            name,
1007            "security_headers.hsts_max_age_secs",
1008            "hsts_max_age_secs must be > 0 (max-age=0 is the RFC 6797 deletion signal — \
1009             delete the field to stop emitting HSTS on this route)",
1010        ));
1011    }
1012    if sh.hsts_max_age_secs.is_none() && (sh.hsts_include_subdomains || sh.hsts_preload) {
1013        issues.push(issue(
1014            "route",
1015            name,
1016            "security_headers.hsts_include_subdomains",
1017            "include_subdomains/preload require hsts_max_age_secs: the directives only \
1018             exist inside a Strict-Transport-Security header, which a max-age starts",
1019        ));
1020    }
1021    if sh.hsts_preload && !sh.hsts_include_subdomains {
1022        issues.push(issue(
1023            "route",
1024            name,
1025            "security_headers.hsts_preload",
1026            "preload requires include_subdomains (the HSTS preload list rejects entries \
1027             without it; emitting a header the list would refuse is an authoring mistake)",
1028        ));
1029    }
1030    if sh
1031        .content_security_policy
1032        .as_deref()
1033        .is_some_and(|p| p.trim().is_empty())
1034    {
1035        issues.push(issue(
1036            "route",
1037            name,
1038            "security_headers.content_security_policy",
1039            "content_security_policy is empty: omit the field to stop emitting CSP on \
1040             this route",
1041        ));
1042    }
1043    if let Some(csp) = sh.content_security_policy.as_deref() {
1044        // The runtime stamps the policy inside a representable-only skip
1045        // (`if let Ok(v) = HeaderValue::from_str(..)`); validation keeps
1046        // that skip unreachable, so a publishable config can never carry
1047        // a CSP the edge would silently not emit. The realistic trap is a
1048        // YAML block scalar's trailing newline.
1049        if !csp.trim().is_empty() && hyper::header::HeaderValue::from_str(csp).is_err() {
1050            issues.push(issue(
1051                "route",
1052                name,
1053                "security_headers.content_security_policy",
1054                "content_security_policy is not representable in an HTTP header (control \
1055                 characters are rejected — including the trailing newline a YAML block \
1056                 scalar adds)",
1057            ));
1058        }
1059    }
1060}
1061
1062/// DW-106: maximum allowed linear memory for a nano-service module.
1063const NANO_SERVICE_MAX_MEMORY: usize = 67_108_864; // 64 MiB
1064
1065/// DW-106: maximum allowed execution timeout for a nano-service module.
1066const NANO_SERVICE_MAX_TIMEOUT_MS: u64 = 5_000;
1067
1068/// Validate a nano-service route action (DW-106): the module file must
1069/// exist and be readable, `memory_limit` must be > 0 and <= 64 MiB, and
1070/// `execution_timeout_ms` must be > 0 and <= 5000ms. (The nano-service
1071/// runtime dispatch is compiled into every build; no cargo feature.)
1072fn validate_nano_service(
1073    name: &str,
1074    nano: &crate::config::NanoServiceAction,
1075    issues: &mut Vec<ValidationIssue>,
1076) {
1077    if nano.module.trim().is_empty() {
1078        issues.push(issue(
1079            "route",
1080            name,
1081            "action.module",
1082            "nano_service module path must be non-empty",
1083        ));
1084    } else if !std::path::Path::new(&nano.module).exists() {
1085        issues.push(issue(
1086            "route",
1087            name,
1088            "action.module",
1089            format!(
1090                "nano_service module file '{}' does not exist (checked at config publish time)",
1091                nano.module
1092            ),
1093        ));
1094    }
1095    if nano.memory_limit == 0 {
1096        issues.push(issue(
1097            "route",
1098            name,
1099            "action.memory_limit",
1100            "nano_service memory_limit must be > 0 (omit for the default 1 MiB)",
1101        ));
1102    } else if nano.memory_limit > NANO_SERVICE_MAX_MEMORY {
1103        issues.push(issue(
1104            "route",
1105            name,
1106            "action.memory_limit",
1107            format!(
1108                "nano_service memory_limit ({}) exceeds the maximum {} (64 MiB)",
1109                nano.memory_limit, NANO_SERVICE_MAX_MEMORY
1110            ),
1111        ));
1112    }
1113    if nano.execution_timeout_ms == 0 {
1114        issues.push(issue(
1115            "route",
1116            name,
1117            "action.execution_timeout_ms",
1118            "nano_service execution_timeout_ms must be > 0 (omit for the default 100ms)",
1119        ));
1120    } else if nano.execution_timeout_ms > NANO_SERVICE_MAX_TIMEOUT_MS {
1121        issues.push(issue(
1122            "route",
1123            name,
1124            "action.execution_timeout_ms",
1125            format!(
1126                "nano_service execution_timeout_ms ({}) exceeds the maximum {}ms",
1127                nano.execution_timeout_ms, NANO_SERVICE_MAX_TIMEOUT_MS
1128            ),
1129        ));
1130    }
1131}
1132
1133/// Validate one `cache` block (DW-037): bounded ttl/stale window/body
1134/// cap, and a vary list whose entries are real header names, dedupli-
1135/// cated, and not one of the names the variance model forbids; the
1136/// DW-038 coalescing wait bound (the grammar lives in `config::cache`,
1137/// the same validate/compile split as every other route block).
1138fn validate_route_cache(
1139    name: &str,
1140    cache: &crate::config::cache::RouteCache,
1141    issues: &mut Vec<ValidationIssue>,
1142) {
1143    use crate::config::cache::{
1144        forbidden_vary_reason, MAX_CACHE_MAX_BODY_BYTES, MAX_CACHE_STALE_SECS, MAX_CACHE_TTL_SECS,
1145        MAX_CACHE_VARY_HEADERS, MAX_COALESCE_WAIT_MS,
1146    };
1147    if cache.ttl_secs == 0 {
1148        issues.push(issue(
1149            "route",
1150            name,
1151            "cache.ttl_secs",
1152            "ttl_secs must be >= 1 (a zero lifetime would expire every entry immediately; \
1153             omit the cache block to disable caching)",
1154        ));
1155    } else if cache.ttl_secs > MAX_CACHE_TTL_SECS {
1156        issues.push(issue(
1157            "route",
1158            name,
1159            "cache.ttl_secs",
1160            format!(
1161                "ttl_secs must be <= {MAX_CACHE_TTL_SECS} (24 h; longer freshness belongs to a \
1162                 CDN in front of the gateway)"
1163            ),
1164        ));
1165    }
1166    if let Some(swr) = cache.stale_while_revalidate_secs {
1167        if swr > MAX_CACHE_STALE_SECS {
1168            issues.push(issue(
1169                "route",
1170                name,
1171                "cache.stale_while_revalidate_secs",
1172                format!(
1173                    "stale_while_revalidate_secs must be <= {MAX_CACHE_STALE_SECS} (24 h, \
1174                     symmetric with the ttl bound)"
1175                ),
1176            ));
1177        }
1178    }
1179    if cache.max_body_bytes == 0 {
1180        issues.push(issue(
1181            "route",
1182            name,
1183            "cache.max_body_bytes",
1184            "max_body_bytes must be >= 1 (0 would store nothing; omit the cache block to \
1185             disable caching)",
1186        ));
1187    } else if cache.max_body_bytes > MAX_CACHE_MAX_BODY_BYTES {
1188        issues.push(issue(
1189            "route",
1190            name,
1191            "cache.max_body_bytes",
1192            format!(
1193                "max_body_bytes must be <= {MAX_CACHE_MAX_BODY_BYTES} (16 MiB; larger bodies \
1194                 stream through unstored)"
1195            ),
1196        ));
1197    }
1198    if cache.vary.len() > MAX_CACHE_VARY_HEADERS {
1199        issues.push(issue(
1200            "route",
1201            name,
1202            "cache.vary",
1203            format!(
1204                "at most {MAX_CACHE_VARY_HEADERS} vary headers per route (each multiplies the \
1205                 entry key space)"
1206            ),
1207        ));
1208    }
1209    let mut seen: Vec<String> = Vec::new();
1210    for (i, raw) in cache.vary.iter().enumerate() {
1211        let trimmed = raw.trim();
1212        let lowered = trimmed.to_ascii_lowercase();
1213        let field = format!("cache.vary[{i}]");
1214        if lowered.is_empty()
1215            || lowered != trimmed
1216            || lowered.starts_with('-')
1217            || lowered.ends_with('-')
1218            || !lowered
1219                .bytes()
1220                .all(|b| b.is_ascii_lowercase() || b == b'-' || b.is_ascii_digit())
1221        {
1222            issues.push(issue(
1223                "route",
1224                name,
1225                &field,
1226                format!("'{raw}' is not a header name (use the trimmed lowercase form)"),
1227            ));
1228            continue;
1229        }
1230        if let Some(reason) = forbidden_vary_reason(&lowered) {
1231            issues.push(issue(
1232                "route",
1233                name,
1234                &field,
1235                format!("'{lowered}' cannot vary: {reason}"),
1236            ));
1237            continue;
1238        }
1239        if seen.contains(&lowered) {
1240            issues.push(issue(
1241                "route",
1242                name,
1243                &field,
1244                format!("duplicate vary header '{lowered}' (deduplicate the list)"),
1245            ));
1246        }
1247        seen.push(lowered);
1248    }
1249    // DW-038: the coalescing follower wait bound. 0 would time every
1250    // follower out before the leader could possibly answer (a wait is
1251    // the only reason the block exists); a minute-plus wait just parks
1252    // clients behind a leader the route's own timeouts already bound.
1253    if let Some(coalescing) = &cache.coalescing {
1254        if coalescing.wait_ms == 0 {
1255            issues.push(issue(
1256                "route",
1257                name,
1258                "cache.coalescing.wait_ms",
1259                "wait_ms must be >= 1 (0 would fail every follower open before the leader \
1260                 answers; omit the coalescing block to disable coalescing)",
1261            ));
1262        } else if coalescing.wait_ms > MAX_COALESCE_WAIT_MS {
1263            issues.push(issue(
1264                "route",
1265                name,
1266                "cache.coalescing.wait_ms",
1267                format!(
1268                    "wait_ms must be <= {MAX_COALESCE_WAIT_MS} (60 s; a follower parked longer \
1269                     is a stuck request from the client's point of view)"
1270                ),
1271            ));
1272        }
1273    }
1274}
1275
1276/// Validate one `masking` block (DW-029): a positive cap, at least one
1277/// pointer somewhere (a masking policy that masks nothing is an
1278/// authoring mistake), pointers that parse and are not the root, and
1279/// group names that resolve against some config consumer's membership
1280/// — a typo'd group name silently never masks, which is fail-OPEN, the
1281/// exact posture this policy forbids (same check, and same store-only
1282/// caveat, as authorization group rules).
1283fn validate_masking(
1284    name: &str,
1285    masking: &crate::config::transforms::Masking,
1286    consumer_groups: &std::collections::BTreeSet<&str>,
1287    issues: &mut Vec<ValidationIssue>,
1288) {
1289    use crate::config::transforms as t;
1290    if masking.fields.is_empty() && masking.groups.is_empty() {
1291        issues.push(issue(
1292            "route",
1293            name,
1294            "masking",
1295            "carries no fields and no groups (nothing to mask) and is always a mistake: \
1296             omit the masking block to disable masking on this route",
1297        ));
1298    }
1299    if masking.max_bytes == 0 {
1300        issues.push(issue(
1301            "route",
1302            name,
1303            "masking.max_bytes",
1304            "max_bytes must be > 0 (0 would fail every response against the masking cap)",
1305        ));
1306    }
1307    let check_pointer =
1308        |field: String, path: &str, issues: &mut Vec<ValidationIssue>| match t::JsonPointer::parse(
1309            path,
1310        ) {
1311            None => issues.push(issue(
1312                "route",
1313                name,
1314                &field,
1315                format!(
1316                    "'{path}' is not an RFC 6901 JSON pointer (start with '/', use ~0 and ~1 for \
1317                 '~' and '/', e.g. /items/0/id)"
1318                ),
1319            )),
1320            Some(pointer) => {
1321                if pointer.is_root() {
1322                    issues.push(issue(
1323                    "route",
1324                    name,
1325                    &field,
1326                    "the root pointer ('') would replace the whole document with the sentinel — \
1327                     a body the route cannot usefully serve; mask fields, not the document",
1328                ));
1329                }
1330            }
1331        };
1332    for (i, path) in masking.fields.iter().enumerate() {
1333        check_pointer(format!("masking.fields[{i}]"), path, issues);
1334    }
1335    for (group, paths) in &masking.groups {
1336        if group.trim().is_empty() {
1337            issues.push(issue(
1338                "route",
1339                name,
1340                "masking.groups",
1341                format!("group key '{group}' is empty: group names are non-empty strings"),
1342            ));
1343        }
1344        if !consumer_groups.contains(group.as_str()) {
1345            issues.push(issue(
1346                "route",
1347                name,
1348                "masking.groups",
1349                format!(
1350                    "group '{group}' matches no configured consumer's groups membership — a \
1351                     typo here silently never masks (fail-open); grant a consumer the group \
1352                     or fix the name (store-managed consumers carry groups the config cannot \
1353                     see, the known caveat of this check)"
1354                ),
1355            ));
1356        }
1357        if paths.is_empty() {
1358            issues.push(issue(
1359                "route",
1360                name,
1361                "masking.groups",
1362                format!(
1363                    "group '{group}' carries no pointers and is always a mistake: omit \
1364                         the entry (the route floor still applies to the group's consumers)"
1365                ),
1366            ));
1367        }
1368        for (i, path) in paths.iter().enumerate() {
1369            check_pointer(format!("masking.groups.{group}[{i}]"), path, issues);
1370        }
1371    }
1372}
1373
1374/// Validate the `gateway.analytics` block (DW-043): the database path
1375/// must be non-empty (it is opened at startup — an empty string would
1376/// silently create a throwaway temp database), `flush_ms` bounded
1377/// (a sub-100 ms flush tick is timer churn; past 60 s the writer's
1378/// latency is the rollup grace's problem, not a useful knob),
1379/// retention monotone (a coarser table may not expire before a finer
1380/// one — the cascade would be recomputing from deleted history), and
1381/// dimensions name-valid (`[a-z0-9_]{1,32}` — the name is the rollup
1382/// table's `dim` key), header-representable, unique, and at most 16
1383/// (every dimension multiplies rollup cardinality).
1384fn validate_analytics(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
1385    let Some(a) = &gateway.analytics else { return };
1386    if a.path.trim().is_empty() {
1387        issues.push(issue(
1388            "gateway",
1389            "(root)",
1390            "analytics.path",
1391            "analytics.path is empty: the database path must name a real file \
1392             (an empty path would open a throwaway temp database, losing every \
1393             record)",
1394        ));
1395    }
1396    if let Some(flush) = a.flush_ms {
1397        if !(100..=60_000).contains(&flush) {
1398            issues.push(issue(
1399                "gateway",
1400                "(root)",
1401                "analytics.flush_ms",
1402                format!(
1403                    "flush_ms {flush} is out of range: must be 100..=60000 \
1404                     (below 100 is timer churn; above 60000 adds nothing the \
1405                     rollup grace does not already cover)"
1406                ),
1407            ));
1408        }
1409    }
1410    // REL-10 (#221): sampled degradation validation.
1411    if let Some(d) = &a.sampled_degradation {
1412        if !(0.0..=1.0).contains(&d.high_watermark) || d.high_watermark <= 0.0 {
1413            issues.push(issue(
1414                "gateway",
1415                "(root)",
1416                "analytics.sampled_degradation.high_watermark",
1417                "high_watermark must be in (0.0, 1.0]",
1418            ));
1419        }
1420        if !(0.0..=1.0).contains(&d.low_watermark) {
1421            issues.push(issue(
1422                "gateway",
1423                "(root)",
1424                "analytics.sampled_degradation.low_watermark",
1425                "low_watermark must be in [0.0, 1.0)",
1426            ));
1427        }
1428        if d.low_watermark >= d.high_watermark {
1429            issues.push(issue(
1430                "gateway",
1431                "(root)",
1432                "analytics.sampled_degradation.low_watermark",
1433                "low_watermark must be < high_watermark",
1434            ));
1435        }
1436        if !(0.0..=1.0).contains(&d.keep_rate) || d.keep_rate <= 0.0 {
1437            issues.push(issue(
1438                "gateway",
1439                "(root)",
1440                "analytics.sampled_degradation.keep_rate",
1441                "keep_rate must be in (0.0, 1.0]",
1442            ));
1443        }
1444    }
1445    if let Some(r) = &a.retention {
1446        let e = r.effective();
1447        let names = ["raw_ms", "m1_ms", "m5_ms", "h1_ms", "d1_ms"];
1448        for i in 0..5 {
1449            let v = e[i];
1450            let cap: i64 = match i {
1451                0 => 7 * 86_400_000,     // raw: a week
1452                1 => 30 * 86_400_000,    // 1m: a month
1453                2 => 90 * 86_400_000,    // 5m: a quarter
1454                3 => 365 * 86_400_000,   // 1h: a year
1455                _ => 3_650 * 86_400_000, // 1d: a decade
1456            };
1457            if v > cap {
1458                issues.push(issue(
1459                    "gateway",
1460                    "(root)",
1461                    &format!("analytics.retention.{}", names[i]),
1462                    format!(
1463                        "{} {} ms exceeds the cap {} ms (bounded disk is the \
1464                         point of the store)",
1465                        names[i], v, cap
1466                    ),
1467                ));
1468            }
1469        }
1470        for w in 0..4 {
1471            if e[w + 1] < e[w] {
1472                issues.push(issue(
1473                    "gateway",
1474                    "(root)",
1475                    &format!("analytics.retention.{}", names[w + 1]),
1476                    format!(
1477                        "{} {} ms is shorter than {} {} ms: a coarser \
1478                         rollup may not expire before the finer table it \
1479                         cascades from",
1480                        names[w + 1],
1481                        e[w + 1],
1482                        names[w],
1483                        e[w]
1484                    ),
1485                ));
1486            }
1487        }
1488    }
1489    let mut seen = std::collections::BTreeSet::new();
1490    for (i, dim) in a.dimensions.iter().enumerate() {
1491        let field = format!("analytics.dimensions[{i}]");
1492        let name_ok = !dim.name.is_empty()
1493            && dim.name.len() <= 32
1494            && dim
1495                .name
1496                .bytes()
1497                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_');
1498        if !name_ok {
1499            issues.push(issue(
1500                "gateway",
1501                "(root)",
1502                &format!("{field}.name"),
1503                format!(
1504                    "dimension name '{}' is invalid: lowercase [a-z0-9_], \
1505                     1..=32 bytes (it is the rollup dimension key)",
1506                    dim.name
1507                ),
1508            ));
1509        }
1510        // Source-specific field validation (DW-093): each source
1511        // requires its own field, and the field must be well-formed.
1512        match dim.effective_source() {
1513            crate::config::DimensionSource::Header => {
1514                if let Some(header) = &dim.header {
1515                    if let Err(e) = hyper::header::HeaderName::try_from(header.as_str()) {
1516                        issues.push(issue(
1517                            "gateway",
1518                            "(root)",
1519                            &format!("{field}.header"),
1520                            format!("header name '{header}' is not representable: {e}"),
1521                        ));
1522                    }
1523                } else {
1524                    issues.push(issue(
1525                        "gateway",
1526                        "(root)",
1527                        &format!("{field}.header"),
1528                        "source 'header' (or absent source) requires the \
1529                         'header' field",
1530                    ));
1531                }
1532            }
1533            crate::config::DimensionSource::Claim => {
1534                if let Some(claim) = &dim.claim {
1535                    if claim.is_empty()
1536                        || !claim.bytes().all(|b| b.is_ascii_graphic())
1537                        || claim.len() > 128
1538                    {
1539                        issues.push(issue(
1540                            "gateway",
1541                            "(root)",
1542                            &format!("{field}.claim"),
1543                            format!(
1544                                "claim name '{}' is invalid: non-empty \
1545                                 printable ASCII, at most 128 bytes",
1546                                claim
1547                            ),
1548                        ));
1549                    }
1550                } else {
1551                    issues.push(issue(
1552                        "gateway",
1553                        "(root)",
1554                        &format!("{field}.claim"),
1555                        "source 'claim' requires the 'claim' field",
1556                    ));
1557                }
1558            }
1559            crate::config::DimensionSource::BodyPath => {
1560                if let Some(path) = &dim.body_path {
1561                    if crate::config::transforms::JsonPointer::parse(path).is_none() {
1562                        issues.push(issue(
1563                            "gateway",
1564                            "(root)",
1565                            &format!("{field}.body_path"),
1566                            format!(
1567                                "body_path '{}' is not a valid RFC 6901 JSON \
1568                                 pointer (must start with '/' or be empty for \
1569                                 the whole document)",
1570                                path
1571                            ),
1572                        ));
1573                    }
1574                } else {
1575                    issues.push(issue(
1576                        "gateway",
1577                        "(root)",
1578                        &format!("{field}.body_path"),
1579                        "source 'body_path' requires the 'body_path' field",
1580                    ));
1581                }
1582            }
1583        }
1584        if !seen.insert(dim.name.to_ascii_lowercase()) {
1585            issues.push(issue(
1586                "gateway",
1587                "(root)",
1588                &format!("{field}.name"),
1589                format!(
1590                    "duplicate dimension name '{}' (each name is one rollup key)",
1591                    dim.name
1592                ),
1593            ));
1594        }
1595    }
1596    if a.dimensions.len() > 16 {
1597        issues.push(issue(
1598            "gateway",
1599            "(root)",
1600            "analytics.dimensions",
1601            format!(
1602                "{} dimensions declared; at most 16 (each multiplies rollup \
1603                 cardinality)",
1604                a.dimensions.len()
1605            ),
1606        ));
1607    }
1608    if let Some(exports) = &a.exports {
1609        if exports.directory.trim().is_empty() {
1610            issues.push(issue(
1611                "gateway",
1612                "(root)",
1613                "analytics.exports.directory",
1614                "analytics.exports.directory is empty: exports need a real \
1615                 directory to write their files into",
1616            ));
1617        }
1618        let mut seen_formats = std::collections::BTreeSet::new();
1619        for (i, f) in exports.formats.iter().enumerate() {
1620            if !seen_formats.insert(f) {
1621                issues.push(issue(
1622                    "gateway",
1623                    "(root)",
1624                    &format!("analytics.exports.formats[{i}]"),
1625                    format!(
1626                        "duplicate export format '{f:?}': each format writes \
1627                         exactly one file per window",
1628                    ),
1629                ));
1630            }
1631        }
1632    }
1633    // DW-092: live sketches freshness target bounds.
1634    if let Some(ls) = &a.live_sketches {
1635        if ls.enabled && !(100..=5000).contains(&ls.freshness_target_ms) {
1636            issues.push(issue(
1637                "gateway",
1638                "(root)",
1639                "analytics.live_sketches.freshness_target_ms",
1640                format!(
1641                    "freshness_target_ms {} is out of range: must be 100..=5000 \
1642                     (below 100 is timer churn; above 5000 is no longer 'live')",
1643                    ls.freshness_target_ms
1644                ),
1645            ));
1646        }
1647    }
1648    // DW-092: insights baseline_windows must be > 0 when either feature
1649    // is enabled.
1650    if let Some(ins) = &a.insights {
1651        if (ins.forecast || ins.anomaly_baseline) && ins.baseline_windows == 0 {
1652            issues.push(issue(
1653                "gateway",
1654                "(root)",
1655                "analytics.insights.baseline_windows",
1656                "baseline_windows must be > 0 when forecast or anomaly_baseline \
1657                 is enabled (the ring buffer must hold at least one window)",
1658            ));
1659        }
1660    }
1661}
1662
1663/// Validate GeoIP rules (DW-050): every `authorization.geoip`
1664/// predicate anywhere in the config (gateway/listener/service/route/
1665/// consumer — the five Authz attachment points) needs a
1666/// `gateway.geoip` database (an unevaluable gate is an authoring
1667/// error, never a silent pass), and country entries must be two ASCII
1668/// letters.
1669fn validate_geoip(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
1670    fn check(a: Option<&crate::config::Authz>, where_: &str, issues: &mut Vec<ValidationIssue>) {
1671        let Some(rules) = a.and_then(|a| a.geoip.as_ref()) else {
1672            return;
1673        };
1674        for c in rules
1675            .allowed_countries
1676            .iter()
1677            .chain(&rules.denied_countries)
1678        {
1679            if c.len() != 2 || !c.chars().all(|ch| ch.is_ascii_alphabetic()) {
1680                issues.push(issue(
1681                    "gateway",
1682                    where_,
1683                    "authorization.geoip.countries",
1684                    format!("country code '{c}' is not two ASCII letters (ISO 3166-1 alpha-2)"),
1685                ));
1686            }
1687        }
1688    }
1689    let has_rules = |a: Option<&crate::config::Authz>| a.is_some_and(|a| a.geoip.is_some());
1690    let any = gateway
1691        .authorization
1692        .as_ref()
1693        .is_some_and(|a| has_rules(Some(a)))
1694        || gateway
1695            .listeners
1696            .iter()
1697            .any(|l| has_rules(l.authorization.as_ref()))
1698        || gateway
1699            .services
1700            .iter()
1701            .any(|s| has_rules(s.authorization.as_ref()))
1702        || gateway
1703            .routes
1704            .iter()
1705            .any(|r| has_rules(r.authorization.as_ref()))
1706        || gateway
1707            .consumers
1708            .iter()
1709            .any(|c| has_rules(c.authorization.as_ref()));
1710    match &gateway.geoip {
1711        None if any => issues.push(issue(
1712            "gateway",
1713            "(root)",
1714            "geoip",
1715            "authorization.geoip rules require a gateway.geoip database block \
1716             (without one the gate cannot be evaluated)",
1717        )),
1718        Some(g) if g.path.trim().is_empty() => issues.push(issue(
1719            "gateway",
1720            "(root)",
1721            "geoip.path",
1722            "geoip.path is empty: name a real .mmdb file or remove the block",
1723        )),
1724        _ => {}
1725    }
1726    check(gateway.authorization.as_ref(), "(root)", issues);
1727    for l in &gateway.listeners {
1728        check(
1729            l.authorization.as_ref(),
1730            &format!("listeners[{}]", l.name),
1731            issues,
1732        );
1733    }
1734    for s in &gateway.services {
1735        check(
1736            s.authorization.as_ref(),
1737            &format!("services[{}]", s.name),
1738            issues,
1739        );
1740    }
1741    for r in &gateway.routes {
1742        check(
1743            r.authorization.as_ref(),
1744            &format!("routes[{}]", r.name),
1745            issues,
1746        );
1747    }
1748    for c in &gateway.consumers {
1749        check(
1750            c.authorization.as_ref(),
1751            &format!("consumers[{}]", c.name),
1752            issues,
1753        );
1754    }
1755}
1756
1757/// Validate consumer request budgets (DW-033): a `quotas` block must
1758/// set at least one budget, and every set budget must be > 0 (a 0
1759/// budget would deny the consumer's first request; "no budget" is the
1760/// omitted field). Quota enforcement additionally needs the state
1761/// store at RUNTIME (`DWARA_STATE_DB`), which config validation
1762/// deliberately cannot see — without one the block is inert and the
1763/// dataplane warns (see `state::quotas`).
1764fn validate_quotas(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
1765    for c in &gateway.consumers {
1766        let Some(q) = &c.quotas else {
1767            continue;
1768        };
1769        let field = format!("consumers[{}].quotas", c.name);
1770        if q.daily_requests.is_none() && q.monthly_requests.is_none() {
1771            issues.push(issue(
1772                "consumer",
1773                &c.name,
1774                &field,
1775                "sets no budget: at least one of daily_requests or monthly_requests \
1776                 must be present (omit the quotas block entirely for no budgets)",
1777            ));
1778        }
1779        for (name, value) in [
1780            ("daily_requests", q.daily_requests),
1781            ("monthly_requests", q.monthly_requests),
1782        ] {
1783            if value == Some(0) {
1784                issues.push(issue(
1785                    "consumer",
1786                    &c.name,
1787                    &format!("{field}.{name}"),
1788                    format!(
1789                        "{name} must be > 0 (a zero budget denies the consumer's \
1790                             first request; no budget is the omitted field)"
1791                    ),
1792                ));
1793            }
1794        }
1795    }
1796}
1797
1798/// Validate the `gateway.plugins` list (DW-055 / DW-119): each plugin
1799/// must set exactly one of `wasm` or `native` (mutually exclusive), and
1800/// must declare a non-empty `phases` list. Duplicate plugin names are
1801/// flagged. Route references to unknown names are rejected (see the
1802/// route validation pass). A `wasm` plugin's file existence and
1803/// compilability are checked by the WASM lifecycle manager at LOAD
1804/// time (DW-157: a broken .wasm is marked crashed there and routes
1805/// referencing it answer 500 fail-closed), not here — the file may not
1806/// be present in the validation environment.
1807fn validate_plugins(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
1808    let mut seen = std::collections::BTreeSet::new();
1809    for p in &gateway.plugins {
1810        if !seen.insert(p.name.as_str()) {
1811            issues.push(issue("plugin", &p.name, "name", "duplicate plugin name"));
1812        }
1813        let has_wasm = p.wasm.is_some();
1814        let has_native = p.native.is_some();
1815        let has_source = p.source.is_some();
1816        // SCALE-12 (#192): source is a remote variant of wasm. A plugin
1817        // with `source` is a WASM plugin whose artifact is downloaded
1818        // from the registry; `wasm` is the optional local cache path.
1819        let has_wasm_or_source = has_wasm || has_source;
1820        if !has_wasm_or_source && !has_native {
1821            issues.push(issue(
1822                "plugin",
1823                &p.name,
1824                "wasm",
1825                "plugin must set exactly one of `wasm`, `source`, or `native` (all are absent)",
1826            ));
1827        }
1828        if has_wasm_or_source && has_native {
1829            issues.push(issue(
1830                "plugin",
1831                &p.name,
1832                "native",
1833                "plugin must set exactly one of `wasm`/`source` or `native` (both are set)",
1834            ));
1835        }
1836        if has_wasm && has_source {
1837            issues.push(issue(
1838                "plugin",
1839                &p.name,
1840                "source",
1841                "plugin must not set both `wasm` and `source` (use `source` with optional `cache_path`)",
1842            ));
1843        }
1844        if p.phases.is_empty() {
1845            issues.push(issue(
1846                "plugin",
1847                &p.name,
1848                "phases",
1849                "phases must be a non-empty subset of request_headers, \
1850                 request_body, response_headers, response_body",
1851            ));
1852        }
1853        // SCALE-12 (#192)/DW-165: validate remote source config. The
1854        // resolver (wasm::source) re-checks all of this defensively;
1855        // validation gives the operator the earliest, field-named view.
1856        if let Some(src) = &p.source {
1857            let digest_ok =
1858                src.digest.len() == 64 && src.digest.bytes().all(|b| b.is_ascii_hexdigit());
1859            if !digest_ok {
1860                let detail = if src.digest.len() != 64 {
1861                    format!("got {} chars, expected 64", src.digest.len())
1862                } else {
1863                    "contains non-hex characters".to_string()
1864                };
1865                issues.push(issue(
1866                    "plugin",
1867                    &p.name,
1868                    "source.digest",
1869                    format!("source digest must be a hex-encoded SHA-256 string ({detail})"),
1870                ));
1871            }
1872            if src.url.starts_with("oci://") {
1873                issues.push(issue(
1874                    "plugin",
1875                    &p.name,
1876                    "source.url",
1877                    "oci:// plugin sources are not supported in this version; publish \
1878                     the artifact over https:// or pre-fetch it with `dwara-cli plugin \
1879                     install` and reference the local file with `wasm`",
1880                ));
1881            } else if !src.url.starts_with("https://") {
1882                issues.push(issue(
1883                    "plugin",
1884                    &p.name,
1885                    "source.url",
1886                    "source url must use the https:// scheme (oci:// is not supported \
1887                     in this version)",
1888                ));
1889            }
1890            if let Some(pk) = &src.public_key {
1891                if pk.len() != 64 || !pk.bytes().all(|b| b.is_ascii_hexdigit()) {
1892                    issues.push(issue(
1893                        "plugin",
1894                        &p.name,
1895                        "source.public_key",
1896                        "source.public_key must be a 32-byte Ed25519 key hex-encoded \
1897                         (64 chars)",
1898                    ));
1899                }
1900            }
1901            if let Some(sig) = &src.signature {
1902                if sig.len() != 128 || !sig.bytes().all(|b| b.is_ascii_hexdigit()) {
1903                    issues.push(issue(
1904                        "plugin",
1905                        &p.name,
1906                        "source.signature",
1907                        "source.signature must be a 64-byte Ed25519 signature \
1908                         hex-encoded (128 chars)",
1909                    ));
1910                }
1911            }
1912            if src.signature.is_some() && src.public_key.is_none() {
1913                // Still required per-plugin: registry-pinned keys alone
1914                // do not identify which key signed THIS plugin unless
1915                // the operator pins them via plugin_registry.
1916                let registry_keys = gateway
1917                    .plugin_registry
1918                    .as_ref()
1919                    .map(|r| !r.public_keys.is_empty())
1920                    .unwrap_or(false);
1921                if !registry_keys {
1922                    issues.push(issue(
1923                        "plugin",
1924                        &p.name,
1925                        "source.public_key",
1926                        "source.public_key is required when source.signature is set \
1927                         (or pin keys via plugin_registry.public_keys)",
1928                    ));
1929                }
1930            }
1931            if src.public_key.is_some() && src.signature.is_none() {
1932                // Review finding: a key without a signature used to be
1933                // silently ignored (nothing would be verified with it);
1934                // that is a config mistake — reject it.
1935                issues.push(issue(
1936                    "plugin",
1937                    &p.name,
1938                    "source.signature",
1939                    "source.signature is required when source.public_key is set \
1940                     (a key without a signature verifies nothing — sign the \
1941                     artifact or remove the key)",
1942                ));
1943            }
1944            if let Some(cp) = &src.cache_path {
1945                // Review finding (containment): cache_path is
1946                // operator-chosen and the resolver creates directories
1947                // and writes artifact bytes there — it must stay inside
1948                // the cache dir. The rule lives on
1949                // PluginSourceConfig (shared with the resolver's
1950                // defensive re-check).
1951                if let Some(reason) = crate::config::PluginSourceConfig::cache_path_violation(cp) {
1952                    issues.push(issue(
1953                        "plugin",
1954                        &p.name,
1955                        "source.cache_path",
1956                        format!(
1957                            "source.cache_path '{cp}' is not usable ({reason}); it must \
1958                             be a relative path under plugin_registry.cache_dir with no \
1959                             parent segments"
1960                        ),
1961                    ));
1962                }
1963            }
1964        }
1965    }
1966    // SCALE-12 (#192): validate plugin registry config.
1967    if let Some(reg) = &gateway.plugin_registry {
1968        for pk in &reg.public_keys {
1969            if pk.len() != 64 {
1970                issues.push(issue(
1971                    "plugin_registry",
1972                    "gateway",
1973                    "public_keys",
1974                    "each public key must be a 32-byte Ed25519 key hex-encoded (64 chars)",
1975                ));
1976            }
1977        }
1978    }
1979}
1980
1981/// SCALE-12 (#191): validate a filter-chain config block. The `order`
1982/// override, if present, must be a permutation of the default phase
1983/// set (no duplicates, no unknown phases, all phases present).
1984fn validate_filter_chain(
1985    fc: &crate::config::FilterChainConfig,
1986    entity: &str,
1987    name: &str,
1988    issues: &mut Vec<ValidationIssue>,
1989) {
1990    if let Some(order) = &fc.order {
1991        let default = crate::config::FilterPhase::DEFAULT_ORDER;
1992        if order.len() != default.len() {
1993            issues.push(issue(
1994                entity,
1995                name,
1996                "filter_chain.order",
1997                format!(
1998                    "order must contain exactly {} phases, got {}",
1999                    default.len(),
2000                    order.len()
2001                ),
2002            ));
2003        } else {
2004            let mut seen = std::collections::BTreeSet::new();
2005            for phase in order {
2006                if !seen.insert(*phase) {
2007                    issues.push(issue(
2008                        entity,
2009                        name,
2010                        "filter_chain.order",
2011                        format!("duplicate phase '{}' in filter_chain.order", phase.as_str()),
2012                    ));
2013                }
2014            }
2015            // Check all default phases are present.
2016            for dp in &default {
2017                if !order.contains(dp) {
2018                    issues.push(issue(
2019                        entity,
2020                        name,
2021                        "filter_chain.order",
2022                        format!("filter_chain.order is missing phase '{}'", dp.as_str()),
2023                    ));
2024                }
2025            }
2026        }
2027    }
2028    // dry_run: check for duplicates.
2029    let mut seen = std::collections::BTreeSet::new();
2030    for phase in &fc.dry_run {
2031        if !seen.insert(*phase) {
2032            issues.push(issue(
2033                entity,
2034                name,
2035                "filter_chain.dry_run",
2036                format!(
2037                    "duplicate phase '{}' in filter_chain.dry_run",
2038                    phase.as_str()
2039                ),
2040            ));
2041        }
2042    }
2043}
2044
2045/// Validate the `gateway.license` block (DW-032): the grace-period days
2046/// must be in 0..=30, and the file path must be non-empty. The signature
2047/// and expiry checks are NOT validation concerns — they run at startup
2048/// in dwara-bin (where a missing or invalid file can exit 1), not in the
2049/// compile pipeline (a license file's validity is a runtime property
2050/// that can change between reloads without a config-schema change).
2051fn validate_license(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2052    let Some(lic) = &gateway.license else {
2053        return;
2054    };
2055    if lic.file.trim().is_empty() {
2056        issues.push(issue(
2057            "gateway",
2058            "(root)",
2059            "license.file",
2060            "license.file must be a non-empty filesystem path",
2061        ));
2062    }
2063    if lic.grace_period_days > crate::config::limits::MAX_LICENSE_GRACE_PERIOD_DAYS {
2064        issues.push(issue(
2065            "gateway",
2066            "(root)",
2067            "license.grace_period_days",
2068            format!(
2069                "license.grace_period_days {} is out of range: must be 0..={}",
2070                lic.grace_period_days,
2071                crate::config::limits::MAX_LICENSE_GRACE_PERIOD_DAYS,
2072            ),
2073        ));
2074    }
2075}
2076
2077/// Validate the `gateway.redis_rate_limiter` block (DW-031): the URL
2078/// must be non-empty, the connection timeout must be in
2079/// 100..=30 000 ms, and the key TTL must be in 1..=604 800 s. The
2080/// license check (does the license grant `redis_rate_limiter`?) is NOT
2081/// a validation concern — it runs at startup in dwara-bin (where a
2082/// missing claim can log a warning and fall back to the local
2083/// limiter), not in the compile pipeline (a license's validity is a
2084/// runtime property that can change between reloads without a
2085/// config-schema change).
2086fn validate_redis_rate_limiter(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2087    let Some(rl) = &gateway.redis_rate_limiter else {
2088        return;
2089    };
2090    if rl.url.trim().is_empty() {
2091        issues.push(issue(
2092            "gateway",
2093            "(root)",
2094            "redis_rate_limiter.url",
2095            "redis_rate_limiter.url must be a non-empty Redis URL",
2096        ));
2097    }
2098    let timeout = rl.connection_timeout_ms;
2099    if !(crate::config::limits::MIN_REDIS_CONNECTION_TIMEOUT_MS
2100        ..=crate::config::limits::MAX_REDIS_CONNECTION_TIMEOUT_MS)
2101        .contains(&timeout)
2102    {
2103        issues.push(issue(
2104            "gateway",
2105            "(root)",
2106            "redis_rate_limiter.connection_timeout_ms",
2107            format!(
2108                "redis_rate_limiter.connection_timeout_ms {} is out of range: must be {}..={}",
2109                timeout,
2110                crate::config::limits::MIN_REDIS_CONNECTION_TIMEOUT_MS,
2111                crate::config::limits::MAX_REDIS_CONNECTION_TIMEOUT_MS,
2112            ),
2113        ));
2114    }
2115    let ttl = rl.key_ttl_s;
2116    if !(crate::config::limits::MIN_REDIS_KEY_TTL_S..=crate::config::limits::MAX_REDIS_KEY_TTL_S)
2117        .contains(&ttl)
2118    {
2119        issues.push(issue(
2120            "gateway",
2121            "(root)",
2122            "redis_rate_limiter.key_ttl_s",
2123            format!(
2124                "redis_rate_limiter.key_ttl_s {} is out of range: must be {}..={}",
2125                ttl,
2126                crate::config::limits::MIN_REDIS_KEY_TTL_S,
2127                crate::config::limits::MAX_REDIS_KEY_TTL_S,
2128            ),
2129        ));
2130    }
2131    if rl.key_prefix.is_empty() {
2132        issues.push(issue(
2133            "gateway",
2134            "(root)",
2135            "redis_rate_limiter.key_prefix",
2136            "redis_rate_limiter.key_prefix must be a non-empty string",
2137        ));
2138    }
2139    validate_redis_ha("redis_rate_limiter", &rl.ha, issues);
2140}
2141
2142/// Validate the `ha` block shared by all Redis-backed config sections
2143/// (SCALE-11, #248). Checks:
2144/// - `topology = sentinel` requires `master_name` and at least one
2145///   node URL.
2146/// - `topology = cluster` requires at least one node URL (or the
2147///   parent `url` must be non-empty).
2148/// - `request_timeout_ms` must be 0 or in 50..=30 000.
2149fn validate_redis_ha(
2150    prefix: &str,
2151    ha: &crate::config::RedisHaConfig,
2152    issues: &mut Vec<ValidationIssue>,
2153) {
2154    use crate::config::RedisTopology;
2155    match ha.topology {
2156        RedisTopology::Single => {}
2157        RedisTopology::Sentinel => {
2158            if ha.master_name.is_none()
2159                || ha
2160                    .master_name
2161                    .as_deref()
2162                    .map(|s| s.trim().is_empty())
2163                    .unwrap_or(true)
2164            {
2165                issues.push(issue(
2166                    "gateway",
2167                    "(root)",
2168                    &format!("{prefix}.ha.master_name"),
2169                    format!("{prefix}.ha.master_name is required when topology = sentinel"),
2170                ));
2171            }
2172            if ha.nodes.is_empty() {
2173                issues.push(issue(
2174                    "gateway",
2175                    "(root)",
2176                    &format!("{prefix}.ha.nodes"),
2177                    format!("{prefix}.ha.nodes must list at least one Sentinel URL when topology = sentinel"),
2178                ));
2179            }
2180        }
2181        RedisTopology::Cluster => {
2182            if ha.nodes.is_empty() {
2183                issues.push(issue(
2184                    "gateway",
2185                    "(root)",
2186                    &format!("{prefix}.ha.nodes"),
2187                    format!("{prefix}.ha.nodes must list at least one cluster node URL when topology = cluster"),
2188                ));
2189            }
2190        }
2191    }
2192    let timeout = ha.request_timeout_ms;
2193    if timeout != 0 && !(50..=30_000).contains(&timeout) {
2194        issues.push(issue(
2195            "gateway",
2196            "(root)",
2197            &format!("{prefix}.ha.request_timeout_ms"),
2198            format!(
2199                "{prefix}.ha.request_timeout_ms {timeout} is out of range: must be 0 or 50..=30000"
2200            ),
2201        ));
2202    }
2203}
2204
2205/// Validate the `gateway.redis_quotas` block (DW-155): the URL must be
2206/// non-empty, the connection timeout must be in 100..=30 000 ms, and
2207/// the key prefix must be non-empty. The license check (does the
2208/// license grant `redis_quotas`?) is NOT a validation concern — it
2209/// runs at startup in dwara-bin (where a missing claim can log a
2210/// warning and fall back to the local SQLite checker), not in the
2211/// compile pipeline.
2212fn validate_redis_quotas(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2213    let Some(rq) = &gateway.redis_quotas else {
2214        return;
2215    };
2216    if rq.url.trim().is_empty() {
2217        issues.push(issue(
2218            "gateway",
2219            "(root)",
2220            "redis_quotas.url",
2221            "redis_quotas.url must be a non-empty Redis URL",
2222        ));
2223    }
2224    let timeout = rq.connection_timeout_ms;
2225    if !(crate::config::limits::MIN_REDIS_CONNECTION_TIMEOUT_MS
2226        ..=crate::config::limits::MAX_REDIS_CONNECTION_TIMEOUT_MS)
2227        .contains(&timeout)
2228    {
2229        issues.push(issue(
2230            "gateway",
2231            "(root)",
2232            "redis_quotas.connection_timeout_ms",
2233            format!(
2234                "redis_quotas.connection_timeout_ms {} is out of range: must be {}..={}",
2235                timeout,
2236                crate::config::limits::MIN_REDIS_CONNECTION_TIMEOUT_MS,
2237                crate::config::limits::MAX_REDIS_CONNECTION_TIMEOUT_MS,
2238            ),
2239        ));
2240    }
2241    if rq.key_prefix.is_empty() {
2242        issues.push(issue(
2243            "gateway",
2244            "(root)",
2245            "redis_quotas.key_prefix",
2246            "redis_quotas.key_prefix must be a non-empty string",
2247        ));
2248    }
2249    validate_redis_ha("redis_quotas", &rq.ha, issues);
2250}
2251
2252/// Validate the `gateway.redis_cache` block (SCALE-04, #183): the URL
2253/// must be non-empty, the connection timeout must be in 100..=30 000 ms,
2254/// and the key prefix must be non-empty. The license check (does the
2255/// license grant `redis_cache`?) is NOT a validation concern — it runs
2256/// at startup in dwara-bin (where a missing claim logs a warning and
2257/// falls back to the local moka cache), not in the compile pipeline.
2258fn validate_redis_cache(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2259    let Some(rc) = &gateway.redis_cache else {
2260        return;
2261    };
2262    if rc.url.trim().is_empty() {
2263        issues.push(issue(
2264            "gateway",
2265            "(root)",
2266            "redis_cache.url",
2267            "redis_cache.url must be a non-empty Redis URL",
2268        ));
2269    }
2270    let timeout = rc.connection_timeout_ms;
2271    if !(crate::config::limits::MIN_REDIS_CONNECTION_TIMEOUT_MS
2272        ..=crate::config::limits::MAX_REDIS_CONNECTION_TIMEOUT_MS)
2273        .contains(&timeout)
2274    {
2275        issues.push(issue(
2276            "gateway",
2277            "(root)",
2278            "redis_cache.connection_timeout_ms",
2279            format!(
2280                "redis_cache.connection_timeout_ms {} is out of range: must be {}..={}",
2281                timeout,
2282                crate::config::limits::MIN_REDIS_CONNECTION_TIMEOUT_MS,
2283                crate::config::limits::MAX_REDIS_CONNECTION_TIMEOUT_MS,
2284            ),
2285        ));
2286    }
2287    if rc.key_prefix.is_empty() {
2288        issues.push(issue(
2289            "gateway",
2290            "(root)",
2291            "redis_cache.key_prefix",
2292            "redis_cache.key_prefix must be a non-empty string",
2293        ));
2294    }
2295    validate_redis_ha("redis_cache", &rc.ha, issues);
2296}
2297
2298/// Validate the `gateway.config_convergence` block (DW-054): the
2299/// backend must be `"redis"` (the only v1 backend; etcd/Consul are
2300/// deferred behind the trait), `redis_url` must be present and
2301/// non-empty when `backend = "redis"`, the poll and drift-check
2302/// intervals must be in range, and the key prefix must be non-empty.
2303/// The license check (does the license grant `config_convergence`?)
2304/// is NOT a validation concern -- it runs at startup in dwara-bin
2305/// (where a missing claim logs a warning and falls back to local-only
2306/// mode), not in the compile pipeline (a license's validity is a
2307/// runtime property that can change between reloads without a
2308/// config-schema change).
2309fn validate_config_convergence(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2310    let Some(cc) = &gateway.config_convergence else {
2311        return;
2312    };
2313    if cc.backend != "redis" {
2314        issues.push(issue(
2315            "gateway",
2316            "(root)",
2317            "config_convergence.backend",
2318            format!(
2319                "config_convergence.backend '{}' is not supported: v1 ships only 'redis' \
2320                 (etcd/Consul are deferred behind the ConfigConvergenceBackend trait)",
2321                cc.backend,
2322            ),
2323        ));
2324    }
2325    if cc.backend == "redis" {
2326        match &cc.redis_url {
2327            None => issues.push(issue(
2328                "gateway",
2329                "(root)",
2330                "config_convergence.redis_url",
2331                "config_convergence.redis_url is required when backend = 'redis'",
2332            )),
2333            Some(url) if url.trim().is_empty() => issues.push(issue(
2334                "gateway",
2335                "(root)",
2336                "config_convergence.redis_url",
2337                "config_convergence.redis_url must be a non-empty Redis URL",
2338            )),
2339            _ => {}
2340        }
2341    }
2342    let poll = cc.poll_interval_ms;
2343    if !(crate::config::limits::MIN_CONFIG_CONVERGENCE_POLL_MS
2344        ..=crate::config::limits::MAX_CONFIG_CONVERGENCE_POLL_MS)
2345        .contains(&poll)
2346    {
2347        issues.push(issue(
2348            "gateway",
2349            "(root)",
2350            "config_convergence.poll_interval_ms",
2351            format!(
2352                "config_convergence.poll_interval_ms {} is out of range: must be {}..={}",
2353                poll,
2354                crate::config::limits::MIN_CONFIG_CONVERGENCE_POLL_MS,
2355                crate::config::limits::MAX_CONFIG_CONVERGENCE_POLL_MS,
2356            ),
2357        ));
2358    }
2359    let drift = cc.drift_check_interval_ms;
2360    if !(crate::config::limits::MIN_CONFIG_CONVERGENCE_DRIFT_CHECK_MS
2361        ..=crate::config::limits::MAX_CONFIG_CONVERGENCE_DRIFT_CHECK_MS)
2362        .contains(&drift)
2363    {
2364        issues.push(issue(
2365            "gateway",
2366            "(root)",
2367            "config_convergence.drift_check_interval_ms",
2368            format!(
2369                "config_convergence.drift_check_interval_ms {} is out of range: must be {}..={}",
2370                drift,
2371                crate::config::limits::MIN_CONFIG_CONVERGENCE_DRIFT_CHECK_MS,
2372                crate::config::limits::MAX_CONFIG_CONVERGENCE_DRIFT_CHECK_MS,
2373            ),
2374        ));
2375    }
2376    if cc.key_prefix.is_empty() {
2377        issues.push(issue(
2378            "gateway",
2379            "(root)",
2380            "config_convergence.key_prefix",
2381            "config_convergence.key_prefix must be a non-empty string",
2382        ));
2383    }
2384}
2385
2386/// Validate the `gateway.fleet` block (DW-098): the stale timeout
2387/// must be in range, upgrade order entries must have non-empty names
2388/// and non-empty label selectors, and label selectors must not
2389/// overlap (an edge would match two waves, making the order
2390/// ambiguous).
2391fn validate_fleet(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2392    let Some(fleet) = &gateway.fleet else {
2393        return;
2394    };
2395    if !fleet.enabled {
2396        return;
2397    }
2398    let stale = fleet.stale_timeout_secs;
2399    if !(5..=3600).contains(&stale) {
2400        issues.push(issue(
2401            "gateway",
2402            "(root)",
2403            "fleet.stale_timeout_secs",
2404            format!("fleet.stale_timeout_secs {stale} is out of range: must be 5..=3600"),
2405        ));
2406    }
2407    if let Some(cv) = &fleet.controller_version {
2408        if cv.trim().is_empty() {
2409            issues.push(issue(
2410                "gateway",
2411                "(root)",
2412                "fleet.controller_version",
2413                "fleet.controller_version must be a non-empty semver string when present",
2414            ));
2415        }
2416    }
2417    if let Some(upgrade) = &fleet.upgrade {
2418        let mut seen_names = std::collections::HashSet::new();
2419        for (i, entry) in upgrade.order.iter().enumerate() {
2420            if entry.name.is_empty() {
2421                issues.push(issue(
2422                    "gateway",
2423                    "(root)",
2424                    &format!("fleet.upgrade.order[{i}].name"),
2425                    "upgrade order entry name must be a non-empty string",
2426                ));
2427            }
2428            if !seen_names.insert(entry.name.as_str()) {
2429                issues.push(issue(
2430                    "gateway",
2431                    "(root)",
2432                    &format!("fleet.upgrade.order[{i}].name"),
2433                    format!(
2434                        "upgrade order entry name '{}' is duplicated (each wave must have a \
2435                         unique name)",
2436                        entry.name
2437                    ),
2438                ));
2439            }
2440            if entry.labels.is_empty() {
2441                issues.push(issue(
2442                    "gateway",
2443                    "(root)",
2444                    &format!("fleet.upgrade.order[{i}].labels"),
2445                    "upgrade order entry labels must be a non-empty label selector",
2446                ));
2447            }
2448        }
2449        // Check for overlapping label selectors (an edge matching two
2450        // waves makes the order ambiguous).
2451        for (i, a) in upgrade.order.iter().enumerate() {
2452            for (j, b) in upgrade.order.iter().enumerate().skip(i + 1) {
2453                if labels_overlap(&a.labels, &b.labels) {
2454                    issues.push(issue(
2455                        "gateway",
2456                        "(root)",
2457                        &format!("fleet.upgrade.order[{i}].labels"),
2458                        format!(
2459                            "upgrade order entries '{}' (index {i}) and '{}' (index {j}) have \
2460                             overlapping label selectors (an edge would match both, making the \
2461                             order ambiguous)",
2462                            a.name, b.name
2463                        ),
2464                    ));
2465                }
2466            }
2467        }
2468    }
2469}
2470
2471/// Whether two label selectors overlap (one is a subset of the other).
2472/// Two selectors overlap when every label in the smaller set is
2473/// present in the larger set with the same value.
2474fn labels_overlap(
2475    a: &std::collections::HashMap<String, String>,
2476    b: &std::collections::HashMap<String, String>,
2477) -> bool {
2478    let (smaller, larger) = if a.len() <= b.len() { (a, b) } else { (b, a) };
2479    smaller.iter().all(|(k, v)| larger.get(k) == Some(v))
2480}
2481
2482/// Validate the `gateway.mesh` block (DW-107): service mesh mode
2483/// (sidecar + SPIFFE/SPIRE mTLS identity). When the `mesh` cargo
2484/// feature is off, the block is accepted but a warning issue is
2485/// emitted (the block is inert -- no sidecar listeners or SPIFFE
2486/// client are wired). Ent-only: when the `ent` feature is off, a
2487/// warning is emitted (mesh is an enterprise feature). SPIFFE:
2488/// `trust_domain` and `workload_api_socket` must be non-empty, and
2489/// `svid_refresh_interval_secs` must be > 0. Sidecar: `inbound_port`
2490/// and `outbound_port` must be > 0 and different, and `redirect_mode`
2491/// must be `iptables` or `tproxy`.
2492fn validate_mesh(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2493    let Some(mesh) = &gateway.mesh else {
2494        return;
2495    };
2496
2497    // Feature gate: warn that the block is inert without the `mesh`
2498    // cargo feature (mirrors the a2a ent-gated warning pattern).
2499
2500    // Ent gate: warn when mesh is configured without the `ent` feature
2501    // (mesh is an enterprise feature; mirrors the FIPS/credential-pool
2502    // ent-gate pattern). The block still parses and the mesh-feature
2503    // scaffold compiles, but license-gated enforcement requires `ent`.
2504    #[cfg(not(feature = "ent"))]
2505    {
2506        issues.push(issue(
2507            "gateway",
2508            "(root)",
2509            "mesh",
2510            "the mesh block is configured but the `ent` cargo feature is \
2511             not compiled in: service mesh mode is an enterprise feature \
2512             (DW-107). Build with --features ent (combined with --features \
2513             mesh) for license-gated enforcement.",
2514        ));
2515    }
2516
2517    if !mesh.enabled {
2518        return;
2519    }
2520
2521    // Sidecar validation.
2522    if let Some(sidecar) = &mesh.sidecar {
2523        if sidecar.inbound_port == 0 {
2524            issues.push(issue(
2525                "gateway",
2526                "(root)",
2527                "mesh.sidecar.inbound_port",
2528                "inbound_port must be > 0",
2529            ));
2530        }
2531        if sidecar.outbound_port == 0 {
2532            issues.push(issue(
2533                "gateway",
2534                "(root)",
2535                "mesh.sidecar.outbound_port",
2536                "outbound_port must be > 0",
2537            ));
2538        }
2539        if sidecar.inbound_port == sidecar.outbound_port && sidecar.inbound_port != 0 {
2540            issues.push(issue(
2541                "gateway",
2542                "(root)",
2543                "mesh.sidecar.inbound_port",
2544                format!(
2545                    "inbound_port ({}) and outbound_port ({}) must be different \
2546                     (the sidecar binds separate listeners for inbound and outbound \
2547                     traffic)",
2548                    sidecar.inbound_port, sidecar.outbound_port
2549                ),
2550            ));
2551        }
2552        if sidecar.redirect_mode != "iptables" && sidecar.redirect_mode != "tproxy" {
2553            issues.push(issue(
2554                "gateway",
2555                "(root)",
2556                "mesh.sidecar.redirect_mode",
2557                format!(
2558                    "redirect_mode '{}' is not a supported redirect mode: must be \
2559                     'iptables' or 'tproxy'",
2560                    sidecar.redirect_mode
2561                ),
2562            ));
2563        }
2564    }
2565
2566    // SPIFFE validation.
2567    if let Some(spiffe) = &mesh.spiffe {
2568        if spiffe.trust_domain.trim().is_empty() {
2569            issues.push(issue(
2570                "gateway",
2571                "(root)",
2572                "mesh.spiffe.trust_domain",
2573                "trust_domain must be non-empty (it forms the SPIFFE ID prefix \
2574                 spiffe://<trust-domain>/...)",
2575            ));
2576        }
2577        if spiffe.workload_api_socket.trim().is_empty() {
2578            issues.push(issue(
2579                "gateway",
2580                "(root)",
2581                "mesh.spiffe.workload_api_socket",
2582                "workload_api_socket must be non-empty (the SPIRE Workload API \
2583                 Unix socket path)",
2584            ));
2585        }
2586        if spiffe.svid_refresh_interval_secs == 0 {
2587            issues.push(issue(
2588                "gateway",
2589                "(root)",
2590                "mesh.spiffe.svid_refresh_interval_secs",
2591                "svid_refresh_interval_secs must be > 0 (omit for the default 300s)",
2592            ));
2593        }
2594    }
2595}
2596
2597/// Validate the `gateway.oidc_providers` list (DW-034): each issuer must
2598/// be an absolute `http(s)://` URL, the introspection cache TTL must be
2599/// in `1..=3600`, the `consumer` reference (when set) must name a known
2600/// consumer, the `trusted_ca_file` (when set) must be a readable PEM
2601/// bundle and only applies to an `https://` issuer, and provider names
2602/// must be unique. Endpoint overrides (`introspection_endpoint`,
2603/// `revocation_endpoint`) are checked for URL shape when present.
2604fn validate_oidc_providers(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2605    // Duplicate provider names (inline: check_dups is a local closure in
2606    // the calling validate function, not a free function).
2607    {
2608        let mut seen = std::collections::BTreeSet::new();
2609        for p in &gateway.oidc_providers {
2610            if !seen.insert(p.name.as_str()) {
2611                issues.push(issue("oidc_provider", &p.name, "name", "duplicate name"));
2612            }
2613        }
2614    }
2615    for p in &gateway.oidc_providers {
2616        match p.issuer.parse::<hyper::Uri>() {
2617            Ok(uri) => {
2618                if !matches!(uri.scheme_str(), Some("http") | Some("https")) || uri.host().is_none()
2619                {
2620                    issues.push(issue(
2621                        "oidc_provider",
2622                        &p.name,
2623                        "issuer",
2624                        format!("'{}' must be an absolute http(s) URL", p.issuer),
2625                    ));
2626                }
2627            }
2628            Err(_) => issues.push(issue(
2629                "oidc_provider",
2630                &p.name,
2631                "issuer",
2632                format!("'{}' is not a valid URL", p.issuer),
2633            )),
2634        }
2635        let ttl = p.introspection_cache_ttl_s;
2636        if !(crate::config::limits::MIN_OIDC_INTROSPECTION_CACHE_TTL_S
2637            ..=crate::config::limits::MAX_OIDC_INTROSPECTION_CACHE_TTL_S)
2638            .contains(&ttl)
2639        {
2640            issues.push(issue(
2641                "oidc_provider",
2642                &p.name,
2643                "introspection_cache_ttl_s",
2644                format!(
2645                    "introspection_cache_ttl_s {} is out of range: must be {}..={}",
2646                    ttl,
2647                    crate::config::limits::MIN_OIDC_INTROSPECTION_CACHE_TTL_S,
2648                    crate::config::limits::MAX_OIDC_INTROSPECTION_CACHE_TTL_S,
2649                ),
2650            ));
2651        }
2652        if let Some(consumer) = &p.consumer {
2653            if !gateway.consumers.iter().any(|c| &c.name == consumer) {
2654                issues.push(issue(
2655                    "oidc_provider",
2656                    &p.name,
2657                    "consumer",
2658                    format!("references unknown consumer '{consumer}'"),
2659                ));
2660            }
2661        }
2662        // trusted_ca_file: same rules as JwtProvider (#121) — only
2663        // meaningful for an https issuer, and must be a readable PEM
2664        // bundle when it does apply.
2665        if let Some(ca) = &p.trusted_ca_file {
2666            if let Ok(uri) = p.issuer.parse::<hyper::Uri>() {
2667                if uri
2668                    .scheme_str()
2669                    .is_some_and(|s| s.eq_ignore_ascii_case("https"))
2670                {
2671                    check_trusted_ca_file("oidc_provider", &p.name, "trusted_ca_file", ca, issues);
2672                } else {
2673                    issues.push(issue(
2674                        "oidc_provider",
2675                        &p.name,
2676                        "trusted_ca_file",
2677                        "trusted_ca_file only applies to an https issuer (no TLS is \
2678                         negotiated toward an http endpoint)",
2679                    ));
2680                }
2681            }
2682        }
2683        // Endpoint overrides: when present, must be absolute http(s) URLs.
2684        if let Some(ep) = &p.introspection_endpoint {
2685            validate_delivery_url(
2686                &format!("oidc_provider[{}].introspection_endpoint", p.name),
2687                ep,
2688                issues,
2689            );
2690        }
2691        if let Some(ep) = &p.revocation_endpoint {
2692            validate_delivery_url(
2693                &format!("oidc_provider[{}].revocation_endpoint", p.name),
2694                ep,
2695                issues,
2696            );
2697        }
2698    }
2699}
2700
2701/// Validate the `gateway.webhooks` list (DW-044): every URL must be an
2702/// absolute http(s) URL, every `events` entry must name an emitted kind
2703/// (unknown spellings are rejected; `quota_near_limit` IS emitted since
2704/// DW-033), header names/values must be
2705/// representable (with `${...}` references resolved NOW, the DW-045
2706/// compile-time contract — an unresolvable reference fails the
2707/// generation closed, and the issue names the reference, never the
2708/// value), the retry knobs must be in bounds, and a duplicate URL is
2709/// rejected (two identical targets would double-deliver every event).
2710fn validate_webhooks(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2711    let mut seen_urls = std::collections::BTreeSet::new();
2712    for (i, hook) in gateway.webhooks.iter().enumerate() {
2713        let field = format!("webhooks[{i}]");
2714        validate_delivery_url(&format!("{field}.url"), &hook.url, issues);
2715        if !seen_urls.insert(hook.url.trim().to_string()) {
2716            issues.push(issue(
2717                "gateway",
2718                "(root)",
2719                &format!("{field}.url"),
2720                format!(
2721                    "duplicate webhook url '{}' (an event would be delivered \
2722                     twice; merge their events lists instead)",
2723                    hook.url
2724                ),
2725            ));
2726        }
2727        if hook.events.is_empty() {
2728            issues.push(issue(
2729                "gateway",
2730                "(root)",
2731                &format!("{field}.events"),
2732                "events is empty: a webhook subscribing to nothing never fires \
2733                 and is always a mistake; omit the entry",
2734            ));
2735        }
2736        for (j, kind) in hook.events.iter().enumerate() {
2737            if crate::events::EventKind::from_config(kind).is_none() {
2738                issues.push(issue(
2739                    "gateway",
2740                    "(root)",
2741                    &format!("{field}.events[{j}]"),
2742                    format!(
2743                        "unknown event kind '{kind}' (emitted kinds: {})",
2744                        crate::events::EventKind::ALL
2745                            .iter()
2746                            .map(|k| k.as_str())
2747                            .collect::<Vec<_>>()
2748                            .join(", ")
2749                    ),
2750                ));
2751            }
2752        }
2753        validate_delivery_headers(&field, &hook.headers, issues);
2754        if hook.timeout_ms == 0 || hook.timeout_ms > crate::config::limits::MAX_WEBHOOK_TIMEOUT_MS {
2755            issues.push(issue(
2756                "gateway",
2757                "(root)",
2758                &format!("{field}.timeout_ms"),
2759                format!(
2760                    "timeout_ms must be in 1..={} (one total budget per delivery, \
2761                     shared by every retry attempt)",
2762                    crate::config::limits::MAX_WEBHOOK_TIMEOUT_MS
2763                ),
2764            ));
2765        }
2766        if hook.max_attempts == 0 || hook.max_attempts > crate::config::limits::MAX_WEBHOOK_ATTEMPTS
2767        {
2768            issues.push(issue(
2769                "gateway",
2770                "(root)",
2771                &format!("{field}.max_attempts"),
2772                format!(
2773                    "max_attempts must be in 1..={} (total attempts per delivery)",
2774                    crate::config::limits::MAX_WEBHOOK_ATTEMPTS
2775                ),
2776            ));
2777        }
2778        if hook.backoff_base_ms == 0 {
2779            issues.push(issue(
2780                "gateway",
2781                "(root)",
2782                &format!("{field}.backoff_base_ms"),
2783                "backoff_base_ms must be > 0",
2784            ));
2785        }
2786        if hook.backoff_cap_ms < hook.backoff_base_ms {
2787            issues.push(issue(
2788                "gateway",
2789                "(root)",
2790                &format!("{field}.backoff_cap_ms"),
2791                "backoff_cap_ms must be >= backoff_base_ms",
2792            ));
2793        }
2794    }
2795}
2796
2797/// The URL shape shared by every outbound HTTP delivery surface
2798/// (DW-044 webhooks and the DW-121 record-stream sink): absolute
2799/// http(s) with a host.
2800fn validate_delivery_url(field: &str, url: &str, issues: &mut Vec<ValidationIssue>) {
2801    match url.parse::<hyper::Uri>() {
2802        Ok(uri) => {
2803            if !matches!(uri.scheme_str(), Some("http") | Some("https")) || uri.host().is_none() {
2804                issues.push(issue(
2805                    "gateway",
2806                    "(root)",
2807                    field,
2808                    format!(
2809                        "'{url}' must be an absolute http(s) URL with a host \
2810                         (e.g. https://hooks.example.com/alerts)"
2811                    ),
2812                ));
2813            }
2814        }
2815        Err(_) => issues.push(issue(
2816            "gateway",
2817            "(root)",
2818            field,
2819            format!("'{url}' is not a valid URL"),
2820        )),
2821    }
2822}
2823
2824/// The header grammar shared by every outbound HTTP delivery surface:
2825/// names must be valid HTTP header names, and values (with `${...}`
2826/// references resolved NOW, the DW-045 compile-time contract — an
2827/// unresolvable reference fails the generation closed) must be
2828/// representable as a single header value. Issues name the header and
2829/// the reason, never the resolved value.
2830fn validate_delivery_headers(
2831    field: &str,
2832    headers: &std::collections::BTreeMap<String, String>,
2833    issues: &mut Vec<ValidationIssue>,
2834) {
2835    for (name, value) in headers {
2836        if hyper::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
2837            issues.push(issue(
2838                "gateway",
2839                "(root)",
2840                &format!("{field}.headers"),
2841                format!("header name '{name}' is not a valid HTTP header name"),
2842            ));
2843            continue;
2844        }
2845        let problem = match crate::config::credentials::resolve_configured_secret(value) {
2846            Ok(resolved) => {
2847                if hyper::header::HeaderValue::from_str(&resolved).is_err() {
2848                    Some(
2849                        "the header value (after secret-reference resolution) \
2850                         contains characters that cannot appear in an HTTP \
2851                         header value"
2852                            .to_string(),
2853                    )
2854                } else {
2855                    None
2856                }
2857            }
2858            Err(message) => Some(message),
2859        };
2860        if let Some(message) = problem {
2861            issues.push(issue(
2862                "gateway",
2863                "(root)",
2864                &format!("{field}.headers"),
2865                format!("header '{name}': {message}"),
2866            ));
2867        }
2868    }
2869}
2870
2871/// Validate the `gateway.analytics_stream` block (DW-121): the sink's
2872/// URL and headers through the shared outbound-delivery grammar
2873/// (alert webhooks and the record stream must not drift), the shared
2874/// retry-knob bounds, and the pipeline knobs — `buffer` (a boot-time
2875/// memory bound), `flush_ms` (batch latency), and `batch_max` (the
2876/// per-delivery record count).
2877fn validate_analytics_stream(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
2878    let Some(cfg) = gateway.analytics_stream.as_ref() else {
2879        return;
2880    };
2881    match &cfg.sink {
2882        crate::config::AnalyticsStreamSink::Webhook(wh) => {
2883            validate_delivery_url("analytics_stream.sink.webhook.url", &wh.url, issues);
2884            validate_delivery_headers("analytics_stream.sink.webhook", &wh.headers, issues);
2885            if wh.timeout_ms == 0 || wh.timeout_ms > crate::config::limits::MAX_WEBHOOK_TIMEOUT_MS {
2886                issues.push(issue(
2887                    "gateway",
2888                    "(root)",
2889                    "analytics_stream.sink.webhook.timeout_ms",
2890                    format!(
2891                        "timeout_ms must be in 1..={} (one total budget per batch \
2892                         delivery, shared by every retry attempt — the shared \
2893                         webhook engine's bound)",
2894                        crate::config::limits::MAX_WEBHOOK_TIMEOUT_MS
2895                    ),
2896                ));
2897            }
2898            if wh.max_attempts == 0 || wh.max_attempts > crate::config::limits::MAX_WEBHOOK_ATTEMPTS
2899            {
2900                issues.push(issue(
2901                    "gateway",
2902                    "(root)",
2903                    "analytics_stream.sink.webhook.max_attempts",
2904                    format!(
2905                        "max_attempts must be in 1..={} (total attempts per batch \
2906                         delivery)",
2907                        crate::config::limits::MAX_WEBHOOK_ATTEMPTS
2908                    ),
2909                ));
2910            }
2911            if wh.backoff_base_ms == 0 {
2912                issues.push(issue(
2913                    "gateway",
2914                    "(root)",
2915                    "analytics_stream.sink.webhook.backoff_base_ms",
2916                    "backoff_base_ms must be > 0",
2917                ));
2918            }
2919            if wh.backoff_cap_ms < wh.backoff_base_ms {
2920                issues.push(issue(
2921                    "gateway",
2922                    "(root)",
2923                    "analytics_stream.sink.webhook.backoff_cap_ms",
2924                    "backoff_cap_ms must be >= backoff_base_ms",
2925                ));
2926            }
2927        }
2928        crate::config::AnalyticsStreamSink::Kafka(k) => {
2929            validate_delivery_url(
2930                "analytics_stream.sink.kafka.rest_proxy_url",
2931                &k.rest_proxy_url,
2932                issues,
2933            );
2934            validate_delivery_headers("analytics_stream.sink.kafka", &k.headers, issues);
2935            if k.timeout_ms == 0 || k.timeout_ms > crate::config::limits::MAX_WEBHOOK_TIMEOUT_MS {
2936                issues.push(issue(
2937                    "gateway",
2938                    "(root)",
2939                    "analytics_stream.sink.kafka.timeout_ms",
2940                    format!(
2941                        "timeout_ms must be in 1..={} (one total budget per batch \
2942                         delivery, shared by every retry attempt — the shared \
2943                         webhook engine's bound)",
2944                        crate::config::limits::MAX_WEBHOOK_TIMEOUT_MS
2945                    ),
2946                ));
2947            }
2948            if k.max_attempts == 0 || k.max_attempts > crate::config::limits::MAX_WEBHOOK_ATTEMPTS {
2949                issues.push(issue(
2950                    "gateway",
2951                    "(root)",
2952                    "analytics_stream.sink.kafka.max_attempts",
2953                    format!(
2954                        "max_attempts must be in 1..={} (total attempts per batch \
2955                         delivery)",
2956                        crate::config::limits::MAX_WEBHOOK_ATTEMPTS
2957                    ),
2958                ));
2959            }
2960            if k.backoff_base_ms == 0 {
2961                issues.push(issue(
2962                    "gateway",
2963                    "(root)",
2964                    "analytics_stream.sink.kafka.backoff_base_ms",
2965                    "backoff_base_ms must be > 0",
2966                ));
2967            }
2968            if k.backoff_cap_ms < k.backoff_base_ms {
2969                issues.push(issue(
2970                    "gateway",
2971                    "(root)",
2972                    "analytics_stream.sink.kafka.backoff_cap_ms",
2973                    "backoff_cap_ms must be >= backoff_base_ms",
2974                ));
2975            }
2976        }
2977    }
2978    if let Some(buffer) = cfg.buffer {
2979        if !(crate::config::limits::MIN_STREAM_BUFFER..=crate::config::limits::MAX_STREAM_BUFFER)
2980            .contains(&buffer)
2981        {
2982            issues.push(issue(
2983                "gateway",
2984                "(root)",
2985                "analytics_stream.buffer",
2986                format!(
2987                    "buffer must be in {}..={} (the queue's whole memory story; \
2988                     a full queue drops and counts, it never blocks)",
2989                    crate::config::limits::MIN_STREAM_BUFFER,
2990                    crate::config::limits::MAX_STREAM_BUFFER,
2991                ),
2992            ));
2993        }
2994    }
2995    if let Some(flush) = cfg.flush_ms {
2996        if !(crate::config::limits::MIN_STREAM_FLUSH_MS
2997            ..=crate::config::limits::MAX_STREAM_FLUSH_MS)
2998            .contains(&flush)
2999        {
3000            issues.push(issue(
3001                "gateway",
3002                "(root)",
3003                "analytics_stream.flush_ms",
3004                format!(
3005                    "flush_ms must be in {}..={} (maximum batch latency)",
3006                    crate::config::limits::MIN_STREAM_FLUSH_MS,
3007                    crate::config::limits::MAX_STREAM_FLUSH_MS,
3008                ),
3009            ));
3010        }
3011    }
3012    if let Some(batch) = cfg.batch_max {
3013        if batch == 0 || batch > crate::config::limits::MAX_STREAM_BATCH_RECORDS {
3014            issues.push(issue(
3015                "gateway",
3016                "(root)",
3017                "analytics_stream.batch_max",
3018                format!(
3019                    "batch_max must be in 1..={} (records per flushed batch — \
3020                     also the largest single delivery)",
3021                    crate::config::limits::MAX_STREAM_BATCH_RECORDS,
3022                ),
3023            ));
3024        }
3025    }
3026}
3027
3028/// Whether a string is a valid RFC 6265 cookie-name (the RFC 2616
3029/// `token` grammar: alphanumerics and `!#$%&'*+-.^_`|~` — no
3030/// separators, spaces, or controls). DW-040's sticky cookie names ride
3031/// `Set-Cookie` headers serialized by hand-adjacent code paths, so the
3032/// name must be provably header-safe.
3033fn is_cookie_name(name: &str) -> bool {
3034    !name.is_empty()
3035        && name.bytes().all(|b| {
3036            b.is_ascii_alphanumeric()
3037                || matches!(
3038                    b,
3039                    b'!' | b'#'
3040                        | b'$'
3041                        | b'%'
3042                        | b'&'
3043                        | b'\''
3044                        | b'*'
3045                        | b'+'
3046                        | b'-'
3047                        | b'.'
3048                        | b'^'
3049                        | b'_'
3050                        | b'`'
3051                        | b'|'
3052                        | b'~'
3053                )
3054        })
3055}
3056
3057/// DW-091: validate one canary analysis rule. Error-rate thresholds
3058/// must be in [0.0, 1.0] (a fraction); latency thresholds must be > 0
3059/// (a 0-ms threshold would trigger on every request).
3060fn validate_canary_rule(
3061    service: &str,
3062    field: &str,
3063    rule: &crate::config::CanaryRule,
3064    issues: &mut Vec<ValidationIssue>,
3065) {
3066    match rule.metric {
3067        crate::config::CanaryMetric::ErrorRate => {
3068            if rule.threshold < 0.0 || rule.threshold > 1.0 {
3069                issues.push(issue(
3070                    "service",
3071                    service,
3072                    &format!("{field}.threshold"),
3073                    "error_rate threshold must be in [0.0, 1.0] (a fraction)",
3074                ));
3075            }
3076        }
3077        crate::config::CanaryMetric::LatencyP99
3078        | crate::config::CanaryMetric::LatencyP95
3079        | crate::config::CanaryMetric::LatencyP50 => {
3080            if rule.threshold <= 0.0 {
3081                issues.push(issue(
3082                    "service",
3083                    service,
3084                    &format!("{field}.threshold"),
3085                    "latency threshold must be > 0 (milliseconds)",
3086                ));
3087            }
3088        }
3089    }
3090}
3091
3092/// DW-091: validate one canary analysis rule for an AI model alias.
3093/// Same bounds as [`validate_canary_rule`] but uses the `gateway`
3094/// scope and the alias in the field path.
3095fn validate_ai_canary_rule(
3096    alias: &str,
3097    field: &str,
3098    rule: &crate::config::CanaryRule,
3099    issues: &mut Vec<ValidationIssue>,
3100) {
3101    match rule.metric {
3102        crate::config::CanaryMetric::ErrorRate => {
3103            if rule.threshold < 0.0 || rule.threshold > 1.0 {
3104                issues.push(issue(
3105                    "gateway",
3106                    "(root)",
3107                    &format!("ai.models[{alias}].{field}.threshold"),
3108                    "error_rate threshold must be in [0.0, 1.0] (a fraction)",
3109                ));
3110            }
3111        }
3112        crate::config::CanaryMetric::LatencyP99
3113        | crate::config::CanaryMetric::LatencyP95
3114        | crate::config::CanaryMetric::LatencyP50 => {
3115            if rule.threshold <= 0.0 {
3116                issues.push(issue(
3117                    "gateway",
3118                    "(root)",
3119                    &format!("ai.models[{alias}].{field}.threshold"),
3120                    "latency threshold must be > 0 (milliseconds)",
3121                ));
3122            }
3123        }
3124    }
3125}
3126
3127/// Check semantic integrity of a parsed [`Gateway`]. An empty Vec means valid.
3128/// Validate the `ai:` block (DW-075): provider names unique and
3129/// non-empty, provider `upstream` references resolve, auth headers are
3130/// valid and their values RESOLVE (the DW-045 compile-time contract —
3131/// an unresolvable reference fails the generation closed), and model
3132/// aliases reference known providers with non-empty provider model
3133/// ids. Issues name the offending field; never a resolved value.
3134fn validate_ai(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
3135    let Some(ai) = &gateway.ai else { return };
3136    if ai.providers.is_empty() {
3137        issues.push(issue(
3138            "gateway",
3139            "(root)",
3140            "ai.providers",
3141            "the ai block is present but declares no providers: it could never \
3142             serve a model (remove the block, or declare at least one provider)",
3143        ));
3144    }
3145    if ai.models.is_empty() {
3146        issues.push(issue(
3147            "gateway",
3148            "(root)",
3149            "ai.models",
3150            "the ai block is present but declares no models: no client model \
3151             could ever resolve (remove the block, or map at least one alias)",
3152        ));
3153    }
3154    let mut names = std::collections::BTreeSet::new();
3155    for p in &ai.providers {
3156        if p.name.trim().is_empty() {
3157            issues.push(issue(
3158                "gateway",
3159                "(root)",
3160                "ai.providers[].name",
3161                "provider name must be non-empty",
3162            ));
3163            continue;
3164        }
3165        if !names.insert(p.name.as_str()) {
3166            issues.push(issue(
3167                "gateway",
3168                &p.name,
3169                "ai.providers[].name",
3170                "duplicate ai provider name",
3171            ));
3172        }
3173        if !gateway.upstreams.iter().any(|u| u.name == p.upstream) {
3174            issues.push(issue(
3175                "gateway",
3176                &p.name,
3177                "ai.providers[].upstream",
3178                format!(
3179                    "references unknown upstream '{}' (the provider's transport \
3180                     is a standard upstreams entry)",
3181                    p.upstream
3182                ),
3183            ));
3184        }
3185        if let Some(path) = &p.path {
3186            if path.trim().is_empty() {
3187                issues.push(issue(
3188                    "gateway",
3189                    &p.name,
3190                    "ai.providers[].path",
3191                    "provider path override must be non-empty (remove the \
3192                     field to use the adapter's default path)",
3193                ));
3194            } else if !path.starts_with('/') {
3195                issues.push(issue(
3196                    "gateway",
3197                    &p.name,
3198                    "ai.providers[].path",
3199                    format!(
3200                        "provider path override '{path}' must start with '/' \
3201                         (it is a request path, not a full URL)"
3202                    ),
3203                ));
3204            }
3205        }
3206        if let Some(auth) = &p.auth {
3207            if hyper::header::HeaderName::from_bytes(auth.header.as_bytes()).is_err() {
3208                issues.push(issue(
3209                    "gateway",
3210                    &p.name,
3211                    "ai.providers[].auth.header",
3212                    format!("'{}' is not a valid HTTP header name", auth.header),
3213                ));
3214            }
3215            // DW-45 compile-time contract: the value must resolve AND
3216            // be representable as a header value. Issues name the
3217            // provider and reason, never the value.
3218            let problem = match crate::config::credentials::resolve_configured_secret(&auth.value) {
3219                Ok(resolved) => {
3220                    if hyper::header::HeaderValue::from_str(&resolved).is_err() {
3221                        Some(
3222                            "the auth value (after secret-reference resolution) \
3223                             contains characters that cannot appear in an HTTP \
3224                             header value"
3225                                .to_string(),
3226                        )
3227                    } else {
3228                        None
3229                    }
3230                }
3231                Err(message) => Some(message),
3232            };
3233            if let Some(message) = problem {
3234                issues.push(issue(
3235                    "gateway",
3236                    &p.name,
3237                    "ai.providers[].auth.value",
3238                    format!("provider auth: {message}"),
3239                ));
3240            }
3241        }
3242        // DW-080: credential pool validation.
3243        if let Some(pool) = &p.credential_pool {
3244            // Ent-only: reject when the ent feature is off.
3245            #[cfg(not(feature = "ent"))]
3246            {
3247                issues.push(issue(
3248                    "gateway",
3249                    &p.name,
3250                    "ai.providers[].credential_pool",
3251                    "credential pools require the enterprise edition (build with \
3252                 --features ent)",
3253                ));
3254            }
3255            // auth and credential_pool are mutually exclusive.
3256            if p.auth.is_some() {
3257                issues.push(issue(
3258                    "gateway",
3259                    &p.name,
3260                    "ai.providers[].credential_pool",
3261                    "credential_pool and auth are mutually exclusive; use one or \
3262                     the other",
3263                ));
3264            }
3265            // At least 2 entries required.
3266            if pool.credentials.len() < 2 {
3267                issues.push(issue(
3268                    "gateway",
3269                    &p.name,
3270                    "ai.providers[].credential_pool.credentials",
3271                    "credential pool requires at least 2 entries (a single-entry \
3272                     pool is equivalent to auth)",
3273                ));
3274            }
3275            // Each entry: validate header name + resolve value.
3276            for (i, cred) in pool.credentials.iter().enumerate() {
3277                if hyper::header::HeaderName::from_bytes(cred.header.as_bytes()).is_err() {
3278                    issues.push(issue(
3279                        "gateway",
3280                        &p.name,
3281                        &format!("ai.providers[].credential_pool.credentials[{i}].header"),
3282                        format!("'{}' is not a valid HTTP header name", cred.header),
3283                    ));
3284                }
3285                let problem =
3286                    match crate::config::credentials::resolve_configured_secret(&cred.value) {
3287                        Ok(resolved) => {
3288                            if hyper::header::HeaderValue::from_str(&resolved).is_err() {
3289                                Some(
3290                                    "the credential value (after secret-reference \
3291                                 resolution) contains characters that cannot appear \
3292                                 in an HTTP header value"
3293                                        .to_string(),
3294                                )
3295                            } else {
3296                                None
3297                            }
3298                        }
3299                        Err(message) => Some(message),
3300                    };
3301                if let Some(message) = problem {
3302                    issues.push(issue(
3303                        "gateway",
3304                        &p.name,
3305                        &format!("ai.providers[].credential_pool.credentials[{i}].value"),
3306                        format!("credential pool entry {i}: {message}"),
3307                    ));
3308                }
3309            }
3310            // Quarantine window bounds.
3311            if pool.quarantine_secs == 0 {
3312                issues.push(issue(
3313                    "gateway",
3314                    &p.name,
3315                    "ai.providers[].credential_pool.quarantine_secs",
3316                    "quarantine_secs must be greater than 0",
3317                ));
3318            }
3319            if pool.quarantine_secs > 600 {
3320                issues.push(issue(
3321                    "gateway",
3322                    &p.name,
3323                    "ai.providers[].credential_pool.quarantine_secs",
3324                    "quarantine_secs must not exceed 600 (10 minutes)",
3325                ));
3326            }
3327        }
3328    }
3329    for (alias, m) in &ai.models {
3330        if alias.trim().is_empty() {
3331            issues.push(issue(
3332                "gateway",
3333                "(root)",
3334                "ai.models",
3335                "model alias must be non-empty",
3336            ));
3337        }
3338        if !names.contains(m.provider.as_str()) {
3339            issues.push(issue(
3340                "gateway",
3341                "(root)",
3342                &format!("ai.models[{alias}].provider"),
3343                format!("references unknown ai provider '{}'", m.provider),
3344            ));
3345        }
3346        if m.provider_model.trim().is_empty() {
3347            issues.push(issue(
3348                "gateway",
3349                "(root)",
3350                &format!("ai.models[{alias}].provider_model"),
3351                "provider_model must be non-empty",
3352            ));
3353        }
3354        // DW-076: failover and canary are mutually exclusive per alias
3355        // (the config docs explain why: failover retries a canary
3356        // request onto the stable version and silently undoes the
3357        // experiment). DW-085: a routing policy is mutually exclusive
3358        // with both — a policy alias composes over OTHER aliases'
3359        // routing plans, so it has no provider/model pair of its own.
3360        if !m.failover.is_empty() && !m.canary.is_empty() {
3361            issues.push(issue(
3362                "gateway",
3363                "(root)",
3364                &format!("ai.models[{alias}]"),
3365                "an alias cannot declare both failover and canary: \
3366                 failover is an availability chain for one logical model, \
3367                 a canary split deliberately serves several versions — \
3368                 combine them on separate aliases",
3369            ));
3370        }
3371        if m.routing_policy.is_some() && (!m.failover.is_empty() || !m.canary.is_empty()) {
3372            issues.push(issue(
3373                "gateway",
3374                "(root)",
3375                &format!("ai.models[{alias}]"),
3376                "an alias cannot declare a routing_policy together with \
3377                 failover or canary: a policy alias composes over other \
3378                 aliases' routing plans and has no provider/model pair of \
3379                 its own — combine them on separate aliases",
3380            ));
3381        }
3382        // DW-086: ab_test is mutually exclusive with failover, canary,
3383        // and routing_policy (an experiment alias composes over OTHER
3384        // aliases' routing plans, same as a policy alias).
3385        if m.ab_test.is_some()
3386            && (!m.failover.is_empty() || !m.canary.is_empty() || m.routing_policy.is_some())
3387        {
3388            issues.push(issue(
3389                "gateway",
3390                "(root)",
3391                &format!("ai.models[{alias}]"),
3392                "an alias cannot declare an ab_test together with \
3393                 failover, canary, or routing_policy: an experiment alias \
3394                 composes over other aliases' routing plans and has no \
3395                 provider/model pair of its own — combine them on separate \
3396                 aliases",
3397            ));
3398        }
3399        // DW-086: the referenced A/B test must exist in
3400        // ai.experiments.ab_tests.
3401        if let Some(test_name) = &m.ab_test {
3402            let exists = ai
3403                .experiments
3404                .as_ref()
3405                .map(|e| e.ab_tests.contains_key(test_name))
3406                .unwrap_or(false);
3407            if !exists {
3408                issues.push(issue(
3409                    "gateway",
3410                    "(root)",
3411                    &format!("ai.models[{alias}].ab_test"),
3412                    format!(
3413                        "references unknown A/B test '{}' (not in \
3414                         ai.experiments.ab_tests — a typo, or the test was \
3415                         removed, or ai.experiments is not configured)",
3416                        test_name
3417                    ),
3418                ));
3419            }
3420        }
3421        // DW-085: the referenced routing policy must exist.
3422        if let Some(policy_name) = &m.routing_policy {
3423            if !ai.routing_policies.contains_key(policy_name) {
3424                issues.push(issue(
3425                    "gateway",
3426                    "(root)",
3427                    &format!("ai.models[{alias}].routing_policy"),
3428                    format!(
3429                        "references unknown routing policy '{}' (not in \
3430                         ai.routing_policies — a typo, or the policy was \
3431                         removed)",
3432                        policy_name
3433                    ),
3434                ));
3435            }
3436        }
3437        // DW-076: the failover chain. Bounded to 4 alternates (the
3438        // chain is walked synchronously — every extra candidate adds a
3439        // full provider round-trip to the request's worst-case
3440        // latency, the same reasoning that bounds splits). Entries
3441        // must resolve, and the chain must not repeat a provider/model
3442        // pair (a duplicate would re-send to a target that just failed
3443        // — the provider's upstream breaker owns same-target retries).
3444        if m.failover.len() > 4 {
3445            issues.push(issue(
3446                "gateway",
3447                "(root)",
3448                &format!("ai.models[{alias}].failover"),
3449                format!(
3450                    "failover accepts at most 4 alternates ({} given): the \
3451                     chain is walked synchronously, so every extra candidate \
3452                     adds a full provider round-trip to the request's \
3453                     worst-case latency",
3454                    m.failover.len()
3455                ),
3456            ));
3457        }
3458        let mut chain = vec![(m.provider.as_str(), m.provider_model.as_str())];
3459        for (i, f) in m.failover.iter().enumerate() {
3460            if !names.contains(f.provider.as_str()) {
3461                issues.push(issue(
3462                    "gateway",
3463                    "(root)",
3464                    &format!("ai.models[{alias}].failover[{i}].provider"),
3465                    format!("references unknown ai provider '{}'", f.provider),
3466                ));
3467            }
3468            if f.provider_model.trim().is_empty() {
3469                issues.push(issue(
3470                    "gateway",
3471                    "(root)",
3472                    &format!("ai.models[{alias}].failover[{i}].provider_model"),
3473                    "provider_model must be non-empty",
3474                ));
3475            }
3476            if chain.contains(&(f.provider.as_str(), f.provider_model.as_str())) {
3477                issues.push(issue(
3478                    "gateway",
3479                    "(root)",
3480                    &format!("ai.models[{alias}].failover[{i}]"),
3481                    format!(
3482                        "duplicates the provider/model pair ('{}' / '{}') already \
3483                         earlier in the chain — the upstream breaker owns \
3484                         same-target retries",
3485                        f.provider, f.provider_model
3486                    ),
3487                ));
3488            } else {
3489                chain.push((f.provider.as_str(), f.provider_model.as_str()));
3490            }
3491        }
3492        // DW-076: the canary split. Bounded to 2..=8 versions (the
3493        // DW-040 split bound: the pick scans linearly, and every
3494        // version is a metrics label — an unbounded list is a series
3495        // explosion; one version is not a split, it is the whole
3496        // model). Version names unique and non-empty, weights >= 1,
3497        // providers resolve.
3498        if !m.canary.is_empty() && !(2..=8).contains(&m.canary.len()) {
3499            issues.push(issue(
3500                "gateway",
3501                "(root)",
3502                &format!("ai.models[{alias}].canary"),
3503                format!(
3504                    "a canary split needs 2..=8 versions ({} given): one \
3505                     version is the whole model (no split), and every version \
3506                     is a metrics label — the bound keeps the pick cheap and \
3507                     the series count sane",
3508                    m.canary.len()
3509                ),
3510            ));
3511        }
3512        let mut versions = std::collections::BTreeSet::new();
3513        for (i, v) in m.canary.iter().enumerate() {
3514            if v.version.trim().is_empty() {
3515                issues.push(issue(
3516                    "gateway",
3517                    "(root)",
3518                    &format!("ai.models[{alias}].canary[{i}].version"),
3519                    "version name must be non-empty",
3520                ));
3521            }
3522            if !versions.insert(v.version.as_str()) {
3523                issues.push(issue(
3524                    "gateway",
3525                    "(root)",
3526                    &format!("ai.models[{alias}].canary[{i}].version"),
3527                    format!("duplicate version name '{}'", v.version),
3528                ));
3529            }
3530            if v.weight == 0 {
3531                issues.push(issue(
3532                    "gateway",
3533                    "(root)",
3534                    &format!("ai.models[{alias}].canary[{i}].weight"),
3535                    "weight must be >= 1 (park a version by removing the \
3536                     entry, not by zeroing it)",
3537                ));
3538            }
3539            if !names.contains(v.provider.as_str()) {
3540                issues.push(issue(
3541                    "gateway",
3542                    "(root)",
3543                    &format!("ai.models[{alias}].canary[{i}].provider"),
3544                    format!("references unknown ai provider '{}'", v.provider),
3545                ));
3546            }
3547            if v.provider_model.trim().is_empty() {
3548                issues.push(issue(
3549                    "gateway",
3550                    "(root)",
3551                    &format!("ai.models[{alias}].canary[{i}].provider_model"),
3552                    "provider_model must be non-empty",
3553                ));
3554            }
3555        }
3556        // DW-091: canary_analysis validation on AI model aliases.
3557        // Only valid on a 2-version canary (baseline + canary);
3558        // the controller compares exactly two sides. Same bounds as
3559        // service-level canary_analysis.
3560        if let Some(analysis) = &m.canary_analysis {
3561            if m.canary.len() != 2 {
3562                issues.push(issue(
3563                    "gateway",
3564                    "(root)",
3565                    &format!("ai.models[{alias}].canary_analysis"),
3566                    "canary_analysis requires exactly 2 canary versions (baseline + canary): \
3567                     the controller compares two sides",
3568                ));
3569            }
3570            if analysis.window_seconds == 0 {
3571                issues.push(issue(
3572                    "gateway",
3573                    "(root)",
3574                    &format!("ai.models[{alias}].canary_analysis.window_seconds"),
3575                    "window_seconds must be >= 1",
3576                ));
3577            }
3578            if analysis.step == 0 {
3579                issues.push(issue(
3580                    "gateway",
3581                    "(root)",
3582                    &format!("ai.models[{alias}].canary_analysis.step"),
3583                    "step must be >= 1 (a 0-step never adjusts)",
3584                ));
3585            }
3586            if analysis.min_requests == 0 {
3587                issues.push(issue(
3588                    "gateway",
3589                    "(root)",
3590                    &format!("ai.models[{alias}].canary_analysis.min_requests"),
3591                    "min_requests must be >= 1",
3592                ));
3593            }
3594            if analysis.cooldown_seconds == 0 {
3595                issues.push(issue(
3596                    "gateway",
3597                    "(root)",
3598                    &format!("ai.models[{alias}].canary_analysis.cooldown_seconds"),
3599                    "cooldown_seconds must be >= 1",
3600                ));
3601            }
3602            validate_ai_canary_rule(alias, "canary_analysis.promote", &analysis.promote, issues);
3603            validate_ai_canary_rule(
3604                alias,
3605                "canary_analysis.rollback",
3606                &analysis.rollback,
3607                issues,
3608            );
3609        }
3610    }
3611    // DW-084: governance team_allowlists must reference model aliases
3612    // that exist in the `models` map. A typo in an allowlist that
3613    // names a non-existent alias is an authoring error — the runtime
3614    // would block every call to it (the alias never matches), so
3615    // validation names it at publish time instead.
3616    if let Some(gov) = &ai.governance {
3617        let known: std::collections::BTreeSet<&str> =
3618            ai.models.keys().map(|s| s.as_str()).collect();
3619        for (policy, allowlist) in &gov.team_allowlists {
3620            // The policy itself must exist (a typo'd policy name would
3621            // silently bind nobody — an allowlist that governs nothing).
3622            if !gateway.policies.iter().any(|p| &p.name == policy) {
3623                issues.push(issue(
3624                    "gateway",
3625                    policy,
3626                    "ai.governance.team_allowlists",
3627                    format!(
3628                        "references unknown policy '{}' (the allowlist would \
3629                         govern nobody — a typo, or the policy was removed)",
3630                        policy
3631                    ),
3632                ));
3633            }
3634            for (i, alias) in allowlist.iter().enumerate() {
3635                if !known.contains(alias.as_str()) {
3636                    issues.push(issue(
3637                        "gateway",
3638                        policy,
3639                        &format!("ai.governance.team_allowlists[{policy}][{i}]"),
3640                        format!(
3641                            "references unknown model alias '{}' (not in \
3642                             ai.models — a typo, or the alias was renamed)",
3643                            alias
3644                        ),
3645                    ));
3646                }
3647            }
3648        }
3649    }
3650    // DW-081: prompt/response logging validation. The sample rate
3651    // must be 0.0..=1.0, retention must be > 0 when enabled, and
3652    // custom redaction patterns must be valid regexes (compiled at
3653    // validation time — invalid regex fails at publish).
3654    if let Some(logging) = &ai.logging {
3655        if !(0.0..=1.0).contains(&logging.sample_rate) {
3656            issues.push(issue(
3657                "gateway",
3658                "(root)",
3659                "ai.logging.sample_rate",
3660                format!(
3661                    "sample_rate must be between 0.0 and 1.0 (got {})",
3662                    logging.sample_rate
3663                ),
3664            ));
3665        }
3666        if logging.enabled && logging.retention_secs == 0 {
3667            issues.push(issue(
3668                "gateway",
3669                "(root)",
3670                "ai.logging.retention_secs",
3671                "retention_secs must be > 0 when logging is enabled \
3672                 (0 would retain records forever — omit the field for \
3673                 the 7-day default, or set a positive value)",
3674            ));
3675        }
3676        for (i, pat) in logging.redaction.patterns.iter().enumerate() {
3677            if regex::Regex::new(pat).is_err() {
3678                issues.push(issue(
3679                    "gateway",
3680                    "(root)",
3681                    &format!("ai.logging.redaction.patterns[{i}]"),
3682                    format!(
3683                        "'{}' is not a valid regex pattern (the custom \
3684                         redaction pattern must compile)",
3685                        pat
3686                    ),
3687                ));
3688            }
3689        }
3690    }
3691    // DW-082: guardrails validation. Rule names must be unique, regex
3692    // patterns must compile, and JSON schemas (for schema-kind rules)
3693    // must be valid. Policy references must name existing policies
3694    // (a typo'd policy name would silently bind nobody).
3695    if let Some(guardrails) = &ai.guardrails {
3696        let mut rule_names = std::collections::BTreeSet::new();
3697        for (i, rule) in guardrails.rules.iter().enumerate() {
3698            if rule.name.trim().is_empty() {
3699                issues.push(issue(
3700                    "gateway",
3701                    "(root)",
3702                    &format!("ai.guardrails.rules[{i}].name"),
3703                    "guardrail rule name must be non-empty",
3704                ));
3705            } else if !rule_names.insert(rule.name.as_str()) {
3706                issues.push(issue(
3707                    "gateway",
3708                    "(root)",
3709                    &format!("ai.guardrails.rules[{i}].name"),
3710                    format!("duplicate guardrail rule name '{}'", rule.name),
3711                ));
3712            }
3713            // Validate regex patterns compile.
3714            for (j, pat) in rule.patterns.iter().enumerate() {
3715                if regex::Regex::new(pat).is_err() {
3716                    issues.push(issue(
3717                        "gateway",
3718                        "(root)",
3719                        &format!("ai.guardrails.rules[{i}].patterns[{j}]"),
3720                        format!(
3721                            "'{}' is not a valid regex pattern (the guardrail \
3722                             pattern must compile)",
3723                            pat
3724                        ),
3725                    ));
3726                }
3727            }
3728            // Validate schema-kind rules have a schema.
3729            if rule.kind == crate::config::ai::AiGuardrailKind::Schema && rule.schema.is_none() {
3730                issues.push(issue(
3731                    "gateway",
3732                    &rule.name,
3733                    &format!("ai.guardrails.rules[{i}].schema"),
3734                    "a schema-kind guardrail rule must declare a JSON schema \
3735                     (the `schema` field is required for kind: schema)",
3736                ));
3737            }
3738            // Validate schema is valid JSON (it is already parsed by
3739            // serde, so this is always true — but check it is an
3740            // object or bool, the valid JSON Schema root shapes).
3741            if let Some(schema) = &rule.schema {
3742                if !schema.is_object() && !schema.is_boolean() {
3743                    issues.push(issue(
3744                        "gateway",
3745                        &rule.name,
3746                        &format!("ai.guardrails.rules[{i}].schema"),
3747                        "a JSON schema must be an object or a boolean \
3748                         (the root schema shape)",
3749                    ));
3750                }
3751            }
3752            // Validate policy references exist.
3753            for (j, policy) in rule.policies.iter().enumerate() {
3754                if !gateway.policies.iter().any(|p| &p.name == policy) {
3755                    issues.push(issue(
3756                        "gateway",
3757                        &rule.name,
3758                        &format!("ai.guardrails.rules[{i}].policies[{j}]"),
3759                        format!(
3760                            "references unknown policy '{}' (the rule would \
3761                             apply to nobody — a typo, or the policy was removed)",
3762                            policy
3763                        ),
3764                    ));
3765                }
3766            }
3767            // Redact action is prompt-phase only (a redact at the
3768            // response phase is a no-op — warn, not reject, since it
3769            // is a benign misconfiguration).
3770            if rule.action == crate::config::ai::AiGuardrailAction::Redact
3771                && !matches!(
3772                    rule.phase,
3773                    crate::config::ai::AiGuardrailPhase::Prompt
3774                        | crate::config::ai::AiGuardrailPhase::Both
3775                )
3776            {
3777                // This is not a hard error — a redact at the response
3778                // phase is treated as a log by the engine. But name
3779                // it so the operator notices.
3780                issues.push(issue(
3781                    "gateway",
3782                    &rule.name,
3783                    &format!("ai.guardrails.rules[{i}].action"),
3784                    "redact action is only effective at the prompt phase; \
3785                     at the response phase it is treated as log (dry-run)",
3786                ));
3787            }
3788        }
3789    }
3790    // DW-083: semantic cache validation. When enabled, the embedding
3791    // URL/model/dim must be valid, the threshold in [0.0, 1.0], and
3792    // the TTL/max-entries/timeout positive. A disabled block is
3793    // accepted without further checks (it is inert).
3794    if let Some(sem_cache) = &ai.semantic_cache {
3795        if sem_cache.enabled {
3796            if sem_cache.embedding_url.trim().is_empty() {
3797                issues.push(issue(
3798                    "gateway",
3799                    "(root)",
3800                    "ai.semantic_cache.embedding_url",
3801                    "the semantic cache embedding_url must be non-empty when \
3802                     enabled (the OpenAI-compatible /v1/embeddings endpoint)",
3803                ));
3804            } else if !sem_cache.embedding_url.starts_with("http://")
3805                && !sem_cache.embedding_url.starts_with("https://")
3806            {
3807                issues.push(issue(
3808                    "gateway",
3809                    "(root)",
3810                    "ai.semantic_cache.embedding_url",
3811                    "the semantic cache embedding_url must be an http or https \
3812                     URL",
3813                ));
3814            }
3815            if sem_cache.embedding_model.trim().is_empty() {
3816                issues.push(issue(
3817                    "gateway",
3818                    "(root)",
3819                    "ai.semantic_cache.embedding_model",
3820                    "the semantic cache embedding_model must be non-empty when \
3821                     enabled (the model name passed to the embedding service)",
3822                ));
3823            }
3824            if sem_cache.embedding_dim == 0 {
3825                issues.push(issue(
3826                    "gateway",
3827                    "(root)",
3828                    "ai.semantic_cache.embedding_dim",
3829                    "the semantic cache embedding_dim must be > 0 (it must \
3830                     match the embedding service's vector dimension)",
3831                ));
3832            }
3833            if !(0.0..=1.0).contains(&sem_cache.threshold) {
3834                issues.push(issue(
3835                    "gateway",
3836                    "(root)",
3837                    "ai.semantic_cache.threshold",
3838                    format!(
3839                        "the semantic cache threshold must be in [0.0, 1.0] \
3840                         (got {})",
3841                        sem_cache.threshold
3842                    ),
3843                ));
3844            }
3845            if sem_cache.ttl_secs == 0 {
3846                issues.push(issue(
3847                    "gateway",
3848                    "(root)",
3849                    "ai.semantic_cache.ttl_secs",
3850                    "the semantic cache ttl_secs must be > 0",
3851                ));
3852            }
3853            if sem_cache.max_entries == 0 {
3854                issues.push(issue(
3855                    "gateway",
3856                    "(root)",
3857                    "ai.semantic_cache.max_entries",
3858                    "the semantic cache max_entries must be > 0",
3859                ));
3860            }
3861            if sem_cache.embedding_timeout_ms == 0 {
3862                issues.push(issue(
3863                    "gateway",
3864                    "(root)",
3865                    "ai.semantic_cache.embedding_timeout_ms",
3866                    "the semantic cache embedding_timeout_ms must be > 0",
3867                ));
3868            }
3869        }
3870    }
3871    // DW-085: routing policies validation. The referenced model
3872    // aliases must exist (a typo would route to nothing), the
3873    // classifier URL/model valid for FallbackChain, the threshold in
3874    // [0.0, 1.0] and timeout > 0, and candidates non-empty with
3875    // cost/latency in 1-10 for LatencyCost. Policy aliases (those
3876    // with `routing_policy` set) cannot be referenced by other
3877    // policies (no nested policies — a policy composes over plain
3878    // chain/canary aliases only).
3879    let policy_aliases: std::collections::BTreeSet<&str> = ai
3880        .models
3881        .iter()
3882        .filter(|(_, m)| m.routing_policy.is_some())
3883        .map(|(alias, _)| alias.as_str())
3884        .collect();
3885    for (policy_name, policy) in &ai.routing_policies {
3886        match policy {
3887            crate::config::ai::AiRoutingPolicy::FallbackChain(p) => {
3888                if !ai.models.contains_key(&p.cheap) {
3889                    issues.push(issue(
3890                        "gateway",
3891                        policy_name,
3892                        "ai.routing_policies[fallback_chain].cheap",
3893                        format!(
3894                            "references unknown model alias '{}' (not in \
3895                             ai.models — a typo, or the alias was renamed)",
3896                            p.cheap
3897                        ),
3898                    ));
3899                } else if policy_aliases.contains(p.cheap.as_str()) {
3900                    issues.push(issue(
3901                        "gateway",
3902                        policy_name,
3903                        "ai.routing_policies[fallback_chain].cheap",
3904                        format!(
3905                            "references alias '{}' which is itself a policy \
3906                             alias (nested policies are not allowed — a policy \
3907                             composes over plain chain/canary aliases only)",
3908                            p.cheap
3909                        ),
3910                    ));
3911                }
3912                if !ai.models.contains_key(&p.escalate_to) {
3913                    issues.push(issue(
3914                        "gateway",
3915                        policy_name,
3916                        "ai.routing_policies[fallback_chain].escalate_to",
3917                        format!(
3918                            "references unknown model alias '{}' (not in \
3919                             ai.models — a typo, or the alias was renamed)",
3920                            p.escalate_to
3921                        ),
3922                    ));
3923                } else if policy_aliases.contains(p.escalate_to.as_str()) {
3924                    issues.push(issue(
3925                        "gateway",
3926                        policy_name,
3927                        "ai.routing_policies[fallback_chain].escalate_to",
3928                        format!(
3929                            "references alias '{}' which is itself a policy \
3930                             alias (nested policies are not allowed — a policy \
3931                             composes over plain chain/canary aliases only)",
3932                            p.escalate_to
3933                        ),
3934                    ));
3935                }
3936                if p.classifier_url.trim().is_empty() {
3937                    issues.push(issue(
3938                        "gateway",
3939                        policy_name,
3940                        "ai.routing_policies[fallback_chain].classifier_url",
3941                        "the classifier_url must be non-empty (the external \
3942                         complexity-classifier service endpoint)",
3943                    ));
3944                } else if !p.classifier_url.starts_with("http://")
3945                    && !p.classifier_url.starts_with("https://")
3946                {
3947                    issues.push(issue(
3948                        "gateway",
3949                        policy_name,
3950                        "ai.routing_policies[fallback_chain].classifier_url",
3951                        "the classifier_url must be an http or https URL",
3952                    ));
3953                }
3954                if p.classifier_model.trim().is_empty() {
3955                    issues.push(issue(
3956                        "gateway",
3957                        policy_name,
3958                        "ai.routing_policies[fallback_chain].classifier_model",
3959                        "the classifier_model must be non-empty (the model \
3960                         name passed to the classifier service)",
3961                    ));
3962                }
3963                if !(0.0..=1.0).contains(&p.threshold) {
3964                    issues.push(issue(
3965                        "gateway",
3966                        policy_name,
3967                        "ai.routing_policies[fallback_chain].threshold",
3968                        format!("the threshold must be in [0.0, 1.0] (got {})", p.threshold),
3969                    ));
3970                }
3971                if p.timeout_ms == 0 {
3972                    issues.push(issue(
3973                        "gateway",
3974                        policy_name,
3975                        "ai.routing_policies[fallback_chain].timeout_ms",
3976                        "the timeout_ms must be > 0",
3977                    ));
3978                }
3979            }
3980            crate::config::ai::AiRoutingPolicy::LatencyCost(p) => {
3981                if p.candidates.is_empty() {
3982                    issues.push(issue(
3983                        "gateway",
3984                        policy_name,
3985                        "ai.routing_policies[latency_cost].candidates",
3986                        "a latency_cost policy must declare at least one \
3987                         candidate (an empty list would route to nothing)",
3988                    ));
3989                }
3990                for (i, c) in p.candidates.iter().enumerate() {
3991                    if !ai.models.contains_key(&c.model) {
3992                        issues.push(issue(
3993                            "gateway",
3994                            policy_name,
3995                            &format!("ai.routing_policies[latency_cost].candidates[{i}].model"),
3996                            format!(
3997                                "references unknown model alias '{}' (not in \
3998                                 ai.models — a typo, or the alias was renamed)",
3999                                c.model
4000                            ),
4001                        ));
4002                    } else if policy_aliases.contains(c.model.as_str()) {
4003                        issues.push(issue(
4004                            "gateway",
4005                            policy_name,
4006                            &format!("ai.routing_policies[latency_cost].candidates[{i}].model"),
4007                            format!(
4008                                "references alias '{}' which is itself a policy \
4009                                 alias (nested policies are not allowed — a \
4010                                 policy composes over plain chain/canary aliases \
4011                                 only)",
4012                                c.model
4013                            ),
4014                        ));
4015                    }
4016                    if !(1..=10).contains(&c.cost) {
4017                        issues.push(issue(
4018                            "gateway",
4019                            policy_name,
4020                            &format!("ai.routing_policies[latency_cost].candidates[{i}].cost"),
4021                            format!("the cost score must be in 1..=10 (got {})", c.cost),
4022                        ));
4023                    }
4024                    if !(1..=10).contains(&c.latency) {
4025                        issues.push(issue(
4026                            "gateway",
4027                            policy_name,
4028                            &format!("ai.routing_policies[latency_cost].candidates[{i}].latency"),
4029                            format!("the latency score must be in 1..=10 (got {})", c.latency),
4030                        ));
4031                    }
4032                }
4033            }
4034        }
4035    }
4036
4037    // DW-086: experiments validation. A/B test variants must reference
4038    // existing plain (non-policy, non-experiment) model aliases; prompt
4039    // references must resolve to existing prompt versions; evals must
4040    // reference existing model aliases and valid prompt versions.
4041    // Experiment aliases (those with `ab_test` set) cannot be
4042    // referenced by other experiments (no nested experiments — an
4043    // experiment composes over plain chain/canary aliases only).
4044    let experiment_aliases: std::collections::BTreeSet<&str> = ai
4045        .models
4046        .iter()
4047        .filter(|(_, m)| m.ab_test.is_some())
4048        .map(|(alias, _)| alias.as_str())
4049        .collect();
4050    if let Some(experiments) = &ai.experiments {
4051        // Validate A/B tests.
4052        for (test_name, test) in &experiments.ab_tests {
4053            if test.variants.len() < 2 {
4054                issues.push(issue(
4055                    "gateway",
4056                    test_name,
4057                    &format!("ai.experiments.ab_tests[{test_name}].variants"),
4058                    "an A/B test must have at least 2 variants (got \
4059                     fewer)",
4060                ));
4061            }
4062            for (i, variant) in test.variants.iter().enumerate() {
4063                if !ai.models.contains_key(&variant.model) {
4064                    issues.push(issue(
4065                        "gateway",
4066                        test_name,
4067                        &format!("ai.experiments.ab_tests[{test_name}].variants[{i}].model"),
4068                        format!(
4069                            "references unknown model alias '{}' (not in \
4070                             ai.models — a typo, or the alias was renamed)",
4071                            variant.model
4072                        ),
4073                    ));
4074                } else if policy_aliases.contains(variant.model.as_str())
4075                    || experiment_aliases.contains(variant.model.as_str())
4076                {
4077                    issues.push(issue(
4078                        "gateway",
4079                        test_name,
4080                        &format!("ai.experiments.ab_tests[{test_name}].variants[{i}].model"),
4081                        format!(
4082                            "references alias '{}' which is itself a policy \
4083                             or experiment alias (nested policies/experiments \
4084                             are not allowed — an experiment composes over \
4085                             plain chain/canary aliases only)",
4086                            variant.model
4087                        ),
4088                    ));
4089                }
4090                if variant.weight == 0 {
4091                    issues.push(issue(
4092                        "gateway",
4093                        test_name,
4094                        &format!("ai.experiments.ab_tests[{test_name}].variants[{i}].weight"),
4095                        "a variant weight must be >= 1 (got 0)",
4096                    ));
4097                }
4098                // Validate prompt reference if present.
4099                if let Some(prompt_ref) = &variant.prompt {
4100                    if let Some((prompt_name, version_name)) = prompt_ref.split_once('/') {
4101                        let prompt_exists = experiments
4102                            .prompts
4103                            .get(prompt_name)
4104                            .is_some_and(|p| p.versions.contains_key(version_name));
4105                        if !prompt_exists {
4106                            issues.push(issue(
4107                                "gateway",
4108                                test_name,
4109                                &format!(
4110                                    "ai.experiments.ab_tests[{test_name}].variants[{i}].prompt"
4111                                ),
4112                                format!(
4113                                    "references unknown prompt version '{}' \
4114                                     (not in ai.experiments.prompts — a typo, \
4115                                     or the prompt/version was removed)",
4116                                    prompt_ref
4117                                ),
4118                            ));
4119                        }
4120                    } else {
4121                        issues.push(issue(
4122                            "gateway",
4123                            test_name,
4124                            &format!("ai.experiments.ab_tests[{test_name}].variants[{i}].prompt"),
4125                            format!(
4126                                "prompt reference '{}' must be in the form \
4127                                 'prompt_name/version_name'",
4128                                prompt_ref
4129                            ),
4130                        ));
4131                    }
4132                }
4133            }
4134        }
4135        // Validate prompts: active version must exist.
4136        for (prompt_name, prompt) in &experiments.prompts {
4137            if !prompt.versions.contains_key(&prompt.active) {
4138                issues.push(issue(
4139                    "gateway",
4140                    prompt_name,
4141                    &format!("ai.experiments.prompts[{prompt_name}].active"),
4142                    format!(
4143                        "references unknown version '{}' (not in \
4144                         ai.experiments.prompts[{prompt_name}].versions — \
4145                         a typo, or the version was removed)",
4146                        prompt.active
4147                    ),
4148                ));
4149            }
4150            if prompt.versions.is_empty() {
4151                issues.push(issue(
4152                    "gateway",
4153                    prompt_name,
4154                    &format!("ai.experiments.prompts[{prompt_name}].versions"),
4155                    "a prompt must have at least one version",
4156                ));
4157            }
4158        }
4159        // Validate evals: prompt reference must resolve, golden set
4160        // non-empty. The model alias is specified at eval run time by
4161        // the admin endpoint (the eval config does not bind to a
4162        // specific model — the same eval can run against any model).
4163        for (eval_name, eval) in &experiments.evals {
4164            if let Some(prompt_ref) = &eval.prompt {
4165                if let Some((prompt_name, version_name)) = prompt_ref.split_once('/') {
4166                    let prompt_exists = experiments
4167                        .prompts
4168                        .get(prompt_name)
4169                        .is_some_and(|p| p.versions.contains_key(version_name));
4170                    if !prompt_exists {
4171                        issues.push(issue(
4172                            "gateway",
4173                            eval_name,
4174                            &format!("ai.experiments.evals[{eval_name}].prompt"),
4175                            format!(
4176                                "references unknown prompt version '{}' \
4177                                 (not in ai.experiments.prompts)",
4178                                prompt_ref
4179                            ),
4180                        ));
4181                    }
4182                } else {
4183                    issues.push(issue(
4184                        "gateway",
4185                        eval_name,
4186                        &format!("ai.experiments.evals[{eval_name}].prompt"),
4187                        format!(
4188                            "prompt reference '{}' must be in the form \
4189                             'prompt_name/version_name'",
4190                            prompt_ref
4191                        ),
4192                    ));
4193                }
4194            }
4195            if eval.golden_set.is_empty() {
4196                issues.push(issue(
4197                    "gateway",
4198                    eval_name,
4199                    &format!("ai.experiments.evals[{eval_name}].golden_set"),
4200                    "an eval must have at least one golden-set case",
4201                ));
4202            }
4203            for (i, case) in eval.golden_set.iter().enumerate() {
4204                if case.input.is_empty() {
4205                    issues.push(issue(
4206                        "gateway",
4207                        eval_name,
4208                        &format!("ai.experiments.evals[{eval_name}].golden_set[{i}].input"),
4209                        "an eval case input must be non-empty",
4210                    ));
4211                }
4212                if case.expected.is_empty() {
4213                    issues.push(issue(
4214                        "gateway",
4215                        eval_name,
4216                        &format!("ai.experiments.evals[{eval_name}].golden_set[{i}].expected"),
4217                        "an eval case expected value must be non-empty",
4218                    ));
4219                }
4220                if let Some(scorer) = &case.scorer {
4221                    if scorer != "exact_match" && scorer != "contains" && scorer != "regex" {
4222                        issues.push(issue(
4223                            "gateway",
4224                            eval_name,
4225                            &format!(
4226                                "ai.experiments.evals[{eval_name}].golden_set[{i}].scorer"
4227                            ),
4228                            format!(
4229                                "unknown scorer '{}' (must be one of: exact_match, contains, regex)",
4230                                scorer
4231                            ),
4232                        ));
4233                    }
4234                    if scorer == "regex" {
4235                        if let Err(e) = regex::Regex::new(&case.expected) {
4236                            issues.push(issue(
4237                                "gateway",
4238                                eval_name,
4239                                &format!(
4240                                    "ai.experiments.evals[{eval_name}].golden_set[{i}].expected"
4241                                ),
4242                                format!("invalid regex pattern for regex scorer: {e}",),
4243                            ));
4244                        }
4245                    }
4246                }
4247            }
4248        }
4249    }
4250
4251    // DW-087: MCP gateway validation. Tool names are non-empty (the
4252    // BTreeMap key enforces uniqueness; empty keys are impossible via
4253    // serde but trim-empty descriptions are caught here). Each tool's
4254    // `upstream` must reference a known upstream. The `path` (if
4255    // present) must start with `/`. Session TTL and max_concurrent
4256    // must be positive.
4257    if let Some(mcp) = &ai.mcp {
4258        let upstream_names: std::collections::BTreeSet<&str> =
4259            gateway.upstreams.iter().map(|u| u.name.as_str()).collect();
4260        for (name, tool) in &mcp.tools {
4261            if name.trim().is_empty() {
4262                issues.push(issue(
4263                    "gateway",
4264                    "(root)",
4265                    "ai.mcp.tools",
4266                    "tool name must be non-empty",
4267                ));
4268            }
4269            if tool.description.trim().is_empty() {
4270                issues.push(issue(
4271                    "gateway",
4272                    name,
4273                    "ai.mcp.tools.description",
4274                    "tool description must be non-empty",
4275                ));
4276            }
4277            if !upstream_names.contains(tool.upstream.as_str()) {
4278                issues.push(issue(
4279                    "gateway",
4280                    name,
4281                    "ai.mcp.tools.upstream",
4282                    format!(
4283                        "references unknown upstream '{}' (not in \
4284                         upstreams[] — a typo, or the upstream was renamed)",
4285                        tool.upstream
4286                    ),
4287                ));
4288            }
4289            if let Some(path) = &tool.path {
4290                if !path.starts_with('/') {
4291                    issues.push(issue(
4292                        "gateway",
4293                        name,
4294                        "ai.mcp.tools.path",
4295                        "the path must start with '/' (it is appended to the upstream endpoint)",
4296                    ));
4297                }
4298            }
4299            if let Some(method) = &tool.method {
4300                let m = method.to_ascii_uppercase();
4301                if !matches!(m.as_str(), "GET" | "POST" | "PUT" | "PATCH" | "DELETE") {
4302                    issues.push(issue(
4303                        "gateway",
4304                        name,
4305                        "ai.mcp.tools.method",
4306                        format!(
4307                            "unknown HTTP method '{}' (must be GET, POST, PUT, PATCH, or DELETE)",
4308                            method
4309                        ),
4310                    ));
4311                }
4312            }
4313            if !tool.input_schema.is_object() {
4314                issues.push(issue(
4315                    "gateway",
4316                    name,
4317                    "ai.mcp.tools.input_schema",
4318                    "input_schema must be a JSON object (a JSON Schema for the tool's arguments)",
4319                ));
4320            }
4321            if let Some(timeout) = tool.timeout_ms {
4322                if timeout == 0 {
4323                    issues.push(issue(
4324                        "gateway",
4325                        name,
4326                        "ai.mcp.tools.timeout_ms",
4327                        "timeout_ms must be > 0 (omit for the default 30s)",
4328                    ));
4329                }
4330            }
4331        }
4332        if let Some(sessions) = &mcp.sessions {
4333            if let Some(ttl) = sessions.ttl_secs {
4334                if ttl == 0 {
4335                    issues.push(issue(
4336                        "gateway",
4337                        "(root)",
4338                        "ai.mcp.sessions.ttl_secs",
4339                        "ttl_secs must be > 0 (omit for the default 3600s)",
4340                    ));
4341                }
4342            }
4343            if let Some(max) = sessions.max_concurrent {
4344                if max == 0 {
4345                    issues.push(issue(
4346                        "gateway",
4347                        "(root)",
4348                        "ai.mcp.sessions.max_concurrent",
4349                        "max_concurrent must be > 0 (omit for the default 1000)",
4350                    ));
4351                }
4352            }
4353        }
4354        if let Some(path) = &mcp.path {
4355            if !path.starts_with('/') {
4356                issues.push(issue(
4357                    "gateway",
4358                    "(root)",
4359                    "ai.mcp.path",
4360                    "the path must start with '/' (it is a reserved HTTP path)",
4361                ));
4362            }
4363        }
4364    }
4365
4366    // DW-114: A2A (agent-to-agent) validation. Agent names are
4367    // non-empty and unique, each agent's `upstream` references a
4368    // known upstream, the `url` is an http/https URL, and the inline
4369    // card (when present) is a JSON object. Session TTL and
4370    // max_concurrent must be positive. When the `a2a` cargo feature
4371    // is off, the block is accepted but a warning issue is emitted
4372    // (the block is inert -- no A2A providers are wired).
4373    if let Some(a2a) = &ai.a2a {
4374        // Feature gate: warn that the block is inert without the
4375        // `a2a` cargo feature (mirrors the ent-gated credential pool
4376        // warning pattern).
4377        let upstream_names: std::collections::BTreeSet<&str> =
4378            gateway.upstreams.iter().map(|u| u.name.as_str()).collect();
4379        let mut agent_names = std::collections::BTreeSet::new();
4380        for agent in &a2a.agents {
4381            if agent.name.trim().is_empty() {
4382                issues.push(issue(
4383                    "gateway",
4384                    "(root)",
4385                    "ai.a2a.agents[].name",
4386                    "agent name must be non-empty",
4387                ));
4388            } else if !agent_names.insert(agent.name.as_str()) {
4389                issues.push(issue(
4390                    "gateway",
4391                    &agent.name,
4392                    "ai.a2a.agents[].name",
4393                    "duplicate a2a agent name",
4394                ));
4395            }
4396            if agent.url.trim().is_empty() {
4397                issues.push(issue(
4398                    "gateway",
4399                    &agent.name,
4400                    "ai.a2a.agents[].url",
4401                    "agent url must be non-empty",
4402                ));
4403            } else if !agent.url.starts_with("http://") && !agent.url.starts_with("https://") {
4404                issues.push(issue(
4405                    "gateway",
4406                    &agent.name,
4407                    "ai.a2a.agents[].url",
4408                    "the agent url must be an http or https URL",
4409                ));
4410            }
4411            if !upstream_names.contains(agent.upstream.as_str()) {
4412                issues.push(issue(
4413                    "gateway",
4414                    &agent.name,
4415                    "ai.a2a.agents[].upstream",
4416                    format!(
4417                        "references unknown upstream '{}' (not in \
4418                         upstreams[] -- a typo, or the upstream was renamed)",
4419                        agent.upstream
4420                    ),
4421                ));
4422            }
4423            if let Some(card) = &agent.card {
4424                if let Some(inline) = &card.inline {
4425                    if !inline.is_object() {
4426                        issues.push(issue(
4427                            "gateway",
4428                            &agent.name,
4429                            "ai.a2a.agents[].card.inline",
4430                            "the inline agent card must be a JSON object",
4431                        ));
4432                    }
4433                }
4434                if card.path.is_none() && card.inline.is_none() {
4435                    issues.push(issue(
4436                        "gateway",
4437                        &agent.name,
4438                        "ai.a2a.agents[].card",
4439                        "agent card has neither inline nor path set (set one)",
4440                    ));
4441                }
4442            }
4443        }
4444        if let Some(sessions) = &a2a.sessions {
4445            if let Some(ttl) = sessions.ttl_secs {
4446                if ttl == 0 {
4447                    issues.push(issue(
4448                        "gateway",
4449                        "(root)",
4450                        "ai.a2a.sessions.ttl_secs",
4451                        "ttl_secs must be > 0 (omit for the default 3600s)",
4452                    ));
4453                }
4454            }
4455            if let Some(max) = sessions.max_concurrent {
4456                if max == 0 {
4457                    issues.push(issue(
4458                        "gateway",
4459                        "(root)",
4460                        "ai.a2a.sessions.max_concurrent",
4461                        "max_concurrent must be > 0 (omit for the default 1000)",
4462                    ));
4463                }
4464            }
4465        }
4466    }
4467}
4468
4469pub fn validate(gateway: &Gateway) -> Vec<ValidationIssue> {
4470    let mut issues = Vec::new();
4471
4472    // Trusted proxies: each entry must be an IP address or CIDR (parsed by
4473    // the dataplane's trusted-proxy matcher; rejected here at compile time).
4474    for (i, entry) in gateway.trusted_proxies.iter().enumerate() {
4475        if crate::config::net::parse_ip_or_cidr(entry).is_none() {
4476            issues.push(issue(
4477                "gateway",
4478                "(root)",
4479                &format!("trusted_proxies[{i}]"),
4480                format!("'{entry}' is not an IP address or CIDR (e.g. 10.0.0.0/8 or ::1/128)"),
4481            ));
4482        }
4483    }
4484
4485    if gateway.max_concurrent_requests == Some(0) {
4486        issues.push(issue(
4487            "gateway",
4488            "(root)",
4489            "max_concurrent_requests",
4490            "max_concurrent_requests must be > 0 (unlimited is expressed by \
4491             omitting the field, so an explicit 0 is rejected as ambiguous)",
4492        ));
4493    }
4494
4495    // SEC-08 (#212): Global WAF validation. When enabled, each CRS
4496    // rule must have a valid regex pattern, a valid phase (1 or 2),
4497    // a valid severity (1-4), and valid target names. The paranoia
4498    // level must be 1-4, and the anomaly threshold must be > 0.
4499    if let Some(waf) = &gateway.waf {
4500        if waf.enabled {
4501            if !(1..=4).contains(&waf.paranoia_level) {
4502                issues.push(issue(
4503                    "gateway",
4504                    "(root)",
4505                    "waf.paranoia_level",
4506                    format!(
4507                        "paranoia_level {} is out of range: must be 1-4",
4508                        waf.paranoia_level
4509                    ),
4510                ));
4511            }
4512            if waf.anomaly_threshold == 0 {
4513                issues.push(issue(
4514                    "gateway",
4515                    "(root)",
4516                    "waf.anomaly_threshold",
4517                    "anomaly_threshold must be > 0 (use dry_run for audit-only mode)",
4518                ));
4519            }
4520            if waf.max_body_inspect_bytes > 1_048_576 {
4521                issues.push(issue(
4522                    "gateway",
4523                    "(root)",
4524                    "waf.max_body_inspect_bytes",
4525                    format!(
4526                        "max_body_inspect_bytes {} is out of range: must be 0 \
4527                         (no body inspection) or 1..=1048576 (1 MiB)",
4528                        waf.max_body_inspect_bytes
4529                    ),
4530                ));
4531            }
4532            for (i, rule) in waf.rules.iter().enumerate() {
4533                if !(1..=4).contains(&rule.severity) {
4534                    issues.push(issue(
4535                        "gateway",
4536                        "(root)",
4537                        &format!("waf.rules[{i}].severity"),
4538                        format!(
4539                            "rule {} severity {} is out of range: must be 1-4",
4540                            rule.id, rule.severity
4541                        ),
4542                    ));
4543                }
4544                if !(1..=2).contains(&rule.phase) {
4545                    issues.push(issue(
4546                        "gateway",
4547                        "(root)",
4548                        &format!("waf.rules[{i}].phase"),
4549                        format!(
4550                            "rule {} phase {} is out of range: must be 1 (request headers) or 2 (request body)",
4551                            rule.id, rule.phase
4552                        ),
4553                    ));
4554                }
4555                if regex::Regex::new(&rule.pattern).is_err() {
4556                    issues.push(issue(
4557                        "gateway",
4558                        "(root)",
4559                        &format!("waf.rules[{i}].pattern"),
4560                        format!("rule {} has an invalid regex pattern", rule.id),
4561                    ));
4562                }
4563                for (j, target) in rule.targets.iter().enumerate() {
4564                    if !matches!(
4565                        target.as_str(),
4566                        "path" | "query" | "header" | "headers" | "body"
4567                    ) {
4568                        issues.push(issue(
4569                            "gateway",
4570                            "(root)",
4571                            &format!("waf.rules[{i}].targets[{j}]"),
4572                            format!(
4573                                "rule {} has unknown target '{}': must be path, query, header, headers, or body",
4574                                rule.id, target
4575                            ),
4576                        ));
4577                    }
4578                }
4579                for (j, t) in rule.transformations.iter().enumerate() {
4580                    if !matches!(
4581                        t.as_str(),
4582                        "lowercase"
4583                            | "url_decode"
4584                            | "html_entity_decode"
4585                            | "compress_whitespace"
4586                            | "remove_whitespace"
4587                            | "url_decode_uni"
4588                    ) {
4589                        issues.push(issue(
4590                            "gateway",
4591                            "(root)",
4592                            &format!("waf.rules[{i}].transformations[{j}]"),
4593                            format!("rule {} has unknown transformation '{}'", rule.id, t),
4594                        ));
4595                    }
4596                }
4597            }
4598        }
4599    }
4600
4601    // Load-shed monitor mode (DW-041) is only meaningful with a cap: an
4602    // uncapped gateway never sheds, so the flag would be a silent no-op
4603    // that reads as monitoring coverage. Rejected rather than ignored so
4604    // the config cannot imply a guarantee it does not provide.
4605    if gateway.load_shed_dry_run && gateway.max_concurrent_requests.is_none() {
4606        issues.push(issue(
4607            "gateway",
4608            "(root)",
4609            "load_shed_dry_run",
4610            "load_shed_dry_run requires max_concurrent_requests: an uncapped gateway \
4611             never sheds, so the flag would be a silent no-op (set a cap, or omit \
4612             the flag)",
4613        ));
4614    }
4615
4616    // Admission queues (DW-053): an enabled queue requires a cap (the
4617    // queue waits for a PERMIT from the cap's semaphore — no cap means
4618    // no permits to wait for, so the queue would be a silent no-op).
4619    // Bounds: max_queue_size 1..=10000, queue_timeout_ms 1..=10000.
4620    if let Some(aq) = &gateway.admission_queue {
4621        if aq.enabled && gateway.max_concurrent_requests.is_none() {
4622            issues.push(issue(
4623                "gateway",
4624                "(root)",
4625                "admission_queue.enabled",
4626                "admission_queue.enabled requires max_concurrent_requests: the queue \
4627                 waits for a cap permit, so an uncapped gateway has nothing to queue \
4628                 for (set a cap, or omit the block)",
4629            ));
4630        }
4631        if aq.max_queue_size == 0 {
4632            issues.push(issue(
4633                "gateway",
4634                "(root)",
4635                "admission_queue.max_queue_size",
4636                "admission_queue.max_queue_size must be > 0 (range 1..=10000)",
4637            ));
4638        }
4639        if aq.max_queue_size > 10000 {
4640            issues.push(issue(
4641                "gateway",
4642                "(root)",
4643                "admission_queue.max_queue_size",
4644                format!(
4645                    "admission_queue.max_queue_size {} is out of range: must be \
4646                     1..=10000",
4647                    aq.max_queue_size
4648                ),
4649            ));
4650        }
4651        if aq.queue_timeout_ms == 0 {
4652            issues.push(issue(
4653                "gateway",
4654                "(root)",
4655                "admission_queue.queue_timeout_ms",
4656                "admission_queue.queue_timeout_ms must be > 0 (range 1..=10000)",
4657            ));
4658        }
4659        if aq.queue_timeout_ms > 10000 {
4660            issues.push(issue(
4661                "gateway",
4662                "(root)",
4663                "admission_queue.queue_timeout_ms",
4664                format!(
4665                    "admission_queue.queue_timeout_ms {} is out of range: must be \
4666                     1..=10000",
4667                    aq.queue_timeout_ms
4668                ),
4669            ));
4670        }
4671    }
4672
4673    // HMAC signing policy bounds (DW-036): a zero-second window rejects
4674    // every signed request with any clock drift at all, and an unbounded
4675    // one pins nonce-cache memory for the process lifetime (nonces are
4676    // remembered for twice the window).
4677    if let Some(hmac) = &gateway.hmac_auth {
4678        let skew = hmac.max_clock_skew_secs;
4679        if !(1..=3600).contains(&skew) {
4680            issues.push(issue(
4681                "gateway",
4682                "(root)",
4683                "hmac_auth.max_clock_skew_secs",
4684                format!(
4685                    "max_clock_skew_secs {skew} is out of range: must be 1..=3600 seconds \
4686                     (0 rejects every signer with any clock drift; a larger window pins \
4687                     replay-nonce memory for its whole duration)"
4688                ),
4689            ));
4690        }
4691    }
4692
4693    // DW-044: alert/event webhook targets.
4694    validate_webhooks(gateway, &mut issues);
4695    // DW-121: the access-record stream.
4696    validate_analytics_stream(gateway, &mut issues);
4697
4698    // DW-043: the embedded analytics store block.
4699    validate_analytics(gateway, &mut issues);
4700
4701    // DW-050: geo rules need a database; countries must be alpha-2.
4702    validate_geoip(gateway, &mut issues);
4703
4704    // DW-033: consumer request budgets.
4705    validate_quotas(gateway, &mut issues);
4706
4707    // DW-032: enterprise license block (grace-period bounds; the
4708    // signature/expiry check is NOT validation — it runs at startup in
4709    // dwara-bin, where a missing/invalid file can exit 1).
4710    validate_license(gateway, &mut issues);
4711
4712    // DW-031: Redis rate limiter config (URL non-empty, timeout/TTL
4713    // bounds; the license claim check is NOT validation — it runs at
4714    // startup in dwara-bin, where a missing claim logs a warning and
4715    // falls back to the local limiter).
4716    validate_redis_rate_limiter(gateway, &mut issues);
4717
4718    // DW-155: Redis quotas config (URL non-empty, timeout bounds; the
4719    // license claim check is NOT validation — it runs at startup in
4720    // dwara-bin, where a missing claim logs a warning and falls back to
4721    // the local SQLite checker).
4722    validate_redis_quotas(gateway, &mut issues);
4723
4724    // SCALE-04 (#183): Redis response cache config (URL non-empty,
4725    // timeout bounds, key prefix non-empty; the license claim check is
4726    // NOT validation — it runs at startup in dwara-bin, where a missing
4727    // claim logs a warning and falls back to the local moka cache).
4728    validate_redis_cache(gateway, &mut issues);
4729
4730    // DW-054: config convergence (backend type, redis_url presence,
4731    // interval bounds; the license claim check is NOT validation — it
4732    // runs at startup in dwara-bin, where a missing claim logs a
4733    // warning and falls back to local-only mode).
4734    validate_config_convergence(gateway, &mut issues);
4735    validate_fleet(gateway, &mut issues);
4736
4737    // DW-107: service mesh mode (sidecar + SPIFFE/SPIRE mTLS
4738    // identity). Feature-gate warning (inert without `mesh`), ent-gate
4739    // warning (enterprise feature), SPIFFE field checks, and sidecar
4740    // port/redirect-mode checks.
4741    validate_mesh(gateway, &mut issues);
4742
4743    // DW-034: OIDC providers (issuer URL shape, introspection cache TTL
4744    // bounds, consumer references, trusted_ca_file readability).
4745    validate_oidc_providers(gateway, &mut issues);
4746
4747    // DW-075: the AI provider-adapter block (provider upstream refs,
4748    // auth resolvability, model alias refs).
4749    validate_ai(gateway, &mut issues);
4750
4751    // DW-111: FIPS 140-3 mode primitive restrictions. When FIPS mode is
4752    // active (Enterprise `ent` build + license claim), reject
4753    // non-approved primitives at config
4754    // validation: Ed25519 certificates (not on the FIPS-validated list
4755    // for aws-lc-rs) and Argon2 credential hashing (not FIPS-approved).
4756    // When FIPS mode is not active, the check is inert (no issues
4757    // produced).
4758    validate_fips(gateway, &mut issues);
4759
4760    // DW-105: post-quantum TLS validation. PQ hybrid key exchange is
4761    // compiled into every build (the feature-off warning is vestigial
4762    // and never fires). When `pq: true` and FIPS mode is active,
4763    // reject (ML-KEM is not on the FIPS-validated list). When `pq: true`
4764    // is on a passthrough listener or an http1 upstream, reject (no TLS
4765    // is terminated/negotiated).
4766    validate_pq(gateway, &mut issues);
4767
4768    // Zero-route guard (#129, maintainer decision): a route-less config is
4769    // schema-valid, and a truncated/torn write (truncate-then-save) lands
4770    // exactly here — publishing it would drop all routing mid-run. Rejected
4771    // unless the operator explicitly opted in for a deliberate admin-only
4772    // shape. Applies to cold start and hot reload alike (compile runs both).
4773    if gateway.routes.is_empty() && !gateway.allow_empty_routes {
4774        issues.push(issue(
4775            "gateway",
4776            "(root)",
4777            "routes",
4778            "routes is empty: publishing an empty route set drops all routing \
4779             (every request would 404), and a truncated or torn config write \
4780             is schema-valid here — set allow_empty_routes: true if this \
4781             route-less shape is deliberate",
4782        ));
4783    }
4784
4785    // DW-078: AI token budgets on policies — set fields must be positive,
4786    // and at least one window must be configured (a budget block that
4787    // bounds nothing is an authoring error).
4788    for p in &gateway.policies {
4789        if let Some(tb) = &p.token_budget {
4790            if tb.tokens_per_min.is_none() && tb.cost_per_day_micros.is_none() {
4791                issues.push(issue(
4792                    "gateway",
4793                    &p.name,
4794                    "token_budget",
4795                    "a token_budget must set tokens_per_min and/or cost_per_day_micros \
4796                 (an empty budget bounds nothing)",
4797                ));
4798            }
4799            if tb.tokens_per_min == Some(0) {
4800                issues.push(issue(
4801                    "gateway",
4802                    &p.name,
4803                    "token_budget.tokens_per_min",
4804                    "tokens_per_min must be > 0 (omit the field for no token window)",
4805                ));
4806            }
4807            if tb.cost_per_day_micros == Some(0) {
4808                issues.push(issue(
4809                    "gateway",
4810                    &p.name,
4811                    "token_budget.cost_per_day_micros",
4812                    "cost_per_day_micros must be > 0 (omit the field for no cost window)",
4813                ));
4814            }
4815        }
4816    }
4817
4818    // Duplicate names within each entity kind.
4819    let mut check_dups = |kind: &str, field: &str, names: Vec<&str>| {
4820        let mut seen = std::collections::BTreeSet::new();
4821        for name in names {
4822            if !seen.insert(name) {
4823                issues.push(issue(kind, name, field, "duplicate name"));
4824            }
4825        }
4826    };
4827    check_dups(
4828        "listener",
4829        "name",
4830        gateway.listeners.iter().map(|l| l.name.as_str()).collect(),
4831    );
4832    check_dups(
4833        "route",
4834        "name",
4835        gateway.routes.iter().map(|r| r.name.as_str()).collect(),
4836    );
4837    check_dups(
4838        "service",
4839        "name",
4840        gateway.services.iter().map(|s| s.name.as_str()).collect(),
4841    );
4842    check_dups(
4843        "upstream",
4844        "name",
4845        gateway.upstreams.iter().map(|u| u.name.as_str()).collect(),
4846    );
4847    check_dups(
4848        "consumer",
4849        "name",
4850        gateway.consumers.iter().map(|c| c.name.as_str()).collect(),
4851    );
4852    check_dups(
4853        "policy",
4854        "name",
4855        gateway.policies.iter().map(|p| p.name.as_str()).collect(),
4856    );
4857    check_dups(
4858        "jwt_provider",
4859        "name",
4860        gateway
4861            .jwt_providers
4862            .iter()
4863            .map(|p| p.name.as_str())
4864            .collect(),
4865    );
4866
4867    // DW-055/DW-119: plugin definitions -- exactly one of wasm/native,
4868    // non-empty phases, duplicate names.
4869    validate_plugins(gateway, &mut issues);
4870
4871    // SCALE-12 (#191): global filter-chain ordering and dry-run.
4872    if let Some(fc) = &gateway.filter_chain {
4873        validate_filter_chain(fc, "gateway", "filter_chain", &mut issues);
4874    }
4875
4876    // SCALE-12 (#191): per-route filter-chain overrides.
4877    for route in &gateway.routes {
4878        if let Some(fc) = &route.filter_chain {
4879            validate_filter_chain(fc, "route", &route.name, &mut issues);
4880        }
4881    }
4882
4883    // JWT providers (DW-019): url shape, algorithm allowlist, refresh
4884    // cadence, and consumer references are compile-time checked — a
4885    // gateway must not boot (or reload) into an unverifiable provider.
4886    for p in &gateway.jwt_providers {
4887        match p.jwks_url.parse::<hyper::Uri>() {
4888            Ok(uri) => {
4889                if !matches!(uri.scheme_str(), Some("http") | Some("https")) || uri.host().is_none()
4890                {
4891                    issues.push(issue(
4892                        "jwt_provider",
4893                        &p.name,
4894                        "jwks_url",
4895                        format!("'{}' must be an absolute http(s) URL", p.jwks_url),
4896                    ));
4897                }
4898            }
4899            Err(_) => issues.push(issue(
4900                "jwt_provider",
4901                &p.name,
4902                "jwks_url",
4903                format!("'{}' is not a valid URL", p.jwks_url),
4904            )),
4905        }
4906        if p.algorithms.is_empty() {
4907            issues.push(issue(
4908                "jwt_provider",
4909                &p.name,
4910                "algorithms",
4911                "at least one algorithm must be allowed",
4912            ));
4913        }
4914        for a in &p.algorithms {
4915            let upper = a.to_ascii_uppercase();
4916            if matches!(upper.as_str(), "HS256" | "HS384" | "HS512" | "NONE") {
4917                issues.push(issue(
4918                    "jwt_provider",
4919                    &p.name,
4920                    "algorithms",
4921                    format!(
4922                        "algorithm '{a}' is not allowed: only asymmetric verification \
4923                         (RS*/ES*/PS*/EdDSA) is supported"
4924                    ),
4925                ));
4926            } else if jsonwebtoken::Algorithm::from_str(&upper).is_err() {
4927                issues.push(issue(
4928                    "jwt_provider",
4929                    &p.name,
4930                    "algorithms",
4931                    format!("unknown algorithm '{a}'"),
4932                ));
4933            }
4934        }
4935        if p.refresh_secs == 0 {
4936            issues.push(issue(
4937                "jwt_provider",
4938                &p.name,
4939                "refresh_secs",
4940                "refresh_secs must be > 0",
4941            ));
4942        }
4943        // DW-046: the retired-key grace is a bounded safety window —
4944        // past a week it is no longer bridging a rotation, it is
4945        // keeping a retired key alive on purpose.
4946        if let Some(grace) = p.retired_key_grace_secs {
4947            if grace > 604_800 {
4948                issues.push(issue(
4949                    "jwt_provider",
4950                    &p.name,
4951                    "retired_key_grace_secs",
4952                    format!(
4953                        "retired_key_grace_secs {grace} exceeds the 604800 cap \
4954                         (7 days): a longer window would keep a retired issuer \
4955                         key verifying indefinitely"
4956                    ),
4957                ));
4958            }
4959        }
4960        if let Some(consumer) = &p.consumer {
4961            if !gateway.consumers.iter().any(|c| &c.name == consumer) {
4962                issues.push(issue(
4963                    "jwt_provider",
4964                    &p.name,
4965                    "consumer",
4966                    format!("references unknown consumer '{consumer}'"),
4967                ));
4968            }
4969        }
4970        // trusted_ca_file (#121): the JWKS fetcher's TLS trust override
4971        // for private-CA issuers. It only applies to an `https://`
4972        // jwks_url — no TLS is negotiated toward an `http://` endpoint,
4973        // so there is nothing to verify and the field is an authoring
4974        // mistake. When it does apply, the bundle must be on disk and
4975        // readable at compile time (see check_trusted_ca_file).
4976        if let Some(ca) = &p.trusted_ca_file {
4977            // An unparseable URL already produced its own jwks_url issue
4978            // above; piling a CA complaint on a broken URL is noise.
4979            if let Ok(uri) = p.jwks_url.parse::<hyper::Uri>() {
4980                if uri
4981                    .scheme_str()
4982                    .is_some_and(|s| s.eq_ignore_ascii_case("https"))
4983                {
4984                    check_trusted_ca_file(
4985                        "jwt_provider",
4986                        &p.name,
4987                        "trusted_ca_file",
4988                        ca,
4989                        &mut issues,
4990                    );
4991                } else {
4992                    issues.push(issue(
4993                        "jwt_provider",
4994                        &p.name,
4995                        "trusted_ca_file",
4996                        "trusted_ca_file only applies to an https jwks_url (no TLS is \
4997                         negotiated toward an http endpoint)",
4998                    ));
4999                }
5000            }
5001        }
5002    }
5003
5004    // Admin block (DW-022): when configured, the bind must parse as a
5005    // socket address and the mTLS material must be present and non-empty.
5006    // A client CA is REQUIRED (mTLS-only, decision 6): an admin block
5007    // without one is rejected here rather than serving no-auth TLS.
5008    if let Some(admin) = &gateway.admin {
5009        if admin.bind.trim().is_empty() {
5010            issues.push(issue(
5011                "admin",
5012                "(root)",
5013                "bind",
5014                "admin bind is empty (default 127.0.0.1:2019)",
5015            ));
5016        } else if admin.bind.parse::<std::net::SocketAddr>().is_err() {
5017            issues.push(issue(
5018                "admin",
5019                "(root)",
5020                "bind",
5021                format!(
5022                    "admin bind '{}' is not a valid address:port (e.g. 127.0.0.1:2019)",
5023                    admin.bind
5024                ),
5025            ));
5026        }
5027        let t = &admin.tls;
5028        for (field, value) in [
5029            ("tls.cert_file", &t.cert_file),
5030            ("tls.key_file", &t.key_file),
5031            ("tls.client_ca_file", &t.client_ca_file),
5032        ] {
5033            if value.trim().is_empty() {
5034                issues.push(issue(
5035                    "admin",
5036                    "(root)",
5037                    field,
5038                    format!("{field} is required (mTLS-only admin: server cert, key, and client CA must all be set)"),
5039                ));
5040            }
5041        }
5042    }
5043
5044    // Listener sanity and bind conflicts.
5045    let mut binds = std::collections::BTreeSet::new();
5046    for l in &gateway.listeners {
5047        if l.port == 0 {
5048            issues.push(issue(
5049                "listener",
5050                &l.name,
5051                "port",
5052                "port 0 is not a valid gateway listen port (only meaningful as an ephemeral test bind)",
5053            ));
5054        }
5055        if l.address.trim().is_empty() {
5056            issues.push(issue("listener", &l.name, "address", "address is empty"));
5057        }
5058        match l.protocol {
5059            ListenerProtocol::Https => {
5060                match &l.tls {
5061                    None => issues.push(issue(
5062                        "listener",
5063                        &l.name,
5064                        "tls",
5065                        "protocol https requires a tls block",
5066                    )),
5067                    Some(t) => match t.mode {
5068                        TlsMode::Terminate => {
5069                            let has_pair = t.cert_file.is_some() || t.key_file.is_some();
5070                            if t.certificates.is_empty() || has_pair {
5071                                if t.cert_file.as_deref().unwrap_or("").trim().is_empty() {
5072                                    issues.push(issue(
5073                                        "listener",
5074                                        &l.name,
5075                                        "tls.cert_file",
5076                                        "tls mode terminate requires a non-empty cert_file",
5077                                    ));
5078                                }
5079                                if t.key_file.as_deref().unwrap_or("").trim().is_empty() {
5080                                    issues.push(issue(
5081                                        "listener",
5082                                        &l.name,
5083                                        "tls.key_file",
5084                                        "tls mode terminate requires a non-empty key_file",
5085                                    ));
5086                                }
5087                            }
5088                            if !t.sni_routes.is_empty() {
5089                                issues.push(issue(
5090                                    "listener",
5091                                    &l.name,
5092                                    "tls.sni_routes",
5093                                    "sni_routes only apply to tls mode passthrough",
5094                                ));
5095                            }
5096                            // #124: a client-CA bundle turns the terminate
5097                            // listener into one that verifies client
5098                            // certificates (optional at the TLS layer, matched
5099                            // against mtls credentials at authn). The same
5100                            // compile-time PEM check as trusted_ca_file: a
5101                            // broken bundle would otherwise surface as a
5102                            // listener-build failure at startup.
5103                            if let Some(ca) = &t.client_ca_file {
5104                                check_trusted_ca_file(
5105                                    "listener",
5106                                    &l.name,
5107                                    "tls.client_ca_file",
5108                                    ca,
5109                                    &mut issues,
5110                                );
5111                            }
5112                            let mut seen_names = std::collections::BTreeSet::new();
5113                            for (i, c) in t.certificates.iter().enumerate() {
5114                                if c.server_names.is_empty() {
5115                                    issues.push(issue(
5116                                        "listener",
5117                                        &l.name,
5118                                        &format!("tls.certificates[{i}].server_names"),
5119                                        "certificate entry lists no server_names",
5120                                    ));
5121                                }
5122                                for n in &c.server_names {
5123                                    if n.trim().is_empty() {
5124                                        issues.push(issue(
5125                                            "listener",
5126                                            &l.name,
5127                                            &format!("tls.certificates[{i}].server_names"),
5128                                            "server name is empty",
5129                                        ));
5130                                    } else if !seen_names.insert(n.to_ascii_lowercase()) {
5131                                        issues.push(issue(
5132                                            "listener",
5133                                            &l.name,
5134                                            &format!("tls.certificates[{i}].server_names"),
5135                                            format!("duplicate server name '{n}'"),
5136                                        ));
5137                                    }
5138                                }
5139                                if c.cert_file.trim().is_empty() {
5140                                    issues.push(issue(
5141                                        "listener",
5142                                        &l.name,
5143                                        &format!("tls.certificates[{i}].cert_file"),
5144                                        "certificate entry requires a non-empty cert_file",
5145                                    ));
5146                                }
5147                                if c.key_file.trim().is_empty() {
5148                                    issues.push(issue(
5149                                        "listener",
5150                                        &l.name,
5151                                        &format!("tls.certificates[{i}].key_file"),
5152                                        "certificate entry requires a non-empty key_file",
5153                                    ));
5154                                }
5155                            }
5156                            // SEC-02: ACME validation. Terminate mode
5157                            // only (passthrough does not terminate TLS).
5158                            if let Some(acme) = &t.acme {
5159                                if acme.domains.is_empty() {
5160                                    issues.push(issue(
5161                                        "listener",
5162                                        &l.name,
5163                                        "tls.acme.domains",
5164                                        "acme domains list must not be empty",
5165                                    ));
5166                                }
5167                                if !acme.staging && acme.contact.is_empty() {
5168                                    issues.push(issue(
5169                                        "listener",
5170                                        &l.name,
5171                                        "tls.acme.contact",
5172                                        "acme contact email is required for production issuance \
5173                                         (Let's Encrypt requires at least one contact email)",
5174                                    ));
5175                                }
5176                            }
5177                        }
5178                        TlsMode::Passthrough => {
5179                            if t.cert_file.is_some() || t.key_file.is_some() {
5180                                issues.push(issue(
5181                                "listener",
5182                                &l.name,
5183                                "tls",
5184                                "tls mode passthrough ignores cert_file/key_file; remove them or use mode terminate",
5185                            ));
5186                            }
5187                            // DW-030: PROXY acceptance needs the HTTP pipeline
5188                            // that consumes the client address (ACL, rate
5189                            // keys, XFF); a passthrough listener splices raw
5190                            // bytes and would silently ignore the knob.
5191                            if l.proxy_protocol {
5192                                issues.push(issue(
5193                                    "listener",
5194                                    &l.name,
5195                                    "proxy_protocol",
5196                                    "proxy_protocol cannot be combined with tls mode passthrough: \
5197                                 passthrough splices raw bytes and never runs the pipeline that \
5198                                 consumes the PROXY client address (use protocol http, or https \
5199                                 with mode terminate)",
5200                                ));
5201                            }
5202                            if !t.certificates.is_empty() {
5203                                issues.push(issue(
5204                                "listener",
5205                                &l.name,
5206                                "tls.certificates",
5207                                "tls mode passthrough does not terminate TLS; certificates do not apply",
5208                            ));
5209                            }
5210                            if t.client_ca_file.is_some() {
5211                                issues.push(issue(
5212                                "listener",
5213                                &l.name,
5214                                "tls.client_ca_file",
5215                                "tls mode passthrough does not terminate TLS; client certificates \
5216                                 cannot be verified (use mode terminate)",
5217                            ));
5218                            }
5219                            if t.acme.is_some() {
5220                                issues.push(issue(
5221                                    "listener",
5222                                    &l.name,
5223                                    "tls.acme",
5224                                    "tls mode passthrough does not terminate TLS; acme certificate \
5225                                     automation does not apply (use mode terminate)",
5226                                ));
5227                            }
5228                            let mut seen_names = std::collections::BTreeSet::new();
5229                            for (i, r) in t.sni_routes.iter().enumerate() {
5230                                if r.server_names.is_empty() {
5231                                    issues.push(issue(
5232                                        "listener",
5233                                        &l.name,
5234                                        &format!("tls.sni_routes[{i}].server_names"),
5235                                        "sni route lists no server_names",
5236                                    ));
5237                                }
5238                                for n in &r.server_names {
5239                                    if n.trim().is_empty() {
5240                                        issues.push(issue(
5241                                            "listener",
5242                                            &l.name,
5243                                            &format!("tls.sni_routes[{i}].server_names"),
5244                                            "server name is empty",
5245                                        ));
5246                                    } else if !seen_names.insert(n.to_ascii_lowercase()) {
5247                                        issues.push(issue(
5248                                            "listener",
5249                                            &l.name,
5250                                            &format!("tls.sni_routes[{i}].server_names"),
5251                                            format!("duplicate server name '{n}'"),
5252                                        ));
5253                                    }
5254                                }
5255                            }
5256                        }
5257                    },
5258                } // close match &l.tls
5259                  // DW-088: zero_rtt is an H3-only policy; on H1/H2
5260                  // listeners it has no effect (TLS 1.3 early data over
5261                  // TCP is not exposed here). Reject it to avoid silent
5262                  // misconfiguration.
5263                if let Some(t) = &l.tls {
5264                    if t.zero_rtt != ZeroRttPolicy::Reject {
5265                        issues.push(issue(
5266                            "listener",
5267                            &l.name,
5268                            "tls.zero_rtt",
5269                            "zero_rtt only applies to protocol h3 listeners (TLS 1.3 early data over QUIC); remove it or use protocol h3",
5270                        ));
5271                    }
5272                }
5273                // DW-088: alt_svc on https listeners advertises the H3
5274                // port. Validate non-empty (same as http).
5275                if let Some(alt_svc) = &l.alt_svc {
5276                    if alt_svc.trim().is_empty() {
5277                        issues.push(issue(
5278                            "listener",
5279                            &l.name,
5280                            "alt_svc",
5281                            "alt_svc is set but empty; provide a valid alt-svc value or remove the field",
5282                        ));
5283                    }
5284                }
5285            }
5286            ListenerProtocol::Http => {
5287                if l.tls.is_some() {
5288                    issues.push(issue(
5289                        "listener",
5290                        &l.name,
5291                        "tls",
5292                        "protocol http must not carry a tls block (use protocol https for TLS)",
5293                    ));
5294                }
5295                // DW-088: alt_svc is meaningful on H1/H2 listeners (they
5296                // advertise the H3 port to clients). An H3 listener
5297                // advertising itself is a no-op.
5298                if let Some(alt_svc) = &l.alt_svc {
5299                    if alt_svc.trim().is_empty() {
5300                        issues.push(issue(
5301                            "listener",
5302                            &l.name,
5303                            "alt_svc",
5304                            "alt_svc is set but empty; provide a valid alt-svc value or remove the field",
5305                        ));
5306                    }
5307                }
5308            }
5309            ListenerProtocol::H3 => {
5310                // DW-088: HTTP/3 (QUIC) ingress. The QUIC stack is
5311                // always linked in the OSS default build.
5312                match &l.tls {
5313                    None => issues.push(issue(
5314                        "listener",
5315                        &l.name,
5316                        "tls",
5317                        "protocol h3 requires a tls block with cert_file and key_file (QUIC uses TLS 1.3)",
5318                    )),
5319                    Some(t) => {
5320                        if t.mode == TlsMode::Passthrough {
5321                            issues.push(issue(
5322                                "listener",
5323                                &l.name,
5324                                "tls.mode",
5325                                "protocol h3 does not support tls mode passthrough (QUIC terminates TLS in the handshake)",
5326                            ));
5327                        } else {
5328                            // Terminate mode: require cert/key (same as
5329                            // https terminate, but H3 has no SNI routes).
5330                            if t.cert_file.as_deref().unwrap_or("").trim().is_empty()
5331                                && t.certificates.is_empty()
5332                            {
5333                                issues.push(issue(
5334                                    "listener",
5335                                    &l.name,
5336                                    "tls.cert_file",
5337                                    "protocol h3 requires a non-empty cert_file or certificates (QUIC handshake needs a server certificate)",
5338                                ));
5339                            }
5340                            if t.key_file.as_deref().unwrap_or("").trim().is_empty()
5341                                && t.certificates.is_empty()
5342                            {
5343                                issues.push(issue(
5344                                    "listener",
5345                                    &l.name,
5346                                    "tls.key_file",
5347                                    "protocol h3 requires a non-empty key_file or certificates (QUIC handshake needs a private key)",
5348                                ));
5349                            }
5350                            if !t.sni_routes.is_empty() {
5351                                issues.push(issue(
5352                                    "listener",
5353                                    &l.name,
5354                                    "tls.sni_routes",
5355                                    "sni_routes only apply to tls mode passthrough (H3 uses SNI for cert selection, not routing)",
5356                                ));
5357                            }
5358                        }
5359                        // zero_rtt is only meaningful on H3 listeners.
5360                        // On H1/H2 listeners it is rejected below.
5361                    }
5362                }
5363                // H3 listeners must not advertise alt_svc (they ARE the
5364                // alt-svc target; advertising themselves is a no-op).
5365                if l.alt_svc.is_some() {
5366                    issues.push(issue(
5367                        "listener",
5368                        &l.name,
5369                        "alt_svc",
5370                        "alt_svc must not be set on an h3 listener (the h3 listener is the alt-svc target, not the advertiser)",
5371                    ));
5372                }
5373                // PROXY protocol on H3 is not supported (QUIC has no
5374                // pre-handshake byte stream to read a PROXY header from).
5375                if l.proxy_protocol {
5376                    issues.push(issue(
5377                        "listener",
5378                        &l.name,
5379                        "proxy_protocol",
5380                        "proxy_protocol is not supported on h3 listeners (QUIC has no pre-handshake byte stream)",
5381                    ));
5382                }
5383            }
5384            ListenerProtocol::Tcp | ListenerProtocol::Udp => {
5385                // DW-103: L4 TCP/UDP proxying. The L4 dispatcher is
5386                // always linked in the OSS default build.
5387                // The `l4` block is required on tcp/udp listeners.
5388                let l4_cfg = match &l.l4 {
5389                    None => {
5390                        issues.push(issue(
5391                            "listener",
5392                            &l.name,
5393                            "l4",
5394                            format!(
5395                                "protocol {} requires an l4 block (upstream + optional \
5396                                 sni_routing + idle_timeout_s)",
5397                                if l.protocol == ListenerProtocol::Tcp {
5398                                    "tcp"
5399                                } else {
5400                                    "udp"
5401                                }
5402                            ),
5403                        ));
5404                        continue;
5405                    }
5406                    Some(cfg) => cfg,
5407                };
5408                // sni_routing is TCP-only (UDP has no byte-stream
5409                // handshake to peek).
5410                if l4_cfg.sni_routing && l.protocol == ListenerProtocol::Udp {
5411                    issues.push(issue(
5412                        "listener",
5413                        &l.name,
5414                        "l4.sni_routing",
5415                        "sni_routing is not supported on udp listeners (UDP has no \
5416                         byte-stream handshake to peek; use protocol tcp for SNI-routed \
5417                         L4 proxying)",
5418                    ));
5419                }
5420                // idle_timeout_s bounds check (at most 1 hour).
5421                if let Some(timeout) = l4_cfg.idle_timeout_s {
5422                    if timeout > 3600 {
5423                        issues.push(issue(
5424                            "listener",
5425                            &l.name,
5426                            "l4.idle_timeout_s",
5427                            "idle_timeout_s must be at most 3600 (1 hour)",
5428                        ));
5429                    }
5430                }
5431                // The upstream (when present) must reference a known
5432                // upstream. When sni_routing is true the upstream is
5433                // optional (the fallback for no-SNI/unmatched); when
5434                // false it is required.
5435                if !l4_cfg.sni_routing && l4_cfg.upstream.is_none() {
5436                    issues.push(issue(
5437                        "listener",
5438                        &l.name,
5439                        "l4.upstream",
5440                        "l4.upstream is required when sni_routing is false (every \
5441                         connection goes to the configured upstream)",
5442                    ));
5443                }
5444                // The upstreams set is built below this loop; defer the
5445                // upstream-exists check to the post-loop block (the
5446                // same pattern as sni_routes upstream checks).
5447                // SNI routing requires the listener to carry a tls
5448                // block with sni_routes (the same routing table
5449                // passthrough uses).
5450                if l4_cfg.sni_routing {
5451                    match &l.tls {
5452                        None => {
5453                            issues.push(issue(
5454                                "listener",
5455                                &l.name,
5456                                "tls",
5457                                "l4.sni_routing requires a tls block with sni_routes \
5458                                 (the SNI routing table, same as tls passthrough)",
5459                            ));
5460                        }
5461                        Some(t) => {
5462                            if t.sni_routes.is_empty() {
5463                                issues.push(issue(
5464                                    "listener",
5465                                    &l.name,
5466                                    "tls.sni_routes",
5467                                    "l4.sni_routing requires at least one tls.sni_routes \
5468                                     entry (the SNI routing table is empty)",
5469                                ));
5470                            }
5471                        }
5472                    }
5473                } else if l.tls.is_some() {
5474                    // A tls block without sni_routing is only meaningful
5475                    // for sni_routes; warn (not reject -- the tls block
5476                    // is additive and could carry future L4-relevant
5477                    // config).
5478                    if l.tls
5479                        .as_ref()
5480                        .map(|t| t.sni_routes.is_empty())
5481                        .unwrap_or(true)
5482                    {
5483                        issues.push(issue(
5484                            "listener",
5485                            &l.name,
5486                            "tls",
5487                            "tls block on an l4 listener without sni_routing has no \
5488                             effect (remove it or set l4.sni_routing: true)",
5489                        ));
5490                    }
5491                }
5492                // alt_svc / zero_rtt are HTTP-transport concepts;
5493                // reject them on L4 listeners.
5494                if l.alt_svc.is_some() {
5495                    issues.push(issue(
5496                        "listener",
5497                        &l.name,
5498                        "alt_svc",
5499                        "alt_svc must not be set on an l4 listener (L4 proxying has no \
5500                         HTTP response to advertise on)",
5501                    ));
5502                }
5503                if let Some(t) = &l.tls {
5504                    if t.zero_rtt != ZeroRttPolicy::Reject {
5505                        issues.push(issue(
5506                            "listener",
5507                            &l.name,
5508                            "tls.zero_rtt",
5509                            "zero_rtt only applies to protocol h3 listeners (TLS 1.3 \
5510                             early data over QUIC); remove it",
5511                        ));
5512                    }
5513                }
5514            }
5515        }
5516        if !binds.insert((l.address.as_str(), l.port)) {
5517            issues.push(issue(
5518                "listener",
5519                &l.name,
5520                "port",
5521                format!(
5522                    "address {}:{} already bound by another listener",
5523                    l.address, l.port
5524                ),
5525            ));
5526        }
5527    }
5528
5529    let services: std::collections::BTreeSet<&str> =
5530        gateway.services.iter().map(|s| s.name.as_str()).collect();
5531    let upstreams: std::collections::BTreeSet<&str> =
5532        gateway.upstreams.iter().map(|u| u.name.as_str()).collect();
5533    let policies: std::collections::BTreeSet<&str> =
5534        gateway.policies.iter().map(|p| p.name.as_str()).collect();
5535    let consumers: std::collections::BTreeSet<&str> =
5536        gateway.consumers.iter().map(|c| c.name.as_str()).collect();
5537    let consumer_groups: std::collections::BTreeSet<&str> = gateway
5538        .consumers
5539        .iter()
5540        .flat_map(|c| c.groups.iter().map(String::as_str))
5541        .collect();
5542    // DW-055/DW-119: plugin names for route reference validation.
5543    let plugin_names: std::collections::BTreeSet<&str> =
5544        gateway.plugins.iter().map(|p| p.name.as_str()).collect();
5545
5546    for l in &gateway.listeners {
5547        // Listener policy attachment (#123): same resolution rule as
5548        // route/service/consumer refs. Runs for EVERY listener (the
5549        // tls-scoped checks below skip tls-less listeners early).
5550        for p in &l.policies {
5551            if !policies.contains(p.as_str()) {
5552                issues.push(issue(
5553                    "listener",
5554                    &l.name,
5555                    "policies",
5556                    format!("references unknown policy '{p}'"),
5557                ));
5558            }
5559        }
5560        // Listener authorization (#123): shared shape checks with every
5561        // other attachment level.
5562        if let Some(authz) = &l.authorization {
5563            validate_authz(
5564                "listener",
5565                &l.name,
5566                authz,
5567                &consumers,
5568                &consumer_groups,
5569                &mut issues,
5570            );
5571        }
5572        let Some(tls) = &l.tls else { continue };
5573        for (i, r) in tls.sni_routes.iter().enumerate() {
5574            if !upstreams.contains(r.upstream.as_str()) {
5575                issues.push(issue(
5576                    "listener",
5577                    &l.name,
5578                    &format!("tls.sni_routes[{i}].upstream"),
5579                    format!("references unknown upstream '{}'", r.upstream),
5580                ));
5581            }
5582        }
5583    }
5584
5585    // DW-103: L4 listener upstream reference checks (deferred to here
5586    // because the `upstreams` set is built after the listener loop).
5587    for l in &gateway.listeners {
5588        if !matches!(l.protocol, ListenerProtocol::Tcp | ListenerProtocol::Udp) {
5589            continue;
5590        }
5591        let Some(l4_cfg) = &l.l4 else { continue };
5592        // The l4.upstream (when present) must reference a known upstream.
5593        if let Some(name) = &l4_cfg.upstream {
5594            if !upstreams.contains(name.as_str()) {
5595                issues.push(issue(
5596                    "listener",
5597                    &l.name,
5598                    "l4.upstream",
5599                    format!("references unknown upstream '{name}'"),
5600                ));
5601            }
5602        }
5603        // SNI routing: each tls.sni_routes[].upstream must reference a
5604        // known upstream (already checked above for ALL listeners with
5605        // tls.sni_routes, so no duplicate check here). Additionally,
5606        // SNI routing requires the referenced upstreams to have TLS
5607        // endpoints (the SNI is a TLS ClientHello field; the upstream
5608        // must terminate or pass through TLS). Check that the upstream
5609        // protocol is a TLS-bearing one (https, http2, h3).
5610        if l4_cfg.sni_routing {
5611            if let Some(tls) = &l.tls {
5612                for r in &tls.sni_routes {
5613                    if let Some(upstream) = gateway.upstreams.iter().find(|u| u.name == r.upstream)
5614                    {
5615                        if !matches!(
5616                            upstream.protocol,
5617                            crate::config::UpstreamProtocol::Https
5618                                | crate::config::UpstreamProtocol::Http2
5619                                | crate::config::UpstreamProtocol::H3
5620                        ) {
5621                            issues.push(issue(
5622                                "listener",
5623                                &l.name,
5624                                "tls.sni_routes.upstream",
5625                                format!(
5626                                    "sni_routing upstream '{}' must use a TLS protocol \
5627                                     (https, http2, or h3); '{}' cannot receive a \
5628                                     TLS-routed L4 splice",
5629                                    r.upstream,
5630                                    upstream_protocol_name(upstream.protocol)
5631                                ),
5632                            ));
5633                        }
5634                    }
5635                }
5636            }
5637        }
5638    }
5639
5640    // Gateway-level (global) policy attachment (#123): named
5641    // `global_policies` because `policies` at this level is the registry.
5642    for p in &gateway.global_policies {
5643        if !policies.contains(p.as_str()) {
5644            issues.push(issue(
5645                "gateway",
5646                "(root)",
5647                "global_policies",
5648                format!("references unknown policy '{p}'"),
5649            ));
5650        }
5651    }
5652    // Gateway-level (global) authorization (#123).
5653    if let Some(authz) = &gateway.authorization {
5654        validate_authz(
5655            "gateway",
5656            "(root)",
5657            authz,
5658            &consumers,
5659            &consumer_groups,
5660            &mut issues,
5661        );
5662    }
5663
5664    for s in &gateway.services {
5665        // DW-040: a service dispatches through exactly one of
5666        // `upstream` (single target) or `split` (weighted targets) —
5667        // both set is ambiguous routing, neither is a service that
5668        // can serve nothing.
5669        match (&s.upstream, &s.split) {
5670            (Some(_), Some(_)) => issues.push(issue(
5671                "service",
5672                &s.name,
5673                "upstream",
5674                "upstream and split are both set: a service dispatches through exactly one \
5675                 (upstream = single target; split = weighted canary/blue-green)",
5676            )),
5677            (None, None) => issues.push(issue(
5678                "service",
5679                &s.name,
5680                "upstream",
5681                "neither upstream nor split is set: set upstream (single target) or split \
5682                 (weighted targets)",
5683            )),
5684            _ => {}
5685        }
5686        if let Some(upstream) = &s.upstream {
5687            if !upstreams.contains(upstream.as_str()) {
5688                issues.push(issue(
5689                    "service",
5690                    &s.name,
5691                    "upstream",
5692                    format!("references unknown upstream '{upstream}'"),
5693                ));
5694            }
5695        }
5696        // DW-040 split validation: existing upstreams, sane bounds, no
5697        // duplicates, and a positive total (individual zeros are the
5698        // parked side of a blue-green pair; an all-zero split routes
5699        // nowhere).
5700        if let Some(split) = &s.split {
5701            if split.targets.len() < 2 || split.targets.len() > 8 {
5702                issues.push(issue(
5703                    "service",
5704                    &s.name,
5705                    "split.targets",
5706                    format!(
5707                        "a split needs 2..=8 targets ({} given): one target is what \
5708                         services[].upstream expresses",
5709                        split.targets.len()
5710                    ),
5711                ));
5712            }
5713            let mut seen = std::collections::BTreeSet::new();
5714            let mut total: u64 = 0;
5715            for (i, t) in split.targets.iter().enumerate() {
5716                if !upstreams.contains(t.upstream.as_str()) {
5717                    issues.push(issue(
5718                        "service",
5719                        &s.name,
5720                        &format!("split.targets[{i}].upstream"),
5721                        format!("references unknown upstream '{}'", t.upstream),
5722                    ));
5723                }
5724                if !seen.insert(t.upstream.clone()) {
5725                    issues.push(issue(
5726                        "service",
5727                        &s.name,
5728                        &format!("split.targets[{i}].upstream"),
5729                        format!(
5730                            "duplicate upstream '{}' in the split (weights merge by intent; \
5731                             name it once)",
5732                            t.upstream
5733                        ),
5734                    ));
5735                }
5736                total += u64::from(t.weight);
5737            }
5738            if total == 0 {
5739                issues.push(issue(
5740                    "service",
5741                    &s.name,
5742                    "split.targets",
5743                    "every weight is 0: a split must serve some traffic (a parked \
5744                     blue-green side is weight 0, the other side is not)",
5745                ));
5746            }
5747            if total > 100_000 {
5748                issues.push(issue(
5749                    "service",
5750                    &s.name,
5751                    "split.targets",
5752                    format!("weights sum to {total}; keep the total at or under 100000"),
5753                ));
5754            }
5755            // DW-091: canary_analysis validation. Only valid on a
5756            // 2-target split (baseline + canary); the controller
5757            // compares exactly two sides. Bounds: window >= 1s,
5758            // step >= 1, min_requests >= 1, cooldown >= 1s, and
5759            // thresholds in [0.0, 1.0] for error_rate or > 0 for
5760            // latency.
5761            if let Some(analysis) = &split.canary_analysis {
5762                if split.targets.len() != 2 {
5763                    issues.push(issue(
5764                        "service",
5765                        &s.name,
5766                        "split.canary_analysis",
5767                        "canary_analysis requires exactly 2 targets (baseline + canary): \
5768                         the controller compares two sides",
5769                    ));
5770                }
5771                if analysis.window_seconds == 0 {
5772                    issues.push(issue(
5773                        "service",
5774                        &s.name,
5775                        "split.canary_analysis.window_seconds",
5776                        "window_seconds must be >= 1",
5777                    ));
5778                }
5779                if analysis.step == 0 {
5780                    issues.push(issue(
5781                        "service",
5782                        &s.name,
5783                        "split.canary_analysis.step",
5784                        "step must be >= 1 (a 0-step never adjusts)",
5785                    ));
5786                }
5787                if analysis.min_requests == 0 {
5788                    issues.push(issue(
5789                        "service",
5790                        &s.name,
5791                        "split.canary_analysis.min_requests",
5792                        "min_requests must be >= 1",
5793                    ));
5794                }
5795                if analysis.cooldown_seconds == 0 {
5796                    issues.push(issue(
5797                        "service",
5798                        &s.name,
5799                        "split.canary_analysis.cooldown_seconds",
5800                        "cooldown_seconds must be >= 1",
5801                    ));
5802                }
5803                validate_canary_rule(
5804                    &s.name,
5805                    "split.canary_analysis.promote",
5806                    &analysis.promote,
5807                    &mut issues,
5808                );
5809                validate_canary_rule(
5810                    &s.name,
5811                    "split.canary_analysis.rollback",
5812                    &analysis.rollback,
5813                    &mut issues,
5814                );
5815            }
5816        }
5817        // DW-040 sticky validation: a usable cookie-name token and a
5818        // bounded lifetime.
5819        if let Some(sticky) = &s.sticky {
5820            if !is_cookie_name(&sticky.cookie) {
5821                issues.push(issue(
5822                    "service",
5823                    &s.name,
5824                    "sticky.cookie",
5825                    format!(
5826                        "'{}' is not a valid cookie name (RFC 6265 token: no \
5827                         separators, spaces, or non-token characters)",
5828                        sticky.cookie
5829                    ),
5830                ));
5831            }
5832            if sticky.ttl_s == 0 || sticky.ttl_s > 2_592_000 {
5833                issues.push(issue(
5834                    "service",
5835                    &s.name,
5836                    "sticky.ttl_s",
5837                    "ttl_s must be in 1..=2592000 (30 days)",
5838                ));
5839            }
5840        }
5841        for p in &s.policies {
5842            if !policies.contains(p.as_str()) {
5843                issues.push(issue(
5844                    "service",
5845                    &s.name,
5846                    "policies",
5847                    format!("references unknown policy '{p}'"),
5848                ));
5849            }
5850        }
5851        // Service authorization (#123): shared shape checks with every
5852        // other attachment level.
5853        if let Some(authz) = &s.authorization {
5854            validate_authz(
5855                "service",
5856                &s.name,
5857                authz,
5858                &consumers,
5859                &consumer_groups,
5860                &mut issues,
5861            );
5862        }
5863    }
5864
5865    for r in &gateway.routes {
5866        if let Some(p) = r.priority {
5867            if p > 10 {
5868                issues.push(issue(
5869                    "route",
5870                    &r.name,
5871                    "priority",
5872                    format!("priority {p} is out of range: must be 0 (lowest) to 10 (highest)"),
5873                ));
5874            }
5875        }
5876        if !services.contains(r.service.as_str()) {
5877            issues.push(issue(
5878                "route",
5879                &r.name,
5880                "service",
5881                format!("references unknown service '{}'", r.service),
5882            ));
5883        }
5884        for p in &r.policies {
5885            if !policies.contains(p.as_str()) {
5886                issues.push(issue(
5887                    "route",
5888                    &r.name,
5889                    "policies",
5890                    format!("references unknown policy '{p}'"),
5891                ));
5892            }
5893        }
5894        // DW-055/DW-119: route plugin references must name a plugin
5895        // defined in the top-level `plugins` list, and each name at
5896        // most once (DW-157: the request-path chain executes every
5897        // reference, so a duplicate would run the plugin twice).
5898        let mut route_plugins = std::collections::BTreeSet::new();
5899        for p in &r.plugins {
5900            if !route_plugins.insert(p.as_str()) {
5901                issues.push(issue(
5902                    "route",
5903                    &r.name,
5904                    "plugins",
5905                    format!("references plugin '{p}' more than once"),
5906                ));
5907            }
5908            if !plugin_names.contains(p.as_str()) {
5909                issues.push(issue(
5910                    "route",
5911                    &r.name,
5912                    "plugins",
5913                    format!("references unknown plugin '{p}'"),
5914                ));
5915            }
5916        }
5917        // Route authorization (DW-020): shared shape checks with every
5918        // other attachment level (see validate_authz).
5919        if let Some(authz) = &r.authorization {
5920            validate_authz(
5921                "route",
5922                &r.name,
5923                authz,
5924                &consumers,
5925                &consumer_groups,
5926                &mut issues,
5927            );
5928        }
5929        // Route-scoped edge policies (DW-027): CORS, response
5930        // compression, request limits.
5931        if let Some(cors) = &r.cors {
5932            validate_cors(&r.name, cors, &mut issues);
5933        }
5934        if let Some(compression) = &r.compression {
5935            validate_compression(&r.name, compression, &mut issues);
5936        }
5937        if let Some(limits) = &r.limits {
5938            validate_request_limits(&r.name, limits, &mut issues);
5939        }
5940        if let Some(dep) = &r.deprecation {
5941            validate_deprecation(&r.name, dep, &mut issues);
5942        }
5943        if let Some(maintenance) = &r.maintenance {
5944            validate_maintenance(&r.name, maintenance, &mut issues);
5945        }
5946        // Route-scoped transforms + security headers (DW-028).
5947        if let Some(transforms) = &r.transforms {
5948            validate_transforms(&r.name, transforms, &mut issues);
5949        }
5950        if let Some(sh) = &r.security_headers {
5951            validate_security_headers(&r.name, sh, &mut issues);
5952        }
5953        // Response field masking (DW-029).
5954        if let Some(masking) = &r.masking {
5955            validate_masking(&r.name, masking, &consumer_groups, &mut issues);
5956        }
5957        // Response caching (DW-037).
5958        if let Some(cache) = &r.cache {
5959            validate_route_cache(&r.name, cache, &mut issues);
5960        }
5961        // Per-route method allowlist (DW-030): every entry must be a
5962        // valid HTTP method token (the same grammar cors.allowed_methods
5963        // checks) and the list must not repeat an entry case-insensitively
5964        // — a duplicate would only ever be an authoring artifact, and the
5965        // 405's Allow header echoes the list verbatim.
5966        {
5967            let mut seen_methods = std::collections::BTreeSet::new();
5968            for (i, m) in r.methods.iter().enumerate() {
5969                if m.trim().is_empty() {
5970                    issues.push(issue(
5971                        "route",
5972                        &r.name,
5973                        &format!("methods[{i}]"),
5974                        "method is empty or only whitespace",
5975                    ));
5976                } else if hyper::Method::from_bytes(m.as_bytes()).is_err() {
5977                    issues.push(issue(
5978                        "route",
5979                        &r.name,
5980                        &format!("methods[{i}]"),
5981                        format!("'{m}' is not a valid HTTP method token"),
5982                    ));
5983                } else if !seen_methods.insert(m.to_ascii_lowercase()) {
5984                    issues.push(issue(
5985                        "route",
5986                        &r.name,
5987                        &format!("methods[{i}]"),
5988                        format!("duplicate method '{m}' (matching is case-insensitive)"),
5989                    ));
5990                }
5991            }
5992        }
5993        // Per-route SLO objectives (DW-052): targets must be achievable
5994        // percentages, a latency target without a threshold can never
5995        // evaluate, and a threshold without a target would silently use
5996        // the default (it does — the error is only for the impossible
5997        // combination).
5998        if let Some(slo) = &r.slo {
5999            if !(0.0..=100.0).contains(&slo.availability) || slo.availability <= 0.0 {
6000                issues.push(issue(
6001                    "route",
6002                    &r.name,
6003                    "slo.availability",
6004                    format!(
6005                        "availability {} is out of range: a percentage in (0, 100] \
6006                         (100 = every request must be non-5xx)",
6007                        slo.availability
6008                    ),
6009                ));
6010            }
6011            if let Some(ms) = slo.latency_ms {
6012                if !(1.0..=600_000.0).contains(&ms) {
6013                    issues.push(issue(
6014                        "route",
6015                        &r.name,
6016                        "slo.latency_ms",
6017                        format!(
6018                            "latency_ms {ms} is out of range: 1..=600000 (the \
6019                             whole-request budget; nothing sensible is faster \
6020                             than 1 ms or slower than the 10-minute class of \
6021                             timeouts)"
6022                        ),
6023                    ));
6024                }
6025            }
6026            if let Some(t) = slo.latency_target {
6027                if !(0.0..=100.0).contains(&t) || t <= 0.0 {
6028                    issues.push(issue(
6029                        "route",
6030                        &r.name,
6031                        "slo.latency_target",
6032                        format!(
6033                            "latency_target {t} is out of range: a percentage in \
6034                             (0, 100]"
6035                        ),
6036                    ));
6037                }
6038            }
6039            if slo.latency_target.is_some() && slo.latency_ms.is_none() {
6040                issues.push(issue(
6041                    "route",
6042                    &r.name,
6043                    "slo.latency_target",
6044                    "latency_target without latency_ms: the objective has no \
6045                     threshold to measure against",
6046                ));
6047            }
6048        }
6049        // Media-type criterion (DW-048): the shared grammar in
6050        // config::versioning is the whole check — a value that is not a
6051        // bare type/subtype can never match and is an authoring error.
6052        if let Some(accept) = &r.r#match.accept {
6053            if crate::config::versioning::normalize_media_type(accept).is_none() {
6054                issues.push(issue(
6055                    "route",
6056                    &r.name,
6057                    "match.accept",
6058                    format!(
6059                        "'{accept}' is not a bare media type: use type/subtype like \
6060                         'application/vnd.acme.v2+json' (parameters and wildcards are not \
6061                         supported; wildcards never match a versioned route)"
6062                    ),
6063                ));
6064            }
6065        }
6066        // WebSocket policy (DW-039): origins must be comparable header
6067        // values (the gate is an exact string match, so an entry that
6068        // could never appear in an Origin header is an authoring
6069        // error), and the frame allowance lives under the same ceiling
6070        // as every other rate-shaped knob.
6071        if let Some(ws) = &r.websocket {
6072            for (i, origin) in ws.origins.iter().enumerate() {
6073                if origin.is_empty() {
6074                    issues.push(issue(
6075                        "route",
6076                        &r.name,
6077                        &format!("websocket.origins[{i}]"),
6078                        "origin is empty: an allowlist entry must name an origin \
6079                         (e.g. https://app.example.com, or the literal null)",
6080                    ));
6081                } else if origin.len() > 256 || !origin.bytes().all(|b| (0x20..0x7f).contains(&b)) {
6082                    issues.push(issue(
6083                        "route",
6084                        &r.name,
6085                        &format!("websocket.origins[{i}]"),
6086                        "origin must be printable ASCII at most 256 bytes",
6087                    ));
6088                }
6089            }
6090            if let Some(rate) = ws.max_frames_per_sec {
6091                if rate == 0 || rate > crate::config::limits::MAX_WEBSOCKET_FRAMES_PER_SEC {
6092                    issues.push(issue(
6093                        "route",
6094                        &r.name,
6095                        "websocket.max_frames_per_sec",
6096                        format!(
6097                            "max_frames_per_sec must be in 1..={} (sustained data \
6098                             frames per second, one-second burst)",
6099                            crate::config::limits::MAX_WEBSOCKET_FRAMES_PER_SEC
6100                        ),
6101                    ));
6102                }
6103            }
6104            // DP-05 (#253): idle timeout 1s..=86400s, frame size 1..=16MiB.
6105            if let Some(idle) = ws.idle_timeout_s {
6106                if idle == 0 || idle > 86400 {
6107                    issues.push(issue(
6108                        "route",
6109                        &r.name,
6110                        "websocket.idle_timeout_s",
6111                        "idle_timeout_s must be in 1..=86400 (seconds, 1s to 24h)",
6112                    ));
6113                }
6114            }
6115            if let Some(sz) = ws.max_frame_size_bytes {
6116                if sz == 0 || sz > 16_777_216 {
6117                    issues.push(issue(
6118                        "route",
6119                        &r.name,
6120                        "websocket.max_frame_size_bytes",
6121                        "max_frame_size_bytes must be in 1..=16777216 (1 byte to 16 MiB)",
6122                    ));
6123                }
6124            }
6125        }
6126        // WAF-lite (DW-051): when enabled, the filter list must be
6127        // either absent (defaults to all three) or a non-empty list of
6128        // known category names, the body inspection cap must be 0 or
6129        // within 1..=1048576, and every custom pattern must compile as
6130        // a valid regex (an invalid regex is an authoring error that
6131        // would panic at request time if it reached the runtime).
6132        if let Some(waf) = &r.waf {
6133            if waf.enabled {
6134                for (i, f) in waf.filters.iter().enumerate() {
6135                    if !matches!(f.as_str(), "sqli" | "xss" | "path_traversal") {
6136                        issues.push(issue(
6137                            "route",
6138                            &r.name,
6139                            &format!("waf.filters[{i}]"),
6140                            format!(
6141                                "unknown filter category '{f}': must be one of \
6142                                 sqli, xss, path_traversal (or omit the list for \
6143                                 all three)"
6144                            ),
6145                        ));
6146                    }
6147                }
6148            }
6149            if waf.max_body_inspect_bytes > 1_048_576 {
6150                issues.push(issue(
6151                    "route",
6152                    &r.name,
6153                    "waf.max_body_inspect_bytes",
6154                    format!(
6155                        "max_body_inspect_bytes {} is out of range: must be 0 \
6156                         (no body inspection) or 1..=1048576 (1 MiB)",
6157                        waf.max_body_inspect_bytes
6158                    ),
6159                ));
6160            }
6161            for (i, pat) in waf.custom_patterns.iter().enumerate() {
6162                if regex::Regex::new(pat).is_err() {
6163                    issues.push(issue(
6164                        "route",
6165                        &r.name,
6166                        &format!("waf.custom_patterns[{i}]"),
6167                        format!("invalid regex pattern: '{pat}'"),
6168                    ));
6169                }
6170            }
6171        }
6172        // GraphQL awareness (DW-099): when a route has a `graphql`
6173        // block, validate that depth_limit and complexity_limit are
6174        // > 0 (a 0 limit would reject every query or none, neither
6175        // is a useful configuration), and that persisted-query
6176        // hashes are non-empty strings. The block is compiled into
6177        // every build (no cargo feature), so
6178        // validation runs unconditionally -- the schema is always
6179        // present.
6180        if let Some(graphql) = &r.graphql {
6181            if graphql.enabled {
6182                if graphql.depth_limit == 0 {
6183                    issues.push(issue(
6184                        "route",
6185                        &r.name,
6186                        "graphql.depth_limit",
6187                        "depth_limit must be > 0 when graphql is enabled",
6188                    ));
6189                }
6190                if graphql.complexity_limit == 0 {
6191                    issues.push(issue(
6192                        "route",
6193                        &r.name,
6194                        "graphql.complexity_limit",
6195                        "complexity_limit must be > 0 when graphql is enabled",
6196                    ));
6197                }
6198                if graphql.complexity_coefficient == 0 {
6199                    issues.push(issue(
6200                        "route",
6201                        &r.name,
6202                        "graphql.complexity_coefficient",
6203                        "complexity_coefficient must be > 0 (use 1 for \
6204                         uniform cost; 0 would make every query free)",
6205                    ));
6206                }
6207            }
6208            if let Some(pq) = &graphql.persisted_queries {
6209                if pq.enabled {
6210                    for (hash, query) in &pq.store {
6211                        if hash.trim().is_empty() {
6212                            issues.push(issue(
6213                                "route",
6214                                &r.name,
6215                                "graphql.persisted_queries.store",
6216                                "persisted query hash must be a non-empty \
6217                                 string",
6218                            ));
6219                        }
6220                        if query.trim().is_empty() {
6221                            issues.push(issue(
6222                                "route",
6223                                &r.name,
6224                                "graphql.persisted_queries.store",
6225                                "persisted query text must be a non-empty \
6226                                 string",
6227                            ));
6228                        }
6229                    }
6230                }
6231            }
6232        }
6233        // gRPC-Web framing + JSON-to-gRPC transcoding (DW-101): when a
6234        // route has a `grpc_web` block with `enabled: true`, validate
6235        // the transcoding sub-block. When transcoding is enabled, at
6236        // least one descriptor must be present and every descriptor
6237        // file must exist and be readable at validation time (a missing
6238        // or unreadable descriptor would otherwise publish fine and
6239        // fail every transcoded request at runtime). The config schema
6240        // is always present (the block parses and validates in every
6241        // build; no cargo feature), so validation runs
6242        // unconditionally; descriptor files are checked only when the
6243        // block is enabled so a staged-but-inert config does not fail.
6244        if let Some(grpc_web) = &r.grpc_web {
6245            if grpc_web.enabled {
6246                if let Some(tc) = &grpc_web.transcoding {
6247                    if tc.enabled {
6248                        if tc.descriptors.is_empty() {
6249                            issues.push(issue(
6250                                "route",
6251                                &r.name,
6252                                "grpc_web.transcoding.descriptors",
6253                                "transcoding is enabled but no descriptors \
6254                                 are listed: at least one descriptor is \
6255                                 required",
6256                            ));
6257                        }
6258                        for (i, desc) in tc.descriptors.iter().enumerate() {
6259                            check_file_readable(
6260                                "route",
6261                                &r.name,
6262                                &format!("grpc_web.transcoding.descriptors[{i}].file"),
6263                                &desc.file,
6264                                &mut issues,
6265                            );
6266                        }
6267                    }
6268                }
6269            }
6270        }
6271        // Protocol translation (DW-100): when a route has a
6272        // `translation` block, validate that the referenced kind has
6273        // the required sub-block. A `rest_to_graphql` or
6274        // `graphql_to_rest` kind requires a `graphql` block with a
6275        // `query_template` (the reverse direction only unwraps the
6276        // response envelope, so the template is required only for
6277        // `rest_to_graphql`). A `rest_to_soap` or `soap_to_rest` kind
6278        // requires a `soap` block with an `operation` name, and
6279        // `rest_to_soap` additionally requires a `namespace`. The
6280        // config schema is always present (the block parses and
6281        // validates in every build; no cargo feature), so validation
6282        // runs unconditionally; the runtime translation is scaffolded
6283        // (not yet dispatched from the request path).
6284        if let Some(translation) = &r.translation {
6285            match translation.kind {
6286                TranslationKind::RestToGraphql => match &translation.graphql {
6287                    None => {
6288                        issues.push(issue(
6289                            "route",
6290                            &r.name,
6291                            "translation.graphql",
6292                            "kind rest_to_graphql requires a graphql block",
6293                        ));
6294                    }
6295                    Some(graphql) => {
6296                        if graphql.query_template.trim().is_empty() {
6297                            issues.push(issue(
6298                                "route",
6299                                &r.name,
6300                                "translation.graphql.query_template",
6301                                "rest_to_graphql requires a query_template",
6302                            ));
6303                        }
6304                    }
6305                },
6306                TranslationKind::GraphqlToRest => {
6307                    if translation.graphql.is_none() {
6308                        issues.push(issue(
6309                            "route",
6310                            &r.name,
6311                            "translation.graphql",
6312                            "kind graphql_to_rest requires a graphql block",
6313                        ));
6314                    }
6315                }
6316                TranslationKind::RestToSoap | TranslationKind::SoapToRest => {
6317                    match &translation.soap {
6318                        None => {
6319                            issues.push(issue(
6320                                "route",
6321                                &r.name,
6322                                "translation.soap",
6323                                "kind requires a soap block",
6324                            ));
6325                        }
6326                        Some(soap) => {
6327                            if soap.operation.trim().is_empty() {
6328                                issues.push(issue(
6329                                    "route",
6330                                    &r.name,
6331                                    "translation.soap.operation",
6332                                    "soap translation requires an operation name",
6333                                ));
6334                            }
6335                            if translation.kind == TranslationKind::RestToSoap
6336                                && soap.namespace.trim().is_empty()
6337                            {
6338                                issues.push(issue(
6339                                    "route",
6340                                    &r.name,
6341                                    "translation.soap.namespace",
6342                                    "rest_to_soap requires a namespace",
6343                                ));
6344                            }
6345                        }
6346                    }
6347                }
6348            }
6349        }
6350        let m = &r.r#match.path;
6351        for (field, entries) in [
6352            ("match.query", &r.r#match.query),
6353            ("match.cookies", &r.r#match.cookies),
6354        ] {
6355            for e in entries.iter() {
6356                if e.name.trim().is_empty() {
6357                    issues.push(issue("route", &r.name, field, "matcher name is empty"));
6358                }
6359                if let Some(v) = &e.value {
6360                    if v.is_empty() {
6361                        issues.push(issue(
6362                            "route",
6363                            &r.name,
6364                            field,
6365                            format!("matcher '{}' carries an empty value; omit value for presence-only matching", e.name),
6366                        ));
6367                    }
6368                }
6369            }
6370        }
6371        if !m.value.starts_with('/') {
6372            issues.push(issue(
6373                "route",
6374                &r.name,
6375                "match.path.value",
6376                format!("path pattern '{}' must start with '/'", m.value),
6377            ));
6378        }
6379        if m.kind == PathMatchKind::Prefix && m.value.trim_end_matches('/').is_empty() {
6380            issues.push(issue(
6381                "route",
6382                &r.name,
6383                "match.path.value",
6384                format!(
6385                    "prefix '{}' would match every path (it trims to the empty string); \
6386                     use a regex route or an explicit catch-all route instead",
6387                    m.value
6388                ),
6389            ));
6390        }
6391        match r.action {
6392            RouteAction::Redirect {
6393                status,
6394                ref scheme,
6395                ref host,
6396                path: ref redirect_path,
6397            } => {
6398                if !(300..=399).contains(&status) {
6399                    issues.push(issue(
6400                        "route",
6401                        &r.name,
6402                        "action.status",
6403                        format!("redirect status {status} is not a 3xx redirect"),
6404                    ));
6405                }
6406                // Location is built into a HeaderValue at request time; a
6407                // hostile config must fail HERE, not panic the dataplane.
6408                if let Some(s) = scheme {
6409                    if !s.eq_ignore_ascii_case("http") && !s.eq_ignore_ascii_case("https") {
6410                        issues.push(issue(
6411                            "route",
6412                            &r.name,
6413                            "action.scheme",
6414                            format!("redirect scheme '{s}' must be http or https"),
6415                        ));
6416                    }
6417                }
6418                if let Some(h) = host {
6419                    if h.is_empty() || !h.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
6420                        issues.push(issue(
6421                            "route",
6422                            &r.name,
6423                            "action.host",
6424                            format!(
6425                                "redirect host '{h}' contains whitespace or control \
6426                                 characters and cannot form a Location header"
6427                            ),
6428                        ));
6429                    }
6430                }
6431                if let Some(p) = redirect_path {
6432                    if !p.is_empty() && !p.starts_with('/') {
6433                        issues.push(issue(
6434                            "route",
6435                            &r.name,
6436                            "action.path",
6437                            format!("redirect path '{p}' must start with '/' or be empty"),
6438                        ));
6439                    }
6440                    if !p.bytes().all(|b| (0x20..=0x7e).contains(&b)) {
6441                        issues.push(issue(
6442                            "route",
6443                            &r.name,
6444                            "action.path",
6445                            format!(
6446                                "redirect path '{p}' contains control characters and \
6447                                 cannot form a Location header"
6448                            ),
6449                        ));
6450                    }
6451                }
6452            }
6453            RouteAction::Respond {
6454                status,
6455                ref headers,
6456                ..
6457            } => {
6458                if !(100..=599).contains(&status) {
6459                    issues.push(issue(
6460                        "route",
6461                        &r.name,
6462                        "action.status",
6463                        format!("respond status {status} is not a valid HTTP status"),
6464                    ));
6465                }
6466                for (name, value) in headers {
6467                    if hyper::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
6468                        issues.push(issue(
6469                            "route",
6470                            &r.name,
6471                            "action.headers",
6472                            format!("respond header name '{name}' is not a valid HTTP header name"),
6473                        ));
6474                    }
6475                    if hyper::header::HeaderValue::from_str(value.as_str()).is_err() {
6476                        issues.push(issue(
6477                            "route",
6478                            &r.name,
6479                            "action.headers",
6480                            format!(
6481                                "respond header value for '{name}' contains characters that \
6482                                 cannot appear in an HTTP header value"
6483                            ),
6484                        ));
6485                    }
6486                }
6487            }
6488            RouteAction::Proxy { rewrite: None } => {}
6489            RouteAction::Proxy {
6490                rewrite: Some(ref rw),
6491            } => validate_rewrite(&r.name, rw, &mut issues),
6492            RouteAction::Mock { ref mock } => validate_mock(&r.name, mock, &mut issues),
6493            RouteAction::Ai { .. } => {
6494                // The action needs the gateway-level alias table; a
6495                // route declaring `ai` without one answers 500s for
6496                // every request and is an authoring error caught here.
6497                if gateway.ai.is_none() {
6498                    issues.push(issue(
6499                        "route",
6500                        &r.name,
6501                        "action.type",
6502                        "an ai action requires a gateway-level ai block \
6503                         (ai.providers + ai.models define what it can serve)",
6504                    ));
6505                }
6506            }
6507            RouteAction::NanoService { ref nano } => {
6508                validate_nano_service(&r.name, nano, &mut issues);
6509            }
6510        }
6511        // Request validation (DW-047): the JSON schema must be
6512        // well-formed (known type names, non-negative bounds). An
6513        // invalid schema is an authoring error that would surface at
6514        // request time otherwise.
6515        if let Some(rv) = &r.request_validation {
6516            validate_json_schema(
6517                &r.name,
6518                "request_validation.body_schema",
6519                &rv.body_schema,
6520                &mut issues,
6521            );
6522        }
6523        // DW-062: mirror validation.
6524        if let Some(m) = &r.mirror {
6525            if m.percentage > 100 {
6526                issues.push(issue(
6527                    "route",
6528                    &r.name,
6529                    "mirror.percentage",
6530                    "percentage must be in [0, 100]",
6531                ));
6532            }
6533            if m.upstream.trim().is_empty() {
6534                issues.push(issue(
6535                    "route",
6536                    &r.name,
6537                    "mirror.upstream",
6538                    "mirror upstream name must not be empty",
6539                ));
6540            } else if !gateway.upstreams.iter().any(|u| u.name == m.upstream) {
6541                issues.push(issue(
6542                    "route",
6543                    &r.name,
6544                    "mirror.upstream",
6545                    format!("mirror upstream '{}' not found in upstreams", m.upstream),
6546                ));
6547            }
6548        }
6549        // DW-062: fault injection validation.
6550        if let Some(fi) = &r.fault_injection {
6551            if let Some(a) = &fi.abort {
6552                if a.percentage > 100 {
6553                    issues.push(issue(
6554                        "route",
6555                        &r.name,
6556                        "fault_injection.abort.percentage",
6557                        "percentage must be in [0, 100]",
6558                    ));
6559                }
6560                if a.status < crate::config::limits::MIN_FAULT_ABORT_STATUS
6561                    || a.status > crate::config::limits::MAX_FAULT_ABORT_STATUS
6562                {
6563                    issues.push(issue(
6564                        "route",
6565                        &r.name,
6566                        "fault_injection.abort.status",
6567                        format!(
6568                            "status must be in [{}, {}]",
6569                            crate::config::limits::MIN_FAULT_ABORT_STATUS,
6570                            crate::config::limits::MAX_FAULT_ABORT_STATUS
6571                        ),
6572                    ));
6573                }
6574            }
6575            if let Some(d) = &fi.delay {
6576                if d.percentage > 100 {
6577                    issues.push(issue(
6578                        "route",
6579                        &r.name,
6580                        "fault_injection.delay.percentage",
6581                        "percentage must be in [0, 100]",
6582                    ));
6583                }
6584                if d.fixed_ms == 0 || d.fixed_ms > crate::config::limits::MAX_FAULT_DELAY_MS {
6585                    issues.push(issue(
6586                        "route",
6587                        &r.name,
6588                        "fault_injection.delay.fixed_ms",
6589                        format!(
6590                            "fixed_ms must be in [1, {}]",
6591                            crate::config::limits::MAX_FAULT_DELAY_MS
6592                        ),
6593                    ));
6594                }
6595            }
6596            // An empty fault_injection block (no abort, no delay) is an
6597            // authoring mistake — reject it.
6598            if fi.abort.is_none() && fi.delay.is_none() {
6599                issues.push(issue(
6600                    "route",
6601                    &r.name,
6602                    "fault_injection",
6603                    "fault_injection block must have at least one of abort or delay",
6604                ));
6605            }
6606        }
6607        // SEC-05: OIDC browser login validation. The provider must
6608        // exist in gateway.oidc_providers; the redirect_uri must be a
6609        // non-empty path starting with `/`.
6610        if let Some(oidc_login) = &r.oidc_login {
6611            if oidc_login.provider.is_empty() {
6612                issues.push(issue(
6613                    "route",
6614                    &r.name,
6615                    "oidc_login.provider",
6616                    "oidc_login provider must not be empty",
6617                ));
6618            } else if !gateway
6619                .oidc_providers
6620                .iter()
6621                .any(|p| p.name == oidc_login.provider)
6622            {
6623                issues.push(issue(
6624                    "route",
6625                    &r.name,
6626                    "oidc_login.provider",
6627                    format!(
6628                        "oidc_login provider '{}' not found in gateway.oidc_providers",
6629                        oidc_login.provider
6630                    ),
6631                ));
6632            }
6633            if !oidc_login.redirect_uri.starts_with('/') {
6634                issues.push(issue(
6635                    "route",
6636                    &r.name,
6637                    "oidc_login.redirect_uri",
6638                    "oidc_login redirect_uri must be a path starting with '/'",
6639                ));
6640            }
6641            if oidc_login.session_ttl_s == 0 {
6642                issues.push(issue(
6643                    "route",
6644                    &r.name,
6645                    "oidc_login.session_ttl_s",
6646                    "oidc_login session_ttl_s must be > 0",
6647                ));
6648            }
6649        }
6650    }
6651
6652    for u in &gateway.upstreams {
6653        // DW-042: when dns_discovery is present, endpoints may be empty
6654        // (the gateway resolves them from DNS). The static-endpoints
6655        // guard only applies to upstreams without dynamic discovery.
6656        if u.endpoints.is_empty() && u.dns_discovery.is_none() {
6657            issues.push(issue(
6658                "upstream",
6659                &u.name,
6660                "endpoints",
6661                "upstream has no endpoints",
6662            ));
6663        }
6664        // DW-042: validate the dns_discovery block.
6665        if let Some(dns) = &u.dns_discovery {
6666            if dns.hostname.trim().is_empty() {
6667                issues.push(issue(
6668                    "upstream",
6669                    &u.name,
6670                    "dns_discovery.hostname",
6671                    "dns_discovery hostname is empty",
6672                ));
6673            }
6674            if dns.record_type != "A" && dns.record_type != "SRV" {
6675                issues.push(issue(
6676                    "upstream",
6677                    &u.name,
6678                    "dns_discovery.record_type",
6679                    "dns_discovery record_type must be \"A\" or \"SRV\"",
6680                ));
6681            }
6682            if dns.refresh_interval_s < crate::config::limits::MIN_DNS_DISCOVERY_REFRESH_S
6683                || dns.refresh_interval_s > crate::config::limits::MAX_DNS_DISCOVERY_REFRESH_S
6684            {
6685                issues.push(issue(
6686                    "upstream",
6687                    &u.name,
6688                    "dns_discovery.refresh_interval_s",
6689                    format!(
6690                        "dns_discovery refresh_interval_s must be in {}..={}",
6691                        crate::config::limits::MIN_DNS_DISCOVERY_REFRESH_S,
6692                        crate::config::limits::MAX_DNS_DISCOVERY_REFRESH_S
6693                    ),
6694                ));
6695            }
6696        }
6697        // trusted_ca_file (#121): the connector's TLS trust override for
6698        // private-CA upstreams. It only applies to the TLS protocols —
6699        // no TLS is negotiated toward an `http1` upstream, so there is
6700        // nothing to verify and the field is an authoring mistake (the
6701        // same reading as a listener's "protocol http must not carry a
6702        // tls block"). When it does apply, the bundle must be on disk
6703        // and readable at compile time.
6704        if let Some(ca) = &u.trusted_ca_file {
6705            let tls = matches!(
6706                u.protocol,
6707                crate::config::UpstreamProtocol::Https | crate::config::UpstreamProtocol::Http2
6708            );
6709            if tls {
6710                check_trusted_ca_file("upstream", &u.name, "trusted_ca_file", ca, &mut issues);
6711            } else {
6712                issues.push(issue(
6713                    "upstream",
6714                    &u.name,
6715                    "trusted_ca_file",
6716                    "trusted_ca_file only applies to TLS upstreams (protocol https or http2); no \
6717                     TLS is negotiated toward an http1 upstream",
6718                ));
6719            }
6720        }
6721        // REL-08 (#219): use_system_roots is the OS-native trust store
6722        // alternative to the webpki default. It only applies to the TLS
6723        // protocols, and is mutually exclusive with trusted_ca_file
6724        // (both select a trust set; setting both is an authoring
6725        // mistake — the operator must pick one).
6726        if u.use_system_roots {
6727            let tls = matches!(
6728                u.protocol,
6729                crate::config::UpstreamProtocol::Https | crate::config::UpstreamProtocol::Http2
6730            );
6731            if !tls {
6732                issues.push(issue(
6733                    "upstream",
6734                    &u.name,
6735                    "use_system_roots",
6736                    "use_system_roots only applies to TLS upstreams (protocol https or http2); no \
6737                     TLS is negotiated toward an http1 upstream",
6738                ));
6739            }
6740            if u.trusted_ca_file.is_some() {
6741                issues.push(issue(
6742                    "upstream",
6743                    &u.name,
6744                    "use_system_roots",
6745                    "use_system_roots and trusted_ca_file are mutually exclusive; pick one trust \
6746                     source per upstream",
6747                ));
6748            }
6749        }
6750        // SEC-04 / DW-109: cert_pinning validation. The block is always
6751        // accepted by the parser (additive-only); validation enforces:
6752        // (1) only on TLS upstreams, (2) non-empty pin list, (3) each
6753        // pin is 64 lowercase hex chars. (Compiled into every build;
6754        // no cargo feature.)
6755        if let Some(cp) = &u.cert_pinning {
6756            let tls = matches!(
6757                u.protocol,
6758                crate::config::UpstreamProtocol::Https | crate::config::UpstreamProtocol::Http2
6759            );
6760            if !tls {
6761                issues.push(issue(
6762                    "upstream",
6763                    &u.name,
6764                    "cert_pinning",
6765                    "cert_pinning only applies to TLS upstreams (protocol https or http2); no \
6766                     TLS is negotiated toward an http1 upstream",
6767                ));
6768            }
6769            if cp.pins.is_empty() {
6770                issues.push(issue(
6771                    "upstream",
6772                    &u.name,
6773                    "cert_pinning.pins",
6774                    "cert_pinning pins list must not be empty (a pin-less pinning block is an \
6775                     authoring mistake, not 'pin nothing')",
6776                ));
6777            }
6778            for (i, pin) in cp.pins.iter().enumerate() {
6779                let h = &pin.spki_sha256;
6780                if h.len() != 64
6781                    || !h
6782                        .chars()
6783                        .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
6784                {
6785                    issues.push(issue(
6786                        "upstream",
6787                        &u.name,
6788                        &format!("cert_pinning.pins[{i}].spki_sha256"),
6789                        "spki_sha256 must be 64 lowercase hex chars (SHA-256)",
6790                    ));
6791                }
6792            }
6793        }
6794        // SEC-03: upstream mTLS client cert validation. The block is
6795        // always accepted by the parser (additive-only); validation
6796        // enforces: (1) only on TLS upstreams, (2) cert/key files exist
6797        // and are readable at compile time.
6798        if let Some(mtls) = &u.mtls {
6799            let tls = matches!(
6800                u.protocol,
6801                crate::config::UpstreamProtocol::Https | crate::config::UpstreamProtocol::Http2
6802            );
6803            if !tls {
6804                issues.push(issue(
6805                    "upstream",
6806                    &u.name,
6807                    "mtls",
6808                    "mtls only applies to TLS upstreams (protocol https or http2); no \
6809                     TLS is negotiated toward an http1 upstream",
6810                ));
6811            } else {
6812                // Check that the cert and key files are readable.
6813                if let Err(e) = std::fs::read_to_string(&mtls.client_cert_file) {
6814                    issues.push(issue(
6815                        "upstream",
6816                        &u.name,
6817                        "mtls.client_cert_file",
6818                        format!(
6819                            "client_cert_file '{}' cannot be read: {e}",
6820                            mtls.client_cert_file
6821                        ),
6822                    ));
6823                }
6824                if let Err(e) = std::fs::read_to_string(&mtls.client_key_file) {
6825                    issues.push(issue(
6826                        "upstream",
6827                        &u.name,
6828                        "mtls.client_key_file",
6829                        format!(
6830                            "client_key_file '{}' cannot be read: {e}",
6831                            mtls.client_key_file
6832                        ),
6833                    ));
6834                }
6835            }
6836        }
6837        let mut seen_targets = std::collections::BTreeSet::new();
6838        let mut total_vnodes: u64 = 0;
6839        for (i, e) in u.endpoints.iter().enumerate() {
6840            if e.address.trim().is_empty() {
6841                issues.push(issue(
6842                    "upstream",
6843                    &u.name,
6844                    &format!("endpoints[{i}].address"),
6845                    "endpoint address is empty",
6846                ));
6847            }
6848            if e.weight == 0 {
6849                issues.push(issue(
6850                    "upstream",
6851                    &u.name,
6852                    &format!("endpoints[{i}].weight"),
6853                    "endpoint weight must be > 0",
6854                ));
6855            }
6856            // Duplicate address:port corrupts shared balancer state (both
6857            // entries carry to the same live counter; guards would
6858            // double-decrement). Reject at validation time. The target is
6859            // compared TRIMMED, exactly like the empty-address check
6860            // above: " 127.0.0.1" and "127.0.0.1" are the same endpoint
6861            // (#128, DW-011 review).
6862            if !seen_targets.insert((e.address.trim(), e.port)) {
6863                issues.push(issue(
6864                    "upstream",
6865                    &u.name,
6866                    &format!("endpoints[{i}].address"),
6867                    format!(
6868                        "duplicate endpoint {}/{}: address:port must be unique within an upstream",
6869                        e.address.trim(),
6870                        e.port
6871                    ),
6872                ));
6873            }
6874            total_vnodes += crate::config::limits::KETAMA_VNODES * e.weight.max(1) as u64;
6875        }
6876        if u.load_balancer == crate::config::LoadBalancer::IpHash
6877            && total_vnodes > crate::config::limits::MAX_RING_VNODES
6878        {
6879            issues.push(issue(
6880                "upstream",
6881                &u.name,
6882                "endpoints.weight",
6883                format!(
6884                    "ip_hash ring too large: total vnodes (160 * sum of weights) must be at most {}",
6885                    crate::config::limits::MAX_RING_VNODES
6886                ),
6887            ));
6888        }
6889        // DP-07 (#236): hash_on is only meaningful for consistent-hash
6890        // load balancers (ip_hash and maglev). A hash_on block on a
6891        // non-hash algorithm is an authoring mistake.
6892        if let Some(hash_on) = &u.hash_on {
6893            if !matches!(
6894                u.load_balancer,
6895                crate::config::LoadBalancer::IpHash | crate::config::LoadBalancer::Maglev
6896            ) {
6897                issues.push(issue(
6898                    "upstream",
6899                    &u.name,
6900                    "hash_on",
6901                    format!(
6902                        "hash_on only applies to ip_hash or maglev load balancers; \
6903                         '{}' does not use a hash key",
6904                        match u.load_balancer {
6905                            crate::config::LoadBalancer::RoundRobin => "round_robin",
6906                            crate::config::LoadBalancer::LeastRequests => "least_requests",
6907                            crate::config::LoadBalancer::Random => "random",
6908                            crate::config::LoadBalancer::PeakEwma => "peak_ewma",
6909                            _ => "this",
6910                        }
6911                    ),
6912                ));
6913            }
6914            // Validate cookie/header names are non-empty.
6915            match hash_on {
6916                crate::config::HashOn::Cookie { cookie } => {
6917                    if cookie.trim().is_empty() {
6918                        issues.push(issue(
6919                            "upstream",
6920                            &u.name,
6921                            "hash_on.cookie",
6922                            "hash_on cookie name must be a non-empty string",
6923                        ));
6924                    }
6925                }
6926                crate::config::HashOn::Header { header } => {
6927                    if header.trim().is_empty() {
6928                        issues.push(issue(
6929                            "upstream",
6930                            &u.name,
6931                            "hash_on.header",
6932                            "hash_on header name must be a non-empty string",
6933                        ));
6934                    }
6935                }
6936                _ => {}
6937            }
6938        }
6939        if u.connection_cap == Some(0) {
6940            issues.push(issue(
6941                "upstream",
6942                &u.name,
6943                "connection_cap",
6944                "connection_cap must be > 0",
6945            ));
6946        }
6947        if u.max_pending == Some(0) {
6948            issues.push(issue(
6949                "upstream",
6950                &u.name,
6951                "max_pending",
6952                "max_pending must be > 0 (unbounded is expressed by omitting \
6953                 the field, so an explicit 0 is rejected as ambiguous)",
6954            ));
6955        }
6956        if let Some(b) = &u.breaker {
6957            for (field, v) in [
6958                (
6959                    "breaker.consecutive_failures",
6960                    u64::from(b.consecutive_failures),
6961                ),
6962                ("breaker.error_volume", u64::from(b.error_volume)),
6963                ("breaker.open_ms", b.open_ms),
6964                ("breaker.half_open_probes", u64::from(b.half_open_probes)),
6965            ] {
6966                if v == 0 {
6967                    issues.push(issue(
6968                        "upstream",
6969                        &u.name,
6970                        field,
6971                        "circuit breaker value must be > 0",
6972                    ));
6973                }
6974            }
6975            if !b.error_ratio.is_finite() || b.error_ratio <= 0.0 || b.error_ratio > 1.0 {
6976                issues.push(issue(
6977                    "upstream",
6978                    &u.name,
6979                    "breaker.error_ratio",
6980                    format!("error_ratio must be in (0, 1]; got {}", b.error_ratio),
6981                ));
6982            }
6983        }
6984        if u.slow_start_ms
6985            .is_some_and(|ms| ms > crate::config::limits::MAX_SLOW_START_MS)
6986        {
6987            issues.push(issue(
6988                "upstream",
6989                &u.name,
6990                "slow_start_ms",
6991                "slow_start_ms must be at most 10 minutes (600000)",
6992            ));
6993        }
6994        if let Some(h) = &u.health {
6995            // Every knob must be positive; the ratio additionally must be a
6996            // real fraction (0, 1]. NaN/inf compare false everywhere and
6997            // would silently disable ejection, so reject them explicitly.
6998            for (field, v) in [
6999                ("health.window_ms", h.window_ms),
7000                ("health.eject_ms", h.eject_ms),
7001            ] {
7002                if v == 0 {
7003                    issues.push(issue(
7004                        "upstream",
7005                        &u.name,
7006                        field,
7007                        "passive health value must be > 0",
7008                    ));
7009                }
7010            }
7011            for (field, v) in [
7012                ("health.consecutive_failures", h.consecutive_failures),
7013                ("health.failure_min_volume", h.failure_min_volume),
7014                ("health.half_open_probes", h.half_open_probes),
7015            ] {
7016                if v == 0 {
7017                    issues.push(issue(
7018                        "upstream",
7019                        &u.name,
7020                        field,
7021                        "passive health value must be > 0",
7022                    ));
7023                }
7024            }
7025            if !h.failure_ratio.is_finite() || h.failure_ratio <= 0.0 || h.failure_ratio > 1.0 {
7026                issues.push(issue(
7027                    "upstream",
7028                    &u.name,
7029                    "health.failure_ratio",
7030                    format!("failure_ratio must be in (0, 1]; got {}", h.failure_ratio),
7031                ));
7032            }
7033        }
7034        if let Some(a) = &u.active_health {
7035            // Active probes report into the passive ejection machinery, so
7036            // the passive block (which owns eject/half-open windows) is a
7037            // hard requirement.
7038            if u.health.is_none() {
7039                issues.push(issue(
7040                    "upstream",
7041                    &u.name,
7042                    "active_health",
7043                    "active health requires the passive `health` block (probe results report \
7044                     into the same ejection machinery)",
7045                ));
7046            }
7047            for (field, v) in [
7048                ("active_health.interval_ms", a.interval_ms),
7049                ("active_health.timeout_ms", a.timeout_ms),
7050            ] {
7051                if v == 0 {
7052                    issues.push(issue(
7053                        "upstream",
7054                        &u.name,
7055                        field,
7056                        "active health value must be > 0",
7057                    ));
7058                }
7059            }
7060            // jitter_ms == 0 is legal: it disables jitter entirely.
7061            for (field, v) in [
7062                ("active_health.success_threshold", a.success_threshold),
7063                ("active_health.failure_threshold", a.failure_threshold),
7064            ] {
7065                if v == 0 {
7066                    issues.push(issue(
7067                        "upstream",
7068                        &u.name,
7069                        field,
7070                        "active health threshold must be > 0",
7071                    ));
7072                }
7073            }
7074            if a.timeout_ms > a.interval_ms {
7075                issues.push(issue(
7076                    "upstream",
7077                    &u.name,
7078                    "active_health.timeout_ms",
7079                    "timeout_ms must be <= interval_ms (an overlapping probe would pile up)",
7080                ));
7081            }
7082            if a.jitter_ms > a.interval_ms {
7083                issues.push(issue(
7084                    "upstream",
7085                    &u.name,
7086                    "active_health.jitter_ms",
7087                    "jitter_ms must be <= interval_ms",
7088                ));
7089            }
7090            if a.kind == crate::config::ProbeKind::Http
7091                && (!a.path.starts_with('/') || a.path.bytes().any(|b| b.is_ascii_control()))
7092            {
7093                issues.push(issue(
7094                    "upstream",
7095                    &u.name,
7096                    "active_health.path",
7097                    format!(
7098                        "probe path '{}' must start with '/' and contain no control characters",
7099                        a.path
7100                    ),
7101                ));
7102            }
7103        }
7104        if let Some(t) = &u.timeouts {
7105            for (field, v) in [
7106                ("connect_ms", t.connect_ms),
7107                ("read_ms", t.read_ms),
7108                ("write_ms", t.write_ms),
7109            ] {
7110                if v == Some(0) {
7111                    issues.push(issue(
7112                        "upstream",
7113                        &u.name,
7114                        &format!("timeouts.{field}"),
7115                        "timeout must be > 0 milliseconds",
7116                    ));
7117                }
7118            }
7119            // DW-030: 0 is LEGAL here (it disables happy-eyeballs racing
7120            // instead of unbounding it — the house zero-disables pattern);
7121            // only the upper bound applies.
7122            if t.happy_eyeballs_ms
7123                .is_some_and(|ms| ms > crate::config::limits::MAX_HAPPY_EYEBALLS_MS)
7124            {
7125                issues.push(issue(
7126                    "upstream",
7127                    &u.name,
7128                    "timeouts.happy_eyeballs_ms",
7129                    format!(
7130                        "happy_eyeballs_ms must be at most {} (0 disables racing)",
7131                        crate::config::limits::MAX_HAPPY_EYEBALLS_MS
7132                    ),
7133                ));
7134            }
7135        }
7136        // DP-03: hyper connection-pool and HTTP/2 tuning bounds. Every
7137        // field is optional; a present value must be within bounds. The
7138        // h2-only knobs are accepted on any protocol (the runtime ignores
7139        // them for non-h2 upstreams), so validation only checks bounds —
7140        // protocol gating is a runtime concern, not an authoring error.
7141        if let Some(p) = &u.pool {
7142            if let Some(ms) = p.pool_idle_timeout_ms {
7143                if ms == 0 {
7144                    issues.push(issue(
7145                        "upstream",
7146                        &u.name,
7147                        "pool.pool_idle_timeout_ms",
7148                        "pool_idle_timeout_ms must be > 0 (omit the field for the hyper default)",
7149                    ));
7150                } else if ms > crate::config::limits::MAX_POOL_IDLE_TIMEOUT_MS {
7151                    issues.push(issue(
7152                        "upstream",
7153                        &u.name,
7154                        "pool.pool_idle_timeout_ms",
7155                        format!(
7156                            "pool_idle_timeout_ms must be at most {}",
7157                            crate::config::limits::MAX_POOL_IDLE_TIMEOUT_MS
7158                        ),
7159                    ));
7160                }
7161            }
7162            if let Some(n) = p.pool_max_idle_per_host {
7163                if n > crate::config::limits::MAX_POOL_MAX_IDLE_PER_HOST {
7164                    issues.push(issue(
7165                        "upstream",
7166                        &u.name,
7167                        "pool.pool_max_idle_per_host",
7168                        format!(
7169                            "pool_max_idle_per_host must be at most {}",
7170                            crate::config::limits::MAX_POOL_MAX_IDLE_PER_HOST
7171                        ),
7172                    ));
7173                }
7174            }
7175            if let Some(ms) = p.http2_keep_alive_interval_ms {
7176                if ms == 0 {
7177                    issues.push(issue(
7178                        "upstream",
7179                        &u.name,
7180                        "pool.http2_keep_alive_interval_ms",
7181                        "http2_keep_alive_interval_ms must be > 0 (omit the field to disable PINGs)",
7182                    ));
7183                } else if ms > crate::config::limits::MAX_HTTP2_KEEP_ALIVE_INTERVAL_MS {
7184                    issues.push(issue(
7185                        "upstream",
7186                        &u.name,
7187                        "pool.http2_keep_alive_interval_ms",
7188                        format!(
7189                            "http2_keep_alive_interval_ms must be at most {}",
7190                            crate::config::limits::MAX_HTTP2_KEEP_ALIVE_INTERVAL_MS
7191                        ),
7192                    ));
7193                }
7194            }
7195            if let Some(ms) = p.http2_keep_alive_timeout_ms {
7196                if ms == 0 {
7197                    issues.push(issue(
7198                        "upstream",
7199                        &u.name,
7200                        "pool.http2_keep_alive_timeout_ms",
7201                        "http2_keep_alive_timeout_ms must be > 0 (omit the field for the hyper default)",
7202                    ));
7203                } else if ms > crate::config::limits::MAX_HTTP2_KEEP_ALIVE_TIMEOUT_MS {
7204                    issues.push(issue(
7205                        "upstream",
7206                        &u.name,
7207                        "pool.http2_keep_alive_timeout_ms",
7208                        format!(
7209                            "http2_keep_alive_timeout_ms must be at most {}",
7210                            crate::config::limits::MAX_HTTP2_KEEP_ALIVE_TIMEOUT_MS
7211                        ),
7212                    ));
7213                }
7214            }
7215            if let Some(n) = p.max_concurrent_streams {
7216                if n == 0 {
7217                    issues.push(issue(
7218                        "upstream",
7219                        &u.name,
7220                        "pool.max_concurrent_streams",
7221                        "max_concurrent_streams must be > 0 (omit the field for the hyper default)",
7222                    ));
7223                } else if n > crate::config::limits::MAX_POOL_MAX_CONCURRENT_STREAMS {
7224                    issues.push(issue(
7225                        "upstream",
7226                        &u.name,
7227                        "pool.max_concurrent_streams",
7228                        format!(
7229                            "max_concurrent_streams must be at most {}",
7230                            crate::config::limits::MAX_POOL_MAX_CONCURRENT_STREAMS
7231                        ),
7232                    ));
7233                }
7234            }
7235            // SCALE-10 (#189): pre-warm, per-endpoint cap, max-age.
7236            if let Some(n) = p.pre_warm {
7237                if n > 64 {
7238                    issues.push(issue(
7239                        "upstream",
7240                        &u.name,
7241                        "pool.pre_warm",
7242                        "pre_warm must be at most 64 per endpoint",
7243                    ));
7244                }
7245            }
7246            if let Some(n) = p.per_endpoint_cap {
7247                if n == 0 {
7248                    issues.push(issue(
7249                        "upstream",
7250                        &u.name,
7251                        "pool.per_endpoint_cap",
7252                        "per_endpoint_cap must be > 0 (omit the field for no per-endpoint cap)",
7253                    ));
7254                } else if n > 1024 {
7255                    issues.push(issue(
7256                        "upstream",
7257                        &u.name,
7258                        "pool.per_endpoint_cap",
7259                        "per_endpoint_cap must be at most 1024",
7260                    ));
7261                }
7262            }
7263            if let Some(ms) = p.max_connection_age_ms {
7264                if ms == 0 {
7265                    issues.push(issue(
7266                        "upstream",
7267                        &u.name,
7268                        "pool.max_connection_age_ms",
7269                        "max_connection_age_ms must be > 0 (omit the field for no max-age)",
7270                    ));
7271                } else if ms > crate::config::limits::MAX_POOL_IDLE_TIMEOUT_MS {
7272                    issues.push(issue(
7273                        "upstream",
7274                        &u.name,
7275                        "pool.max_connection_age_ms",
7276                        format!(
7277                            "max_connection_age_ms must be at most {}",
7278                            crate::config::limits::MAX_POOL_IDLE_TIMEOUT_MS
7279                        ),
7280                    ));
7281                }
7282            }
7283        }
7284        if let Some(r) = &u.retries {
7285            if r.attempts > crate::config::limits::MAX_RETRY_ATTEMPTS {
7286                issues.push(issue(
7287                    "upstream",
7288                    &u.name,
7289                    "retries.attempts",
7290                    format!(
7291                        "retries.attempts must be at most {} (retries beyond the first attempt)",
7292                        crate::config::limits::MAX_RETRY_ATTEMPTS
7293                    ),
7294                ));
7295            }
7296            if r.backoff_base_ms == 0 {
7297                issues.push(issue(
7298                    "upstream",
7299                    &u.name,
7300                    "retries.backoff_base_ms",
7301                    "backoff_base_ms must be > 0",
7302                ));
7303            }
7304            if r.backoff_cap_ms < r.backoff_base_ms {
7305                issues.push(issue(
7306                    "upstream",
7307                    &u.name,
7308                    "retries.backoff_cap_ms",
7309                    "backoff_cap_ms must be >= backoff_base_ms",
7310                ));
7311            }
7312            if r.budget_percent == 0 || r.budget_percent > 100 {
7313                issues.push(issue(
7314                    "upstream",
7315                    &u.name,
7316                    "retries.budget_percent",
7317                    "budget_percent must be in (0, 100]",
7318                ));
7319            }
7320            // REL-01: cross-attempt total retry deadline (wall-clock cap
7321            // including backoff). 0 is rejected (omit the field for
7322            // unbounded); a present value must be within the bound.
7323            if let Some(td) = r.total_deadline_ms {
7324                if td == 0 {
7325                    issues.push(issue(
7326                        "upstream",
7327                        &u.name,
7328                        "retries.total_deadline_ms",
7329                        "total_deadline_ms must be > 0 (omit the field for unbounded)",
7330                    ));
7331                } else if td > crate::config::limits::MAX_RETRY_TOTAL_DEADLINE_MS {
7332                    issues.push(issue(
7333                        "upstream",
7334                        &u.name,
7335                        "retries.total_deadline_ms",
7336                        format!(
7337                            "total_deadline_ms must be at most {}",
7338                            crate::config::limits::MAX_RETRY_TOTAL_DEADLINE_MS
7339                        ),
7340                    ));
7341                }
7342            }
7343            for (i, status) in r.retry_statuses.iter().enumerate() {
7344                if !(400..=599).contains(status) {
7345                    issues.push(issue(
7346                        "upstream",
7347                        &u.name,
7348                        &format!("retries.retry_statuses[{i}]"),
7349                        format!("retry status {status} is not a 4xx/5xx status"),
7350                    ));
7351                }
7352            }
7353            // DW-063: hedge validation.
7354            if let Some(h) = &r.hedge {
7355                if h.hedge_after_ms < crate::config::limits::MIN_HEDGE_AFTER_MS
7356                    || h.hedge_after_ms > crate::config::limits::MAX_HEDGE_AFTER_MS
7357                {
7358                    issues.push(issue(
7359                        "upstream",
7360                        &u.name,
7361                        "retries.hedge.hedge_after_ms",
7362                        format!(
7363                            "hedge_after_ms must be in [{}, {}]",
7364                            crate::config::limits::MIN_HEDGE_AFTER_MS,
7365                            crate::config::limits::MAX_HEDGE_AFTER_MS
7366                        ),
7367                    ));
7368                }
7369                if h.hedge_max == 0 || h.hedge_max > crate::config::limits::MAX_HEDGE_COPIES {
7370                    issues.push(issue(
7371                        "upstream",
7372                        &u.name,
7373                        "retries.hedge.hedge_max",
7374                        format!(
7375                            "hedge_max must be in [1, {}]",
7376                            crate::config::limits::MAX_HEDGE_COPIES
7377                        ),
7378                    ));
7379                }
7380                // Hedging requires a replayable body.
7381                if r.buffer_max_bytes == 0 {
7382                    issues.push(issue(
7383                        "upstream",
7384                        &u.name,
7385                        "retries.hedge",
7386                        "hedging requires retries.buffer_max_bytes > 0 (the body must be \
7387                         replayable to send a hedge copy)",
7388                    ));
7389                }
7390            }
7391        }
7392        // DW-035: OAuth2 client-credentials validation.
7393        if let Some(o) = &u.oauth2_client_credentials {
7394            // token_endpoint must be an absolute http(s) URL.
7395            match o.token_endpoint.parse::<hyper::Uri>() {
7396                Ok(uri) => {
7397                    if !matches!(uri.scheme_str(), Some("http") | Some("https"))
7398                        || uri.host().is_none()
7399                    {
7400                        issues.push(issue(
7401                            "upstream",
7402                            &u.name,
7403                            "oauth2_client_credentials.token_endpoint",
7404                            format!("'{}' must be an absolute http(s) URL", o.token_endpoint),
7405                        ));
7406                    }
7407                }
7408                Err(_) => issues.push(issue(
7409                    "upstream",
7410                    &u.name,
7411                    "oauth2_client_credentials.token_endpoint",
7412                    format!("'{}' is not a valid URL", o.token_endpoint),
7413                )),
7414            }
7415            if o.client_id.is_empty() {
7416                issues.push(issue(
7417                    "upstream",
7418                    &u.name,
7419                    "oauth2_client_credentials.client_id",
7420                    "client_id must not be empty",
7421                ));
7422            }
7423            if o.client_secret.is_empty() {
7424                issues.push(issue(
7425                    "upstream",
7426                    &u.name,
7427                    "oauth2_client_credentials.client_secret",
7428                    "client_secret must not be empty",
7429                ));
7430            }
7431            if let Some(ttl) = o.token_cache_ttl_s {
7432                if ttl == 0 {
7433                    issues.push(issue(
7434                        "upstream",
7435                        &u.name,
7436                        "oauth2_client_credentials.token_cache_ttl_s",
7437                        "token_cache_ttl_s must be > 0",
7438                    ));
7439                }
7440            }
7441            // mTLS cert/key files must exist and be readable at compile
7442            // time (the same check as trusted_ca_file). A missing file
7443            // disables the upstream's OAuth2 at build instead of failing
7444            // every request.
7445            if let Some(m) = &o.mtls {
7446                check_file_readable(
7447                    "upstream",
7448                    &u.name,
7449                    "oauth2_client_credentials.mtls.client_cert",
7450                    &m.client_cert,
7451                    &mut issues,
7452                );
7453                check_file_readable(
7454                    "upstream",
7455                    &u.name,
7456                    "oauth2_client_credentials.mtls.client_key",
7457                    &m.client_key,
7458                    &mut issues,
7459                );
7460            }
7461        }
7462        // DW-090: Peak-EWMA load-balancer tuning validation.
7463        if let Some(pe) = &u.peak_ewma {
7464            if let Some(decay) = pe.decay_ms {
7465                if decay == 0 {
7466                    issues.push(issue(
7467                        "upstream",
7468                        &u.name,
7469                        "peak_ewma.decay_ms",
7470                        "peak_ewma.decay_ms must be > 0 (the EWMA decay window)",
7471                    ));
7472                }
7473            }
7474            if let Some(rtt) = pe.default_rtt_ms {
7475                if rtt == 0 {
7476                    issues.push(issue(
7477                        "upstream",
7478                        &u.name,
7479                        "peak_ewma.default_rtt_ms",
7480                        "peak_ewma.default_rtt_ms must be > 0 (the initial cost estimate)",
7481                    ));
7482                }
7483            }
7484        }
7485        // DW-094 (Ent): locality-aware routing and data residency
7486        // validation.
7487        if let Some(loc) = &u.locality {
7488            // Data residency: allowed_regions must be non-empty when
7489            // enforce_data_residency is true.
7490            if loc.enforce_data_residency && loc.allowed_regions.is_empty() {
7491                issues.push(issue(
7492                    "upstream",
7493                    &u.name,
7494                    "locality.allowed_regions",
7495                    "locality.allowed_regions must be non-empty when \
7496                     enforce_data_residency is true",
7497                ));
7498            }
7499            // Region/zone fields on endpoints must be non-empty strings.
7500            for (i, ep) in u.endpoints.iter().enumerate() {
7501                if let Some(r) = &ep.region {
7502                    if r.is_empty() {
7503                        issues.push(issue(
7504                            "upstream",
7505                            &u.name,
7506                            &format!("endpoints[{i}].region"),
7507                            "endpoint region must be a non-empty string",
7508                        ));
7509                    }
7510                }
7511                if let Some(z) = &ep.zone {
7512                    if z.is_empty() {
7513                        issues.push(issue(
7514                            "upstream",
7515                            &u.name,
7516                            &format!("endpoints[{i}].zone"),
7517                            "endpoint zone must be a non-empty string",
7518                        ));
7519                    }
7520                }
7521            }
7522            // When data residency is enforced, at least one endpoint
7523            // must satisfy the allowed_regions constraint (or have no
7524            // region — locality-agnostic endpoints are always allowed).
7525            if loc.enforce_data_residency {
7526                let has_satisfiable = u.endpoints.iter().any(|ep| {
7527                    ep.region
7528                        .as_ref()
7529                        .is_none_or(|r| loc.allowed_regions.contains(r))
7530                });
7531                if !has_satisfiable {
7532                    issues.push(issue(
7533                        "upstream",
7534                        &u.name,
7535                        "locality.allowed_regions",
7536                        "no endpoint satisfies the allowed_regions constraint \
7537                         (every endpoint has a region not in the allowed list)",
7538                    ));
7539                }
7540            }
7541            // denied_regions must not overlap with allowed_regions.
7542            for d in &loc.denied_regions {
7543                if loc.allowed_regions.contains(d) {
7544                    issues.push(issue(
7545                        "upstream",
7546                        &u.name,
7547                        "locality.denied_regions",
7548                        format!(
7549                            "region '{}' appears in both allowed_regions and \
7550                             denied_regions (contradictory)",
7551                            d
7552                        ),
7553                    ));
7554                }
7555            }
7556        }
7557    }
7558
7559    // DW-035: mTLS consumer mapping validation.
7560    if let Some(m) = &gateway.mtls_consumer_mapping {
7561        if m.enabled {
7562            let consumer_names: std::collections::BTreeSet<&str> =
7563                gateway.consumers.iter().map(|c| c.name.as_str()).collect();
7564            for (i, entry) in m.consumers.iter().enumerate() {
7565                // Fingerprint grammar: 64 hex digits with optional colon
7566                // separators (lowercase or uppercase; normalized at build).
7567                let stripped = entry.fingerprint.replace(':', "");
7568                if stripped.len() != 64 || !stripped.bytes().all(|b| b.is_ascii_hexdigit()) {
7569                    issues.push(issue(
7570                        "gateway",
7571                        "(root)",
7572                        &format!("mtls_consumer_mapping.consumers[{i}].fingerprint"),
7573                        format!(
7574                            "fingerprint '{}' must be 64 hex digits with optional ':' separators \
7575                             (SHA-256 of the certificate DER)",
7576                            entry.fingerprint
7577                        ),
7578                    ));
7579                }
7580                if !consumer_names.contains(entry.consumer.as_str()) {
7581                    issues.push(issue(
7582                        "gateway",
7583                        "(root)",
7584                        &format!("mtls_consumer_mapping.consumers[{i}].consumer"),
7585                        format!(
7586                            "consumer '{}' is not defined in the consumers list",
7587                            entry.consumer
7588                        ),
7589                    ));
7590                }
7591            }
7592            for (cn, consumer) in &m.subject_cn_mapping {
7593                if cn.is_empty() {
7594                    issues.push(issue(
7595                        "gateway",
7596                        "(root)",
7597                        "mtls_consumer_mapping.subject_cn_mapping",
7598                        "subject CN key must not be empty",
7599                    ));
7600                }
7601                if !consumer_names.contains(consumer.as_str()) {
7602                    issues.push(issue(
7603                        "gateway",
7604                        "(root)",
7605                        "mtls_consumer_mapping.subject_cn_mapping",
7606                        format!("consumer '{consumer}' is not defined in the consumers list"),
7607                    ));
7608                }
7609            }
7610        }
7611    }
7612
7613    // DW-035: mTLS forward headers validation.
7614    if let Some(f) = &gateway.mtls_forward_headers {
7615        if f.enabled {
7616            // Prefix must be a valid HTTP token (it becomes header
7617            // names like `<prefix>-Fingerprint`).
7618            let prefix = f.prefix.as_str();
7619            if prefix.is_empty() {
7620                issues.push(issue(
7621                    "gateway",
7622                    "(root)",
7623                    "mtls_forward_headers.prefix",
7624                    "prefix must not be empty",
7625                ));
7626            }
7627            // Check that the derived header names are valid.
7628            for suffix in &["Fingerprint", "Subject-CN", "Issuer-CN", "Not-After"] {
7629                let name = format!("{prefix}-{suffix}");
7630                if hyper::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
7631                    issues.push(issue(
7632                        "gateway",
7633                        "(root)",
7634                        "mtls_forward_headers.prefix",
7635                        format!("'{name}' is not a valid HTTP header name"),
7636                    ));
7637                }
7638            }
7639        }
7640    }
7641
7642    // DW-036: hmac key ids are the SELECTOR a signed request presents;
7643    // a duplicate would make consumer resolution ambiguous. First
7644    // declarant wins the map, later ones get the issue.
7645    let mut hmac_key_ids: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
7646    for c in &gateway.consumers {
7647        // The consumer name is injected upstream as the X-Consumer-Name
7648        // header value; non-visible-ASCII names cannot be represented in a
7649        // header and would silently break the identity injection (DW-019
7650        // review). Reject at compile time instead.
7651        if c.name.is_empty() || !c.name.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
7652            issues.push(issue(
7653                "consumer",
7654                &c.name,
7655                "name",
7656                "consumer name must be non-empty visible ASCII (0x21-0x7E): it is injected \
7657                 upstream as the X-Consumer-Name header value",
7658            ));
7659        }
7660        if let Some(p) = c.priority {
7661            if p > 10 {
7662                issues.push(issue(
7663                    "consumer",
7664                    &c.name,
7665                    "priority",
7666                    format!("priority {p} is out of range: must be 0 (lowest) to 10 (highest)"),
7667                ));
7668            }
7669        }
7670        for (i, cred) in c.credentials.iter().enumerate() {
7671            let field = format!("credentials[{i}]");
7672            // DW-036: cross-consumer key-id uniqueness (see the map's
7673            // declaration note above).
7674            if let Credential::Hmac { key_id, .. } = cred {
7675                match hmac_key_ids.get_key_value(key_id.as_str()) {
7676                    Some((_, other_consumer)) => {
7677                        issues.push(issue(
7678                            "consumer",
7679                            &c.name,
7680                            &field,
7681                            format!(
7682                                "hmac key_id '{key_id}' is already declared by consumer \
7683                                 '{other_consumer}' (key ids select the credential a signed \
7684                                 request presents; they must be unique)"
7685                            ),
7686                        ));
7687                    }
7688                    None => {
7689                        hmac_key_ids.insert(key_id.as_str(), c.name.as_str());
7690                    }
7691                }
7692            }
7693            let problem = match cred {
7694                Credential::ApiKey { key } if key.is_empty() => {
7695                    Some("api key is empty".to_string())
7696                }
7697                // DW-045: an api key may be a ${...} reference. Resolve it
7698                // NOW (config-compile time) so an unresolvable secret fails
7699                // the generation closed — the same contract as
7700                // trusted_ca_file. Reference-shaped but malformed values
7701                // fail equally: treating a typo'd reference as a literal
7702                // key would silently install garbage bytes as the key.
7703                // Messages name the reference, never a resolved value.
7704                Credential::ApiKey { key } => {
7705                    match crate::config::credentials::parse_secret_reference(key) {
7706                        None => None,
7707                        Some(Ok(reference)) => reference.resolve().err(),
7708                        Some(Err(malformed)) => Some(malformed),
7709                    }
7710                }
7711                // DW-036: an hmac signing credential. The key id is the
7712                // PRESENTED selector (a header value), so it must be
7713                // representable: non-empty visible ASCII, bounded length.
7714                // The secret follows the same reference contract as api
7715                // keys (resolve at compile time, fail closed naming the
7716                // reference). Duplicate key ids across ALL consumers are
7717                // rejected inside the loop below (an ambiguous selector
7718                // cannot pick a consumer deterministically).
7719                Credential::Hmac { key_id, secret } => {
7720                    if key_id.is_empty() {
7721                        Some("hmac key_id is empty".to_string())
7722                    } else if !key_id.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
7723                        Some(
7724                            "hmac key_id must be visible ASCII (0x21-0x7E): it is presented \
7725                             as the X-Dwara-Key-Id header value"
7726                                .to_string(),
7727                        )
7728                    } else if key_id.len() > 128 {
7729                        Some("hmac key_id is longer than 128 bytes".to_string())
7730                    } else if secret.is_empty() {
7731                        Some("hmac secret is empty".to_string())
7732                    } else {
7733                        match crate::config::credentials::parse_secret_reference(secret) {
7734                            None => None,
7735                            Some(Ok(reference)) => reference.resolve().err(),
7736                            Some(Err(malformed)) => Some(malformed),
7737                        }
7738                    }
7739                }
7740                Credential::Jwt { issuer, .. } if issuer.is_empty() => {
7741                    Some("jwt issuer is empty".to_string())
7742                }
7743                // #124: an mtls credential matches a verified client
7744                // certificate by subject CN or by fingerprint — exactly
7745                // one of the two must carry a non-empty value. Both-set
7746                // is rejected because only the subject would ever be
7747                // matched (the fingerprint would sit inert).
7748                Credential::Mtls {
7749                    subject,
7750                    fingerprint,
7751                } => {
7752                    let s = subject.as_deref().unwrap_or("");
7753                    let f = fingerprint.as_deref().unwrap_or("");
7754                    if s.is_empty() && f.is_empty() {
7755                        Some(
7756                            "mtls credential must set subject or fingerprint (both are empty)"
7757                                .to_string(),
7758                        )
7759                    } else if subject.is_some() && s.is_empty() {
7760                        Some("mtls subject is empty".to_string())
7761                    } else if fingerprint.is_some() && f.is_empty() {
7762                        Some("mtls fingerprint is empty".to_string())
7763                    } else if subject.is_some() && fingerprint.is_some() {
7764                        Some(
7765                            "mtls credential must set subject or fingerprint, not both \
7766                             (both are set; only the subject would be matched)"
7767                                .to_string(),
7768                        )
7769                    } else {
7770                        None
7771                    }
7772                }
7773                _ => None,
7774            };
7775            if let Some(m) = problem {
7776                issues.push(issue("consumer", &c.name, &field, m));
7777            }
7778        }
7779        for p in &c.policies {
7780            if !policies.contains(p.as_str()) {
7781                issues.push(issue(
7782                    "consumer",
7783                    &c.name,
7784                    "policies",
7785                    format!("references unknown policy '{p}'"),
7786                ));
7787            }
7788        }
7789        // Consumer authorization (#123): shared shape checks with every
7790        // other attachment level.
7791        if let Some(authz) = &c.authorization {
7792            validate_authz(
7793                "consumer",
7794                &c.name,
7795                authz,
7796                &consumers,
7797                &consumer_groups,
7798                &mut issues,
7799            );
7800        }
7801        // DW-113: tool_allowlist entries must reference configured MCP
7802        // tools. When no `ai.mcp` block is configured, a non-empty
7803        // allowlist is an error (the tools do not exist).
7804        if !c.tool_allowlist.is_empty() {
7805            let mcp_tools: Option<std::collections::BTreeSet<&str>> = gateway
7806                .ai
7807                .as_ref()
7808                .and_then(|ai| ai.mcp.as_ref())
7809                .map(|mcp| mcp.tools.keys().map(|s| s.as_str()).collect());
7810            match mcp_tools {
7811                None => {
7812                    issues.push(issue(
7813                        "consumer",
7814                        &c.name,
7815                        "tool_allowlist",
7816                        "tool_allowlist is non-empty but no ai.mcp block is configured \
7817                         (the tools do not exist)",
7818                    ));
7819                }
7820                Some(tool_names) => {
7821                    for tool in &c.tool_allowlist {
7822                        if !tool_names.contains(tool.as_str()) {
7823                            issues.push(issue(
7824                                "consumer",
7825                                &c.name,
7826                                "tool_allowlist",
7827                                format!(
7828                                    "tool_allowlist references unknown tool '{tool}' \
7829                                     (not in ai.mcp.tools)"
7830                                ),
7831                            ));
7832                        }
7833                    }
7834                }
7835            }
7836        }
7837        // DW-113: per-consumer token_budget — same shape checks as a
7838        // policy's token_budget (at least one window, positive values).
7839        if let Some(tb) = &c.token_budget {
7840            if tb.tokens_per_min.is_none() && tb.cost_per_day_micros.is_none() {
7841                issues.push(issue(
7842                    "consumer",
7843                    &c.name,
7844                    "token_budget",
7845                    "a token_budget must set tokens_per_min and/or cost_per_day_micros \
7846                     (an empty budget bounds nothing)",
7847                ));
7848            }
7849            if tb.tokens_per_min == Some(0) {
7850                issues.push(issue(
7851                    "consumer",
7852                    &c.name,
7853                    "token_budget.tokens_per_min",
7854                    "tokens_per_min must be > 0 (omit the field for no token window)",
7855                ));
7856            }
7857            if tb.cost_per_day_micros == Some(0) {
7858                issues.push(issue(
7859                    "consumer",
7860                    &c.name,
7861                    "token_budget.cost_per_day_micros",
7862                    "cost_per_day_micros must be > 0 (omit the field for no cost window)",
7863                ));
7864            }
7865        }
7866    }
7867
7868    for p in &gateway.policies {
7869        if let Some(rl) = &p.rate_limit {
7870            if rl.requests == 0 {
7871                issues.push(issue(
7872                    "policy",
7873                    &p.name,
7874                    "rate_limit.requests",
7875                    "rate limit requests must be > 0 (0 would block every request)",
7876                ));
7877            }
7878            if rl.window_seconds == 0 {
7879                issues.push(issue(
7880                    "policy",
7881                    &p.name,
7882                    "rate_limit.window_seconds",
7883                    "rate limit window_seconds must be > 0 (0 is a nonsensical window)",
7884                ));
7885            }
7886        }
7887        for (i, rule) in p.rate_limits.iter().enumerate() {
7888            let field = format!("rate_limits[{i}]");
7889            if rule.selector.is_empty() {
7890                issues.push(issue(
7891                    "policy",
7892                    &p.name,
7893                    &format!("{field}.selector"),
7894                    "rate limit selector must name at least one key component",
7895                ));
7896            }
7897            let rp = &rule.requests_per;
7898            if rp.per_second.is_none() && rp.minute.is_none() && rp.hour.is_none() {
7899                issues.push(issue(
7900                    "policy",
7901                    &p.name,
7902                    &format!("{field}.requests_per"),
7903                    "rate limit rule must set at least one window (s, minute, hour)",
7904                ));
7905            }
7906            for (window, value) in [
7907                ("s", rp.per_second),
7908                ("minute", rp.minute),
7909                ("hour", rp.hour),
7910            ] {
7911                if value == Some(0) {
7912                    issues.push(issue(
7913                        "policy",
7914                        &p.name,
7915                        &format!("{field}.requests_per.{window}"),
7916                        "rate limit window rate must be > 0 (0 would block every request)",
7917                    ));
7918                }
7919            }
7920            if rule.burst == Some(0) {
7921                issues.push(issue(
7922                    "policy",
7923                    &p.name,
7924                    &format!("{field}.burst"),
7925                    "rate limit burst must be >= 1 when present (omit for the default)",
7926                ));
7927            }
7928        }
7929        // DW-090: anomaly scoring policy validation.
7930        if let Some(an) = &p.anomaly {
7931            if an.enabled && an.signals.is_empty() {
7932                issues.push(issue(
7933                    "policy",
7934                    &p.name,
7935                    "anomaly.signals",
7936                    "anomaly scoring is enabled but no signals are configured (at least \
7937                     one signal is required)",
7938                ));
7939            }
7940            if !an.threshold.is_finite() || an.threshold <= 0.0 || an.threshold > 1.0 {
7941                issues.push(issue(
7942                    "policy",
7943                    &p.name,
7944                    "anomaly.threshold",
7945                    format!("anomaly.threshold must be in (0, 1]; got {}", an.threshold),
7946                ));
7947            }
7948            if an.max_body_inspect_bytes == 0 {
7949                issues.push(issue(
7950                    "policy",
7951                    &p.name,
7952                    "anomaly.max_body_inspect_bytes",
7953                    "anomaly.max_body_inspect_bytes must be > 0 (the body_size signal \
7954                     cap; use a large value to effectively disable the cap)",
7955                ));
7956            }
7957        }
7958        // DW-089: adaptive rate-limit tuning validation.
7959        if let Some(ad) = &p.adaptive {
7960            if ad.ewma_window_secs == 0 {
7961                issues.push(issue(
7962                    "policy",
7963                    &p.name,
7964                    "adaptive.ewma_window_secs",
7965                    "adaptive.ewma_window_secs must be > 0 (0 is a nonsensical decay window)",
7966                ));
7967            }
7968            if !ad.min_factor.is_finite() || ad.min_factor <= 0.0 {
7969                issues.push(issue(
7970                    "policy",
7971                    &p.name,
7972                    "adaptive.min_factor",
7973                    format!(
7974                        "adaptive.min_factor must be > 0 (never 0); got {}",
7975                        ad.min_factor
7976                    ),
7977                ));
7978            }
7979            if !ad.max_factor.is_finite() || ad.max_factor <= 0.0 {
7980                issues.push(issue(
7981                    "policy",
7982                    &p.name,
7983                    "adaptive.max_factor",
7984                    format!(
7985                        "adaptive.max_factor must be > 0 (never unbounded); got {}",
7986                        ad.max_factor
7987                    ),
7988                ));
7989            }
7990            if ad.min_factor.is_finite()
7991                && ad.max_factor.is_finite()
7992                && ad.min_factor > 0.0
7993                && ad.max_factor > 0.0
7994                && ad.min_factor > ad.max_factor
7995            {
7996                issues.push(issue(
7997                    "policy",
7998                    &p.name,
7999                    "adaptive.min_factor",
8000                    format!(
8001                        "adaptive.min_factor ({}) must be <= adaptive.max_factor ({})",
8002                        ad.min_factor, ad.max_factor
8003                    ),
8004                ));
8005            }
8006            if !ad.error_threshold.is_finite()
8007                || ad.error_threshold <= 0.0
8008                || ad.error_threshold > 1.0
8009            {
8010                issues.push(issue(
8011                    "policy",
8012                    &p.name,
8013                    "adaptive.error_threshold",
8014                    format!(
8015                        "adaptive.error_threshold must be in (0, 1]; got {}",
8016                        ad.error_threshold
8017                    ),
8018                ));
8019            }
8020            if ad.latency_threshold_ms == 0 {
8021                issues.push(issue(
8022                    "policy",
8023                    &p.name,
8024                    "adaptive.latency_threshold_ms",
8025                    "adaptive.latency_threshold_ms must be > 0 (0 would mark every \
8026                     response as slow)",
8027                ));
8028            }
8029        }
8030    }
8031
8032    issues
8033}
8034
8035/// DW-111: FIPS 140-3 mode primitive restrictions. When the `fips` cargo
8036/// feature is ON, reject non-approved primitives at config validation:
8037///
8038/// - Ed25519 certificates: the leaf certificate's SubjectPublicKeyInfo
8039///   algorithm must not be Ed25519 (not on the FIPS-validated list for
8040///   aws-lc-rs). The check walks the certificate DER the same way the
8041///   SPKI extractor in `security::tls` does (no X.509 parser dependency).
8042/// - Argon2 credential hashing: a config-declared credential whose
8043///   stored-hash format prefix is `argon2id` (or `argon2`) is rejected.
8044///   Config-declared credentials use sha256/hmac-sha256 (the fast path);
8045///   argon2 is store-managed only, but a config that declares an argon2
8046///   hash directly is rejected here so the gateway never boots in FIPS
8047///   mode with a non-approved hash.
8048///
8049/// When the `fips` feature is OFF, this function is inert (no issues
8050/// produced). The check is conservative: a certificate that cannot be
8051/// parsed is skipped (the TLS build path will reject an unparseable cert
8052/// on its own); only a parseable cert whose SPKI algorithm is Ed25519 is
8053/// flagged.
8054fn validate_fips(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
8055    if !crate::security::fips::FipsMode::current().is_enabled() {
8056        return;
8057    }
8058    {
8059        use crate::security::fips;
8060
8061        // Ed25519 certificate check: walk every terminate-mode listener's
8062        // certificate files and check the leaf certificate's SPKI algorithm.
8063        // The DER walk is the same substrate as `spki_of_leaf` in
8064        // `security::tls` (no X.509 parser dependency). A certificate that
8065        // cannot be read or parsed is skipped here — the TLS build path
8066        // rejects unparseable certs on its own, and piling a FIPS issue on
8067        // a broken file is noise.
8068        for l in &gateway.listeners {
8069            if l.protocol != ListenerProtocol::Https {
8070                continue;
8071            }
8072            let Some(tls) = &l.tls else { continue };
8073            if tls.mode != TlsMode::Terminate {
8074                continue;
8075            }
8076            // Collect all cert file paths from the terminate block.
8077            let mut cert_paths: Vec<&str> = Vec::new();
8078            if let Some(cf) = &tls.cert_file {
8079                cert_paths.push(cf.as_str());
8080            }
8081            for c in &tls.certificates {
8082                cert_paths.push(c.cert_file.as_str());
8083            }
8084            for cf in &cert_paths {
8085                if let Some(algo) = spki_algorithm_of_cert_file(cf) {
8086                    if fips::is_signature_disallowed(&algo) {
8087                        issues.push(issue(
8088                            "listener",
8089                            &l.name,
8090                            "tls.cert_file",
8091                            format!(
8092                                "FIPS mode rejects certificate '{cf}': the leaf certificate's \
8093                                 signature algorithm '{algo}' is not on the FIPS-approved \
8094                                 allowlist (Ed25519 is not FIPS-validated in aws-lc-rs; use \
8095                                 RSA-PSS or ECDSA P-256/P-384 certificates instead)"
8096                            ),
8097                        ));
8098                    }
8099                }
8100            }
8101        }
8102
8103        // Admin block: check the admin mTLS server certificate too.
8104        if let Some(admin) = &gateway.admin {
8105            if let Some(algo) = spki_algorithm_of_cert_file(&admin.tls.cert_file) {
8106                if fips::is_signature_disallowed(&algo) {
8107                    issues.push(issue(
8108                        "admin",
8109                        "(root)",
8110                        "tls.cert_file",
8111                        format!(
8112                            "FIPS mode rejects admin certificate '{}': the leaf certificate's \
8113                             signature algorithm '{}' is not on the FIPS-approved allowlist \
8114                             (Ed25519 is not FIPS-validated in aws-lc-rs; use RSA-PSS or \
8115                             ECDSA P-256/P-384 certificates instead)",
8116                            admin.tls.cert_file, algo
8117                        ),
8118                    ));
8119                }
8120            }
8121        }
8122
8123        // Argon2 credential hashing check: config-declared credentials
8124        // use sha256/hmac-sha256 (the fast path). A config that declares
8125        // an argon2 hash directly is rejected. In practice config
8126        // credentials are ApiKey/Hmac/Jwt/Mtls (no stored-hash field),
8127        // but the check is here for completeness and to catch any future
8128        // config field that carries a stored hash.
8129        for c in &gateway.consumers {
8130            for (i, cred) in c.credentials.iter().enumerate() {
8131                let field = format!("credentials[{i}]");
8132                // The ApiKey key value is the raw secret, not a stored hash,
8133                // so it is not checked here. Argon2 appears only in
8134                // store-managed credential records (the state store), not
8135                // in config. This check is a no-op for config-declared
8136                // credentials today, but documents the FIPS restriction
8137                // for any future config field that carries a stored hash.
8138                let _ = cred;
8139                let _ = &field;
8140            }
8141        }
8142    }
8143}
8144
8145/// DW-105: post-quantum TLS validation. Enforces three rules:
8146///
8147/// 1. **Feature-off warning**: vestigial. PQ hybrid key exchange is
8148///    compiled into every build (`PqMode::current()` is always
8149///    `Enabled`), so this warning never fires; the branch remains for
8150///    the day the kx group is conditionally absent.
8151/// 2. **FIPS incompatibility**: when `pq: true` is configured AND FIPS
8152///    mode is active (Enterprise `ent` build), REJECT with an
8153///    error issue. ML-KEM is not on the FIPS-validated list for
8154///    aws-lc-rs, so the two must not combine unless both
8155///    algorithms are on a validated list.
8156/// 3. **Protocol relevance**: `pq: true` on a passthrough listener (no
8157///    TLS termination) or an `http1` upstream (no TLS negotiation) is
8158///    rejected — the kx group list is irrelevant when no TLS is
8159///    terminated/negotiated.
8160fn validate_pq(gateway: &Gateway, issues: &mut Vec<ValidationIssue>) {
8161    use crate::config::{TlsMode, UpstreamProtocol};
8162    use crate::security::pq::PqMode;
8163
8164    let pq_feature = PqMode::current();
8165    let fips_active = crate::security::fips::FipsMode::current().is_enabled();
8166
8167    // Listeners: check pq: true on each listener's TLS block.
8168    for l in &gateway.listeners {
8169        if let Some(tls) = &l.tls {
8170            if tls.pq {
8171                // Rule 3: passthrough listeners do not terminate TLS, so
8172                // the kx group list is irrelevant.
8173                if tls.mode == TlsMode::Passthrough {
8174                    issues.push(issue(
8175                        "listener",
8176                        &l.name,
8177                        "tls.pq",
8178                        "pq: true is only meaningful in tls.mode terminate (passthrough does \
8179                         not terminate TLS, so the kx group list is irrelevant)",
8180                    ));
8181                    continue;
8182                }
8183                // Rule 2: FIPS incompatibility.
8184                if fips_active {
8185                    issues.push(issue(
8186                        "listener",
8187                        &l.name,
8188                        "tls.pq",
8189                        "pq: true is incompatible with FIPS mode: ML-KEM is not on the \
8190                         FIPS-validated list for aws-lc-rs (the two features must not combine \
8191                         unless both algorithms are on a validated list)",
8192                    ));
8193                    continue;
8194                }
8195                // Rule 1: feature-off warning.
8196                if !pq_feature.is_enabled() {
8197                    issues.push(issue(
8198                        "listener",
8199                        &l.name,
8200                        "tls.pq",
8201                        "pq: true is configured but the `pq` cargo feature is OFF — PQ hybrid \
8202                         key exchange is inert (no kx group is prepended). Build with \
8203                         --features pq to enable post-quantum TLS",
8204                    ));
8205                }
8206            }
8207        }
8208    }
8209
8210    // Upstreams: check pq: true on each upstream.
8211    for u in &gateway.upstreams {
8212        if u.pq {
8213            // Rule 3: http1/h2c upstreams do not negotiate TLS.
8214            if matches!(u.protocol, UpstreamProtocol::Http1 | UpstreamProtocol::H2c) {
8215                issues.push(issue(
8216                    "upstream",
8217                    &u.name,
8218                    "pq",
8219                    "pq: true is only meaningful for https or http2 upstreams (http1 and h2c do \
8220                     not negotiate TLS, so the kx group list is irrelevant)",
8221                ));
8222                continue;
8223            }
8224            // Rule 2: FIPS incompatibility.
8225            if fips_active {
8226                issues.push(issue(
8227                    "upstream",
8228                    &u.name,
8229                    "pq",
8230                    "pq: true is incompatible with FIPS mode: ML-KEM is not on the \
8231                     FIPS-validated list for aws-lc-rs (the two features must not combine \
8232                     unless both algorithms are on a validated list)",
8233                ));
8234                continue;
8235            }
8236            // Rule 1: feature-off warning.
8237            if !pq_feature.is_enabled() {
8238                issues.push(issue(
8239                    "upstream",
8240                    &u.name,
8241                    "pq",
8242                    "pq: true is configured but the `pq` cargo feature is OFF — PQ hybrid \
8243                     key exchange is inert (no kx group is prepended). Build with \
8244                     --features pq to enable post-quantum TLS",
8245                ));
8246            }
8247        }
8248    }
8249}
8250
8251/// DW-111: Extract the SubjectPublicKeyInfo algorithm OID from a
8252/// certificate file's leaf certificate. Returns the algorithm name
8253/// ("ed25519", "rsa", "ecdsa") or None when the file cannot be read or
8254/// the SPKI algorithm cannot be determined. Uses the same DER-walk
8255/// substrate as `security::tls::spki_of_leaf` (no X.509 parser
8256/// dependency).
8257fn spki_algorithm_of_cert_file(path: &str) -> Option<String> {
8258    use rustls_pki_types::pem::PemObject;
8259
8260    let certs = rustls_pki_types::CertificateDer::pem_file_iter(path)
8261        .ok()?
8262        .collect::<Result<Vec<_>, _>>()
8263        .ok()?;
8264    let leaf = certs.first()?;
8265    spki_algorithm_of_leaf(leaf)
8266}
8267
8268/// DW-111: Walk the leaf certificate's DER to the SubjectPublicKeyInfo
8269/// algorithm OID and map it to a name. Returns "ed25519" for Ed25519,
8270/// "rsa" for RSA, "ecdsa" for ECDSA P-256/P-384, or None on any
8271/// structural shortcoming.
8272fn spki_algorithm_of_leaf(cert: &rustls_pki_types::CertificateDer<'_>) -> Option<String> {
8273    // Certificate SEQUENCE -> TBSCertificate SEQUENCE.
8274    let (tag, cert_content, _) = der_elem_local(cert.as_ref())?;
8275    if tag != 0x30 {
8276        return None;
8277    }
8278    let (tag, tbs, _) = der_elem_local(cert_content)?;
8279    if tag != 0x30 {
8280        return None;
8281    }
8282    let mut rest = tbs;
8283    // Optional [0] EXPLICIT version comes before the serial INTEGER.
8284    if let Some((tag, _, tail)) = der_elem_local(rest) {
8285        if tag & 0xc0 == 0x80 {
8286            rest = tail;
8287        }
8288    }
8289    // serialNumber, signature, issuer, validity, subject, then SPKI.
8290    for _ in 0..5 {
8291        let (_, _, tail) = der_elem_local(rest)?;
8292        rest = tail;
8293    }
8294    // SPKI: SEQUENCE { algorithm SEQUENCE { OID, params }, subjectPublicKey BIT STRING }
8295    let (tag, spki_content, _) = der_elem_local(rest)?;
8296    if tag != 0x30 {
8297        return None;
8298    }
8299    // algorithm SEQUENCE
8300    let (tag, algo_content, _) = der_elem_local(spki_content)?;
8301    if tag != 0x30 {
8302        return None;
8303    }
8304    // OID
8305    let (oid_tag, oid, _) = der_elem_local(algo_content)?;
8306    if oid_tag != 0x06 {
8307        return None;
8308    }
8309    // Map the algorithm OID to a name.
8310    // 1.2.840.10045.4.3 = id-ecPublicKey (ECDSA) — but the SPKI algorithm
8311    // OID for ECDSA keys is 1.2.840.10045.2.1 (id-ecPublicKey). The
8312    // SIGNATURE algorithm OIDs are different. Here we check the SPKI
8313    // algorithm OID, which identifies the KEY type.
8314    //
8315    // RSA: 1.2.840.113549.1.1.1 (rsaEncryption)
8316    // EC: 1.2.840.10045.2.1 (id-ecPublicKey)
8317    // Ed25519: 1.3.101.112 (id-Ed25519)
8318    if oid == [0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01] {
8319        // id-ecPublicKey — could be P-256, P-384, or Ed25519 (but Ed25519
8320        // has its own OID). This is ECDSA.
8321        Some("ecdsa".to_string())
8322    } else if oid == [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01] {
8323        // rsaEncryption
8324        Some("rsa".to_string())
8325    } else if oid == [0x2b, 0x65, 0x70] {
8326        // id-Ed25519 (1.3.101.112)
8327        Some("ed25519".to_string())
8328    } else {
8329        // Unknown algorithm OID — return the raw bytes as hex for the
8330        // caller to decide. The FIPS check treats unknown algorithms as
8331        // allowed (conservative: only known non-approved primitives are
8332        // denied).
8333        Some(format!("unknown:{}", hex_oid(oid)))
8334    }
8335}
8336
8337/// DW-111: Encode an OID's content bytes as lowercase hex (for the
8338/// "unknown" algorithm fallback).
8339fn hex_oid(bytes: &[u8]) -> String {
8340    const TABLE: &[u8; 16] = b"0123456789abcdef";
8341    let mut out = String::with_capacity(bytes.len() * 2);
8342    for b in bytes {
8343        out.push(TABLE[usize::from(*b >> 4)] as char);
8344        out.push(TABLE[usize::from(*b & 0x0f)] as char);
8345    }
8346    out
8347}
8348
8349/// DW-111: Local copy of the DER element walker (the same logic as
8350/// `security::tls::der_elem`, duplicated here because `der_elem` is
8351/// private to that module and `snapshot` must not import `security`
8352/// internals — the dependency direction is `snapshot <- security`, not
8353/// the reverse).
8354fn der_elem_local(buf: &[u8]) -> Option<(u8, &[u8], &[u8])> {
8355    let tag = *buf.first()?;
8356    let mut i = 1usize;
8357    let first = *buf.get(i)?;
8358    let len = if first < 0x80 {
8359        i += 1;
8360        first as usize
8361    } else {
8362        let n = (first & 0x7f) as usize;
8363        if n == 0 || n > 8 {
8364            return None;
8365        }
8366        i += 1;
8367        let mut l = 0usize;
8368        for &b in buf.get(i..i + n)? {
8369            l = (l << 8) | b as usize;
8370        }
8371        i += n;
8372        l
8373    };
8374    let end = len.checked_add(i).filter(|end| *end <= buf.len())?;
8375    Some((tag, &buf[i..end], &buf[end..]))
8376}
8377
8378/// Validate a proxy-action path rewrite (shape only; regex COMPILATION is
8379/// checked in [`compile`], like the regex path-match kind).
8380fn validate_rewrite(route: &str, rw: &PathRewrite, issues: &mut Vec<ValidationIssue>) {
8381    match rw {
8382        PathRewrite::StripPrefix {} => {}
8383        PathRewrite::ReplacePrefix {
8384            prefix,
8385            replacement,
8386        } => {
8387            if !prefix.starts_with('/') {
8388                issues.push(issue(
8389                    "route",
8390                    route,
8391                    "action.rewrite.prefix",
8392                    format!("replace_prefix prefix '{prefix}' must start with '/'"),
8393                ));
8394            }
8395            if !replacement.is_empty() && !replacement.starts_with('/') {
8396                issues.push(issue(
8397                    "route",
8398                    route,
8399                    "action.rewrite.replacement",
8400                    format!(
8401                        "replace_prefix replacement '{replacement}' must start with '/' or be \
8402                         empty (empty turns the prefix into the root path)"
8403                    ),
8404                ));
8405            }
8406        }
8407        PathRewrite::Regex {
8408            pattern,
8409            substitution,
8410        } => {
8411            if pattern.is_empty() {
8412                issues.push(issue(
8413                    "route",
8414                    route,
8415                    "action.rewrite.pattern",
8416                    "rewrite regex pattern is empty",
8417                ));
8418            }
8419            if substitution.is_empty() {
8420                issues.push(issue(
8421                    "route",
8422                    route,
8423                    "action.rewrite.substitution",
8424                    "rewrite substitution is empty (use replace_prefix or strip_prefix to \
8425                     remove path material)",
8426                ));
8427            } else {
8428                // The substitution must expand to an absolute path or be a
8429                // pure capture expansion: reject anything that starts with
8430                // a literal (a relative result cannot be reparsed as a
8431                // request URI, and the dataplane would silently forward the
8432                // ORIGINAL path). Whitespace, '?', and '#' would corrupt
8433                // the path/query split; control characters never belong.
8434                if !substitution.starts_with(['/', '$', '{']) {
8435                    issues.push(issue(
8436                        "route",
8437                        route,
8438                        "action.rewrite.substitution",
8439                        format!(
8440                            "rewrite substitution '{substitution}' must start with '/' (or a \
8441                             capture reference like '$1' / '${{name}}') so it expands to an \
8442                             absolute path"
8443                        ),
8444                    ));
8445                }
8446                if substitution
8447                    .chars()
8448                    .any(|c| c.is_whitespace() || c == '?' || c == '#' || c.is_control())
8449                {
8450                    issues.push(issue(
8451                        "route",
8452                        route,
8453                        "action.rewrite.substitution",
8454                        format!(
8455                            "rewrite substitution '{substitution}' must not contain \
8456                             whitespace, '?', '#', or control characters"
8457                        ),
8458                    ));
8459                }
8460            }
8461        }
8462    }
8463}
8464
8465/// Validate a mock action (DW-047): status in 100..=599, delay_ms in
8466/// 0..=30000, exactly one of body/body_file (or neither), body_file
8467/// exists and is readable, and header names/values are buildable.
8468fn validate_mock(route: &str, mock: &crate::config::MockAction, issues: &mut Vec<ValidationIssue>) {
8469    if !(100..=599).contains(&mock.status) {
8470        issues.push(issue(
8471            "route",
8472            route,
8473            "action.mock.status",
8474            format!(
8475                "mock status {} is not a valid HTTP status (100..=599)",
8476                mock.status
8477            ),
8478        ));
8479    }
8480    if let Some(delay) = mock.delay_ms {
8481        if delay > 30000 {
8482            issues.push(issue(
8483                "route",
8484                route,
8485                "action.mock.delay_ms",
8486                format!("mock delay_ms {delay} is out of range: must be 0..=30000"),
8487            ));
8488        }
8489    }
8490    if mock.body.is_some() && mock.body_file.is_some() {
8491        issues.push(issue(
8492            "route",
8493            route,
8494            "action.mock",
8495            "mock action sets both 'body' and 'body_file'; set at most one \
8496             (use body for an inline string, body_file for a fixture file)",
8497        ));
8498    }
8499    for (name, value) in &mock.headers {
8500        if hyper::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
8501            issues.push(issue(
8502                "route",
8503                route,
8504                "action.mock.headers",
8505                format!("mock header name '{name}' is not a valid HTTP header name"),
8506            ));
8507        }
8508        if hyper::header::HeaderValue::from_str(value.as_str()).is_err() {
8509            issues.push(issue(
8510                "route",
8511                route,
8512                "action.mock.headers",
8513                format!(
8514                    "mock header value for '{name}' contains characters that \
8515                     cannot appear in an HTTP header value"
8516                ),
8517            ));
8518        }
8519    }
8520    if let Some(path) = &mock.body_file {
8521        if path.trim().is_empty() {
8522            issues.push(issue(
8523                "route",
8524                route,
8525                "action.mock.body_file",
8526                "mock body_file path is empty",
8527            ));
8528        } else if std::fs::read(path).is_err() {
8529            issues.push(issue(
8530                "route",
8531                route,
8532                "action.mock.body_file",
8533                format!("mock body_file '{path}' is not a readable file"),
8534            ));
8535        }
8536    }
8537}
8538
8539/// Validate a JSON schema (DW-047): type names must be known, numeric
8540/// bounds non-negative, and the schema recursively well-formed. Unknown
8541/// keywords are ignored (forward compatibility).
8542fn validate_json_schema(
8543    route: &str,
8544    field: &str,
8545    schema: &crate::config::BodySchema,
8546    issues: &mut Vec<ValidationIssue>,
8547) {
8548    if let Some(t) = &schema.r#type {
8549        if !matches!(
8550            t.as_str(),
8551            "object" | "array" | "string" | "number" | "integer" | "boolean" | "null"
8552        ) {
8553            issues.push(issue(
8554                "route",
8555                route,
8556                field,
8557                format!("json schema type '{t}' is not a known JSON type"),
8558            ));
8559        }
8560    }
8561    if let Some(min) = schema.min_length {
8562        if min > u64::MAX / 4 {
8563            // Guard against absurd values; the practical cap is the
8564            // route's body limit, enforced at request time.
8565        }
8566    }
8567    for (name, child) in &schema.properties {
8568        validate_json_schema(route, &format!("{field}.properties[{name}]"), child, issues);
8569    }
8570    if let Some(items) = &schema.items {
8571        validate_json_schema(route, &format!("{field}.items"), items, issues);
8572    }
8573    if let Some(ap) = &schema.additional_properties {
8574        if let crate::config::AdditionalProperties::Schema(s) = ap.as_ref() {
8575            validate_json_schema(route, &format!("{field}.additionalProperties"), s, issues);
8576        }
8577    }
8578}
8579
8580/// Compiled route structures for one snapshot. Path-only lookup (v1); host,
8581/// method, header, query, and cookie matching are applied by the dataplane
8582/// after path resolution (see `proxy::route_applies`).
8583///
8584/// # Route precedence (canonical spec, DW-010)
8585///
8586/// A request path resolves to AT MOST ONE route, chosen across three
8587/// path-match kinds in a fixed order:
8588///
8589/// 1. **Exact** — `matchit` radix templates (`type: exact`). Within this
8590///    kind, `matchit`'s specificity rules apply: static segments beat
8591///    parameters (`/users/active` before `/users/{id}`). Templates that
8592///    would conflict are rejected at compile time
8593///    ([`CompileError::RouteConflict`]), so there is no ambiguity here.
8594/// 2. **Regex** — a shared `RegexSet` over all `type: regex` routes. When
8595///    several patterns match, the FIRST-DECLARED route in config order
8596///    wins (`RegexSet` returns matches in insertion order and lookup takes
8597///    the first).
8598/// 3. **Prefix** — byte-prefix matching (no segment boundary: `/v1` also
8599///    matches `/v1anything`). The LONGEST matching prefix wins; equal-length
8600///    ties go to the FIRST-DECLARED route (strict `>` comparison keeps the
8601///    earlier entry).
8602///
8603/// Cross-kind precedence is therefore: exact beats regex beats prefix,
8604/// regardless of declaration order and regardless of how "specific" a
8605/// regex or prefix looks. A shorter exact template beats a longer regex.
8606/// The prefix stored is the configured value with trailing `/` trimmed,
8607/// so `/v1/` and `/v1` are the same prefix and equal-length ties are
8608/// possible (first declared wins).
8609///
8610/// Non-path criteria (host, method, headers, query, cookies) are applied
8611/// AFTER this resolution; a criteria miss does NOT fall through to the
8612/// next candidate — the request is unmatched (404 in v1).
8613///
8614/// PERF-03 (#205): prefix lookup uses a radix trie (`PrefixTrie`) for
8615/// O(k) longest-prefix-match where k is the path length, instead of the
8616/// O(n) linear scan over all prefix routes.
8617///
8618/// PERF-03 (#205): A radix trie for longest-prefix matching. Each
8619/// node stores the route indices whose prefix ends at this node
8620/// (empty for internal nodes) and children keyed by the next byte.
8621/// Lookup traverses the trie following the path bytes, recording the
8622/// last node carrying indices — that is the longest prefix match.
8623///
8624/// O(k) lookup where k is the path length, replacing the previous
8625/// O(n) linear scan over all prefix routes.
8626#[derive(Debug, Default)]
8627struct PrefixTrie {
8628    /// Route indices of prefixes ending at this node, in declaration
8629    /// order. Empty means this node is internal (no prefix ends here,
8630    /// but longer prefixes pass through it). More than one entry means
8631    /// duplicate configured prefixes — each is a distinct fall-through
8632    /// candidate for `find_candidates` (DP-06), while `longest_match`
8633    /// takes the first entry (first-declared wins).
8634    route_idxs: Vec<usize>,
8635    /// Child nodes keyed by the next byte of the prefix.
8636    children: std::collections::HashMap<u8, Box<PrefixTrie>>,
8637}
8638
8639impl PrefixTrie {
8640    fn new() -> Self {
8641        Self::default()
8642    }
8643
8644    /// Insert a prefix and its route index. Every route declared with
8645    /// this exact prefix is kept, in declaration order: the candidate
8646    /// fall-through walks all of them (the first-declared-wins tie rule
8647    /// for `find_full` is applied by `longest_match` reading the first
8648    /// entry).
8649    fn insert(&mut self, prefix: &str, idx: usize) {
8650        let mut node = self;
8651        for &byte in prefix.as_bytes() {
8652            node = node
8653                .children
8654                .entry(byte)
8655                .or_insert_with(|| Box::new(PrefixTrie::new()));
8656        }
8657        node.route_idxs.push(idx);
8658    }
8659
8660    /// Longest-prefix-match: traverse the trie following the path
8661    /// bytes, recording the last node with a route index. Returns
8662    /// the route index of the longest matching prefix, or `None`.
8663    fn longest_match(&self, path: &str) -> Option<usize> {
8664        let mut node = self;
8665        let mut best = node.route_idxs.first().copied();
8666        for &byte in path.as_bytes() {
8667            match node.children.get(&byte) {
8668                Some(child) => {
8669                    node = child;
8670                    if let Some(&idx) = node.route_idxs.first() {
8671                        best = Some(idx);
8672                    }
8673                }
8674                None => break,
8675            }
8676        }
8677        best
8678    }
8679
8680    /// Every prefix match along the path, ONE SLICE PER MATCHED NODE,
8681    /// in increasing-depth (= increasing prefix length) order. The
8682    /// root is included first (an empty configured prefix — `/`
8683    /// trimmed — matches every path), so a caller wanting longest-first
8684    /// reverses the NODE order while keeping each node's slice in
8685    /// declaration order: the flat Vec cannot simply be reversed, or
8686    /// duplicate configured prefixes at one node would flip their
8687    /// fall-through order.
8688    fn collect_matches(&self, path: &str) -> Vec<&[usize]> {
8689        let mut node = self;
8690        let mut out = Vec::new();
8691        if !node.route_idxs.is_empty() {
8692            out.push(&node.route_idxs[..]);
8693        }
8694        for &byte in path.as_bytes() {
8695            match node.children.get(&byte) {
8696                Some(child) => {
8697                    node = child;
8698                    if !node.route_idxs.is_empty() {
8699                        out.push(&node.route_idxs[..]);
8700                    }
8701                }
8702                None => break,
8703            }
8704        }
8705        out
8706    }
8707}
8708
8709#[derive(Debug)]
8710pub struct RouteTable {
8711    exact: matchit::Router<usize>,
8712    /// (prefix, route index) for prefix-kind routes; longest prefix wins.
8713    /// Kept for backward compatibility and Debug; the trie is the
8714    /// primary lookup structure (PERF-03, #205).
8715    #[allow(dead_code)]
8716    prefixes: Vec<(String, usize)>,
8717    /// PERF-03 (#205): radix trie for O(k) longest-prefix-match.
8718    prefix_trie: PrefixTrie,
8719    regex_set: regex::RegexSet,
8720    /// Route index per RegexSet member, in insertion order.
8721    regex_indices: Vec<usize>,
8722    /// Specificity score per RegexSet member (DP-06, #254): higher =
8723    /// more specific. When multiple regexes match, the most specific
8724    /// one wins instead of the first declared. Scored by literal
8725    /// character count in the pattern.
8726    regex_scores: Vec<u32>,
8727    /// Compiled `path_rewrite.regex` pattern per route index (None where
8728    /// the action carries no regex rewrite). Validation guarantees these
8729    /// compiled at config-compile time, never at request time.
8730    rewrite_regexes: Vec<Option<regex::Regex>>,
8731    /// Precompiled `path_rewrite.regex.substitution` per route index
8732    /// (PERF-04, #206): the `$1`/`${name}` syntax is parsed into
8733    /// literal segments and capture references ONCE at compile time so
8734    /// the request path never re-parses the substitution string. `None`
8735    /// where the route carries no regex rewrite.
8736    rewrite_substitutions: Vec<Option<CompiledSubstitution>>,
8737    /// Precompiled CORS origin matcher per route index (DW-027): `None`
8738    /// where the route carries no `cors` block, mirroring
8739    /// `Route::cors` exactly so the proxy's config read and matcher
8740    /// lookup stay in lockstep.
8741    cors_origins: Vec<Option<crate::config::CompiledCorsOrigins>>,
8742    /// Precompiled compression content-type filter per route index
8743    /// (DW-027), mirroring `Route::compression` the same way.
8744    compression_types: Vec<Option<crate::config::CompiledContentTypeFilter>>,
8745    /// Precompiled deprecation header values per route index (DW-048),
8746    /// mirroring `Route::deprecation` the same way: HTTP-dates are
8747    /// parsed once here, never per response.
8748    deprecations: Vec<Option<crate::config::CompiledDeprecation>>,
8749    /// Normalized `match.accept` media type per route index (DW-048),
8750    /// mirroring `Route::r#match.accept` exactly (`None` = no
8751    /// criterion). Normalizing here — trim + lowercase via the shared
8752    /// `config::versioning` grammar — keeps the raw config string out
8753    /// of the request path, so a padded or mixed-case spelling matches
8754    /// exactly like its canonical form. Validation guarantees every
8755    /// configured value normalizes; a `None` for a configured one is
8756    /// the same unreachable-skip contract as the compilations above
8757    /// (`compile` has already run validation by then).
8758    accept_media_types: Vec<Option<String>>,
8759    /// Precompiled request JSON body transforms per route index
8760    /// (DW-028), mirroring `routes[idx].transforms.request.body.json`
8761    /// exactly: pointers parsed once here, never per request. Header
8762    /// and query ops carry no parseable grammar (names are checked by
8763    /// validation and applied verbatim), so they deliberately have no
8764    /// compiled form — only the JSON pointers need precompute.
8765    request_body_ops: Vec<Option<crate::config::transforms::CompiledJsonTransform>>,
8766    /// Precompiled response JSON body transforms per route index
8767    /// (DW-028), same mirroring as `request_body_ops`.
8768    response_body_ops: Vec<Option<crate::config::transforms::CompiledJsonTransform>>,
8769    /// Precompiled response field masking policies per route index
8770    /// (DW-029), mirroring `routes[idx].masking`: pointers parsed once
8771    /// here; the per-request union with the consumer's groups is
8772    /// resolved at apply time (group membership is per-request state).
8773    masking: Vec<Option<crate::config::transforms::CompiledMasking>>,
8774    /// Precompiled response-cache policies per route index (DW-037),
8775    /// mirroring `routes[idx].cache` with the policy-derived vary folds
8776    /// (`match.accept` -> `Accept`, `cors` -> `Origin`) resolved once.
8777    caches: Vec<Option<std::sync::Arc<crate::config::cache::CompiledRouteCache>>>,
8778    /// Preloaded mock response bodies per route index (DW-047): the
8779    /// bytes of a mock action's `body_file`, read once at compile time
8780    /// so the request path never touches the filesystem. `None` where
8781    /// the route carries no mock action or uses an inline `body`.
8782    mock_bodies: Vec<Option<bytes::Bytes>>,
8783    /// Effective security headers per route index (SEC-09, #213): the
8784    /// route's own `security_headers` block when present, else the
8785    /// gateway-level `default_security_headers` when the route has
8786    /// not opted out, else `None`. Computed once at compile time so
8787    /// the request path never re-merges.
8788    effective_security_headers: Vec<Option<crate::config::transforms::SecurityHeaders>>,
8789}
8790
8791impl RouteTable {
8792    fn empty() -> Self {
8793        RouteTable {
8794            exact: matchit::Router::new(),
8795            prefixes: Vec::new(),
8796            prefix_trie: PrefixTrie::new(),
8797            regex_set: regex::RegexSet::empty(),
8798            regex_indices: Vec::new(),
8799            regex_scores: Vec::new(),
8800            rewrite_regexes: Vec::new(),
8801            rewrite_substitutions: Vec::new(),
8802            cors_origins: Vec::new(),
8803            compression_types: Vec::new(),
8804            deprecations: Vec::new(),
8805            accept_media_types: Vec::new(),
8806            request_body_ops: Vec::new(),
8807            response_body_ops: Vec::new(),
8808            masking: Vec::new(),
8809            caches: Vec::new(),
8810            mock_bodies: Vec::new(),
8811            effective_security_headers: Vec::new(),
8812        }
8813    }
8814
8815    /// Resolve a request path to a route index. Precedence: exact template,
8816    /// then most-specific regex match, then longest prefix. `None` means no
8817    /// route.
8818    pub fn find(&self, path: &str) -> Option<usize> {
8819        self.find_full(path).map(|(idx, _)| idx)
8820    }
8821
8822    /// Like [`RouteTable::find`] but also returns the path parameters
8823    /// captured by an exact-template match (`{name}` segments), as
8824    /// (name, value) pairs in template order. Regex- and prefix-kind
8825    /// routes return an empty parameter list.
8826    pub fn find_full(&self, path: &str) -> Option<(usize, Vec<(String, String)>)> {
8827        if let Ok(m) = self.exact.at(path) {
8828            let params = m
8829                .params
8830                .iter()
8831                .map(|(k, v)| (k.to_string(), v.to_string()))
8832                .collect();
8833            return Some((*m.value, params));
8834        }
8835        // DP-06 (#254): when multiple regexes match, choose the most
8836        // specific one (highest score), not the first declared. Ties
8837        // break by declaration order (stable sort).
8838        let matches = self.regex_set.matches(path);
8839        let mut best: Option<(u32, usize)> = None; // (score, regex_set_index)
8840        for i in matches.iter() {
8841            let score = self.regex_scores[i];
8842            if best.is_none_or(|(s, _)| score > s) {
8843                best = Some((score, i));
8844            }
8845        }
8846        if let Some((_, i)) = best {
8847            return Some((self.regex_indices[i], Vec::new()));
8848        }
8849        // PERF-03 (#205): O(k) trie lookup replaces O(n) linear scan.
8850        if let Some(idx) = self.prefix_trie.longest_match(path) {
8851            return Some((idx, Vec::new()));
8852        }
8853        None
8854    }
8855
8856    /// Yield all candidate route indices for a path in precedence order
8857    /// (DP-06, #254): exact, then regex matches by specificity (highest
8858    /// first), then prefix matches by longest prefix. Each candidate
8859    /// also carries its path parameters (populated only for exact
8860    /// matches). The caller is expected to test non-path criteria
8861    /// (`route_applies`) on each candidate and stop at the first that
8862    /// fully matches -- this is the "fall-through" behavior.
8863    pub fn find_candidates(&self, path: &str) -> Vec<(usize, Vec<(String, String)>)> {
8864        let mut candidates = Vec::new();
8865
8866        // 1. Exact match (at most one).
8867        if let Ok(m) = self.exact.at(path) {
8868            let params = m
8869                .params
8870                .iter()
8871                .map(|(k, v)| (k.to_string(), v.to_string()))
8872                .collect();
8873            candidates.push((*m.value, params));
8874        }
8875
8876        // 2. Regex matches, most specific first.
8877        let matches = self.regex_set.matches(path);
8878        let mut regex_matches: Vec<(u32, usize)> = Vec::new();
8879        for i in matches.iter() {
8880            regex_matches.push((self.regex_scores[i], i));
8881        }
8882        // Sort by score descending; stable sort preserves declaration
8883        // order for ties. PERF: skipped entirely for the 0/1-match cases
8884        // (the overwhelming majority — sorting one element is a no-op).
8885        if regex_matches.len() > 1 {
8886            regex_matches.sort_by(|a, b| b.0.cmp(&a.0));
8887        }
8888        for (_, i) in regex_matches {
8889            candidates.push((self.regex_indices[i], Vec::new()));
8890        }
8891
8892        // 3. Prefix matches, longest first. PERF-03 (#205): the trie
8893        // walk visits only nodes along the path — O(k) in the path
8894        // length — replacing the O(n) scan-and-sort over every
8895        // configured prefix. The walk yields matched nodes in
8896        // increasing-depth order; reversing the node order gives
8897        // longest-first while each node's slice stays in declaration
8898        // order (duplicate configured prefixes fall through in
8899        // declaration order, matching the old stable sort's output).
8900        for idxs in self.prefix_trie.collect_matches(path).iter().rev() {
8901            for &idx in *idxs {
8902                candidates.push((idx, Vec::new()));
8903            }
8904        }
8905
8906        candidates
8907    }
8908
8909    /// The compiled rewrite regex for `idx`, if the route's proxy action
8910    /// carries a `path_rewrite.regex`.
8911    pub fn rewrite_regex(&self, idx: usize) -> Option<&regex::Regex> {
8912        self.rewrite_regexes.get(idx).and_then(|r| r.as_ref())
8913    }
8914
8915    /// The precompiled substitution template for `idx` (PERF-04, #206),
8916    /// if the route's proxy action carries a `path_rewrite.regex`.
8917    pub fn rewrite_substitution(&self, idx: usize) -> Option<&CompiledSubstitution> {
8918        self.rewrite_substitutions.get(idx).and_then(|s| s.as_ref())
8919    }
8920
8921    /// The precompiled CORS origin matcher for route `idx` (`None`: the
8922    /// route carries no `cors` block — exactly mirroring
8923    /// `gateway().routes[idx].cors`).
8924    pub fn cors_origins(&self, idx: usize) -> Option<&crate::config::CompiledCorsOrigins> {
8925        self.cors_origins.get(idx).and_then(|o| o.as_ref())
8926    }
8927
8928    /// The precompiled compression content-type filter for route `idx`
8929    /// (`None`: the route carries no `compression` block — exactly
8930    /// mirroring `gateway().routes[idx].compression`).
8931    pub fn compression_types(
8932        &self,
8933        idx: usize,
8934    ) -> Option<&crate::config::CompiledContentTypeFilter> {
8935        self.compression_types.get(idx).and_then(|t| t.as_ref())
8936    }
8937
8938    /// The precompiled deprecation header values for route `idx` (`None`:
8939    /// the route carries no `deprecation` block — exactly mirroring
8940    /// `gateway().routes[idx].deprecation`).
8941    pub fn deprecation(&self, idx: usize) -> Option<&crate::config::CompiledDeprecation> {
8942        self.deprecations.get(idx).and_then(|d| d.as_ref())
8943    }
8944
8945    /// The normalized `match.accept` media type for route `idx` (`None`:
8946    /// the route carries no accept criterion — exactly mirroring
8947    /// `gateway().routes[idx].r#match.accept`). This is the comparison
8948    /// key the proxy's Accept criterion must use, never the raw config
8949    /// string.
8950    pub fn accept_media_type(&self, idx: usize) -> Option<&str> {
8951        self.accept_media_types.get(idx).and_then(|a| a.as_deref())
8952    }
8953
8954    /// The precompiled REQUEST JSON body transform for route `idx`
8955    /// (`None`: the route transforms no request bodies — exactly
8956    /// mirroring `gateway().routes[idx].transforms.request.body`).
8957    pub fn request_body_ops(
8958        &self,
8959        idx: usize,
8960    ) -> Option<&crate::config::transforms::CompiledJsonTransform> {
8961        self.request_body_ops.get(idx).and_then(|t| t.as_ref())
8962    }
8963
8964    /// The precompiled RESPONSE JSON body transform for route `idx`
8965    /// (`None`: the route transforms no response bodies — exactly
8966    /// mirroring `gateway().routes[idx].transforms.response.body`).
8967    pub fn response_body_ops(
8968        &self,
8969        idx: usize,
8970    ) -> Option<&crate::config::transforms::CompiledJsonTransform> {
8971        self.response_body_ops.get(idx).and_then(|t| t.as_ref())
8972    }
8973
8974    /// The precompiled response field masking policy for route `idx`
8975    /// (`None`: the route masks nothing — exactly mirroring
8976    /// `gateway().routes[idx].masking`).
8977    pub fn masking(&self, idx: usize) -> Option<&crate::config::transforms::CompiledMasking> {
8978        self.masking.get(idx).and_then(|m| m.as_ref())
8979    }
8980
8981    /// The precompiled response-cache policy for route `idx` (`None`:
8982    /// the route's responses are never cached — exactly mirroring
8983    /// `gateway().routes[idx].cache`, with the policy-derived vary
8984    /// folds already resolved).
8985    pub fn cache(
8986        &self,
8987        idx: usize,
8988    ) -> Option<&std::sync::Arc<crate::config::cache::CompiledRouteCache>> {
8989        self.caches.get(idx).and_then(|c| c.as_ref())
8990    }
8991
8992    /// The preloaded mock response body for route `idx` (DW-047,
8993    /// `None`: the route carries no mock `body_file` — exactly
8994    /// mirroring `gateway().routes[idx].action.mock.body_file`).
8995    pub fn mock_body(&self, idx: usize) -> Option<&bytes::Bytes> {
8996        self.mock_bodies.get(idx).and_then(|b| b.as_ref())
8997    }
8998
8999    /// The effective security headers for a route (SEC-09, #213): the
9000    /// route's own `security_headers` block when present, else the
9001    /// gateway-level `default_security_headers` when the route has
9002    /// not opted out, else `None`.
9003    pub fn effective_security_headers(
9004        &self,
9005        idx: usize,
9006    ) -> Option<&crate::config::transforms::SecurityHeaders> {
9007        self.effective_security_headers
9008            .get(idx)
9009            .and_then(|sh| sh.as_ref())
9010    }
9011}
9012
9013/// A precompiled `path_rewrite.regex.substitution` (PERF-04, #206).
9014///
9015/// The `$1` / `${name}` substitution syntax is parsed ONCE at snapshot
9016/// compile time into a list of segments — literal text or a named/numeric
9017/// reference — so the per-request rewrite path never re-parses the
9018/// substitution string. Each segment is expanded against the regex
9019/// captures (and the route's `{param}` template captures) in a single
9020/// pass with a pre-sized `String`.
9021#[derive(Debug, Clone)]
9022pub struct CompiledSubstitution {
9023    segments: Vec<SubstSegment>,
9024    /// The precomputed output capacity: sum of all literal segment
9025    /// lengths, so the result `String` is allocated once at the right
9026    /// size (capture expansions add to it, but the literal baseline is
9027    /// the dominant cost for most templates).
9028    capacity: usize,
9029}
9030
9031/// One segment of a compiled substitution template.
9032#[derive(Debug, Clone)]
9033enum SubstSegment {
9034    /// Literal text copied verbatim.
9035    Literal(String),
9036    /// A numeric capture-group reference (`$1`, `${1}`).
9037    Numeric(usize),
9038    /// A named reference (`$name`, `${name}`): resolved against the
9039    /// regex's named capture groups first, then the route's `{param}`
9040    /// template captures.
9041    Named(String),
9042}
9043
9044impl CompiledSubstitution {
9045    /// Parse a `$1` / `${name}` substitution string into segments.
9046    /// Mirrors the grammar of `expand_substitution` in
9047    /// `dataplane/proxy.rs` exactly: `$` followed by digits, an
9048    /// identifier, or `{...}`; a lone `$` (end of string or followed
9049    /// by a non-identifier char) is kept literally.
9050    pub fn compile(substitution: &str) -> Self {
9051        let mut segments = Vec::new();
9052        let mut capacity = 0;
9053        let mut rest = substitution;
9054        let mut literal = String::new();
9055
9056        while let Some(dollar) = rest.find('$') {
9057            literal.push_str(&rest[..dollar]);
9058            let after = &rest[dollar + 1..];
9059            if after.is_empty() {
9060                literal.push('$');
9061                rest = after;
9062                break;
9063            }
9064            if let Some(braced) = after.strip_prefix('{') {
9065                match braced.find('}') {
9066                    Some(end) => {
9067                        if !literal.is_empty() {
9068                            capacity += literal.len();
9069                            segments.push(SubstSegment::Literal(std::mem::take(&mut literal)));
9070                        }
9071                        let name = &braced[..end];
9072                        if let Ok(i) = name.parse::<usize>() {
9073                            segments.push(SubstSegment::Numeric(i));
9074                        } else {
9075                            segments.push(SubstSegment::Named(name.to_string()));
9076                        }
9077                        rest = &braced[end + 1..];
9078                    }
9079                    None => {
9080                        literal.push('$');
9081                        rest = after;
9082                    }
9083                }
9084            } else {
9085                let end = after
9086                    .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
9087                    .unwrap_or(after.len());
9088                if end == 0 {
9089                    literal.push('$');
9090                    rest = after;
9091                } else {
9092                    if !literal.is_empty() {
9093                        capacity += literal.len();
9094                        segments.push(SubstSegment::Literal(std::mem::take(&mut literal)));
9095                    }
9096                    let name = &after[..end];
9097                    if let Ok(i) = name.parse::<usize>() {
9098                        segments.push(SubstSegment::Numeric(i));
9099                    } else {
9100                        segments.push(SubstSegment::Named(name.to_string()));
9101                    }
9102                    rest = &after[end..];
9103                }
9104            }
9105        }
9106        literal.push_str(rest);
9107        if !literal.is_empty() {
9108            capacity += literal.len();
9109            segments.push(SubstSegment::Literal(literal));
9110        }
9111
9112        CompiledSubstitution { segments, capacity }
9113    }
9114
9115    /// Expand against regex captures and route `{param}` captures.
9116    /// The result `String` is pre-sized to the compiled literal
9117    /// capacity, so the common case (a template with mostly literal
9118    /// text and a few short capture refs) allocates exactly once.
9119    pub fn expand(&self, caps: &regex::Captures<'_>, params: &[(String, String)]) -> String {
9120        let mut out = String::with_capacity(self.capacity);
9121        for seg in &self.segments {
9122            match seg {
9123                SubstSegment::Literal(s) => out.push_str(s),
9124                SubstSegment::Numeric(i) => {
9125                    if let Some(m) = caps.get(*i) {
9126                        out.push_str(m.as_str());
9127                    }
9128                }
9129                SubstSegment::Named(name) => {
9130                    if let Some(m) = caps.name(name) {
9131                        out.push_str(m.as_str());
9132                    } else if let Some((_, v)) = params.iter().find(|(n, _)| n == name) {
9133                        out.push_str(v);
9134                    }
9135                }
9136            }
9137        }
9138        out
9139    }
9140
9141    /// The precomputed literal capacity (PERF-04, #206): the sum of
9142    /// all literal segment lengths, so a caller building a larger
9143    /// string around the expansion can pre-size its own output.
9144    pub fn capacity(&self) -> usize {
9145        self.capacity
9146    }
9147}
9148
9149/// Output of the pure compile step: everything a [`Snapshot`] needs except
9150/// its generation id, which is assigned at publish time.
9151#[derive(Debug)]
9152pub struct Compiled {
9153    gateway: Arc<Gateway>,
9154    routes: Arc<RouteTable>,
9155    content_hash: u64,
9156    /// USA-12 (#233): the developer portal, built at compile time when
9157    /// `lifecycle.portal.enabled` is true. `None` when the portal is
9158    /// disabled or no lifecycle config is present.
9159    portal: Option<crate::lifecycle::DevPortal>,
9160}
9161
9162impl Compiled {
9163    pub fn gateway(&self) -> &Gateway {
9164        &self.gateway
9165    }
9166
9167    pub fn route_table(&self) -> &RouteTable {
9168        &self.routes
9169    }
9170
9171    pub fn content_hash(&self) -> u64 {
9172        self.content_hash
9173    }
9174
9175    /// USA-12 (#233): the developer portal, when built.
9176    pub fn portal(&self) -> Option<&crate::lifecycle::DevPortal> {
9177        self.portal.as_ref()
9178    }
9179}
9180
9181/// Immutable, fully compiled configuration generation, cheap to share.
9182#[derive(Debug)]
9183pub struct Snapshot {
9184    generation: u64,
9185    content_hash: u64,
9186    gateway: Arc<Gateway>,
9187    routes: Arc<RouteTable>,
9188    /// USA-12 (#233): the developer portal, when built.
9189    portal: Option<crate::lifecycle::DevPortal>,
9190}
9191
9192impl Snapshot {
9193    /// The empty generation-0 snapshot a cold gateway serves before any
9194    /// config has been published.
9195    pub fn empty() -> Self {
9196        Snapshot {
9197            generation: 0,
9198            content_hash: 0,
9199            gateway: Arc::new(Gateway {
9200                version: 1,
9201                trusted_proxies: vec![],
9202                listeners: Vec::new(),
9203                routes: Vec::new(),
9204                services: Vec::new(),
9205                upstreams: Vec::new(),
9206                consumers: Vec::new(),
9207                policies: Vec::new(),
9208                global_policies: Vec::new(),
9209                authorization: None,
9210                default_security_headers: None,
9211                waf: None,
9212                max_concurrent_requests: None,
9213                load_shed_dry_run: false,
9214                jwt_providers: Vec::new(),
9215                admin: None,
9216                allow_empty_routes: false,
9217                hmac_auth: None,
9218                webhooks: Vec::new(),
9219                analytics: None,
9220                analytics_stream: None,
9221                geoip: None,
9222                admission_queue: None,
9223                mtls_consumer_mapping: None,
9224                mtls_forward_headers: None,
9225                license: None,
9226                oidc_providers: Vec::new(),
9227                redis_rate_limiter: None,
9228                redis_quotas: None,
9229                redis_cache: None,
9230                config_convergence: None,
9231                plugins: Vec::new(),
9232                filter_chain: None,
9233                plugin_registry: None,
9234                ai: None,
9235                fleet: None,
9236                mesh: None,
9237                lifecycle: None,
9238                ssrf_filter: None,
9239            }),
9240            routes: Arc::new(RouteTable::empty()),
9241            portal: None,
9242        }
9243    }
9244
9245    pub fn generation(&self) -> u64 {
9246        self.generation
9247    }
9248
9249    /// Build a generation-0 snapshot from a [`Compiled`] value (DW-102:
9250    /// replay and other offline tools that need a snapshot without
9251    /// publishing through [`ConfigState`]). The generation id is 0
9252    /// (replay does not need a real generation id; it only reads the
9253    /// compiled route table and gateway).
9254    pub fn from_compiled(compiled: Compiled) -> Self {
9255        Snapshot {
9256            generation: 0,
9257            content_hash: compiled.content_hash,
9258            gateway: compiled.gateway,
9259            routes: compiled.routes,
9260            portal: compiled.portal,
9261        }
9262    }
9263
9264    pub fn content_hash(&self) -> u64 {
9265        self.content_hash
9266    }
9267
9268    pub fn gateway(&self) -> &Gateway {
9269        &self.gateway
9270    }
9271
9272    pub fn route_table(&self) -> &RouteTable {
9273        &self.routes
9274    }
9275
9276    /// USA-12 (#233): the developer portal, when built.
9277    pub fn portal(&self) -> Option<&crate::lifecycle::DevPortal> {
9278        self.portal.as_ref()
9279    }
9280
9281    /// Convenience: resolve a path to the matching route, if any.
9282    pub fn match_route(&self, path: &str) -> Option<&Route> {
9283        self.routes.find(path).map(|i| &self.gateway.routes[i])
9284    }
9285}
9286
9287/// Identity of a successfully published snapshot, for logs/metrics.
9288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9289pub struct SnapshotInfo {
9290    pub generation: u64,
9291    /// Per-process change-detection token over the normalized config.
9292    /// NOT stable across Rust versions (SipHash internals may change) and
9293    /// NOT a content digest; never persist it, compare it across
9294    /// processes/versions, or treat it as cryptographic integrity.
9295    pub content_hash: u64,
9296    pub route_count: usize,
9297}
9298
9299/// The single hashing step behind both the whole-gateway content hash
9300/// in [`compile`] and the per-entity [`entity_content_hash`]: a
9301/// `DefaultHasher` (SipHash-1-3) over a normalized YAML serialization.
9302fn normalized_hash(yaml: &str) -> u64 {
9303    let mut hasher = DefaultHasher::new();
9304    yaml.hash(&mut hasher);
9305    hasher.finish()
9306}
9307
9308/// Per-entity content hash: the entity's normalized YAML serialization
9309/// (stable field order, defaulted-empty fields omitted — the same
9310/// normalization `gateway_to_yaml` applies to a whole [`Gateway`])
9311/// through the same `DefaultHasher` as the snapshot content hash. Two
9312/// entities of one kind hash equal exactly when their normalized
9313/// serializations are equal, so source key order and omitted defaults
9314/// do not affect the result. Same caveats as
9315/// [`SnapshotInfo::content_hash`]: a per-process change-detection
9316/// token, not stable across Rust versions, not a content digest.
9317pub fn entity_content_hash<T: serde::Serialize>(entity: &T) -> Result<u64, String> {
9318    let yaml =
9319        serde_yaml_ng::to_string(entity).map_err(|e| format!("normalization failed: {e}"))?;
9320    Ok(normalized_hash(&yaml))
9321}
9322
9323/// Compute a specificity score for a regex pattern (DP-06, #254).
9324///
9325/// The score is the count of literal (non-meta) characters in the
9326/// pattern. A pattern like `/x/[0-9]+` scores higher than `/x/`
9327/// because it has more literal characters and is therefore more
9328/// specific. This is a simple heuristic that avoids pulling in a
9329/// regex AST crate; it is sufficient for the common case of
9330/// distinguishing broad vs. narrow path patterns.
9331///
9332/// Meta characters counted as non-literal: `\`, `.`, `*`, `+`, `?`,
9333/// `(`, `)`, `[`, `]`, `{`, `}`, `|`, `^`, `$`. Escaped characters
9334/// (`\.`) count the escaped char as literal but the backslash as
9335/// non-literal, which is the right relative ordering.
9336fn regex_specificity_score(pattern: &str) -> u32 {
9337    pattern
9338        .chars()
9339        .filter(|c| {
9340            !matches!(
9341                c,
9342                '\\' | '.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$'
9343            )
9344        })
9345        .count() as u32
9346}
9347
9348/// Pure compile: validated [`Gateway`] -> [`Compiled`]. Fails with
9349/// [`CompileError::Validation`] on semantic issues, or with
9350/// [`CompileError::InvalidRegex`] / [`CompileError::RouteConflict`] when
9351/// schema-valid config still cannot compile.
9352pub fn compile(gateway: &Gateway) -> Result<Compiled, CompileError> {
9353    let issues = validate(gateway);
9354    if !issues.is_empty() {
9355        return Err(CompileError::Validation(issues));
9356    }
9357
9358    let mut exact = matchit::Router::new();
9359    let mut prefixes = Vec::new();
9360    let mut prefix_trie = PrefixTrie::new();
9361    let mut regex_patterns = Vec::new();
9362    let mut regex_indices = Vec::new();
9363    let mut regex_scores = Vec::new();
9364    let mut rewrite_regexes: Vec<Option<regex::Regex>> = vec![None; gateway.routes.len()];
9365    let mut rewrite_substitutions: Vec<Option<CompiledSubstitution>> =
9366        vec![None; gateway.routes.len()];
9367
9368    for (idx, route) in gateway.routes.iter().enumerate() {
9369        let path = &route.r#match.path;
9370        match path.kind {
9371            PathMatchKind::Exact => {
9372                exact
9373                    .insert(&path.value, idx)
9374                    .map_err(|e| CompileError::RouteConflict {
9375                        route: route.name.clone(),
9376                        pattern: path.value.clone(),
9377                        message: e.to_string(),
9378                    })?;
9379            }
9380            PathMatchKind::Prefix => {
9381                let prefix = path.value.trim_end_matches('/').to_string();
9382                // PERF-03 (#205): insert into the trie for O(k) lookup.
9383                // Duplicate prefixes each keep their own entry in the
9384                // node (declaration order) so the candidate fall-through
9385                // (find_candidates) offers all of them; `find_full`'s
9386                // first-declared-wins tie rule reads the node's first
9387                // entry. The Vec mirrors the trie for Debug output only.
9388                prefix_trie.insert(&prefix, idx);
9389                prefixes.push((prefix, idx));
9390            }
9391            PathMatchKind::Regex => {
9392                regex::Regex::new(&path.value).map_err(|e| CompileError::InvalidRegex {
9393                    route: route.name.clone(),
9394                    pattern: path.value.clone(),
9395                    message: e.to_string(),
9396                })?;
9397                // DP-06 (#254): score the regex pattern for specificity.
9398                // Higher score = more literal characters = more specific.
9399                // When multiple regexes match a path, the most specific
9400                // one wins instead of the first declared.
9401                regex_scores.push(regex_specificity_score(&path.value));
9402                regex_patterns.push(path.value.clone());
9403                regex_indices.push(idx);
9404            }
9405        }
9406        if let RouteAction::Proxy {
9407            rewrite:
9408                Some(PathRewrite::Regex {
9409                    pattern,
9410                    substitution,
9411                }),
9412        } = &route.action
9413        {
9414            let compiled = regex::Regex::new(pattern).map_err(|e| CompileError::InvalidRegex {
9415                route: route.name.clone(),
9416                pattern: pattern.clone(),
9417                message: e.to_string(),
9418            })?;
9419            rewrite_regexes[idx] = Some(compiled);
9420            rewrite_substitutions[idx] = Some(CompiledSubstitution::compile(substitution));
9421        }
9422    }
9423
9424    let regex_set =
9425        regex::RegexSet::new(regex_patterns).map_err(|e| CompileError::Internal(e.to_string()))?;
9426
9427    let yaml = gateway_to_yaml(gateway)
9428        .map_err(|e| CompileError::Internal(format!("normalization failed: {e}")))?;
9429    let content_hash = normalized_hash(&yaml);
9430
9431    // DW-027 edge-policy matching state, precompiled per route index in
9432    // lockstep with `Route::cors` / `Route::compression` (built from the
9433    // same route list, so lookups mirror the config the proxy reads):
9434    // normalized CORS origins and lowercased content-type prefixes are
9435    // computed once here instead of on every request.
9436    let cors_origins = gateway
9437        .routes
9438        .iter()
9439        .map(|r| {
9440            r.cors
9441                .as_ref()
9442                .map(crate::config::CompiledCorsOrigins::compile)
9443        })
9444        .collect();
9445    let compression_types = gateway
9446        .routes
9447        .iter()
9448        .map(|r| {
9449            r.compression
9450                .as_ref()
9451                .map(crate::config::CompiledContentTypeFilter::compile)
9452        })
9453        .collect();
9454    // DW-048: the deprecation header values, parsed from their config
9455    // HTTP-dates once here (validation guarantees they parse; the
9456    // filter_map fallback documents the same unreachable-skip contract
9457    // as the CORS/compression compilations above).
9458    let deprecations = gateway
9459        .routes
9460        .iter()
9461        .map(|r| {
9462            r.deprecation
9463                .as_ref()
9464                .map(crate::config::CompiledDeprecation::compile)
9465        })
9466        .collect();
9467    // DW-048: the accept criterion's comparison key, normalized once
9468    // here instead of on every request — validation guarantees every
9469    // configured value normalizes, so the None fallback is unreachable
9470    // (the same skip contract as the compilations above). Without this
9471    // the raw config string reached the hot path, and a padded
9472    // `match.accept` published cleanly only to 404 every request.
9473    let accept_media_types = gateway
9474        .routes
9475        .iter()
9476        .map(|r| {
9477            r.r#match
9478                .accept
9479                .as_deref()
9480                .and_then(crate::config::versioning::normalize_media_type)
9481        })
9482        .collect();
9483    // DW-028: the JSON body transforms' pointers, parsed once here
9484    // (validation guarantees they parse; the same unreachable-skip
9485    // contract as the compilations above). Header and query ops have
9486    // no parseable grammar and deliberately ride the config value.
9487    let request_body_ops = gateway
9488        .routes
9489        .iter()
9490        .map(|r| {
9491            r.transforms
9492                .as_ref()
9493                .and_then(|t| t.request.as_ref())
9494                .and_then(|req| req.body.as_ref())
9495                .map(|b| crate::config::transforms::CompiledJsonTransform::compile(&b.json))
9496        })
9497        .collect();
9498    let response_body_ops = gateway
9499        .routes
9500        .iter()
9501        .map(|r| {
9502            r.transforms
9503                .as_ref()
9504                .and_then(|t| t.response.as_ref())
9505                .and_then(|resp| resp.body.as_ref())
9506                .map(|b| crate::config::transforms::CompiledJsonTransform::compile(&b.json))
9507        })
9508        .collect();
9509    // DW-029: masking pointers, parsed once here (validation guarantees
9510    // they parse; the same unreachable-skip contract). Group membership
9511    // varies per request, so the union resolves at apply time.
9512    let masking = gateway
9513        .routes
9514        .iter()
9515        .map(|r| {
9516            r.masking
9517                .as_ref()
9518                .map(crate::config::transforms::CompiledMasking::compile)
9519        })
9520        .collect();
9521    // DW-037: compiled cache policies, including the policy-derived
9522    // vary folds (`match.accept` -> Accept, `cors` -> Origin) resolved
9523    // once here so the request path never re-derives the vary set.
9524    let caches = gateway
9525        .routes
9526        .iter()
9527        .map(|r| {
9528            r.cache.as_ref().map(|c| {
9529                std::sync::Arc::new(crate::config::cache::CompiledRouteCache::compile(
9530                    c,
9531                    r.r#match.accept.is_some(),
9532                    r.cors.is_some(),
9533                ))
9534            })
9535        })
9536        .collect();
9537    // DW-047: preload mock `body_file` bytes once here so the request
9538    // path never touches the filesystem. Validation has already checked
9539    // the file exists and is readable; a read failure here is a
9540    // generation tear (the file was removed between validate and
9541    // compile) and surfaces as a compile error naming the route.
9542    let mock_bodies = gateway
9543        .routes
9544        .iter()
9545        .map(|r| match &r.action {
9546            RouteAction::Mock { mock } => mock
9547                .body_file
9548                .as_ref()
9549                .and_then(|path| std::fs::read(path).ok().map(bytes::Bytes::from)),
9550            _ => None,
9551        })
9552        .collect();
9553
9554    // SEC-09 (#213): compute the effective security headers per route
9555    // — the route's own block when present, else the gateway-level
9556    // default when the route has not opted out, else None. Computed
9557    // once here so the request path never re-merges.
9558    let effective_security_headers = gateway
9559        .routes
9560        .iter()
9561        .map(|r| {
9562            if let Some(sh) = &r.security_headers {
9563                Some(sh.clone())
9564            } else if !r.security_headers_opt_out {
9565                gateway.default_security_headers.clone()
9566            } else {
9567                None
9568            }
9569        })
9570        .collect();
9571
9572    // USA-12 (#233): build the developer portal at compile time when
9573    // `lifecycle.portal.enabled` is true. The portal is a read-only
9574    // static HTML page aggregating the configured OpenAPI specs.
9575    let portal = gateway
9576        .lifecycle
9577        .as_ref()
9578        .and_then(|l| l.portal.as_ref())
9579        .filter(|p| p.enabled)
9580        .map(crate::lifecycle::DevPortal::build);
9581
9582    Ok(Compiled {
9583        gateway: Arc::new(gateway.clone()),
9584        portal,
9585        routes: Arc::new(RouteTable {
9586            exact,
9587            prefixes,
9588            prefix_trie,
9589            regex_set,
9590            regex_indices,
9591            regex_scores,
9592            rewrite_regexes,
9593            rewrite_substitutions,
9594            cors_origins,
9595            compression_types,
9596            deprecations,
9597            accept_media_types,
9598            request_body_ops,
9599            response_body_ops,
9600            masking,
9601            caches,
9602            mock_bodies,
9603            effective_security_headers,
9604        }),
9605        content_hash,
9606    })
9607}
9608
9609/// Holds the currently published [`Snapshot`] behind an `ArcSwap` and owns
9610/// the monotonic generation counter. Shared across the dataplane (read) and
9611/// the config source / hot reload (write, DW-006). May carry the process's
9612/// [`crate::events::EventBus`] (DW-044): every publish outcome emits one event
9613/// (`config_published` / `config_rejected`) onto it, which is why the bus
9614/// sits in a domain BELOW this one.
9615pub struct ConfigState {
9616    snapshot: ArcSwap<Snapshot>,
9617    generation: AtomicU64,
9618    /// Serializes publish attempts so generation ids stay gap-free and
9619    /// monotonic under concurrent writers.
9620    publish_lock: Mutex<()>,
9621    /// The event bus publish outcomes emit onto (DW-044). Attached at
9622    /// construction ([`ConfigState::with_event_bus`]) or by the dataplane
9623    /// ([`ConfigState::attach_event_bus`], first attach wins). Interior
9624    /// mutability because the bus is created before/independently of the
9625    /// state in some wiring orders (the binary creates both up front; a
9626    /// bare `ConfigState::new` gets one when a `DataPlane` is built from
9627    /// it, so config events are never silently missing in a live
9628    /// gateway).
9629    events: std::sync::RwLock<Option<Arc<crate::events::EventBus>>>,
9630}
9631
9632impl Default for ConfigState {
9633    fn default() -> Self {
9634        ConfigState {
9635            snapshot: ArcSwap::from_pointee(Snapshot::empty()),
9636            generation: AtomicU64::new(0),
9637            publish_lock: Mutex::new(()),
9638            events: std::sync::RwLock::new(None),
9639        }
9640    }
9641}
9642
9643impl ConfigState {
9644    pub fn new() -> Self {
9645        Self::default()
9646    }
9647
9648    /// New state with its event bus attached from the start (DW-044):
9649    /// the binary's wiring, so the very first (startup) publish already
9650    /// emits onto the bus the deliverer will drain.
9651    pub fn with_event_bus(bus: Arc<crate::events::EventBus>) -> Self {
9652        let state = Self::default();
9653        state.attach_event_bus(bus);
9654        state
9655    }
9656
9657    /// Attach the event bus if none is attached yet (first attach wins;
9658    /// a live bus is never swapped underneath an emitter). Idempotent.
9659    pub fn attach_event_bus(&self, bus: Arc<crate::events::EventBus>) {
9660        let mut slot = self.events.write().expect("event bus slot poisoned");
9661        if slot.is_none() {
9662            *slot = Some(bus);
9663        }
9664    }
9665
9666    /// The attached event bus, if any (the dataplane adopts/creates it).
9667    pub fn event_bus(&self) -> Option<Arc<crate::events::EventBus>> {
9668        self.events.read().expect("event bus slot poisoned").clone()
9669    }
9670
9671    /// The emitter for publish outcomes (a no-op handle when no bus is
9672    /// attached — `None` at the call site keeps emission optional in
9673    /// unwired constructions).
9674    fn event_emitter(&self) -> Option<crate::events::Emitter> {
9675        self.event_bus().map(|bus| bus.emitter())
9676    }
9677
9678    /// Currently published snapshot (load is lock-free).
9679    pub fn snapshot(&self) -> Arc<Snapshot> {
9680        self.snapshot.load_full()
9681    }
9682
9683    /// Validate, compile, and atomically publish. On ANY failure the
9684    /// currently-published snapshot is untouched (rollback = not-published);
9685    /// the generation counter is only advanced on success.
9686    ///
9687    /// DW-044: emits `config_published` (generation, content hash, route
9688    /// count) on success and `config_rejected` (issue count, plus the
9689    /// generation still running) on failure — every publish path (cold
9690    /// start, hot reload, admin POST) funnels through here, so one
9691    /// emission site covers them all. Emission is the bus's bounded
9692    /// non-blocking hand-off; a full queue drops and counts.
9693    pub fn compile_and_publish(&self, gateway: &Gateway) -> Result<SnapshotInfo, CompileError> {
9694        let _guard = self.publish_lock.lock().unwrap_or_else(|p| p.into_inner());
9695        let emitter = self.event_emitter();
9696        let compiled = match compile(gateway) {
9697            Ok(compiled) => compiled,
9698            Err(err) => {
9699                if let Some(emitter) = &emitter {
9700                    let issue_count = match &err {
9701                        CompileError::Validation(issues) => issues.len(),
9702                        // A compile-stage failure (regex, route conflict)
9703                        // is one named problem; its Display carries it.
9704                        _ => 1,
9705                    };
9706                    emitter.emit(
9707                        crate::events::EventKind::ConfigRejected,
9708                        crate::events::EventPayload {
9709                            issue_count: Some(issue_count),
9710                            generation: Some(self.snapshot.load().generation),
9711                            ..crate::events::EventPayload::default()
9712                        },
9713                    );
9714                }
9715                return Err(err);
9716            }
9717        };
9718        let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
9719        let snapshot = Snapshot {
9720            generation,
9721            content_hash: compiled.content_hash,
9722            gateway: compiled.gateway,
9723            routes: compiled.routes,
9724            portal: compiled.portal,
9725        };
9726        let info = SnapshotInfo {
9727            generation,
9728            content_hash: snapshot.content_hash,
9729            route_count: snapshot.gateway.routes.len(),
9730        };
9731        self.snapshot.store(Arc::new(snapshot));
9732        if let Some(emitter) = &emitter {
9733            emitter.emit(
9734                crate::events::EventKind::ConfigPublished,
9735                crate::events::EventPayload {
9736                    generation: Some(info.generation),
9737                    content_hash: Some(info.content_hash),
9738                    route_count: Some(info.route_count),
9739                    ..crate::events::EventPayload::default()
9740                },
9741            );
9742        }
9743        Ok(info)
9744    }
9745}