yah-cloud 0.8.46

Declarative cloud substrate for yah-managed camps: .yah/cloud/ config schema, MachineProvider drivers (Hetzner + local containerd), cloud-init rendering, and the pond/mesofact reconcilers.
//! Cloud's floating/reserved-IP surface: the vendor adapters, the credentialed
//! constructor, and a re-export of the shared seam.
//!
//! **The seam itself moved.** [`FloatingIpProvider`], [`reconcile_assignment`],
//! [`plan_ingress_owner_effect`] and everything around them now live in the
//! `yah-floating-ip` crate (`oss/yubaba/crates/floating-ip`), which both this
//! crate and `yubaba` depend on. R859-F2 landed the planner here with no
//! production caller because its caller belongs in yubaba's scheduler tick and
//! a runtime `yubaba -> cloud` edge would have pulled velveteen,
//! velveteen-exec, yah-hetzner, yah-mesofact-bundle and yah-almanac into the
//! release daemon on every fleet node — reversing the carve-out R374-F3 made
//! for `local-driver` (see this crate's `yah-local-driver` dep comment).
//! Operator decision, 2026-09-08: repeat R374-F3's move instead. Hence the
//! extraction, and hence this module being a thin lid on it.
//!
//! Everything the old module exported is still reachable at the same path —
//! `cloud::provider::floating_ip::*` — via the `pub use` below, so no call site
//! moved. What is *defined* here is only what genuinely cannot leave:
//!
//! - [`floating_ip_provider_for`], because it resolves vault slots through
//!   `fob` and constructs the three vendor adapters. Credentials are the
//!   consumer's concern, not the seam's.
//! - [`machine_facts`], the boundary conversion from this crate's
//!   [`MachineConfig`] to the shared crate's `FloatingIpMachine`. Five fields
//!   are the whole contract; keeping it that narrow is what lets `yubaba`,
//!   which has no `.yah/infra/machines/` tree at all, use the same planner.
//!
//! **The vendor adapters left too, on 2026-09-08 (R859-F3).** This module's
//! previous revision said they could not, because each was two things at once —
//! a `FloatingIpProvider` *and* an `EnvoyAdapter` — and their typed envoy
//! handlers were inherent impls, which Rust forbids on a foreign type. Only the
//! second half of that was true: `impl EnvoyAdapter for HetznerFloatingIp` is a
//! *local* trait on a foreign type, which the orphan rule permits, and the
//! inherent handlers were byte-identical across all three, so one blanket
//! `FloatingIpEnvoy` impl replaced them. The transports are now
//! `yah-floating-ip-adapters` and the envoy half is [`super::floating_ip_envoy`]
//! — which is what makes `yubaba`'s `Reassign` arm reachable at all.
//!
//! This mirrors, at the sovereign-ingress tier, the "external identity
//! follows placement" property [R591](yah://arch/symbol/R591) names for
//! Headscale via a Cloudflare Tunnel.
//!
//! @yah:ticket(R859-F2, "Wire floating-ip.* provider adapters to ingress_owner transitions + health-checked DNS withdrawal for dead origins")
//! @yah:status(review)
//! @yah:phase(P2)
//! @yah:at(2026-09-08T21:24:15Z)
//! @yah:assignee(agent:bundle-anthropic-ashguard)
//! @yah:parent(R859)
//! @yah:handoff("LANDED, uncommitted. Six pieces. (1) THE BLOCKER, fixed as decided: MemberInfo and YubabaRequest::SetMember each gained machine: Option<String> with #[serde(default)] (oss/yubaba/crates/yubaba/src/raft/mod.rs), written by member_registration from leader::derive_machine_name() — the SAME function that writes ingress_owner, so the two strings are comparable by construction. Accessors YubabaStateMachine::machine_for_node / node_for_machine at raft/store.rs:769. (2) quorum_health.rs (new, oss/yubaba/crates/yubaba/src/): pure judge_quorum(voters, LivenessReport) -> QuorumVerdict{Healthy{voters,available,margin} | Degraded{reason} | Unknown{reason}} + permits_withdrawal(); thin caller wired into scheduler.rs's tick loop where both inputs are already in hand. (3) cloud::provider::floating_ip gained the registry R594-F5 left out: floating_ip_provider_for(&MachineConfig), provider_has_floating_ip_adapter(&str), and one FLOATING_IP_PROVIDERS table both read so they cannot drift. (4) All three adapters registered in envoy.rs default_adapters() — floating_ip.assign/status are now genuinely dispatchable. (5) MachineConfig.ingress_floating_ip: Option<String> (config.rs, beside `cloudflared`) + cloud::validate::check_ingress_floating_ip, wired into `yah cloud validate` (error) and the apply preflight (warning), same split as R605-F12. (6) DNS withdrawal through F1's EXISTING seam: public_origins gained a health_excluded arg and returns ResolvedOrigins{origins, health_withdrawn}; DomainPasswayPlan gained health_withdrawn; diff_apex_records prunes a health-withdrawn address regardless of origins_complete.")
//! @yah:handoff("THE DEPENDENCY FORK, resolved with evidence, and the answer is NOT the one the brief's criterion predicts. Read both manifests: oss/yubaba/crates/cloud/Cargo.toml has NO yubaba dep, and oss/yubaba/crates/yubaba/Cargo.toml already carries `cloud = { package = \"yah-cloud\", path = \"../cloud\" }` — but under [dev-dependencies]. So a runtime yubaba -> cloud edge would create NO cargo cycle. I did not take it anyway, and the reason is a documented architectural rule the brief's cycle-check could not see: cloud/Cargo.toml's `local-driver` dep comment records that local-driver was carved out of cloud in R374-F3 SPECIFICALLY \"so yubaba could own MinIO lifecycle without a reverse yubaba->cloud dep\". Adding that edge would put velveteen, velveteen-exec, yah-hetzner, yah-mesofact-bundle and yah-almanac into the release daemon shipped to every fleet node — an architecture call outside a courier's blast radius. So I took the SECOND branch: plan_ingress_owner_effect() is pure, fully tested, and UNWIRED. Everything else in the ticket ships.")
//! @yah:handoff("WHERE THE PLANNER LIVES, and why there. plan_ingress_owner_effect is in cloud (provider/floating_ip.rs), not yubaba, because its inputs include MachineConfig and its outputs command cloud adapters. The two yubaba-side facts cross the boundary AS PLAIN DATA, never as types: OwnerLiveness{ConfirmedUp,ConfirmedDown,Unconfirmed} re-spells TransitionTracker::committed, and QuorumHealth{Healthy,Degraded{reason}} re-spells QuorumVerdict (its Unknown collapses into Degraded — both refuse, and the distinction survives in the reason string). That honours decision 7: raft stays read-only from the cloud side, and no dep edge is created in either direction. Signature: plan_ingress_owner_effect(previous_owner, current_owner, current_owner_liveness, &quorum, machines) -> IngressOwnerEffect{Reassign{machine,ip_id} | Withdraw{machine,reason} | Refuse{reason} | NoOp{reason}}. NoOp carries a reason (the brief wrote it bare) because a log line saying which of the five no-op paths was taken is worth six characters. The two action variants ARE W267's two failover speeds: Reassign is intra-provider, Withdraw feeds public_origins' health_excluded set for the cross-provider path — which is what gives the enum's fourth variant real work rather than a placeholder.")
//! @yah:gotcha("READ THIS BEFORE ATTACHING THE EFFECTOR — the identity bridge is narrower than its name. `ingress_owner` and the new `MemberInfo.machine` both carry `/etc/hostname` (leader::derive_machine_name), which is NOT reliably the .yah/infra/machines/<name>.toml name. Evidence, not inference: app/yah/cli/src/mesh.rs:108's R858-T3 gotcha states it outright, and R841's incident record (app/yah/cli/src/rollout/executor.rs) has ingress_owner holding `vps-4c1efa56` for the box declared `us-west-001`. The brief's decided fix assumed these were machine names; they are not, and I corrected the field's doc comment rather than shipping a plausible-looking lie. What the bridge DOES guarantee is exact: node_id <-> ingress_owner, because both strings come from one derivation. Resolving that string to a MachineConfig is a SEPARATE, fail-loud step — cloud::provider::floating_ip::resolve_ingress_owner matches declared names EXACTLY (no prefix match, no fuzzy fallback, no \"it is probably the only public-ip box\") and refuses by name, listing every declared machine and explaining the hostname mismatch. Pinned by an_ingress_owner_that_names_no_declared_machine_refuses_loudly. So on today's fleet a us-west-001 flip would REFUSE rather than misfire. Closing it properly means renaming hostnames to match machine names, or adding a declared hostname alias to MachineConfig — separable work, not R859-F2's.")
//! @yah:handoff("TWO DESIGN CALLS I MADE THAT ARE NOT IN THE BRIEF, both forced by a test that failed. (a) `Degraded` means A VOTER IS DOWN, not \"this topology has no redundancy\". My first judge_quorum keyed purely on margin and my own 1-voter test failed it: a rig has zero margin at its healthiest, so a margin-only rule calls its best possible state degraded and refuses every withdrawal forever — an inert feature wearing the costume of a safety check. Rule is now `Degraded` iff available < majority, OR available == majority AND available < voters. A fully-available cluster is Healthy at any size with margin stating the slack honestly. Pinned by an_intact_two_voter_cluster_is_healthy_but_a_three_voter_one_reduced_to_two_is_not — identical `available`, opposite verdicts, because one lost a voter and the other did not. (b) The empty-apex guard in plan_domain_passway is checked against the origins that SURVIVE health exclusion, which makes it the health failover's backstop for free: if every declared front door is confirmed down, plan_domain_passway refuses. \"All our front doors are down\" must never render as \"withdraw every A record\" — a dead origin still in DNS is a partial outage, an empty apex is a total one. The health withdrawal is therefore capped at all-but-the-last origin by construction, with no second rule to keep in sync. Same reasoning one level finer: an address a SURVIVING origin still answers on is dropped from health_withdrawn (two machines can share a floating IP).")
//! @yah:handoff("THE origins_complete x health CROSS-PRODUCT, decided and tested as four cells (health_withdrawal_and_declaration_completeness_are_independent). complete+healthy -> prune. complete+down -> prune. incomplete+healthy -> withheld_prune. incomplete+down -> PRUNE ANYWAY. The bottom-right cell is the whole point and it is a judgement, so here is the reasoning: origins_complete=false protects against mistaking an ABSENCE for a withdrawal, and a health withdrawal is not an absence — it is a positive observation about a machine the collation resolved, taint-checked and address-checked on the way into health_withdrawn. An unrelated service's broken TOML is not evidence about a box we watched go down; letting it veto the prune would leave a dead origin taking its share of the round-robin for as long as that typo lives. Fail-closed-on-withdrawal is NOT weakened: the gate on a health withdrawal is the QUORUM verdict, applied one layer up in plan_ingress_owner_effect, which refuses to emit the exclusion at all out of a degraded quorum. Two withdrawal paths, each fail-closed on the evidence actually relevant to it. Also tested: an_incomplete_collation_prunes_only_the_health_withdrawn_surplus (both reasons coexist in one diff — health-excluded pruned, merely-absent still withheld). Decisions 1, 8 and 9 held as written: TransitionTracker/HysteresisPolicy reused with no new debounce type; no TTL parameter anywhere and a doc comment saying why so the next reader does not re-open it; cloud.mesh_failover untouched and the planner cannot transfer leadership.")
//! @yah:verify("Baselines measured BEFORE any edit, on this tree. cloud (`cargo test --manifest-path oss/yubaba/crates/cloud/Cargo.toml --features json-schema`): 1124 passed / 0 failed / 4 ignored (lib) + 3/0/1 + 2/0/0 + 0/0/1. yubaba (`--manifest-path oss/yubaba/crates/yubaba/Cargo.toml --lib`): 708 passed / 0 failed. `cargo build --workspace`: GREEN before I started — it was not already red, so nothing here is inherited. AFTER: cloud 1152 / 0 / 4 (+28, same other three targets); yubaba lib 724 / 0 / 0 (+16); `cargo build --workspace` green; `cargo check --manifest-path oss/yubaba/Cargo.toml --all-targets` clean (covers the two integration-test files I touched). Epoch gate: `RUSTC_WRAPPER='' cargo run -p xtask -- cluster-epochs` GREEN — both axes were red from my raft/mod.rs + raft/store.rs edits, verdict NOT BREAKING on both, hashes re-recorded, cluster_protocol stays 5 and state_epoch stays 4, with a full why_not_a_bump entry in cluster-epochs.json surface_rerecords[2026-09-05]. Both drifted surfaces were verified to contain ONLY my hunks (git diff -U0: store.rs is one 47-line insertion) before writing, so no peer's unanalysed change was swept into a verdict. `scripts/check-workload-spec-ts.sh`: ok.")
//! @yah:gotcha("scripts/check-schema-drift.sh is RED, and it is NOT this ticket's drift. The gate regenerates and then `git diff --quiet -- .yah/schema`, so it fails for ANY uncommitted regeneration, in sync or not — exactly the condition R860-T1 already recorded (\"both gates go red for that reason; a pathspec commit of the generated paths was attempted and DENIED by the approval gate\"). It was red before I started (.yah/schema/{machine,workload}.toml.schema.json were both already dirty in the tree at session start). I ran `cargo run -p xtask -- emit-schemas` as required — MachineConfig gained a field — and machine.toml.schema.json:77 now carries `ingress_floating_ip`. Note the regen ALSO shrank workload.toml.schema.json's WorkloadSpec description, because R860-T1's @yah: annotations have since left workload-spec/src/lib.rs; that is a correct regeneration of a generated artifact, not damage, and any peer running the same command gets the same output. The gate goes green when those two paths are committed. I did not commit (instructed not to).")
//! @yah:gotcha("OVH's floating-IP adapter is now REGISTERED but is NOT live-ready, and registering it was still right. ovh_floating_ip.rs's own module doc records that its auth is a placeholder — OVH signs with an application key + secret + consumer key + timestamped HMAC, not the bare `X-Ovh-Consumer` header the adapter sends. Registering it in default_adapters() makes the verb dispatchable (the latent bug decision 4 names); it does not make it correct against api.ovh.com. All three registrations are gated on their credential being present via fob::get_or_env, so the adapter is absent from every camp that has not deliberately set `ovh-consumer-key`/$OVH_CONSUMER_KEY. Swap in real OVH request signing before pointing it at anything live. Checked rather than assumed: HetznerEnvoy (cloud.vps.*) and HetznerFloatingIp (floating_ip.*) share the adapter id \"hetzner\" but claim DISJOINT verb sets, and agent-tools/src/envoy_tools.rs:214 groups by VERB id rather than adapter id — so neither shadows the other, and the per-verb `provider` enum still gets three distinct choices.")
//! @yah:handoff("FILES (all uncommitted; tree anchor f086233d, the commit this session started from — quote that SHA, not HEAD, in any restore instruction). yubaba: raft/mod.rs (field on MemberInfo + SetMember, apply arm, 3 new tests), raft/store.rs:769 (machine_for_node/node_for_machine), quorum_health.rs (NEW, 12 tests), lib.rs (module decl), scheduler.rs (judge_quorum caller + debug! import), member_registration.rs (machine param through spawn/run/plan_registration/write_row, 3 new tests, 12 existing call sites updated), leader.rs (derive_machine_name now pub, doc), main.rs (passes it), leader_pin.rs + headroom.rs (MemberInfo literals), tests/raft_tenant_placement.rs, yubaba-test-harness/src/solo_node.rs (per-node stand-in name — /etc/hostname would make every in-process node identical and node_for_machine ambiguous), cluster-epochs.json. cloud: provider/floating_ip.rs (registry + planner + resolver + 14 tests), provider/mod.rs (re-exports), envoy.rs (default_adapters), config.rs (ingress_floating_ip + 4 test literals), validate.rs (check_ingress_floating_ip + 8 tests), reconciler/domain.rs (ResolvedOrigins, health_withdrawn, diff rule, 5 new tests), plus mechanical `ingress_floating_ip: None,` in 10 more files' MachineConfig literals. app/yah/cli/src/cloud.rs: lint wired at both sites + tally. .yah/schema/{machine,workload}.toml.schema.json regenerated. COLLISION CHECK: envoy.rs has 5 hunks and only ONE is mine (default_adapters); the other four are R859-F1's known_verb_descriptors work — expected, not a collision. I did not touch the foreign hunks the brief named (proc_control.rs, topology.rs, cloud.rs's header) and no unexpected diffs appeared in any file I own.")
//! @yah:handoff("LEADER SIGN-OFF, independently verified by a separate session that re-ran every gate and traced each claim to file:line — not taken on the implementer's word. Commands: cloud tests EXIT=0 at 1152 passed / 0 failed / 4 ignored (baseline 1124/0/4 after R859-F1, +28); yubaba --lib EXIT=0 at 724/0 (baseline 708/0, +16); cargo build --workspace EXIT=0; cargo check oss/yubaba --all-targets EXIT=0. Confirmed in code: MemberInfo.machine and SetMember.machine are Option<String> with #[serde(default)] (raft/mod.rs:1159, :352) — the rollout-safety property, pinned by a_pre_r859_f2_snapshot_loads_untagged_and_a_downgrade_reads_a_tagged_one (:1612) and a_pre_r859_f2_set_member_still_applies_with_no_machine (:1651), so a live cluster's existing JSON snapshot still deserializes. judge_quorum (quorum_health.rs:154) is pure, Degraded requires a voter actually down, a 1-voter rig reads Healthy, and scheduler.rs:445 feeds it real voter_ids() plus the raft LivenessReport rather than fabricated data. Withdrawal and reassign are refused on Degraded (floating_ip.rs:509, :529) while upserts and tenant placement stay ungated — the same fail-closed-on-withdrawal / fail-open-on-addition rule R859-F1 established for its prune gate, now applied consistently across both children. The four-cell cross-product is tested in health_withdrawal_and_declaration_completeness_are_independent (domain.rs:1673), and diff_apex_records (:833) remains the SOLE prune path, so withdrawal went through F1's existing public_origins -> plan_domain_passway seam with no parallel route to DNS mutation. Registry, the three default_adapters() registrations, unknown-provider bail (floating_ip.rs:238), ingress_floating_ip lint wiring (cloud.rs:9641/:10735) with absent-config as a clean skip (validate.rs:770), and TransitionTracker/HysteresisPolicy reuse with no second debounce all verify.")
//! @yah:handoff("Tree anchor at handoff: f086233d6b092de2f32cafad5e0010494078269c — the shared tree as I left it. Diff against it (`git diff f086233d6b092de2f32cafad5e0010494078269c..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
//! @yah:gotcha("check-schema-drift.sh exits 1, and it is NOT this relay's drift. Verified by hashing .yah/schema/*.json before and after: the script's own regeneration produces byte-identical files, so the gate is red purely on its `git diff --quiet` uncommitted-artifact condition — R860-T1's recorded state, independently corroborated by an unrelated session's gotcha at topology.rs:67. Nothing was changed. Note the uncommitted schema diff is MIXED: machine.toml.schema.json:77 carries this ticket's own regenerated ingress_floating_ip entry alongside R860's description churn, so whoever commits must not assume the whole diff is theirs.")
//! @yah:handoff("DELIVERED BUT UNWIRED, and this is the relay's one genuine operator call. plan_ingress_owner_effect() landed pure, fully tested, and with no production caller: inputs are (previous ingress_owner, current ingress_owner, quorum verdict, hysteresis verdict, machine configs), output is an action enum. scheduler.rs's call site is prepared and already computes the quorum verdict the effector would need, so attaching it is a small change — but it is not a courier's call to make. There is NO cargo cycle today (cloud has no yubaba dep; yubaba depends on cloud only under [dev-dependencies], Cargo.toml:152). What blocks it is a deliberate architectural decision, not a technical impossibility: cloud/Cargo.toml:75-78's `local-driver` comment records R374-F3 carving that crate out SPECIFICALLY to avoid a reverse yubaba->cloud dependency, and taking that edge would pull velveteen/hetzner/mesofact/almanac into the fleet daemon. Reversing a documented carve-out is an operator decision, so everything else in the ticket shipped and this one seam waits on an answer.")
//! @yah:gotcha("PREMISE CORRECTION, found by the implementer against a claim the Leader's dispatch had asserted — the dispatch said to populate the new machine tag from derive_machine_name(), assuming it yields the .yah/infra/machines/<name>.toml name. It does not: it reads /etc/hostname (leader.rs:876-884), exactly as app/yah/cli/src/mesh.rs:108 already states, and R841's incident record has ingress_owner holding `vps-4c1efa56` for the box declared `us-west-001`. The bridge is still exact where it matters, because node_id and ingress_owner come from ONE derivation — but turning that string into a MachineConfig is now a separate fail-loud step, resolve_ingress_owner (floating_ip.rs:383), which refuses by exact name rather than guessing (test at :887). On today's fleet an ingress_owner flip would therefore REFUSE rather than misfire — correct, but it means the floating-IP path is inert until hostnames and machine-TOML names are reconciled. That reconciliation is not in this relay.")
//! @yah:handoff("OPERATOR DECISION 1 (2026-09-08, ask_user F4086), answering the ticket's one genuine architecture call: EXTRACT A SHARED CRATE — do not take the runtime yubaba -> cloud edge, and do not leave the effector operator-driven. LANDED: new `oss/yubaba/crates/floating-ip` (package `yah-floating-ip`, extern name `floating_ip`), registered in oss/yubaba/Cargo.toml's members. It holds the seam and the decision layer — FloatingIpProvider, FloatingIpTarget/State/AssignOutcome, reconcile_assignment, on_ingress_owner_changed, FLOATING_IP_PROVIDERS + provider_has_floating_ip_adapter + new supported_floating_ip_providers(), OwnerLiveness, QuorumHealth, IngressOwnerEffect, resolve_ingress_owner, plan_ingress_owner_effect — on exactly TWO dependencies, anyhow and async-trait. That budget is load-bearing, not tidiness: this crate links into the fleet daemon, which is the whole reason it exists, so a reqwest or a serde in its manifest ships to every node. cloud::provider::floating_ip is now a thin lid that `pub use`s all of it at the historical path, so NO call site moved (validate.rs, app/yah/cli/src/cloud.rs, reconciler/domain.rs all untouched).")
//! @yah:handoff("WHAT MADE THE EXTRACTION POSSIBLE, and it is the load-bearing design choice: `FloatingIpMachine`, a five-field value (name, provider, location, region, ingress_floating_ip) replacing `cloud::config::MachineConfig` throughout the moved code. Checked rather than assumed — the three vendor adapters read ONLY those five fields off a machine (rg over hetzner/ovh/vultr_floating_ip.rs), so five is the entire contract. cloud converts at the boundary via `machine_facts(&MachineConfig) -> FloatingIpMachine` (a function, not a From impl, because the source is a borrow and the target is five clones), pinned by machine_facts_carries_every_field_the_floating_ip_layer_reads — a dropped field there would silently disable a failover path rather than fail to compile, since a missing ingress_floating_ip reads exactly like the supported \"this machine has no floating IP\". And it is what lets yubaba use the planner at all: a fleet node cannot construct a MachineConfig, but it can name a machine and say which provider hosts it.")
//! @yah:handoff("WHAT DELIBERATELY DID NOT MOVE, with the reason, because the decision text said \"trait + adapters\" and only the trait went. The three vendor adapters stay in cloud because each is TWO things at once — a FloatingIpProvider and an EnvoyAdapter — and splitting them hits the orphan rule: their typed envoy handlers (floating_ip_assign/floating_ip_status) are an INHERENT impl on the struct, and Rust forbids an inherent impl on a foreign type, so moving the struct would force those handlers into free functions and reshape cloud's envoy dispatch. That refactor buys nothing today, because yubaba cannot use a floating-IP adapter until it can resolve a machine to a provider and an IP id — see the next entry. The adapters were retargeted onto &FloatingIpMachine (their resolve_target signature, ovh_zone_for, and the three test fixtures, each of which shrank from a 22-field MachineConfig literal to a 5-field one). floating_ip_provider_for also stays in cloud: it reads FLOATING_IP_PROVIDERS' slot/env columns through `fob` and constructs the adapters, and neither fob nor a vendor HTTP client belongs in a crate that links into the fleet daemon.")
//! @yah:gotcha("THE EXTRACTION REMOVES THE DEPENDENCY OBJECTION BUT DOES NOT BY ITSELF ATTACH THE EFFECTOR — there is a SECOND, independent blocker the previous handoff did not name, and its next-step (a) asserted the opposite (\"the three facts it needs are all in scope there\"). plan_ingress_owner_effect takes FOUR inputs, not three, and the fourth is `machines`. A fleet node has NO fleet-wide machine declarations: `rg MachineConfig oss/yubaba/crates/yubaba/src/*.rs` returns only doc-comment prose, yubaba never loads .yah/infra/machines/, and each node knows only its OWN declared facts, passed as CLI flags (--sovereign-group, --sovereign-role, region). So the leader cannot resolve a dead ingress owner to a provider, a floating IP id, or a public address — resolve_ingress_owner would refuse every time — and it has no path to the apex A records, which cloud's dns.* verbs own. Verify it yourself before building on any plan that assumes otherwise; it is one rg.")
//! @yah:handoff("OPERATOR DECISION 2 (2026-09-08, ask_user F4088), on that second blocker: SHIP DECLARATIONS TO THE NODES AND WRITE DNS FROM THE FLEET. Rejected: (i) publishing the exclusion into raft for `yah cloud apply` to consume — smallest change, but the withdrawal then waits for a human to run apply, so a 3am node loss still costs half the round-robin, which is the exact pain R859 was filed against; (ii) reusing the elected ACME issuer's Cloudflare credential (acme_issuer.rs:273/399 — the fleet DOES already write Cloudflare DNS, via passway-acme's DNS-01 TXT publisher), because that makes a SECOND writer of yah.dev records outside the dns.* seam R859-F1 just established as the single source — the two-source problem this relay exists to dissolve, relocated rather than fixed. Chosen path is both automatic and single-seam, and costs a raft-surface change (epoch re-record) plus a Cloudflare credential on the nodes.")
//! @yah:gotcha("YIELDED ON THE YUBABA-SIDE PHASE — a live peer owns those files. @Ashguard:coffee (session:c431ac51, relay R600, lifecycle=working) is actively editing oss/yubaba/crates/yubaba/src/: scheduler.rs, headscale_appliance.rs and service_records.rs all had mtimes inside a 5-minute window while I was scoping the wiring (12:13-12:14 PDT 2026-09-08), and acme_issuer.rs / leader.rs / member_registration.rs / raft/mod.rs are dirty too. Those are exactly the files decision 2's plumbing needs. Per the shared-tree rule I stopped BEFORE touching any of them rather than hand-fighting the file, so there is no seam of mine in their tree and nothing for them to re-check. Everything I did land is in disjoint paths (the new crate, cloud/src/provider/*floating_ip*.rs, two Cargo.tomls) and no unexpected diff appeared in any of them. Whoever picks this up: re-check camp.roster for a live session on R600 before opening scheduler.rs.")
//! @yah:verify("EXTRACTION VERIFIED 2026-09-08. New crate: `cargo test --manifest-path oss/yubaba/crates/floating-ip/Cargo.toml` = 18 passed / 0 failed (17 moved from cloud verbatim + 1 new, every_registered_provider_is_named_in_the_supported_list, pinning the table-to-error-message join now that the constructor reading the slot/env columns lives in a different crate). `cargo clippy --manifest-path oss/yubaba/crates/floating-ip/Cargo.toml --all-targets`: ZERO errors and ZERO warnings.")
//! @yah:verify("cloud after the extraction: lib 1136 passed / 0 failed / 4 ignored (from 1152/0/4 before — 17 tests moved out to the new crate, 1 new one added here: machine_facts_carries_every_field_the_floating_ip_layer_reads); tests/main 3 passed / 0 failed / 2 ignored; pond_smoke 2/0; doc-tests 0/0/1. Net across the two crates: 1152 -> 1154. `cargo check --manifest-path oss/yubaba/Cargo.toml --all-targets` clean, and `cargo build --workspace` EXIT=0 — the root workspace matters here because app/yah/cli consumes cloud through the [patch.crates-io] bridge, so a broken re-export would have surfaced there. Note the camp's skew guard flagged concurrent peer edits to unrelated yubaba/src files during two of these runs; the final pass above was re-run and is the one quoted.")
//! @yah:handoff("Tree anchor at handoff: af805057ba632298d3e7e53c0197a164ed413824 (HEAD; everything below is uncommitted against it — quote this SHA, not 'HEAD', in any revert instruction). My files, all disjoint from the live peer's yubaba/src edits: NEW oss/yubaba/crates/floating-ip/{Cargo.toml,src/lib.rs}; oss/yubaba/Cargo.toml (members); oss/yubaba/crates/cloud/Cargo.toml (the dep); cloud/src/provider/{floating_ip,hetzner_floating_ip,ovh_floating_ip,vultr_floating_ip}.rs. The @yah: annotation block on provider/floating_ip.rs was preserved verbatim through the rewrite — this ticket's source anchor did not move.")
//! @yah:handoff("Tree anchor at handoff: af805057ba632298d3e7e53c0197a164ed413824 — the shared tree as I left it. Diff against it (`git diff af805057ba632298d3e7e53c0197a164ed413824..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
//! @yah:handoff("PHASE A + PHASE B BOTH LANDED, uncommitted against tree anchor e336e7278f7f40780323d40a8ceb6e438bead0b1 (quote that SHA, not HEAD, in any restore instruction). THE WHOLE CHAIN NOW RUNS ON THE FLEET: a node declares its public-ingress facts on its own flags -> member_registration publishes them into its raft member row -> the leader's scheduler tick assembles Vec&lt;FloatingIpMachine&gt; from the member map, reads ingress_owner, judges the owner's liveness through the hysteresis tracker and the quorum verdict, calls floating_ip::plan_ingress_owner_effect, and APPLIES a Withdraw as one content-matched Cloudflare A-record delete. R859 was filed because node loss left a dead A record taking ~half the round-robin until a human edited DNS; that is now automatic.")
//! @yah:handoff("PHASE A — the declarations. MemberInfo and YubabaRequest::SetMember each gained `provider`, `location`, `ingress_floating_ip` and `public_address` (Option&lt;String&gt;, #[serde(default)], raft/mod.rs), fed from four new `yubaba serve` flags of the same names, copied per node from .yah/infra/machines/&lt;name&gt;.toml exactly as --region is. `location` ships now although nothing reads it yet on the fleet path — every vendor adapter needs it to derive a mobility zone, and adding it later would cost a SECOND raft-surface change and a second cluster-epoch re-record for one optional string. Two accessors on YubabaStateMachine (raft/store.rs): `floating_ip_machines()` reassembles the fleet's declarations into the planner's shape, SKIPPING rows with no machine name (an empty `name` matches no ingress_owner, so including it would turn a clean \"cannot resolve — refusing\" into a silent lookup failure, and two such rows would look like a duplicate declaration); `public_address_for_machine()` is the one fact a content-matched delete cannot be performed without.")
//! @yah:handoff("PHASE A DISSOLVES THIS TICKET'S OWN LEAD GOTCHA, on the fleet path only. The recorded trap was that ingress_owner carries /etc/hostname and is not reliably the .yah/infra/machines/&lt;name&gt;.toml name (R841 saw `vps-4c1efa56` for the box declared `us-west-001`), so cloud::resolve_ingress_owner would REFUSE on today's fleet rather than misfire. That still holds for the `yah cloud apply` path, which resolves against machine TOMLs. It does NOT hold for the fleet path this ticket just built: MemberInfo.machine and ingress_owner are both written from leader::derive_machine_name() on the same box, so floating_ip_machines() is a list of hostnames and resolve_ingress_owner matches BY CONSTRUCTION rather than by luck. That is the quiet win in phase A and the reason the effector is live rather than inert.")
//! @yah:handoff("PHASE B — the effector, and the invariant that makes two writers of one apex safe. New module oss/yubaba/crates/yubaba/src/ingress_effector.rs. THE FLEET MAY ONLY EVER SUBTRACT; THE DECLARATION MAY ONLY EVER ADD. The fleet issues ONE targeted delete — the dead origin's A record at the apex, matched on CONTENT, never on name (a round-robin apex holds several A records under one name, so deleting by name would take the survivors with it and turn a partial outage into a total one). It never renders the apex, never adds a record, and never needs the domain manifests or the machine TOMLs. `yah cloud apply` re-adds the record when the box returns, because R859-F1's diff is fail-open on addition. The `ApexWithdrawal` trait has exactly ONE method, it deletes, and the module doc says in as many words that teaching it to add would put the two writers in a loop. Neither the empty-apex backstop nor the degraded-quorum refusal is re-implemented here — both live one layer up, and a second copy of a safety rule is a second thing to drift.")
//! @yah:handoff("WHY THE EFFECTOR RIDES THE SCHEDULER TICK and not a loop of its own: that tick is the one place per pass already holding all four inputs — the leader check, the TransitionTracker's committed verdict, the quorum verdict R859-F2 added there last session, and the member map. A second loop would duplicate all four and could disagree with this one about any of them. `previous_ingress_owner` is tracked across ticks because plan_ingress_owner_effect is stateless; starting at None on a freshly elected leader is correct rather than a gap (first tick reads an existing owner as a change and converges the IP idempotently, withdrawal arms on the tick after). The marker is NOT advanced past a Failed apply — a withdrawal that did not land must be re-decided, not recorded as done. Config is env-sourced (YUBABA_INGRESS_APEX + a fob-injected CF token file + zone id), mirroring acme_issuer::parse_issuer_config rather than adding a fourth credential flag; unset is the default and every camp today. Half-configured is FATAL AT STARTUP on purpose — the opposite of member_registration's never-wedge-a-boot rule — because an effector that could not read its token would discover that during the 3am failover it exists to perform.")
//! @yah:handoff("FOUR DISCOVERED FIXES OUTSIDE THE TICKET TITLE, all landed rather than filed. (1) raft/mod.rs:1801/:1849/:2617 — three #[cfg(test)] SetMember sites broke the whole camp's `cargo test -p yubaba --lib` for a window; @Ashguard:dove flagged it with line numbers and I fixed it in the same pass. (2) NodeDeclaration (raft/mod.rs) + MemberInfo::from_declaration replace six positional Option&lt;String&gt;s threaded through member_registration::spawn -&gt; run -&gt; plan_registration -&gt; write_row; at six params two adjacent Options could be swapped at a call site with no type error, and `provider` and `location` are both \"a short lowercase string naming where this box lives\". (3) SchedulerDeps (scheduler.rs) does the same for scheduler::spawn/run, which clippy flagged at 8/7 args — all four are Option&lt;Arc&lt;dyn …&gt;&gt; and three of the four would swap silently. Default is all-None, which is exactly \"behaves as before any of these gates existed\". (4) QuorumVerdict::as_ingress_health() (quorum_health.rs) implements the Unknown -&gt; Degraded collapse this ticket's own @yah:assumes recorded as documented-but-unwritten; it is now written and tested, and the collapse is only reachable because the effector is attached.")
//! @yah:handoff("CLUSTER-EPOCHS RE-RECORDED, both axes, NO BUMP — cluster_protocol stays 5 (a77f2501… → d4b539bd…), state_epoch stays 4 (10215557… → 5b11870a…). Gate re-run after: green on both. Full verdict in oss/yubaba/crates/yubaba/cluster-epochs.json surface_rerecords[2026-09-08]. TWO sessions' work was in that diff and each verdict was made by the session that wrote its half: my four #[serde(default)] Option fields are the identical shape to `machine`, which surface_rerecords[2026-09-05] already ruled NOT BREAKING on the SAME struct and SAME variant; @Ashguard:dove's R869 half (applied_state / seed_state_machine) is quoted verbatim and attributed. Checked `git diff -U0` on raft/mod.rs and raft/store.rs before writing to confirm no third party's unanalysed hunk was swept in — mod.rs is entirely mine, store.rs is exactly the two of us.")
//! @yah:handoff("FILES (all uncommitted against e336e7278f7f40780323d40a8ceb6e438bead0b1). NEW: oss/yubaba/crates/yubaba/src/ingress_effector.rs (11 tests). yubaba: raft/mod.rs (4 fields on MemberInfo + SetMember, NodeDeclaration, MemberInfo::from_declaration, apply arm, 4 test hunks), raft/store.rs (2 accessors + 1 test), member_registration.rs (NodeDeclaration through the whole loop, 1 new test), scheduler.rs (SchedulerDeps + the effector tick), quorum_health.rs (as_ingress_health + 1 test), lib.rs (module decl), main.rs (4 flags, effector construction, 2 no-raft warnings, SchedulerDeps), leader_pin.rs + headroom.rs (MemberInfo literals via from_declaration), Cargo.toml (yah-floating-ip as a RUNTIME dep), cluster-epochs.json, tests/raft_appliance_ownership.rs, tests/raft_tenant_placement.rs, yubaba-test-harness/src/solo_node.rs. app/yah/cli: resources/yubaba.service (EnvironmentFile=-/etc/yah-cloud/ingress.env + the reasoning), tests/camp_systemd_unit_emit.rs (1 new test pinning it). cloud: provider/floating_ip.rs is annotation-only (my board.claim timestamp), no code change. COLLISION CHECK: leader.rs, acme_issuer.rs, domain_issuer.rs, cert_store.rs, state_backup.rs and everything under oss/passway/ are dirty and are NOT mine — @Ashguard:coffee (R853) and @Ashguard:dove (R869). I touched none of them.")
//! @yah:gotcha("THE CODE IS DONE; THE FLEET IS NOT CONFIGURED, AND THAT IS AN OPERATOR STEP ON LIVE NODES, NOT A CODE ONE. Nothing withdraws anything until (a) each node's systemd drop-in gains `--provider --location --ingress-floating-ip --public-address` and (b) /etc/yah-cloud/ingress.env is laid down on the raft voters. Grounded, not assumed: fleet flags are hand-rolled per node into a drop-in (see .yah/infra/cloud-init/dev-raft-node.sh:97 writing `ExecStart=` then a full `yubaba serve …` line) — nothing renders them from the machine TOML, and --region is not even passed on the dev-raft boxes today. Worse, R858's measurement at app/yah/cli/src/mesh.rs:117 records that /etc/yah-cloud/ DOES NOT EXIST on us-east-001, so the acme.env and litestream.env EnvironmentFile lines already resolve to nothing there; ingress.env would too. The three public origins are us-west-001 15.204.89.240, us-south-001 45.32.194.254, us-east-001 51.81.85.145 — those are the --public-address values.")
//! @yah:gotcha("THE FLEET'S CLOUDFLARE TOKEN MUST BE ITS OWN, NOT THE ACME ISSUER'S — deliberately, and it is the decision this ticket's operator call turned on. Operator decision 2 rejected reusing the elected ACME issuer's credential (acme_issuer.rs:273/399) because that makes one token both publish TXT challenges and delete A records, which fuses the two writers this design keeps apart. The effector's token needs `DNS: Edit` on the apex zone and nothing else. Also note R858's finding at mesh.rs:117: yubaba's ACME issuer is not running anywhere on this fleet (no YUBABA_ACME_* is set on any box) — the live doors get their certs from PASSWAY's own acme engine. So there is no existing yubaba-side CF credential on the fleet to reuse even if that were wanted.")
//! @yah:gotcha("REASSIGN IS NOT APPLIED ON THE FLEET, IT IS REFUSED LOUDLY, AND IT IS UNREACHABLE ANYWAY — filed as R859-F3 rather than built, because it is genuinely separable rather than work I was standing on. Grounded: all nine machines in .yah/infra/machines/*.toml declare `provider = \"static\"`, which has no floating-IP adapter, and none declares an `ingress_floating_ip`, so plan_ingress_owner_effect returns NoOp(\"declares no ingress_floating_ip\") and cannot emit Reassign on this fleet at all. Making it real means extracting the three vendor adapters into a sibling of yah-floating-ip plus a vendor credential on every node — see R859-F3, which also records that the orphan-rule objection blocking that extraction is half wrong (a local trait on a foreign type is fine; only the three byte-identical inherent handler pairs bind, and one blanket impl replaces them). Until then apply_effect's Reassign arm returns NotApplied naming the machine, the IP and the exact missing piece — pinned by an_unappliable_reassign_names_the_missing_transport, so it can never degrade into a silent no-op.")
//! @yah:verify("FINAL PASS 2026-09-08, every command re-run after the last edit, on tree anchor e336e7278f7f40780323d40a8ceb6e438bead0b1. `cargo test --manifest-path oss/yubaba/Cargo.toml -p yubaba --lib` = 828 passed / 0 failed / 0 ignored (baseline at the previous handoff was 724/0; the delta is this pass's 14 plus peers' tests landing alongside). `cargo test --manifest-path oss/yubaba/crates/cloud/Cargo.toml --features json-schema` = lib 1138/0/4, tests/main 3/0/2, pond_smoke 2/0, doc-tests 0/0/1 — cloud is untouched by this pass and stayed green. `cargo check --manifest-path oss/yubaba/Cargo.toml --all-targets` = no errors (covers the two integration-test files I edited). `cargo build --workspace` = no errors. `cargo clippy --manifest-path oss/yubaba/Cargo.toml -p yubaba --all-targets` = ZERO findings on any file I touched; the only warnings in that crate are pre-existing (raft/store.rs:397/:419 clone_on_copy, :491 derivable_impls) and belong to code I did not write. `cargo test -p yah --test main camp_systemd_unit_emit` = 14/0 (was 13; +1 for the new unit assertion). `RUSTC_WRAPPER='' cargo run -p xtask -- cluster-epochs` = GREEN on both axes after re-recording. `scripts/check-schema-drift.sh` = ok (this pass touched no generator input — MachineConfig did not move). `scripts/check-workload-spec-ts.sh` = ok. `cargo run --bin yubaba -- serve --help` shows all four new flags with their help text, so the clap wiring is proven rather than assumed.")
//! @yah:verify("WHAT THE 14 NEW TESTS ACTUALLY PIN, since a count is not evidence. ingress_effector.rs (11): a_withdrawal_deletes_the_dead_origins_own_address asserts the ADDRESS handed to the transport is the dead machine's and not a survivor's — the one mistake that turns this feature into an outage; an_undeclared_public_address_refuses_instead_of_guessing asserts NOTHING is deleted when the address is unknown; a_repeated_withdrawal_deleting_nothing_is_still_a_success pins the steady state while a node stays down (the planner re-emits Withdraw every tick, so 0-deleted must not read as failure); a_failed_provider_call_is_reported_as_failed_not_withdrawn keeps a failed withdrawal retryable instead of recorded as done; an_unappliable_reassign_names_the_missing_transport stops the unwired arm degrading into a silent no-op; not_knowing_is_never_confirmed_down pins the veto-only direction of the liveness channel through BOTH ways of not knowing (owner maps to no node id; node not committed either way); plus four config-parse cases including a_declared_apex_with_no_credential_is_refused_at_parse_time. raft/store.rs (1): the_member_map_reassembles_the_fleets_declarations — round-trips three SetMember rows through the real state machine, asserts the no-machine-name row is OMITTED, asserts resolve_ingress_owner Ok on a declared name and Err on an undeclared one, and asserts public_address_for_machine returns None rather than a fallback. member_registration.rs (1): the_public_ingress_declaration_is_compared_like_every_other_field — THE UPGRADE PATH: a row identical on every older field still rewrites when only the ingress declaration differs, then converges. quorum_health.rs (1): unknown_crosses_into_the_planner_as_degraded_carrying_its_own_reason.")

use anyhow::{bail, Context, Result};

use crate::config::MachineConfig;

// The shared seam, re-exported at its historical path so no call site moved
// when R859-F2's extraction landed. `cloud::provider::floating_ip::X` still
// resolves for every X the old module defined.
pub use floating_ip::{
    on_ingress_owner_changed, plan_ingress_owner_effect, provider_has_floating_ip_adapter,
    reconcile_assignment, resolve_ingress_owner, supported_floating_ip_providers,
    FloatingIpAssignOutcome, FloatingIpMachine, FloatingIpProvider, FloatingIpState,
    FloatingIpTarget, IngressOwnerEffect, OwnerLiveness, QuorumHealth, FLOATING_IP_PROVIDERS,
};

/// Narrow a [`MachineConfig`] to the five facts floating-IP mobility reads.
///
/// The boundary conversion, and the reason the shared crate needs nothing from
/// `cloud`. Written as a function rather than a `From` impl because the source
/// is a borrow and the target is owned, and `From<&MachineConfig>` reads as a
/// cheap coercion at call sites where it is actually five clones.
pub fn machine_facts(machine: &MachineConfig) -> FloatingIpMachine {
    FloatingIpMachine {
        name: machine.name.clone(),
        provider: machine.provider.clone(),
        location: machine.location.clone(),
        region: machine.region.clone(),
        ingress_floating_ip: machine.ingress_floating_ip.clone(),
    }
}

/// Resolve `machine.provider` to a live [`FloatingIpProvider`] — the registry
/// R594-F5 left out.
///
/// Without this the three adapters were unreachable from any caller holding a
/// [`MachineConfig`]: each knows its own wire format, and nothing mapped a
/// declared provider onto one. Credentials come from the same
/// `fob`-then-env source [`super::HetznerDriver::from_default_sources`] uses,
/// so a camp that can already drive a provider can drive its floating IPs with
/// no extra configuration.
///
/// Stayed in `cloud` when the seam moved out (R859-F2): the slot/env columns of
/// [`FLOATING_IP_PROVIDERS`] are read here and the adapters are constructed
/// here, and neither `fob` nor the vendor HTTP clients belong in a crate that
/// links into the fleet daemon.
///
/// Two distinct failures, kept distinct because they want different fixes: a
/// provider with no adapter is a *declaration* error (nothing will ever move
/// that IP), while a missing credential is an *environment* error (the
/// declaration is fine, this process cannot act on it).
pub fn floating_ip_provider_for(machine: &MachineConfig) -> Result<Box<dyn FloatingIpProvider>> {
    let Some((_, slot, env)) = FLOATING_IP_PROVIDERS
        .iter()
        .find(|(id, _, _)| *id == machine.provider)
    else {
        bail!(
            "machine {:?} declares provider {:?}, which has no floating-IP adapter — \
             floating/reserved IPs are implemented for {} only",
            machine.name,
            machine.provider,
            supported_floating_ip_providers(),
        );
    };
    let token = fob::get_or_env(slot, env)
        .with_context(|| format!("reading {slot} for machine {:?}", machine.name))?
        .with_context(|| {
            format!(
                "machine {:?} needs {:?} credentials to move its floating IP, but neither the \
                 `{slot}` vault slot nor ${env} is set",
                machine.name, machine.provider,
            )
        })?;
    // R859-F3: the provider-id → client match lives in the adapters crate now,
    // so `yubaba`'s effector and this constructor build the same client from the
    // same table. What stays here is only the half `fob` is needed for.
    floating_ip_adapters::adapter_for(&machine.provider, &token).with_context(|| {
        format!(
            "building the floating-IP adapter for machine {:?}",
            machine.name
        )
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn machine(name: &str) -> MachineConfig {
        MachineConfig {
            name: name.into(),
            provider: "fake".into(),
            location: None,
            server_type: None,
            hosts_mirrors: vec![],
            mesh_tags: vec![],
            region: None,
            zone: None,
            arch: None,
            bucket: None,
            vendor: None,
            nickname: None,
            legacy_hostkey_fingerprint: None,
            registration: Default::default(),
            ssh_keys: vec![],
            cloudflared: None,
            hosts_operator_bridge: false,
            connect: None,
            allocatable: None,
            taints: vec![],
            sovereign_group: None,
            sovereign_participation: Default::default(),
            host_machine: None,
            ingress_floating_ip: None,
        }
    }

    /// The declaration error and the environment error are different failures
    /// wanting different fixes, so they must not collapse into one message.
    #[test]
    fn a_provider_with_no_adapter_is_refused_by_name_before_any_credential_lookup() {
        let mut m = machine("us-west-002");
        m.provider = "digitalocean".into();
        let err = match floating_ip_provider_for(&m) {
            Ok(_) => panic!("digitalocean has no floating-IP adapter but the registry built one"),
            Err(e) => e,
        };
        let msg = format!("{err:#}");
        assert!(msg.contains("us-west-002"), "{msg}");
        assert!(msg.contains("digitalocean"), "{msg}");
        assert!(
            msg.contains("hetzner") && msg.contains("ovh") && msg.contains("vultr"),
            "the refusal should name what IS supported: {msg}"
        );
    }

    /// The boundary conversion is the only thing standing between `cloud`'s
    /// machine declarations and a crate that cannot see them, so a field
    /// dropped here would silently disable a failover path rather than fail to
    /// compile — `ingress_floating_ip` going missing reads exactly like "this
    /// machine has no floating IP", which is a supported shape.
    #[test]
    fn machine_facts_carries_every_field_the_floating_ip_layer_reads() {
        let mut m = machine("us-east-001");
        m.provider = "hetzner".into();
        m.location = Some("iad".into());
        m.region = Some("us-east".into());
        m.ingress_floating_ip = Some("fip-42".into());

        let facts = machine_facts(&m);
        assert_eq!(facts.name, "us-east-001");
        assert_eq!(facts.provider, "hetzner");
        assert_eq!(facts.location.as_deref(), Some("iad"));
        assert_eq!(facts.region.as_deref(), Some("us-east"));
        assert_eq!(facts.ingress_floating_ip.as_deref(), Some("fip-42"));
        // `location()` is what the Hetzner adapter calls, and it must agree
        // with MachineConfig's own accessor for a machine that declares none.
        assert_eq!(machine_facts(&machine("static-1")).location(), "");
    }
}