Skip to main content

cloud/
inner_door.rs

1//! The **inner door** planner (R870-F23) — the `service.toml` +
2//! domain-manifest join that produces a passway `PASSWAY_PATH_ROUTES_FILE`.
3//!
4//! ## What an inner door is, and what it is not
5//!
6//! R870-F15 landed path routing in passway; R870-T18 gave it a config surface
7//! (a JSON mount table named by `PASSWAY_PATH_ROUTES_FILE`). Both are the
8//! *consumer*. This module is the producer: given a service's declared
9//! components and the domain manifest that routes them, it answers "what mount
10//! table does this service's own door need, if any".
11//!
12//! It is an **inner** door because it sits behind the service's public one, on
13//! loopback. The public door owns a hostname and terminates TLS; the inner door
14//! owns one hostname's *paths* and splits them across units that deploy
15//! independently. That split is the only thing it does — and it is the thing
16//! the public door structurally cannot do, because the public tier routes by
17//! SNI/Host and a request's path is not visible until after that.
18//!
19//! ## The join carries no new vocabulary
20//!
21//! R870-F15 claimed the join needs nothing new, and that holds up. Every input
22//! already exists:
23//!
24//! | Field | Source |
25//! |---|---|
26//! | `mount` | [`ServiceComponent::mount`], normalized by [`normalize_mount`] |
27//! | `headers` | the route the domain's table gives that mount's path ([`DomainConfig::route_for_path`]) |
28//! | tier | [`ServiceComponent::deploy`] — the one thing R870-F23 added |
29//! | `upstreams` | placement-time, so it is [`InnerDoorPlan::routes_file`]'s argument, not a config field |
30//!
31//! The mount/route agreement is not re-derived here: [`CloudConfig::cross_ref_validate`]
32//! already *proves* a component's mount and its route's path prefix are the
33//! same string, so the lookup below cannot silently mismatch — a config where
34//! it would have does not load.
35//!
36//! ## Headers come from the ONE route table (R898-F2)
37//!
38//! The header column is not this module's to derive. R898-F1 compiled a
39//! domain's declared routes into one ordered [`RouteTable`], and every tier —
40//! the Worker, mesofact, the outer door's `ROUTE_HEADERS` — answers "what
41//! governs this path" by that table's rule. [`headers_for`] therefore asks the
42//! table's rule too, rather than re-deriving the join; two producers of one
43//! fact is precisely what the R898 relay exists to delete.
44//!
45//! **And this tier is the only one that applies those headers.** Operator call,
46//! 2026-09-12: the outer passway door stays a pure HOST router (path routing and
47//! host routing are mutually exclusive at its boot — `HOST_ROUTING_ENV` in
48//! passway's `main.rs`), so it applies no per-path headers and cannot
49//! double-apply these. That settles R870-F23's open ownership question, and it
50//! is what makes a bundle-tier component at a non-root mount keeping its own
51//! entry here CORRECT rather than redundant: the entry points at the same bundle
52//! upstream as the root and exists purely to carry that mount's headers.
53//! Deleting it as a duplicate would silently strip them.
54//!
55//! [`RouteTable`]: crate::route_table::RouteTable
56//! [`ServiceComponent::mount`]: crate::config::ServiceComponent::mount
57//! [`ServiceComponent::deploy`]: crate::config::ServiceComponent::deploy
58//!
59//! ## The two admission rules
60//!
61//! Both belong here, never to passway: passway proxies whatever `PathRouter`
62//! it is handed and has no view of how many components a service declares.
63//!
64//! 1. **A service with one independently-deployed unit gets no inner tier at
65//!    all.** Enforced by construction — [`plan`] answers `Ok(None)` below two
66//!    units, so there is no config to write and no process to supervise. That
67//!    makes the negative assertable on the *absence* of a plan rather than on
68//!    a site staying up, which is the only form of that assertion that can
69//!    fail loudly.
70//! 2. **A component cannot be both bundle-staged and its own workload.**
71//!    Enforced by [`DeployTier`] being one field with two values rather than
72//!    two independent flags: the contradictory state has no spelling. What
73//!    remains checkable — that two components do not claim one mount — lives
74//!    in `cross_ref_validate`'s existing loop, widened rather than duplicated.
75//!
76//! ## Grouping is by deployed UNIT, not by component
77//!
78//! Every bundle-tier component of a service shares ONE bundle workload (config
79//! 1, R870-B11), so they contribute one upstream between them —
80//! [`DeployedUnit::Bundle`]. They still contribute their own *mounts*, because
81//! a mount is where the bundle stores that component's output
82//! (`app/dist/<mount>/`) and because the domain manifest may give that path
83//! response headers the root does not have. So N bundle components produce N
84//! mounts and one unit, and it is the unit count that rule 1 keys on.
85
86use std::collections::{BTreeMap, HashMap};
87use std::path::{Path, PathBuf};
88
89use anyhow::{bail, Result};
90use serde::Serialize;
91use workload_spec::{
92    EnvValue, EnvVar, ExposeSpec, HealthProbe, Healthcheck, ImageRef, InlineFile,
93    LifecycleArchetype, MeshExpose, MeshIdent, Millis, NamespaceId, ResourceLimits, RestartPolicy,
94    StopPolicy, TenantId, TierTag, Workload, WorkloadSpec,
95};
96
97use crate::config::{
98    domain_serving_service, normalize_mount, DeployTier, DomainConfig, ServiceConfig,
99};
100
101/// `schema_version` of the route table this module writes. Must match
102/// passway's `path_routes_file::SCHEMA_VERSION`; a mismatch is a boot failure
103/// on the door naming both numbers, which is the intended way for a
104/// producer/consumer skew to surface (see that module's doc).
105pub const ROUTES_SCHEMA_VERSION: u32 = 1;
106
107/// Which deployed thing serves a mount.
108///
109/// The distinction the whole module turns on: several components can share one
110/// of these, and rule 1 counts *these*, not components.
111#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
112pub enum DeployedUnit {
113    /// The service's single assembled W272 bundle — every [`DeployTier::Bundle`]
114    /// component, collapsed.
115    Bundle,
116    /// One [`DeployTier::Workload`] component, by component id.
117    Component(String),
118}
119
120/// One mount of an inner door's table, before upstream addresses exist.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct InnerDoorMount {
123    /// passway's mount spelling: `""` for the service root, otherwise
124    /// `/segment[/segment…]`. The `service.toml` side spells the same mount
125    /// without the leading slash — see [`passway_mount`].
126    pub mount: String,
127    /// What serves it.
128    pub unit: DeployedUnit,
129    /// Response headers the domain manifest gives this path (R746) — the
130    /// answer of the domain's one route table, not a second join. Empty when
131    /// the route governing this mount declares none, or when no route governs
132    /// it at all.
133    ///
134    /// This tier is their sole owner; see the module doc for why the outer door
135    /// cannot double-apply them, and why a bundle sub-mount's entry is not
136    /// redundant.
137    pub headers: BTreeMap<String, String>,
138}
139
140/// A service's inner door, as configuration — everything but the addresses.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct InnerDoorPlan {
143    /// Service name, for error messages and the workload name.
144    pub service: String,
145    /// Mounts in declaration order. Precedence is `PathRouter`'s (longest
146    /// mount wins), not this vector's, so the order is presentational.
147    pub mounts: Vec<InnerDoorMount>,
148}
149
150/// Translate a normalized mount (`""`, `"app"`) to passway's spelling (`""`,
151/// `"/app"`).
152///
153/// The twin of [`normalize_mount`] on the wire side, and deliberately built by
154/// composing with it rather than trimming slashes again: `/app`, `app/` and
155/// `/app/` all mean one mount, and this crate already has exactly one place
156/// that knows so.
157///
158/// passway has its own `path_route::mount_from_component` doing the same job on
159/// the reading side. That is a genuine two-sided format rather than a
160/// duplicated normalizer — passway is a separately released crate with its own
161/// workspace, and yubaba cannot call into it — and it is handled the way the
162/// wire format handles every other such risk: `PathRouter::new` is the ONE
163/// validator of mount well-formedness, so a disagreement here is a loud boot
164/// failure on the door, never a silently mis-served prefix.
165pub fn passway_mount(raw: Option<&str>) -> String {
166    match raw.map(normalize_mount) {
167        Some(m) if !m.is_empty() => format!("/{m}"),
168        _ => String::new(),
169    }
170}
171
172/// Plan the inner door for one service, or answer `None` when it should not
173/// have one.
174///
175/// `None` is rule 1 and is the common answer: a service whose components all
176/// ship in one bundle has a single upstream, and a proxy in front of a single
177/// upstream is a hop that can only add latency and a failure mode.
178///
179/// `Err` is reserved for a service that *needs* a door and cannot have a
180/// working one — today that is exactly one case, no root mount, which would
181/// produce a table that 503s every unclaimed path.
182pub fn plan(
183    service: &ServiceConfig,
184    domains: &BTreeMap<String, DomainConfig>,
185) -> Result<Option<InnerDoorPlan>> {
186    let domain = domain_serving_service(domains, &service.name);
187
188    let mut mounts: Vec<InnerDoorMount> = Vec::new();
189    for component in &service.components {
190        let unit = match component.deploy {
191            DeployTier::Bundle => DeployedUnit::Bundle,
192            DeployTier::Workload => DeployedUnit::Component(component.id.clone()),
193        };
194        let mount = passway_mount(component.mount.as_deref());
195        mounts.push(InnerDoorMount {
196            headers: headers_for(domain, &mount),
197            mount,
198            unit,
199        });
200    }
201
202    // Rule 1, counted on UNITS. Three bundle components are one unit and get
203    // no door; one bundle component plus one workload component are two and
204    // do.
205    let units: std::collections::BTreeSet<&DeployedUnit> = mounts.iter().map(|m| &m.unit).collect();
206    if units.len() < 2 {
207        return Ok(None);
208    }
209
210    if !mounts.iter().any(|m| m.mount.is_empty()) {
211        bail!(
212            "services/{}/service.toml declares {} independently-deployed units but no component \
213             at the service root — every component sets a `mount`. An inner door's table needs a \
214             root (\"\") mount as its catch-all; without one every path outside the declared \
215             mounts 503s, which is indistinguishable from an outage. Drop the `mount` from \
216             whichever component serves `/`.",
217            service.name,
218            units.len(),
219        );
220    }
221
222    Ok(Some(InnerDoorPlan {
223        service: service.name.clone(),
224        mounts,
225    }))
226}
227
228/// The response headers `domain`'s route table gives `mount` (passway spelling:
229/// `""` or `/app`).
230///
231/// ## One join, not two
232///
233/// This used to be its own walk — the [`DomainRoute`] whose `route_path_prefix`
234/// equalled the component's [`normalize_mount`], skipping headerless routes.
235/// R898-F1 made a domain's routes a compiled [`RouteTable`] with ONE matching
236/// rule, and the Worker, mesofact and the outer door's `ROUTE_HEADERS` all
237/// answer by that rule. An inner door answering by a *different* one would serve
238/// different headers on the same path than the table says are served there — two
239/// producers of one fact. So the lookup goes through
240/// [`DomainConfig::route_for_path`], which is [`RouteTable::match_path`]'s walk
241/// over the declared routes.
242///
243/// Matched on the mount's PATH rather than on the route's `component`
244/// reference, so a route declared as a bare prefix still contributes:
245/// `cross_ref_validate` has already proved the two agree for every route that
246/// names a component, and matching on the path is what makes the lookup total.
247///
248/// ## The consequence: the answer is order-dependent now
249///
250/// First-match-wins is, and the old prefix-equality join was not. A manifest
251/// that puts `/*` above `/app/*` gives the ROOT's headers to `/app` — at every
252/// tier, now including this one. That is the table being right rather than this
253/// being wrong: `/app` is what such a manifest actually serves through the
254/// outer tier, and `yah-dev.toml:108` states the contract ("routes above this
255/// catch-all … Vec order = match order"). A headerless route that matches is
256/// likewise an answer of "no headers", not a reason to keep looking — no
257/// merging across rules (R746).
258///
259/// ## Why this takes a `DomainConfig` and not a compiled `RouteTable`
260///
261/// It would be circular. `CdnPlacement::backend_origin` resolves a route's
262/// origin out of [`InnerDoorPlan::resolve_addresses`]' output (R898-F1), so no
263/// table can be compiled until the plan this function is helping build exists.
264/// [`DomainConfig::route_for_path`] is the placement-free half of that same
265/// table, in exactly the relationship `route_headers_json` already has to
266/// [`RouteTable::headers_json`].
267///
268/// [`DomainRoute`]: crate::config::DomainRoute
269/// [`RouteTable`]: crate::route_table::RouteTable
270/// [`RouteTable::match_path`]: crate::route_table::RouteTable::match_path
271/// [`RouteTable::headers_json`]: crate::route_table::RouteTable::headers_json
272fn headers_for(domain: Option<&DomainConfig>, mount: &str) -> BTreeMap<String, String> {
273    // passway spells the root mount `""`; a request for it is `/`.
274    let path = if mount.is_empty() { "/" } else { mount };
275    domain
276        .and_then(|d| d.route_for_path(path))
277        .map(|r| r.headers.clone())
278        .unwrap_or_default()
279}
280
281// ── Rendering ────────────────────────────────────────────────────────────────
282
283/// Serialization mirror of passway's `path_routes_file::PathRoutesFile`. Kept
284/// private: the supported output is [`InnerDoorPlan::routes_file`]'s string, so
285/// nothing can construct a half-filled table and write it.
286#[derive(Debug, Serialize)]
287struct RoutesFile<'a> {
288    schema_version: u32,
289    routes: Vec<RouteEntry<'a>>,
290}
291
292#[derive(Debug, Serialize)]
293struct RouteEntry<'a> {
294    mount: &'a str,
295    upstreams: Vec<String>,
296    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
297    headers: &'a BTreeMap<String, String>,
298}
299
300impl InnerDoorPlan {
301    /// Every distinct unit this door proxies to, in a stable order. What a
302    /// caller resolving addresses has to answer for.
303    pub fn units(&self) -> Vec<DeployedUnit> {
304        let set: std::collections::BTreeSet<DeployedUnit> =
305            self.mounts.iter().map(|m| m.unit.clone()).collect();
306        set.into_iter().collect()
307    }
308
309    /// Render the JSON passway reads, resolving each unit to its address.
310    ///
311    /// `address` is placement-time knowledge — which node the unit landed on
312    /// and which port kamaji gave it — so it arrives as a closure rather than
313    /// as config. Returning `None` from it is refused rather than skipped: a
314    /// mount whose upstream could not be resolved would be dropped from the
315    /// table, and the door would then serve that path from whichever *shorter*
316    /// mount matched — the root, usually — which is a wrong answer wearing a
317    /// 200.
318    pub fn routes_file(
319        &self,
320        address: impl Fn(&DeployedUnit) -> Option<String>,
321    ) -> Result<String> {
322        let mut routes = Vec::with_capacity(self.mounts.len());
323        for m in &self.mounts {
324            let Some(addr) = address(&m.unit) else {
325                bail!(
326                    "service {}: mount {:?} is served by {:?}, which has no resolved address yet. \
327                     Refusing to write a partial route table — a missing mount does not 503, it \
328                     falls through to the root mount and serves the wrong component with a 200.",
329                    self.service,
330                    m.mount,
331                    m.unit,
332                );
333            };
334            routes.push(RouteEntry {
335                mount: &m.mount,
336                upstreams: vec![addr],
337                headers: &m.headers,
338            });
339        }
340        Ok(serde_json::to_string(&RoutesFile {
341            schema_version: ROUTES_SCHEMA_VERSION,
342            routes,
343        })?)
344    }
345}
346
347// ── Supervision ──────────────────────────────────────────────────────────────
348
349/// The passway binary every node carries, installed by the yubaba release
350/// tarball's `control_plane_install`. The inner door is the *same* binary as
351/// the public door — one door implementation, two configurations, which is the
352/// property R870-F15 built path routing to preserve.
353pub const INNER_DOOR_BINARY: &str = "/usr/local/bin/passway";
354
355/// Where a node keeps generated route tables. Same directory the demux and
356/// http-router tables already live in.
357pub const ROUTES_DIR: &str = "/var/lib/passway/routes";
358
359/// The only address an inner door ever binds, and the only one the outer door
360/// ever dials it at. Literal rather than a parameter — see
361/// [`InnerDoorPlan::workload`].
362pub const INNER_DOOR_HOST: &str = "127.0.0.1";
363
364/// Low end of the window [`listen_port`] picks from, inclusive.
365pub const INNER_DOOR_PORT_LOW: u16 = 10_000;
366/// High end of the window [`listen_port`] picks from, inclusive.
367pub const INNER_DOOR_PORT_HIGH: u16 = 19_999;
368
369/// The loopback port a service's inner door listens on — derived from the
370/// service name, so every apply of an unchanged tree renders the same number.
371///
372/// ## Why a derived pin rather than kamaji's ledger
373///
374/// R870-F23 phase 2 preferred taking the number from `kamaji::ports`
375/// ([`LedgerPorts`], `oss/kamaji/crates/kamaji/src/ports.rs`). Read rather than
376/// assumed, that ledger cannot answer here, for three reasons that also happen
377/// to make a pin safe:
378///
379/// 1. **It is node-local and has no RPC.** `LedgerPorts` persists
380///    `(ident, name) -> port` to a JSON file beside the supervisor's state dir,
381///    and yubaba's HTTP surface exposes no allocation verb (`yubaba/src/lib.rs`
382///    routes `/workloads/*`, `/services`, `/node/*` — nothing for ports). An
383///    apply running on an operator's laptop has no way to ask.
384/// 2. **A pin is honoured, not rejected, on the path this workload takes.**
385///    R844-F14's rule — a non-world-fixed pin is an error — bites in
386///    `LedgerPorts::resolve_set`, and `NativeRuntime::resolve_declared_ports`
387///    (`kamaji/src/native.rs:280`) filters `pin.is_none()` *before* calling it.
388///    A stated number is therefore passed through, which is what
389///    `PASSWAY_LISTEN` needs: the door's own env has to carry the number, and
390///    a number the node picked after the spec was rendered could not be in it.
391/// 3. **A collision is not even representable.** The ledger allocates on the
392///    workload's *mesh* IP; an inner door binds loopback. `100.64.0.3:14210`
393///    and `127.0.0.1:14210` are different sockets.
394///
395/// The window is deliberately below Linux's default ephemeral range
396/// (32768-60999), which is where `pick_free_port`'s `bind(:0)` draws from — so
397/// a derived number cannot land on one the ledger is about to hand out even on
398/// the same interface.
399///
400/// The hash is FNV-1a written out here rather than `DefaultHasher`, whose
401/// output std explicitly does not promise to keep stable across releases. This
402/// number is written into a deployed door's environment and into the outer
403/// door's upstream list; a toolchain bump silently moving it would repoint one
404/// tier and not the other.
405pub fn listen_port(service: &str) -> u16 {
406    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
407    for byte in service.as_bytes() {
408        hash ^= u64::from(*byte);
409        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
410    }
411    let span = u64::from(INNER_DOOR_PORT_HIGH - INNER_DOOR_PORT_LOW) + 1;
412    INNER_DOOR_PORT_LOW + (hash % span) as u16
413}
414
415/// The mesh identity a [`DeployTier::Workload`] component registers its service
416/// record under.
417///
418/// **This is the naming rule, not a lookup**, and it is stated here because
419/// nothing else states it. A bundle's ident comes from the mirror
420/// (`BundleSlot::workload_name`, overridable by `name = "…"`), but a
421/// workload-tier component has no slot of its own — `[providers.*]` is
422/// per-kind, per-mirror, which is exactly the gap [`DeployTier`] was added to
423/// close. So the ident has to be derivable from the two names the service
424/// already declares, and this is that derivation.
425///
426/// Getting it wrong is a *loud* failure rather than a quiet one:
427/// [`InnerDoorPlan::routes_file`] refuses a mount whose unit resolved to no
428/// address, naming the unit, so a component that registered under some other
429/// ident fails the apply instead of falling through to the root mount.
430pub fn component_workload_ident(service: &str, component_id: &str) -> String {
431    crate::reconciler::native_support::sanitize_ident(&format!("{service}-{component_id}"))
432}
433
434impl InnerDoorPlan {
435    /// The workload name / mesh identity for this service's inner door.
436    pub fn workload_name(&self) -> String {
437        format!("passway-inner-{}", self.service)
438    }
439
440    /// Where this door's route table is materialized on the node.
441    pub fn routes_path(&self) -> PathBuf {
442        Path::new(ROUTES_DIR).join(format!("{}.routes.json", self.service))
443    }
444
445    /// This door's loopback port — [`listen_port`] of the service name.
446    pub fn listen_port(&self) -> u16 {
447        listen_port(&self.service)
448    }
449
450    /// The mesh identity whose ready service record carries `unit`'s address.
451    ///
452    /// The two arms come from different places on purpose, and neither is
453    /// derivable from the other. A bundle's ident is a *mirror* fact —
454    /// `BundleSlot::workload_name`, which a slot may rename with `name = "…"` —
455    /// so it is handed in. A workload-tier component has no slot to rename it,
456    /// so its ident is derived ([`component_workload_ident`]).
457    pub fn unit_ident(&self, unit: &DeployedUnit, bundle_ident: &str) -> String {
458        match unit {
459            DeployedUnit::Bundle => bundle_ident.to_string(),
460            DeployedUnit::Component(id) => component_workload_ident(&self.service, id),
461        }
462    }
463
464    /// Resolve every unit to a `host:port`, given a way to look an address up
465    /// by mesh identity.
466    ///
467    /// The step between [`units`](Self::units) and the `address` closure
468    /// [`routes_file`](Self::routes_file) and [`workload`](Self::workload)
469    /// take: those two ask "where is this unit", this answers it from a
470    /// discovery read. Split out rather than folded in so the identity mapping
471    /// stays testable without a fleet.
472    ///
473    /// A unit with no answer is simply absent from the map — the refusal lives
474    /// in `routes_file`, which is the single place a missing address is
475    /// reported and which already explains why a dropped mount is worse than a
476    /// failed apply.
477    pub fn resolve_addresses(
478        &self,
479        bundle_ident: &str,
480        lookup: impl Fn(&str) -> Option<String>,
481    ) -> BTreeMap<DeployedUnit, String> {
482        self.units()
483            .into_iter()
484            .filter_map(|unit| {
485                let addr = lookup(&self.unit_ident(&unit, bundle_ident))?;
486                Some((unit, addr))
487            })
488            .collect()
489    }
490
491    /// Render the supervisable workload: a passway process serving this
492    /// service's mount table on loopback.
493    ///
494    /// ## Cleartext, and the invariant that makes it safe
495    ///
496    /// `PASSWAY_TLS_MODE=plaintext` (operator call, 2026-09-09 — see
497    /// `passway::tls::parse_listener_tls_mode` for the full argument). The
498    /// short version: no CA issues for `127.0.0.1`, so "TLS everywhere" here
499    /// means a self-signed leaf plus a way to switch OFF upstream certificate
500    /// verification on the *public* door — a real trust-boundary knob traded
501    /// for encrypting a hop that never leaves the loopback interface.
502    ///
503    /// This function cannot violate that invariant even if `listen_port` is
504    /// wrong, because it binds `127.0.0.1` literally and passway refuses the
505    /// mode on anything else. The bind is not a parameter.
506    ///
507    /// ## Why `listen_port` is an argument
508    ///
509    /// It is placement-time knowledge, exactly like the upstream addresses:
510    /// which port is free is a property of the node, not of the config. The
511    /// caller allocates and passes it, so this stays a pure function of
512    /// (plan, port, addresses) and is testable without a node.
513    ///
514    /// ## The route table travels IN the spec
515    ///
516    /// Not written beside it: [`WorkloadSpec::files`] makes the table and the
517    /// process that reads it one deploy rather than two, so a redeploy cannot
518    /// leave a door serving a stale table. Only kamaji's native backend
519    /// materializes those; every other backend refuses the spec by name rather
520    /// than starting the door against a file that is not there.
521    ///
522    /// ## Why `Workload::Container` and not a new `Workload` variant
523    ///
524    /// `TenantPasswayWorkload` is a typed variant, so the precedent for one
525    /// exists — but it earns that by carrying config kamaji itself must act on
526    /// (a domain to match, a PEM pair to re-read on every cold start, an idle
527    /// TTL to reap against). An inner door carries none of it: its entire
528    /// configuration is an argv, three env vars and one file, all of which
529    /// `WorkloadSpec` already expresses. A variant would buy nothing but
530    /// exhaustive-match churn in peer-owned `kamaji-proto`, which is the trade
531    /// R572-F1 already made and recorded.
532    pub fn workload(
533        &self,
534        listen_port: u16,
535        address: impl Fn(&DeployedUnit) -> Option<String>,
536    ) -> Result<Workload> {
537        let routes_path = self.routes_path();
538        let name = self.workload_name();
539        let listen = format!("127.0.0.1:{listen_port}");
540
541        let env = vec![
542            literal_env("PASSWAY_TLS_MODE", "plaintext".to_string()),
543            literal_env("PASSWAY_LISTEN", listen),
544            literal_env(
545                "PASSWAY_PATH_ROUTES_FILE",
546                routes_path.display().to_string(),
547            ),
548        ];
549
550        let spec = WorkloadSpec {
551            name: name.clone(),
552            // Identity metadata only — the native backend pulls nothing.
553            image: ImageRef {
554                registry: "local".into(),
555                repository: "passway".into(),
556                tag: "inner-door".into(),
557                digest: String::new(),
558            },
559            tier: TierTag("infra".into()),
560            tenant: TenantId::singleton(),
561            namespace: NamespaceId::singleton(),
562            replicas: 1,
563            command: Some(vec![INNER_DOOR_BINARY.to_string()]),
564            entrypoint: None,
565            workdir: None,
566            user: None,
567            env,
568            secrets: vec![],
569            volumes: vec![],
570            resources: ResourceLimits {
571                memory_mb: 128,
572                cpu_millis: 256,
573                memory_request_mb: None,
574                cpu_limit_millis: None,
575                pids_max: None,
576                scratch_floor_mb: None,
577            },
578            depends_on: vec![],
579            requires: vec![],
580            healthcheck: Some(Healthcheck {
581                // A cleartext listener would answer an HttpGet probe, but a
582                // bare connect is the same liveness signal without asking the
583                // door to route a synthetic path through a mount table that
584                // may legitimately not have a catch-all for it.
585                probe: HealthProbe::TcpConnect { port: listen_port },
586                interval: Millis::from_secs(10),
587                timeout: Millis::from_secs(2),
588                initial_delay: Millis::from_secs(5),
589                failure_threshold: 3,
590            }),
591            restart_policy: RestartPolicy::Always,
592            // Pinned and non-drainable: the service's public door proxies to
593            // this on loopback, so moving it to another node does not relocate
594            // the thing that reaches it — it severs it.
595            archetype: Some(LifecycleArchetype::Appliance),
596            stop_policy: StopPolicy {
597                signal: 15,
598                grace_period: Millis::from_secs(5),
599            },
600            expose: ExposeSpec {
601                mesh: MeshExpose {
602                    identity: MeshIdent(name),
603                    ports: MeshExpose::anonymous_ports([listen_port]),
604                    allow_from: vec![],
605                },
606                // Loopback only. Nothing off this node reaches an inner door,
607                // which is the premise the cleartext listener rests on.
608                public: None,
609                operator: None,
610            },
611            labels: HashMap::new(),
612            durability: None,
613            annotations: HashMap::new(),
614            files: vec![InlineFile {
615                path: routes_path,
616                content: self.routes_file(address)?,
617                mode: Some(0o600),
618            }],
619        };
620
621        Ok(Workload::container(spec))
622    }
623}
624
625fn literal_env(name: &str, value: String) -> EnvVar {
626    EnvVar {
627        name: name.into(),
628        value: EnvValue::Literal { value },
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::config::{DomainRoute, FrontDoor, RouteMode, ServiceComponent};
636    use crate::route_table::{CdnPlacement, RouteTable};
637
638    fn component(id: &str, mount: Option<&str>, deploy: DeployTier) -> ServiceComponent {
639        ServiceComponent {
640            id: id.to_string(),
641            kind: "mesofact-spa".to_string(),
642            path: format!("app/{id}"),
643            git: None,
644            role: "static".to_string(),
645            publishes: Some("static".to_string()),
646            mount: mount.map(str::to_string),
647            wave: 0,
648            deploy,
649        }
650    }
651
652    fn service(name: &str, components: Vec<ServiceComponent>) -> ServiceConfig {
653        ServiceConfig {
654            schema_version: 1,
655            name: name.to_string(),
656            domain: format!("{name}.test"),
657            health_path: None,
658            components,
659            db: Default::default(),
660        }
661    }
662
663    fn domains(service: &str, routes: &[(&str, &[(&str, &str)])]) -> BTreeMap<String, DomainConfig> {
664        let mut map = BTreeMap::new();
665        map.insert(
666            "test".to_string(),
667            DomainConfig {
668                schema_version: 1,
669                name: "test".to_string(),
670                domain: format!("{service}.test"),
671                front_door: FrontDoor::Passway,
672                cdn_bucket: "cdn".to_string(),
673                worker_bundle_path: None,
674                routes: routes
675                    .iter()
676                    .map(|(path, headers)| DomainRoute {
677                        path: path.to_string(),
678                        headers: headers
679                            .iter()
680                            .map(|(k, v)| (k.to_string(), v.to_string()))
681                            .collect(),
682                        mode: RouteMode::Static {
683                            component: format!("{service}/root"),
684                        },
685                    })
686                    .collect(),
687            },
688        );
689        map
690    }
691
692    /// Rule 1's negative, and the cheap half of this ticket's verify list: a
693    /// single-component service produces no plan at all, so there is no config
694    /// to write and no process to supervise.
695    #[test]
696    fn a_single_unit_service_gets_no_inner_door() {
697        let svc = service(
698            "yah-marketing",
699            vec![component("site", None, DeployTier::Bundle)],
700        );
701        assert_eq!(plan(&svc, &BTreeMap::new()).unwrap(), None);
702    }
703
704    /// The same negative one step further out, and the one that would be easy
705    /// to get wrong: THREE components still share one bundle, so they are one
706    /// unit and still earn no door.
707    #[test]
708    fn several_bundle_components_are_one_unit_and_still_get_no_door() {
709        let svc = service(
710            "noisetable",
711            vec![
712                component("site", None, DeployTier::Bundle),
713                component("app", Some("app"), DeployTier::Bundle),
714                component("docs", Some("docs"), DeployTier::Bundle),
715            ],
716        );
717        assert_eq!(plan(&svc, &BTreeMap::new()).unwrap(), None);
718    }
719
720    #[test]
721    fn one_bundle_component_plus_one_workload_component_is_two_units() {
722        let svc = service(
723            "noisetable",
724            vec![
725                component("site", None, DeployTier::Bundle),
726                component("account", Some("app"), DeployTier::Workload),
727            ],
728        );
729        let plan = plan(&svc, &BTreeMap::new()).unwrap().expect("two units");
730        assert_eq!(
731            plan.mounts.iter().map(|m| m.mount.as_str()).collect::<Vec<_>>(),
732            ["", "/app"]
733        );
734        assert_eq!(
735            plan.units(),
736            vec![
737                DeployedUnit::Bundle,
738                DeployedUnit::Component("account".into())
739            ]
740        );
741    }
742
743    /// The header half of the join: a mount picks up exactly the headers its
744    /// own route declares, and the root picks up none when its route declares
745    /// none. This is the config-side half of the ticket's live assertion that
746    /// `/app/` carries COOP/COEP while `/` carries neither.
747    ///
748    /// R898-F2 REORDERED THIS FIXTURE and changed nothing else. The assertions
749    /// and the property they pin are untouched; the route list now declares
750    /// `/app/*` ABOVE `/*`, which is what a legal manifest looks like under the
751    /// route table's first-match-wins contract (`yah-dev.toml:108`,
752    /// `noisetable-com.toml`). The old fixture declared the catch-all first,
753    /// which the previous prefix-equality join was blind to and the shared
754    /// matching rule is not — see [`headers_for`].
755    #[test]
756    fn each_mount_carries_only_its_own_routes_headers() {
757        let svc = service(
758            "noisetable",
759            vec![
760                component("site", None, DeployTier::Bundle),
761                component("account", Some("app"), DeployTier::Workload),
762            ],
763        );
764        let domains = domains(
765            "noisetable",
766            &[
767                (
768                    "/app/*",
769                    &[
770                        ("cross-origin-opener-policy", "same-origin"),
771                        ("cross-origin-embedder-policy", "require-corp"),
772                    ],
773                ),
774                ("/*", &[]),
775            ],
776        );
777        let plan = plan(&svc, &domains).unwrap().expect("two units");
778
779        let root = &plan.mounts[0];
780        assert_eq!(root.mount, "");
781        assert!(root.headers.is_empty(), "{:?}", root.headers);
782
783        let app = &plan.mounts[1];
784        assert_eq!(app.mount, "/app");
785        assert_eq!(
786            app.headers.get("cross-origin-opener-policy").map(String::as_str),
787            Some("same-origin")
788        );
789        assert_eq!(
790            app.headers
791                .get("cross-origin-embedder-policy")
792                .map(String::as_str),
793            Some("require-corp")
794        );
795    }
796
797    /// A bundle-tier component at a non-root mount keeps its own headers even
798    /// though it shares the bundle's upstream — the reason mounts are per
799    /// COMPONENT while units are per deployed thing.
800    #[test]
801    fn a_bundle_components_sub_mount_keeps_its_headers_and_the_bundle_upstream() {
802        let svc = service(
803            "noisetable",
804            vec![
805                component("site", None, DeployTier::Bundle),
806                component("docs", Some("docs"), DeployTier::Bundle),
807                component("account", Some("app"), DeployTier::Workload),
808            ],
809        );
810        let domains = domains(
811            "noisetable",
812            &[("/docs/*", &[("x-frame-options", "DENY")])],
813        );
814        let plan = plan(&svc, &domains).unwrap().expect("two units");
815
816        let docs = &plan.mounts[1];
817        assert_eq!(docs.mount, "/docs");
818        assert_eq!(docs.unit, DeployedUnit::Bundle);
819        assert_eq!(docs.headers.get("x-frame-options").map(String::as_str), Some("DENY"));
820        // Two units, three mounts.
821        assert_eq!(plan.units().len(), 2);
822        assert_eq!(plan.mounts.len(), 3);
823    }
824
825    #[test]
826    fn a_table_with_no_root_mount_is_refused_rather_than_written() {
827        let svc = service(
828            "noisetable",
829            vec![
830                component("app", Some("app"), DeployTier::Bundle),
831                component("account", Some("account"), DeployTier::Workload),
832            ],
833        );
834        let err = plan(&svc, &BTreeMap::new()).unwrap_err().to_string();
835        assert!(err.contains("no component at the service root"), "{err}");
836    }
837
838    #[test]
839    fn rendering_produces_the_exact_shape_passway_reads() {
840        let svc = service(
841            "noisetable",
842            vec![
843                component("site", None, DeployTier::Bundle),
844                component("account", Some("app"), DeployTier::Workload),
845            ],
846        );
847        let domains = domains(
848            "noisetable",
849            &[("/app/*", &[("cross-origin-opener-policy", "same-origin")])],
850        );
851        let plan = plan(&svc, &domains).unwrap().unwrap();
852
853        let json = plan
854            .routes_file(|unit| match unit {
855                DeployedUnit::Bundle => Some("127.0.0.1:8081".to_string()),
856                DeployedUnit::Component(id) if id == "account" => {
857                    Some("127.0.0.1:8082".to_string())
858                }
859                DeployedUnit::Component(_) => None,
860            })
861            .unwrap();
862
863        assert_eq!(
864            json,
865            r#"{"schema_version":1,"routes":[{"mount":"","upstreams":["127.0.0.1:8081"]},{"mount":"/app","upstreams":["127.0.0.1:8082"],"headers":{"cross-origin-opener-policy":"same-origin"}}]}"#
866        );
867    }
868
869    /// An unresolved address must stop the write. Dropping the mount would
870    /// leave the door serving `/app` from the ROOT mount with a 200 — the
871    /// silent wrong answer, not a 503.
872    #[test]
873    fn an_unresolved_upstream_refuses_the_whole_table() {
874        let svc = service(
875            "noisetable",
876            vec![
877                component("site", None, DeployTier::Bundle),
878                component("account", Some("app"), DeployTier::Workload),
879            ],
880        );
881        let plan = plan(&svc, &BTreeMap::new()).unwrap().unwrap();
882        let err = plan
883            .routes_file(|unit| match unit {
884                DeployedUnit::Bundle => Some("127.0.0.1:8081".to_string()),
885                DeployedUnit::Component(_) => None,
886            })
887            .unwrap_err()
888            .to_string();
889        assert!(err.contains("no resolved address"), "{err}");
890        assert!(err.contains("account"), "{err}");
891    }
892
893    /// The rendered door, pinned on the four properties that are not
894    /// cosmetic: cleartext ONLY on loopback, the routes file travelling inside
895    /// the spec, and the env var passway selects path routing by.
896    #[test]
897    fn the_rendered_door_is_cleartext_on_loopback_and_carries_its_own_table() {
898        let svc = service(
899            "noisetable",
900            vec![
901                component("site", None, DeployTier::Bundle),
902                component("account", Some("app"), DeployTier::Workload),
903            ],
904        );
905        let plan = plan(&svc, &BTreeMap::new()).unwrap().unwrap();
906        let workload = plan
907            .workload(8443, |unit| match unit {
908                DeployedUnit::Bundle => Some("127.0.0.1:8081".to_string()),
909                DeployedUnit::Component(_) => Some("127.0.0.1:8082".to_string()),
910            })
911            .unwrap();
912        let spec = workload.container_spec().expect("container-shaped");
913
914        let env: BTreeMap<&str, &str> = spec
915            .env
916            .iter()
917            .filter_map(|e| match &e.value {
918                EnvValue::Literal { value } => Some((e.name.as_str(), value.as_str())),
919                _ => None,
920            })
921            .collect();
922        assert_eq!(env.get("PASSWAY_TLS_MODE"), Some(&"plaintext"));
923        // The invariant: cleartext is bound to loopback by construction, not
924        // by whoever picked the port.
925        assert_eq!(env.get("PASSWAY_LISTEN"), Some(&"127.0.0.1:8443"));
926        assert!(spec.expose.public.is_none(), "an inner door is never public");
927
928        // The routes file rides the spec, and the env var points AT it.
929        assert_eq!(spec.files.len(), 1);
930        let file = &spec.files[0];
931        assert_eq!(
932            env.get("PASSWAY_PATH_ROUTES_FILE").map(|s| s.to_string()),
933            Some(file.path.display().to_string())
934        );
935        assert!(file.content.contains("\"schema_version\":1"), "{}", file.content);
936        assert!(file.content.contains("127.0.0.1:8082"), "{}", file.content);
937        assert_eq!(spec.command.as_deref(), Some(&[INNER_DOOR_BINARY.to_string()][..]));
938    }
939
940    /// A door whose table cannot be rendered is not rendered at all — the
941    /// refusal propagates out of `workload`, so there is no spec that deploys
942    /// a door pointing at nothing.
943    #[test]
944    fn an_unresolvable_unit_stops_the_workload_being_built() {
945        let svc = service(
946            "noisetable",
947            vec![
948                component("site", None, DeployTier::Bundle),
949                component("account", Some("app"), DeployTier::Workload),
950            ],
951        );
952        let plan = plan(&svc, &BTreeMap::new()).unwrap().unwrap();
953        assert!(plan.workload(8443, |_| None).is_err());
954    }
955
956    #[test]
957    fn the_mount_spelling_matches_passways_convention_in_both_directions() {
958        assert_eq!(passway_mount(None), "");
959        assert_eq!(passway_mount(Some("")), "");
960        assert_eq!(passway_mount(Some("/")), "");
961        // Every spelling of one mount collapses to one string — the whole
962        // reason this composes with `normalize_mount` instead of formatting.
963        for raw in ["app", "/app", "app/", "/app/"] {
964            assert_eq!(passway_mount(Some(raw)), "/app", "{raw}");
965        }
966        assert_eq!(passway_mount(Some("/a/b/")), "/a/b");
967    }
968
969    // ── Phase 2: placement (R870-F23 steps 2 and 3) ─────────────────────────
970
971    /// The property the whole pin rests on: same service, same number, forever.
972    /// The outer door's upstream list and the inner door's `PASSWAY_LISTEN` are
973    /// rendered by two different call sites in two different apply phases; if
974    /// this drifted, one tier would be repointed and the other would not.
975    #[test]
976    fn the_derived_port_is_stable_and_inside_its_declared_window() {
977        assert_eq!(listen_port("noisetable"), listen_port("noisetable"));
978        for service in ["noisetable", "yah-marketing", "", "a", "a-very-long-service-name"] {
979            let port = listen_port(service);
980            assert!(
981                (INNER_DOOR_PORT_LOW..=INNER_DOOR_PORT_HIGH).contains(&port),
982                "{service} -> {port}"
983            );
984            // Below the Linux default ephemeral floor, which is where
985            // `kamaji::ports::pick_free_port`'s `bind(:0)` draws from. A number
986            // inside that range could collide with a ledger allocation.
987            assert!(port < 32_768, "{service} -> {port}");
988        }
989    }
990
991    /// Different services get different doors. Not a guarantee the hash can
992    /// make in general — 10_000 slots, so a collision is possible — but two
993    /// services co-tenant on one node colliding is what this is checked
994    /// against, and the two real ones do not.
995    #[test]
996    fn two_services_do_not_share_a_door() {
997        assert_ne!(listen_port("noisetable"), listen_port("yah-marketing"));
998    }
999
1000    /// Step 3's identity mapping. The two arms come from different places and
1001    /// the test says so: the bundle's ident is handed in (a mirror may rename
1002    /// it), a component's is derived from names the service already declares.
1003    #[test]
1004    fn each_unit_resolves_through_its_own_identity_rule() {
1005        let svc = service(
1006            "noisetable",
1007            vec![
1008                component("site", None, DeployTier::Bundle),
1009                component("account", Some("app"), DeployTier::Workload),
1010            ],
1011        );
1012        let plan = plan(&svc, &BTreeMap::new()).unwrap().unwrap();
1013
1014        assert_eq!(
1015            plan.unit_ident(&DeployedUnit::Bundle, "renamed-bundle"),
1016            "renamed-bundle",
1017            "a slot's `name = \"…\"` override has to win — it is what the record carries"
1018        );
1019        assert_eq!(
1020            plan.unit_ident(&DeployedUnit::Component("account".into()), "renamed-bundle"),
1021            "noisetable-account",
1022        );
1023    }
1024
1025    /// A component id that is not already a legal mesh ident is folded, not
1026    /// passed through — the ident travels into a service-record lookup and a
1027    /// `MeshIdent`, both of which are lowercase-and-dash.
1028    #[test]
1029    fn a_derived_component_ident_is_folded_like_every_other_mesh_ident() {
1030        assert_eq!(
1031            component_workload_ident("Noise_Table", "Account.API"),
1032            "noise-table-account-api"
1033        );
1034    }
1035
1036    /// Steps 2 and 3 joined: a two-unit service renders a door whose table
1037    /// names both resolved addresses and whose listener is the derived port.
1038    /// The positive half of the ticket's verify list, at the config tier.
1039    #[test]
1040    fn resolved_units_render_a_door_on_the_derived_port() {
1041        let svc = service(
1042            "noisetable",
1043            vec![
1044                component("site", None, DeployTier::Bundle),
1045                component("account", Some("app"), DeployTier::Workload),
1046            ],
1047        );
1048        let domains = domains(
1049            "noisetable",
1050            &[(
1051                "/app/*",
1052                &[
1053                    ("cross-origin-opener-policy", "same-origin"),
1054                    ("cross-origin-embedder-policy", "require-corp"),
1055                ],
1056            )],
1057        );
1058        let plan = plan(&svc, &domains).unwrap().unwrap();
1059
1060        let addresses = plan.resolve_addresses("noisetable", |ident| match ident {
1061            "noisetable" => Some("100.64.0.3:8080".to_string()),
1062            "noisetable-account" => Some("100.64.0.3:14001".to_string()),
1063            _ => None,
1064        });
1065        assert_eq!(addresses.len(), 2);
1066
1067        let workload = plan
1068            .workload(plan.listen_port(), |unit| addresses.get(unit).cloned())
1069            .unwrap();
1070        let spec = workload.container_spec().expect("container-shaped");
1071        let listen = spec
1072            .env
1073            .iter()
1074            .find(|e| e.name == "PASSWAY_LISTEN")
1075            .and_then(|e| match &e.value {
1076                EnvValue::Literal { value } => Some(value.clone()),
1077                _ => None,
1078            })
1079            .expect("a door always declares its listener");
1080        assert_eq!(listen, format!("127.0.0.1:{}", listen_port("noisetable")));
1081
1082        let table = &spec.files[0].content;
1083        assert!(table.contains("100.64.0.3:8080"), "{table}");
1084        assert!(table.contains("100.64.0.3:14001"), "{table}");
1085        assert!(table.contains("cross-origin-embedder-policy"), "{table}");
1086    }
1087
1088    /// A unit that resolved to nothing is ABSENT from the map rather than
1089    /// present-and-empty — which is what makes `routes_file`'s refusal the
1090    /// single place a missing address is reported.
1091    #[test]
1092    fn an_unresolvable_unit_is_absent_rather_than_defaulted() {
1093        let svc = service(
1094            "noisetable",
1095            vec![
1096                component("site", None, DeployTier::Bundle),
1097                component("account", Some("app"), DeployTier::Workload),
1098            ],
1099        );
1100        let plan = plan(&svc, &BTreeMap::new()).unwrap().unwrap();
1101        let addresses = plan.resolve_addresses("noisetable", |ident| {
1102            (ident == "noisetable").then(|| "100.64.0.3:8080".to_string())
1103        });
1104        assert_eq!(addresses.len(), 1);
1105        assert!(!addresses.contains_key(&DeployedUnit::Component("account".into())));
1106        assert!(plan
1107            .workload(plan.listen_port(), |unit| addresses.get(unit).cloned())
1108            .is_err());
1109    }
1110
1111    // ── R898-F2: the inner door and the compiled table are one join ──────────
1112
1113    /// A placement that resolves the fixture's `static` routes. Nothing here
1114    /// asserts on origins — the point is that the HEADER answer the door plans
1115    /// and the header answer the compiled table carries come from one walk.
1116    fn placement() -> CdnPlacement {
1117        CdnPlacement {
1118            cdn_base: "https://cdn.test".to_string(),
1119            env: "prod".to_string(),
1120            ..Default::default()
1121        }
1122    }
1123
1124    fn compiled(domains: &BTreeMap<String, DomainConfig>) -> RouteTable {
1125        domains["test"]
1126            .route_table(&placement())
1127            .expect("the fixture's routes all resolve")
1128    }
1129
1130    /// passway spells the root mount `""`; the request that reaches it is `/`.
1131    fn mount_path(mount: &str) -> &str {
1132        if mount.is_empty() {
1133            "/"
1134        } else {
1135            mount
1136        }
1137    }
1138
1139    /// A two-unit service whose domain declares a headered `/app/*` above a
1140    /// headerless catch-all — a legal manifest under first-match-wins.
1141    fn seam_fixture() -> (ServiceConfig, BTreeMap<String, DomainConfig>) {
1142        let svc = service(
1143            "noisetable",
1144            vec![
1145                component("site", None, DeployTier::Bundle),
1146                component("account", Some("app"), DeployTier::Workload),
1147            ],
1148        );
1149        let domains = domains(
1150            "noisetable",
1151            &[
1152                (
1153                    "/app/*",
1154                    &[("cross-origin-opener-policy", "same-origin")],
1155                ),
1156                ("/*", &[("x-frame-options", "DENY")]),
1157            ],
1158        );
1159        (svc, domains)
1160    }
1161
1162    /// **THE SEAM (R898-F2).** Every mount's headers are the compiled route
1163    /// table's answer for that mount's path — not a second join that happens to
1164    /// agree. If the two ever diverge, the inner door serves headers the table
1165    /// says are served somewhere else.
1166    #[test]
1167    fn every_mounts_headers_are_the_compiled_route_tables_answer() {
1168        let (svc, domains) = seam_fixture();
1169        let table = compiled(&domains);
1170        let plan = plan(&svc, &domains).unwrap().expect("two units");
1171
1172        for m in &plan.mounts {
1173            let expected = table
1174                .match_path(mount_path(&m.mount))
1175                .map(|e| e.headers.clone())
1176                .unwrap_or_default();
1177            assert_eq!(m.headers, expected, "mount {:?}", m.mount);
1178        }
1179
1180        // Non-trivially: the fixture gives both mounts headers, and DIFFERENT
1181        // ones, so an implementation that returned `Default::default()` for
1182        // everything could not pass the loop above.
1183        assert_eq!(
1184            plan.mounts
1185                .iter()
1186                .map(|m| m.headers.keys().cloned().collect::<Vec<_>>())
1187                .collect::<Vec<_>>(),
1188            vec![
1189                vec!["x-frame-options".to_string()],
1190                vec!["cross-origin-opener-policy".to_string()],
1191            ]
1192        );
1193    }
1194
1195    /// The property the seam exists for: a header edited in the domain manifest
1196    /// reaches the inner door's table without a second join being touched, and
1197    /// both tiers move together.
1198    #[test]
1199    fn a_header_changed_in_the_manifest_moves_both_tiers_at_once() {
1200        let (svc, mut domains) = seam_fixture();
1201        let before = plan(&svc, &domains).unwrap().unwrap();
1202        assert_eq!(
1203            before.mounts[1]
1204                .headers
1205                .get("cross-origin-opener-policy")
1206                .map(String::as_str),
1207            Some("same-origin")
1208        );
1209
1210        // One edit, in the manifest, to the route that governs `/app`.
1211        let route = domains
1212            .get_mut("test")
1213            .unwrap()
1214            .routes
1215            .iter_mut()
1216            .find(|r| r.path == "/app/*")
1217            .unwrap();
1218        route.headers.insert(
1219            "cross-origin-embedder-policy".to_string(),
1220            "require-corp".to_string(),
1221        );
1222
1223        let after = plan(&svc, &domains).unwrap().unwrap();
1224        let table = compiled(&domains);
1225        assert_eq!(
1226            after.mounts[1].headers,
1227            table.match_path("/app").unwrap().headers,
1228            "the door's mount and the compiled entry must move together"
1229        );
1230        assert_eq!(
1231            after.mounts[1]
1232                .headers
1233                .get("cross-origin-embedder-policy")
1234                .map(String::as_str),
1235            Some("require-corp")
1236        );
1237        // And only that mount moved — no merging across rules.
1238        assert_eq!(before.mounts[0].headers, after.mounts[0].headers);
1239    }
1240
1241    /// The order-dependence the shared rule brings, stated as a test rather
1242    /// than left to be discovered: a catch-all declared ABOVE `/app/*` claims
1243    /// `/app` at every tier, and the inner door now agrees instead of quietly
1244    /// disagreeing. This is the manifest being wrong, not the door.
1245    #[test]
1246    fn a_catch_all_declared_first_claims_every_mount_at_both_tiers() {
1247        let svc = service(
1248            "noisetable",
1249            vec![
1250                component("site", None, DeployTier::Bundle),
1251                component("account", Some("app"), DeployTier::Workload),
1252            ],
1253        );
1254        let domains = domains(
1255            "noisetable",
1256            &[
1257                ("/*", &[("x-frame-options", "DENY")]),
1258                ("/app/*", &[("cross-origin-opener-policy", "same-origin")]),
1259            ],
1260        );
1261        let table = compiled(&domains);
1262        let plan = plan(&svc, &domains).unwrap().unwrap();
1263
1264        assert_eq!(
1265            table.match_path("/app").unwrap().path,
1266            "/*",
1267            "first match wins over the compiled table"
1268        );
1269        assert_eq!(plan.mounts[1].mount, "/app");
1270        assert_eq!(
1271            plan.mounts[1].headers.keys().cloned().collect::<Vec<_>>(),
1272            vec!["x-frame-options".to_string()],
1273            "and the inner door reports the same route's headers, not a second join's"
1274        );
1275    }
1276}