Expand description
Provider-abstracted floating/reserved-IP mobility (R594-F5).
FloatingIpProvider is the domain-level trait each vendor adapter
(HetznerFloatingIp, OvhFloatingIp, VultrFloatingIp — sibling
modules in this directory) implements. reconcile_assignment is the
shared idempotent + zone-checked core all three run through, so the
“no-op when already assigned” / “reject a cross-zone move” behavior is
written and tested exactly once instead of three times.
on_ingress_owner_changed is the Rust-level callable entry point for
R594-F5’s ask: given the raft ingress_owner seam
(oss/yubaba/crates/yubaba/src/raft/mod.rs’s YubabaRequest::SetIngressOwner
/ ClearIngressOwner, RaftAppState::ingress_owner) and the machine it
currently names, command the provider floating IP to follow. Wiring
this to fire automatically whenever ingress_owner transitions lives
in the raft-apply / leadership-reconcile path
(oss/yubaba/crates/yubaba/src/leader.rs), which is peer-owned and
off-limits to this ticket — see on_ingress_owner_changed’s doc
comment for the exact call site a follow-up should add. This is the
same “mechanism now, wiring later” shape R594-F3 used for service
records.
This mirrors, at the sovereign-ingress tier, the “external identity
follows placement” property R591 names for
Headscale via a Cloudflare Tunnel. R591 is peer-owned and gated on R570
(real multi-node raft HA); this module is not blocked on either — it
builds directly on the ingress_owner seam, which already exists.
@yah:ticket(R859-F2, “Wire floating-ip.* provider adapters to ingress_owner transitions + health-checked DNS withdrawal for dead origins”)
@yah:at(2026-09-05T10:22:42Z)
@yah:assignee(agent:bundle-anthropic-glimmerstone)
@yah:parent(R859)
@yah:next(“The verbs and adapters exist with zero callers: envoy/floating_ip.rs + provider/{vultr,hetzner,ovh}_floating_ip.rs are dead code today. Raft already holds and applies ingress_owner (raft/mod.rs:597,1182) — the missing piece is the effector that commands the provider when it changes, which is exactly W267 Tier 1’s ‘external identity follows placement’ (the R591 property).”)
@yah:next(“Two failover speeds, both currently manual: intra-provider = floating-IP reassign (seconds, no DNS propagation, no cert re-mint — mind the W267-verified mobility constraints: Hetzner per network zone, OVH per DC region, Vultr region-bound); cross-provider = short-TTL DNS withdrawal of the dead origin’s A record (needs R859-F1’s rendering).”)
@yah:next(“The health signal for withdrawal must NOT come from raft health (W267 §‘Where liveness lives’ — reachability is observer-relative); use the supervisor-level fact only: a machine leaving the fleet / its yubaba unreachable from quorum, not a per-proxy probe.”)
@yah:next(“cloud.mesh_failover (W271) is the existing manual verb — keep it as the operator path; this ticket automates the effector both paths share.”)
@yah:next(“Tier: Wizard — touches live-fleet failover semantics; wrong wiring here turns a leadership flap into a public outage. Design the guard rails (hysteresis, refuse-on-degraded-quorum per yubaba-failover.md) before the effector.”)
@yah:handoff(“LANDED, uncommitted. Six pieces. (1) THE BLOCKER, fixed as decided: MemberInfo and YubabaRequest::SetMember each gained machine: Optioncloudflared) + 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/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:next(“ATTACHING THE EFFECTOR is the one genuine operator/architecture call left, and it is the cycle question the brief anticipated — just with a different answer than "cycle: yes/no". There is no cargo cycle; there IS a documented rule (R374-F3, recorded in cloud/Cargo.toml’s local-driver comment) against a runtime yubaba -> cloud edge, and taking it would put velveteen/velveteen-exec/yah-hetzner/yah-mesofact-bundle/yah-almanac into the fleet daemon. Three options for whoever decides: (a) accept the edge and call plan_ingress_owner_effect from scheduler.rs:370’s tick loop, which already computes is_leader, owns the TransitionTracker, and now computes the quorum verdict — the call site is ready and the three facts it needs are all in scope there; (b) extract the floating-IP provider trait + adapters into a small crate both depend on, the same move R374-F3 made for local-driver; (c) leave it operator-driven and expose the planner through a cloud verb. Nothing else in this ticket is blocked on the answer — the decision logic and every gate are landed and tested either way.”)
@yah:next(“Two follow-ups worth their own tickets, both genuinely separable rather than deferred work I was standing on. (1) The hostname vs machine-name gap (see gotcha): today an ingress_owner of vps-4c1efa56 REFUSES loudly instead of misfiring, which is safe but means the effector is inert on any box whose /etc/hostname differs from its declared name. Fix is either renaming those hostnames or adding a declared hostname alias to MachineConfig that resolve_ingress_owner also matches — a fleet-config decision, not a code one. (2) Real OVH request signing (application key + secret + consumer key + timestamped HMAC) before the ovh floating_ip.* verbs touch api.ovh.com.”)
@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 Optiongit 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:assumes(“QuorumVerdict::Unknown -> QuorumHealth::Degraded is documented but has no conversion code yet. That is consistent with plan_ingress_owner_effect being unwired — the collapse only becomes reachable when the effector is attached — but it is the first thing to implement if it is.”)
@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/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.”)
Structs§
- Floating
IpAssign Outcome - Outcome of
reconcile_assignment/on_ingress_owner_changed. - Floating
IpState - Current provider-side state of a floating/reserved IP.
- Floating
IpTarget - A resolved reassign target: provider-native attach id + the mobility zone it lives in.
Enums§
- Ingress
Owner Effect - What should happen to public ingress, given an
ingress_ownerobservation. - Owner
Liveness - What a
TransitionTracker-style hysteresis says about one machine, crossed into this crate as plain data. - Quorum
Health - Live consensus health, crossed into this crate as plain data.
Traits§
- Floating
IpProvider - One provider’s floating/reserved-IP transport + mobility policy.
Functions§
- floating_
ip_ provider_ for - Resolve
machine.providerto a liveFloatingIpProvider— the registry R594-F5 left out. - on_
ingress_ owner_ changed - Callable entry point: react to the raft
ingress_ownerseam namingmachineas the box that now owns public ingress, by commandingip_idto follow it. - plan_
ingress_ owner_ effect - Decide what public ingress should do about an
ingress_ownerobservation — pure, so the decision is testable as arithmetic and the I/O is somebody else’s problem. - provider_
has_ floating_ ip_ adapter - Does
providerhave a floating-IP adapter at all? - reconcile_
assignment - Idempotent, zone-checked core shared by every provider adapter and by
on_ingress_owner_changed. - resolve_
ingress_ owner - Resolve an
ingress_ownerstring to the machine it names.