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