Skip to main content

mesofact_core/
policy.rs

1//! Declared-vs-enforced route policy (R749-T1) — the general form of the rule
2//! [`crate::manifest`] fields keep re-learning one field at a time.
3//!
4//! **The rule.** A route policy that is declared in `mesofact.routes.ts` and
5//! not enforced by the tier actually serving it is a hard error, named at
6//! startup. Never a warning, never a skip, never fail-open.
7//!
8//! **Why a mechanism rather than another one-off check.** Express middleware is
9//! a function you can watch run; a declared policy that nothing wired looks
10//! byte-identical to one that is enforced, from outside the process and from
11//! inside the manifest alike. That class has now bitten four times — `requires:
12//! ["user"]` served fail-open by `mesofact serve` (R556-B13), `route_headers`
13//! dropped on a `worker` → `passway` front-door flip (R749-F3),
14//! `cache_policy` inert in the serve tier and `concurrency` unread by it
15//! (R746-S4's audit, W225 §2c). Each was found by someone reading the code, not
16//! by the system. Four is enough to stop fixing instances.
17//!
18//! **The shape is advertise-and-check**, reused from the bundle/runtime
19//! contract (R746-F6, W272): a tier states the policy set it implements
20//! ([`PolicySupport`]), and startup diffs that set against what the manifest
21//! actually declares ([`check_manifest`]). A tier that gains an enforcement
22//! point edits one line here; a tier that never does refuses to serve the
23//! routes it would lie about.
24//!
25//! **Unknown fields refuse too**, and that is the half that makes the class
26//! impossible rather than merely closed today. A hand-written serde slice can
27//! only miss a policy field added after it — silently, by construction, which
28//! is the exact defect. So the check walks the manifest's *raw JSON* keys and
29//! refuses any route key it cannot classify, instead of deserializing into a
30//! struct that would discard it. An old binary handed a manifest from a newer
31//! build stops, naming the field.
32//!
33//! **The escape hatch is an operator assertion, not a downgrade.** A policy may
34//! be [`PolicySupport::delegate`]d to something in front of the process — an
35//! authenticating edge, a CDN. That is the generalization of R556-B13's
36//! `--trust-edge-auth`: it stays a positive claim someone made, recorded in the
37//! process's own startup log, and it can be wrong — but it cannot be *silent*,
38//! which is the property this module is defending.
39
40use std::collections::{BTreeMap, BTreeSet};
41use std::fmt;
42
43/// A route field that changes **serving** behaviour, and can therefore fail
44/// open when the serving tier does not implement it.
45///
46/// Deliberately not "every field on [`crate::manifest::Route`]" — build-time
47/// and publish-time fields (`prerender`, `data_inputs`, `placement`,
48/// `source_reads`, `hydration`) are consumed before a request exists, so a
49/// server that ignores them cannot serve a route that quietly lacks a policy
50/// it declared. See [`STRUCTURAL_FIELDS`] for that half of the partition;
51/// every key on `Route` belongs to exactly one of the two, pinned by
52/// `every_route_field_is_classified`.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
54pub enum RoutePolicy {
55    /// `requires: ["user" | "project" | "region"]` — the auth gate. The one
56    /// whose fail-open is a confidentiality breach rather than a performance
57    /// regression, which is why R556-B13 was filed at severity high.
58    Requires,
59    /// `cache_policy: { ttl, swr, negative_ttl, vary }`.
60    CachePolicy,
61    /// `concurrency` — per-route in-flight cap.
62    Concurrency,
63    /// `resilience: { retry, timeout_ms }` (W181).
64    Resilience,
65}
66
67impl RoutePolicy {
68    /// Every policy, in declaration order. Nothing at compile time checks this
69    /// list is complete — `parse()` searches it at runtime — so a variant left
70    /// out here is caught only by `every_route_field_is_classified` below.
71    pub const ALL: [RoutePolicy; 4] = [
72        RoutePolicy::Requires,
73        RoutePolicy::CachePolicy,
74        RoutePolicy::Concurrency,
75        RoutePolicy::Resilience,
76    ];
77
78    /// The manifest / `defineRoutes` field name. This is what an author reads
79    /// in their own routes file, so it is what a refusal message must name.
80    pub const fn field(self) -> &'static str {
81        match self {
82            RoutePolicy::Requires => "requires",
83            RoutePolicy::CachePolicy => "cache_policy",
84            RoutePolicy::Concurrency => "concurrency",
85            RoutePolicy::Resilience => "resilience",
86        }
87    }
88
89    /// What an author expects the field to *do*, one clause. Rendered into the
90    /// refusal so the message says what is being lost, not just which key.
91    pub const fn effect(self) -> &'static str {
92        match self {
93            RoutePolicy::Requires => "gate the route behind a resolved session",
94            RoutePolicy::CachePolicy => "cache the response for the declared ttl/swr",
95            RoutePolicy::Concurrency => "cap in-flight requests for this route",
96            RoutePolicy::Resilience => "retry and time-bound the render",
97        }
98    }
99
100    /// Parse a field name, for `--policy-delegated` / `MESOFACT_POLICY_DELEGATED`.
101    pub fn parse(field: &str) -> Option<Self> {
102        Self::ALL.into_iter().find(|p| p.field() == field)
103    }
104
105    /// For a policy carried as a JSON object, the sub-keys this build actually
106    /// implements. Empty = the policy is a scalar/array with no inner shape.
107    ///
108    /// Checked as strictly as the top level, because a policy's *sub*-field is
109    /// the same defect one level down and hides better: `serde` on
110    /// [`crate::manifest::CachePolicy`] silently drops an unknown key, so a
111    /// `cache_policy.private` added by a newer build would deserialize into a
112    /// policy that looks complete and quietly is not.
113    ///
114    /// `resilience.queue` is deliberately **absent** — W181 reserves the slot
115    /// for v2 and `defineRoutes` rejects it today, so the type exists only so
116    /// v1 binaries keep deserializing v2 manifests. Deserializing one is not
117    /// the same as honouring it; a manifest that carries a queue policy must
118    /// not be served as though the queueing happens.
119    pub const fn subfields(self) -> &'static [&'static str] {
120        match self {
121            RoutePolicy::CachePolicy => &["ttl", "swr", "negative_ttl", "vary"],
122            RoutePolicy::Resilience => &["retry", "timeout_ms"],
123            RoutePolicy::Requires | RoutePolicy::Concurrency => &[],
124        }
125    }
126
127    /// Sub-keys that exist on the manifest type and are deliberately not
128    /// implemented. Listing them is what turns "we left it out" into a
129    /// decision the completeness gate can check, and it keeps
130    /// [`subfields`](Self::subfields)'s omissions from reading as oversights.
131    pub const fn reserved_subfields(self) -> &'static [&'static str] {
132        match self {
133            // W181 § "v1 scope" — type slot only, rejected at `defineRoutes`.
134            RoutePolicy::Resilience => &["queue"],
135            _ => &[],
136        }
137    }
138}
139
140impl fmt::Display for RoutePolicy {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        f.write_str(self.field())
143    }
144}
145
146/// Route keys that carry no serving policy — consumed by the build, the
147/// publisher, or the router's own addressing, all of which happen before a
148/// request exists.
149///
150/// This list is the *other half* of the partition [`RoutePolicy`] opens, and
151/// it is exhaustive on purpose: a key in neither list is an unknown policy and
152/// refuses ([`Violation::UnknownField`]). Growing this list is the deliberate
153/// act of saying "this new field cannot fail open".
154pub const STRUCTURAL_FIELDS: [&str; 8] = [
155    // Routing identity — a server that ignored these would not route at all.
156    "route",
157    "mode",
158    "render_entrypoint",
159    // Build-time inference and build-time inputs.
160    "source_reads",
161    "data_inputs",
162    // Build/publish-time: which instances exist, which bundle, which script.
163    "prerender",
164    "placement",
165    "hydration",
166];
167
168/// The policy set one serving tier implements, plus the set an operator has
169/// asserted is enforced in front of it.
170///
171/// Constructed at startup by whichever binary is about to serve, so the claim
172/// lives next to the code that makes it true rather than in a doc.
173#[derive(Debug, Clone)]
174pub struct PolicySupport {
175    tier: String,
176    enforced: BTreeSet<RoutePolicy>,
177    delegated: BTreeSet<RoutePolicy>,
178}
179
180impl PolicySupport {
181    /// A tier that enforces nothing. `tier` names the binary/subcommand as an
182    /// operator would type it (`mesofact serve`), since that is the thing they
183    /// have to change.
184    pub fn new(tier: impl Into<String>) -> Self {
185        Self {
186            tier: tier.into(),
187            enforced: BTreeSet::new(),
188            delegated: BTreeSet::new(),
189        }
190    }
191
192    /// Advertise a policy this tier implements itself.
193    #[must_use]
194    pub fn enforces(mut self, policy: RoutePolicy) -> Self {
195        self.enforced.insert(policy);
196        self
197    }
198
199    /// Record an operator's assertion that something in front of this process
200    /// enforces `policy` (an authenticating edge, a CDN). Distinct from
201    /// [`enforces`](Self::enforces) so the startup log can say which of the two
202    /// is carrying a route — "we do this" and "someone says they do this" are
203    /// not the same claim and should not print the same.
204    #[must_use]
205    pub fn delegate(mut self, policy: RoutePolicy) -> Self {
206        self.delegated.insert(policy);
207        self
208    }
209
210    pub fn tier(&self) -> &str {
211        &self.tier
212    }
213
214    pub fn covers(&self, policy: RoutePolicy) -> bool {
215        self.enforced.contains(&policy) || self.delegated.contains(&policy)
216    }
217
218    pub fn is_delegated(&self, policy: RoutePolicy) -> bool {
219        self.delegated.contains(&policy)
220    }
221
222    /// Policies this tier implements, for the startup log.
223    pub fn enforced(&self) -> impl Iterator<Item = RoutePolicy> + '_ {
224        self.enforced.iter().copied()
225    }
226
227    /// Policies covered only by an operator assertion, for the startup log.
228    pub fn delegated(&self) -> impl Iterator<Item = RoutePolicy> + '_ {
229        self.delegated.iter().copied()
230    }
231}
232
233/// One route declaring something this tier will not do.
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub enum Violation {
236    /// A known policy, declared, and neither enforced nor delegated.
237    Unenforced { route: String, policy: RoutePolicy },
238    /// A route key (or policy sub-key) nothing in this binary implements —
239    /// either a manifest from a newer build, or a slot reserved for a version
240    /// this one is not. Refusing is the whole point: a field we cannot even
241    /// name is the maximally-silent case of the defect, and it is the half that
242    /// makes the class impossible rather than merely closed today.
243    UnknownField { route: String, field: String },
244}
245
246impl Violation {
247    fn route(&self) -> &str {
248        match self {
249            Violation::Unenforced { route, .. } | Violation::UnknownField { route, .. } => route,
250        }
251    }
252
253    fn line(&self) -> String {
254        match self {
255            Violation::Unenforced { route, policy } => format!(
256                "  {route} declares `{}` — nothing here will {}",
257                policy.field(),
258                policy.effect(),
259            ),
260            Violation::UnknownField { route, field } => format!(
261                "  {route} declares `{field}`, which nothing in this binary implements — the \
262                 manifest is either newer than this build or uses a slot reserved for one"
263            ),
264        }
265    }
266}
267
268/// Every violation found, rendered as the refusal an operator reads.
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct PolicyRefusal {
271    pub tier: String,
272    pub violations: Vec<Violation>,
273}
274
275impl fmt::Display for PolicyRefusal {
276    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277        let n = self.violations.len();
278        write!(
279            f,
280            "refusing to start: {n} route policy declaration(s) that `{}` does not enforce.\n",
281            self.tier,
282        )?;
283        for v in &self.violations {
284            writeln!(f, "{}", v.line())?;
285        }
286        let unknown = self
287            .violations
288            .iter()
289            .any(|v| matches!(v, Violation::UnknownField { .. }));
290        let known: BTreeSet<&'static str> = self
291            .violations
292            .iter()
293            .filter_map(|v| match v {
294                Violation::Unenforced { policy, .. } => Some(policy.field()),
295                Violation::UnknownField { .. } => None,
296            })
297            .collect();
298        write!(
299            f,
300            "A policy declared here and enforced nowhere is worse than no policy: the route \
301             serves 200 and looks correct. Either (a) drop the declaration if it was never \
302             meant to bind, (b) serve these routes on a tier that implements it, or (c) if \
303             something in front of this process really does enforce it, say so with \
304             `--policy-delegated <field>` / `MESOFACT_POLICY_DELEGATED=<field,…>`",
305        )?;
306        if !known.is_empty() {
307            write!(
308                f,
309                " (here: `{}`)",
310                known.into_iter().collect::<Vec<_>>().join(",")
311            )?;
312        }
313        f.write_str(".")?;
314        if unknown {
315            write!(
316                f,
317                " The unknown field(s) are not delegatable — upgrade this binary to one that \
318                 knows them, or rebuild the workload with a matching toolchain."
319            )?;
320        }
321        Ok(())
322    }
323}
324
325impl std::error::Error for PolicyRefusal {}
326
327/// Check a built `manifest.json` against what `support` claims to enforce.
328///
329/// Takes the **raw bytes**, not a [`crate::manifest::Manifest`], and that is
330/// load-bearing: deserializing first would discard exactly the unknown keys
331/// this is looking for. Only the route objects' key sets are inspected, so a
332/// manifest this binary cannot fully model is still checkable.
333///
334/// A manifest that does not parse as JSON is an error, not a pass — reading
335/// "no policies declared" out of a file we failed to understand is the
336/// fail-open shape this module exists to prevent.
337pub fn check_manifest(raw: &[u8], support: &PolicySupport) -> Result<(), PolicyCheckError> {
338    let doc: serde_json::Value =
339        serde_json::from_slice(raw).map_err(|e| PolicyCheckError::Unreadable(e.to_string()))?;
340    let routes = match doc.get("routes") {
341        Some(serde_json::Value::Array(routes)) => routes.as_slice(),
342        Some(_) => return Err(PolicyCheckError::Unreadable("`routes` is not an array".into())),
343        None => &[],
344    };
345    let mut violations = Vec::new();
346    for route in routes {
347        let Some(obj) = route.as_object() else {
348            return Err(PolicyCheckError::Unreadable(
349                "a manifest route entry is not an object".into(),
350            ));
351        };
352        let name = obj
353            .get("route")
354            .and_then(|v| v.as_str())
355            .unwrap_or("<unnamed route>")
356            .to_string();
357        for (key, value) in obj {
358            if STRUCTURAL_FIELDS.contains(&key.as_str()) {
359                continue;
360            }
361            match RoutePolicy::parse(key) {
362                Some(policy) => {
363                    if is_declared(policy, value) && !support.covers(policy) {
364                        violations.push(Violation::Unenforced {
365                            route: name.clone(),
366                            policy,
367                        });
368                    }
369                    // Sub-keys get the same treatment, and are not delegatable
370                    // for the same reason the top-level unknowns are not: an
371                    // operator cannot assert an edge enforces a directive
372                    // neither of them can name.
373                    let known = policy.subfields();
374                    if !known.is_empty() {
375                        if let Some(obj) = value.as_object() {
376                            for sub in obj.keys() {
377                                if !known.contains(&sub.as_str()) {
378                                    violations.push(Violation::UnknownField {
379                                        route: name.clone(),
380                                        field: format!("{}.{sub}", policy.field()),
381                                    });
382                                }
383                            }
384                        }
385                    }
386                }
387                None => violations.push(Violation::UnknownField {
388                    route: name.clone(),
389                    field: key.clone(),
390                }),
391            }
392        }
393    }
394    if violations.is_empty() {
395        return Ok(());
396    }
397    // Stable order: by route, then by the message itself, so a refusal reads
398    // the same on every node and diffs cleanly in a deploy log.
399    violations.sort_by(|a, b| a.route().cmp(b.route()).then_with(|| a.line().cmp(&b.line())));
400    Err(PolicyCheckError::Refused(PolicyRefusal {
401        tier: support.tier.clone(),
402        violations,
403    }))
404}
405
406/// Why a policy check could not conclude "this is safe to serve".
407#[derive(Debug, Clone, PartialEq, Eq)]
408pub enum PolicyCheckError {
409    /// The manifest is present and we could not read it. Not a pass.
410    Unreadable(String),
411    /// The manifest is fine and declares policy this tier will not honour.
412    Refused(PolicyRefusal),
413}
414
415impl fmt::Display for PolicyCheckError {
416    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
417        match self {
418            PolicyCheckError::Unreadable(why) => write!(
419                f,
420                "refusing to start: cannot read the route manifest to check for declared \
421                 policy this binary does not enforce — {why}"
422            ),
423            PolicyCheckError::Refused(r) => r.fmt(f),
424        }
425    }
426}
427
428impl std::error::Error for PolicyCheckError {}
429
430/// Is this field's value an actual declaration, or the inert default every
431/// route carries?
432///
433/// The distinction matters because `cache_policy` is **required** in
434/// `defineRoutes` — every route in every workload has one. Treating the
435/// no-op form (`{ ttl: 0 }`, which is what an author writes to mean "never
436/// cache this") as a declaration would refuse every workload in existence and
437/// teach operators to reach straight for the escape hatch, which costs the
438/// mechanism its entire value.
439fn is_declared(policy: RoutePolicy, value: &serde_json::Value) -> bool {
440    if value.is_null() {
441        return false;
442    }
443    match policy {
444        RoutePolicy::Requires => value.as_array().is_some_and(|a| !a.is_empty()),
445        RoutePolicy::CachePolicy => {
446            let Some(obj) = value.as_object() else {
447                // A `cache_policy` that is not an object is malformed, and a
448                // malformed policy is emphatically not an absent one.
449                return true;
450            };
451            // `ttl: 0` with nothing else is "do not cache" — a statement the
452            // serving tier honours by doing nothing.
453            obj.get("ttl").and_then(|v| v.as_u64()).unwrap_or(0) > 0
454                || obj.contains_key("swr")
455                || obj.contains_key("negative_ttl")
456                || obj
457                    .get("vary")
458                    .is_some_and(|v| v.as_array().is_some_and(|a| !a.is_empty()))
459        }
460        RoutePolicy::Concurrency => true,
461        RoutePolicy::Resilience => value
462            .as_object()
463            .is_some_and(|o| o.values().any(|v| !v.is_null())),
464    }
465}
466
467/// Group a refusal's violations by policy, for a caller that wants to log the
468/// summary rather than the whole list.
469pub fn by_policy(refusal: &PolicyRefusal) -> BTreeMap<&'static str, Vec<&str>> {
470    let mut out: BTreeMap<&'static str, Vec<&str>> = BTreeMap::new();
471    for v in &refusal.violations {
472        let key = match v {
473            Violation::Unenforced { policy, .. } => policy.field(),
474            Violation::UnknownField { .. } => "<unknown>",
475        };
476        out.entry(key).or_default().push(v.route());
477    }
478    out
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::manifest::{
485        CachePolicy, Hydration, Prerender, Requires, ResiliencePolicy, ResolvedPlacement,
486        RetryPolicy, Route, RouteMode,
487    };
488
489    fn serve_tier() -> PolicySupport {
490        PolicySupport::new("mesofact serve").enforces(RoutePolicy::Resilience)
491    }
492
493    fn manifest(routes: &str) -> Vec<u8> {
494        format!(r#"{{"version":"1","build_id":"b","routes":[{routes}]}}"#).into_bytes()
495    }
496
497    const PLAIN: &str = r#"{"route":"/","mode":"static","render_entrypoint":"e.js","cache_policy":{"ttl":0}}"#;
498
499    /// THE test this ticket exists for, written as the absence of a success
500    /// path: a declared-and-unenforced policy must not produce `Ok`. Phrased
501    /// this way — rather than asserting on the error string — because the
502    /// failure mode is invisible by construction, so the thing worth pinning
503    /// is that no input reaches the serving side at all.
504    #[test]
505    fn a_declared_unenforced_policy_has_no_success_path() {
506        let tier = serve_tier();
507        let declarations = [
508            r#""requires":["user"]"#,
509            r#""cache_policy":{"ttl":3600}"#,
510            r#""cache_policy":{"ttl":0,"swr":60}"#,
511            r#""cache_policy":{"ttl":0,"vary":["accept-language"]}"#,
512            r#""concurrency":4"#,
513            r#""future_policy":{"limit":2}"#,
514        ];
515        for decl in declarations {
516            let raw = manifest(&format!(
517                r#"{{"route":"/p","mode":"ssr","render_entrypoint":"e.js","cache_policy":{{"ttl":0}},{decl}}}"#
518            ));
519            match check_manifest(&raw, &tier) {
520                Err(PolicyCheckError::Refused(_)) => {}
521                other => panic!(
522                    "declaring {decl} on a tier that does not enforce it returned {other:?}; \
523                     that is the silent no-op R749-T1 forbids ({} policies known)",
524                    RoutePolicy::ALL.len(),
525                ),
526            }
527        }
528    }
529
530    #[test]
531    fn the_refusal_names_the_route_and_the_field() {
532        let raw = manifest(
533            r#"{"route":"/private","mode":"ssr","render_entrypoint":"e.js","cache_policy":{"ttl":0},"requires":["user"]}"#,
534        );
535        let err = check_manifest(&raw, &serve_tier()).unwrap_err().to_string();
536        assert!(err.contains("/private"), "{err}");
537        assert!(err.contains("requires"), "{err}");
538        assert!(err.contains("--policy-delegated"), "{err}");
539    }
540
541    #[test]
542    fn the_inert_cache_policy_every_route_carries_is_not_a_declaration() {
543        assert!(check_manifest(&manifest(PLAIN), &serve_tier()).is_ok());
544    }
545
546    #[test]
547    fn an_enforced_policy_passes_and_a_delegated_one_does_too() {
548        let raw = manifest(
549            r#"{"route":"/a","mode":"ssr","render_entrypoint":"e.js","cache_policy":{"ttl":0},"resilience":{"timeout_ms":5000},"requires":["user"]}"#,
550        );
551        assert!(check_manifest(&raw, &serve_tier()).is_err());
552        let trusting = serve_tier().delegate(RoutePolicy::Requires);
553        assert!(check_manifest(&raw, &trusting).is_ok());
554        assert!(trusting.is_delegated(RoutePolicy::Requires));
555        assert!(!trusting.is_delegated(RoutePolicy::Resilience));
556    }
557
558    /// An empty `resilience: {}` block survives a round-trip through
559    /// `skip_serializing_if` as `{}`; nothing is being asked for, so nothing
560    /// is unenforced.
561    #[test]
562    fn an_empty_policy_block_is_not_a_declaration() {
563        let raw = manifest(
564            r#"{"route":"/a","mode":"ssr","render_entrypoint":"e.js","cache_policy":{"ttl":0},"resilience":{},"requires":[]}"#,
565        );
566        assert!(check_manifest(&raw, &PolicySupport::new("bare")).is_ok());
567    }
568
569    #[test]
570    fn an_unparseable_manifest_is_not_a_pass() {
571        assert!(matches!(
572            check_manifest(b"{ not json", &serve_tier()),
573            Err(PolicyCheckError::Unreadable(_))
574        ));
575        assert!(matches!(
576            check_manifest(br#"{"routes":"nope"}"#, &serve_tier()),
577            Err(PolicyCheckError::Unreadable(_))
578        ));
579    }
580
581    #[test]
582    fn an_unknown_field_is_refused_and_not_delegatable() {
583        let raw = manifest(
584            r#"{"route":"/x","mode":"ssr","render_entrypoint":"e.js","cache_policy":{"ttl":0},"rate_limit":{"rps":10}}"#,
585        );
586        let mut permissive = serve_tier();
587        for p in RoutePolicy::ALL {
588            permissive = permissive.delegate(p);
589        }
590        let err = check_manifest(&raw, &permissive).unwrap_err().to_string();
591        assert!(err.contains("rate_limit"), "{err}");
592        assert!(err.contains("not delegatable"), "{err}");
593    }
594
595    /// The completeness gate. Every serde key on [`Route`] must be classified
596    /// as either a [`RoutePolicy`] or a [`STRUCTURAL_FIELDS`] entry — so adding
597    /// a field to the manifest without deciding "can this fail open?" fails
598    /// here rather than shipping as the next R556-B13.
599    ///
600    /// Built from a maximally-populated `Route` rather than a hand-listed set,
601    /// because a hand-listed set is exactly the artifact that goes stale.
602    #[test]
603    fn every_route_field_is_classified() {
604        let full = Route {
605            route: "/x/:id".into(),
606            mode: RouteMode::Ssr,
607            render_entrypoint: "dist/server/x.js".into(),
608            requires: Some(vec![Requires::User]),
609            source_reads: Some(vec!["s".into()]),
610            data_inputs: Some(vec!["d.json".into()]),
611            cache_policy: CachePolicy {
612                ttl: 1,
613                swr: Some(1),
614                negative_ttl: Some(1),
615                vary: Some(vec!["accept".into()]),
616            },
617            concurrency: Some(1),
618            hydration: Some(Hydration {
619                script: "s.js".into(),
620                code_split: vec![],
621            }),
622            prerender: Some(Prerender::Deferred { deferred: true }),
623            placement: Some(ResolvedPlacement::Host),
624            resilience: Some(ResiliencePolicy {
625                retry: Some(RetryPolicy {
626                    attempts: 2,
627                    backoff_ms: vec![10],
628                    retry_on: None,
629                    budget_ms: None,
630                }),
631                queue: None,
632                timeout_ms: Some(1),
633            }),
634        };
635        let json = serde_json::to_value(&full).unwrap();
636        let unclassified: Vec<&String> = json
637            .as_object()
638            .unwrap()
639            .keys()
640            .filter(|k| {
641                !STRUCTURAL_FIELDS.contains(&k.as_str()) && RoutePolicy::parse(k).is_none()
642            })
643            .collect();
644        assert!(
645            unclassified.is_empty(),
646            "manifest Route gained field(s) {unclassified:?} that are neither a RoutePolicy nor \
647             STRUCTURAL_FIELDS. Decide which: if a serving tier ignoring it would silently drop \
648             behaviour an author asked for, it is a RoutePolicy and every tier must advertise \
649             it; otherwise add it to STRUCTURAL_FIELDS with a reason.",
650        );
651        // And the reverse: a policy nothing on `Route` carries would refuse a
652        // manifest nobody can produce.
653        for policy in RoutePolicy::ALL {
654            let value = json
655                .get(policy.field())
656                .unwrap_or_else(|| panic!(
657                    "RoutePolicy::{policy:?} names `{}`, which is not a field on manifest::Route",
658                    policy.field(),
659                ));
660            // Same gate one level down: every sub-key must be implemented or
661            // explicitly reserved, so a new `cache_policy` directive cannot
662            // land as a field serde quietly drops.
663            let Some(obj) = value.as_object() else { continue };
664            let unclassified: Vec<&String> = obj
665                .keys()
666                .filter(|k| {
667                    !policy.subfields().contains(&k.as_str())
668                        && !policy.reserved_subfields().contains(&k.as_str())
669                })
670                .collect();
671            assert!(
672                unclassified.is_empty(),
673                "`{}` gained sub-field(s) {unclassified:?}: add them to RoutePolicy::subfields \
674                 once a tier implements them, or to reserved_subfields with the doc that \
675                 reserves the slot",
676                policy.field(),
677            );
678        }
679    }
680
681    /// `resilience.queue` is a v2 slot `defineRoutes` rejects — so a manifest
682    /// carrying one did not come from `defineRoutes`, and deserializing it is
683    /// not the same as queueing anything.
684    #[test]
685    fn a_reserved_subfield_refuses_even_where_its_parent_policy_is_enforced() {
686        let raw = manifest(
687            r#"{"route":"/q","mode":"ssr","render_entrypoint":"e.js","cache_policy":{"ttl":0},"resilience":{"timeout_ms":100,"queue":{"queue":"q","ack":"on_enqueue"}}}"#,
688        );
689        let err = check_manifest(&raw, &serve_tier()).unwrap_err().to_string();
690        assert!(err.contains("resilience.queue"), "{err}");
691    }
692
693    #[test]
694    fn an_unknown_cache_directive_refuses_instead_of_being_dropped_by_serde() {
695        let raw = manifest(
696            r#"{"route":"/c","mode":"static","render_entrypoint":"e.js","cache_policy":{"ttl":60,"shared_max_age":30}}"#,
697        );
698        let tier = serve_tier().enforces(RoutePolicy::CachePolicy);
699        let err = check_manifest(&raw, &tier).unwrap_err().to_string();
700        assert!(err.contains("cache_policy.shared_max_age"), "{err}");
701    }
702}