Skip to main content

cloud/reconciler/
domain.rs

1//! Domain-level reconciler — `.yah/domains/*.toml` → live Cloudflare state.
2//!
3//! Today's only shape is the **R2 custom-domain** binding: a domain that
4//! declares `front_door = "bucket-direct"` (R594-F12) and names an R2 bucket
5//! in `cdn_bucket`. Cloudflare's R2 Custom Domains API binds the hostname to
6//! the bucket and writes the CNAME into the parent zone automatically when
7//! the zone lives on the same account — no DNS-side call is needed here.
8//!
9//! Domains with `front_door = "worker"` (e.g. `app-yah-dev.toml`) are out of
10//! scope for this shape; they get DNS + route management through the Worker
11//! reconciler below. `front_door = "passway"` is the sovereign-ingress path
12//! (W267) and is reconciled by [`ensure_passway_apex`] — R859-F1 — which
13//! renders the apex A record set from the domain manifest plus the workspace
14//! ingress collation, DNS-only, through the provider-agnostic `dns.*` envoy
15//! verbs.
16//!
17//! Before R594-F12 the shape was *inferred* from the absence of `[[routes]]`,
18//! which meant a route-carrying manifest bound straight to R2 was accepted
19//! and silently skipped by this pass. The discriminator is now declared and
20//! validated at load.
21//!
22//! @yah:relay(R859, "Sovereign-edge automation ring: DNS reconciled from declared intent, public addressing follows placement (W267 audit followups)")
23//! @yah:at(2026-09-04T19:06:24Z)
24//! @yah:status(handoff)
25//! @yah:assignee(agent:user-custom-char-gul2)
26//! @arch:see(.yah/docs/working/W267-sovereign-public-ingress.md)
27//! @yah:next("Filed from the 2026-09-04 HA/ingress audit (chat session, operator-reviewed). The grey door is live but the automation ring around it is manual: DNS flips ride scripts/cf-apex-mode.sh by hand, and node loss leaves a dead A record taking ~half the round-robin traffic until a human edits DNS.")
28//! @yah:next("Order: F1 (DNS reconciler) before F2 (floating-IP wiring) — F2's DNS-withdrawal half needs F1's record-rendering to exist. Sibling track R844-F23 (poll-N door discovery) is independent and filed under R844 where door discovery lives.")
29//! @yah:next("Deliberately out of scope here: replica layer (W284/R626), kamaji healthcheck execution, generic appliance backup/migrate (R742-F3), external synthetic prober — each is its own track; this relay is only 'public addressing follows declared intent'.")
30
31use std::path::Path;
32
33use anyhow::{Context, Result};
34use tracing::{debug, info, warn};
35
36use crate::CloudflareClient;
37
38/// Bind `domain` as a custom domain on `bucket_name`. Idempotent — does
39/// nothing when the binding is already present, regardless of `enabled`
40/// state (CF reflects newly-added bindings as `enabled: true` immediately;
41/// disabling is an explicit dashboard action we don't undo here).
42///
43/// Resolves account_id + the Cloudflare API token from the named provider
44/// (`.yah/infra/providers/<provider_id>.toml`, see
45/// [`super::cf_creds::CfProvider`]). Mirrors the list-first pattern of
46/// `static_asset::ensure_r2_bucket` so the apply loop is safe to re-run.
47///
48/// Required token scopes: `Workers R2 Storage: Edit` and `Zone: Read`
49/// (the latter because the API requires the zone id of the parent zone —
50/// CF writes the CNAME there). The caller does NOT need `DNS: Edit`; CF
51/// provisions the record itself when the binding is created.
52pub async fn ensure_r2_custom_domain(
53    workspace_root: &Path,
54    provider_id: &str,
55    bucket_name: &str,
56    domain: &str,
57) -> Result<()> {
58    let cf_provider = super::cf_creds::CfProvider::resolve(workspace_root, provider_id)?;
59    let account_id = cf_provider.account_id.clone();
60    let cf = CloudflareClient::new(cf_provider.api_token()?);
61    let existing = cf
62        .list_r2_custom_domains(&account_id, bucket_name)
63        .await
64        .with_context(|| format!("listing R2 custom domains on bucket {bucket_name:?}"))?;
65    if existing.iter().any(|d| d.domain == domain) {
66        debug!(
67            domain,
68            bucket_name, "R2 custom domain already bound — skipping"
69        );
70        return Ok(());
71    }
72    let zone_name = parent_zone_name(domain);
73    let zone_id = cf
74        .zone_id_for_name(zone_name)
75        .await
76        .with_context(|| format!("resolving zone id for {zone_name:?}"))?;
77    cf.add_r2_custom_domain(&account_id, bucket_name, domain, &zone_id)
78        .await
79        .with_context(|| format!("binding R2 custom domain {domain:?} → bucket {bucket_name:?}"))?;
80    info!(domain, bucket_name, zone_name, "R2 custom domain bound");
81    Ok(())
82}
83
84/// Heuristic: the parent CF zone of `domain` is its last two labels.
85///
86/// `cdn.yah.dev` → `yah.dev`; `yah.dev` → `yah.dev` (already apex). This is
87/// correct for every yah-owned zone today (all are two-label apexes). If a
88/// future workspace registers a three-label zone (e.g. `staging.yah.dev`
89/// as its own CF zone) and binds a subdomain under it, this will resolve
90/// to the wrong zone — swap in a longest-suffix match against
91/// `list_zones()` when that day arrives.
92fn parent_zone_name(domain: &str) -> &str {
93    let last_dot = domain.rfind('.');
94    let Some(last_dot) = last_dot else {
95        return domain; // single-label — let CF surface the bad input
96    };
97    if let Some(prev_dot) = domain[..last_dot].rfind('.') {
98        &domain[prev_dot + 1..]
99    } else {
100        domain // already a two-label apex
101    }
102}
103
104// ─── R561-F3: domain-manifest-driven static Worker ──────────────────────────
105//
106// A per-tenant alias-tier manifest (e.g. scrabcake.net.yah.dev) carries a
107// `static` route → `<service>/<component>`. Serving it means deploying the
108// shared router bundle (`WORKER_SCRIPT`) configured to fetch the tenant's
109// assets from its R2 prefix, then binding the subdomain to that Worker via the
110// Workers Custom Domains API. This is the routed-domain shape the apply loop
111// currently Skips.
112
113use crate::config::{DomainConfig, DomainRoute, FrontDoor, RouteMode};
114use crate::provider::cloudflare::WorkerBinding;
115use crate::reconciler::mesofact_static::{
116    worker_config_bindings, BackendOrigins, ConfigBinding, WorkerMode, WORKER_SCRIPT,
117};
118use crate::route_table::WorkerAssets;
119use std::collections::BTreeMap;
120
121/// The plan for deploying one subdomain's static Worker — everything decided
122/// before any Cloudflare call. Pure output of [`plan_domain_worker`], so the
123/// decision logic is unit-testable offline; [`deploy_domain_worker`] performs
124/// the I/O.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct DomainWorkerPlan {
127    /// Worker script name (the domain manifest's file stem / `name`).
128    pub worker_name: String,
129    /// Hostname to bind via Workers Custom Domains, e.g. `scrabcake.net.yah.dev`.
130    pub custom_domain: String,
131    /// `ASSET_ORIGIN` the Worker fetches from — the fall-through for a path no
132    /// `ROUTE_TABLE` entry claims, i.e. the first component static route's
133    /// `<cdn_base>/<service>/<env>`. `None` when no route is a component route
134    /// (a bucket-only CDN, R560-F13): its entries read R2 bindings, and the
135    /// binding is emitted empty.
136    pub asset_origin: Option<String>,
137    /// Worker bindings — the plain-text config plus one R2 bucket binding per
138    /// distinct bucket route — produced by
139    /// [`mesofact_static::worker_config_bindings`](crate::reconciler::mesofact_static)
140    /// — the same one producer the mirror-driven arm calls (R898-T4), not a
141    /// hand-synced twin of it.
142    pub bindings: Vec<ConfigBinding>,
143}
144
145/// Build the [`DomainWorkerPlan`] for a `front_door = "worker"` manifest.
146///
147/// `assets` says how component static routes resolve. For the alias tier it is
148/// [`WorkerAssets::PerComponent`]: `cdn_base` is the tier's CDN origin (an R2
149/// custom domain bound to the tier bucket, e.g. `https://cdn.net.yah.dev`) and
150/// `env` the mirror env the publisher wrote under (e.g. `prod`). The publisher
151/// lays assets down at `<bucket>/<service>/<env>/<key>` and the Worker fetches
152/// `${origin}/<key>`, so a component route resolves to
153/// `<cdn_base>/<service>/<env>` — per route, through its own component.
154///
155/// **R560-F13: bucket routes need no `assets` at all.** A `bucket = "…"` route
156/// compiles to an entry naming its R2 binding, and the binding set gains one
157/// R2 bucket binding per distinct bucket. A manifest-only Worker — one no
158/// mirror deploys, planned by `yah cloud apply`'s domain pass — passes
159/// [`WorkerAssets::Unplaced`], under which a component route is refused naming
160/// itself rather than resolved to an origin nobody computed.
161///
162/// **R898-T4: every declared route is planned, in manifest order.** This used
163/// to `find_map` the FIRST `RouteMode::Static` route and discard the rest with
164/// a bare `_ => None` ("v1 alias tier serves one site per subdomain"), so a
165/// manifest's second static route and every backend/redirect route were
166/// silently dropped — and a dropped entry does not 503, it falls through to
167/// whichever shorter route matches and serves the wrong thing with a 200. The
168/// routes now compile through [`DomainConfig::route_table`], the same producer
169/// every other consumer reads, with [`WorkerPlacement`] as the placement:
170/// static routes resolve per component, backend routes to the manifest's
171/// declared `origin` (the one door for which that field is the answer).
172///
173/// This also retires `static_worker_bindings`, whose own doc said it was "kept
174/// in lockstep with `mesofact_static::worker_config_bindings` by hand". It was
175/// not: it still emitted `UPLOAD_ORIGIN`, `SSR_ORIGIN`, `SSR_PREFIXES` and
176/// `ROUTE_HEADERS` after R898-F3 deleted all four from the Worker, so an
177/// alias-tier Worker would have deployed with a binding set the bundled router
178/// no longer reads. There is one producer now and this calls it.
179///
180/// Fails fast (before any network call) when the manifest has no `static`
181/// route, or when any route's origin does not resolve.
182pub fn plan_domain_worker(
183    domain: &DomainConfig,
184    assets: WorkerAssets<'_>,
185) -> Result<DomainWorkerPlan> {
186    // R594-F12: refuse to synthesize a Worker for a domain that declared a
187    // different front door. Without this the plan succeeds and deploys a
188    // Worker that never receives traffic (bucket-direct) or that duplicates
189    // the sovereign ingress (passway).
190    if domain.front_door != FrontDoor::Worker {
191        anyhow::bail!(
192            "domain {} ({}) declares front_door = \"{}\" — only \"worker\" \
193             domains get a generated Cloudflare Worker",
194            domain.name,
195            domain.domain,
196            domain.front_door.as_str()
197        );
198    }
199
200    if !domain.routes.iter().any(|r| {
201        matches!(
202            r.mode,
203            RouteMode::Static { .. } | RouteMode::StaticBucket { .. }
204        )
205    }) {
206        anyhow::bail!(
207            "domain {} ({}) has no `static` route — nothing for a static Worker to serve",
208            domain.name,
209            domain.domain
210        );
211    }
212
213    // An unresolvable component origin is refused here, naming the route and
214    // its component.
215    let bindings = worker_config_bindings(
216        &WorkerMode::Static,
217        assets,
218        &BackendOrigins::default(),
219        Some(domain),
220    )?;
221
222    // `ASSET_ORIGIN` is a single binding, so it carries the fall-through for a
223    // path no table entry claims — the first component route's origin. Every
224    // static route still has its own entry in `ROUTE_TABLE`, which is what the
225    // door matches on.
226    let asset_origin = assets.fallback_origin(Some(domain));
227
228    Ok(DomainWorkerPlan {
229        worker_name: domain.name.clone(),
230        custom_domain: domain.domain.clone(),
231        bindings,
232        asset_origin,
233    })
234}
235
236/// Deploy the planned Worker and bind the subdomain (R561-F3, live I/O).
237///
238/// Reuses the shared `WORKER_SCRIPT` bundle + the existing CloudflareClient
239/// deploy/custom-domain methods. Gated on `cloudflare-api-token`. The decision
240/// logic is covered by `plan_domain_worker`'s tests; this I/O path has NOT been
241/// exercised against a live account yet (no creds in CI) — treat as untested
242/// until a live `yah cloud apply` confirms it.
243///
244/// CAVEAT (zone resolution): `parent_zone_name` uses a two-label heuristic, so
245/// for `scrabcake.net.yah.dev` it returns `yah.dev`. But the alias tiers
246/// `net.yah.dev` / `com.yah.dev` are their own Cloudflare zones — the binding
247/// must target `net.yah.dev`, not `yah.dev`. Before this goes live, swap in a
248/// longest-suffix match against the account's zones (the upgrade path
249/// `parent_zone_name`'s doc already names). Tracked on R561-F3.
250pub async fn deploy_domain_worker(
251    workspace_root: &Path,
252    provider_id: &str,
253    plan: &DomainWorkerPlan,
254) -> Result<()> {
255    let cf_provider = super::cf_creds::CfProvider::resolve(workspace_root, provider_id)?;
256    let account_id = cf_provider.account_id.clone();
257    let cf = CloudflareClient::new(cf_provider.api_token()?);
258
259    let worker_bindings: Vec<WorkerBinding<'_>> = plan
260        .bindings
261        .iter()
262        .map(ConfigBinding::as_worker_binding)
263        .collect();
264
265    cf.deploy_worker_script(
266        &account_id,
267        &plan.worker_name,
268        WORKER_SCRIPT,
269        &worker_bindings,
270    )
271    .await
272    .with_context(|| format!("deploying static Worker {}", plan.worker_name))?;
273    info!(worker = %plan.worker_name, "alias-tier Worker deployed");
274
275    let zone = parent_zone_name(&plan.custom_domain);
276    let zone_id = cf
277        .zone_id_for_name(zone)
278        .await
279        .with_context(|| format!("resolving zone id for {zone:?}"))?;
280    cf.upsert_worker_custom_domain(
281        &account_id,
282        &zone_id,
283        &plan.custom_domain,
284        &plan.worker_name,
285    )
286    .await
287    .with_context(|| {
288        format!(
289            "binding {} to Worker {}",
290            plan.custom_domain, plan.worker_name
291        )
292    })?;
293    info!(domain = %plan.custom_domain, worker = %plan.worker_name, "alias-tier custom domain bound");
294    Ok(())
295}
296
297// ─── R561-F4: alias-tier registration ───────────────────────────────────────
298//
299// "Claim <name>.{com,net}.yah.dev": validate the label, check it's free, and
300// produce the per-tenant DomainConfig (the F2 manifest) the registration flow
301// writes. Pure + offline — the SaaS "sign up, get a subdomain" moment.
302
303/// The two wildcard alias tiers. `Com` = managed/commercial, `Net` = community.
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub enum AliasTier {
306    Com,
307    Net,
308}
309
310impl AliasTier {
311    /// The tier's Cloudflare zone, e.g. `net.yah.dev`.
312    pub fn zone(self) -> &'static str {
313        match self {
314            AliasTier::Com => "com.yah.dev",
315            AliasTier::Net => "net.yah.dev",
316        }
317    }
318
319    /// The tier's shared R2 bucket (per-tenant `<svc>/<env>` prefixes within).
320    pub fn bucket(self) -> &'static str {
321        match self {
322            AliasTier::Com => "com-yah-dev",
323            AliasTier::Net => "net-yah-dev",
324        }
325    }
326
327    /// Manifest-name infix, e.g. `net` → `<name>-net-yah-dev` file stem.
328    fn slug(self) -> &'static str {
329        match self {
330            AliasTier::Com => "com",
331            AliasTier::Net => "net",
332        }
333    }
334}
335
336/// A valid DNS label: 1–63 chars, lowercase alphanumeric or hyphen, no
337/// leading/trailing hyphen. (Subdomain names a tenant can claim.)
338pub fn valid_subdomain_label(name: &str) -> bool {
339    !name.is_empty()
340        && name.len() <= 63
341        && !name.starts_with('-')
342        && !name.ends_with('-')
343        && name
344            .bytes()
345            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
346}
347
348/// Validate and build the per-tenant alias manifest for a claim.
349///
350/// `name` is the requested subdomain label (e.g. `scrabcake`); `component` is
351/// the tenant's static component ref (`"<service>/<component-id>"`). `existing`
352/// is the current domain map (from `CloudConfig`) — the claim fails if the
353/// resulting host or manifest name is already taken. Returns the
354/// [`DomainConfig`] to write at `.yah/domains/<stem>.toml`.
355pub fn plan_alias_claim(
356    tier: AliasTier,
357    name: &str,
358    component: &str,
359    existing: &BTreeMap<String, DomainConfig>,
360) -> Result<DomainConfig> {
361    if !valid_subdomain_label(name) {
362        anyhow::bail!(
363            "invalid subdomain {name:?} — must be 1–63 chars, lowercase \
364             alphanumeric or hyphen, no leading/trailing hyphen"
365        );
366    }
367    if component.split_once('/').is_none() {
368        anyhow::bail!("component {component:?} — expected \"<service>/<component-id>\"");
369    }
370
371    let domain = format!("{name}.{}", tier.zone());
372    let stem = format!("{name}-{}-yah-dev", tier.slug());
373
374    if existing.values().any(|d| d.domain == domain) {
375        anyhow::bail!("{domain} is already claimed");
376    }
377    if existing.contains_key(&stem) {
378        anyhow::bail!("domain manifest {stem:?} already exists");
379    }
380
381    Ok(DomainConfig {
382        schema_version: 1,
383        name: stem,
384        domain,
385        // R594-F12: every tenant manifest is Worker-served (R561-F3 binds the
386        // subdomain as a custom domain on the generated Worker).
387        front_door: FrontDoor::Worker,
388        cdn_bucket: tier.bucket().to_string(),
389        worker_bundle_path: None,
390        routes: vec![DomainRoute {
391            path: "/*".into(),
392            // A claimed alias serves one bundle at the root with no special
393            // header needs; a tenant that wants some edits its own manifest.
394            headers: BTreeMap::new(),
395            mode: RouteMode::Static {
396                component: component.to_string(),
397            },
398        }],
399    })
400}
401
402// ─── R859-F1: sovereign apex (front_door = "passway") ───────────────────────
403//
404// The two-source flip this dissolves: `.yah/domains/*.toml` DECLARED
405// `front_door`, while `scripts/cf-apex-mode.sh` imperatively MUTATED the apex
406// records, and nothing kept the two in agreement. The declaration drifted from
407// the live zone for 19 days once (R330-B36) and 4 more (R703-B4). Here the
408// manifest is the only source and the reconciler renders the records from it.
409//
410// Same house shape as the Worker arm: a pure planner (`plan_domain_passway` +
411// `diff_apex_records`) that is fully unit-testable offline, and an I/O applier
412// (`deploy_domain_passway`) that only executes the diff.
413
414use crate::config::MachineConfig;
415use crate::envoy::dns_record::{
416    DnsRecordDeleteInput, DnsRecordListInput, DnsRecordUpsertInput,
417};
418use crate::provider::cloudflare_envoy::CloudflareEnvoy;
419use std::net::Ipv4Addr;
420
421/// One declared public origin behind a sovereign apex — a machine that runs a
422/// passway front door and carries a routable IPv4 address.
423#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
424pub struct PasswayOrigin {
425    /// Machine name, as declared in `.yah/infra/machines/<name>.toml`. Carried
426    /// so every error and log line names the box an operator would open.
427    pub machine: String,
428    /// The address the apex A record will publish.
429    pub address: Ipv4Addr,
430}
431
432/// What [`public_origins`] resolved: the origins to publish, and the ones it
433/// deliberately held back because their machine is confirmed down (R859-F2).
434///
435/// Two lists rather than one shorter one, because the difference is
436/// load-bearing downstream: an origin that is *absent* from a declaration and
437/// an origin that is *present and known dead* want opposite treatment when the
438/// declaration itself is untrustworthy. See
439/// [`DomainPasswayPlan::health_withdrawn`].
440#[derive(Debug, Clone, Default, PartialEq, Eq)]
441pub struct ResolvedOrigins {
442    /// Origins the apex should publish.
443    pub origins: Vec<PasswayOrigin>,
444    /// Declared, resolvable origins withheld because their machine is
445    /// confirmed down.
446    pub health_withdrawn: Vec<PasswayOrigin>,
447}
448
449/// The apex record set one passway domain should carry — pure output of
450/// [`plan_domain_passway`], applied by [`deploy_domain_passway`].
451#[derive(Debug, Clone, PartialEq, Eq)]
452pub struct DomainPasswayPlan {
453    /// Parent Cloudflare zone apex name, from [`parent_zone_name`].
454    pub zone: String,
455    /// FQDN the records live at — the manifest's `domain`.
456    pub name: String,
457    /// Desired origins, deduplicated and sorted by address so two planning
458    /// runs over the same declaration are byte-identical.
459    pub origins: Vec<PasswayOrigin>,
460    /// Whether [`origins`](Self::origins) is the **whole** declared set.
461    ///
462    /// `false` when the ingress collation reported problems, because
463    /// [`collate_workspace_ingress`](crate::validate::collate_workspace_ingress)
464    /// returns `Ok` while *skipping* an edge whose declaration fails to plan —
465    /// so a passway edge with a config typo silently drops its machine from
466    /// `front_doors`. An absent machine and a withdrawn machine are then
467    /// indistinguishable from inside this plan, and the two want opposite
468    /// treatment: a withdrawal should prune the record, a typo must not.
469    ///
470    /// So the flag gates exactly one thing —
471    /// [`diff_apex_records`] withholds every prune when it is `false` —
472    /// and nothing else. Upserts still happen: growing the fleet must keep
473    /// working through an unrelated service's broken declaration, and an
474    /// upsert can never make the apex worse. **Fail-closed on withdrawal,
475    /// fail-open on addition.**
476    pub origins_complete: bool,
477    /// Addresses withdrawn because their machine is **confirmed down** —
478    /// R859-F2's cross-provider failover, rendered through R859-F1's existing
479    /// diff rather than through a second entry point.
480    ///
481    /// # Why this is not just "absent from `origins`"
482    ///
483    /// [`origins_complete`](Self::origins_complete) and this field are two
484    /// different facts and it is worth being exact about which is which, because
485    /// conflating them silently disables the failover this ticket exists for:
486    ///
487    /// - `origins_complete` answers **"is the *declaration* picture
488    ///   trustworthy?"** `false` means the collation skipped an edge, so an
489    ///   origin's absence might be a withdrawal or might be a config typo —
490    ///   indistinguishable, so every prune is withheld.
491    /// - `health_withdrawn` answers **"which declared machine do we know is
492    ///   down?"** It is *positive* evidence about a machine the collation
493    ///   plainly saw, not an inference from an absence.
494    ///
495    /// So a health withdrawal is **not** suppressed by `origins_complete =
496    /// false`. The discriminator `origins_complete` exists to protect is
497    /// exactly "did we see this machine declared?", and for these machines the
498    /// answer is yes — they were resolved, taint-checked and address-checked on
499    /// the way into this list. A broken edge elsewhere in the workspace says
500    /// nothing about a box we watched go down, and letting it veto the
501    /// withdrawal would leave a dead origin taking its share of the
502    /// round-robin for as long as some unrelated service's TOML is wrong.
503    ///
504    /// The fail-closed-on-withdrawal rule is not weakened by this: the gate for
505    /// a health withdrawal is the *quorum* verdict, applied one layer up in
506    /// [`plan_ingress_owner_effect`](crate::provider::floating_ip::plan_ingress_owner_effect),
507    /// which refuses to emit the exclusion at all out of a degraded quorum. Two
508    /// withdrawal paths, each fail-closed on the evidence that is actually
509    /// relevant to it.
510    pub health_withdrawn: Vec<PasswayOrigin>,
511}
512
513/// One A record currently live at the apex, as read back by
514/// `dns.record.list`.
515#[derive(Debug, Clone, PartialEq, Eq)]
516pub struct LiveApexRecord {
517    /// The published address.
518    pub content: String,
519    /// Whether the provider proxies it (Cloudflare orange-cloud).
520    pub proxied: bool,
521}
522
523/// What one apply has to change to converge the apex.
524#[derive(Debug, Clone, Default, PartialEq, Eq)]
525pub struct ApexRecordDiff {
526    /// Addresses to upsert as DNS-only A records.
527    pub upsert: Vec<String>,
528    /// Addresses to remove — surplus A records at the same name.
529    pub prune: Vec<String>,
530    /// Addresses that *would* have been pruned but were left in place because
531    /// [`DomainPasswayPlan::origins_complete`] was `false`.
532    ///
533    /// Kept rather than dropped so the applier can name the exact records it
534    /// declined to touch. A live A record sitting here is the visible symptom
535    /// of a broken ingress declaration somewhere in the workspace — it is
536    /// either a real withdrawal this apply refused to act on, or an origin the
537    /// collation could not see.
538    pub withheld_prune: Vec<String>,
539}
540
541impl ApexRecordDiff {
542    /// `true` when this apply has no write to make. Withheld prunes do not
543    /// count: they are deliberately not writes, and a converged-with-withheld
544    /// diff still has something to warn about.
545    pub fn is_converged(&self) -> bool {
546        self.upsert.is_empty() && self.prune.is_empty()
547    }
548}
549
550/// What [`deploy_domain_passway`] actually did, for the apply summary.
551#[derive(Debug, Clone, Default, PartialEq, Eq)]
552pub struct PasswayApexOutcome {
553    /// Addresses written (created or re-pointed to DNS-only).
554    pub upserted: Vec<String>,
555    /// Addresses withdrawn.
556    pub pruned: Vec<String>,
557    /// Addresses left in place that the declaration no longer names, because
558    /// the ingress collation was incomplete — see
559    /// [`DomainPasswayPlan::origins_complete`]. Surfaced in the outcome (not
560    /// only in the log) so `yah cloud apply` can print it: a withheld prune is
561    /// the operator's cue that some service's ingress declaration is broken.
562    pub withheld_prune: Vec<String>,
563}
564
565impl PasswayApexOutcome {
566    /// `true` when the live apex already matched the declaration and nothing
567    /// was written.
568    pub fn is_noop(&self) -> bool {
569        self.upserted.is_empty() && self.pruned.is_empty()
570    }
571}
572
573/// Parse `address` as a **publicly routable** IPv4, naming `machine` on
574/// failure.
575///
576/// This is the publicness cross-check nothing performs today: the `public-ip`
577/// taint and `connect.address` are two independent operator declarations, and
578/// [`crate::config::ConnectSpec::address`]'s own doc warns it is *whichever*
579/// address the operator chose to dial the box on — public, LAN, or tailnet
580/// (us-west-002 is deliberately pointed at its tailnet IP, R608-F10). Publishing
581/// a `100.64.0.x` or `192.168.x.x` A record on a public apex takes its share of
582/// the round-robin straight to a black hole, so a taint/address disagreement
583/// has to be a hard error rather than a silently-broken record.
584///
585/// Kept local to this arm on purpose (R859-F1 decision 4): a workspace-wide
586/// lint would fire on every machine that carries the taint for placement
587/// reasons while being dialled over the mesh, which is legitimate.
588fn public_ipv4_for(machine: &str, address: &str) -> Result<Ipv4Addr> {
589    let ip: Ipv4Addr = address.parse().map_err(|_| {
590        anyhow::anyhow!(
591            "machine {machine}: connect.address = {address:?} is not an IPv4 address, so it \
592             cannot be published as an apex A record. Either set it to the box's public IPv4 \
593             or drop the `public-ip` taint from {machine}.toml."
594        )
595    })?;
596    let o = ip.octets();
597    // 100.64.0.0/10 — RFC 6598 shared address space, which is also the
598    // tailnet range this fleet's mesh addresses live in.
599    let is_cgnat = o[0] == 100 && (64..=127).contains(&o[1]);
600    let reason = if ip.is_unspecified() {
601        Some("unspecified")
602    } else if ip.is_loopback() {
603        Some("loopback")
604    } else if ip.is_private() {
605        Some("RFC 1918 private")
606    } else if ip.is_link_local() {
607        Some("link-local")
608    } else if is_cgnat {
609        Some("RFC 6598 shared / tailnet")
610    } else if ip.is_multicast() {
611        Some("multicast")
612    } else if ip.is_broadcast() {
613        Some("broadcast")
614    } else {
615        None
616    };
617    if let Some(reason) = reason {
618        anyhow::bail!(
619            "machine {machine}: connect.address = {address:?} is a {reason} address, not a \
620             public one — publishing it at a public apex would black-hole its share of the \
621             round-robin. Set connect.address to the box's public IPv4 or drop the \
622             `public-ip` taint from {machine}.toml."
623        );
624    }
625    Ok(ip)
626}
627
628/// Resolve collated front-door machine names to the public origins the apex
629/// should publish (R859-F1).
630///
631/// Keeps only machines carrying the [`workload_spec::PUBLIC_IP_TAINT`]
632/// affinity taint — the same declaration that makes yubaba willing to place a
633/// public ingress appliance there — and reads each one's
634/// [`ConnectSpec::address`](crate::config::ConnectSpec::address). A named
635/// machine that is absent from `machines` is an error, not a silent drop: the
636/// alternative is an apex that quietly shrinks because a machine TOML was
637/// renamed.
638///
639/// Machines without the taint are skipped silently. That is the intended
640/// filter, not a swallowed error — a passway edge may be collated onto a
641/// mesh-only box that fronts internal hostnames.
642///
643/// # `health_excluded` — R859-F2's withdrawal seam
644///
645/// Machines named in `health_excluded` are declared, resolvable, and dropped
646/// from the origin list anyway, because something confirmed they are down. The
647/// resulting shorter list flows through [`plan_domain_passway`] and
648/// [`diff_apex_records`] unchanged, so the withdrawal renders as an ordinary
649/// prune and needs no second entry point — the whole point of extending this
650/// function rather than adding a parallel one.
651///
652/// Exclusions are returned separately (see [`ResolvedOrigins`]) rather than
653/// silently vanishing, because "declared but withheld for health" is a
654/// different fact from "never declared", and [`diff_apex_records`] has to be
655/// able to tell them apart. See [`DomainPasswayPlan::health_withdrawn`].
656///
657/// An excluded machine is still *resolved* first: it must exist, carry the
658/// taint and have a public address, exactly as if it were staying. Skipping
659/// those checks would let a health exclusion paper over a config error, and a
660/// machine we cannot resolve is one we cannot honestly say we are withdrawing.
661///
662/// A name in `health_excluded` that is not a front door at all is ignored — the
663/// caller's liveness view covers the whole fleet, and most of it never fronts
664/// anything.
665pub fn public_origins(
666    front_door_machines: &[String],
667    machines: &[MachineConfig],
668    health_excluded: &[String],
669) -> Result<ResolvedOrigins> {
670    let mut resolved = ResolvedOrigins::default();
671    for name in front_door_machines {
672        let machine = machines
673            .iter()
674            .find(|m| &m.name == name)
675            .with_context(|| {
676                format!(
677                    "front door collated onto machine {name:?}, which is in neither this \
678                     camp's own .yah/infra/machines/*.toml nor any fleet it borrows through \
679                     .yah/infra/sources.toml — cannot resolve its public address"
680                )
681            })?;
682        if !machine
683            .taints
684            .iter()
685            .any(|t| t == workload_spec::PUBLIC_IP_TAINT)
686        {
687            debug!(
688                machine = %name,
689                "front-door machine has no `public-ip` taint — not an apex origin"
690            );
691            continue;
692        }
693        let address = machine
694            .connect
695            .as_ref()
696            .map(|c| c.address.as_str())
697            .with_context(|| {
698                format!(
699                    "machine {name} carries the `public-ip` taint but declares no \
700                     [connect] address — nothing to publish at the apex"
701                )
702            })?;
703        let origin = PasswayOrigin {
704            machine: name.clone(),
705            address: public_ipv4_for(name, address)?,
706        };
707        if health_excluded.iter().any(|m| m == name) {
708            debug!(
709                machine = %name,
710                address = %origin.address,
711                "front-door machine confirmed down — withheld from the apex origin set"
712            );
713            resolved.health_withdrawn.push(origin);
714        } else {
715            resolved.origins.push(origin);
716        }
717    }
718    Ok(resolved)
719}
720
721/// Build the apex record plan for a `front_door = "passway"` manifest.
722///
723/// Refuses two shapes outright:
724///
725/// 1. A manifest declaring a different front door — the same guard
726///    [`plan_domain_worker`] carries, for the same reason.
727/// 2. An **empty** origin set. The desired set is what the applier prunes
728///    against, so an empty one would not mean "leave it alone", it would mean
729///    "delete every A record at the apex" — a live outage rendered from a
730///    collation that simply found nothing. Nothing about "no machine is
731///    declared" says "take the site down", so it is an error.
732///
733/// That empty-set guard is checked **before** `origins_complete` is consulted:
734/// an empty set is unusable whether or not the collation was clean, so it stays
735/// the louder failure.
736///
737/// `origins_complete` is the caller's answer to "did the collation see the
738/// whole picture?" — `false` withholds every prune downstream. See
739/// [`DomainPasswayPlan::origins_complete`].
740///
741/// # The empty-set guard is also the health failover's backstop (R859-F2)
742///
743/// Guard 2 is checked against the origins that **survive** health exclusion, and
744/// that is the load-bearing interaction of this whole feature: if every declared
745/// front door is confirmed down, `origins` is empty and this refuses. "All our
746/// front doors are down" must never render as "withdraw every A record and take
747/// the site down" — a dead origin still in DNS is a partial outage, an empty
748/// apex is a total one, and between those the first is strictly better. So the
749/// health withdrawal is capped at "all but the last origin" by construction,
750/// with no separate rule to keep in sync.
751///
752/// An address that is *also* served by a surviving origin is dropped from
753/// [`health_withdrawn`](DomainPasswayPlan::health_withdrawn) for the same
754/// reason, one level finer: two machines can share a floating IP (the dedup
755/// comment below names that case), and pruning the record because one of them
756/// died would withdraw an address the other is still answering on.
757pub fn plan_domain_passway(
758    domain: &DomainConfig,
759    resolved: ResolvedOrigins,
760    origins_complete: bool,
761) -> Result<DomainPasswayPlan> {
762    let ResolvedOrigins {
763        origins,
764        health_withdrawn,
765    } = resolved;
766    if domain.front_door != FrontDoor::Passway {
767        anyhow::bail!(
768            "domain {} ({}) declares front_door = \"{}\" — only \"passway\" domains \
769             get a sovereign apex rendered here",
770            domain.name,
771            domain.domain,
772            domain.front_door.as_str()
773        );
774    }
775
776    // By address, not by machine name: the address is what lands in DNS, so
777    // ordering on it is what makes two planning runs byte-identical even if a
778    // box is renamed. Dedup on the same key — two edges collated onto one
779    // machine (or two machines sharing a floating IP) publish one record.
780    let mut origins = origins;
781    origins.sort_by(|a, b| (a.address, &a.machine).cmp(&(b.address, &b.machine)));
782    origins.dedup_by(|a, b| a.address == b.address);
783
784    if origins.is_empty() {
785        anyhow::bail!(
786            "domain {} ({}) declares front_door = \"passway\" but no declared front-door \
787             machine carries the `public-ip` taint with a public address — refusing to \
788             render an empty apex, which would withdraw every A record and take the site \
789             down. Declare the ingress edge's machines (or their taints) first.",
790            domain.name,
791            domain.domain
792        );
793    }
794
795    // Same normalisation as `origins`, plus the survivor filter: an address a
796    // live origin still answers on is not withdrawn, however many of the
797    // machines sharing it went down.
798    let mut health_withdrawn = health_withdrawn;
799    health_withdrawn.retain(|w| !origins.iter().any(|o| o.address == w.address));
800    health_withdrawn.sort_by(|a, b| (a.address, &a.machine).cmp(&(b.address, &b.machine)));
801    health_withdrawn.dedup_by(|a, b| a.address == b.address);
802
803    Ok(DomainPasswayPlan {
804        zone: parent_zone_name(&domain.domain).to_string(),
805        name: domain.domain.clone(),
806        origins,
807        origins_complete,
808        health_withdrawn,
809    })
810}
811
812/// Diff the planned apex against the A records live at that name.
813///
814/// `live` must already be narrowed to **type A at `plan.name`** — MX, TXT and
815/// AAAA records share the apex and are never this arm's to touch.
816///
817/// A live record whose content is desired but which is *proxied* still lands in
818/// `upsert`: orange-cloud is a break-glass state
819/// (`scripts/cf-apex-mode.sh orange`) and this reconciler declares DNS-only, so
820/// re-writing the record with `proxied = false` is convergence, not churn.
821///
822/// When [`plan.origins_complete`](DomainPasswayPlan::origins_complete) is
823/// `false` the surplus records move to
824/// [`withheld_prune`](ApexRecordDiff::withheld_prune) instead of `prune`, and
825/// `upsert` is unaffected. An incomplete collation cannot tell a withdrawn
826/// origin from one whose declaration failed to plan, and only one of those two
827/// wants a DNS withdrawal.
828///
829/// # …with one exception, and it is the point of R859-F2
830///
831/// A surplus address that appears in
832/// [`plan.health_withdrawn`](DomainPasswayPlan::health_withdrawn) is pruned
833/// **regardless of `origins_complete`**. The two flags are not two strengths of
834/// the same doubt, they are answers to different questions, and the four cases
835/// come out like this:
836///
837/// | `origins_complete` | in `health_withdrawn` | verdict |
838/// |---|---|---|
839/// | `true`  | no  | `prune` — an ordinary withdrawal from a trusted declaration |
840/// | `true`  | yes | `prune` — a health withdrawal from a trusted declaration |
841/// | `false` | no  | `withheld_prune` — might be a withdrawal, might be a typo |
842/// | `false` | yes | `prune` — we saw this machine declared *and* saw it die |
843///
844/// The bottom-right cell is the one that matters. `origins_complete = false`
845/// protects against mistaking an absence for a withdrawal; a health withdrawal
846/// is not an absence, it is a positive observation about a machine the
847/// collation resolved. An unrelated service's broken TOML is not evidence about
848/// a box we watched go down, and letting it withhold the prune would leave a
849/// dead origin serving its share of the round-robin for as long as that typo
850/// lives.
851pub fn diff_apex_records(plan: &DomainPasswayPlan, live: &[LiveApexRecord]) -> ApexRecordDiff {
852    let desired: Vec<String> = plan
853        .origins
854        .iter()
855        .map(|o| o.address.to_string())
856        .collect();
857    let upsert = desired
858        .iter()
859        .filter(|ip| {
860            !live
861                .iter()
862                .any(|r| &&r.content == ip && !r.proxied)
863        })
864        .cloned()
865        .collect();
866    let surplus: Vec<String> = live
867        .iter()
868        .filter(|r| !desired.contains(&r.content))
869        .map(|r| r.content.clone())
870        .collect();
871    if plan.origins_complete {
872        return ApexRecordDiff {
873            upsert,
874            prune: surplus,
875            withheld_prune: Vec::new(),
876        };
877    }
878    // Incomplete declaration: withhold every prune EXCEPT the health
879    // withdrawals, which rest on a positive observation rather than on an
880    // absence. See this function's doc table.
881    let withdrawn: Vec<String> = plan
882        .health_withdrawn
883        .iter()
884        .map(|o| o.address.to_string())
885        .collect();
886    let (prune, withheld_prune) = surplus
887        .into_iter()
888        .partition(|content| withdrawn.contains(content));
889    ApexRecordDiff {
890        upsert,
891        prune,
892        withheld_prune,
893    }
894}
895
896/// Resolve the DNS adapter one passway apply talks to.
897///
898/// Its own function only so the read path and the write path cannot drift onto
899/// different credentials or a different provider seam.
900fn passway_envoy(workspace_root: &Path, provider_id: &str) -> Result<CloudflareEnvoy> {
901    let cf_provider = super::cf_creds::CfProvider::resolve(workspace_root, provider_id)?;
902    Ok(CloudflareEnvoy::new(
903        cf_provider.api_token()?,
904        cf_provider.account_id.clone(),
905    ))
906}
907
908/// The `list` leg of [`deploy_domain_passway`] — the live A records at one
909/// name, as `dns.record.list` reports them.
910///
911/// Takes `zone`/`name` rather than a [`DomainPasswayPlan`] because the
912/// no-door-declared branch of [`ensure_passway_apex`] has to read the apex
913/// *without* a plan — there are no origins to build one from, and whether the
914/// apex is currently serving is exactly the question it needs answered.
915async fn read_live_apex(
916    envoy: &CloudflareEnvoy,
917    zone: &str,
918    name: &str,
919) -> Result<Vec<LiveApexRecord>> {
920    let listed = envoy
921        .dns_record_list(DnsRecordListInput {
922            zone: zone.to_string(),
923            name: Some(name.to_string()),
924            record_type: Some("A".to_string()),
925        })
926        .await
927        .with_context(|| format!("listing A records at {name}"))?;
928    Ok(listed
929        .records
930        .into_iter()
931        .map(|r| LiveApexRecord {
932            content: r.content,
933            proxied: r.proxied,
934        })
935        .collect())
936}
937
938/// Read the live apex A records **without being able to write any** — the
939/// read-only half of [`deploy_domain_passway`], exposed so convergence can be
940/// checked against a real Cloudflare account by something that has no write
941/// path at all (R859-F1's live acceptance check,
942/// `tests/passway_apex_live.rs`).
943///
944/// Pair it with [`plan_passway_apex`] and [`diff_apex_records`] to answer "what
945/// would `yah cloud apply` change?" against live DNS. That is the *whole* of
946/// what an apply decides — same planner, same list verb, same diff — minus the
947/// two `dns.record.upsert` / `dns.record.delete` calls, which this function
948/// cannot reach. A caller can therefore assert convergence against the real
949/// zone without a live-DNS blast radius, which is what makes the check safe to
950/// leave runnable rather than described in a handoff.
951pub async fn list_live_apex_records(
952    workspace_root: &Path,
953    provider_id: &str,
954    plan: &DomainPasswayPlan,
955) -> Result<Vec<LiveApexRecord>> {
956    let envoy = passway_envoy(workspace_root, provider_id)?;
957    read_live_apex(&envoy, &plan.zone, &plan.name).await
958}
959
960/// Apply a [`DomainPasswayPlan`] against live DNS (R859-F1, live I/O).
961///
962/// **First production consumer of the `dns.*` envoy verbs** — every read and
963/// write here goes through [`CloudflareEnvoy`]'s typed verb handlers rather
964/// than the `CloudflareClient` methods the older arms call directly, so
965/// swapping the DNS provider is a matter of resolving a different adapter here
966/// and nothing else.
967///
968/// Two invariants, both borrowed from `scripts/cf-apex-mode.sh`, whose
969/// behaviour this replaces for the routine case:
970///
971/// - **List first, skip when converged.** Same shape as
972///   [`ensure_r2_custom_domain`], so a re-run of `yah cloud apply` writes
973///   nothing.
974/// - **Upsert before prune.** Desired records are written first and surplus
975///   ones removed last, so the apex is never momentarily recordless. Only
976///   `A` records at the name are ever pruned; the apex's MX and TXT records
977///   (mail routing, SPF, site verification) are outside the filter by
978///   construction.
979/// - **Fail-closed on withdrawal, fail-open on addition.** When the plan says
980///   the origin set is incomplete
981///   ([`origins_complete = false`](DomainPasswayPlan::origins_complete)) the
982///   upserts still run but every prune is withheld and warned about — an
983///   absent origin might be a withdrawal or might be a config typo upstream,
984///   and only one of those should take a live record away.
985///
986/// Always writes `proxied = false`: the manifest cannot express grey vs
987/// orange, and orange stays break-glass in the script (W267 tier ladder).
988pub async fn deploy_domain_passway(
989    workspace_root: &Path,
990    provider_id: &str,
991    plan: &DomainPasswayPlan,
992) -> Result<PasswayApexOutcome> {
993    let envoy = passway_envoy(workspace_root, provider_id)?;
994    let live = read_live_apex(&envoy, &plan.zone, &plan.name).await?;
995
996    let diff = diff_apex_records(plan, &live);
997
998    // Warned before the converged early-return: a withheld prune is worth
999    // saying out loud even on an apply that writes nothing, because the record
1000    // it names stays live until someone fixes the declaration.
1001    if !diff.withheld_prune.is_empty() {
1002        warn!(
1003            domain = %plan.name,
1004            withheld = %diff.withheld_prune.join(", "),
1005            "apex A records NOT withdrawn: the ingress collation reported problems, so an \
1006             origin missing from it may be a broken declaration rather than a withdrawal. \
1007             Run `yah cloud validate` and fix the reported ingress declaration; these \
1008             records stay live until the collation is clean."
1009        );
1010    }
1011
1012    if diff.is_converged() {
1013        debug!(
1014            domain = %plan.name,
1015            origins = plan.origins.len(),
1016            "sovereign apex already matches the declaration — skipping"
1017        );
1018        return Ok(PasswayApexOutcome {
1019            withheld_prune: diff.withheld_prune,
1020            ..Default::default()
1021        });
1022    }
1023
1024    // Writes first: never leave the apex without a routing record.
1025    for ip in &diff.upsert {
1026        envoy
1027            .dns_record_upsert(DnsRecordUpsertInput {
1028                zone: plan.zone.clone(),
1029                name: plan.name.clone(),
1030                record_type: "A".to_string(),
1031                content: ip.clone(),
1032                ttl: 1,
1033                proxied: false,
1034                // A round-robin apex is a multi-valued RRset: without this the
1035                // second origin would overwrite the first.
1036                match_content: true,
1037            })
1038            .await
1039            .with_context(|| format!("upserting A {} -> {ip}", plan.name))?;
1040        info!(domain = %plan.name, origin = %ip, "sovereign apex A record written");
1041    }
1042
1043    // Prune last, and only ever type A carrying an undeclared value.
1044    for ip in &diff.prune {
1045        envoy
1046            .dns_record_delete(DnsRecordDeleteInput {
1047                zone: plan.zone.clone(),
1048                name: plan.name.clone(),
1049                record_type: Some("A".to_string()),
1050                content: Some(ip.clone()),
1051            })
1052            .await
1053            .with_context(|| format!("pruning surplus A {} -> {ip}", plan.name))?;
1054        info!(domain = %plan.name, origin = %ip, "surplus apex A record withdrawn");
1055    }
1056
1057    Ok(PasswayApexOutcome {
1058        upserted: diff.upsert,
1059        pruned: diff.prune,
1060        withheld_prune: diff.withheld_prune,
1061    })
1062}
1063
1064/// Reconcile one `front_door = "passway"` domain end to end — the entry point
1065/// `yah cloud apply` calls (R859-F1).
1066///
1067/// Collates the workspace's declared ingress edges, keeps the passway front
1068/// doors, resolves their machines to public addresses, plans, and applies.
1069/// Every input is *declared* config: no network read decides which origins the
1070/// apex publishes, which is what makes the manifest the single source the
1071/// two-source flip lacked.
1072///
1073/// The planning half is [`plan_passway_apex`] (which also carries the
1074/// `report.problems` prune gate); this function is that plan handed to
1075/// [`deploy_domain_passway`].
1076///
1077/// # `Ok(None)`: the door is declared but not stood up yet
1078///
1079/// A camp can legitimately declare `front_door = "passway"` on a domain whose
1080/// passway edge does not exist yet — the manifest field is how you *say* which
1081/// door you intend, and R859-F1 made it the mechanism as well, so it is written
1082/// before the edge is. The noisetable camp is exactly this: `noisetable.com`
1083/// declares `passway`, its only ingress edge is a `cloudflare-tunnel` on a
1084/// different domain, and its apex is deliberately NXDOMAIN pending a node.
1085///
1086/// Treating that as an error would have been wrong twice over. It fails the
1087/// whole `yah cloud apply` — the domain loop `bail!`s at the first failure
1088/// without `--continue-on-error` — so one not-yet-built door takes down the
1089/// publish chain for every service and every other domain in the camp. And the
1090/// empty-apex guard it would trip
1091/// ([`plan_domain_passway`]'s "refusing to render an empty apex") exists to
1092/// prevent a *wipe*, which skipping prevents equally well: this arm writes
1093/// nothing at all on this path.
1094///
1095/// So "no passway edge collated" is `Ok(None)` — but only after checking that
1096/// the apex is not **currently serving**. Those are two different worlds and
1097/// only a DNS read tells them apart:
1098///
1099/// - Nothing live at the name → the door was never stood up. Skip.
1100/// - Records live at the name → an edge that WAS fronting this apex has
1101///   vanished from the collation, and the next apply would otherwise silently
1102///   leave orphaned records pointing at whatever used to serve. That is a hard
1103///   error, and it is the case the empty-apex guard was really written for.
1104///
1105/// The read costs nothing extra in practice: this arm only runs inside the
1106/// domain pass, which already resolved a Cloudflare provider and an
1107/// `account_id` before entering the loop.
1108pub async fn ensure_passway_apex(
1109    workspace_root: &Path,
1110    provider_id: &str,
1111    domain: &DomainConfig,
1112) -> Result<Option<PasswayApexOutcome>> {
1113    let Some(plan) = plan_passway_apex(workspace_root, domain)? else {
1114        let zone = parent_zone_name(&domain.domain).to_string();
1115        let envoy = passway_envoy(workspace_root, provider_id)?;
1116        let live = read_live_apex(&envoy, &zone, &domain.domain).await?;
1117        if live.is_empty() {
1118            debug!(
1119                domain = %domain.domain,
1120                "declares front_door = \"passway\" with no passway ingress edge collated, and \
1121                 nothing is live at the apex — nothing to render, skipping"
1122            );
1123            return Ok(None);
1124        }
1125        anyhow::bail!(
1126            "domain {} ({}) declares front_door = \"passway\" and {} A record(s) are live at \
1127             the apex ({}), but no passway ingress edge collates onto it as a public origin. \
1128             Either an edge that was fronting this apex has disappeared from the declaration, \
1129             and these records now point at whatever used to serve — restore the edge, or move \
1130             the domain off `passway` — or every door fronting it now sits behind a cloudflare \
1131             tunnel (`via = \"passway\"`, R910), whose hostname is a CNAME the tunnel owns: \
1132             delete these A records, since Cloudflare refuses a CNAME beside them.",
1133            domain.name,
1134            domain.domain,
1135            live.len(),
1136            live.iter()
1137                .map(|r| r.content.as_str())
1138                .collect::<Vec<_>>()
1139                .join(", "),
1140        );
1141    };
1142    deploy_domain_passway(workspace_root, provider_id, &plan)
1143        .await
1144        .map(Some)
1145}
1146
1147/// The pure half of [`ensure_passway_apex`]: collate, resolve, plan — no
1148/// network, no credential, no write path.
1149///
1150/// Split out so the apex a given workspace *would* publish can be computed by a
1151/// caller that must not be able to change it. `yah cloud apply` reaches it only
1152/// through [`ensure_passway_apex`]; the live acceptance check
1153/// (`tests/passway_apex_live.rs`) calls it directly and pairs it with
1154/// [`list_live_apex_records`], so the check exercises this exact planner rather
1155/// than a second copy of its logic that could agree with the live zone while
1156/// production disagreed.
1157///
1158/// Every input is *declared* config: no network read decides which origins the
1159/// apex publishes, which is what makes the manifest the single source the
1160/// two-source flip lacked.
1161///
1162/// ## Why `report.problems` gates the prune
1163///
1164/// [`collate_workspace_ingress`](crate::validate::collate_workspace_ingress)
1165/// returns `Ok` while **skipping** an edge whose declaration fails to plan
1166/// (`validate.rs`, the `IngressProblem::Declaration` arms). A passway edge with
1167/// a config typo therefore drops its machine out of `front_doors` silently, and
1168/// from inside the plan that is indistinguishable from the operator having
1169/// withdrawn the node — so without this gate a typo would render as a DNS
1170/// withdrawal and take that origin's share of the apex offline.
1171///
1172/// Any non-empty `problems` therefore sets `origins_complete = false`, which
1173/// withholds every prune for this apply. Deliberately *not* narrowed to the
1174/// problems that mention this domain's edge: attributing a problem to an edge
1175/// requires the very planning that failed, and "non-empty means the picture is
1176/// incomplete" is the whole of what the prune path needs to know. Equally
1177/// deliberately, problems do **not** fail the arm — one service's broken
1178/// ingress declaration must not block another's DNS apply, and upserts can
1179/// only ever add reachable origins.
1180///
1181/// ## `Ok(None)` — no passway edge collates at all
1182///
1183/// Distinct from every error this can return, and the distinction is the whole
1184/// point: a camp that declares `front_door = "passway"` before building the
1185/// door has made no mistake, and must not have its apply failed for it. See
1186/// [`ensure_passway_apex`], which decides what to do about it (and is the one
1187/// that can tell "never stood up" from "the door vanished", because that takes
1188/// a DNS read this pure function must not make).
1189///
1190/// A passway door a cloudflare tunnel stacks in front of for this hostname
1191/// (R910, [`NodeFrontDoor::stacked`](crate::reconciler::NodeFrontDoor::stacked))
1192/// counts as no door here: its hostname is a CNAME to the tunnel, so it has no
1193/// apex origin to publish, and a domain served only through such doors plans
1194/// to `None`.
1195///
1196/// Note the narrowness: `None` means the *collation* produced no passway edge.
1197/// A declared edge whose machine lacks the `public-ip` taint, or carries a
1198/// private address, still reaches [`plan_domain_passway`] and still trips its
1199/// empty-apex guard as an error — an intended door that resolves to nothing is
1200/// a misconfiguration, not an absence.
1201pub fn plan_passway_apex(
1202    workspace_root: &Path,
1203    domain: &DomainConfig,
1204) -> Result<Option<DomainPasswayPlan>> {
1205    let report = crate::validate::collate_workspace_ingress(workspace_root)
1206        .context("collating workspace ingress to find the passway front doors")?;
1207
1208    let origins_complete = report.problems.is_empty();
1209    if !origins_complete {
1210        for problem in &report.problems {
1211            warn!(
1212                domain = %domain.domain,
1213                "ingress collation problem — apex prunes withheld this apply: {}",
1214                problem.message()
1215            );
1216        }
1217    }
1218
1219    // Only the doors that front THIS domain's hostname (R858-B27). This used to
1220    // take every passway door in the camp, so a domain inherited doors declared
1221    // for a different hostname entirely: staging.noisetable.com, whose own edge
1222    // is on a LAN box with no public address, was published at production's
1223    // doors and served production's site from there.
1224    let mut front_door_machines: Vec<String> = report
1225        .collation
1226        .front_doors
1227        .iter()
1228        .filter(|fd| fd.provider == crate::config::IngressProvider::Passway)
1229        .filter(|fd| fd.rules.iter().any(|r| r.hostname == domain.domain))
1230        // R910: a door a cloudflare tunnel stacks in front of for this hostname
1231        // is not an apex origin. The world reaches it through the tunnel's
1232        // CNAME, so an A record for its machine would sit beside that CNAME —
1233        // and a tunnel-fronted node is usually one with no public address at
1234        // all, where `public_origins` would refuse the private one outright
1235        // (us-west-011's 192.168.10.11 is the case that filed this).
1236        .filter(|fd| !fd.stacked.contains(&domain.domain))
1237        .map(|fd| fd.machine.clone())
1238        .collect();
1239    front_door_machines.sort();
1240    front_door_machines.dedup();
1241
1242    // The door is declared but not built yet. Answered here rather than left to
1243    // plan_domain_passway's empty-apex guard, because an absent edge and an
1244    // edge that resolves to no public address want opposite treatment.
1245    if front_door_machines.is_empty() {
1246        return Ok(None);
1247    }
1248
1249    // R870-B13: the camp's resolved fleet inventory, not its camp-local
1250    // machine files. A camp that BORROWS another camp's fleet (empty
1251    // `.yah/infra/machines/`, one `[[source]]` link in
1252    // `.yah/infra/sources.toml`) declares no machines of its own, so reading
1253    // the camp-local loader here failed the apex render on a front-door
1254    // machine that was declared all along — in the owner's tree, which the
1255    // link names. The inventory is the one reader every name lookup shares.
1256    let inventory = crate::config::resolve_fleet_inventory(workspace_root)
1257        .context("resolving the fleet inventory to place the apex origins")?;
1258
1259    // R859-F2: no health exclusions on the `yah cloud apply` path, deliberately
1260    // and not as a stub. The confirmed-down fact is a *fleet-runtime* one —
1261    // it comes from a leader's lease hysteresis, gated on a healthy quorum
1262    // (`plan_ingress_owner_effect`) — and `yah cloud apply` is an operator
1263    // running a declarative converge from a laptop, which holds no such view.
1264    // Feeding it a liveness guess here would let a laptop with a flaky uplink
1265    // withdraw a live origin. The exclusion set enters through the effector
1266    // that *has* the fact; this path renders the declaration as declared.
1267    //
1268    // The source accounting rides along on the error rather than the success
1269    // path: "no such machine" reads identically whether this camp declared no
1270    // link, aimed one at a directory that is not a camp, or filtered the
1271    // machine out with `select`, and the operator needs to know which.
1272    let resolved =
1273        public_origins(&front_door_machines, &inventory.machines, &[]).map_err(|e| {
1274            let sources = inventory.describe_sources();
1275            if sources.is_empty() {
1276                e
1277            } else {
1278                e.context(sources)
1279            }
1280        })?;
1281    plan_domain_passway(domain, resolved, origins_complete).map(Some)
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286    use super::parent_zone_name;
1287
1288    #[test]
1289    fn parent_zone_strips_one_label_off_subdomain() {
1290        assert_eq!(parent_zone_name("cdn.yah.dev"), "yah.dev");
1291        assert_eq!(parent_zone_name("app.yah.dev"), "yah.dev");
1292    }
1293
1294    #[test]
1295    fn parent_zone_returns_self_for_apex() {
1296        assert_eq!(parent_zone_name("yah.dev"), "yah.dev");
1297    }
1298
1299    #[test]
1300    fn parent_zone_strips_only_first_label_for_deeper_subdomain() {
1301        // Two-label heuristic: yah-side zones are all two-label apexes today.
1302        assert_eq!(parent_zone_name("a.b.yah.dev"), "yah.dev");
1303    }
1304
1305    use super::{plan_domain_worker, DomainWorkerPlan};
1306    use crate::config::{DomainConfig, DomainRoute, FrontDoor, RouteMode};
1307    use crate::reconciler::mesofact_static::ConfigBinding;
1308    use crate::route_table::WorkerAssets;
1309
1310    const NET_TIER: WorkerAssets<'static> = WorkerAssets::PerComponent {
1311        cdn_base: "https://cdn.net.yah.dev",
1312        env: "prod",
1313    };
1314
1315    /// The plain-text half of a plan's bindings, by name.
1316    fn plain_text(plan: &DomainWorkerPlan) -> std::collections::BTreeMap<String, String> {
1317        plan.bindings
1318            .iter()
1319            .filter_map(|b| match b {
1320                ConfigBinding::PlainText { name, text } => Some((name.clone(), text.clone())),
1321                ConfigBinding::R2Bucket { .. } => None,
1322            })
1323            .collect()
1324    }
1325
1326    /// The R2 half of a plan's bindings: `(binding name, bucket)`, in order.
1327    fn r2_buckets(plan: &DomainWorkerPlan) -> Vec<(&str, &str)> {
1328        plan.bindings
1329            .iter()
1330            .filter_map(|b| match b {
1331                ConfigBinding::R2Bucket { name, bucket_name } => {
1332                    Some((name.as_str(), bucket_name.as_str()))
1333                }
1334                ConfigBinding::PlainText { .. } => None,
1335            })
1336            .collect()
1337    }
1338
1339    /// cdn.noisetable.com's decided shape, abridged: several prefixes over
1340    /// three buckets, one bucket named twice, the default bucket last.
1341    fn multi_bucket_manifest() -> DomainConfig {
1342        let bucket_route = |path: &str, bucket: &str| DomainRoute {
1343            headers: Default::default(),
1344            path: path.into(),
1345            mode: RouteMode::StaticBucket {
1346                bucket: bucket.into(),
1347            },
1348        };
1349        DomainConfig {
1350            schema_version: 1,
1351            name: "cdn-noisetable-com".into(),
1352            domain: "cdn.noisetable.com".into(),
1353            front_door: FrontDoor::Worker,
1354            cdn_bucket: "noisetable-marketing".into(),
1355            worker_bundle_path: None,
1356            routes: vec![
1357                bucket_route("/engine/*", "noisetable-releases"),
1358                bucket_route("/nt-cas/*", "noisetable-assets"),
1359                bucket_route("/dev/*", "noisetable-releases"),
1360                bucket_route("/*", "noisetable-marketing"),
1361            ],
1362        }
1363    }
1364
1365    /// R560-F13 acceptance: one Worker domain over several buckets compiles
1366    /// every route, in manifest order, to an entry naming the R2 binding it
1367    /// reads — distinct per bucket, shared by routes naming the same bucket,
1368    /// and carrying NO origin (these buckets have no public hostname).
1369    #[test]
1370    fn a_multi_bucket_domain_compiles_each_route_to_its_buckets_binding_in_manifest_order() {
1371        let plan = plan_domain_worker(&multi_bucket_manifest(), WorkerAssets::Unplaced).unwrap();
1372        let table = table_of(&plan);
1373        let got: Vec<(&str, &str, &str)> = table
1374            .iter()
1375            .map(|e| {
1376                assert_eq!(e["mode"], "static", "{e}");
1377                assert!(
1378                    e.get("origin").is_none() && e.get("component").is_none(),
1379                    "a bucket entry is read through its binding, never fetched: {e}"
1380                );
1381                (
1382                    e["path"].as_str().unwrap(),
1383                    e["bucket"].as_str().unwrap(),
1384                    e["binding"].as_str().unwrap(),
1385                )
1386            })
1387            .collect();
1388        assert_eq!(
1389            got,
1390            [
1391                ("/engine/*", "noisetable-releases", "R2_NOISETABLE_RELEASES"),
1392                ("/nt-cas/*", "noisetable-assets", "R2_NOISETABLE_ASSETS"),
1393                ("/dev/*", "noisetable-releases", "R2_NOISETABLE_RELEASES"),
1394                ("/*", "noisetable-marketing", "R2_NOISETABLE_MARKETING"),
1395            ]
1396        );
1397    }
1398
1399    /// The binding list carries exactly one R2 bucket binding per DISTINCT
1400    /// bucket, in first-use order, and every binding a table entry names is
1401    /// one the upload creates — a name the upload lacks is a 502 at the door.
1402    #[test]
1403    fn the_binding_list_carries_one_r2_bucket_binding_per_distinct_bucket() {
1404        let plan = plan_domain_worker(&multi_bucket_manifest(), WorkerAssets::Unplaced).unwrap();
1405        let r2 = r2_buckets(&plan);
1406        assert_eq!(
1407            r2,
1408            [
1409                ("R2_NOISETABLE_RELEASES", "noisetable-releases"),
1410                ("R2_NOISETABLE_ASSETS", "noisetable-assets"),
1411                ("R2_NOISETABLE_MARKETING", "noisetable-marketing"),
1412            ]
1413        );
1414        for entry in table_of(&plan) {
1415            let binding = entry["binding"].as_str().unwrap();
1416            assert!(r2.iter().any(|(name, _)| *name == binding), "{binding} not bound");
1417        }
1418        // No component route, so nothing falls through to an HTTP origin.
1419        assert_eq!(plan.asset_origin, None);
1420        assert_eq!(plain_text(&plan)["ASSET_ORIGIN"], "");
1421        assert_eq!(
1422            plan.bindings.iter().map(ConfigBinding::name).collect::<Vec<_>>(),
1423            [
1424                "ASSET_ORIGIN",
1425                "POINTER_ORIGIN",
1426                "WORKER_MODE",
1427                "ROUTE_TABLE",
1428                "R2_NOISETABLE_RELEASES",
1429                "R2_NOISETABLE_ASSETS",
1430                "R2_NOISETABLE_MARKETING",
1431            ]
1432        );
1433    }
1434
1435    /// The alias tier must not move: a component-only domain deploys exactly
1436    /// the binding set it did before R560-F13 — no R2 binding, and a
1437    /// `ROUTE_TABLE` byte-identical to R898-T4's.
1438    #[test]
1439    fn a_component_only_domain_deploys_exactly_todays_bindings() {
1440        let plan = plan_domain_worker(&net_tier_manifest(), NET_TIER).unwrap();
1441        let plain = |name: &str, text: &str| ConfigBinding::PlainText {
1442            name: name.into(),
1443            text: text.into(),
1444        };
1445        assert_eq!(
1446            plan.bindings,
1447            vec![
1448                plain("ASSET_ORIGIN", "https://cdn.net.yah.dev/scrabcake/prod"),
1449                plain("POINTER_ORIGIN", "https://cdn.net.yah.dev/scrabcake/prod"),
1450                plain("WORKER_MODE", "static"),
1451                plain(
1452                    "ROUTE_TABLE",
1453                    r#"[{"path":"/*","mode":"static","component":"scrabcake/site","origin":"https://cdn.net.yah.dev/scrabcake/prod","auth":"anonymous"}]"#,
1454                ),
1455            ]
1456        );
1457    }
1458
1459    /// A manifest-only Worker has no CDN tier to resolve a component against,
1460    /// so a component route is refused naming itself — never deployed with an
1461    /// origin nobody computed.
1462    #[test]
1463    fn an_unplaced_worker_refuses_a_component_static_route() {
1464        let err = plan_domain_worker(&net_tier_manifest(), WorkerAssets::Unplaced).unwrap_err();
1465        let msg = format!("{err:#}");
1466        assert!(msg.contains("scrabcake/site"), "{msg}");
1467        assert!(msg.contains("no resolved origin"), "{msg}");
1468    }
1469
1470    fn net_tier_manifest() -> DomainConfig {
1471        // Mirrors .yah/domains/scrabcake-net-yah-dev.toml.
1472        DomainConfig {
1473            schema_version: 1,
1474            name: "scrabcake-net-yah-dev".into(),
1475            domain: "scrabcake.net.yah.dev".into(),
1476            front_door: FrontDoor::Worker,
1477            cdn_bucket: "net-yah-dev".into(),
1478            worker_bundle_path: None,
1479            routes: vec![DomainRoute {
1480                headers: Default::default(),
1481                path: "/*".into(),
1482                mode: RouteMode::Static {
1483                    component: "scrabcake/site".into(),
1484                },
1485            }],
1486        }
1487    }
1488
1489    /// The compiled `ROUTE_TABLE` binding, parsed.
1490    fn table_of(plan: &DomainWorkerPlan) -> Vec<serde_json::Value> {
1491        let raw = plain_text(plan)
1492            .remove("ROUTE_TABLE")
1493            .expect("ROUTE_TABLE binding");
1494        serde_json::from_str(&raw).expect("ROUTE_TABLE is JSON")
1495    }
1496
1497    /// R898-T4 REWROTE THIS TEST, and what it used to pin is the reason.
1498    ///
1499    /// It asserted the literal six-binding list `static_worker_bindings`
1500    /// emitted — `UPLOAD_ORIGIN`, `SSR_ORIGIN`, `SSR_PREFIXES`,
1501    /// `ROUTE_HEADERS` included. R898-F3 deleted all four from the Worker,
1502    /// and because that binding set was a hand-kept twin of
1503    /// `worker_config_bindings` rather than a call to it, this test kept
1504    /// passing while pinning a set the bundled router no longer reads. The
1505    /// binding set is now the one producer's output, so this pins that.
1506    #[test]
1507    fn plan_resolves_worker_name_domain_and_the_one_producers_bindings() {
1508        let plan =
1509            plan_domain_worker(&net_tier_manifest(), NET_TIER).unwrap();
1510        assert_eq!(plan.worker_name, "scrabcake-net-yah-dev");
1511        assert_eq!(plan.custom_domain, "scrabcake.net.yah.dev");
1512        assert_eq!(
1513            plan.asset_origin.as_deref(),
1514            Some("https://cdn.net.yah.dev/scrabcake/prod")
1515        );
1516        assert_eq!(
1517            plan.bindings.iter().map(ConfigBinding::name).collect::<Vec<_>>(),
1518            ["ASSET_ORIGIN", "POINTER_ORIGIN", "WORKER_MODE", "ROUTE_TABLE"],
1519            "the alias tier must deploy the same binding set the mirror-driven \
1520             arm does — a twin is how it drifted last time"
1521        );
1522        let map = plain_text(&plan);
1523        assert_eq!(map["ASSET_ORIGIN"], "https://cdn.net.yah.dev/scrabcake/prod");
1524        assert_eq!(map["POINTER_ORIGIN"], "https://cdn.net.yah.dev/scrabcake/prod");
1525        assert_eq!(map["WORKER_MODE"], "static");
1526        let table = table_of(&plan);
1527        assert_eq!(table.len(), 1);
1528        assert_eq!(table[0]["path"], "/*");
1529        assert_eq!(table[0]["mode"], "static");
1530        assert_eq!(table[0]["component"], "scrabcake/site");
1531        assert_eq!(table[0]["origin"], "https://cdn.net.yah.dev/scrabcake/prod");
1532    }
1533
1534    /// R898-T4, the ticket's own acceptance: EVERY static route is planned, in
1535    /// manifest order, each resolved through its OWN component.
1536    ///
1537    /// This is also R560-F13's first verify line ("one Worker domain fronting
1538    /// several buckets" at the unit level). Before this, `plan_domain_worker`
1539    /// `find_map`-ed the first `RouteMode::Static` and dropped the rest with a
1540    /// bare `_ => None`, so the second site here was served by the first's
1541    /// catch-all — a wrong body with a 200, never a 404.
1542    #[test]
1543    fn plan_carries_every_static_route_in_manifest_order_with_its_own_origin() {
1544        let mut dom = net_tier_manifest();
1545        dom.routes = vec![
1546            DomainRoute {
1547                headers: Default::default(),
1548                path: "/docs/*".into(),
1549                mode: RouteMode::Static {
1550                    component: "handbook/site".into(),
1551                },
1552            },
1553            DomainRoute {
1554                headers: Default::default(),
1555                path: "/*".into(),
1556                mode: RouteMode::Static {
1557                    component: "scrabcake/site".into(),
1558                },
1559            },
1560        ];
1561        let plan = plan_domain_worker(&dom, NET_TIER).unwrap();
1562        let table = table_of(&plan);
1563        assert_eq!(table.len(), 2, "both static routes, not just the first");
1564        assert_eq!(table[0]["path"], "/docs/*");
1565        assert_eq!(table[0]["origin"], "https://cdn.net.yah.dev/handbook/prod");
1566        assert_eq!(table[1]["path"], "/*");
1567        assert_eq!(table[1]["origin"], "https://cdn.net.yah.dev/scrabcake/prod");
1568        // ASSET_ORIGIN is the single fall-through binding, so it stays the
1569        // first static route's origin; the per-route origins ride the table.
1570        assert_eq!(
1571            plan.asset_origin.as_deref(),
1572            Some("https://cdn.net.yah.dev/handbook/prod")
1573        );
1574    }
1575
1576    /// A `front_door = "worker"` domain's non-static routes are planned too —
1577    /// they were dropped entirely by the old `_ => None`. The backend origin is
1578    /// the manifest's DECLARED one, which R898-F3 established is the right
1579    /// answer for the Worker arm specifically.
1580    #[test]
1581    fn plan_carries_backend_and_redirect_routes_the_old_planner_dropped() {
1582        let mut dom = net_tier_manifest();
1583        dom.routes.insert(
1584            0,
1585            DomainRoute {
1586                headers: Default::default(),
1587                path: "/old/*".into(),
1588                mode: RouteMode::Redirect {
1589                    target: "https://elsewhere.example".into(),
1590                    status: 308,
1591                },
1592            },
1593        );
1594        let plan = plan_domain_worker(&dom, NET_TIER).unwrap();
1595        let table = table_of(&plan);
1596        assert_eq!(table.len(), 2);
1597        assert_eq!(table[0]["mode"], "redirect");
1598        assert_eq!(table[0]["target"], "https://elsewhere.example");
1599        assert_eq!(table[0]["status"], 308);
1600        assert_eq!(table[1]["mode"], "static");
1601    }
1602
1603    /// R746: headers declared on a route reach the deployed Worker. They rode
1604    /// the `ROUTE_HEADERS` binding until R898-F3 folded that table into
1605    /// `ROUTE_TABLE`; this asserts the same thing about the binding that
1606    /// survived. Without it the manifest could declare them and the Worker
1607    /// would serve without them — the exact silent gap the primitive exists to
1608    /// close.
1609    #[test]
1610    fn plan_carries_declared_route_headers_into_the_bindings() {
1611        let mut dom = net_tier_manifest();
1612        dom.routes[0].headers = [
1613            ("Cross-Origin-Opener-Policy".to_string(), "same-origin".to_string()),
1614            (
1615                "Cross-Origin-Embedder-Policy".to_string(),
1616                "require-corp".to_string(),
1617            ),
1618        ]
1619        .into_iter()
1620        .collect();
1621        let plan = plan_domain_worker(&dom, NET_TIER).unwrap();
1622        let table = table_of(&plan);
1623        assert_eq!(table[0]["path"], "/*");
1624        assert_eq!(
1625            table[0]["headers"]["Cross-Origin-Opener-Policy"],
1626            "same-origin"
1627        );
1628        assert_eq!(
1629            table[0]["headers"]["Cross-Origin-Embedder-Policy"],
1630            "require-corp"
1631        );
1632    }
1633
1634    /// A static route whose component ref is not `<service>/<component-id>`
1635    /// refuses the plan, naming the route — it does not quietly resolve to a
1636    /// truncated origin. The refusal moved from `plan_domain_worker`'s own
1637    /// `split_once` into `DomainConfig::route_table`, which names more.
1638    #[test]
1639    fn plan_refuses_a_malformed_component_ref() {
1640        let mut dom = net_tier_manifest();
1641        dom.routes[0].mode = RouteMode::Static {
1642            component: "no-slash".into(),
1643        };
1644        let err = plan_domain_worker(&dom, NET_TIER).unwrap_err();
1645        let msg = format!("{err:#}");
1646        assert!(msg.contains("no-slash"), "{msg}");
1647        assert!(msg.contains("no resolved origin"), "{msg}");
1648    }
1649
1650    #[test]
1651    fn plan_trims_trailing_slash_on_cdn_base() {
1652        let plan =
1653            plan_domain_worker(
1654                &net_tier_manifest(),
1655                WorkerAssets::PerComponent {
1656                    cdn_base: "https://cdn.net.yah.dev/",
1657                    env: "prod",
1658                },
1659            )
1660            .unwrap();
1661        assert_eq!(
1662            plan.asset_origin.as_deref(),
1663            Some("https://cdn.net.yah.dev/scrabcake/prod")
1664        );
1665    }
1666
1667    /// R594-F12: a manifest that declares a different front door must not
1668    /// get a Cloudflare Worker synthesized behind its back — the Worker
1669    /// would deploy and then never receive traffic.
1670    #[test]
1671    fn plan_bails_when_front_door_is_not_worker() {
1672        for door in [FrontDoor::BucketDirect, FrontDoor::Passway] {
1673            let mut dom = net_tier_manifest();
1674            dom.front_door = door;
1675            let err = plan_domain_worker(&dom, NET_TIER).unwrap_err();
1676            let msg = format!("{err:#}");
1677            assert!(msg.contains("front_door"), "{msg}");
1678            assert!(msg.contains(door.as_str()), "{msg}");
1679        }
1680    }
1681
1682    #[test]
1683    fn plan_bails_when_no_static_route() {
1684        let mut dom = net_tier_manifest();
1685        dom.routes = vec![DomainRoute {
1686            headers: Default::default(),
1687            path: "/old".into(),
1688            mode: RouteMode::Redirect {
1689                target: "https://elsewhere".into(),
1690                status: 308,
1691            },
1692        }];
1693        let err = plan_domain_worker(&dom, NET_TIER).unwrap_err();
1694        assert!(
1695            format!("{err:#}").contains("no `static` route"),
1696            "got: {err:#}"
1697        );
1698    }
1699
1700    // ── R561-F4: alias-tier registration ──
1701    use super::{plan_alias_claim, valid_subdomain_label, AliasTier};
1702    use std::collections::BTreeMap;
1703
1704    #[test]
1705    fn label_validation_rules() {
1706        assert!(valid_subdomain_label("scrabcake"));
1707        assert!(valid_subdomain_label("my-repo-1"));
1708        assert!(!valid_subdomain_label("")); // empty
1709        assert!(!valid_subdomain_label("-lead")); // leading hyphen
1710        assert!(!valid_subdomain_label("trail-")); // trailing hyphen
1711        assert!(!valid_subdomain_label("Caps")); // uppercase
1712        assert!(!valid_subdomain_label("under_score")); // underscore
1713    }
1714
1715    #[test]
1716    fn claim_builds_net_tier_manifest() {
1717        let dom = plan_alias_claim(
1718            AliasTier::Net,
1719            "scrabcake",
1720            "scrabcake/site",
1721            &BTreeMap::new(),
1722        )
1723        .unwrap();
1724        assert_eq!(dom.name, "scrabcake-net-yah-dev");
1725        assert_eq!(dom.domain, "scrabcake.net.yah.dev");
1726        assert_eq!(dom.cdn_bucket, "net-yah-dev");
1727        assert_eq!(dom.routes.len(), 1);
1728        assert!(
1729            matches!(&dom.routes[0].mode, RouteMode::Static { component } if component == "scrabcake/site")
1730        );
1731    }
1732
1733    #[test]
1734    fn claim_uses_com_tier_zone_and_bucket() {
1735        let dom = plan_alias_claim(AliasTier::Com, "acme", "acme/site", &BTreeMap::new()).unwrap();
1736        assert_eq!(dom.domain, "acme.com.yah.dev");
1737        assert_eq!(dom.cdn_bucket, "com-yah-dev");
1738    }
1739
1740    #[test]
1741    fn claim_bails_on_duplicate_host() {
1742        let existing: BTreeMap<String, DomainConfig> =
1743            [("scrabcake-net-yah-dev".to_string(), net_tier_manifest())]
1744                .into_iter()
1745                .collect();
1746        let err =
1747            plan_alias_claim(AliasTier::Net, "scrabcake", "scrabcake/site", &existing).unwrap_err();
1748        assert!(format!("{err:#}").contains("already"), "got: {err:#}");
1749    }
1750
1751    #[test]
1752    fn claim_bails_on_invalid_label() {
1753        let err =
1754            plan_alias_claim(AliasTier::Net, "Bad_Name", "x/y", &BTreeMap::new()).unwrap_err();
1755        assert!(
1756            format!("{err:#}").contains("invalid subdomain"),
1757            "got: {err:#}"
1758        );
1759    }
1760
1761    // ── R859-F1: sovereign apex (front_door = "passway") ──
1762    //
1763    // Everything here is the pure half. The blast radius of this arm is LIVE
1764    // DNS on a public apex, so the decision logic is exercised offline in full
1765    // and `deploy_domain_passway` only executes the diff these produce.
1766
1767    use super::{
1768        diff_apex_records, plan_domain_passway, plan_passway_apex, public_origins, ApexRecordDiff,
1769        DomainPasswayPlan, LiveApexRecord, PasswayOrigin, ResolvedOrigins,
1770    };
1771    use crate::config::{ConnectSpec, MachineConfig};
1772    use std::net::Ipv4Addr;
1773
1774    /// Mirrors `.yah/domains/yah-dev.toml` — the only `front_door = "passway"`
1775    /// manifest in the camp today.
1776    fn passway_manifest() -> DomainConfig {
1777        DomainConfig {
1778            schema_version: 1,
1779            name: "yah-dev".into(),
1780            domain: "yah.dev".into(),
1781            front_door: FrontDoor::Passway,
1782            cdn_bucket: "yah-dev".into(),
1783            worker_bundle_path: None,
1784            routes: vec![DomainRoute {
1785                headers: Default::default(),
1786                path: "/*".into(),
1787                mode: RouteMode::Static {
1788                    component: "yah-marketing/site".into(),
1789                },
1790            }],
1791        }
1792    }
1793
1794    fn machine(name: &str, address: Option<&str>, taints: &[&str]) -> MachineConfig {
1795        MachineConfig {
1796            name: name.into(),
1797            provider: "ovh".into(),
1798            location: None,
1799            server_type: None,
1800            hosts_mirrors: vec![],
1801            mesh_tags: vec![],
1802            region: None,
1803            zone: None,
1804            arch: None,
1805            bucket: None,
1806            vendor: None,
1807            nickname: None,
1808            legacy_hostkey_fingerprint: None,
1809            registration: Default::default(),
1810            ssh_keys: vec![],
1811            cloudflared: None,
1812            hosts_operator_bridge: false,
1813            connect: address.map(|a| ConnectSpec {
1814                address: a.into(),
1815                ssh: format!("root@{a}"),
1816                identity_file: "~/.ssh/yah".into(),
1817                yubaba_port: None,
1818                yubaba: None,
1819            }),
1820            allocatable: None,
1821            taints: taints.iter().map(|t| t.to_string()).collect(),
1822            sovereign_group: None,
1823            sovereign_role: None,
1824            ingress_floating_ip: None,
1825        }
1826    }
1827
1828    /// The live fleet shape: us-east-001 and us-west-001 both carry the
1829    /// `public-ip` taint with a public address (machines/*.toml), a third box
1830    /// fronts internal hostnames over the mesh and must not reach the apex.
1831    fn fleet() -> Vec<MachineConfig> {
1832        vec![
1833            machine("us-east-001", Some("51.81.85.145"), &["public-ip"]),
1834            machine("us-west-001", Some("15.204.89.240"), &["public-ip"]),
1835            machine("us-west-002", Some("100.64.0.4"), &[]),
1836        ]
1837    }
1838
1839    fn origins(names: &[&str]) -> ResolvedOrigins {
1840        origins_excluding(names, &[])
1841    }
1842
1843    /// R859-F2: the same resolution with `down` confirmed dead — declared and
1844    /// resolvable, but held out of the published set.
1845    fn origins_excluding(names: &[&str], down: &[&str]) -> ResolvedOrigins {
1846        let fleet = fleet();
1847        let names: Vec<String> = names.iter().map(|n| n.to_string()).collect();
1848        let down: Vec<String> = down.iter().map(|n| n.to_string()).collect();
1849        public_origins(&names, &fleet, &down).unwrap()
1850    }
1851
1852    /// A plan whose origin set is COMPLETE — the clean-collation case.
1853    fn plan_of(names: &[&str]) -> DomainPasswayPlan {
1854        plan_domain_passway(&passway_manifest(), origins(names), true).unwrap()
1855    }
1856
1857    /// The same plan, but built from a collation that reported problems, so an
1858    /// origin's absence cannot be read as a withdrawal.
1859    fn plan_of_incomplete(names: &[&str]) -> DomainPasswayPlan {
1860        plan_domain_passway(&passway_manifest(), origins(names), false).unwrap()
1861    }
1862
1863    fn live(records: &[(&str, bool)]) -> Vec<LiveApexRecord> {
1864        records
1865            .iter()
1866            .map(|(content, proxied)| LiveApexRecord {
1867                content: (*content).into(),
1868                proxied: *proxied,
1869            })
1870            .collect()
1871    }
1872
1873    #[test]
1874    fn public_origins_keeps_tainted_machines_and_skips_the_rest() {
1875        let got = origins(&["us-east-001", "us-west-001", "us-west-002"]);
1876        assert!(
1877            got.health_withdrawn.is_empty(),
1878            "no exclusions were passed, so nothing may be withheld"
1879        );
1880        assert_eq!(
1881            got.origins,
1882            vec![
1883                PasswayOrigin {
1884                    machine: "us-east-001".into(),
1885                    address: Ipv4Addr::new(51, 81, 85, 145),
1886                },
1887                PasswayOrigin {
1888                    machine: "us-west-001".into(),
1889                    address: Ipv4Addr::new(15, 204, 89, 240),
1890                },
1891            ],
1892            "an untainted mesh-only front door is not an apex origin"
1893        );
1894    }
1895
1896    /// The publicness cross-check nothing did before R859-F1: the `public-ip`
1897    /// taint and `connect.address` are independent declarations, and a machine
1898    /// dialled over its tailnet address would otherwise publish `100.64.0.x`
1899    /// at a public apex and black-hole its share of the round-robin.
1900    #[test]
1901    fn public_origins_rejects_a_non_public_address_and_names_the_machine() {
1902        for bad in ["100.64.0.4", "10.0.0.7", "192.168.1.20", "127.0.0.1"] {
1903            let fleet = vec![machine("us-west-002", Some(bad), &["public-ip"])];
1904            let err = public_origins(&["us-west-002".to_string()], &fleet, &[]).unwrap_err();
1905            let msg = format!("{err:#}");
1906            assert!(msg.contains("us-west-002"), "{msg}");
1907            assert!(msg.contains(bad), "{msg}");
1908            assert!(msg.contains("public"), "{msg}");
1909        }
1910    }
1911
1912    #[test]
1913    fn public_origins_rejects_an_unparseable_address_and_names_the_machine() {
1914        let fleet = vec![machine("us-east-001", Some("edge.example.net"), &["public-ip"])];
1915        let err = public_origins(&["us-east-001".to_string()], &fleet, &[]).unwrap_err();
1916        let msg = format!("{err:#}");
1917        assert!(msg.contains("us-east-001"), "{msg}");
1918        assert!(msg.contains("not an IPv4 address"), "{msg}");
1919    }
1920
1921    #[test]
1922    fn public_origins_rejects_a_tainted_machine_with_no_connect_block() {
1923        let fleet = vec![machine("us-east-001", None, &["public-ip"])];
1924        let err = public_origins(&["us-east-001".to_string()], &fleet, &[]).unwrap_err();
1925        let msg = format!("{err:#}");
1926        assert!(msg.contains("us-east-001"), "{msg}");
1927        assert!(msg.contains("[connect]"), "{msg}");
1928    }
1929
1930    #[test]
1931    fn public_origins_rejects_a_machine_with_no_toml() {
1932        let err = public_origins(&["ghost-001".to_string()], &fleet(), &[]).unwrap_err();
1933        let msg = format!("{err:#}");
1934        assert!(msg.contains("ghost-001"), "{msg}");
1935    }
1936
1937    #[test]
1938    fn plan_sorts_and_dedups_origins_and_resolves_the_apex_zone() {
1939        let mut o = origins(&["us-west-001", "us-east-001"]);
1940        o.origins.push(o.origins[0].clone()); // same machine collated twice
1941        let plan = plan_domain_passway(&passway_manifest(), o, true).unwrap();
1942        assert_eq!(plan.zone, "yah.dev");
1943        assert_eq!(plan.name, "yah.dev");
1944        assert_eq!(
1945            plan.origins
1946                .iter()
1947                .map(|o| o.address.to_string())
1948                .collect::<Vec<_>>(),
1949            vec!["15.204.89.240", "51.81.85.145"],
1950        );
1951    }
1952
1953    /// A camp may declare `front_door = "passway"` BEFORE the passway edge
1954    /// exists — the field is how you say which door you intend, and R859-F1
1955    /// made it the mechanism as well, so it gets written first. The noisetable
1956    /// camp is exactly this: `noisetable.com` declares `passway`, its only
1957    /// ingress edge is a `cloudflare-tunnel` on a different domain, and its
1958    /// apex is deliberately NXDOMAIN pending a node of its own (passway serves
1959    /// one cert per listener — R777).
1960    ///
1961    /// That must plan to `None`, never to an `Err`. The domain loop in `yah
1962    /// cloud apply` bails at the first domain failure without
1963    /// `--continue-on-error`, so an error here would take down the publish
1964    /// chain of every service in the camp over a door nobody has built yet.
1965    #[test]
1966    fn a_passway_domain_with_no_ingress_edge_plans_to_none_rather_than_erroring() {
1967        let dir = tempfile::tempdir().unwrap();
1968        let root = dir.path();
1969        // A workspace with a services tree but nothing fronting anything —
1970        // the shape of a camp whose door is declared but not stood up.
1971        std::fs::create_dir_all(root.join(".yah/services")).unwrap();
1972
1973        let planned = plan_passway_apex(root, &passway_manifest())
1974            .expect("an undeclared door is not an error");
1975        assert!(
1976            planned.is_none(),
1977            "expected None (nothing to render), got {planned:?}"
1978        );
1979    }
1980
1981    /// R870-B13, end to end: a camp that BORROWS another camp's fleet renders
1982    /// its sovereign apex from the owner's machine declaration.
1983    ///
1984    /// This is the ticket's defect in one test. The borrowing camp's own
1985    /// `.yah/infra/machines/` is empty — it declares one `[[source]]` link in
1986    /// `.yah/infra/sources.toml` and pins its front door by name. Before the
1987    /// fix, `plan_passway_apex` read a camp-local-only loader, so the pinned
1988    /// name resolved against an empty fleet and the render died with "has no
1989    /// .yah/infra/machines/*.toml — cannot resolve its public address" on a
1990    /// machine that was declared all along, one directory over.
1991    ///
1992    /// The camp-local control assertion is what makes this non-vacuous: the
1993    /// borrowing camp genuinely holds no copy of the inventory, so a pass here
1994    /// can only come from the link being followed.
1995    #[test]
1996    fn a_borrowing_camp_renders_its_apex_from_the_owners_machine_declaration() {
1997        let dir = tempfile::tempdir().unwrap();
1998
1999        // The owner camp — the ONE copy of the inventory.
2000        let owner = dir.path().join("owner");
2001        std::fs::create_dir_all(owner.join(".yah/infra/machines")).unwrap();
2002        std::fs::write(
2003            owner.join(".yah/infra/machines/us-east-001.toml"),
2004            "name = \"us-east-001\"\nprovider = \"ovh\"\nmesh_tags = []\n\
2005             taints = [\"public-ip\"]\n\
2006             [connect]\naddress = \"51.81.85.145\"\nssh = \"root@51.81.85.145\"\n\
2007             identity_file = \"~/.ssh/yah\"\n",
2008        )
2009        .unwrap();
2010
2011        // The borrowing camp: empty machines dir, one link, one pinned edge.
2012        let borrower = dir.path().join("borrower");
2013        std::fs::create_dir_all(borrower.join(".yah/infra/machines")).unwrap();
2014        std::fs::write(
2015            borrower.join(".yah/infra/sources.toml"),
2016            "schema_version = 1\n[[source]]\nowner = \"owner\"\nkind = \"path\"\n\
2017             path = \"../owner\"\nmode = \"read-only\"\n",
2018        )
2019        .unwrap();
2020        let svc = borrower.join(".yah/services/marketing");
2021        std::fs::create_dir_all(svc.join("mirrors")).unwrap();
2022        std::fs::write(
2023            svc.join("service.toml"),
2024            "schema_version = 1\nname = \"marketing\"\n[address]\nkind = \"front-door\"\ndomain = \"yah.dev\"\n\
2025             [[components]]\nid = \"site\"\nkind = \"static-asset\"\n\
2026             path = \"marketing/site\"\nrole = \"static\"\n",
2027        )
2028        .unwrap();
2029        std::fs::write(
2030            svc.join("mirrors/cloud.toml"),
2031            "schema_version = 1\nshape = \"single-machine\"\n\
2032             ingress = \"passway\"\ningress_machines = [\"us-east-001\"]\n\
2033             [providers.compute]\nuse = \"hetzner\"\nzone = \"yah.dev\"\n\
2034             port = 8080\nupstream_host = \"100.64.0.5\"\n",
2035        )
2036        .unwrap();
2037
2038        // Control: the camp holds no machine declaration of its own.
2039        assert!(crate::validate::load_camp_local_machine_tomls(&borrower)
2040            .unwrap()
2041            .is_empty());
2042
2043        let plan = plan_passway_apex(&borrower, &passway_manifest())
2044            .expect("a borrowed front-door machine must resolve")
2045            .expect("the passway edge collates, so this is not the no-door case");
2046        assert_eq!(
2047            plan.origins
2048                .iter()
2049                .map(|o| o.address.to_string())
2050                .collect::<Vec<_>>(),
2051            vec!["51.81.85.145".to_string()],
2052        );
2053    }
2054
2055    /// R858-B27: a domain publishes only the doors that front ITS hostname.
2056    ///
2057    /// The camp below has two passway edges on two public machines: the apex
2058    /// (`yah.dev`) on us-east-001 and a staging hostname on us-west-001. Before
2059    /// the fix, `plan_passway_apex` took every passway door in the collation, so
2060    /// the apex would have published us-west-001 too. The live instance was
2061    /// staging.noisetable.com being published at production's doors.
2062    #[test]
2063    fn a_domain_publishes_only_the_doors_fronting_its_own_hostname() {
2064        let dir = tempfile::tempdir().unwrap();
2065        let root = dir.path();
2066        std::fs::create_dir_all(root.join(".yah/infra/machines")).unwrap();
2067        for (name, addr) in [("us-east-001", "51.81.85.145"), ("us-west-001", "15.204.89.240")] {
2068            std::fs::write(
2069                root.join(format!(".yah/infra/machines/{name}.toml")),
2070                format!(
2071                    "name = \"{name}\"\nprovider = \"ovh\"\nmesh_tags = []\n\
2072                     taints = [\"public-ip\"]\n\
2073                     [connect]\naddress = \"{addr}\"\nssh = \"root@{addr}\"\n\
2074                     identity_file = \"~/.ssh/yah\"\n"
2075                ),
2076            )
2077            .unwrap();
2078        }
2079        for (svc_name, mirror, machine, zone) in [
2080            ("marketing", "cloud", "us-east-001", "yah.dev"),
2081            ("staging-site", "staging", "us-west-001", "staging.yah.dev"),
2082        ] {
2083            let svc = root.join(format!(".yah/services/{svc_name}"));
2084            std::fs::create_dir_all(svc.join("mirrors")).unwrap();
2085            std::fs::write(
2086                svc.join("service.toml"),
2087                format!(
2088                    "schema_version = 1\nname = \"{svc_name}\"\n[address]\nkind = \"front-door\"\ndomain = \"yah.dev\"\n\
2089                     [[components]]\nid = \"site\"\nkind = \"static-asset\"\n\
2090                     path = \"{svc_name}/site\"\nrole = \"static\"\n"
2091                ),
2092            )
2093            .unwrap();
2094            std::fs::write(
2095                svc.join(format!("mirrors/{mirror}.toml")),
2096                format!(
2097                    "schema_version = 1\nshape = \"single-machine\"\n\
2098                     ingress = \"passway\"\ningress_machines = [\"{machine}\"]\n\
2099                     [providers.compute]\nuse = \"hetzner\"\nzone = \"{zone}\"\n\
2100                     port = 8080\nupstream_host = \"100.64.0.5\"\n"
2101                ),
2102            )
2103            .unwrap();
2104        }
2105
2106        let plan = plan_passway_apex(root, &passway_manifest())
2107            .expect("both edges plan")
2108            .expect("the apex has a door of its own");
2109        assert_eq!(
2110            plan.origins
2111                .iter()
2112                .map(|o| o.address.to_string())
2113                .collect::<Vec<_>>(),
2114            vec!["51.81.85.145".to_string()],
2115            "the staging hostname's door must not be published at the apex"
2116        );
2117    }
2118
2119    /// R910: a passway door a cloudflare tunnel stacks in front of is not an
2120    /// apex origin. The machine is the one that filed it — `public-ip`-tainted
2121    /// but on a LAN address — so without the filter `public_origins` refuses
2122    /// 192.168.10.11 and the apply fails, instead of planning no apex at all.
2123    #[test]
2124    fn a_door_behind_a_cloudflare_tunnel_is_not_an_apex_origin() {
2125        let dir = tempfile::tempdir().unwrap();
2126        let root = dir.path();
2127        std::fs::create_dir_all(root.join(".yah/infra/machines")).unwrap();
2128        std::fs::write(
2129            root.join(".yah/infra/machines/us-west-011.toml"),
2130            "name = \"us-west-011\"\nprovider = \"ovh\"\nmesh_tags = []\n\
2131             taints = [\"public-ip\"]\n\
2132             [connect]\naddress = \"192.168.10.11\"\nssh = \"root@192.168.10.11\"\n\
2133             identity_file = \"~/.ssh/yah\"\n",
2134        )
2135        .unwrap();
2136        let svc = root.join(".yah/services/staging");
2137        std::fs::create_dir_all(svc.join("mirrors")).unwrap();
2138        std::fs::write(
2139            svc.join("service.toml"),
2140            "schema_version = 1\nname = \"staging\"\n[address]\nkind = \"front-door\"\ndomain = \"yah.dev\"\n\
2141             [[components]]\nid = \"site\"\nkind = \"static-asset\"\n\
2142             path = \"staging/site\"\nrole = \"static\"\n",
2143        )
2144        .unwrap();
2145        std::fs::write(
2146            svc.join("mirrors/staging.toml"),
2147            "schema_version = 1\nshape = \"single-machine\"\n\
2148             [[ingress]]\nprovider = \"passway\"\nmachines = [\"us-west-011\"]\n\
2149             hostnames = [\"yah.dev\"]\n\
2150             [ingress.tunnel_door]\ncontact_email = \"ops@yah.dev\"\nzone_id = \"z\"\n\
2151             token_secret = \"yah/cf-dns\"\nports = { \"yah.dev\" = 8445 }\n\
2152             [[ingress]]\nprovider = \"cloudflare-tunnel\"\nvia = \"passway\"\n\
2153             machines = [\"us-west-011\"]\nhostnames = [\"yah.dev\"]\n\
2154             [providers.compute]\nuse = \"hetzner\"\nzone = \"yah.dev\"\n\
2155             port = 8080\nupstream_host = \"100.64.0.5\"\n",
2156        )
2157        .unwrap();
2158
2159        let report = crate::validate::collate_workspace_ingress(root).unwrap();
2160        assert!(report.problems.is_empty(), "the pair plans cleanly");
2161        assert!(
2162            report
2163                .collation
2164                .front_doors
2165                .iter()
2166                .any(|d| d.provider == crate::config::IngressProvider::Passway
2167                    && d.stacked == ["yah.dev"]),
2168            "the passway door collates, marked stacked"
2169        );
2170        assert!(
2171            plan_passway_apex(root, &passway_manifest())
2172                .expect("a tunnel-fronted door must not trip the private-address guard")
2173                .is_none(),
2174            "no apex origin for a door reached through the tunnel"
2175        );
2176    }
2177
2178    /// The failure that remains a failure, with the diagnosis attached: a
2179    /// pinned machine no linked source supplies must still error — and the
2180    /// error must name the links that were consulted, since "no such machine"
2181    /// alone cannot tell an undeclared link from a broken one.
2182    #[test]
2183    fn an_unresolvable_front_door_names_the_links_that_were_consulted() {
2184        let dir = tempfile::tempdir().unwrap();
2185        let borrower = dir.path().join("borrower");
2186        std::fs::create_dir_all(borrower.join(".yah/infra/machines")).unwrap();
2187        std::fs::write(
2188            borrower.join(".yah/infra/sources.toml"),
2189            "schema_version = 1\n[[source]]\nowner = \"owner\"\nkind = \"path\"\n\
2190             path = \"../not-a-camp\"\n",
2191        )
2192        .unwrap();
2193        let svc = borrower.join(".yah/services/marketing");
2194        std::fs::create_dir_all(svc.join("mirrors")).unwrap();
2195        std::fs::write(
2196            svc.join("service.toml"),
2197            "schema_version = 1\nname = \"marketing\"\n[address]\nkind = \"front-door\"\ndomain = \"yah.dev\"\n\
2198             [[components]]\nid = \"site\"\nkind = \"static-asset\"\n\
2199             path = \"marketing/site\"\nrole = \"static\"\n",
2200        )
2201        .unwrap();
2202        std::fs::write(
2203            svc.join("mirrors/cloud.toml"),
2204            "schema_version = 1\nshape = \"single-machine\"\n\
2205             ingress = \"passway\"\ningress_machines = [\"us-east-001\"]\n\
2206             [providers.compute]\nuse = \"hetzner\"\nzone = \"yah.dev\"\n\
2207             port = 8080\nupstream_host = \"100.64.0.5\"\n",
2208        )
2209        .unwrap();
2210
2211        let err = plan_passway_apex(&borrower, &passway_manifest()).unwrap_err();
2212        let msg = format!("{err:#}");
2213        assert!(msg.contains("us-east-001"), "{msg}");
2214        assert!(msg.contains("sources.toml"), "{msg}");
2215        assert!(msg.contains("owner"), "{msg}");
2216        assert!(msg.contains("ABSENT"), "{msg}");
2217    }
2218
2219    /// The other side of that discriminator, and the reason it is drawn at the
2220    /// COLLATION rather than at the resolved-address set: a machine that IS
2221    /// declared as a front door but yields no public address is a
2222    /// misconfiguration of an intended door, and stays an error.
2223    #[test]
2224    fn a_declared_front_door_that_resolves_to_no_public_address_still_errors() {
2225        // Declared as a front door, but carries no `public-ip` taint — so the
2226        // taint filter empties the set even though the edge exists.
2227        let machines = vec![machine("us-east-001", Some("51.81.85.145"), &[])];
2228        let resolved = public_origins(&["us-east-001".to_string()], &machines, &[]).unwrap();
2229        let err = plan_domain_passway(&passway_manifest(), resolved, true).unwrap_err();
2230        let msg = format!("{err:#}");
2231        assert!(msg.contains("empty apex"), "{msg}");
2232    }
2233
2234    /// The live-DNS blast radius this arm exists to bound: an empty desired
2235    /// set is what the applier prunes against, so rendering it would withdraw
2236    /// every A record at the apex. Nothing about "no machine declared it"
2237    /// means "take the site down" — it must be an error, not a wipe.
2238    #[test]
2239    fn empty_origin_set_is_an_error_not_an_apex_wipe() {
2240        let err = plan_domain_passway(&passway_manifest(), ResolvedOrigins::default(), true).unwrap_err();
2241        let msg = format!("{err:#}");
2242        assert!(msg.contains("refusing to render an empty apex"), "{msg}");
2243        assert!(msg.contains("yah.dev"), "{msg}");
2244    }
2245
2246    #[test]
2247    fn plan_bails_when_front_door_is_not_passway() {
2248        for door in [FrontDoor::BucketDirect, FrontDoor::Worker] {
2249            let mut dom = passway_manifest();
2250            dom.front_door = door;
2251            let err = plan_domain_passway(&dom, origins(&["us-east-001"]), true).unwrap_err();
2252            let msg = format!("{err:#}");
2253            assert!(msg.contains("front_door"), "{msg}");
2254            assert!(msg.contains(door.as_str()), "{msg}");
2255        }
2256    }
2257
2258    #[test]
2259    fn converged_apex_is_a_no_op() {
2260        let plan = plan_of(&["us-east-001", "us-west-001"]);
2261        let diff = diff_apex_records(
2262            &plan,
2263            &live(&[("51.81.85.145", false), ("15.204.89.240", false)]),
2264        );
2265        assert_eq!(diff, ApexRecordDiff::default());
2266        assert!(diff.is_converged());
2267    }
2268
2269    #[test]
2270    fn adding_a_machine_yields_exactly_one_added_record_and_no_prune() {
2271        let plan = plan_of(&["us-east-001", "us-west-001"]);
2272        let diff = diff_apex_records(&plan, &live(&[("51.81.85.145", false)]));
2273        assert_eq!(diff.upsert, vec!["15.204.89.240".to_string()]);
2274        assert!(diff.prune.is_empty(), "{diff:?}");
2275    }
2276
2277    #[test]
2278    fn removing_a_machine_yields_exactly_one_prune_and_leaves_the_survivor() {
2279        let plan = plan_of(&["us-east-001"]);
2280        let diff = diff_apex_records(
2281            &plan,
2282            &live(&[("51.81.85.145", false), ("15.204.89.240", false)]),
2283        );
2284        assert_eq!(diff.prune, vec!["15.204.89.240".to_string()]);
2285        assert!(
2286            diff.upsert.is_empty(),
2287            "the survivor is already correct: {diff:?}"
2288        );
2289    }
2290
2291    /// `scripts/cf-apex-mode.sh orange` is break-glass; the manifest declares
2292    /// DNS-only. A proxied record carrying a desired address is therefore
2293    /// drift, and re-upserting it (`proxied = false`) is the convergence.
2294    #[test]
2295    fn a_proxied_record_at_a_desired_address_is_rewritten_not_left_alone() {
2296        let plan = plan_of(&["us-east-001"]);
2297        let diff = diff_apex_records(&plan, &live(&[("51.81.85.145", true)]));
2298        assert_eq!(diff.upsert, vec!["51.81.85.145".to_string()]);
2299        assert!(
2300            diff.prune.is_empty(),
2301            "the address is declared — it must not be pruned: {diff:?}"
2302        );
2303    }
2304
2305    /// **Fail-closed on withdrawal.** `collate_workspace_ingress` returns `Ok`
2306    /// while SKIPPING an edge whose declaration fails to plan, so a passway
2307    /// edge with a config typo silently drops its machine from `front_doors` —
2308    /// which, from inside the plan, is indistinguishable from the operator
2309    /// withdrawing that node. Without this gate a typo would render as a DNS
2310    /// withdrawal and take that origin offline. Upserts must still run: an
2311    /// unrelated service's broken declaration cannot be allowed to block
2312    /// growing the fleet, and an upsert can never make the apex worse.
2313    #[test]
2314    fn an_incomplete_collation_upserts_but_withholds_every_prune() {
2315        // us-west-001 is missing from the front-door set, and the collation
2316        // reported problems — so its absence is not trustworthy evidence of a
2317        // withdrawal. us-east-001 is newly declared and must still be written.
2318        let plan = plan_of_incomplete(&["us-east-001"]);
2319        let diff = diff_apex_records(&plan, &live(&[("15.204.89.240", false)]));
2320
2321        assert_eq!(
2322            diff.upsert,
2323            vec!["51.81.85.145".to_string()],
2324            "additions must still land through a dirty collation"
2325        );
2326        assert!(
2327            diff.prune.is_empty(),
2328            "a live record must never be withdrawn on an incomplete collation: {diff:?}"
2329        );
2330        assert_eq!(
2331            diff.withheld_prune,
2332            vec!["15.204.89.240".to_string()],
2333            "the withheld record is reported so the applier can name it"
2334        );
2335
2336        // Same inputs, clean collation: the prune is real. This is the
2337        // control that proves the gate is what changed the outcome.
2338        let trusted = plan_of(&["us-east-001"]);
2339        let diff = diff_apex_records(&trusted, &live(&[("15.204.89.240", false)]));
2340        assert_eq!(diff.prune, vec!["15.204.89.240".to_string()]);
2341        assert!(diff.withheld_prune.is_empty(), "{diff:?}");
2342    }
2343
2344    /// The empty-set guard stays AHEAD of the incompleteness gate: an empty
2345    /// origin set is unusable whether or not the collation was clean, so it
2346    /// remains the louder failure rather than degrading into a silent
2347    /// everything-withheld apply.
2348    #[test]
2349    fn empty_origin_set_still_errors_on_an_incomplete_collation() {
2350        let err = plan_domain_passway(&passway_manifest(), ResolvedOrigins::default(), false).unwrap_err();
2351        assert!(
2352            format!("{err:#}").contains("refusing to render an empty apex"),
2353            "got: {err:#}"
2354        );
2355    }
2356
2357    // ── R859-F2: health-excluded origins ──────────────────────────────────
2358
2359    /// The exclusion happens after full resolution, and reports what it held
2360    /// back rather than dropping it silently — `health_withdrawn` is what makes
2361    /// "declared but dead" distinguishable from "never declared" downstream.
2362    #[test]
2363    fn a_confirmed_down_machine_is_resolved_then_withheld_not_dropped() {
2364        let got = origins_excluding(&["us-east-001", "us-west-001"], &["us-east-001"]);
2365        assert_eq!(
2366            got.origins,
2367            vec![PasswayOrigin {
2368                machine: "us-west-001".into(),
2369                address: Ipv4Addr::new(15, 204, 89, 240),
2370            }]
2371        );
2372        assert_eq!(
2373            got.health_withdrawn,
2374            vec![PasswayOrigin {
2375                machine: "us-east-001".into(),
2376                address: Ipv4Addr::new(51, 81, 85, 145),
2377            }],
2378            "a withheld origin must still be reported, with the address it would have published"
2379        );
2380    }
2381
2382    /// An exclusion naming a machine that fronts nothing is not an error. The
2383    /// caller's liveness view covers the whole fleet and most of it never
2384    /// fronts anything, so requiring the sets to line up would make every
2385    /// unrelated node failure an apex-render failure.
2386    #[test]
2387    fn excluding_a_machine_that_fronts_nothing_is_a_no_op() {
2388        let got = origins_excluding(&["us-east-001"], &["us-west-001", "ghost-001"]);
2389        assert_eq!(got.origins.len(), 1);
2390        assert!(got.health_withdrawn.is_empty());
2391    }
2392
2393    /// The catastrophic case, and the reason the empty-set guard is checked
2394    /// against the *survivors*: "every front door is down" must not render as
2395    /// "withdraw every A record". A dead origin still in DNS is a partial
2396    /// outage; an empty apex is a total one.
2397    #[test]
2398    fn excluding_every_origin_is_an_error_not_an_apex_wipe() {
2399        let resolved = origins_excluding(
2400            &["us-east-001", "us-west-001"],
2401            &["us-east-001", "us-west-001"],
2402        );
2403        assert_eq!(resolved.health_withdrawn.len(), 2);
2404        let err = plan_domain_passway(&passway_manifest(), resolved, true).unwrap_err();
2405        assert!(
2406            format!("{err:#}").contains("refusing to render an empty apex"),
2407            "got: {err:#}"
2408        );
2409    }
2410
2411    /// Two machines can share one address (a floating IP mid-move, or two edges
2412    /// collated onto one box). Losing one of them must not withdraw a record
2413    /// the other still answers on.
2414    #[test]
2415    fn an_address_a_live_origin_still_serves_is_not_withdrawn() {
2416        let fleet = vec![
2417            machine("us-east-001", Some("51.81.85.145"), &["public-ip"]),
2418            machine("us-east-002", Some("51.81.85.145"), &["public-ip"]),
2419        ];
2420        let resolved = public_origins(
2421            &["us-east-001".to_string(), "us-east-002".to_string()],
2422            &fleet,
2423            &["us-east-001".to_string()],
2424        )
2425        .unwrap();
2426        let plan = plan_domain_passway(&passway_manifest(), resolved, true).unwrap();
2427        assert_eq!(
2428            plan.origins
2429                .iter()
2430                .map(|o| o.address.to_string())
2431                .collect::<Vec<_>>(),
2432            vec!["51.81.85.145"]
2433        );
2434        assert!(
2435            plan.health_withdrawn.is_empty(),
2436            "us-east-002 still answers on that address, so it must not be pruned"
2437        );
2438    }
2439
2440    /// **The cross-product.** `origins_complete` and `health_withdrawn` are two
2441    /// different facts, and the bottom-right cell is the whole point of R859-F2:
2442    /// an unrelated service's broken declaration must not veto a withdrawal
2443    /// resting on a box we watched go down.
2444    #[test]
2445    fn health_withdrawal_and_declaration_completeness_are_independent() {
2446        let live = live(&[("51.81.85.145", false), ("15.204.89.240", false)]);
2447
2448        // complete + healthy: an ordinary withdrawal from a trusted declaration.
2449        let complete_healthy = plan_domain_passway(
2450            &passway_manifest(),
2451            origins(&["us-west-001"]),
2452            true,
2453        )
2454        .unwrap();
2455        let d = diff_apex_records(&complete_healthy, &live);
2456        assert_eq!(d.prune, vec!["51.81.85.145"]);
2457        assert!(d.withheld_prune.is_empty());
2458
2459        // complete + down: the same prune, now on health grounds.
2460        let complete_down = plan_domain_passway(
2461            &passway_manifest(),
2462            origins_excluding(&["us-east-001", "us-west-001"], &["us-east-001"]),
2463            true,
2464        )
2465        .unwrap();
2466        let d = diff_apex_records(&complete_down, &live);
2467        assert_eq!(d.prune, vec!["51.81.85.145"]);
2468        assert!(d.withheld_prune.is_empty());
2469
2470        // incomplete + healthy: might be a withdrawal, might be a typo — withheld.
2471        let incomplete_healthy = plan_domain_passway(
2472            &passway_manifest(),
2473            origins(&["us-west-001"]),
2474            false,
2475        )
2476        .unwrap();
2477        let d = diff_apex_records(&incomplete_healthy, &live);
2478        assert!(
2479            d.prune.is_empty(),
2480            "an absence under an incomplete collation must never prune"
2481        );
2482        assert_eq!(d.withheld_prune, vec!["51.81.85.145"]);
2483
2484        // incomplete + down: PRUNED ANYWAY. We saw this machine declared and we
2485        // saw it die; the incompleteness is about a different declaration.
2486        let incomplete_down = plan_domain_passway(
2487            &passway_manifest(),
2488            origins_excluding(&["us-east-001", "us-west-001"], &["us-east-001"]),
2489            false,
2490        )
2491        .unwrap();
2492        let d = diff_apex_records(&incomplete_down, &live);
2493        assert_eq!(
2494            d.prune,
2495            vec!["51.81.85.145"],
2496            "a health withdrawal rests on a positive observation, not on an absence, so \
2497             origins_complete = false must not suppress it"
2498        );
2499        assert!(d.withheld_prune.is_empty());
2500    }
2501
2502    /// The two reasons for a prune coexist in one diff without merging: under an
2503    /// incomplete collation, the health-excluded address is pruned and the
2504    /// merely-absent one is still withheld.
2505    #[test]
2506    fn an_incomplete_collation_prunes_only_the_health_withdrawn_surplus() {
2507        let plan = plan_domain_passway(
2508            &passway_manifest(),
2509            origins_excluding(&["us-east-001", "us-west-001"], &["us-east-001"]),
2510            false,
2511        )
2512        .unwrap();
2513        let d = diff_apex_records(
2514            &plan,
2515            &live(&[
2516                ("51.81.85.145", false), // declared + confirmed down -> prune
2517                ("15.204.89.240", false), // the surviving origin -> kept
2518                ("203.0.113.9", false),  // never declared at all -> withheld
2519            ]),
2520        );
2521        assert_eq!(d.prune, vec!["51.81.85.145"]);
2522        assert_eq!(d.withheld_prune, vec!["203.0.113.9"]);
2523        assert!(d.upsert.is_empty());
2524    }
2525
2526    /// A withheld prune is not convergence — it is a write the applier
2527    /// declined — but it is also not a write, so `is_converged` stays false
2528    /// only when there is something to actually do.
2529    #[test]
2530    fn a_withheld_prune_alone_leaves_nothing_to_write() {
2531        let plan = plan_of_incomplete(&["us-east-001"]);
2532        let diff = diff_apex_records(
2533            &plan,
2534            &live(&[("51.81.85.145", false), ("15.204.89.240", false)]),
2535        );
2536        assert!(diff.upsert.is_empty(), "{diff:?}");
2537        assert!(diff.prune.is_empty(), "{diff:?}");
2538        assert!(diff.is_converged(), "no write to make: {diff:?}");
2539        assert_eq!(diff.withheld_prune, vec!["15.204.89.240".to_string()]);
2540    }
2541
2542    /// Ordering contract (`cf-apex-mode.sh:245-246,287-288`): the applier
2543    /// writes `upsert` before `prune`, so a full origin swap never leaves the
2544    /// apex without a routing record.
2545    #[test]
2546    fn a_full_origin_swap_writes_the_new_record_before_pruning_the_old() {
2547        let plan = plan_of(&["us-west-001"]);
2548        let diff = diff_apex_records(&plan, &live(&[("51.81.85.145", false)]));
2549        assert_eq!(diff.upsert, vec!["15.204.89.240".to_string()]);
2550        assert_eq!(diff.prune, vec!["51.81.85.145".to_string()]);
2551        assert!(!diff.is_converged());
2552    }
2553}