Skip to main content

cloud/
config.rs

1//! @yah:ticket(R040-F16, "pg-on-mesh service recipe: bind tailscale0 + pg_hba.conf snippet + ufw rules")
2//! @yah:at(2026-05-05T00:32:34Z)
3//! @yah:assignee(agent:claude)
4//! @yah:status(review)
5//! @yah:parent(R040)
6//! @yah:handoff("Companion to R040-F15. Inter-node TCP (Postgres primary↔replica, NATS clusters, anything raw-protocol) lives on the Headscale mesh, not on Hetzner public IPs. Each node has a stable 100.64.x.x mesh IP that survives replacement of the underlying box, so DNS / config / pg_hba never churn when a CPX-11 is rebuilt. WireGuard already encrypts the wire — TLS becomes defense-in-depth, not load-bearing. This ticket carries the concrete pg-shaped recipe so the first stateful service deploy doesn't have to re-derive the pattern; subsequent services (redis, NATS, etc.) cargo-cult from it.")
7//! @yah:next("ServiceConfig gains a `bind_interface: Option<String>` field (e.g. `Some(\"tailscale0\")` for mesh-only services). The cloud-init/podman compose renderer translates this into either `--network host` + `pg listen_addresses = '<mesh-ip>'` OR a podman macvlan/host-binding pattern that achieves the same.")
8//! @yah:next("Generated pg_hba.conf snippet: allow the mesh subnet (100.64.0.0/10) for replication + app users. Postgres binds to the node's tailscale0 mesh IP only — `listen_addresses` is templated from the node's `tailscale ip --4` at first boot.")
9//! @yah:next("Generated ufw rules: `ufw allow in on tailscale0 to any port 5432; ufw deny 5432` — mirrors the existing yah-yubaba 7443 pattern in mirror.yml. Same shape works for any mesh-only port.")
10//! @yah:next("Replica connection string uses primary's mesh IP, NOT its public IP. Stable across box replacement.")
11//! @yah:next("Out of scope: pg_basebackup orchestration, failover, WAL archiving — those belong in noisetable's domain; this ticket only standardizes the binding/firewall/auth shape so noisetable's pg deployment doesn't reinvent it.")
12//!
13//!
14//! @yah:ticket(R323-F9, "Add sync-wave ordering to ServiceComponent (deploy-panel wave order)")
15//! @yah:assignee(agent:claude)
16//! @yah:at(2026-05-26T15:20:25Z)
17//! @yah:status(review)
18//! @yah:phase(P2)
19//! @yah:parent(R323)
20//! @yah:next("ServiceComponent gains a wave/order field (or depends_on between components) so the deploy panel (R323-F4) can group workload rollout rows into sync waves (wave 0 parallel, wait healthy, wave 1, …). Today all components are implicitly wave 0.")
21//! @yah:next("compute_service/compute_cell in reconciler/sync_status.rs surface the wave per workload so F4 doesn't re-derive it.")
22//! @yah:gotcha("Until this lands, F4 should render every workload as wave 0 (no ordering).")
23//! @yah:handoff("Added wave: u32 (serde default=0, skip_serializing_if zero) to ServiceComponent in config.rs. Added is_zero_u32 helper. Fixed the three struct literal call-sites that now need wave: 0 (config.rs test, local_sim.rs x2, mesofact_static.rs). Added wave?: number to the TS ServiceComponent interface with a doc comment. Deploy panel now reads c.wave ?? 0 for each WorkloadRow instead of hardcoded 0. SyncFooter computes maxWave from the components array and renders 'wave 0' (all-zero case) or 'waves 0–N' (multi-wave). All 218 cloud lib tests pass; bun run typecheck clean.")
24//! @yah:verify("cargo test -p cloud --lib  # 218 passed")
25//! @yah:verify("cd packages/yah/ui && bun run typecheck  # no new errors")
26//! @yah:verify("In service.toml: add wave = 1 to a component, rebuild, open the deploy panel — that workload row shows 'w1' badge; SyncFooter shows 'waves 0–1'")
27//! @yah:verify("Component with no wave field in TOML deserializes as wave=0 (default). Saving a wave=0 component omits the field from the output TOML (skip_serializing_if).")
28//!
29//! @arch:see(.yah/docs/working/W142-pond.md)
30//!
31//! @yah:relay(R615, "Linked infra sources: sources.toml overlay so a camp can borrow another camp's substrate")
32//! @yah:at(2026-07-20T18:18:05Z)
33//! @yah:status(open)
34//! @arch:see(.yah/docs/working/W274-linked-infra-sources.md)
35//!
36//! @yah:ticket(R615-F1, "InfraSource types + SourcesConfig::load(infra_dir) parsing .yah/infra/sources.toml")
37//! @yah:status(review)
38//! @yah:assignee(agent:bundle-anthropic-miravel)
39//! @yah:at(2026-08-08T19:55:57Z)
40//! @yah:phase(P1)
41//! @yah:parent(R615)
42//! @yah:next("Add InfraSourceKind { Path { path }, Git(GitSource) } + InfraSource { owner, kind, mode, select } to cloud/src/config.rs. Reuse the existing GitSource (config.rs:1205, { repo, ref, subdir }) verbatim — do not invent a second git-source shape.")
43//! @yah:next("SourcesConfig::load(infra_dir) reads .yah/infra/sources.toml (schema_version = 1, ordered [[source]] array). Absent file = empty list, never an error — every existing camp has no sources.toml.")
44//! @yah:next("mode is the write-gate: read-only (borrower cannot mutate) vs owner-manages. Model it as an enum, not a bool, so a future read-write-with-approval tier is additive.")
45//! @yah:verify("cargo check -p cloud && cargo test -p cloud")
46//! @arch:see(.yah/docs/working/W274-linked-infra-sources.md)
47//! @yah:tier(Cleric)
48//! @yah:handoff("InfraSourceKind{Path{path},Git(GitSource)} + SourceMode{ReadOnly,Manage} + InfraSource{owner,kind,mode,select} + SourcesConfig{schema_version,source} all landed in oss/yubaba/crates/cloud/src/config.rs (after default_git_ref, ~line 1550). GitSource reused verbatim -- Git(GitSource) wraps the existing R561 type unchanged, no second git-source shape. InfraSourceKind is internally tagged (#[serde(tag=\"kind\", rename_all=\"kebab-case\")]) and flattened into InfraSource so a [[source]] table reads exactly like W274's example: owner/kind/path-or-repo+ref+subdir/mode/select all at one table level. mode: SourceMode defaults ReadOnly via #[serde(default)] on the field (enum, not bool, per the ticket's own instruction -- Manage is the explicit escape hatch). SourcesConfig::load(infra_dir) returns Ok(default()) -- schema_version=1, empty source list -- when sources.toml is absent; only parses+errors when the file exists and is malformed.")
49//! @yah:handoff("Tree anchor 85801e7f. Pathspec: oss/yubaba/crates/cloud/src/config.rs (only file touched). Tests: cargo test -p yah-cloud --lib (from oss/yubaba) 710 passed / 0 failed / 4 ignored, +6 new over the 704 baseline your R707-T6 verification recorded (sources_load_is_empty_when_the_file_is_absent, sources_parses_a_path_kind_exactly_like_w274s_example, sources_parses_a_git_kind_reusing_gitsource_verbatim, sources_mode_defaults_to_read_only_and_manage_is_explicit, sources_preserves_declaration_order, sources_round_trips_through_serialize). cargo check -p cloud also green (implied by the test build).")
50//! @yah:handoff("Tree anchor at handoff: 85801e7f6b76b369c0c8ecd2e5c7874990cd9286 — the shared tree as I left it. Diff against it (`git diff 85801e7f6b76b369c0c8ecd2e5c7874990cd9286..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
51//! @yah:next("R615-F2 picks this straight up: overlay these sources into CloudConfig::load, tagging origin{owner,source} and merging camp-local-wins-on-collision.")
52//! @yah:handoff("Verified pre-existing work: InfraSourceKind{Path,Git(GitSource)} + SourceMode + InfraSource + SourcesConfig all present in oss/yubaba/crates/cloud/src/config.rs at tree anchor 871fde1c, matching the inline @yah:handoff notes already on this ticket. GitSource reused verbatim, no second git-source shape. This session added no new code -- only ran verification and closed the board state, which a prior session left stuck in `open` despite the work being done (code + handoff notes landed, but board.review/handoff was never called).")
53//! @yah:verify("cargo check -p yah-cloud -- clean (2 pre-existing unrelated warnings)")
54//! @yah:verify("cargo test -p yah-cloud --lib -- 723 passed; 0 failed; 4 ignored (from oss/yubaba)")
55//!
56//! @yah:ticket(R615-F2, "Overlay loader: resolve sources in CloudConfig::load, tag origin, camp-local wins on collision")
57//! @yah:status(review)
58//! @yah:assignee(agent:bundle-anthropic-miravel)
59//! @yah:at(2026-08-08T19:56:05Z)
60//! @yah:phase(P1)
61//! @yah:parent(R615)
62//! @yah:next("In CloudConfig::load, after loading camp-local machines/providers/rules, resolve each source to an infra root (git sources read from the .yah/cache/infra/ sync cache — load stays offline), load that root's machines/providers/rules, tag each entry with origin { owner, source }, and overlay UNDER camp-local. Camp-local wins on name collision.")
63//! @yah:next("The machine load site is config.rs:533 (load_dir::<MachineConfig>(paths::machines_dir(...))). Note config.rs:575 load_from_config_dir is a SECOND machine load site that deliberately skips the inherit_machines redirect for multi-root/sibling trees (W206) — decide explicitly whether sources overlay applies there too, and document the answer either way.")
64//! @yah:verify("cargo check -p cloud && cargo test -p cloud")
65//! @yah:verify("A camp with sources.toml [[source]] kind=path to a sibling camp sees that camp's machines in CloudConfig::load, each tagged with the source owner")
66//! @yah:gotcha("Cross-camp MachineConfig schema skew is real: noisetable ships an older machine schema (location/server_type/hosts_mirrors) while yah's use region/arch/[connect]. A borrowed source can carry fields the borrower's binary predates. Overlay load MUST tolerate/skip unparseable foreign entries per-file and warn — never fail the whole load.")
67//! @arch:see(.yah/docs/working/W274-linked-infra-sources.md)
68//! @yah:depends_on(R615-F1)
69//! @yah:tier(Warrior)
70//! @yah:handoff("Overlay landed in CloudConfig::load (oss/yubaba/crates/cloud/src/config.rs). After camp-local machines/providers/legacy-merge finish, SourcesConfig::load(paths::infra_dir(workspace_root)) resolves + overlay_infra_sources() merges each source's machines/providers UNDER what's already there -- camp-local wins any name collision, and among sources themselves the earlier-declared one wins (both proven by dedicated tests). Provenance is NOT a field on MachineConfig/ProviderConfig: added CloudConfig.machine_origins/provider_origins: BTreeMap<String, InfraOrigin> instead, keyed by name/id. Reason recorded in a doc comment on InfraOrigin -- MachineConfig/ProviderConfig are constructed by struct literal in test helpers across several crates (including crates/yah/agent-tools/src/cloud_tools.rs, which is fenced/live-owned this session), so widening either shape would have forced an edit there for zero semantic gain; origin is a property of the LOAD, not the machine.")
71//! @yah:handoff("GOTCHA closed: added load_dir_tolerant<T>() -- a per-file-tolerant sibling of the existing (strict) load_dir -- so one unparseable foreign machine/provider (schema skew) skips-with-a-tracing::warn! and never sinks the rest of that source's directory or this camp's own load. Proven by one_unparseable_foreign_machine_does_not_sink_the_rest_of_the_directory_or_the_load. load_dir itself is untouched -- camp-local files still hard-fail on a bad TOML, which is correct, only borrowed roots get the tolerant path.")
72//! @yah:handoff("Git sources: InfraSource::infra_root() resolves kind=path to <workspace_root>/<path>/.yah/infra (live tree, no I/O beyond building the path) and kind=git to paths::infra_source_cache_dir(workspace_root, owner)/infra -- a NEW path helper in paths.rs, also what R615-T3's `yah infra sync` target directory must be so the two line up. An unsynced git source (cache dir absent) overlays nothing and is explicitly NOT an error (test: an_unsynced_git_source_overlays_nothing_and_is_not_an_error) -- load() stays fully offline as W274 §3 requires.")
73//! @yah:handoff("select filtering implemented for machines only (name exact-match or literal mesh_tags membership -- not a glob engine, matches W274's own example verbatim) via machine_matches_select(); does NOT apply to providers -- documented as a deliberate choice, nothing in W274 or the ticket describes a provider-scoped filter.")
74//! @yah:handoff("EXPLICIT DECISION on the config.rs:575-equivalent gotcha (now load_from_config_dir): sources overlay does NOT apply there. Multi-root sibling config dirs (W206 layout (b)) are a second config root INSIDE the same camp, not a second camp -- .yah/infra/sources.toml is tied to paths::infra_dir(workspace_root) specifically, which has no well-defined meaning for an arbitrary config_dir. Documented in the function's doc comment and proven by load_from_config_dir_never_applies_sources_overlay (a sources.toml at the real workspace root does NOT leak into a load_from_config_dir call against a sibling .noisetable/ dir under that same root).")
75//! @yah:handoff("Tree anchor 85801e7f. Pathspec: oss/yubaba/crates/cloud/src/config.rs, oss/yubaba/crates/cloud/src/paths.rs (added infra_source_cache_dir + 1 test), oss/yubaba/crates/cloud/src/reconciler/mesofact_bundle.rs (CloudConfig test-literal fixed for the 2 new fields), app/yah/cli/src/cloud.rs (3 CloudConfig test-literal sites fixed, same reason). Tests: cargo test -p yah-cloud --lib (from oss/yubaba) 720 passed / 0 failed / 4 ignored, +10 over R615-F1's 710 baseline (9 overlay tests in config.rs + 1 in paths.rs). cargo build -p yah --lib (repo root) green -- confirms nothing downstream (agent-tools, cloud.rs, hub) broke from CloudConfig's two new fields.")
76//! @yah:handoff("Tree anchor at handoff: 85801e7f6b76b369c0c8ecd2e5c7874990cd9286 — the shared tree as I left it. Diff against it (`git diff 85801e7f6b76b369c0c8ecd2e5c7874990cd9286..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
77//! @yah:next("R615-T3 (yah infra sync) is unblocked and has everything it needs: paths::infra_source_cache_dir(workspace_root, owner) is the exact target directory to clone/pull git sources into, already matching what F2's overlay reads from.")
78//! @yah:next("R615-F4 (Infra tab origin badge, not in my assigned lane) can read CloudConfig.machine_origins/provider_origins directly -- no further backend plumbing needed for the badge itself.")
79//! @yah:handoff("Verified pre-existing work: overlay landed in CloudConfig::load (oss/yubaba/crates/cloud/src/config.rs) at tree anchor 871fde1c -- SourcesConfig::load resolves sources, overlay_infra_sources() merges under camp-local with camp-local-wins and earlier-source-wins collision rules, machine_origins/provider_origins BTreeMaps added to CloudConfig, load_dir_tolerant() added for per-file-tolerant foreign schema skew, InfraSource::infra_root() resolves path/git kinds, load_from_config_dir explicitly does NOT get the overlay (documented). Matches this ticket's own inline @yah:handoff notes. This session added no new code -- only ran verification and closed board state that a prior session left stuck in `open` despite the work being done.")
80//! @yah:verify("cargo check -p yah-cloud -- clean (2 pre-existing unrelated warnings)")
81//! @yah:verify("cargo test -p yah-cloud --lib -- 723 passed; 0 failed; 4 ignored (from oss/yubaba), includes overlay tests + load_dir_tolerant test + infra_source_cache_dir test in paths.rs")
82//!
83//! @yah:ticket(R605-F12, "Sovereign groups have no voting axis, so non-voting membership is inexpressible and the raft guard is enforced by an absent field")
84//! @yah:status(review)
85//! @yah:at(2026-08-20T05:15:30Z)
86//! @yah:assignee(agent:bundle-anthropic-ashguard)
87//! @yah:parent(R605)
88//! @arch:see(.yah/docs/working/W325-isolated-x86-build-capacity.md)
89//! @yah:next("OPERATOR INTENT (2026-08-19) that the model cannot currently record: us-west-003 is a NON-VOTING member of the us-west-001-based (prod) sovereign group, and us-west-011 is a DIFFERENT sovereign (dev) from 001/003. The dev/prod split is already declared correctly. The non-voting membership is not — us-west-003.toml declares no sovereign_group at all.")
90//! @yah:next("THE GAP: MachineConfig::sovereign_group is a single Option<String>, so membership is binary, and judge_join (oss/yubaba/crates/cloud/src/config.rs:459) permits a join IFF both sides declare the same non-None group. There is no way to say 'in this blast radius, but not quorum-eligible'.")
91//! @yah:next("WHY THAT IS ACTIVELY BAD, not just missing: today the ONLY thing refusing us-west-003 into the prod raft at the join gate is its ABSENT stamp. Its own file is emphatic it must never hold a raft node id ('a home-internet partition should never be able to stall the raft'), and that guarantee currently rests on a field nobody wrote. Stamping it prod to record the operator's real intent would REMOVE the guard. This is precisely the W305 failure mode that produced R742-T4: `no-voter` sat inert on three nodes asserting something nothing enforced.")
92//! @yah:next("PROPOSED SHAPE (recommended): a second axis, e.g. sovereign_role = voter | non-voter (default voter for back-compat, or make it required), with judge_join permitting a same-group join only for voters. Then us-west-003 stamps prod + non-voter, the intent is machine-readable, and the raft guard stops depending on omission. us-west-004 (R605-T7) would take the same shape.")
93//! @yah:next("TOUCHES TWO COPIES OF THE PREDICATE, do not fix only one: cloud::judge_join renders the camp-side refusal, but the predicate itself lives in workload_spec::sovereign::join_permitted because yubaba's POST /raft/add-learner gate asks the same question and there is deliberately no yubaba -> cloud edge. Also re-read `yubaba serve --sovereign-group`, whose node-side gate is narrower on purpose (an unset flag means 'declared nothing', not 'declared standalone').")
94//! @yah:gotcha("THE CODE AND THE OPERATOR CURRENTLY DISAGREE ABOUT 003, and a reader should know which is which before editing. judge_join's own doc comment asserts 'prod and dev are both stamped, and us-west-002/003/015 are deliberately not raft members' — i.e. R742-F1 modelled 003 as STANDALONE. The operator's model is that it is a NON-VOTING MEMBER of prod. Those are different claims, not a wording difference: standalone means no blast-radius relationship to 001 at all. Do not silently 'correct' either side; this ticket is the reconciliation.")
95//! @yah:gotcha("FLEET STATE AS DECLARED (2026-08-19): prod = us-west-001, us-south-001, us-east-001. dev = us-west-011, us-west-013, us-west-014. NO sovereign_group declared = us-west-002, us-west-003, us-west-015. Verify against the files rather than trusting this list — xtask/tests/fleet_sovereign_groups.rs pins the roster and will need updating in the same change (it also asserts the stamp parses as a TOP-LEVEL key, which matters because 003 has a long comment block before [allocatable] where a stamp would silently become a member of that table).")
96//! @yah:gotcha("SEPARATE AXIS, DO NOT ENTANGLE: mesh membership is not sovereign membership. The standing rule is ONE mesh for the entire fleet regardless of group (operator, 2026-08-19), so us-west-003 and us-west-011 enrolling in headscale is unrelated work with no design question in it — see R605-T10. A voting axis on sovereign_group must not become a reason to keep any node off the mesh.")
97//! @yah:gotcha("SHARED-TREE COLLISION, live 2026-08-20: R772 (Miravel:spade, session:ce6d74a9) is refactoring oss/yubaba/crates/cloud/src/validate.rs at the same time and the file is currently RED - error[E0425] cannot find function load_machines at validate.rs:753, a half-landed extraction of the machine-loading walk that check_inert_taints / check_retired_arch_tags / the new check_unroled_sovereign_members all duplicate. That error is NOT from this ticket. Told them by party.chat and asked them to absorb check_unroled_sovereign_members into load_machines rather than leave one holdout. Do not hand-fight the file.")
98//! @yah:gotcha("R772 ALSO BROKE THREE PRE-EXISTING INGRESS TESTS, again not this ticket: two_services_fronting_one_node_collate_into_one_front_door, a_cross_service_hostname_clash_is_reported_with_both_declarations, one_mirrors_broken_declaration_does_not_hide_the_rest - all failing with 'providers.compute.use = hetzner - no such provider'. Cause is their new CloudConfig::load(workspace_root) at validate.rs:750 inside collate_workspace_ingress; the fronted_mirror fixture declares the slot but never writes infra/providers/hetzner.toml, and CloudConfig::load runs cross_ref_validate. Left alone deliberately - peer-owned.")
99//! @yah:gotcha("TRAP THAT MADE THREE OF MY OWN TESTS PASS FOR THE WRONG REASON: the machine-lint sweeps SKIP unparseable TOMLs by design (a peer's half-written scaffold must not sink the sweep). So a test fixture missing a REQUIRED MachineConfig field - mesh_tags is the one that bites - is silently skipped, the lint finds nothing, and every assert-empty test passes vacuously. Only the one test asserting found.len() == 1 noticed. write_sovereign_machine now always writes mesh_tags = [] and carries a comment saying why. Check this before trusting any new test in cloud::validate.")
100//! @yah:verify("cargo test -p yah-workload-spec --lib sovereign (from oss/yah-base) -- 9 passed, 0 failed. Covers both new refusals (a_non_voting_member_does_not_join_its_own_group, a_non_voting_target_has_no_quorum_to_join), the back-compat pin (the_default_role_is_the_pre_r605_f12_meaning), and the one-spelling round-trip across TOML/CLI/JSON.")
101//! @yah:verify("cargo test -p yubaba --lib sovereign (from oss/yubaba) -- 13 passed, 0 failed. Includes a_non_voting_joiner_is_refused_by_role_not_by_group, a_non_voting_target_refuses_every_joiner, a_group_without_a_role_key_is_a_voter_not_a_refusal (the deployed-fleet back-compat seam), a_peer_reports_its_role_in_the_toml_spelling.")
102//! @yah:verify("cargo test -p yubaba --test raft_sovereign_group (from oss/yubaba) -- 11 passed, 0 failed, up from 8. Three new end-to-end against real single-node rafts: a_non_voting_member_of_the_same_group_is_refused, a_non_voting_leader_refuses_to_grow_its_quorum, a_node_publishes_its_role_and_the_leader_reads_it_there (which also proves the request body cannot vote a non-voter in - the leader dials the joiner).")
103//! @yah:verify("cargo test -p xtask --test fleet_sovereign_groups (from repo root) -- 2 passed, 0 failed. THE DECISIVE ONE: parses the real .yah/infra/machines/*.toml through the actual MachineConfig deserializer. Confirms us-west-003 = prod + non-voter on disk, all six pre-existing voters now stamped sovereign_role = voter explicitly, and neither key swallowed by a table header.")
104//! @yah:verify("cargo test -p yah-cloud --lib (from oss/yubaba) -- 891 passed, 3 failed, where all 3 failures were R772's ingress-collate tests and none were mine. A clean re-run is BLOCKED, not failing: R555's in-flight AdmissionGrant.secrets field breaks velveteen-exec, and yah-cloud is not a root workspace member so its dev-deps can only resolve from the oss/yubaba workspace. Re-run once R555 lands.")
105//! @yah:handoff("LANDED, operator chose the second-axis shape (Call 1 = A, 2026-08-20). sovereign_role = voter | non-voter now sits beside sovereign_group, and ONE predicate judges both: workload_spec::sovereign::join_permitted(Membership, Membership) where Membership { group: Option<&str>, role: SovereignRole }. Permitted iff same non-None group AND both sides Voter. Both copies of the predicate call it - cloud::judge_join (camp-side) and yubaba::sovereign_group::judge (node-side) - so the rule itself cannot drift; only the prose differs, which was already the R742-F1 split.")
106//! @yah:handoff("WHY THE ROLE IS CHECKED ON BOTH SIDES, since only the joiner half was asked for: a join grows a quorum and it takes two nodes. Refusing a non-voting JOINER is the us-west-003 case. Refusing a non-voting TARGET is the same assertion read from the other end - a box declared non-voting that is serving add-learner is already holding a raft seat its own declaration forbids, and permitting there would paper over the contradiction. Both refusals name the role rather than the group when the groups match, because a message reading 'cross-group join refused: prod and prod' reads as a bug in the check.")
107//! @yah:handoff("THE DEFAULT IS THE LOAD-BEARING DECISION AND IT IS DELIBERATELY PERMISSIVE. An absent sovereign_role resolves to Voter (MachineConfig::sovereign_membership, the ONE place the Option is resolved). Reason: before this field, declaring a group WAS declaring quorum eligibility, so absence has to keep meaning that or the change silently retires six live voters. The permissiveness is bounded at the other end by cloud::validate::check_unroled_sovereign_members, which makes `yah cloud validate` FAIL on a group stamp with no role beside it - so the default can be reached by choice but not by silence. MachineConfig::sovereign_role stays Option<SovereignRole> (not a defaulted plain field) precisely so that lint can tell 'chose voter' from 'never considered it'.")
108//! @yah:handoff("NODE-SIDE BACK-COMPAT SEAM, pinned by a test because it is a decision and not an oversight: a peer answering GET /raft/status with a sovereign_group but NO sovereign_role key - every yubaba built between R742-F1 and R605-F12, which today is the entire prod raft - is read as Voter, not refused. Refusing would freeze a stamped cluster's growth until every member was rolled, strictly worse than what the role guards against, and it is the same degrade-toward-prior-behaviour stance the module already took for the group. Residue, named rather than hidden in read_group's doc: a box whose machine.toml says non-voter but whose daemon predates the flag answers 'voter' and the node gate admits it. judge_join refuses it camp-side, which is where operator-driven joins go. Window closes per-group as its nodes carry the flag.")
109//! @yah:handoff("FILES: workload-spec/src/sovereign.rs (SovereignRole + Membership + role-aware join_permitted, +227). cloud/src/config.rs (sovereign_role field, sovereign_membership(), judge_join same-group role branch, SovereignRole re-exported from cloud::config). cloud/src/validate.rs (check_unroled_sovereign_members + UnroledSovereignMember). app/yah/cli/src/cloud.rs (lint wired: ERROR in `yah cloud validate`, WARNING in the apply preflight - same split as inert-taint/retired-arch-tag, because an unwritten role changes no placement decision and the machine may be declared in a tree this camp does not own). yubaba/src/{sovereign_group,lib,main}.rs (--sovereign-role flag, ServerState.sovereign_role, /raft/status publishes it always-never-null, gate both directions). yubaba-test-harness/src/solo_node.rs (solo_node_with_sovereign_role). .yah/infra/machines/*.toml (7 files). xtask/tests/fleet_sovereign_groups.rs + fleet_build_placement.rs. W325 section 3d.")
110//! @yah:handoff("ONE BEHAVIOUR CHANGE WORTH A SECOND OPINION: a node started with --sovereign-role non-voter AND a --raft-node-id now refuses EVERY add-learner. I judged that correct - it is a contradiction the operator should see loudly - but the symptom is 'joins mysteriously stop working' rather than a startup refusal. main.rs warns loudly at boot when that pair is present; I did NOT make it fatal, because refusing to start could brick a node mid-roll. Reconsider if it bites.")
111//! @yah:handoff("NOT DONE, and it is a HARD GATE: .yah/schema/machine.toml.schema.json has NOT been regenerated, so sovereign_role is absent from it and schema-drift-guard (scripts/check-schema-drift.sh, a step in .yah/qed/check.toml, run by CI on every push) WILL FAIL. Fix is `cargo run -p xtask -- emit-schemas` from the repo root - it was queued behind ~7 concurrent peer cargo builds for the whole session. Nothing else is required to make this pushable.")
112//! @yah:handoff("ALSO NOT RE-CONFIRMED: `cargo test -p yah-cloud --lib` needs a clean run. Its last real run was 891 passed / 3 failed with all three failures belonging to R772's ingress-collate work and none to this ticket. The re-run is BLOCKED not failing - R555's in-flight AdmissionGrant.secrets field breaks velveteen-exec, and yah-cloud is not a root workspace member so its dev-deps only resolve from the oss/yubaba workspace where that break lives. Re-run from oss/yubaba once R555 lands.")
113//! @yah:verify("cargo run -p xtask -- emit-schemas (from repo root) -- wrote 8 files, exit 0 after an 18m24s build queued behind ~7 concurrent peer cargo jobs. .yah/schema/machine.toml.schema.json now carries the sovereign_role property (anyOf SovereignRole | null, with the full doc comment) and the SovereignRole definition as a oneOf over the two string enums voter / non-voter. The schema-drift-guard gate for THIS ticket is closed.")
114//! @yah:gotcha("emit-schemas IS ALL-OR-NOTHING AND WILL PICK UP A PEER'S UNCOMMITTED WORK. Running it to close this ticket's machine-schema drift also regenerated .yah/schema/secret.toml.schema.json (+34) from R555-F5's in-flight SecretAccess::Recipes / RecipeMatch source. That output is CORRECT for the tree as it stands and was not hand-edited, but it means the schema diff in the working tree is not purely R605-F12's: machine.toml.schema.json (+32) is this ticket, secret.toml.schema.json (+34) is R555. Told Ashguard:spade by party.chat so they carry it with their commit rather than regenerating on top. Anyone splitting these commits needs to split the schema diff too.")
115//! @yah:handoff("ALL GATES CLOSED as of 2026-08-20. Both items listed as outstanding in the earlier handoff notes are done: emit-schemas ran (machine.toml.schema.json carries sovereign_role + the SovereignRole voter/non-voter enum, drift guard satisfied), and cargo test -p yah-cloud --lib is 896 passed / 0 failed once R555 and R772 settled. 45 tests green across workload-spec (9), yubaba lib (13), yubaba raft integration (11), yah-cloud lib (10 of this ticket's, within 896), xtask fleet (2). Ready for review. NOTE for whoever commits: the working tree's schema diff is not purely this ticket - .yah/schema/machine.toml.schema.json (+32) is R605-F12, .yah/schema/secret.toml.schema.json (+34) is R555-F5, both correct generated output from one emit-schemas run. Ashguard:spade has agreed to carry theirs.")
116//! @yah:verify("cargo test -p yah-cloud --lib (from oss/yubaba) -- 896 passed, 0 FAILED, 4 ignored. The blocked check from earlier is now clean: R555 landed the velveteen-exec and TransformRecipe.secrets fixes, R772's ingress-collate work settled (they replaced the CloudConfig::load in collate_workspace_ingress with a narrower machines-only loader, so cross_ref_validate can no longer fail the collate over an unrelated provider typo). All 45 R605-F12 tests across the four crates are green simultaneously on one tree.")
117//! @yah:verify("Confirmed by NAME rather than by total, since a passing count proves nothing about which tests ran: cargo test -p yah-cloud --lib -- role voter voting lists all ten of this ticket's cloud tests green - a_non_voting_member_is_refused_into_its_own_group, a_non_voting_target_has_no_quorum_to_grow, a_refusal_names_the_group_when_fixing_the_role_would_not_help, an_unwritten_role_still_joins_its_group, a_non_voter_is_still_in_the_group_it_names, sovereign_role_round_trips_and_is_omitted_when_unwritten, a_group_with_no_role_is_reported_with_the_declaring_file, either_stated_role_is_clean, a_machine_in_no_group_is_not_asked_for_a_role, unroled_findings_are_ordered_by_file_so_output_is_stable.")
118
119use anyhow::{bail, Context, Result};
120use serde::{Deserialize, Serialize};
121use std::collections::{BTreeMap, HashMap};
122use std::path::Path;
123use thiserror::Error;
124use workload_spec::secrets::SecretAccess;
125use workload_spec::sovereign::Membership;
126pub use workload_spec::sovereign::SovereignRole;
127use workload_spec::{validate, LifecycleArchetype, TenantId, WorkloadSpec};
128
129/// Static node capacity declaration on `machine.toml` (R572-F3).
130///
131/// `memory_mb` and `cpu_millis` express the node's *total* hardware budget.
132/// F5's bin-packer subtracts the sum of committed workload requests from
133/// this floor to determine available headroom; an absent `allocatable`
134/// block means no capacity constraint is enforced (any workload fits).
135#[derive(Debug, Clone, Serialize, Deserialize)]
136#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
137pub struct NodeAllocatable {
138    /// Total physical RAM in mebibytes (e.g. 512 for a 512 MB node).
139    pub memory_mb: u32,
140    /// Total CPU in k8s millicores (1000 = 1 core, 250 = 0.25 CPU).
141    pub cpu_millis: u32,
142}
143
144/// `[registration]` — facts **observed** about a running box, written by the
145/// fleet rather than declared by an operator (R707-T1).
146///
147/// The rest of `machine.toml` is *declaration*: intent, operator-authored,
148/// reviewed and diffed like any other source. This block is the other half —
149/// what the box turned out to be once it booted and joined. Keeping the two
150/// apart is what lets the published fleet index (R707-F3) say which half it is
151/// carrying; publishing them under one schema would bake the confusion into a
152/// permanent record.
153///
154/// The split is a **provenance** boundary, not a trust or reach one:
155/// - *Declaration* answers "what did we ask for" — `name`, `region`, `arch`,
156///   `mesh_tags`, `[allocatable]`, and the declared reach in [`ConnectSpec`].
157/// - *Registration* answers "what did we observe" — the hostkey TOFU'd at
158///   attach, the mesh address headscale assigned at join.
159///
160/// It stays in the git-tracked TOML on purpose. Registration is not local
161/// scratch state: every consumer needs the mesh address to dial a node, so it
162/// has to travel with the declaration. (`.yah/infra/state/machines/<name>.json`
163/// — [`crate::state::MachineState`] — remains the *gitignored* sidecar for
164/// provider-side derivatives that nobody but this camp needs.)
165#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
166#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
167pub struct MachineRegistration {
168    /// Yubaba's ed25519 `/identity` fingerprint, TOFU-recorded by
169    /// `yah cloud machine attach` on first contact (`SHA256:…`). An observed
170    /// property of a running process — not the operator's intent — which is
171    /// why it moved out of the top level here.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub hostkey_fingerprint: Option<String>,
174    /// Mesh (headscale/tailnet) IPv4 assigned at join, e.g. `"100.64.0.1"`.
175    /// Bare address, not a URL: the *port* is declared reach and lives on
176    /// [`ConnectSpec::yubaba_port`]. [`MachineConfig::yubaba_url`] composes the
177    /// two. Absent until the node has joined the mesh.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub mesh_ipv4: Option<String>,
180    /// RFC3339 timestamp of the mesh join that produced `mesh_ipv4`. Free-form
181    /// audit; nothing keys off it.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub joined_at: Option<String>,
184}
185
186impl MachineRegistration {
187    /// True when nothing has been observed yet — used to omit the whole
188    /// `[registration]` table from a serialized machine TOML.
189    pub fn is_empty(&self) -> bool {
190        self.hostkey_fingerprint.is_none() && self.mesh_ipv4.is_none() && self.joined_at.is_none()
191    }
192}
193
194/// Per-machine TOML from `.yah/infra/machines/<name>.toml`.
195///
196/// Two halves, split by provenance (R707-T1): everything here is *declaration*
197/// — operator intent under review and blame — except [`registration`], which
198/// carries what the fleet observed. See [`MachineRegistration`] for why the
199/// boundary is drawn there and what depends on it.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
202pub struct MachineConfig {
203    pub name: String,
204    pub provider: String,
205    /// Who the hardware actually comes from (`"ovh"`, `"vultr"`, `"on-prem"`).
206    ///
207    /// Deliberately *not* [`provider`](Self::provider), which selects the
208    /// auto-provision driver: a box we rented by hand and brought up over SSH
209    /// is `provider = "static"` for its whole life, and writing the vendor
210    /// there instead would flip it driver-backed and make
211    /// [`validate`](Self::validate) demand `location` + `server_type` it has no
212    /// answer for. The two axes genuinely differ — vendor is who bills you,
213    /// `provider` is who yah can call an API against.
214    ///
215    /// Worth recording because vendor-scoped policy is invisible in every other
216    /// field and decides real work: outbound port 25, rDNS/PTR control, IP
217    /// reputation, egress billing. It survived only in TOML prose until now,
218    /// which made it ungreppable at exactly the moment you need it.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub vendor: Option<String>,
221    /// Human label for the box (`"gamer"`, `"the GEEKOM"`). Free-form and never
222    /// matched on — [`name`](Self::name) stays the identity everywhere. This is
223    /// only so operators and agents can say which box they mean out loud.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub nickname: Option<String>,
226    /// Provider DC code (e.g. Hetzner `"hil"`). **Provisioning-only**: required
227    /// iff the provider has an auto-provision driver ([`provider_has_machine_driver`]);
228    /// a BYO `static` node we brought up over SSH has no such code. Optional at
229    /// load time so static machine.tomls omit it; [`MachineConfig::validate`]
230    /// enforces presence at the right moment for driver-backed providers.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub location: Option<String>,
233    /// Provider SKU/size (e.g. Hetzner `"ccx13"`). Provisioning-only, same
234    /// optionality contract as [`location`](Self::location).
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub server_type: Option<String>,
237    /// **Deprecated (R330-F16).** A machine should describe *itself* (region,
238    /// zone, provider, mesh_tags); *which* mirrors run on it is derived by the
239    /// reconciler from each mirror's `required` placement spec, not declared
240    /// here. Now optional + omitted-when-empty so new machine.tomls leave it
241    /// out. The legacy `resolve_mirror_machine` topology fallback still reads
242    /// it until yubaba's reverse-index supersedes the topology.toml path; once
243    /// that lands, this field and its readers are removed wholesale.
244    #[serde(default, skip_serializing_if = "Vec::is_empty")]
245    pub hosts_mirrors: Vec<String>,
246    pub mesh_tags: Vec<String>,
247    /// Canonical geo region label (latency axis), e.g. `"us-west"`. F16's three
248    /// topology axes are orthogonal: `region` = geo (latency), `zone` = failure
249    /// domain within a region (HA), `provider` = network/cost. `region` is
250    /// distinct from `location` (the provider's DC code, e.g. Hetzner `"hil"`):
251    /// `location` is provider-scoped, `region` is our provider-neutral label.
252    /// Optional for backward-compat; a machine without it never satisfies a
253    /// `required.regions` constraint.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub region: Option<String>,
256    /// Failure-domain label within a region (HA axis), e.g. `"hil"`. For
257    /// single-DC Hetzner this typically mirrors `location`. F16 placement
258    /// matches `required.zones` against this. Optional for backward-compat.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub zone: Option<String>,
261    /// Declared CPU architecture (`"x86_64"` / `"aarch64"`). A machine has
262    /// exactly one — it's a first-class property of the box, not a reach
263    /// detail and not a mesh tag. Drives the yubaba release triple. Optional
264    /// only because there's no provider API to probe it (static nodes declare
265    /// it; a driver-backed provider may leave it unset until known).
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub arch: Option<String>,
268    pub bucket: Option<BucketSpec>,
269    /// **Legacy location, superseded by `[registration].hostkey_fingerprint`**
270    /// (R707-T1). Still deserialized so machine TOMLs written before the split
271    /// keep parsing; never *read* directly — go through
272    /// [`MachineConfig::hostkey_fingerprint`], which prefers the registration
273    /// block. [`MachineConfig::normalize`] folds this into `registration`, and
274    /// [`MachineConfig::save`] normalizes before writing, so a load→save cycle
275    /// migrates the file rather than dropping the value.
276    #[serde(
277        rename = "hostkey_fingerprint",
278        default,
279        skip_serializing_if = "Option::is_none"
280    )]
281    pub legacy_hostkey_fingerprint: Option<String>,
282    /// Provider-side SSH-key IDs (Hetzner: from `GET /v1/ssh_keys`)
283    /// authorized for `root` at create time. Defaults to empty for
284    /// backwards-compat with existing machine declarations; an empty
285    /// list yields a Hetzner-emailed random root password (which the
286    /// driver currently discards). Populate this when you want pre-mesh
287    /// SSH access for bootstrap deploys or recovery.
288    #[serde(default, skip_serializing_if = "Vec::is_empty")]
289    pub ssh_keys: Vec<u64>,
290    /// Cloudflare Tunnel ID this machine joins (e.g. `abc123.cfargotunnel.com`).
291    /// `None` → no tunnel (mesh-only node, no public ingress).
292    /// When set, `yah cloud machine provision` reads `cloudflare-tunnel-token`
293    /// from the keys vault and injects the cloudflared install block into
294    /// cloud-init so the new machine connects to CF edge on first boot.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub cloudflared: Option<String>,
297    /// When `true`, this machine hosts operator-bridge workloads (Tailscale
298    /// operator access to mesh-internal services). `yah cloud machine provision`
299    /// will install tailscaled and run `tailscale up` during cloud-init via the
300    /// `{{OPERATOR_BRIDGE_BLOCK}}` placeholder. Defaults to `false` for
301    /// backward-compat with existing machine declarations.
302    #[serde(default)]
303    pub hosts_operator_bridge: bool,
304    /// BYO `static`-node reach descriptor. Static nodes have no provider API to
305    /// probe, so how the camp reaches them (SSH user@host + the yubaba URL,
306    /// which is loopback until the WireGuard mesh lands) is *declared* here.
307    /// `None` for driver-backed providers (Hetzner/Vultr), whose address is
308    /// resolved from the provider API / mesh at provision time.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub connect: Option<ConnectSpec>,
311    /// Static node capacity (R572-F3). Declares the node's total hardware
312    /// budget; F5's scheduler subtracts committed workload requests from this
313    /// to check whether a new workload fits. Absent means unconstrained.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub allocatable: Option<NodeAllocatable>,
316    /// Placement taint keys (R572-F3). **There is no toleration** — a
317    /// `no-<archetype>` taint is an absolute block, not a preference
318    /// (W305/R742-T4; the pre-2026-08-11 "repel-unless-tolerate" wording here
319    /// described an `unless` that was never built).
320    ///
321    /// A key in this list influences placement in exactly one of two ways, and
322    /// [`taint_effect`] is the authority on which:
323    ///
324    /// - **repulsion** — `"no-server"` / `"no-appliance"` / `"no-job"` reject
325    ///   workloads of that [`LifecycleArchetype`] outright;
326    /// - **affinity** — a key in [`AFFINITY_TAINT_KEYS`] (today just
327    ///   `"public-ip"`) that a workload names in
328    ///   `yah.placement.requires-taint`, which then *requires* this node.
329    ///
330    /// Anything else is **inert**: it parses, it round-trips, and no scheduler
331    /// decision can ever read it. `yah cloud validate` rejects such keys
332    /// (`validate::check_inert_taints`) rather than letting them sit looking
333    /// load-bearing — which is how `no-voter` spent months asserting a
334    /// falsehood on three nodes. Facts about a node that are not placement
335    /// inputs belong in [`mesh_tags`](Self::mesh_tags) or a comment.
336    #[serde(default, skip_serializing_if = "Vec::is_empty")]
337    pub taints: Vec<String>,
338    /// Which consensus group this node belongs to — W305/R742-F1. `None` means
339    /// standalone: in no group at all, which is us-west-002 and us-west-015.
340    ///
341    /// Membership is not by itself quorum eligibility; that is
342    /// [`sovereign_role`](Self::sovereign_role), added by R605-F12 because
343    /// us-west-003 is in prod's blast radius *and* must never vote in it.
344    ///
345    /// **Not a placement input.** It is deliberately absent from
346    /// [`RequiredSpec::matches`], and adding it there would be a category
347    /// error: a sovereign group is a *blast radius*, not a filter. Nothing
348    /// about "which quorum does this box vote in" should decide where a
349    /// workload runs — that is what made the fleet express three unrelated
350    /// properties through one taint list and get all three wrong (W305).
351    ///
352    /// What it *is* for is refusal. [`judge_join`] answers "may this node join
353    /// that node's cluster", and the answer is no unless both declare the same
354    /// group. Before this field the only guard was a comment in three machine
355    /// TOMLs saying "never run a raft join against this box from a shell
356    /// pointed at prod" — habit, with no mechanism behind it, which is the
357    /// same class of guard W257 §8 admitted to.
358    ///
359    /// # Why `sovereign_group` and not `raft_group`
360    ///
361    /// Raft is today's mechanism (operator, 2026-08-10). A field named for the
362    /// mechanism goes stale the day the mechanism is swapped, and every
363    /// consumer that reads it inherits the lie. `sovereign` names what the
364    /// group *has* — its own authority, its own upgrade cadence, its own
365    /// destruction — which stays true under any consensus protocol.
366    ///
367    /// Note the word already appears in this tree as prose (W267's title, the
368    /// `IngressProvider::Passway` doc comment's "sovereign edge"). That is an
369    /// adjective meaning "self-hosted, not SaaS"; this is the first time it
370    /// carries structure.
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub sovereign_group: Option<String>,
373    /// Whether this node may hold a seat in its group's quorum — R605-F12.
374    /// Meaningless without [`sovereign_group`](Self::sovereign_group): a
375    /// standalone box has no quorum to be eligible for.
376    ///
377    /// **`None` is "not written", not a third role.** Read it through
378    /// [`sovereign_membership`](Self::sovereign_membership), which resolves the
379    /// absence to [`SovereignRole::Voter`] — what declaring a group has always
380    /// meant, so the six nodes stamped before this field keep their seats
381    /// without an edit. The distinction is kept only so
382    /// [`crate::validate::check_unroled_sovereign_members`] can tell an
383    /// operator who *chose* voter from one who never considered the question;
384    /// no join decision reads the `Option` directly.
385    ///
386    /// # Why this is not a taint
387    ///
388    /// It was, once: `no-voter` sat in [`taints`](Self::taints) on three nodes
389    /// for months, read by nothing, and R742-T4 removed it because the taint
390    /// list is a *placement* vocabulary and this is not a placement input (see
391    /// [`taint_effect`]). Nor is it a second group label. It is a modifier on
392    /// the membership this node already declares, which is why it lives beside
393    /// the group and is judged with it in one predicate,
394    /// [`workload_spec::sovereign::join_permitted`].
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub sovereign_role: Option<SovereignRole>,
397    /// `[registration]` — the observed half (R707-T1). Empty until the box has
398    /// been attached / mesh-joined. See [`MachineRegistration`].
399    #[serde(default, skip_serializing_if = "MachineRegistration::is_empty")]
400    pub registration: MachineRegistration,
401}
402
403/// True iff `provider` has an auto-provision driver (create/destroy via API).
404/// Driver-backed providers require `location` + `server_type`; BYO `static`
405/// nodes (brought up over SSH) do not. The cloud-vs-vps distinction the fleet
406/// cares about lives here — at the provider-capability layer — not as a
407/// separate machine type (W242 BYO Phase-0 decision).
408pub fn provider_has_machine_driver(provider: &str) -> bool {
409    matches!(provider, "hetzner" | "vultr" | "digitalocean")
410}
411
412/// Taint keys a workload may name in `yah.placement.requires-taint` to
413/// *require* a node (W305/R742-T4 affinity vocabulary).
414///
415/// This is a closed list on purpose. `WorkloadSpec::requires_taint` returns
416/// free text, but every producer in the tree is code — `passway_ingress.rs`
417/// and `cloudflared_ingress.rs`, both emitting
418/// [`workload_spec::PUBLIC_IP_TAINT`] — and no on-disk `workload.toml` sets the
419/// annotation at all. So the set of keys a node can usefully carry for
420/// affinity is knowable at compile time, which is what lets
421/// [`taint_effect`] call anything outside it inert instead of guessing.
422///
423/// **Adding an affinity key means adding it here**, in the same change that
424/// teaches a workload to require it. That coupling is the point: it makes the
425/// node side and the workload side impossible to land apart.
426pub const AFFINITY_TAINT_KEYS: &[&str] = &[workload_spec::PUBLIC_IP_TAINT];
427
428/// How a key in [`MachineConfig::taints`] can affect placement.
429///
430/// W305 finding 1: before R742-T4 nothing asked this question, so a key that
431/// no scheduler path could read — `"qa"`, `"no-voter"` — parsed, validated,
432/// and quietly did nothing. Both of the findings that cost real fleet state
433/// were invisible for exactly that reason.
434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435pub enum TaintEffect {
436    /// `"no-<archetype>"`: rejects workloads of that archetype outright. Read
437    /// by [`RequiredSpec::matches`] via `repel_archetype`.
438    Repels(LifecycleArchetype),
439    /// A key in [`AFFINITY_TAINT_KEYS`]: a workload naming it in
440    /// `yah.placement.requires-taint` is restricted to nodes carrying it.
441    Attracts,
442    /// Neither. No placement decision can read this key.
443    Inert,
444}
445
446/// Classify one node taint key. See [`TaintEffect`].
447///
448/// The repulsion half is derived from [`LifecycleArchetype::ALL`] rather than
449/// a literal list, so a fourth archetype makes `no-<its key>` live without an
450/// edit here.
451pub fn taint_effect(key: &str) -> TaintEffect {
452    if let Some(arch) = LifecycleArchetype::ALL
453        .into_iter()
454        .find(|a| key == format!("no-{}", a.taint_key()))
455    {
456        return TaintEffect::Repels(arch);
457    }
458    if AFFINITY_TAINT_KEYS.contains(&key) {
459        return TaintEffect::Attracts;
460    }
461    TaintEffect::Inert
462}
463
464/// Every key the scheduler *can* act on, sorted — for error messages that
465/// tell the operator what the legal vocabulary actually is instead of only
466/// what was wrong.
467pub fn live_taint_keys() -> Vec<String> {
468    let mut keys: Vec<String> = LifecycleArchetype::ALL
469        .into_iter()
470        .map(|a| format!("no-{}", a.taint_key()))
471        .chain(AFFINITY_TAINT_KEYS.iter().map(|k| (*k).to_string()))
472        .collect();
473    keys.sort();
474    keys
475}
476
477/// What [`judge_join`] decided about one proposed cluster join.
478///
479/// Shaped like yubaba's `PromotionVerdict` / `GeographyVerdict` and for the
480/// same reason: the rule stays unit-testable without a live cluster, and a
481/// refusal carries its reason from the place that knows it.
482#[derive(Debug, Clone, PartialEq, Eq)]
483pub enum JoinVerdict {
484    /// Both nodes declare the same sovereign group and both are voters. The
485    /// join is within one blast radius and grows a quorum both sides are
486    /// eligible for.
487    Permit,
488    /// The join is refused. Carries an operator-readable reason naming both
489    /// declared values and the file to edit — a refusal that only says
490    /// "invalid" gets worked around rather than fixed.
491    Refuse(String),
492}
493
494/// May `joiner` join the cluster `target` belongs to? — W305/R742-F1.
495///
496/// **A join is permitted iff both nodes declare the same non-`None`
497/// [`sovereign_group`](MachineConfig::sovereign_group) and both are
498/// [`SovereignRole::Voter`].** One rule, no special cases, and it makes the
499/// declaration mandatory before any quorum grows.
500///
501/// The case this exists for is two *different* declared groups: joining a dev
502/// Pi into prod is refused rather than trusted, where today the only guard is
503/// a comment saying not to do it. But an undeclared node is refused too, and
504/// that is the deliberate half — `None` means "in no group", not "unknown", so
505/// growing prod with an unstamped box is exactly as much a cross-group join as
506/// the dev case is. Failing open there would leave the operator believing a
507/// guarantee that was never evaluated, which is the reasoning
508/// `QuorumGeography::judge` already applies to untagged voters.
509///
510/// No legitimate flow pays for that strictness: prod and dev are both stamped,
511/// and us-west-002/015 are deliberately in no group at all. Adding a real
512/// member means declaring it first, which is the point.
513///
514/// # The non-voting refusal (R605-F12)
515///
516/// Same group and still refused, when either side declares
517/// [`SovereignRole::NonVoter`]. This is the case a group label alone could not
518/// express. us-west-003 is a residential-uplink build box the operator counts
519/// as part of prod — same secrets, same upgrade cadence, same destruction — and
520/// which must never hold a prod raft seat, because a home-internet partition
521/// should not be able to stall the quorum. Until R605-F12 the only thing
522/// refusing it was its *absent* stamp, so recording the operator's real intent
523/// (`sovereign_group = "prod"`) would have removed the guard. Now the intent
524/// and the guard are the same two lines.
525///
526/// Note what this is not: the refusal here is about *voting*, and it says
527/// nothing about the mesh. One mesh spans the whole fleet regardless of group
528/// or role (operator, 2026-08-19); a non-voter is reachable, schedulable and
529/// rollable like any other node.
530///
531/// This is the **camp-side** rendering of the rule. The predicate itself lives
532/// in [`workload_spec::sovereign::join_permitted`] because yubaba's
533/// `POST /raft/add-learner` gate asks the same question and cannot see this
534/// crate (there is deliberately no yubaba → cloud edge). Only the prose is
535/// duplicated, and it has to be: a refusal here names
536/// `.yah/infra/machines/<name>.toml`, while the node-side one has no machine
537/// name in hand and must also name `yubaba serve --sovereign-group`.
538///
539/// The node-side gate is *narrower* on purpose, and the difference is worth
540/// knowing when reading either: a daemon started without `--sovereign-group`
541/// has declared nothing rather than declared standalone, so yubaba resolves
542/// that unknown before it judges, and its gate is in force only once the
543/// cluster being joined declares a group. See `yubaba::sovereign_group`.
544pub fn judge_join(joiner: &MachineConfig, target: &MachineConfig) -> JoinVerdict {
545    let stamp_hint = |m: &MachineConfig| {
546        format!(
547            "declare `sovereign_group = \"<group>\"` in .yah/infra/machines/{}.toml",
548            m.name
549        )
550    };
551    let role_hint = |m: &MachineConfig| {
552        format!(
553            "set `sovereign_role = \"voter\"` in .yah/infra/machines/{}.toml",
554            m.name
555        )
556    };
557    if workload_spec::sovereign::join_permitted(
558        joiner.sovereign_membership(),
559        target.sovereign_membership(),
560    ) {
561        return JoinVerdict::Permit;
562    }
563    let (j, t) = (
564        joiner.sovereign_group.as_deref(),
565        target.sovereign_group.as_deref(),
566    );
567    // Everything below is a refusal; the only permitted shape returned above.
568    //
569    // R605-F12: when both sides name the SAME group, the role is the only thing
570    // left that can have refused, and it gets its own message. Falling through
571    // to the arms below would print "cross-group join refused: 'us-west-003' is
572    // in "prod" and 'us-west-001' is in "prod"" — a message that reads as a bug
573    // in the check rather than a decision about the fleet.
574    //
575    // Deliberately not hoisted above the group comparison. A non-voting joiner
576    // whose target is standalone is refused for *both* reasons, and naming the
577    // role there would send the operator to fix a field that would not have
578    // made the join legal anyway.
579    if let (Some(a), Some(b)) = (j, t) {
580        if a == b {
581            for (m, side, other) in [
582                (joiner, "the joiner", &target.name),
583                (target, "the target", &joiner.name),
584            ] {
585                if m.sovereign_membership().role.is_voter() {
586                    continue;
587                }
588                return JoinVerdict::Refuse(format!(
589                    "join refused: {side} '{}' is a NON-VOTING member of sovereign group {a:?}, \
590                     the same group as '{other}'. It is inside that blast radius — same secrets, \
591                     same upgrade cadence, same destruction — but declares itself ineligible for \
592                     the quorum, so this is refused by declaration rather than by omission. If it \
593                     should genuinely vote, {}; if it should not, this refusal is the field doing \
594                     its job and the join is the thing to reconsider.",
595                    m.name,
596                    role_hint(m),
597                ));
598            }
599        }
600    }
601    match (j, t) {
602        (Some(a), Some(b)) => JoinVerdict::Refuse(format!(
603            "cross-group join refused: '{}' is in sovereign group {a:?} and '{}' is in {b:?}. \
604             These are separate blast radii — separate quorums, separate upgrade cadences, \
605             separately destroyable — and merging them is not something a join can undo. If \
606             the move is genuinely intended, restamp '{}' to {b:?} first and treat it as \
607             leaving its old group.",
608            joiner.name,
609            target.name,
610            joiner.name,
611        )),
612        (None, Some(b)) => JoinVerdict::Refuse(format!(
613            "join refused: '{}' declares no sovereign_group, so it is standalone — in no \
614             group — while '{}' is in {b:?}. That is a cross-group join, not an unchecked \
615             one. To make '{}' a member of {b:?}, {}.",
616            joiner.name,
617            target.name,
618            joiner.name,
619            stamp_hint(joiner),
620        )),
621        (Some(a), None) => JoinVerdict::Refuse(format!(
622            "join refused: '{}' is in sovereign group {a:?} but '{}' declares none, so the \
623             target is standalone and has no group to join. Either {}, or found the group on \
624             '{}' rather than growing it.",
625            joiner.name,
626            target.name,
627            stamp_hint(target),
628            joiner.name,
629        )),
630        (None, None) => JoinVerdict::Refuse(format!(
631            "join refused: neither '{}' nor '{}' declares a sovereign_group, so this join \
632             would form a group nobody declared and nothing could later reason about. Name \
633             the group on both boxes first: {}, and the same for '{}'.",
634            joiner.name,
635            target.name,
636            stamp_hint(joiner),
637            target.name,
638        )),
639    }
640}
641
642impl MachineConfig {
643    /// This node's declared place in a sovereign group, as the shared join rule
644    /// wants it — R605-F12.
645    ///
646    /// The one place `sovereign_role`'s `None` is resolved. Absence means
647    /// [`SovereignRole::Voter`], which is what declaring a group meant before
648    /// the role existed; resolving it here rather than at each call site is what
649    /// keeps the camp-side and node-side gates from disagreeing about a node
650    /// that never wrote the field.
651    pub fn sovereign_membership(&self) -> Membership<'_> {
652        Membership {
653            group: self.sovereign_group.as_deref(),
654            role: self.sovereign_role.unwrap_or_default(),
655        }
656    }
657
658    /// Provider DC code, or `""` when omitted (static nodes). Most readers want
659    /// a `&str`; the driver-backed provision/status paths still go through
660    /// [`validate`](Self::validate) which guarantees presence for those.
661    pub fn location(&self) -> &str {
662        self.location.as_deref().unwrap_or("")
663    }
664
665    /// Provider SKU, or `""` when omitted (static nodes).
666    pub fn server_type(&self) -> &str {
667        self.server_type.as_deref().unwrap_or("")
668    }
669
670    /// Enforce the provisioning-only-field contract: a machine whose provider
671    /// has an auto-provision driver MUST declare `location` + `server_type`
672    /// (the driver can't create a server without them). Static nodes may omit
673    /// both. Call this before any provision/diff that assumes a driver.
674    pub fn validate(&self) -> Result<()> {
675        if provider_has_machine_driver(&self.provider) {
676            if self.location.is_none() {
677                anyhow::bail!(
678                    "machine '{}' (provider '{}') has an auto-provision driver but no `location`",
679                    self.name,
680                    self.provider
681                );
682            }
683            if self.server_type.is_none() {
684                anyhow::bail!(
685                    "machine '{}' (provider '{}') has an auto-provision driver but no `server_type`",
686                    self.name,
687                    self.provider
688                );
689            }
690        }
691        Ok(())
692    }
693
694    /// Declared taints that no placement decision can read (W305/R742-T4).
695    ///
696    /// Deliberately **not** folded into [`validate`](Self::validate): that
697    /// guard runs on the provision/diff hot path and answers a different
698    /// question (can the driver create this server). An inert taint is a lint
699    /// — it never breaks an operation in flight, it just means the file is
700    /// asserting something the scheduler will not honour. `yah cloud validate`
701    /// is where the operator asks for that judgement; see
702    /// [`crate::validate::check_inert_taints`].
703    pub fn inert_taints(&self) -> Vec<&str> {
704        self.taints
705            .iter()
706            .filter(|t| taint_effect(t) == TaintEffect::Inert)
707            .map(String::as_str)
708            .collect()
709    }
710
711    /// Yubaba's TOFU'd hostkey fingerprint, from `[registration]` and falling
712    /// back to the pre-R707-T1 top-level field. **The only read path** — a
713    /// caller that reaches for `legacy_hostkey_fingerprint` directly sees
714    /// `None` on every migrated machine.
715    pub fn hostkey_fingerprint(&self) -> Option<&str> {
716        self.registration
717            .hostkey_fingerprint
718            .as_deref()
719            .or(self.legacy_hostkey_fingerprint.as_deref())
720    }
721
722    /// Record (or clear) the observed hostkey fingerprint. Writes
723    /// `[registration]` and drops any pre-R707-T1 top-level value, so the two
724    /// locations can never disagree after a writeback.
725    pub fn set_hostkey_fingerprint(&mut self, fingerprint: Option<String>) {
726        self.registration.hostkey_fingerprint = fingerprint;
727        self.legacy_hostkey_fingerprint = None;
728    }
729
730    /// Mesh (tailnet) IPv4 for this node, or `None` pre-mesh.
731    ///
732    /// Prefers `[registration].mesh_ipv4`; falls back to the host of a legacy
733    /// `[connect].yubaba` URL when that host is in the `100.64.0.0/10` CGNAT
734    /// range the mesh uses. A loopback placeholder (`http://127.0.0.1:7443`,
735    /// meaning "pre-mesh, reachable only through an SSH tunnel") is *not* a
736    /// mesh address and yields `None`.
737    pub fn mesh_ipv4(&self) -> Option<&str> {
738        if let Some(ip) = self.registration.mesh_ipv4.as_deref() {
739            return Some(ip);
740        }
741        let url = self.connect.as_ref()?.yubaba.as_deref()?;
742        mesh_ipv4_from_url(url)
743    }
744
745    /// Base URL for this node's yubaba, or `None` when no reach resolves.
746    ///
747    /// Thin wrapper over [`reach`](Self::reach) for the many call sites that
748    /// only branch on presence. Prefer `reach` anywhere the operator sees the
749    /// outcome — a `None` here throws away a refusal that names exactly which
750    /// address is missing.
751    pub fn yubaba_url(&self) -> Option<String> {
752        self.reach().ok()
753    }
754
755    /// The **one** address automation dials for this node — mesh-only.
756    ///
757    /// `Err` is a *named refusal*, not an absence: a node with no mesh address
758    /// is unresolvable to every automated path, and R605-T10's whole complaint
759    /// is that this used to surface as a connect timeout against an address the
760    /// caller has no route to.
761    ///
762    /// Resolution order:
763    ///
764    /// 1. A declared `[connect].yubaba` on a **private** host (10/8,
765    ///    172.16/12, 192.168/16) is **not dialed** — see below.
766    /// 2. Any other declared `[connect].yubaba` wins verbatim. That includes
767    ///    the pre-mesh loopback placeholder (`http://127.0.0.1:7443`, "I have
768    ///    no mesh address; reach me through the SSH tunnel to `ssh`"), which is
769    ///    a genuine declaration and stays honoured.
770    /// 3. Otherwise `[registration].mesh_ipv4` composed with
771    ///    `[connect].yubaba_port`.
772    ///
773    /// **Why a LAN literal loses (R605-T10, operator 2026-08-19).** The LAN
774    /// address is an emergency break-glass route, never an official one, and
775    /// automation must ALWAYS assume the caller is not on that LAN — this camp
776    /// sits on 192.168.22.0/22 with no route to the fleet's 192.168.10.0/24 at
777    /// all. Writing one into the field every resolver dials does not sit beside
778    /// the mesh route, it *overrides* it: R707-T6 made a declared literal beat
779    /// `mesh_ipv4` outright, so us-west-011 (mesh-joined, healthy) was elected
780    /// for every aarch64 build and then dialed at an address that answers only
781    /// from inside bldg-2506.
782    ///
783    /// **What R707-T6 wanted is preserved elsewhere.** Its forcing case was
784    /// identity, not reach: the dev raft group advertises LAN addrs
785    /// (`192.168.10.11:7443`, verified live off `/raft/status` 2026-08-27), and
786    /// `rollout::yubaba::membership_to_nodes` has to map those back to declared
787    /// machines. That match now runs against [`lan_endpoint`](Self::lan_endpoint),
788    /// which is composed from the break-glass `[connect].address` metadata and
789    /// is never dialed — so the two concerns the old precedence rule fused are
790    /// split, and the literal can stop squatting a dialed field.
791    ///
792    /// The LAN address itself STAYS in the machine TOML. It is useful metadata
793    /// and the manual `ssh` path is entitled to it; it is only disconnected
794    /// from every automated process.
795    pub fn reach(&self) -> Result<String, String> {
796        let Some(connect) = self.connect.as_ref() else {
797            return Err(format!(
798                "machine {:?} declares no [connect] block, so nothing knows how to reach it \
799                 \u{2192} declare one, or leave it unprovisioned and out of placement",
800                self.name
801            ));
802        };
803        let mesh = || {
804            self.registration
805                .mesh_ipv4
806                .as_deref()
807                .map(|ip| format!("http://{ip}:{}", connect.yubaba_port()))
808        };
809        if let Some(literal) = &connect.yubaba {
810            let Some(lan) = private_ipv4_from_url(literal) else {
811                return Ok(literal.clone());
812            };
813            return mesh().ok_or_else(|| {
814                format!(
815                    "machine {:?} is unresolvable to automation: its only declared yubaba reach \
816                     is the private literal {:?} and it has no [registration].mesh_ipv4\n\
817                     \u{2192} a LAN address is an emergency break-glass route, never an official \
818                     one (R605-T10) — every automated path assumes the caller is NOT on {}/24\n\
819                     \u{2192} mesh-join the box and record `mesh_ipv4` under [registration], then \
820                     delete `[connect].yubaba` so the port composes with it",
821                    self.name,
822                    literal,
823                    lan.rsplit_once('.').map(|(net, _)| net).unwrap_or(lan),
824                )
825            });
826        }
827        mesh().ok_or_else(|| {
828            format!(
829                "machine {:?} has no [registration].mesh_ipv4 and declares no \
830                 [connect].yubaba, so no automated path can reach it\n\
831                 \u{2192} mesh-join the box and record its tailnet address, or taint it out of \
832                 placement — do not point `[connect].yubaba` at a LAN address (R605-T10)",
833                self.name
834            )
835        })
836    }
837
838    /// The LAN `host:port` this node's yubaba answers on, composed from the
839    /// break-glass `[connect].address` metadata plus the declared port.
840    ///
841    /// **Identity only — never dial this.** It exists so a raft membership
842    /// entry that names a node by its LAN address can be mapped back to the
843    /// declared machine (`rollout::yubaba::membership_to_nodes`) without that
844    /// address having to live in a field a resolver reads. `None` when the
845    /// machine is unprovisioned.
846    pub fn lan_endpoint(&self) -> Option<String> {
847        let connect = self.connect.as_ref()?;
848        Some(format!("{}:{}", connect.address, connect.yubaba_port()))
849    }
850
851    /// Fold the pre-R707-T1 top-level `hostkey_fingerprint` into
852    /// `[registration]`, and lift a mesh IP out of a legacy `[connect].yubaba`
853    /// URL. Idempotent; a machine already on the split shape is untouched.
854    ///
855    /// [`save`](Self::save) calls this, so writing a machine TOML migrates it
856    /// rather than round-tripping the old shape back out.
857    pub fn normalize(&mut self) {
858        if let Some(fp) = self.legacy_hostkey_fingerprint.take() {
859            self.registration.hostkey_fingerprint.get_or_insert(fp);
860        }
861        if self.registration.mesh_ipv4.is_none() {
862            if let Some(ip) = self
863                .connect
864                .as_ref()
865                .and_then(|c| c.yubaba.as_deref())
866                .and_then(mesh_ipv4_from_url)
867                .map(str::to_string)
868            {
869                self.registration.mesh_ipv4 = Some(ip);
870                // The URL was pure derivation from mesh IP + port; keep only
871                // the declared half so the two can't drift apart.
872                if let Some(c) = self.connect.as_mut() {
873                    c.yubaba = None;
874                }
875            }
876        }
877    }
878
879    /// Persist to `<cloud_dir>/machines/<name>.toml`, creating the dir if needed.
880    ///
881    /// ⚠ Serializes the struct, so **operator comments in the target file are
882    /// lost**. Pre-existing behaviour, not introduced here, but it is why
883    /// registration writeback (`yah cloud machine attach`) goes through
884    /// [`crate::state::MachineState`] and the comment-preserving path in the
885    /// CLI rather than calling this on a hand-authored inventory file.
886    pub fn save(&self, cloud_dir: &Path) -> Result<()> {
887        let dir = cloud_dir.join("machines");
888        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
889        let path = dir.join(format!("{}.toml", self.name));
890        let mut normalized = self.clone();
891        normalized.normalize();
892        let s = toml::to_string_pretty(&normalized)
893            .with_context(|| format!("serializing machine {}", self.name))?;
894        std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
895    }
896}
897
898/// Host of an `http://host:port` URL iff it is a mesh (headscale) IPv4 in the
899/// `100.64.0.0/10` CGNAT range. String-level rather than URL-parsed: the
900/// inventory format is stable and this crate carries no URL dependency (same
901/// reasoning as `fleet_metrics::extract_host` and
902/// `hub::coordinator::is_loopback_url`).
903fn mesh_ipv4_from_url(url: &str) -> Option<&str> {
904    let host = ipv4_host_of(url)?;
905    let ip: std::net::Ipv4Addr = host.parse().ok()?;
906    let [a, b, ..] = ip.octets();
907    // 100.64.0.0/10 ⇒ first octet 100, second octet 64..=127.
908    (a == 100 && (64..=127).contains(&b)).then_some(host)
909}
910
911/// Host of an `http://host:port` URL iff it is an **RFC1918 private** IPv4 —
912/// `10/8`, `172.16/12`, `192.168/16`. `None` for anything else, loopback and
913/// the `100.64/10` mesh range included: neither is a LAN literal.
914///
915/// The judgement R605-T10 turns on. A private literal is only ever reachable
916/// from inside one building, so it is metadata about where the box physically
917/// sits and never an address automation may dial — see
918/// [`MachineConfig::reach`] and [`crate::validate::check_lan_dial_targets`].
919pub fn private_ipv4_from_url(url: &str) -> Option<&str> {
920    let host = ipv4_host_of(url)?;
921    is_private_ipv4(host).then_some(host)
922}
923
924/// Whether a bare host string is an RFC1918 private IPv4 literal.
925pub fn is_private_ipv4(host: &str) -> bool {
926    let Ok(ip) = host.parse::<std::net::Ipv4Addr>() else {
927        return false;
928    };
929    ip.is_private()
930}
931
932/// Bare host of a `[scheme://]host[:port][/path]` string.
933fn ipv4_host_of(url: &str) -> Option<&str> {
934    let after_scheme = url.split("://").nth(1).unwrap_or(url);
935    after_scheme.split(['/', ':']).next()
936}
937
938/// Declared **reach** for a BYO `static` node (no provider API). Lives under
939/// `[connect]` in the machine TOML.
940///
941/// Reach only — how the camp gets to the box. *Permission* is a separate axis
942/// that belongs to cheers' scopes (W295 §"Deliberately deferred"); the two
943/// collapse in practice today (mesh membership grants everything) and the data
944/// model must not fuse them, so do not add an authorization field here.
945///
946/// `address` and `ssh` stay whole, literal, operator-authored strings even
947/// though their values often *look* derived. They are not: us-west-001 dials
948/// SSH over its public IP while us-west-002 was deliberately repointed at its
949/// tailnet IP (R608-F10) precisely because the LAN address is unreachable
950/// off-LAN. Decomposing them into user + host and recomposing would silently
951/// undo per-machine decisions like that one. `yubaba` is the field that *was*
952/// derived — mesh IP plus a fixed port, rewritten by mesh-join — so that is
953/// where R707-T1 cut.
954#[derive(Debug, Clone, Serialize, Deserialize)]
955#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
956pub struct ConnectSpec {
957    /// Reachable IPv4/host for the box, e.g. `"45.32.194.254"`. Declared: which
958    /// of a machine's several addresses the camp should use is an operator
959    /// choice (public IP vs. LAN IP vs. tailnet IP).
960    pub address: String,
961    /// SSH target the camp dials for bootstrap + (pre-mesh) tunneled deploys,
962    /// e.g. `"root@45.32.194.254"` or `"struc@100.64.0.4"`. Uses the operator's
963    /// `~/.ssh/yah` key. Declared, whole — see the type doc.
964    pub ssh: String,
965    /// Port yubaba listens on. Declared reach; defaults to 7443 when omitted,
966    /// which is every machine in the fleet today. Composed with the *observed*
967    /// [`MachineRegistration::mesh_ipv4`] by [`MachineConfig::yubaba_url`].
968    #[serde(default, skip_serializing_if = "Option::is_none")]
969    pub yubaba_port: Option<u16>,
970    /// Explicit yubaba base URL, overriding the composed form.
971    ///
972    /// Two live uses, both genuine declarations: a pre-mesh node saying
973    /// `"http://127.0.0.1:7443"` — "I have no mesh address; reach me through
974    /// the SSH tunnel to `ssh`" — and any node whose yubaba is not at
975    /// `mesh_ipv4:port`. A URL here whose host *is* a mesh IP is the
976    /// pre-R707-T1 shape; [`MachineConfig::normalize`] lifts it into
977    /// `[registration].mesh_ipv4` and clears this field so the two cannot
978    /// drift apart.
979    #[serde(default, skip_serializing_if = "Option::is_none")]
980    pub yubaba: Option<String>,
981}
982
983/// Default yubaba listen port, used when `[connect].yubaba_port` is omitted.
984pub const DEFAULT_YUBABA_PORT: u16 = 7443;
985
986impl ConnectSpec {
987    /// Declared yubaba port, defaulting to [`DEFAULT_YUBABA_PORT`].
988    pub fn yubaba_port(&self) -> u16 {
989        self.yubaba_port.unwrap_or(DEFAULT_YUBABA_PORT)
990    }
991}
992
993#[derive(Debug, Clone, Serialize, Deserialize)]
994#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
995pub struct BucketSpec {
996    pub name: String,
997    pub public_read: bool,
998}
999
1000/// Per-camp mirror declaration from `.yah/cloud/mirrors/<id>/mirror.toml`
1001/// (folder form) or the legacy `.yah/cloud/mirrors/<id>.toml` (flat form).
1002///
1003/// The folder form is preferred for new mirrors so that per-mirror secrets
1004/// and override files can sit next to `mirror.toml` without polluting the
1005/// top-level `mirrors/` directory.
1006#[derive(Debug, Clone, Serialize, Deserialize)]
1007pub struct LegacyMirrorConfig {
1008    /// Logical camp name this mirror hosts, e.g. `"yah"` or `"noisetable"`.
1009    ///
1010    /// Serialised as `camp`; accepts the legacy `rig` spelling for files that
1011    /// predate the R137 rig→camp rename (one-time migration: `sed -i ''
1012    /// 's/^rig = /camp = /' ~/.yah/cloud/mirrors/*.toml`).
1013    #[serde(rename = "camp", alias = "rig")]
1014    pub camp: String,
1015    pub regions: Vec<String>,
1016    /// Workload names deployed as part of this mirror (references `workloads/<name>.toml`).
1017    /// Renamed from `services` in R092-F1; use `yah cloud config migrate-services-to-workloads`
1018    /// on repos that still have the old `services/` layout.
1019    #[serde(alias = "services")]
1020    pub workloads: Vec<String>,
1021    /// Base domain for Cloudflare-fronted services on this mirror's machines.
1022    /// Combined with the machine's `location` to build virtual-host names:
1023    /// e.g. `cloud_domain = "cloud.noisetable.example"` on machine in location
1024    /// `pdx` → Caddyfile site address `pdx.cloud.noisetable.example`.
1025    /// Optional: if unset the Caddyfile falls back to `:port` listeners.
1026    #[serde(default, skip_serializing_if = "Option::is_none")]
1027    pub cloud_domain: Option<String>,
1028}
1029
1030/// Error from loading or validating a single workload TOML file.
1031#[derive(Debug, Error)]
1032pub enum WorkloadConfigError {
1033    #[error("reading {path}: {source}")]
1034    Io {
1035        path: String,
1036        source: std::io::Error,
1037    },
1038    #[error("parsing {path}: {source}")]
1039    Toml {
1040        path: String,
1041        source: toml::de::Error,
1042    },
1043    #[error("invalid WorkloadSpec in {path}: {source}")]
1044    Shape {
1045        path: String,
1046        source: validate::ShapeError,
1047    },
1048}
1049
1050/// A workload declaration loaded from `.yah/cloud/workloads/<name>.toml`.
1051///
1052/// Each file is the human-authored TOML serialization of a [`WorkloadSpec`].
1053/// On load, the spec is validated against the shape layer; failures surface as
1054/// a [`CloudConfigError::Workload`] with the file path and field path.
1055#[derive(Debug, Clone, Serialize, Deserialize)]
1056pub struct WorkloadConfig {
1057    /// The validated spec.
1058    #[serde(flatten)]
1059    pub spec: WorkloadSpec,
1060}
1061
1062impl WorkloadConfig {
1063    /// Persist to `<cloud_dir>/workloads/<name>.toml`, creating the dir if needed.
1064    pub fn save(&self, cloud_dir: &Path) -> Result<()> {
1065        let dir = cloud_dir.join("workloads");
1066        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
1067        let path = dir.join(format!("{}.toml", self.spec.name));
1068        let s = toml::to_string_pretty(self)
1069            .with_context(|| format!("serializing workload {}", self.spec.name))?;
1070        std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
1071    }
1072}
1073
1074/// Error surfaced by [`CloudConfig::load`] when a workload TOML fails validation.
1075#[derive(Debug, Error)]
1076pub enum CloudConfigError {
1077    #[error(transparent)]
1078    Anyhow(#[from] anyhow::Error),
1079    #[error("workload validation failed: {0}")]
1080    Workload(WorkloadConfigError),
1081}
1082
1083/// Mirror-to-machine assignment table from `.yah/cloud/topology.toml`.
1084///
1085/// Declares which logical mirror names are assigned to which machines.
1086/// This is the source-canonical placement until yubaba raft observes it
1087/// (per the migration tracker in the arch doc).
1088#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1089pub struct TopologyConfig {
1090    /// Mirror→machine assignments.
1091    #[serde(default)]
1092    pub assignments: Vec<MirrorAssignment>,
1093    /// Declared buckets, logged by `yah cloud bucket create`.
1094    /// Source-canonical until yubaba raft observes actual placement.
1095    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1096    pub buckets: Vec<BucketLogEntry>,
1097}
1098
1099impl TopologyConfig {
1100    /// Load from a `topology.toml` file, returning `Default` when absent.
1101    pub fn load(path: &Path) -> Result<Self> {
1102        if !path.exists() {
1103            return Ok(Self::default());
1104        }
1105        let s =
1106            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1107        toml::from_str(&s).with_context(|| format!("parsing {}", path.display()))
1108    }
1109
1110    /// Persist to `topology.toml`, creating parent dirs if needed.
1111    pub fn save(&self, path: &Path) -> Result<()> {
1112        if let Some(parent) = path.parent() {
1113            std::fs::create_dir_all(parent)
1114                .with_context(|| format!("creating {}", parent.display()))?;
1115        }
1116        let s = toml::to_string_pretty(self).context("serializing topology")?;
1117        std::fs::write(path, s).with_context(|| format!("writing {}", path.display()))
1118    }
1119
1120    /// Find a declared bucket by name.
1121    pub fn bucket_by_name(&self, name: &str) -> Option<&BucketLogEntry> {
1122        self.buckets.iter().find(|b| b.name == name)
1123    }
1124
1125    /// Find a mutable declared bucket by name.
1126    pub fn bucket_by_name_mut(&mut self, name: &str) -> Option<&mut BucketLogEntry> {
1127        self.buckets.iter_mut().find(|b| b.name == name)
1128    }
1129
1130    /// Returns true if the bucket is declared as cross-machine (no owning machine).
1131    pub fn is_cross_machine_bucket(&self, name: &str) -> bool {
1132        self.buckets
1133            .iter()
1134            .any(|b| b.name == name && b.machine.is_none())
1135    }
1136}
1137
1138/// One mirror→machine placement entry in `topology.toml`.
1139#[derive(Debug, Clone, Serialize, Deserialize)]
1140pub struct MirrorAssignment {
1141    /// Logical mirror name, e.g. `"noisetable-pdx"`.
1142    pub mirror: String,
1143    /// Machine that hosts this mirror, e.g. `"noisetable-pdx-1"`.
1144    pub machine: String,
1145}
1146
1147/// A bucket declaration logged in `topology.toml` by `yah cloud bucket create`.
1148#[derive(Debug, Clone, Serialize, Deserialize)]
1149pub struct BucketLogEntry {
1150    pub name: String,
1151    /// Machine that owns this bucket. `None` marks it as cross-machine
1152    /// (no single-machine ownership; requires an explicit declaration in
1153    /// `topology.toml` before `yah cloud bucket create` will proceed without
1154    /// `--machine`).
1155    #[serde(default, skip_serializing_if = "Option::is_none")]
1156    pub machine: Option<String>,
1157    /// Logical location of the bucket, e.g. `"pdx"`.
1158    pub location: String,
1159    /// Current declared policy: `"private"` | `"public-read"` | `"signed-only"`.
1160    #[serde(default = "default_bucket_policy")]
1161    pub policy: String,
1162}
1163
1164fn default_bucket_policy() -> String {
1165    "private".to_string()
1166}
1167
1168/// Per-service config from `.yah/cloud/services/<name>.toml`.
1169///
1170/// **Deprecated.** The `services/` layout was replaced by `workloads/` in R092-F1.
1171/// Kept to allow in-place reads for repos that haven't migrated yet; use
1172/// `yah cloud config migrate-services-to-workloads` to upgrade.
1173#[derive(Debug, Clone, Serialize, Deserialize)]
1174pub struct LegacyServiceConfig {
1175    pub name: String,
1176    pub image: String,
1177    pub version: String,
1178    #[serde(default)]
1179    pub env: HashMap<String, String>,
1180    #[serde(default)]
1181    pub ports: Vec<PortMapping>,
1182    #[serde(default)]
1183    pub mesh_only: bool,
1184    /// Network interface this service binds to exclusively (e.g. `"tailscale0"`).
1185    ///
1186    /// When set the compose renderer emits `network_mode: "host"` and the
1187    /// service is NOT joined to the shared compose bridge network. The service
1188    /// process must bind its listen socket to the named interface's IP — for
1189    /// Postgres this means setting `POSTGRES_LISTEN_ADDRESSES` to the node's
1190    /// `tailscale ip --4` output at first boot. See [`crate::mesh_service`] for
1191    /// the standard pg_hba.conf snippet and ufw rules to pair with this field.
1192    #[serde(default, skip_serializing_if = "Option::is_none")]
1193    pub bind_interface: Option<String>,
1194
1195    /// Tenant this service belongs to (W206 isolation axis). Absent in the
1196    /// service TOML → [`TenantId::singleton`], keeping single-tenant machines
1197    /// on one shared compose network. When a machine hosts services from two
1198    /// or more distinct tenants, the compose renderer (R558-T2) splits them
1199    /// into per-tenant `<tenant>-<tier>` networks so cross-tenant stacks on the
1200    /// same host are not bridged together.
1201    #[serde(default = "TenantId::singleton")]
1202    pub tenant: TenantId,
1203}
1204
1205#[derive(Debug, Clone, Serialize, Deserialize)]
1206pub struct PortMapping {
1207    pub host: u16,
1208    pub container: u16,
1209}
1210
1211/// A loaded service plus its per-environment mirrors.
1212///
1213/// Wraps the `service.toml` body and the directory of `mirrors/<env>.toml`
1214/// files that project the service onto concrete infra.
1215#[derive(Debug, Clone, Serialize, Deserialize)]
1216pub struct ServiceWithMirrors {
1217    pub service: ServiceConfig,
1218    /// Mirrors keyed by environment name (file stem of `mirrors/<env>.toml`).
1219    pub mirrors: BTreeMap<String, MirrorConfig>,
1220    /// Transform recipe names keyed by component id. Populated from each
1221    /// static-asset component's `workload.toml` at load time — not stored
1222    /// in service.toml. Only present for components that declare
1223    /// `[asset.derive.transform] recipe = "..."`.
1224    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1225    pub component_transform_recipes: BTreeMap<String, String>,
1226    /// Nodes each mirror's passway front door is placed on, keyed by env —
1227    /// exactly what [`MirrorConfig::passway_machines`] returns, with the envs
1228    /// that declare no passway edge left out.
1229    ///
1230    /// Derived at load time like `component_transform_recipes` above: it is
1231    /// stored in no TOML file. It exists so that a consumer of this wire type —
1232    /// the desktop `service_list` command, and through it the Services tab's
1233    /// custom-domain panel — never reconciles the two `ingress` spellings
1234    /// itself. An env present here with an **empty** list is a passway edge
1235    /// whose placement is co-located rather than declared; see
1236    /// [`MirrorConfig::passway_machines`] for why that is a different answer
1237    /// from being absent.
1238    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1239    pub passway_machines: BTreeMap<String, Vec<String>>,
1240}
1241
1242/// All cloud config loaded from a workspace root (the parent of `.yah/`).
1243///
1244/// Reads two trees:
1245/// - `.yah/infra/` — `machines/`, `providers/`
1246/// - `.yah/services/<svc>/` — `service.toml` + `mirrors/<env>.toml`
1247///
1248/// Pre-R215 fields (`legacy_mirrors`, `legacy_services`, `workloads`,
1249/// `topology`) are still populated from `.yah/cloud/` when present so
1250/// pre-R215 callers (compose.rs, bucket commands) keep compiling — they
1251/// just see empty collections in a post-B1 workspace where the legacy
1252/// data was deleted. These fields are scheduled for removal in B3-T3.
1253#[derive(Debug)]
1254pub struct CloudConfig {
1255    /// Workspace root that was loaded — useful for path-resolving
1256    /// component references on a [`ServiceComponent`].
1257    pub workspace_root: std::path::PathBuf,
1258
1259    // ─── R215+ tree ────────────────────────────────────────────────────────
1260    /// `.yah/infra/machines/<name>.toml`
1261    pub machines: Vec<MachineConfig>,
1262    /// `.yah/infra/providers/<id>.toml`
1263    pub providers: Vec<ProviderConfig>,
1264    /// Provenance for every entry in `machines` that came from a linked
1265    /// `.yah/infra/sources.toml` source rather than this camp's own
1266    /// `.yah/infra/machines/` (R615-F2 / W274). Keyed by
1267    /// [`MachineConfig::name`]; a name absent here is camp-local. Empty from
1268    /// [`CloudConfig::load_from_config_dir`] — see its doc for why sources
1269    /// don't apply to multi-root sibling trees.
1270    pub machine_origins: BTreeMap<String, InfraOrigin>,
1271    /// Same as [`machine_origins`](Self::machine_origins), keyed by
1272    /// [`ProviderConfig::id`].
1273    pub provider_origins: BTreeMap<String, InfraOrigin>,
1274    /// `.yah/services/<svc>/` — service.toml plus mirrors/<env>.toml.
1275    pub services: BTreeMap<String, ServiceWithMirrors>,
1276    /// `.yah/domains/<name>.toml` — public-facing routing manifests
1277    /// (R347). Single file per domain; no nested per-env tree because
1278    /// domains themselves aren't projected onto infra — they describe
1279    /// how a Worker bundle ingresses requests onto services.
1280    pub domains: BTreeMap<String, DomainConfig>,
1281
1282    // ─── Pre-R215 legacy (slated for removal in B3-T3) ────────────────────
1283    /// Legacy mirrors from `.yah/cloud/mirrors/`.
1284    pub legacy_mirrors: Vec<LegacyMirrorConfig>,
1285    /// Workloads from `.yah/cloud/workloads/*.toml` (R092-F1 schema).
1286    pub workloads: Vec<WorkloadConfig>,
1287    /// Topology from `.yah/cloud/topology.toml` (mirror→machine assignments).
1288    pub topology: TopologyConfig,
1289    /// Legacy services from `.yah/cloud/services/*.toml` (pre-R092 layout).
1290    pub legacy_services: Vec<LegacyServiceConfig>,
1291}
1292
1293impl CloudConfig {
1294    /// Load all cloud config rooted at `workspace_root` (the parent of `.yah/`).
1295    ///
1296    /// Reads the R215+ tree (`.yah/infra/`, `.yah/services/<svc>/`) eagerly
1297    /// and the pre-R215 `.yah/cloud/` tree opportunistically. Returns `Err`
1298    /// immediately if any TOML fails to parse or a workload TOML fails
1299    /// shape validation; the error includes the file path and field path.
1300    ///
1301    /// Cross-ref validation runs after both trees finish loading: every
1302    /// `mirror.providers.X.use = "<id>"` must resolve to a real provider
1303    /// declared under `.yah/infra/providers/`.
1304    ///
1305    /// R844-B7 — **a missing `.yah/` is a wrong-root error, not an empty
1306    /// fleet.** Every sub-loader below tolerates a missing directory by
1307    /// returning empty, so before this check a call against the wrong
1308    /// directory produced a perfectly valid `CloudConfig` with zero machines,
1309    /// zero services and zero providers. Nothing downstream can tell that
1310    /// apart from a camp that genuinely declares nothing, so the failure
1311    /// surfaces as an operation that silently does nothing to nothing: a
1312    /// collate that renders no backends, a fanout that asks no nodes, a
1313    /// rollout that plans against an empty fleet. It was found the hard way —
1314    /// a live-fleet test in `app/yah/cli` called this with `"."`, which under
1315    /// `cargo test` is the *package* root, and passed while measuring nothing.
1316    ///
1317    /// The line is drawn at `.yah/` and only there: a workspace whose
1318    /// `.yah/infra/machines/` is absent or empty is a real, if unusual, camp
1319    /// with an empty fleet and still loads. `unknown` is not `answered with
1320    /// none`.
1321    pub fn load(workspace_root: &Path) -> Result<Self> {
1322        let yah_dir = crate::paths::yah_dir(workspace_root);
1323        if !yah_dir.is_dir() {
1324            anyhow::bail!(
1325                "not a yah workspace: no {} — expected the camp root (the parent \
1326                 of `.yah/`), got {}. This is a wrong-root error, not an empty \
1327                 fleet; a camp with no machines declared still has a `.yah/`.",
1328                yah_dir.display(),
1329                workspace_root.display(),
1330            );
1331        }
1332
1333        let mut providers = load_providers(&crate::paths::providers_dir(workspace_root))?;
1334        let services = load_services(&crate::paths::services_dir(workspace_root), workspace_root)?;
1335        let domains = load_domains(&crate::paths::domains_dir(workspace_root))?;
1336
1337        Self::cross_ref_validate(&providers, &services, &domains)?;
1338
1339        // Legacy `.yah/cloud/` reads — empty in post-B1 workspaces. Wrapped in
1340        // a helper so a missing tree is silent (no error, no warning).
1341        let cloud_dir = crate::paths::legacy_cloud_dir(workspace_root);
1342        let (legacy_machines, legacy_mirrors, legacy_workloads, topology, legacy_services) =
1343            if cloud_dir.exists() {
1344                (
1345                    load_dir::<MachineConfig>(cloud_dir.join("machines"))?,
1346                    load_mirrors(cloud_dir.join("mirrors"))?,
1347                    load_workloads(cloud_dir.join("workloads"))?,
1348                    load_topology(cloud_dir.join("topology.toml"))?,
1349                    load_dir::<LegacyServiceConfig>(cloud_dir.join("services"))?,
1350                )
1351            } else {
1352                Default::default()
1353            };
1354
1355        // Workloads come from `.yah/infra/workloads/` (R215+). R568-T7: before
1356        // that path was read here, this field was populated *only* from the
1357        // legacy tree above — which R222-B1 emptied — so `cfg.workload(name)`
1358        // resolved nothing in every post-R215 camp and `yah cloud workload
1359        // deploy` could not find any declaration at all. The bug survived
1360        // because the only workloads ever deployed were forge/QED runs, which
1361        // build their spec in memory and never come through here. Same
1362        // dedupe-by-name shape as machines below: R215+ wins.
1363        let mut workloads = load_workloads(crate::paths::workloads_dir(workspace_root))?;
1364        let workload_names: std::collections::HashSet<String> =
1365            workloads.iter().map(|w| w.spec.name.clone()).collect();
1366        for w in legacy_workloads {
1367            if !workload_names.contains(&w.spec.name) {
1368                workloads.push(w);
1369            }
1370        }
1371
1372        // Machines come from `.yah/infra/machines/` (R215+); the pre-R215
1373        // tree shouldn't have any since B1 moved them, but if it does we
1374        // dedupe by name (R215 wins).
1375        let mut machines = load_dir::<MachineConfig>(crate::paths::machines_dir(workspace_root))?;
1376        let names: std::collections::HashSet<String> =
1377            machines.iter().map(|m| m.name.clone()).collect();
1378        for m in legacy_machines {
1379            if !names.contains(&m.name) {
1380                machines.push(m);
1381            }
1382        }
1383
1384        // R615-F2: overlay every linked `.yah/infra/sources.toml` source's
1385        // machines/providers UNDER what's already loaded above, so camp-local
1386        // (including the legacy-tree entries just merged in) always wins on a
1387        // name collision. `SourcesConfig::load` itself never touches the
1388        // network — git sources are read from `yah infra sync`'s cache
1389        // (R615-T3), so this call keeps `load()`'s whole offline contract.
1390        let sources = SourcesConfig::load(&crate::paths::infra_dir(workspace_root))?;
1391        let mut machine_origins = BTreeMap::new();
1392        let mut provider_origins = BTreeMap::new();
1393        overlay_infra_sources(
1394            workspace_root,
1395            &sources,
1396            &mut machines,
1397            &mut providers,
1398            &mut machine_origins,
1399            &mut provider_origins,
1400        );
1401
1402        Ok(Self {
1403            workspace_root: workspace_root.to_path_buf(),
1404            machines,
1405            providers,
1406            machine_origins,
1407            provider_origins,
1408            services,
1409            domains,
1410            legacy_mirrors,
1411            workloads,
1412            topology,
1413            legacy_services,
1414        })
1415    }
1416
1417    /// Load the R215+ tree (`infra/`, `services/`, `domains/`) rooted at an
1418    /// arbitrary config directory instead of the hardcoded `.yah/`. This is the
1419    /// building block for multi-root deployments (W206 config layout (b), sibling
1420    /// `.noisetable/` trees) — see [`crate::multi_root`]. Part of R558-F4.
1421    ///
1422    /// `config_dir` is the `.X/` directory itself (e.g. `<parent>/.noisetable`);
1423    /// `workspace_root` remains the camp dir (the config dir's parent) so a
1424    /// component's `path` reference resolves against the same tree the classic
1425    /// [`CloudConfig::load`] uses. The legacy `.yah/cloud/` reads are skipped —
1426    /// multi-root deployments are post-R215 by construction — so `legacy_*`,
1427    /// `workloads`, and `topology` come back empty. Machines are read from
1428    /// `config_dir/infra/machines` directly (sibling trees declare their own
1429    /// inventory or none).
1430    ///
1431    /// R615-F2 decision, explicit rather than silent: **sources.toml overlay
1432    /// does NOT apply here.** This function
1433    /// exists specifically because a multi-root sibling tree (W206 layout
1434    /// (b), e.g. `.noisetable/`) is a *second config root inside the same
1435    /// camp*, not a second camp — `config_dir` is already wherever the
1436    /// caller decided this tree's infra lives, and `.yah/infra/sources.toml`
1437    /// (singular, tied to `paths::infra_dir(workspace_root)`) has no
1438    /// well-defined meaning for an arbitrary `config_dir` that isn't that
1439    /// path. A sibling tree that wants borrowed infra declares its own
1440    /// `sources.toml` under whichever root actually calls
1441    /// [`CloudConfig::load`] for it; `machine_origins`/`provider_origins`
1442    /// come back empty here, not wrong — there is nothing to overlay.
1443    pub fn load_from_config_dir(config_dir: &Path, workspace_root: &Path) -> Result<Self> {
1444        let providers = load_providers(&config_dir.join("infra").join("providers"))?;
1445        let services = load_services(&config_dir.join("services"), workspace_root)?;
1446        let domains = load_domains(&config_dir.join("domains"))?;
1447
1448        Self::cross_ref_validate(&providers, &services, &domains)?;
1449
1450        let machines = load_dir::<MachineConfig>(config_dir.join("infra").join("machines"))?;
1451
1452        Ok(Self {
1453            workspace_root: workspace_root.to_path_buf(),
1454            machines,
1455            providers,
1456            machine_origins: BTreeMap::new(),
1457            provider_origins: BTreeMap::new(),
1458            services,
1459            domains,
1460            legacy_mirrors: vec![],
1461            workloads: vec![],
1462            topology: TopologyConfig::default(),
1463            legacy_services: vec![],
1464        })
1465    }
1466
1467    /// Cross-reference validation shared by [`CloudConfig::load`] and
1468    /// [`CloudConfig::load_from_config_dir`]: every mirror `providers.X.use =
1469    /// "<id>"` must resolve to a declared provider, and every domain route's
1470    /// `component = "<service>/<component-id>"` must resolve to a real component.
1471    fn cross_ref_validate(
1472        providers: &[ProviderConfig],
1473        services: &BTreeMap<String, ServiceWithMirrors>,
1474        domains: &BTreeMap<String, DomainConfig>,
1475    ) -> Result<()> {
1476        // Mirror `use = "<id>"` slots must resolve to a declared provider.
1477        let provider_ids: std::collections::HashSet<&str> =
1478            providers.iter().map(|p| p.id.as_str()).collect();
1479        for (svc_name, svc) in services {
1480            for (env, mirror) in &svc.mirrors {
1481                for (slot, body) in &mirror.providers {
1482                    if let Some(id) = body.provider_id() {
1483                        if !provider_ids.contains(id) {
1484                            anyhow::bail!(
1485                                "services/{svc_name}/mirrors/{env}.toml: \
1486                                 providers.{slot}.use = \"{id}\" — no such provider; \
1487                                 declare it at infra/providers/{id}.toml"
1488                            );
1489                        }
1490                    }
1491                }
1492                // An `[[ingress]]` edge's own `use` is the same kind of
1493                // reference (R845) and gets the same check: a typo there is
1494                // otherwise invisible until `yah cloud apply` reaches the
1495                // Cloudflare arm and fails on a missing provider file.
1496                for (idx, edge) in mirror.ingress_edge_slice().iter().enumerate() {
1497                    if let Some(id) = edge.provider_id.as_deref() {
1498                        if !provider_ids.contains(id) {
1499                            anyhow::bail!(
1500                                "services/{svc_name}/mirrors/{env}.toml: \
1501                                 ingress[{idx}].use = \"{id}\" — no such provider; \
1502                                 declare it at infra/providers/{id}.toml"
1503                            );
1504                        }
1505                    }
1506                }
1507            }
1508        }
1509
1510        // Every domain route's `component = "<service>/<component-id>"` must
1511        // resolve to a real component.
1512        for (dom_name, dom) in domains {
1513            for (idx, route) in dom.routes.iter().enumerate() {
1514                let Some(component_ref) = route.mode.component() else {
1515                    continue; // redirects don't reference components
1516                };
1517                let Some((svc_name, comp_id)) = split_component_ref(component_ref) else {
1518                    anyhow::bail!(
1519                        "domains/{dom_name}.toml: routes[{idx}].component = \
1520                         \"{component_ref}\" — expected \"<service>/<component-id>\""
1521                    );
1522                };
1523                let Some(svc) = services.get(svc_name) else {
1524                    anyhow::bail!(
1525                        "domains/{dom_name}.toml: routes[{idx}].component = \
1526                         \"{component_ref}\" — no such service \"{svc_name}\" \
1527                         under services/"
1528                    );
1529                };
1530                let Some(component) = svc.service.components.iter().find(|c| c.id == comp_id)
1531                else {
1532                    anyhow::bail!(
1533                        "domains/{dom_name}.toml: routes[{idx}].component = \
1534                         \"{component_ref}\" — service \"{svc_name}\" has no \
1535                         component with id \"{comp_id}\""
1536                    );
1537                };
1538
1539                // R746: a mounted component must be routed where it publishes.
1540                // The publisher writes its bundle under the mount and the front
1541                // door looks a request up by its own path, so a route path and
1542                // a mount that disagree produce a 404 with its cause two files
1543                // away. Checked in both directions, since either one alone is
1544                // the same silent miss.
1545                //
1546                // Static routes only: `mount` is a *storage* prefix, and a
1547                // backend route proxies to an origin that owns its own paths.
1548                if !matches!(route.mode, RouteMode::Static { .. }) {
1549                    continue;
1550                }
1551                let mount = component.mount.as_deref().map(normalize_mount);
1552                let route_prefix = route_path_prefix(&route.path);
1553                if let Some(mount) = mount {
1554                    if mount != route_prefix {
1555                        anyhow::bail!(
1556                            "domains/{dom_name}.toml: routes[{idx}].path = \
1557                             \"{path}\" serves \"{component_ref}\", which \
1558                             declares mount = \"/{mount}\" — a mounted \
1559                             component publishes under its mount, so the route \
1560                             must be \"/{mount}\" or \"/{mount}/*\" (or drop \
1561                             the mount to serve from the service root)",
1562                            path = route.path,
1563                        );
1564                    }
1565                } else if !route_prefix.is_empty() {
1566                    anyhow::bail!(
1567                        "domains/{dom_name}.toml: routes[{idx}].path = \
1568                         \"{path}\" serves \"{component_ref}\", which declares \
1569                         no `mount` — its bundle publishes at the service root, \
1570                         so nothing is stored under \"/{route_prefix}\". Set \
1571                         mount = \"/{route_prefix}\" on the component, or route \
1572                         it at \"/*\"",
1573                        path = route.path,
1574                    );
1575                }
1576            }
1577        }
1578        Ok(())
1579    }
1580
1581    /// Look up a domain manifest by name (file stem under `.yah/domains/`).
1582    pub fn domain(&self, name: &str) -> Option<&DomainConfig> {
1583        self.domains.get(name)
1584    }
1585
1586    pub fn machine(&self, name: &str) -> Option<&MachineConfig> {
1587        self.machines.iter().find(|m| m.name == name)
1588    }
1589
1590    /// Look up a provider by id (matches `provider.id`, not the file stem).
1591    pub fn provider(&self, id: &str) -> Option<&ProviderConfig> {
1592        self.providers.iter().find(|p| p.id == id)
1593    }
1594
1595    /// Look up a service by name (matches `service.toml`'s `name` field).
1596    pub fn service(&self, name: &str) -> Option<&ServiceWithMirrors> {
1597        self.services.get(name)
1598    }
1599
1600    /// Look up a legacy mirror by camp name (pre-R215 .yah/cloud/mirrors/).
1601    pub fn legacy_mirror(&self, camp: &str) -> Option<&LegacyMirrorConfig> {
1602        self.legacy_mirrors.iter().find(|m| m.camp == camp)
1603    }
1604
1605    pub fn workload(&self, name: &str) -> Option<&WorkloadConfig> {
1606        self.workloads.iter().find(|w| w.spec.name == name)
1607    }
1608
1609    /// Every machine declaring `sovereign_group == group`, in declaration order.
1610    ///
1611    /// W305/R742-F3. A sovereign group has no file of its own — it exists only
1612    /// as the set of machines that name the same string — so "which boxes are
1613    /// the dev cluster" has to be *derived*, and before this it was not derived
1614    /// anywhere: `yah cloud rollout plan` still takes a hand-listed
1615    /// `--voter us-west-011 --voter us-west-013 …` for a fact the machine TOMLs
1616    /// already state (W314 gap 1).
1617    ///
1618    /// **This is not placement.** Resolving a group to its members is a
1619    /// *lookup*, and it stays outside [`RequiredSpec`] on purpose — see
1620    /// [`MachineConfig::sovereign_group`]. `migrate` calls this to pick the
1621    /// candidate set it then admits a workload against; nothing here filters
1622    /// scheduling, and adding `sovereign_group` to `matches` would still be the
1623    /// category error that doc warns about.
1624    ///
1625    /// An empty result means no machine declares `group`, which is
1626    /// indistinguishable from a typo — callers should say so with
1627    /// [`Self::declared_sovereign_groups`] rather than reporting "no
1628    /// candidates".
1629    pub fn machines_in_group(&self, group: &str) -> Vec<&MachineConfig> {
1630        self.machines
1631            .iter()
1632            .filter(|m| m.sovereign_group.as_deref() == Some(group))
1633            .collect()
1634    }
1635
1636    /// Every distinct `sovereign_group` declared by any machine, sorted.
1637    ///
1638    /// Exists so a bad `--to` names the real vocabulary instead of complaining
1639    /// abstractly — the same fail-loud shape [`taint_effect`]'s legal-key list
1640    /// gives `check_inert_taints`. Standalone machines (`None`) contribute
1641    /// nothing: "in no group" is not a group you can migrate *to*.
1642    pub fn declared_sovereign_groups(&self) -> Vec<&str> {
1643        let mut groups: Vec<&str> = self
1644            .machines
1645            .iter()
1646            .filter_map(|m| m.sovereign_group.as_deref())
1647            .collect();
1648        groups.sort_unstable();
1649        groups.dedup();
1650        groups
1651    }
1652
1653    /// F16 placement v1: the first machine satisfying every hard axis of `req`
1654    /// (region/zone/provider membership + mesh_tags superset). Declaration order
1655    /// in `.yah/infra/machines/` decides ties — deterministic-greedy, no
1656    /// backtracking. A fully-unconstrained `req` matches the first machine.
1657    ///
1658    /// Fails loud with the constraint summary and the candidate machine names
1659    /// when nothing matches, so `yah cloud apply` surfaces *why* placement
1660    /// failed instead of a silent empty set.
1661    pub fn resolve_machine(&self, req: &RequiredSpec) -> Result<&MachineConfig> {
1662        resolve_machine_among(&self.machines, req)
1663    }
1664
1665    /// F16 placement at horizontal scale: the first
1666    /// [`RequiredSpec::replica_count`] machines satisfying every hard axis of
1667    /// `req`, in declaration order (R844-F8).
1668    ///
1669    /// The N-valued form of [`Self::resolve_machine`], which is the N=1 case of
1670    /// this and not a different selector — both land in [`select_matching`].
1671    /// That shared bottom is what makes the deploy resolver
1672    /// (`reconciler::mesofact_bundle::resolve_bundle_machines`, which calls
1673    /// this) and the ingress planner's
1674    /// (`reconciler::ingress::resolve_ingress_placements`, which calls
1675    /// [`resolve_machines_among`] over the same `machines` slice) agree on the
1676    /// same N machines **by construction**. They must agree set-for-set, not
1677    /// merely in count: a front door aimed at nodes the workload was never
1678    /// deployed to renders a *subset* of the backends, which is the failure that
1679    /// looks like it worked.
1680    pub fn resolve_machines(&self, req: &RequiredSpec) -> Result<Vec<&MachineConfig>> {
1681        resolve_machines_among(&self.machines, req)
1682    }
1683
1684    /// F16 placement: first machine whose `mesh_tags` is a superset of
1685    /// `required`. Declaration order in `.yah/infra/machines/` decides ties.
1686    /// Empty `required` matches the first machine; callers should treat
1687    /// empty-required as "no constraint" and skip this lookup.
1688    ///
1689    /// Back-compat thin wrapper over [`CloudConfig::resolve_machine`] for the
1690    /// mesh-tags-only call sites that predate the topology axes.
1691    pub fn resolve_machine_by_mesh_tags(&self, required: &[String]) -> Option<&MachineConfig> {
1692        let req = RequiredSpec {
1693            mesh_tags: required.to_vec(),
1694            ..Default::default()
1695        };
1696        self.resolve_machine(&req).ok()
1697    }
1698
1699    /// Admission: resolve the target machine for a remote [`WorkloadSpec`],
1700    /// honoring the R594 mesh-tag node-selector annotation
1701    /// (`velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION` =
1702    /// `yah.node-selector.mesh-tags`, comma-joined).
1703    ///
1704    /// The producer side (`velveteen_exec::remote::build_workload_spec`, R594) writes
1705    /// `TaskLocation::RemoteAny.mesh_tags` — e.g. `[tag:build-worker, arch:x86]`
1706    /// from [`qed::platform::build_worker_mesh_tags`] — into the workload's
1707    /// annotations. This is the consumer: candidates are restricted to machines
1708    /// whose `mesh_tags` are a **superset** of the requested set, so an amd64
1709    /// build lands on the `arch:x86` build-worker (us-west-002) and an arm64
1710    /// build on a `arch:arm` Pi5. Declaration order in `.yah/infra/machines/`
1711    /// breaks ties.
1712    ///
1713    /// An absent or empty annotation means "no mesh-tag constraint" — pre-R594
1714    /// behavior (any node), matching [`RequiredSpec::is_unconstrained`].
1715    ///
1716    /// This is the single admission seam: R572-F5 extends it with the capacity
1717    /// floor (workload request fits node allocatable−committed) and taint
1718    /// repulsion/affinity by enriching [`RequiredSpec::matches`] /
1719    /// [`Self::resolve_machine`]. Do not fork a second selector.
1720    pub fn admit_workload(&self, ws: &WorkloadSpec) -> Result<&MachineConfig> {
1721        self.resolve_machine(&admission_spec(ws))
1722    }
1723
1724    /// Every machine that admits `ws`, in declaration order — the *pool*
1725    /// [`Self::admit_workload`] returns the head of (R605-T14).
1726    ///
1727    /// # Why a pool and not just the winner
1728    ///
1729    /// `tag:build-worker` is a statement that the tagged boxes are
1730    /// **interchangeable**: a build is booked against the tag, not against
1731    /// `us-west-002`. Returning one machine forced every caller to act as if it
1732    /// were booked against a name, and admission has no liveness input — so a
1733    /// tagged box that is asleep won the file-name tie-break and its builds
1734    /// failed rather than landing on the identical box next to it. That is
1735    /// exactly what happened on 2026-09-03 when `us-west-002` regained the tag.
1736    ///
1737    /// The fix is **not** to teach this function about liveness. It stays a pure
1738    /// function of the declared inventory (see `xtask/tests/fleet_build_placement.rs`
1739    /// on why a placement pin that needs the network is a flake). It hands the
1740    /// dispatcher the whole interchangeable set instead, and the dispatcher —
1741    /// which has the network — probes and fails over within it:
1742    /// `app/yah/cli/src/yubaba_client.rs`'s `MeshYubabaClient::deploy`.
1743    ///
1744    /// Order is the declaration order `admit_workload` already used, and callers
1745    /// should preserve it as their preference order rather than load-balancing
1746    /// across it: a retried build wants the node still holding its warm
1747    /// `target/`, which is the same reason [`first_match`] is deliberately
1748    /// first-fit.
1749    ///
1750    /// `Err` — never `Ok(vec![])` — when nothing admits `ws`, carrying the same
1751    /// message [`Self::admit_workload`] would have produced. "No node admits
1752    /// this" and "the pool is empty" are the same failure and must read the same.
1753    pub fn admit_workload_candidates(&self, ws: &WorkloadSpec) -> Result<Vec<&MachineConfig>> {
1754        let req = admission_spec(ws);
1755        let all: Vec<&MachineConfig> = self.machines.iter().collect();
1756        let matched = matching(&all, &req);
1757        if matched.is_empty() {
1758            // Delegate the wording so the two paths cannot drift apart.
1759            return Err(first_match(&all, &req, DECLARED_POOL, EMPTY_DECLARED_POOL)
1760                .expect_err("matching() found nothing, so first_match cannot succeed"));
1761        }
1762        Ok(matched)
1763    }
1764
1765    /// [`Self::admit_workload`] restricted to the machines of one sovereign
1766    /// group (W305/R742-F3, `yah cloud migrate --to <group>`).
1767    ///
1768    /// Same [`RequiredSpec`], same [`RequiredSpec::matches`], same
1769    /// declaration-order tie-break — only the candidate *set* differs. That is
1770    /// the whole reason this is a narrowing of the admission seam rather than a
1771    /// second selector: a workload that cannot be scheduled onto a group's
1772    /// boxes must fail here for exactly the reason it would fail anywhere else,
1773    /// and `no-appliance` on the dev Pis (W305 finding 2) is precisely the case
1774    /// that must not be silently routed around by a migration verb.
1775    ///
1776    /// `Err` when the group has no members *or* when no member admits `ws`; the
1777    /// two are different mistakes, so callers wanting to tell them apart should
1778    /// check [`Self::machines_in_group`] first.
1779    pub fn admit_workload_in_group(
1780        &self,
1781        ws: &WorkloadSpec,
1782        group: &str,
1783    ) -> Result<&MachineConfig> {
1784        let members = self.machines_in_group(group);
1785        let empty_pool = format!(
1786            "(no machine declares sovereign_group = \"{group}\" — declared groups: {})",
1787            match self.declared_sovereign_groups().as_slice() {
1788                [] => "(none)".to_string(),
1789                gs => gs.join(", "),
1790            }
1791        );
1792        first_match(
1793            &members,
1794            &admission_spec(ws),
1795            &format!("machines in sovereign group '{group}'"),
1796            &empty_pool,
1797        )
1798    }
1799}
1800
1801/// **The** placement selector: the first candidate satisfying every axis of
1802/// `req`, declaration order breaking ties, deterministic-greedy with no
1803/// backtracking.
1804///
1805/// Every path that picks a machine goes through here, and the only thing any
1806/// of them varies is *which machines are candidates* — never the predicate.
1807/// [`CloudConfig::resolve_machine`] passes the whole fleet;
1808/// [`CloudConfig::admit_workload_in_group`] passes one sovereign group's
1809/// members. That split is the point: a candidate-set narrowing composes with
1810/// the [`RequiredSpec`] axes for free, whereas expressing the same narrowing
1811/// *as* an axis would put facts like blast radius into a filter they must
1812/// never be in (see [`MachineConfig::sovereign_group`]).
1813///
1814/// So a new placement scope is a new candidate set plus a `pool` label, and a
1815/// new placement *constraint* is a field on [`RequiredSpec`] — those are the
1816/// two extension points, and neither is a second selector. `pool` and
1817/// `empty_pool` exist only so the failure names the set it actually searched;
1818/// a refusal that says "no candidates" without saying *among what* is one the
1819/// operator has to reconstruct by hand.
1820/// F16 placement v1 resolution over an explicit machine list — the
1821/// `.machines`-only half of [`CloudConfig::resolve_machine`], for callers that
1822/// have loaded just the machines tree rather than the whole cross-ref-validated
1823/// config.
1824///
1825/// R772: `resolve_ingress_placements` (`reconciler::ingress`) is the reason
1826/// this is `pub(crate)` rather than staying folded into
1827/// `CloudConfig::resolve_machine` — ingress collation walks every mirror in
1828/// the workspace and has no business hard-failing over an unrelated mirror's
1829/// `providers.X.use = "<id>"` typo, which is what going through
1830/// `CloudConfig::load`'s cross-ref validation would do. "Do not fork a second
1831/// selector" (see the module doc above) still holds: this is the *same*
1832/// [`first_match`], just handed a narrower candidate set than `self.machines`.
1833pub(crate) fn resolve_machine_among<'a>(
1834    machines: &'a [MachineConfig],
1835    req: &RequiredSpec,
1836) -> Result<&'a MachineConfig> {
1837    let all: Vec<&MachineConfig> = machines.iter().collect();
1838    first_match(&all, req, DECLARED_POOL, EMPTY_DECLARED_POOL)
1839}
1840
1841/// R844-F8: [`resolve_machine_among`] widened to the constraint's own replica
1842/// count — the first [`RequiredSpec::replica_count`] matching machines, in the
1843/// same declaration order, from the same candidate slice.
1844///
1845/// **The one entry point both resolvers share.**
1846/// `reconciler::ingress::resolve_ingress_placements` calls this directly and
1847/// `reconciler::mesofact_bundle::resolve_bundle_machines` reaches it through
1848/// [`CloudConfig::resolve_machines`], both over `cfg.machines` — so the ingress
1849/// planner and the deployer cannot pick different subsets. That is a structural
1850/// guarantee, not a tested coincidence, and it has to be: discovery aimed at a
1851/// node the bundle was never placed on publishes a hostname with a dead
1852/// backend behind it, and at scale > 1 the front door still answers from the
1853/// nodes that *did* get it.
1854///
1855/// Determinism is therefore part of correctness here. `machines` arrives in
1856/// file-name order (`load_dir`, pinned by
1857/// `machines_load_in_file_name_order_not_read_dir_order`), and selection is a
1858/// stable prefix of that order — so "the first two matching" is the same two
1859/// on both sides of the same tree.
1860pub(crate) fn resolve_machines_among<'a>(
1861    machines: &'a [MachineConfig],
1862    req: &RequiredSpec,
1863) -> Result<Vec<&'a MachineConfig>> {
1864    let all: Vec<&MachineConfig> = machines.iter().collect();
1865    select_matching(
1866        &all,
1867        req,
1868        req.replica_count(),
1869        DECLARED_POOL,
1870        EMPTY_DECLARED_POOL,
1871    )
1872}
1873
1874const DECLARED_POOL: &str = "declared machines";
1875const EMPTY_DECLARED_POOL: &str = "(no machines declared under .yah/infra/machines/)";
1876
1877fn first_match<'a>(
1878    candidates: &[&'a MachineConfig],
1879    req: &RequiredSpec,
1880    pool: &str,
1881    empty_pool: &str,
1882) -> Result<&'a MachineConfig> {
1883    Ok(select_matching(candidates, req, 1, pool, empty_pool)?
1884        .into_iter()
1885        .next()
1886        .expect("select_matching errors rather than returning short"))
1887}
1888
1889/// The N-selecting core of the placement selector: the first `want` candidates
1890/// satisfying `req`, in candidate order (R844-F8).
1891///
1892/// [`first_match`] is this with `want = 1`, which is why widening a caller to a
1893/// replica count cannot introduce a second selector — the predicate, the
1894/// ordering and the failure vocabulary are all one implementation.
1895///
1896/// **A shortfall is an error.** Matching one machine when two were asked for
1897/// returns `Err` naming both numbers and the pool searched, never a one-element
1898/// vec: a half-placed workload that reports success is worse than a failed
1899/// apply, because the front door then publishes a hostname whose backend set is
1900/// quietly smaller than declared. `want = 0` is the same mistake spelled
1901/// differently and is refused for the same reason.
1902fn select_matching<'a>(
1903    candidates: &[&'a MachineConfig],
1904    req: &RequiredSpec,
1905    want: usize,
1906    pool: &str,
1907    empty_pool: &str,
1908) -> Result<Vec<&'a MachineConfig>> {
1909    let names = || {
1910        if candidates.is_empty() {
1911            empty_pool.to_string()
1912        } else {
1913            candidates
1914                .iter()
1915                .map(|m| m.name.as_str())
1916                .collect::<Vec<_>>()
1917                .join(", ")
1918        }
1919    };
1920
1921    if want == 0 {
1922        anyhow::bail!(
1923            "replicas = 0 places {} on nothing — a placement that deploys to no machine is \
1924             a typo, not a scale-down; remove the slot instead",
1925            req.describe()
1926        );
1927    }
1928
1929    let mut matched = matching(candidates, req);
1930    if matched.len() >= want {
1931        matched.truncate(want);
1932        return Ok(matched);
1933    }
1934
1935    if want == 1 {
1936        anyhow::bail!(
1937            "no candidates matching {} — {pool}: {}",
1938            req.describe(),
1939            names()
1940        );
1941    }
1942    anyhow::bail!(
1943        "only {} of {want} machines match {} — placing fewer than the declared \
1944         `replicas = {want}` would publish a smaller backend set than the mirror asks for; \
1945         {pool}: {}",
1946        matched.len(),
1947        req.describe(),
1948        names()
1949    )
1950}
1951
1952/// The predicate itself, applied to every candidate in order — the one place
1953/// `req.matches` is called on a set.
1954///
1955/// [`select_matching`] takes a prefix of this; [`CloudConfig::admit_workload_candidates`]
1956/// takes all of it. Keeping both on this function is what makes "the pool the
1957/// dispatcher failed over within" and "the machine admission picked" the same
1958/// answer by construction rather than by two filters that happen to agree.
1959fn matching<'a>(candidates: &[&'a MachineConfig], req: &RequiredSpec) -> Vec<&'a MachineConfig> {
1960    candidates
1961        .iter()
1962        .copied()
1963        .filter(|m| req.matches(m))
1964        .collect()
1965}
1966
1967/// The [`RequiredSpec`] a workload is admitted against — the single place the
1968/// axes are derived from a [`WorkloadSpec`].
1969///
1970/// Extracted from [`CloudConfig::admit_workload`] so that
1971/// [`CloudConfig::admit_workload_in_group`] narrows the candidate set without
1972/// restating the axes. Forking that derivation is how the two paths would
1973/// silently disagree about whether a workload fits a node.
1974fn admission_spec(ws: &WorkloadSpec) -> RequiredSpec {
1975    RequiredSpec {
1976        mesh_tags: node_selector_mesh_tags(ws),
1977        // R833-F8: imperative node pin. Derived here alongside the inferred
1978        // mesh tags rather than short-circuiting the resolver, so a pinned
1979        // workload is still checked against capacity and taints.
1980        nodes: node_selector_node(ws).into_iter().collect(),
1981        // R572-F5: capacity floor from the workload's resource request.
1982        //
1983        // `memory_request_mb()` and NOT `resources.memory_mb`: the latter
1984        // is a cgroup ceiling, and reading a ceiling as a floor made
1985        // `for_forge`'s deliberately-roomy 32 GiB limit mean "only place
1986        // me on a 32 GiB node". That excluded every build-worker in the
1987        // fleet but one. The accessor falls back to `resources.memory_mb`
1988        // when no request is declared, so specs that never set one are
1989        // admitted exactly as before.
1990        memory_mb: ws.memory_request_mb(),
1991        cpu_millis: ws.resources.cpu_millis,
1992        // R572-F5: taint repulsion derived from the workload's effective archetype.
1993        repel_archetype: Some(ws.effective_archetype()),
1994        // R572-F5: taint affinity from the requires-taint annotation.
1995        requires_taint: ws.requires_taint().map(str::to_owned),
1996        ..Default::default()
1997    }
1998}
1999
2000/// Parse the R594 mesh-tag node-selector off a workload's annotations into the
2001/// requested tag set. Absent annotation or empty value ⇒ empty vec ("no
2002/// constraint"). Whitespace around each comma-separated tag is trimmed and
2003/// empty segments are dropped, so `"tag:build-worker, arch:x86"` and
2004/// `"tag:build-worker,arch:x86"` parse identically.
2005pub fn node_selector_mesh_tags(ws: &WorkloadSpec) -> Vec<String> {
2006    ws.annotations
2007        .get(velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION)
2008        .map(|v| {
2009            v.split(',')
2010                .map(str::trim)
2011                .filter(|s| !s.is_empty())
2012                .map(String::from)
2013                .collect()
2014        })
2015        .unwrap_or_default()
2016}
2017
2018/// Parse the R833-F8 imperative node-selector off a workload's annotations —
2019/// the single machine `name` the operator pinned the run to
2020/// (`--where=node:us-west-003`). Absent or blank ⇒ `None` ("no constraint"),
2021/// which is every workload built before this axis existed.
2022///
2023/// One node, not a list: the annotation exists to express "run it *there*", and
2024/// a comma-joined set would be a worse spelling of the mesh-tag selector that
2025/// already handles "any of these".
2026pub fn node_selector_node(ws: &WorkloadSpec) -> Option<String> {
2027    ws.annotations
2028        .get(velveteen_exec::remote::NODE_SELECTOR_NODE_ANNOTATION)
2029        .map(|v| v.trim())
2030        .filter(|v| !v.is_empty())
2031        .map(String::from)
2032}
2033
2034/// Load every `.yah/infra/providers/*.toml` into a [`ProviderConfig`] list.
2035/// Missing directory → empty list.
2036fn load_providers(dir: &Path) -> Result<Vec<ProviderConfig>> {
2037    if !dir.exists() {
2038        return Ok(vec![]);
2039    }
2040    let mut items = vec![];
2041    let mut entries: Vec<_> = std::fs::read_dir(dir)
2042        .with_context(|| format!("reading {}", dir.display()))?
2043        .filter_map(|e| e.ok())
2044        .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
2045        .collect();
2046    entries.sort_by_key(|e| e.file_name());
2047    for entry in entries {
2048        items.push(ProviderConfig::load(&entry.path())?);
2049    }
2050    Ok(items)
2051}
2052
2053/// Map legacy mirror file stems to their canonical tier names.
2054///
2055/// Canonical tiers: `dev` / `pond` / `cloud` / `ha`.
2056/// Legacy stems pre-R362: `local` (dev tier), `local-sim` / `sim` (pond tier), `prod` (cloud tier).
2057/// Both forms are accepted; canonical names are preferred for new files.
2058pub fn canonical_tier(stem: &str) -> &str {
2059    match stem {
2060        "local" => "dev",
2061        "local-sim" | "sim" => "pond",
2062        "prod" => "cloud",
2063        other => other,
2064    }
2065}
2066
2067/// Walk `.yah/services/<svc>/` for every service and its mirrors.
2068/// Missing directory → empty map. Mirror file stems are normalized to canonical
2069/// tier names via [`canonical_tier`] so callers always see `dev/pond/cloud/ha`.
2070fn load_services(
2071    dir: &Path,
2072    workspace_root: &Path,
2073) -> Result<BTreeMap<String, ServiceWithMirrors>> {
2074    if !dir.exists() {
2075        return Ok(BTreeMap::new());
2076    }
2077    let mut out = BTreeMap::new();
2078    let mut entries: Vec<_> = std::fs::read_dir(dir)
2079        .with_context(|| format!("reading {}", dir.display()))?
2080        .filter_map(|e| e.ok())
2081        .filter(|e| e.path().is_dir())
2082        .collect();
2083    entries.sort_by_key(|e| e.file_name());
2084
2085    for entry in entries {
2086        let svc_dir = entry.path();
2087        let service_toml = svc_dir.join("service.toml");
2088        if !service_toml.exists() {
2089            // Skip directories without a service.toml — leaves room for
2090            // future siblings (e.g. `secrets/`, `README.md`) without
2091            // triggering false-positive parse errors.
2092            continue;
2093        }
2094        let service = ServiceConfig::load(&service_toml)?;
2095        let mut mirrors = BTreeMap::new();
2096        let mirrors_dir = svc_dir.join("mirrors");
2097        if mirrors_dir.exists() {
2098            let mut menv: Vec<_> = std::fs::read_dir(&mirrors_dir)
2099                .with_context(|| format!("reading {}", mirrors_dir.display()))?
2100                .filter_map(|e| e.ok())
2101                .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
2102                .collect();
2103            menv.sort_by_key(|e| e.file_name());
2104            for m in menv {
2105                let path = m.path();
2106                let stem = path
2107                    .file_stem()
2108                    .and_then(|s| s.to_str())
2109                    .unwrap_or("")
2110                    .to_string();
2111                let tier = canonical_tier(&stem).to_string();
2112                // Last-write wins if both legacy and canonical forms coexist
2113                // (e.g. local-sim.toml + pond.toml). Sort order ensures the
2114                // canonical file (pond.toml) wins because 'p' > 'l'.
2115                mirrors.insert(tier, MirrorConfig::load(&path)?);
2116            }
2117        }
2118        let mut component_transform_recipes = BTreeMap::new();
2119        for component in &service.components {
2120            if component.kind == "static-asset" {
2121                if let Some(recipe) =
2122                    read_component_transform_recipe(workspace_root, &component.path)
2123                {
2124                    component_transform_recipes.insert(component.id.clone(), recipe);
2125                }
2126            }
2127        }
2128        let passway_machines = mirrors
2129            .iter()
2130            .filter_map(|(env, m)| m.passway_machines().map(|ms| (env.clone(), ms)))
2131            .collect();
2132        out.insert(
2133            service.name.clone(),
2134            ServiceWithMirrors {
2135                service,
2136                mirrors,
2137                component_transform_recipes,
2138                passway_machines,
2139            },
2140        );
2141    }
2142    Ok(out)
2143}
2144
2145/// Read the first transform recipe name from a component's `workload.toml`.
2146/// Returns `None` when the file is absent or has no `[asset.derive.transform]`
2147/// section. Best-effort — parse failures are silently ignored so a malformed
2148/// workload.toml doesn't abort the entire service catalog load.
2149fn read_component_transform_recipe(workspace_root: &Path, component_path: &str) -> Option<String> {
2150    let workload_path = workspace_root.join(component_path).join("workload.toml");
2151    let text = std::fs::read_to_string(&workload_path).ok()?;
2152    let value: toml::Value = toml::from_str(&text).ok()?;
2153    let assets = value.get("asset")?.as_array()?;
2154    for asset in assets {
2155        if let Some(recipe) = asset
2156            .get("derive")
2157            .and_then(|d| d.get("transform"))
2158            .and_then(|t| t.get("recipe"))
2159            .and_then(|r| r.as_str())
2160        {
2161            return Some(recipe.to_string());
2162        }
2163    }
2164    None
2165}
2166
2167/// Load every `.yah/domains/*.toml` into a [`DomainConfig`] map keyed by
2168/// file stem. Missing directory → empty map.
2169fn load_domains(dir: &Path) -> Result<BTreeMap<String, DomainConfig>> {
2170    if !dir.exists() {
2171        return Ok(BTreeMap::new());
2172    }
2173    let mut out = BTreeMap::new();
2174    let mut entries: Vec<_> = std::fs::read_dir(dir)
2175        .with_context(|| format!("reading {}", dir.display()))?
2176        .filter_map(|e| e.ok())
2177        .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
2178        .collect();
2179    entries.sort_by_key(|e| e.file_name());
2180    for entry in entries {
2181        let path = entry.path();
2182        let stem = path
2183            .file_stem()
2184            .and_then(|s| s.to_str())
2185            .unwrap_or("")
2186            .to_string();
2187        let dom = DomainConfig::load(&path)?;
2188        if dom.name != stem {
2189            anyhow::bail!(
2190                "domains/{}.toml: name = \"{}\" must match the file stem",
2191                stem,
2192                dom.name
2193            );
2194        }
2195        out.insert(dom.name.clone(), dom);
2196    }
2197    Ok(out)
2198}
2199
2200/// Load and shape-validate all `*.toml` files in `dir` as [`WorkloadConfig`].
2201fn load_workloads(dir: std::path::PathBuf) -> Result<Vec<WorkloadConfig>> {
2202    if !dir.exists() {
2203        return Ok(vec![]);
2204    }
2205    let mut items = vec![];
2206    let mut entries: Vec<_> = std::fs::read_dir(&dir)
2207        .with_context(|| format!("reading {}", dir.display()))?
2208        .filter_map(|e| e.ok())
2209        .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
2210        .collect();
2211    entries.sort_by_key(|e| e.file_name());
2212
2213    for entry in entries {
2214        let path = entry.path();
2215        let path_str = path.display().to_string();
2216        let src =
2217            std::fs::read_to_string(&path).with_context(|| format!("reading {}", path_str))?;
2218        let spec: WorkloadSpec =
2219            toml::from_str(&src).with_context(|| format!("parsing {}", path_str))?;
2220
2221        // Shape-validate before accepting into the loaded config.
2222        validate::shape(&spec)
2223            .map_err(|e| anyhow::anyhow!("workload {} failed shape validation: {e}", path_str))?;
2224
2225        items.push(WorkloadConfig { spec });
2226    }
2227    Ok(items)
2228}
2229
2230/// Load all mirror configs from the `mirrors/` directory.
2231///
2232/// Handles two layouts that may coexist:
2233/// - **Folder**: `mirrors/<id>/mirror.toml` — preferred; allows secrets and
2234///   per-mirror overrides to live next to the config file.
2235/// - **Flat**: `mirrors/<id>.toml` — legacy; still supported.
2236///
2237/// Each file is parsed as [`LegacyMirrorConfig`]. A malformed file returns an error
2238/// that includes the file path and the TOML field path + line/column, so the
2239/// caller can surface it to the user directly.
2240fn load_mirrors(dir: std::path::PathBuf) -> Result<Vec<LegacyMirrorConfig>> {
2241    if !dir.exists() {
2242        return Ok(vec![]);
2243    }
2244    let mut mirrors = vec![];
2245    let mut entries: Vec<_> = std::fs::read_dir(&dir)
2246        .with_context(|| format!("reading {}", dir.display()))?
2247        .filter_map(|e| e.ok())
2248        .collect();
2249    entries.sort_by_key(|e| e.file_name());
2250
2251    for entry in entries {
2252        let path = entry.path();
2253        if path.is_dir() {
2254            // Folder layout: mirrors/<id>/mirror.toml
2255            let mirror_toml = path.join("mirror.toml");
2256            if mirror_toml.exists() {
2257                let src = std::fs::read_to_string(&mirror_toml)
2258                    .with_context(|| format!("reading {}", mirror_toml.display()))?;
2259                let cfg: LegacyMirrorConfig = toml::from_str(&src)
2260                    .with_context(|| format!("parsing {}", mirror_toml.display()))?;
2261                mirrors.push(cfg);
2262            }
2263        } else if path.extension().map_or(false, |e| e == "toml") {
2264            // Flat layout: mirrors/<id>.toml
2265            let src = std::fs::read_to_string(&path)
2266                .with_context(|| format!("reading {}", path.display()))?;
2267            let cfg: LegacyMirrorConfig =
2268                toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
2269            mirrors.push(cfg);
2270        }
2271    }
2272    Ok(mirrors)
2273}
2274
2275/// Load `topology.toml` if it exists; return a default (empty) topology otherwise.
2276fn load_topology(path: std::path::PathBuf) -> Result<TopologyConfig> {
2277    if !path.exists() {
2278        return Ok(TopologyConfig::default());
2279    }
2280    let src =
2281        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
2282    toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2283}
2284
2285/// R555-S1: entries are sorted by file name before parsing, so "declaration
2286/// order in `.yah/infra/machines/` breaks ties" — the contract
2287/// [`CloudConfig::admit_workload`] documents — is actually true. `read_dir`
2288/// yields filesystem order, which is unspecified and differs between APFS and
2289/// a hashed-dir ext4; without the sort, *which* of two equally-matching nodes a
2290/// workload admits to could change when an unrelated file is added to the
2291/// directory. That was latent while each tag set had one match and became
2292/// observable the day us-west-003 joined us-west-002 on
2293/// `[tag:build-worker, arch:x86, os:linux]`. Same sort `load_providers` has
2294/// always done.
2295fn load_dir<T: for<'de> Deserialize<'de>>(dir: std::path::PathBuf) -> Result<Vec<T>> {
2296    if !dir.exists() {
2297        return Ok(vec![]);
2298    }
2299    let mut entries: Vec<_> = std::fs::read_dir(&dir)
2300        .with_context(|| format!("reading {}", dir.display()))?
2301        .collect::<std::io::Result<Vec<_>>>()
2302        .with_context(|| format!("reading {}", dir.display()))?;
2303    entries.sort_by_key(|e| e.file_name());
2304
2305    let mut items = vec![];
2306    for entry in entries {
2307        let path = entry.path();
2308        if path.extension().map_or(false, |e| e == "toml") {
2309            let src = std::fs::read_to_string(&path)
2310                .with_context(|| format!("reading {}", path.display()))?;
2311            let item: T =
2312                toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
2313            items.push(item);
2314        }
2315    }
2316    Ok(items)
2317}
2318
2319// ─── New manifest shapes (R222 B2) ───────────────────────────────────────────
2320//
2321// The post-R215 layout splits substrate from service declarations:
2322//
2323//   .yah/infra/providers/<id>.toml      → ProviderConfig
2324//   .yah/services/<svc>/service.toml    → ServiceConfig
2325//   .yah/services/<svc>/mirrors/<env>.toml → MirrorConfig
2326//
2327// CloudConfig::load still reads the legacy layout — B3 swaps in these types
2328// and removes the Legacy* shapes plus TopologyConfig.
2329
2330/// Tag for the infrastructure provider kind. Drives which fields are valid in
2331/// a [`ProviderConfig`] body or a [`MirrorProviderSlot::Inline`] block.
2332///
2333/// Two flavors:
2334/// - **Account/runtime providers** (`cloudflare`, `hetzner`, `local-container`)
2335///   live as files under `.yah/infra/providers/<id>.toml` and are referenced
2336///   from a mirror via `use = "<id>"`.
2337/// - **Inline-only providers** (`local-static`, `miniflare-container`,
2338///   `minio-container`) declare an operator-local stand-in directly inside a
2339///   mirror via `kind = "..."`. They carry no credentials and have no provider
2340///   file. The container-backed kinds ride on top of whichever
2341///   `local-container` runtime is declared in infra (orbstack/colima/docker);
2342///   the reconciler resolves the runtime at up-time.
2343#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2344#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2345#[serde(rename_all = "kebab-case")]
2346pub enum Provider {
2347    /// Cloudflare account: R2 buckets, DNS, Workers, Tunnels.
2348    Cloudflare,
2349    /// Hetzner Cloud + Object Storage account.
2350    Hetzner,
2351    /// Vultr cloud VPS — auto-provisioned via the `cloud.vps.*` Envoy
2352    /// (`VultrEnvoy`), the burst/scaling counterpart to Hetzner. Driver-backed.
2353    Vultr,
2354    /// BYO bare/static node (OVH, on-prem, anything we did NOT provision via a
2355    /// cloud API). Brought up over SSH (`stand-up-yubaba.sh` / `yah cloud
2356    /// machine bootstrap`); reach is declared in the machine's `[connect]`
2357    /// block. No create/destroy driver — placement-only.
2358    Static,
2359    /// Built-in static-file server bound to localhost. Inline-only; never
2360    /// declared as a standalone provider file because it carries no creds.
2361    LocalStatic,
2362    /// Local container runtime (orbstack/colima/docker). Configured by a
2363    /// provider file under `.yah/infra/providers/` so the discovery hints +
2364    /// runtime override sit in one place.
2365    LocalContainer,
2366    /// Dev-tier compute: the component runs as a kamaji-supervised host
2367    /// process against the operator's real workspace, no container and no
2368    /// build step per edit. Inline-only — it carries no credentials, and
2369    /// "the machine you are sitting at" is not an account to point at.
2370    /// See `reconciler::local_process`.
2371    LocalProcess,
2372    /// Containerized miniflare (workerd subprocess) fronting MinIO — the
2373    /// pond-tier stand-in for a CF Worker + R2 static surface. Inline-only;
2374    /// the reconciler spawns miniflare via the JS runtime and starts a MinIO
2375    /// container on the local-container runtime.
2376    MiniflareContainer,
2377    /// Containerized MinIO providing an S3-compatible API — the pond-tier
2378    /// stand-in for Cloudflare R2. Inline-only; the reconciler spins up the
2379    /// container on the local-container runtime and auto-creates the declared
2380    /// bucket on first up.
2381    MinioContainer,
2382    /// Dev-tier PostgreSQL — a real server speaking real pgwire on loopback,
2383    /// supervised by kamaji as the `yah-pg-dev` workload (W265, R584-F1). No
2384    /// docker daemon: the driver fetches a per-arch PostgreSQL tarball on first
2385    /// run and `initdb`s a cluster under `.yah/infra/state/dev/pg/`.
2386    ///
2387    /// Inline-only — it carries no credentials worth a provider file (the
2388    /// cluster is loopback-bound with a fixed dev password). Declared under
2389    /// [`MirrorConfig::drivers`], not `providers`:
2390    ///
2391    /// ```toml
2392    /// [drivers.pg]
2393    /// kind = "local-pg-dev"
2394    /// ```
2395    LocalPgDev,
2396}
2397
2398/// A provider account/runtime binding from `.yah/infra/providers/<id>.toml`.
2399///
2400/// The `kind` discriminator picks the schema for the remaining fields. Strict
2401/// on `kind` (unknown values are a parse error); permissive on per-kind fields
2402/// (carried as a free-form map so this loader stays stable as new fields land).
2403/// B3/B4 will tighten by introducing typed variants alongside JSON Schema.
2404#[derive(Debug, Clone, Serialize, Deserialize)]
2405#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2406pub struct ProviderConfig {
2407    pub schema_version: u32,
2408    pub id: String,
2409    pub kind: Provider,
2410    /// Reference into the OS keystore for live credentials (e.g.
2411    /// `"keystore://cloudflare/yah"`). `None` for providers that don't need
2412    /// creds (local-static, optionally local-container).
2413    #[serde(default, skip_serializing_if = "Option::is_none")]
2414    pub credentials: Option<String>,
2415    /// Kind-specific fields. Examples:
2416    /// - cloudflare: `default_zone`
2417    /// - hetzner:    `default_location`, `default_server_type`, `ssh_keys`
2418    /// - local-container: `runtime`, `discovery`
2419    #[serde(flatten)]
2420    #[cfg_attr(
2421        feature = "json-schema",
2422        schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
2423    )]
2424    pub fields: BTreeMap<String, toml::Value>,
2425}
2426
2427impl ProviderConfig {
2428    /// Parse a single `providers/<id>.toml` file.
2429    pub fn load(path: &Path) -> Result<Self> {
2430        let src =
2431            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
2432        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2433    }
2434}
2435
2436/// An operator-facing service declaration from
2437/// `.yah/services/<svc>/service.toml`.
2438///
2439/// A service groups one or more components (a static surface, a containerized
2440/// API, an almanac…) under a single domain. Mirrors project the service onto
2441/// concrete infra; see [`MirrorConfig`].
2442#[derive(Debug, Clone, Serialize, Deserialize)]
2443#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2444pub struct ServiceConfig {
2445    pub schema_version: u32,
2446    pub name: String,
2447    pub domain: String,
2448    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2449    pub components: Vec<ServiceComponent>,
2450    /// Databases this service exposes, grouped by environment (W241). Every
2451    /// entry becomes a data-workbench / `sql_*` catalog id of the shape
2452    /// `<env>:<service>:<name>` (e.g. `pond:scrabcake:main`). Optional and
2453    /// default-empty — services without databases omit the `[db]` table
2454    /// entirely.
2455    #[serde(default, skip_serializing_if = "DbCatalog::is_empty")]
2456    pub db: DbCatalog,
2457}
2458
2459impl ServiceConfig {
2460    /// Parse a single `services/<svc>/service.toml` file.
2461    pub fn load(path: &Path) -> Result<Self> {
2462        let src =
2463            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
2464        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2465    }
2466
2467    /// Persist to `.yah/services/<name>/service.toml`, creating the service
2468    /// directory if needed. Create-or-overwrite — the canonical replacement
2469    /// for the legacy `sites.json` write path. `workspace_root` is the camp
2470    /// dir (the parent of `.yah/`).
2471    pub fn save(&self, workspace_root: &Path) -> Result<()> {
2472        let dir = crate::paths::service_dir(workspace_root, &self.name);
2473        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
2474        let path = crate::paths::service_toml(workspace_root, &self.name);
2475        let s = toml::to_string_pretty(self)
2476            .with_context(|| format!("serializing service {}", self.name))?;
2477        std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
2478    }
2479
2480    /// Remove `.yah/services/<name>/` and everything under it (service.toml
2481    /// plus its `mirrors/`). Returns `false` when the directory was already
2482    /// absent, so callers can distinguish "deleted" from "no-op".
2483    pub fn delete(workspace_root: &Path, name: &str) -> Result<bool> {
2484        let dir = crate::paths::service_dir(workspace_root, name);
2485        if !dir.exists() {
2486            return Ok(false);
2487        }
2488        std::fs::remove_dir_all(&dir).with_context(|| format!("removing {}", dir.display()))?;
2489        Ok(true)
2490    }
2491}
2492
2493/// A git source for a component (R561-F1, "BYO git").
2494///
2495/// When a [`ServiceComponent`] sets `git`, the component's code is NOT in this
2496/// workspace — it lives in an external repo that the reconciler shallow-clones
2497/// into a source cache before build (approach A: clone-at-reconcile, so config
2498/// load + validation stay offline). The component's `path` is then interpreted
2499/// relative to `<checkout>/<subdir>` instead of the workspace root.
2500#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2501#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2502pub struct GitSource {
2503    /// Clone URL (https or ssh) of the tenant repo.
2504    pub repo: String,
2505    /// Branch, tag, or commit SHA to check out. Defaults to `"main"`.
2506    #[serde(default = "default_git_ref")]
2507    pub r#ref: String,
2508    /// Optional sub-directory within the repo that the workspace is rooted at
2509    /// (e.g. a monorepo's `site/`). `path` is resolved relative to this.
2510    #[serde(default, skip_serializing_if = "Option::is_none")]
2511    pub subdir: Option<String>,
2512}
2513
2514fn default_git_ref() -> String {
2515    "main".to_string()
2516}
2517
2518/// How to reach an external infra root (R615-F1 / W274, "linked infra
2519/// sources"): a filesystem link to a sibling camp's live tree, or a git
2520/// checkout of an extracted infra repo.
2521#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2522#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2523#[serde(tag = "kind", rename_all = "kebab-case")]
2524pub enum InfraSourceKind {
2525    /// Filesystem link — reads the owner's live tree. The dev-loop shortcut,
2526    /// and the whole story until W274's "infra as its own repo" end-state.
2527    /// `path` is relative to *this* camp's root; infra is read from
2528    /// `<path>/.yah/infra/`.
2529    Path {
2530        path: String,
2531    },
2532    /// Git link — reused verbatim from [`GitSource`] (R561, "BYO git"),
2533    /// lifted here from "a component's code" to "a camp's infra registry."
2534    /// Loading stays offline (W274 §3): `yah infra sync` (R615-T3) is what
2535    /// clones/pulls this into `.yah/cache/infra/<owner>/`; `CloudConfig::load`
2536    /// only ever reads that cache, never the network.
2537    Git(GitSource),
2538}
2539
2540/// Write-gate for a linked [`InfraSource`] (R615-F1 / W274).
2541///
2542/// An enum, not a bool: the two states today are "borrower renders/plans but
2543/// cannot reconcile" and "this camp genuinely co-administers the shared
2544/// root," and a future read-write-with-approval tier is a third variant, not
2545/// a renamed boolean.
2546#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2547#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2548#[serde(rename_all = "kebab-case")]
2549pub enum SourceMode {
2550    /// Borrower can render and plan against the linked entries but cannot
2551    /// reconcile/mutate them — the owner remains the single manager. Default:
2552    /// a borrower is opt-in to write access, never opt-out of the safe state.
2553    #[default]
2554    ReadOnly,
2555    /// Escape hatch for a camp that genuinely co-administers a shared root.
2556    Manage,
2557}
2558
2559/// One `[[source]]` entry in `.yah/infra/sources.toml` (R615-F1 / W274) — an
2560/// external infra root this camp borrows machines/providers from.
2561#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2562#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2563pub struct InfraSource {
2564    /// Logical owner name, badged in the Infra tab (e.g. `"yah"`). Distinct
2565    /// from any camp/repo name the `kind` resolves through — this is what an
2566    /// operator sees on a borrowed row, not a path.
2567    pub owner: String,
2568    #[serde(flatten)]
2569    pub kind: InfraSourceKind,
2570    #[serde(default)]
2571    pub mode: SourceMode,
2572    /// Optional filter — name globs or mesh-tag selectors — to borrow a
2573    /// subset of the source root rather than everything it declares. Empty
2574    /// (the default) borrows everything.
2575    #[serde(default)]
2576    pub select: Vec<String>,
2577}
2578
2579fn default_sources_schema_version() -> u32 {
2580    1
2581}
2582
2583/// `.yah/infra/sources.toml` — the ordered list of external infra roots this
2584/// camp borrows from (R615-F1 / W274).
2585#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2586#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2587pub struct SourcesConfig {
2588    #[serde(default = "default_sources_schema_version")]
2589    pub schema_version: u32,
2590    /// `[[source]]` entries, in declaration order — overlay order matters
2591    /// when two linked sources both name the same machine (R615-F2).
2592    #[serde(default, rename = "source")]
2593    pub source: Vec<InfraSource>,
2594}
2595
2596impl Default for SourcesConfig {
2597    fn default() -> Self {
2598        Self {
2599            schema_version: default_sources_schema_version(),
2600            source: Vec::new(),
2601        }
2602    }
2603}
2604
2605impl SourcesConfig {
2606    /// Load `<infra_dir>/sources.toml`. A missing file is not an error —
2607    /// every camp without linked infra has none, which today is every camp —
2608    /// and yields an empty source list rather than `Err`.
2609    pub fn load(infra_dir: &Path) -> Result<Self> {
2610        let path = infra_dir.join("sources.toml");
2611        if !path.exists() {
2612            return Ok(Self::default());
2613        }
2614        let src =
2615            std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
2616        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2617    }
2618}
2619
2620impl InfraSource {
2621    /// Human-readable descriptor of *which* source this is, for
2622    /// [`InfraOrigin::source`] — distinguishes two linked sources from the
2623    /// same owner. Never includes credentials: `GitSource.repo` is a clone
2624    /// URL (https/ssh), the same thing R561 already treats as safe to log,
2625    /// with any real secret resolved separately via `keystore://` (W274's
2626    /// own precedent).
2627    fn describe(&self) -> String {
2628        match &self.kind {
2629            InfraSourceKind::Path { path } => format!("path:{path}"),
2630            InfraSourceKind::Git(g) => format!("git:{}@{}", g.repo, g.r#ref),
2631        }
2632    }
2633
2634    /// Resolve this source to an infra root directory (R615-F2 / W274 §3).
2635    /// Does no I/O and touches no network: `path` sources read the owner's
2636    /// live tree directly; `git` sources read wherever `yah infra sync`
2637    /// (R615-T3) last synced to, which may not exist yet (an unsynced git
2638    /// source overlays nothing, not an error — see [`load_dir_tolerant`]).
2639    ///
2640    /// `git.subdir` (reused verbatim from [`GitSource`]/R561) is honoured
2641    /// exactly like the component case: the checkout root when unset, or
2642    /// `<checkout>/<subdir>` when set — e.g. `subdir = "infra"` for a
2643    /// monorepo whose infra registry lives under `infra/` rather than at the
2644    /// clone's root. `yah infra sync` (R615-T3) clones into the *checkout*
2645    /// root ([`crate::paths::infra_source_cache_dir`]), never into a
2646    /// subdir-suffixed path, so this is the one place that appends `subdir`.
2647    fn infra_root(&self, workspace_root: &Path) -> std::path::PathBuf {
2648        match &self.kind {
2649            InfraSourceKind::Path { path } => workspace_root.join(path).join(".yah").join("infra"),
2650            InfraSourceKind::Git(g) => {
2651                let checkout = crate::paths::infra_source_cache_dir(workspace_root, &self.owner);
2652                match g.subdir.as_deref() {
2653                    Some(subdir) => checkout.join(subdir),
2654                    None => checkout,
2655                }
2656            }
2657        }
2658    }
2659}
2660
2661/// Provenance for a [`MachineConfig`] or [`ProviderConfig`] pulled in from a
2662/// linked `.yah/infra/sources.toml` entry, rather than declared in this
2663/// camp's own `.yah/infra/` (R615-F2 / W274).
2664///
2665/// Lives in [`CloudConfig::machine_origins`] / `provider_origins`, keyed by
2666/// name/id, rather than as a field on `MachineConfig`/`ProviderConfig`
2667/// themselves: those two types are constructed by struct literal in test
2668/// helpers across several crates (including ones this ticket has no reason to
2669/// touch), so widening either shape would ripple out past this crate for no
2670/// semantic gain — origin is a property of *this load*, not an inherent
2671/// property of the machine/provider. A name absent from the map is
2672/// camp-local; present means borrowed, and the Infra tab (R615-F4) / reconcile
2673/// gating (`InfraSource::mode`, copied onto `mode` below) read it from here.
2674#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2675#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2676pub struct InfraOrigin {
2677    /// The [`InfraSource::owner`] that supplied this entry, e.g. `"yah"`.
2678    pub owner: String,
2679    /// Which source, rendered — see [`InfraSource::describe`].
2680    pub source: String,
2681    /// The write-gate that applied when this entry was overlaid — copied
2682    /// from [`InfraSource::mode`] so a caller holding just the machine/
2683    /// provider doesn't need the source list in hand to know it's borrowed
2684    /// read-only.
2685    pub mode: SourceMode,
2686}
2687
2688/// Like [`load_dir`], but tolerant **per file**: a foreign infra root (an
2689/// owner's live tree, or a synced git checkout) can carry entries this
2690/// binary's `T` predates — noisetable's pre-migration machines used an older
2691/// schema than yah's, and the reverse will happen too as each side evolves
2692/// independently. One unparseable file on a source this camp doesn't own must
2693/// never sink every other entry in the same directory, let alone this camp's
2694/// own load (R615-F2 gotcha). Contrast [`load_dir`], which stays strict for
2695/// camp-local files, where a malformed TOML genuinely should be a hard error.
2696///
2697/// Returns the entries that parsed, plus `(path, error)` for every file that
2698/// didn't — the caller logs those, it doesn't drop them silently. A missing
2699/// or unreadable directory yields `(vec![], vec![])`, same "no entries" as
2700/// `load_dir`'s `!dir.exists()` case (an unsynced git source, or a source
2701/// root with no `providers/` at all, are both normal, not warnings).
2702fn load_dir_tolerant<T: for<'de> Deserialize<'de>>(
2703    dir: &Path,
2704) -> (Vec<T>, Vec<(std::path::PathBuf, anyhow::Error)>) {
2705    let Ok(read_dir) = std::fs::read_dir(dir) else {
2706        return (Vec::new(), Vec::new());
2707    };
2708    let mut entries: Vec<_> = read_dir.filter_map(|e| e.ok()).collect();
2709    entries.sort_by_key(|e| e.file_name());
2710
2711    let mut items = Vec::new();
2712    let mut skipped = Vec::new();
2713    for entry in entries {
2714        let path = entry.path();
2715        if path.extension().map_or(true, |e| e != "toml") {
2716            continue;
2717        }
2718        let parsed = std::fs::read_to_string(&path)
2719            .with_context(|| format!("reading {}", path.display()))
2720            .and_then(|src| {
2721                toml::from_str::<T>(&src).with_context(|| format!("parsing {}", path.display()))
2722            });
2723        match parsed {
2724            Ok(item) => items.push(item),
2725            Err(e) => skipped.push((path, e)),
2726        }
2727    }
2728    (items, skipped)
2729}
2730
2731/// Whether a borrowed machine passes an [`InfraSource::select`] filter
2732/// (R615-F2 / W274). Empty `select` borrows everything. A non-empty `select`
2733/// entry matches either the machine's exact `name` or literal membership in
2734/// its `mesh_tags` — the one shape W274's own example uses
2735/// (`select = ["tag:cloud-runner"]`). Not a glob engine: mesh tags are
2736/// already flat strings compared for exact equality everywhere else in this
2737/// crate (see `resolve_machine_by_mesh_tags`), so a select entry is that same
2738/// comparison, not a new pattern language.
2739fn machine_matches_select(machine: &MachineConfig, select: &[String]) -> bool {
2740    select.is_empty()
2741        || select
2742            .iter()
2743            .any(|s| *s == machine.name || machine.mesh_tags.contains(s))
2744}
2745
2746/// Overlay every linked `.yah/infra/sources.toml` source's machines and
2747/// providers into `machines`/`providers`, recording provenance into
2748/// `machine_origins`/`provider_origins` (R615-F2 / W274). Must be called
2749/// AFTER camp-local entries are already in both vectors and both origin maps
2750/// are seeded with every camp-local name/id already `HashSet`-tracked as
2751/// "seen": collision resolution is "first writer wins," so seeding with
2752/// camp-local first is what makes camp-local win over every source, and an
2753/// earlier source win over a later one.
2754///
2755/// `select` filters which machines a source contributes; it does not apply
2756/// to providers (nothing in W274 or the source ticket describes a
2757/// provider-scoped filter — every provider a source declares either overlays
2758/// whole or, on a name collision, doesn't).
2759fn overlay_infra_sources(
2760    workspace_root: &Path,
2761    sources: &SourcesConfig,
2762    machines: &mut Vec<MachineConfig>,
2763    providers: &mut Vec<ProviderConfig>,
2764    machine_origins: &mut BTreeMap<String, InfraOrigin>,
2765    provider_origins: &mut BTreeMap<String, InfraOrigin>,
2766) {
2767    let mut seen_machine_names: std::collections::HashSet<String> =
2768        machines.iter().map(|m| m.name.clone()).collect();
2769    let mut seen_provider_ids: std::collections::HashSet<String> =
2770        providers.iter().map(|p| p.id.clone()).collect();
2771
2772    for source in &sources.source {
2773        let root = source.infra_root(workspace_root);
2774        let origin = InfraOrigin {
2775            owner: source.owner.clone(),
2776            source: source.describe(),
2777            mode: source.mode,
2778        };
2779
2780        let (foreign_machines, skipped) = load_dir_tolerant::<MachineConfig>(&root.join("machines"));
2781        for (path, e) in skipped {
2782            tracing::warn!(
2783                "infra source {:?} ({}): skipping unparseable machine {}: {e:#}",
2784                source.owner,
2785                root.display(),
2786                path.display()
2787            );
2788        }
2789        for m in foreign_machines {
2790            if seen_machine_names.contains(&m.name) {
2791                continue; // camp-local, or an earlier source, already claimed this name
2792            }
2793            if !machine_matches_select(&m, &source.select) {
2794                continue;
2795            }
2796            seen_machine_names.insert(m.name.clone());
2797            machine_origins.insert(m.name.clone(), origin.clone());
2798            machines.push(m);
2799        }
2800
2801        let (foreign_providers, skipped) = load_dir_tolerant::<ProviderConfig>(&root.join("providers"));
2802        for (path, e) in skipped {
2803            tracing::warn!(
2804                "infra source {:?} ({}): skipping unparseable provider {}: {e:#}",
2805                source.owner,
2806                root.display(),
2807                path.display()
2808            );
2809        }
2810        for p in foreign_providers {
2811            if seen_provider_ids.contains(&p.id) {
2812                continue;
2813            }
2814            seen_provider_ids.insert(p.id.clone());
2815            provider_origins.insert(p.id.clone(), origin.clone());
2816            providers.push(p);
2817        }
2818    }
2819}
2820
2821/// One component of a [`ServiceConfig`]. The `kind` (e.g. `"mesofact-static"`,
2822/// `"almanac"`, `"container"`) selects which reconciler runs against the
2823/// pointed-at workload manifest.
2824#[derive(Debug, Clone, Serialize, Deserialize)]
2825#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2826pub struct ServiceComponent {
2827    pub id: String,
2828    pub kind: String,
2829    /// Path of the directory holding this component's `workload.toml`. Relative
2830    /// to the workspace root for in-tree components, or to the materialized
2831    /// `<checkout>/<subdir>` when [`git`](Self::git) is set.
2832    pub path: String,
2833    /// Optional external git source (R561-F1). When set, the component's code
2834    /// is materialized by shallow-clone before build; see [`GitSource`].
2835    #[serde(default, skip_serializing_if = "Option::is_none")]
2836    pub git: Option<GitSource>,
2837    /// Operator-facing role label, e.g. `"static"`, `"dynamic"`, `"compute"`.
2838    pub role: String,
2839    /// Optional artifact kind this component publishes (`"static"`,
2840    /// `"container-image"`, …). Drives mirror provider-slot routing.
2841    #[serde(default, skip_serializing_if = "Option::is_none")]
2842    pub publishes: Option<String>,
2843    /// URL sub-path a static component's build output is published under,
2844    /// relative to the service's publish prefix (R746). `None` = the service
2845    /// root, which is what every pre-R746 component means.
2846    ///
2847    /// Static publishers lay a component's `out_dir` down at
2848    /// `<bucket>/<service>/<env>/…` and the front door fetches
2849    /// `${ASSET_ORIGIN}/<request path>` — the request path *is* the key. So a
2850    /// service with two static components had them overwrite each other at
2851    /// one prefix, and there was no way to say "this bundle serves under
2852    /// /app". `mount` is that: it appends to the publish prefix, which makes
2853    /// the URL sub-path and the storage sub-path the same string by
2854    /// construction rather than by two manifests agreeing.
2855    ///
2856    /// Cross-checked against the domain route that names the component
2857    /// ([`CloudConfig::cross_ref_validate`]): a component mounted at `/app`
2858    /// must be routed at `/app` or `/app/*`, because a disagreement means
2859    /// requests land on a prefix nothing published to — a 404 whose cause is
2860    /// two files apart.
2861    #[serde(default, skip_serializing_if = "Option::is_none")]
2862    pub mount: Option<String>,
2863    /// Sync-wave index (0-based). Components in wave 0 roll out in parallel
2864    /// first; the reconciler waits for all wave-N components to become healthy
2865    /// before starting wave N+1. Defaults to 0 (all components in one wave).
2866    #[serde(default, skip_serializing_if = "is_zero_u32")]
2867    pub wave: u32,
2868}
2869
2870#[inline]
2871fn is_zero_u32(n: &u32) -> bool {
2872    *n == 0
2873}
2874
2875/// A service's declared databases, grouped by environment (W241 §Sections).
2876/// Parsed from the `[db]` table of `service.toml`; each `[[db.<env>]]` array
2877/// entry names one database. The environment tag drives backend selection at
2878/// query time (see the data-workbench's `db.query` / the `sql_*` MCP tools):
2879/// `dev` = local file, `pond` = a DB inside the running pond container stack
2880/// (reached on a declared localhost port), `cloud` = a remote libSQL/Turso or
2881/// Postgres endpoint whose auth comes from an env var (never stored in TOML).
2882#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
2883#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2884pub struct DbCatalog {
2885    /// Local-file SQLite databases used in dev mode.
2886    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2887    pub dev: Vec<DevDb>,
2888    /// Databases running inside the pond container stack.
2889    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2890    pub pond: Vec<PondDb>,
2891    /// Remote cloud databases (Turso, Postgres).
2892    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2893    pub cloud: Vec<CloudDb>,
2894}
2895
2896impl DbCatalog {
2897    /// True when no database is declared in any environment. Lets
2898    /// [`ServiceConfig`] skip serializing an empty `[db]` table.
2899    pub fn is_empty(&self) -> bool {
2900        self.dev.is_empty() && self.pond.is_empty() && self.cloud.is_empty()
2901    }
2902}
2903
2904/// A dev-mode local SQLite database (`[[db.dev]]`). `path` is resolved
2905/// relative to the workspace root and opened as a local file — read/write, no
2906/// network, no auth.
2907#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2908#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2909pub struct DevDb {
2910    /// Logical name, unique within the service's `dev` list. Forms the `name`
2911    /// segment of the catalog id `dev:<service>:<name>`.
2912    pub name: String,
2913    /// On-disk SQLite path, relative to the workspace root (or absolute).
2914    pub path: String,
2915}
2916
2917/// A database running inside the pond container stack (`[[db.pond]]`). The
2918/// pond publishes the DB on a localhost TCP port; the hub connects to
2919/// `127.0.0.1:<port>` when the pond is up and returns a clear error when it is
2920/// not. Either `port` (defaulting to a libSQL/`sqld` HTTP endpoint) or a full
2921/// `url` must be given.
2922#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2923#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2924pub struct PondDb {
2925    /// Logical name, unique within the service's `pond` list.
2926    pub name: String,
2927    /// Localhost TCP port the pond publishes the DB on. Interpreted per
2928    /// [`kind`](Self::kind). Mutually complete with `url` (provide one).
2929    #[serde(default, skip_serializing_if = "Option::is_none")]
2930    pub port: Option<u16>,
2931    /// Full connection URL, overriding `port` when set (e.g. a non-localhost
2932    /// host or an explicit scheme).
2933    #[serde(default, skip_serializing_if = "Option::is_none")]
2934    pub url: Option<String>,
2935    /// Wire protocol the pond DB speaks. Selects how a bare `port` becomes a
2936    /// URL: `turso` → `http://127.0.0.1:<port>` (libSQL/`sqld` over Hrana),
2937    /// `postgres` → `postgres://127.0.0.1:<port>`.
2938    #[serde(default)]
2939    pub kind: PondDbKind,
2940}
2941
2942/// Wire protocol of a [`PondDb`].
2943#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2944#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2945#[serde(rename_all = "kebab-case")]
2946pub enum PondDbKind {
2947    /// libSQL / `sqld` over Hrana HTTP — the default.
2948    #[default]
2949    Turso,
2950    /// PostgreSQL wire protocol.
2951    Postgres,
2952}
2953
2954/// A remote cloud database (`[[db.cloud]]`). The connection `url` is stored in
2955/// TOML but the credential never is — `auth_token_env` names an environment
2956/// variable the daemon reads at connect time, so the same declaration works
2957/// whether the token is provisioned service-locally or camp-shared (W241;
2958/// operator confirmed both scopes are needed). A camp-wide cloud DB not owned
2959/// by any single service is declared identically in `.yah/db/cloud.toml`.
2960#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2961#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2962pub struct CloudDb {
2963    /// Logical name, unique within its `cloud` list.
2964    pub name: String,
2965    /// Connection URL: `libsql://…` / `http(s)://…` (Turso, `sqld`) or
2966    /// `postgres://…`.
2967    pub url: String,
2968    /// Name of the environment variable holding the auth token. Resolved in
2969    /// the daemon at connect time (value never stored on disk). For a libSQL
2970    /// URL the token is threaded as `?auth_token=…`.
2971    #[serde(default, skip_serializing_if = "Option::is_none")]
2972    pub auth_token_env: Option<String>,
2973}
2974
2975/// A camp-shared cloud database catalog, parsed from `.yah/db/cloud.toml`.
2976/// These are cloud DBs not owned by any single service — declared once at camp
2977/// scope and addressed as `cloud:<name>` (two-segment id), distinct from a
2978/// service-local `cloud:<service>:<name>`.
2979#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
2980#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2981pub struct CampCloudDbs {
2982    #[serde(default, rename = "cloud", skip_serializing_if = "Vec::is_empty")]
2983    pub cloud: Vec<CloudDb>,
2984}
2985
2986impl CampCloudDbs {
2987    /// Load `<camp_root>/.yah/db/cloud.toml`, or an empty catalog if the file
2988    /// is absent (the common case — most camps declare no shared cloud DBs).
2989    pub fn load(camp_root: &Path) -> Result<Self> {
2990        let path = camp_root.join(".yah/db/cloud.toml");
2991        if !path.exists() {
2992            return Ok(Self::default());
2993        }
2994        let src = std::fs::read_to_string(&path)
2995            .with_context(|| format!("reading {}", path.display()))?;
2996        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2997    }
2998}
2999
3000/// Topological shape of a mirror — how its providers sit relative to each other.
3001#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3002#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3003#[serde(rename_all = "kebab-case")]
3004pub enum MirrorShape {
3005    /// Single machine hosts compute (and any non-Cloudflare-fronted static).
3006    SingleMachine,
3007    /// Operator-local dev mirror — static via built-in file server, compute
3008    /// via the local container runtime.
3009    Local,
3010    /// Multi-machine deployment (machines listed per provider slot).
3011    MultiMachine,
3012}
3013
3014/// Which public-ingress provider fronts this mirror's compute (W267, R594-F11).
3015///
3016/// Both arms answer exactly one question — *given these local workload ports,
3017/// make them publicly reachable at these hostnames* — and they differ only in
3018/// where the ingress rules live and who supervises the front door:
3019///
3020/// | | [`CloudflareTunnel`](Self::CloudflareTunnel) | [`Passway`](Self::Passway) |
3021/// |---|---|---|
3022/// | Ingress rules live | Cloudflare's API (token-form tunnels are remotely-managed) | the pingora `Backends` set in the proxy process |
3023/// | How they get there | an API call per deployed workload | passway polls `GET /service-records?ready=true` |
3024/// | Front door lifecycle | a kamaji-supervised `cloudflared` appliance | a kamaji-supervised passway appliance |
3025///
3026/// Flipping this field is the whole tier ladder: rented edge → sovereign edge
3027/// is a one-line mirror edit, not a rewrite. The provider owns **addressing**
3028/// and never **rendering** — the W173 render cube stays in mesofact's manifest.
3029#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
3030#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3031#[serde(rename_all = "kebab-case")]
3032pub enum IngressProvider {
3033    /// No public front door for this mirror. The default: a mirror that
3034    /// publishes to R2 behind a Worker, or a mesh-only compute tier, has no
3035    /// ingress provider to reconcile.
3036    #[default]
3037    None,
3038    /// Rented edge — `cloudflared` dials *out* from the node to Cloudflare's
3039    /// edge. Zero inbound ports, no TLS to manage on the box, hostname rules
3040    /// held in Cloudflare's API.
3041    CloudflareTunnel,
3042    /// Sovereign edge — passway terminates TLS on the node and load-balances
3043    /// an upstream set discovered from yubaba's service records.
3044    Passway,
3045}
3046
3047impl IngressProvider {
3048    /// `true` when this mirror declares a front door that has to be reconciled.
3049    pub fn is_declared(self) -> bool {
3050        !matches!(self, Self::None)
3051    }
3052
3053    /// Kebab-case wire name, as it appears in `mirrors/<env>.toml`.
3054    pub fn as_str(self) -> &'static str {
3055        match self {
3056            Self::None => "none",
3057            Self::CloudflareTunnel => "cloudflare-tunnel",
3058            Self::Passway => "passway",
3059        }
3060    }
3061}
3062
3063/// One declared **edge**: a front door, the slots it fronts, and the nodes it
3064/// is placed on (W305 F2).
3065///
3066/// A mirror declares a *list* of these, which is what lets one service mix
3067/// front doors — cloudflare for the public web tier, passway for an internal or
3068/// high-throughput one. Before this, [`MirrorConfig::ingress`] was a single
3069/// [`IngressProvider`], so a mirror could **swap** front doors but never mix
3070/// them.
3071///
3072/// ```toml
3073/// [[ingress]]
3074/// provider = "passway"
3075/// machines = ["us-east-001", "us-south-001"]
3076/// slots    = ["bundle"]
3077///
3078/// [[ingress]]
3079/// provider  = "cloudflare-tunnel"
3080/// hostnames = ["issues.yah.dev"]
3081/// ```
3082///
3083/// **The per-node appliance is derived from this, never declared beside it.**
3084/// An edge does invoke a cloudflared or passway process on a box, but that is a
3085/// *consequence* of the service's declaration:
3086/// [`collate_front_doors`](crate::reconciler::collate_front_doors) walks every
3087/// service and derives what each node must run. Declaring it node-side too is
3088/// what produces two sources of truth for one fact.
3089#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3090#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3091pub struct IngressEdge {
3092    /// Which front door this edge is. [`IngressProvider::None`] is rejected at
3093    /// plan time — an edge that fronts with nothing is always a typo, never an
3094    /// intent (write no edge instead).
3095    pub provider: IngressProvider,
3096    /// Nodes this front door is placed on — **independent of where the fronted
3097    /// workload runs** (R330-F37).
3098    ///
3099    /// Empty falls back to the fronted slot's own `machine` / `machines`, which
3100    /// is the co-located shape every mirror had before front-door placement was
3101    /// expressible. Listing several is what lets the ingress tier and the
3102    /// service tier scale independently: **N front doors over ONE deployment**,
3103    /// one rendered copy, so no cache coherence to settle.
3104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3105    pub machines: Vec<String>,
3106    /// Provider slot roles this edge fronts (`"bundle"`, `"compute"`, …).
3107    ///
3108    /// One of the two selectors. With a single edge both may be empty, meaning
3109    /// "every fronted slot" — the legacy shape. With **several** edges a
3110    /// selector is mandatory on each, and the partition must be total and
3111    /// disjoint: a slot claimed by no edge, or by two, is an error naming it.
3112    /// An implicit catch-all across mixed front doors would silently publish a
3113    /// service through the wrong one.
3114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3115    pub slots: Vec<String>,
3116    /// Public hostnames this edge fronts — the other selector, for partitioning
3117    /// by what the world dials rather than by which slot serves it.
3118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3119    pub hostnames: Vec<String>,
3120    /// Cloudflare Tunnel id this edge publishes through, overriding the
3121    /// fronting machine's [`MachineConfig::cloudflared`].
3122    ///
3123    /// This is W267 Gap 3's real fix, and it is the *service* side of it: a node
3124    /// can join two cohorts' orange networks, and since §Granularity argues the
3125    /// tunnel credential **is** the isolation boundary, which cohort a given
3126    /// service fronts through is a property of the service, not of the box.
3127    /// `MachineConfig.cloudflared` stays as the per-node default (one tunnel is
3128    /// the common case, and the credential does live on the node), but it is no
3129    /// longer the only way to say it — so the node never has to enumerate
3130    /// cohorts.
3131    #[serde(default, skip_serializing_if = "Option::is_none")]
3132    pub tunnel_id: Option<String>,
3133    /// Infra provider id whose credentials this edge's front door authenticates
3134    /// with — `use = "cloudflare"`, resolved through
3135    /// `.yah/infra/providers/<id>.toml` exactly as a slot's `use` is.
3136    ///
3137    /// Same split as [`tunnel_id`](Self::tunnel_id), one field over: whose
3138    /// Cloudflare account holds the tunnel is a property of the **front door**,
3139    /// not of the box that runs the compute. Without this the account was read
3140    /// off the fronted slot's own `use`, which conflates two unrelated facts —
3141    /// and is unwritable for a slot whose compute provider is `kind = "static"`
3142    /// (a borrowed bare box: placement only, no credentials). Such a mirror had
3143    /// no way to name a Cloudflare account at all, short of writing
3144    /// `use = "cloudflare"` on the compute slot and lying about what runs it
3145    /// (R845).
3146    ///
3147    /// `None` falls back to the fronted slot's `use`, which is what every
3148    /// mirror written before this field meant.
3149    #[serde(default, rename = "use", skip_serializing_if = "Option::is_none")]
3150    pub provider_id: Option<String>,
3151}
3152
3153impl IngressEdge {
3154    /// An edge with no selector — fronts every fronted slot, legal only when it
3155    /// is the mirror's only edge.
3156    pub fn all_slots(provider: IngressProvider, machines: Vec<String>) -> Self {
3157        Self {
3158            provider,
3159            machines,
3160            slots: Vec::new(),
3161            hostnames: Vec::new(),
3162            tunnel_id: None,
3163            provider_id: None,
3164        }
3165    }
3166
3167    /// `true` when this edge names which slots/hostnames it fronts.
3168    pub fn has_selector(&self) -> bool {
3169        !self.slots.is_empty() || !self.hostnames.is_empty()
3170    }
3171
3172    /// Does this edge claim the rule derived from `slot` publishing `hostname`?
3173    ///
3174    /// A selectorless edge claims everything; that is checked to be
3175    /// unambiguous (one edge only) before this is consulted.
3176    pub fn claims(&self, slot: &str, hostname: &str) -> bool {
3177        if !self.has_selector() {
3178            return true;
3179        }
3180        self.slots.iter().any(|s| s == slot) || self.hostnames.iter().any(|h| h == hostname)
3181    }
3182
3183    /// Human-readable identity for an error message — the provider plus
3184    /// whichever selector was written.
3185    pub fn label(&self) -> String {
3186        let sel = match (self.slots.is_empty(), self.hostnames.is_empty()) {
3187            (true, true) => "no selector".to_string(),
3188            (false, true) => format!("slots = {:?}", self.slots),
3189            (true, false) => format!("hostnames = {:?}", self.hostnames),
3190            (false, false) => format!("slots = {:?} + hostnames = {:?}", self.slots, self.hostnames),
3191        };
3192        format!("[[ingress]] provider = {:?} ({sel})", self.provider.as_str())
3193    }
3194}
3195
3196/// A mirror's `ingress` declaration, in either spelling.
3197///
3198/// The list is the general form; the bare provider is shorthand for the single
3199/// edge fronting everything, and is kept rather than migrated because it is the
3200/// honest spelling for the common case — one service, one front door. Both
3201/// normalize to the same `Vec<IngressEdge>` through
3202/// [`MirrorConfig::ingress_edges`], so nothing downstream branches on which was
3203/// written.
3204#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3205#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3206#[serde(untagged)]
3207pub enum IngressDecl {
3208    /// `ingress = "passway"` — one edge fronting every fronted slot, placed by
3209    /// the sibling [`MirrorConfig::ingress_machines`].
3210    Provider(IngressProvider),
3211    /// `[[ingress]]` — one entry per declared edge.
3212    Edges(Vec<IngressEdge>),
3213}
3214
3215/// Hand-written because `#[serde(untagged)]` throws the real error away.
3216///
3217/// A derived untagged `Deserialize` tries each variant and, on failure, reports
3218/// only `data did not match any variant of untagged enum IngressDecl` — so a
3219/// misspelled `provider = "passwya"` says nothing about providers, nothing about
3220/// the legal values, and points at the `[[ingress]]` header rather than the
3221/// field. Dispatching on the input shape first means each arm's own error
3222/// survives: a bad string names the legal provider vocabulary, a bad edge table
3223/// names the offending field.
3224impl<'de> Deserialize<'de> for IngressDecl {
3225    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
3226        struct DeclVisitor;
3227
3228        impl<'de> serde::de::Visitor<'de> for DeclVisitor {
3229            type Value = IngressDecl;
3230
3231            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
3232                f.write_str(
3233                    "a provider name (`ingress = \"passway\"`) or a list of edge tables \
3234                     (`[[ingress]]`)",
3235                )
3236            }
3237
3238            fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
3239                IngressProvider::deserialize(serde::de::value::StrDeserializer::new(v))
3240                    .map(IngressDecl::Provider)
3241            }
3242
3243            fn visit_seq<A: serde::de::SeqAccess<'de>>(
3244                self,
3245                seq: A,
3246            ) -> std::result::Result<Self::Value, A::Error> {
3247                Vec::<IngressEdge>::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))
3248                    .map(IngressDecl::Edges)
3249            }
3250        }
3251
3252        d.deserialize_any(DeclVisitor)
3253    }
3254}
3255
3256/// No front door — the shape of every mirror that publishes to R2 behind a
3257/// Worker, or runs a mesh-only compute tier.
3258impl Default for IngressDecl {
3259    fn default() -> Self {
3260        Self::Provider(IngressProvider::None)
3261    }
3262}
3263
3264impl IngressDecl {
3265    /// `true` when this mirror declares no front door at all.
3266    pub fn is_absent(&self) -> bool {
3267        match self {
3268            Self::Provider(p) => !p.is_declared(),
3269            Self::Edges(e) => e.is_empty(),
3270        }
3271    }
3272}
3273
3274impl From<IngressProvider> for IngressDecl {
3275    fn from(p: IngressProvider) -> Self {
3276        Self::Provider(p)
3277    }
3278}
3279
3280/// A service mirror — the projection of a [`ServiceConfig`] onto concrete
3281/// infra. Lives at `.yah/services/<svc>/mirrors/<env>.toml`.
3282#[derive(Debug, Clone, Serialize, Deserialize)]
3283#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3284pub struct MirrorConfig {
3285    pub schema_version: u32,
3286    pub shape: MirrorShape,
3287    /// Public-ingress edges fronting this mirror (W267, W305 F2). Defaults to
3288    /// none.
3289    ///
3290    /// Two spellings, one meaning — see [`IngressDecl`]. `ingress = "passway"`
3291    /// is one edge fronting everything; `[[ingress]]` entries declare several,
3292    /// each naming its provider plus the slots or hostnames it fronts. Read it
3293    /// through [`ingress_edges`](Self::ingress_edges), never by matching on the
3294    /// enum, so the two spellings cannot drift apart.
3295    ///
3296    /// Declared at mirror scope rather than per provider slot because a front
3297    /// door does **fan-in**: one `cloudflared` (or one passway) on a node
3298    /// multiplexes every hostname→port rule it fronts, so pinning one to a
3299    /// single slot would mint one edge connection per slot for no gain. An
3300    /// edge's `slots` selector is the general form of that — it groups slots
3301    /// behind one front door, it does not split a front door per slot.
3302    #[serde(default, skip_serializing_if = "IngressDecl::is_absent")]
3303    pub ingress: IngressDecl,
3304    /// Machines the front door is placed on — **independent of where the
3305    /// fronted workload runs** (R330-F37).
3306    ///
3307    /// The single-edge spelling of [`IngressEdge::machines`]: it applies to the
3308    /// one edge `ingress = "<provider>"` declares, and combining it with
3309    /// `[[ingress]]` entries is an error rather than a silent precedence rule.
3310    ///
3311    /// Empty (the default) keeps the pre-existing behaviour: the front door is
3312    /// co-located with the fronted slot's own `machine` / `machines`. That was
3313    /// never a design choice, it was an artifact of bundles binding
3314    /// `127.0.0.1` — nothing off-node could reach a workload, so a proxy had to
3315    /// sit on top of it. R599-F12 landed mesh binding, which removes the
3316    /// constraint: passway is a reverse proxy, and a valid front door needs a
3317    /// cert and an upstream it can *reach*, not a local copy of the service.
3318    ///
3319    /// Listing several machines is what lets the ingress tier and the service
3320    /// tier scale independently — **N front doors over ONE deployment**. There
3321    /// is still exactly one rendered copy of the site, so fanning the front door
3322    /// out introduces no cache-coherence problem; that only appears if you
3323    /// deploy the *workload* to every node instead.
3324    ///
3325    /// ```toml
3326    /// ingress = "passway"
3327    /// ingress_machines = ["us-east-001", "us-west-001"]
3328    /// ```
3329    ///
3330    /// Declaring this without [`ingress`](Self::ingress) is an error, not a
3331    /// no-op — it always means the operator expected a front door somewhere.
3332    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3333    pub ingress_machines: Vec<String>,
3334    /// Provider slots, keyed by role (`"static"`, `"compute"`, …). Each value
3335    /// either references a provider declared under `.yah/infra/providers/` or
3336    /// inlines a local-only provider (no creds, no infra file).
3337    ///
3338    /// A role is normally service-wide — one slot serves every component that
3339    /// shares it — but [`ReconcileCtx::slot`](crate::reconciler::ReconcileCtx::slot)
3340    /// looks up the component-qualified key `"<role>:<component id>"` first.
3341    /// A service with two components of the same role (e.g. two
3342    /// `mesofact-static` components under one mirror) declares
3343    /// `providers."static:<id>"` per component to give each its own port;
3344    /// omitting the qualifier keeps the pre-existing single-slot behavior.
3345    #[serde(default)]
3346    pub providers: BTreeMap<String, MirrorProviderSlot>,
3347    /// Capability→driver bindings, keyed by **capability** (`"pg"`, `"s3"`, …)
3348    /// rather than by slot role (W265 §Drivers).
3349    ///
3350    /// This is the generalization of [`Self::providers`]: `providers.static` /
3351    /// `providers.object_store` are the special case where the slot name and
3352    /// the capability happen to coincide, and keying by capability is what stops
3353    /// the slot enum growing one arm per tier-specific implementation. A service
3354    /// says "I need pg"; the mirror says which implementation of pg *this tier*
3355    /// uses; the app talks the same wire protocol either way and never forks.
3356    ///
3357    /// ```toml
3358    /// [drivers.pg]
3359    /// kind = "local-pg-dev"     # dev  — kamaji-supervised loopback postgres
3360    /// ```
3361    ///
3362    /// Additive in P1: `drivers` lands *alongside* `providers`, and migrating
3363    /// the existing `providers.static` / `providers.object_store` declarations
3364    /// over is a separate pass (W265 §"Open follow-ups"). A mirror that declares
3365    /// neither is unchanged.
3366    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3367    pub drivers: BTreeMap<String, MirrorProviderSlot>,
3368    /// Per-environment alias overrides for `kind = "static-asset"` components.
3369    ///
3370    /// Keys are logical names (e.g. `"whisper-default"`); values must be
3371    /// filenames present in the component's `workload.toml` catalog.
3372    /// **Resolution only** — this table may never introduce a filename absent
3373    /// from the catalog. Validated against the workload catalog at sync time.
3374    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3375    pub asset_aliases: BTreeMap<String, String>,
3376}
3377
3378impl MirrorConfig {
3379    /// This mirror's declared edges, with both spellings normalized (W305 F2).
3380    ///
3381    /// The single place `ingress` + `ingress_machines` are reconciled, so no
3382    /// consumer has to know which spelling was written. Returns an empty vec
3383    /// when the mirror declares no front door.
3384    ///
3385    /// Errors are the declarations that cannot mean anything:
3386    ///
3387    /// - `ingress_machines` with no `ingress` — front-door placement with no
3388    ///   front door to place, always a typo (R330-F37);
3389    /// - `ingress_machines` alongside `[[ingress]]` — placement declared twice,
3390    ///   in a form where one silently wins;
3391    /// - `provider = "none"` on an edge — an edge that fronts with nothing.
3392    /// The `[[ingress]]` entries exactly as written, without normalizing the
3393    /// scalar spelling or validating anything.
3394    ///
3395    /// [`ingress_edges`](Self::ingress_edges) is the one to reach for; this
3396    /// exists for the checks that must run *before* a mirror is known to be
3397    /// well-formed — cross-reference validation walks every mirror in the
3398    /// workspace, and hard-failing there on an unrelated mirror's shape error
3399    /// would report the wrong file. Empty for the scalar spelling, which has no
3400    /// edge table to carry per-edge fields.
3401    pub fn ingress_edge_slice(&self) -> &[IngressEdge] {
3402        match &self.ingress {
3403            IngressDecl::Edges(edges) => edges,
3404            IngressDecl::Provider(_) => &[],
3405        }
3406    }
3407
3408    pub fn ingress_edges(&self) -> Result<Vec<IngressEdge>> {
3409        match &self.ingress {
3410            IngressDecl::Provider(p) if !p.is_declared() => {
3411                if !self.ingress_machines.is_empty() {
3412                    bail!(
3413                        "mirror declares `ingress_machines = {:?}` but no `ingress` provider — \
3414                         front-door placement with no front door to place. Add \
3415                         `ingress = \"passway\"` (or \"cloudflare-tunnel\"), or drop \
3416                         `ingress_machines`.",
3417                        self.ingress_machines
3418                    );
3419                }
3420                Ok(Vec::new())
3421            }
3422            IngressDecl::Provider(p) => Ok(vec![IngressEdge::all_slots(
3423                *p,
3424                self.ingress_machines.clone(),
3425            )]),
3426            IngressDecl::Edges(edges) => {
3427                if !self.ingress_machines.is_empty() {
3428                    bail!(
3429                        "mirror declares both `[[ingress]]` edges and the single-edge \
3430                         `ingress_machines = {:?}` — front-door placement stated twice. Move \
3431                         those names onto the edge they place: `machines = [...]` inside the \
3432                         `[[ingress]]` entry.",
3433                        self.ingress_machines
3434                    );
3435                }
3436                for edge in edges {
3437                    if !edge.provider.is_declared() {
3438                        bail!(
3439                            "{}: `provider = \"none\"` fronts nothing. An edge exists to name a \
3440                             front door — delete the entry instead.",
3441                            edge.label()
3442                        );
3443                    }
3444                }
3445                Ok(edges.clone())
3446            }
3447        }
3448    }
3449
3450    /// Nodes this mirror's **passway** front doors are placed on, in
3451    /// declaration order and de-duplicated — or `None` when the mirror declares
3452    /// no passway edge at all.
3453    ///
3454    /// `Some(vec![])` is a real and different answer from `None`: a passway edge
3455    /// is declared but names no machine, so its placement falls back to the
3456    /// fronted slot's own. That fallback is placement *resolution* — it belongs
3457    /// to [`IngressRule::machines`](crate::reconciler::IngressRule::machines)
3458    /// and the plan it is built from, not to a mirror read in isolation — so it
3459    /// is reported as "declared, placement unknown from here" rather than
3460    /// half-derived. A caller that needs a node to dial has to say so.
3461    ///
3462    /// Passway-only because the caller is tenant DNS onboarding: only a passway
3463    /// node serves yubaba's `GET /domains/{domain}/onboarding`. A
3464    /// cloudflare-tunnel edge publishes through Cloudflare's own DNS and has no
3465    /// such record to hand a tenant, so folding its machines in would point the
3466    /// UI at a node that cannot answer.
3467    ///
3468    /// Read through [`ingress_edges`](Self::ingress_edges), so both spellings
3469    /// are covered by construction. A declaration that cannot mean anything
3470    /// (`ingress_machines` with no `ingress`, or both spellings at once) reads
3471    /// as `None` rather than propagating an error: those are reported by
3472    /// cross-reference validation, which can name the offending file.
3473    pub fn passway_machines(&self) -> Option<Vec<String>> {
3474        let edges = self.ingress_edges().ok()?;
3475        let mut declared = false;
3476        let mut machines: Vec<String> = Vec::new();
3477        for edge in edges
3478            .iter()
3479            .filter(|e| matches!(e.provider, IngressProvider::Passway))
3480        {
3481            declared = true;
3482            for m in &edge.machines {
3483                if !machines.iter().any(|seen| seen == m) {
3484                    machines.push(m.clone());
3485                }
3486            }
3487        }
3488        declared.then_some(machines)
3489    }
3490
3491    /// Parse a single `mirrors/<env>.toml` file.
3492    pub fn load(path: &Path) -> Result<Self> {
3493        let src =
3494            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
3495        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
3496    }
3497
3498    /// Persist to `.yah/services/<service>/mirrors/<env>.toml`, creating the
3499    /// `mirrors/` directory if needed. Create-or-overwrite. The mirror file is
3500    /// named by `env` (its stem); `service` selects the owning service dir.
3501    pub fn save(&self, workspace_root: &Path, service: &str, env: &str) -> Result<()> {
3502        let dir = crate::paths::service_mirrors_dir(workspace_root, service);
3503        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
3504        let path = crate::paths::service_mirror_toml(workspace_root, service, env);
3505        let s = toml::to_string_pretty(self)
3506            .with_context(|| format!("serializing mirror {service}/{env}"))?;
3507        std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
3508    }
3509
3510    /// Remove `.yah/services/<service>/mirrors/<env>.toml`. Returns `false`
3511    /// when the file was already absent. Leaves the service and its other
3512    /// mirrors untouched.
3513    ///
3514    /// Also checks legacy stems (e.g. `local-sim` when `env = "pond"`) so
3515    /// deleting a canonical tier name removes whichever file exists on disk.
3516    pub fn delete(workspace_root: &Path, service: &str, env: &str) -> Result<bool> {
3517        let path = crate::paths::service_mirror_toml(workspace_root, service, env);
3518        if path.exists() {
3519            std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
3520            return Ok(true);
3521        }
3522        // Try legacy file stems for canonical tier names.
3523        let legacy: &[&str] = match env {
3524            "dev" => &["local"],
3525            "pond" => &["local-sim", "sim"],
3526            "cloud" => &["prod"],
3527            _ => &[],
3528        };
3529        for stem in legacy {
3530            let alt = crate::paths::service_mirror_toml(workspace_root, service, stem);
3531            if alt.exists() {
3532                std::fs::remove_file(&alt)
3533                    .with_context(|| format!("removing {}", alt.display()))?;
3534                return Ok(true);
3535            }
3536        }
3537        Ok(false)
3538    }
3539}
3540
3541/// A provider slot inside a [`MirrorConfig`]. Two shapes:
3542/// - **Reference** (`use = "<provider-id>"`) — point at an infra-declared
3543///   provider; extra fields are slot-specific (bucket, zone, dns, …).
3544/// - **Inline** (`kind = "local-*"`) — for providers that need no infra
3545///   declaration because they carry no credentials.
3546#[derive(Debug, Clone, Serialize, Deserialize)]
3547#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3548#[serde(untagged)]
3549pub enum MirrorProviderSlot {
3550    Reference {
3551        #[serde(rename = "use")]
3552        provider_id: String,
3553        #[serde(flatten)]
3554        #[cfg_attr(
3555            feature = "json-schema",
3556            schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
3557        )]
3558        fields: BTreeMap<String, toml::Value>,
3559    },
3560    Inline {
3561        kind: Provider,
3562        #[serde(flatten)]
3563        #[cfg_attr(
3564            feature = "json-schema",
3565            schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
3566        )]
3567        fields: BTreeMap<String, toml::Value>,
3568    },
3569}
3570
3571impl MirrorProviderSlot {
3572    /// Provider id this slot references, or `None` for inline slots.
3573    pub fn provider_id(&self) -> Option<&str> {
3574        match self {
3575            Self::Reference { provider_id, .. } => Some(provider_id),
3576            Self::Inline { .. } => None,
3577        }
3578    }
3579
3580    /// Provider kind for inline slots, or `None` for reference slots
3581    /// (resolve via the referenced [`ProviderConfig`]).
3582    pub fn inline_kind(&self) -> Option<Provider> {
3583        match self {
3584            Self::Reference { .. } => None,
3585            Self::Inline { kind, .. } => Some(*kind),
3586        }
3587    }
3588
3589    pub fn fields(&self) -> &BTreeMap<String, toml::Value> {
3590        match self {
3591            Self::Reference { fields, .. } | Self::Inline { fields, .. } => fields,
3592        }
3593    }
3594
3595    /// F16 placement: parse the optional `required = { … }` sub-table on this
3596    /// slot. Returns `None` when absent or unparseable (callers treat as no
3597    /// constraint). See [`RequiredSpec`] for the field grammar.
3598    pub fn required(&self) -> Option<RequiredSpec> {
3599        let v = self.fields().get("required")?.clone();
3600        v.try_into().ok()
3601    }
3602}
3603
3604/// F16 placement constraints declared on a [`MirrorProviderSlot`], lives under
3605/// `[providers.<role>] required = { regions = [...], mesh_tags = [...] }` in
3606/// `mirrors/<env>.toml`.
3607///
3608/// Hard (must-satisfy) axes, all AND-ed together:
3609/// - `regions` / `zones` / `providers` — *membership*: the machine's
3610///   `region` / `zone` / `provider` must be one of the listed values.
3611/// - `mesh_tags` — *superset*: the machine's `mesh_tags` must contain every
3612///   listed tag.
3613/// - `memory_mb` / `cpu_millis` — *capacity floor* (R572-F5): the machine's
3614///   `allocatable` budget must cover the demand. `0` = no constraint.
3615/// - `repel_archetype` — *taint repulsion* (R572-F5): the machine must not
3616///   carry the taint `"no-<archetype.taint_key()>"` for the workload's class.
3617///   `None` = no repulsion check. Absolute — see [`Self::repel_archetype`].
3618/// - `requires_taint` — *taint affinity* (R572-F5): the machine must carry
3619///   this taint key (in `taints` or `mesh_tags`). `None` = no affinity.
3620///
3621/// These two are the **only** readers of [`MachineConfig::taints`], which is
3622/// what makes [`taint_effect`]'s closed vocabulary well-founded.
3623///
3624/// [`MachineConfig::sovereign_group`] is deliberately **not** an axis here and
3625/// must not become one (W305/R742-F1). A sovereign group is a blast radius,
3626/// not a filter: which quorum a box votes in says nothing about whether a
3627/// workload may run on it, and a dev-group node exists precisely so dev-mode
3628/// services — stateful ones included — can be scheduled onto it. Filtering on
3629/// it would re-make the mistake W305 exists to undo, where one mechanism
3630/// silently carried three unrelated properties.
3631///
3632/// An empty / zero / None on every axis means "no constraint on that axis".
3633/// A fully-unconstrained `RequiredSpec` matches every machine (see
3634/// [`RequiredSpec::is_unconstrained`]).
3635#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3636#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3637pub struct RequiredSpec {
3638    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3639    pub regions: Vec<String>,
3640    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3641    pub zones: Vec<String>,
3642    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3643    pub providers: Vec<String>,
3644    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3645    pub mesh_tags: Vec<String>,
3646
3647    /// R833-F8: **imperative** placement — the machine must be one of these by
3648    /// `name`. Empty (the default) = no constraint, which is every pre-R833-F8
3649    /// caller.
3650    ///
3651    /// This is the one axis that is not a *capability* the scheduler infers.
3652    /// The operator typed `--where=node:us-west-003`, so it composes with the
3653    /// other axes exactly like the rest — a named node that fails the capacity
3654    /// floor or carries a repelling taint still does not match, and the refusal
3655    /// names why rather than silently placing the work somewhere else.
3656    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3657    pub nodes: Vec<String>,
3658
3659    /// R572-F5: minimum memory (MiB) the target node must have in its
3660    /// declared `allocatable` budget. `0` = no constraint. Filled by
3661    /// [`CloudConfig::admit_workload`] from the workload's
3662    /// `memory_request_mb()` — its placement **request**, which is not the
3663    /// same number as the `resources.memory_mb` cgroup **ceiling**.
3664    #[serde(default, skip_serializing_if = "is_zero_u32")]
3665    pub memory_mb: u32,
3666    /// R572-F5: minimum CPU (millicores) the target node must have in its
3667    /// declared `allocatable` budget. `0` = no constraint. Filled by
3668    /// [`CloudConfig::admit_workload`] from the workload's `resources.cpu_millis`.
3669    #[serde(default, skip_serializing_if = "is_zero_u32")]
3670    pub cpu_millis: u32,
3671    /// R572-F5: effective archetype of the workload being placed. The scheduler
3672    /// rejects any node that carries the taint `"no-<archetype.taint_key()>"`.
3673    /// `None` = no repulsion check (backwards-compat for callers that don't
3674    /// thread a spec through).
3675    ///
3676    /// **This is an absolute block, not a preference.**
3677    /// [`CloudConfig::admit_workload`] sets it unconditionally from the
3678    /// workload's effective archetype, and nothing in the tree tolerates a
3679    /// taint — so a workload cannot opt out of a `no-<archetype>` node
3680    /// (W305 finding 2 / R742-T4). Adding toleration means giving
3681    /// `WorkloadSpec` a tolerations list and consulting it here; until then,
3682    /// do not describe this as "repel-unless-tolerate".
3683    #[serde(skip)]
3684    pub repel_archetype: Option<LifecycleArchetype>,
3685    /// R572-F5: taint the workload requires the target node to carry
3686    /// (annotation `yah.placement.requires-taint`). The node must have the
3687    /// key in its `taints` list or `mesh_tags`. `None` = no affinity constraint.
3688    #[serde(skip)]
3689    pub requires_taint: Option<String>,
3690
3691    /// R844-F8: **how many** machines this constraint places onto. `None` — the
3692    /// only shape on disk before this field — means one, so every mirror in the
3693    /// tree resolves byte-identically across the change.
3694    ///
3695    /// This is not a match axis: it never appears in [`Self::matches`] and never
3696    /// changes whether a given machine qualifies. It is the *cardinality* of the
3697    /// answer, which is why it lives here rather than as another filter — the
3698    /// operator declares what is required and how many of it, and the scheduler
3699    /// picks which.
3700    ///
3701    /// **Declared, never inferred.** The count is emphatically not "how many
3702    /// machines happen to match": deriving it that way would make adding a box
3703    /// to the fleet silently scale a production front door. A constraint that
3704    /// matches four machines and asks for two places on two.
3705    ///
3706    /// **Fewer matches than asked is an error** ([`select_matching`]), not a
3707    /// partial placement. Placing one of two and reporting success is the
3708    /// subset-that-looks-like-it-worked failure R844 exists to close.
3709    ///
3710    /// Deliberately absent from [`Self::is_unconstrained`], which answers "does
3711    /// every machine match" — a question about the predicate, not the count. A
3712    /// `required = { replicas = 2 }` with no axis is therefore still
3713    /// unconstrained, and the deploy side still refuses it as an
3714    /// underspecified placement.
3715    #[serde(default, skip_serializing_if = "Option::is_none")]
3716    pub replicas: Option<u32>,
3717}
3718
3719impl RequiredSpec {
3720    /// How many machines this constraint places onto — [`Self::replicas`],
3721    /// resolving the absent case to the pre-R844-F8 answer of one.
3722    ///
3723    /// The single place that default is spelled, so the ingress planner and the
3724    /// deploy resolver cannot disagree about what "no replica count" means.
3725    pub fn replica_count(&self) -> usize {
3726        self.replicas.unwrap_or(1) as usize
3727    }
3728
3729    /// True when no axis carries a constraint — every machine matches.
3730    pub fn is_unconstrained(&self) -> bool {
3731        self.regions.is_empty()
3732            && self.zones.is_empty()
3733            && self.providers.is_empty()
3734            && self.mesh_tags.is_empty()
3735            && self.nodes.is_empty()
3736            && self.memory_mb == 0
3737            && self.cpu_millis == 0
3738            && self.repel_archetype.is_none()
3739            && self.requires_taint.is_none()
3740    }
3741
3742    /// Whether `machine` satisfies every hard axis.
3743    ///
3744    /// - Membership axes (region/zone/provider): machine must carry the field
3745    ///   and it must appear in the constraint list.
3746    /// - `mesh_tags`: machine tags must be a superset of the required set.
3747    /// - **R572-F5 capacity floor**: `machine.allocatable.{memory,cpu}` must
3748    ///   cover `self.{memory,cpu}`. A machine with no `allocatable` block passes
3749    ///   unconditionally (capacity unknown → no constraint enforced).
3750    /// - **R572-F5 taint repulsion**: machine must not carry the taint
3751    ///   `"no-<archetype.taint_key()>"` for the workload's class. Absolute —
3752    ///   the workload has no way to tolerate it (W305 finding 2).
3753    /// - **R572-F5 taint affinity**: if `requires_taint` is set, the machine
3754    ///   must carry that key in its `taints` list or `mesh_tags`.
3755    ///
3756    /// Any *other* taint on the machine is ignored here, which is precisely
3757    /// why [`crate::validate::check_inert_taints`] refuses to let one be
3758    /// declared: it would read as a constraint and be none.
3759    pub fn matches(&self, machine: &MachineConfig) -> bool {
3760        let member_ok = |constraint: &[String], value: Option<&str>| -> bool {
3761            constraint.is_empty() || value.map_or(false, |v| constraint.iter().any(|c| c == v))
3762        };
3763
3764        // R833-F8: imperative node pin, checked first because it is the axis a
3765        // human asserted rather than one the scheduler derived — a refusal
3766        // should read "us-west-003 does not match" and not lead with a tag set
3767        // the operator never typed.
3768        if !member_ok(&self.nodes, Some(machine.name.as_str())) {
3769            return false;
3770        }
3771
3772        // Membership + mesh-tags (pre-existing axes).
3773        if !member_ok(&self.regions, machine.region.as_deref())
3774            || !member_ok(&self.zones, machine.zone.as_deref())
3775            || !member_ok(&self.providers, Some(machine.provider.as_str()))
3776            || !self
3777                .mesh_tags
3778                .iter()
3779                .all(|t| machine.mesh_tags.iter().any(|mt| mt == t))
3780        {
3781            return false;
3782        }
3783
3784        // R572-F5: capacity floor. Skipped when machine has no allocatable
3785        // declaration (unknown capacity → passes, consistent with pre-F5 behaviour).
3786        if self.memory_mb > 0 || self.cpu_millis > 0 {
3787            if let Some(alloc) = &machine.allocatable {
3788                if self.memory_mb > alloc.memory_mb || self.cpu_millis > alloc.cpu_millis {
3789                    return false;
3790                }
3791            }
3792        }
3793
3794        // R572-F5: taint repulsion. A node taint "no-<archetype>" rejects the
3795        // workload class outright — there is no toleration list to consult.
3796        if let Some(arch) = self.repel_archetype {
3797            let repel_key = format!("no-{}", arch.taint_key());
3798            if machine.taints.iter().any(|t| *t == repel_key) {
3799                return false;
3800            }
3801        }
3802
3803        // R572-F5: taint affinity. Machine must carry the required taint key
3804        // in either its `taints` list or `mesh_tags`.
3805        if let Some(req) = &self.requires_taint {
3806            let has_it = machine.taints.iter().any(|t| t == req)
3807                || machine.mesh_tags.iter().any(|t| t == req);
3808            if !has_it {
3809                return false;
3810            }
3811        }
3812
3813        true
3814    }
3815
3816    /// Human-readable summary of the constraints, for fail-loud error messages.
3817    /// Example: `required.regions=[us-west] + required.mesh_tags=[tag:cloud-runner]`.
3818    pub fn describe(&self) -> String {
3819        let mut parts = Vec::new();
3820        let mut push = |label: &str, vals: &[String]| {
3821            if !vals.is_empty() {
3822                parts.push(format!("required.{label}=[{}]", vals.join(",")));
3823            }
3824        };
3825        push("nodes", &self.nodes);
3826        push("regions", &self.regions);
3827        push("zones", &self.zones);
3828        push("providers", &self.providers);
3829        push("mesh_tags", &self.mesh_tags);
3830        if self.memory_mb > 0 {
3831            parts.push(format!("memory_mb>={}", self.memory_mb));
3832        }
3833        if self.cpu_millis > 0 {
3834            parts.push(format!("cpu_millis>={}", self.cpu_millis));
3835        }
3836        if let Some(arch) = self.repel_archetype {
3837            parts.push(format!("not-tainted(no-{})", arch.taint_key()));
3838        }
3839        if let Some(req) = &self.requires_taint {
3840            parts.push(format!("requires_taint={req}"));
3841        }
3842        if parts.is_empty() {
3843            "no constraints".to_string()
3844        } else {
3845            parts.join(" + ")
3846        }
3847    }
3848}
3849
3850/// Which front door actually serves a domain's requests (R594-F12).
3851///
3852/// Every domain manifest must say this out loud. Before it existed the
3853/// difference between "R2 serves this hostname directly" and "a Worker
3854/// serves it" was expressed *only* by whether the file happened to carry
3855/// `[[routes]]` — so binding a route-carrying domain straight to R2 was
3856/// accepted silently and served 200s on its SSG half while losing clean
3857/// URLs, SPA shell fallback, deferred-route pointers and branded error
3858/// pages. All of those live in the Worker
3859/// (`oss/mesofact/packages/mesofact-edge/src/router.ts`) or in
3860/// mesofact-serve; an R2 custom domain has none of them.
3861///
3862/// The vocabulary mirrors `scripts/cf-apex-mode.sh` (worker | grey | orange)
3863/// — this moves the choice into the config where it can be checked instead
3864/// of living in one bash script.
3865///
3866/// A front door does **fan-in** only. The render cube (SSG / SPA / SSR /
3867/// deferred / 404) is mesofact's manifest, not this one — see W173 and
3868/// `.yah/docs/working/W267-sovereign-public-ingress.md`
3869/// §"Two front doors, one render contract".
3870#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3871#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3872#[serde(rename_all = "kebab-case")]
3873pub enum FrontDoor {
3874    /// Cloudflare R2 custom domain. Requests hit R2 objects with edge
3875    /// caching and nothing else — no clean URLs, no SPA fallback, no
3876    /// branded errors. Correct for a pure asset tier (W175's verdict for
3877    /// `cdn.yah.dev`) and wrong for anything that renders pages.
3878    /// Implies zero `[[routes]]` and no `worker_bundle_path`.
3879    BucketDirect,
3880    /// Cloudflare Worker generated from this manifest's route table.
3881    Worker,
3882    /// Sovereign L7 ingress — the `passway` proxy on yah-owned metal
3883    /// (`oss/passway`, W267). Same route table as `worker`; different
3884    /// machine terminates TLS.
3885    Passway,
3886}
3887
3888impl FrontDoor {
3889    /// Whether this front door consumes the manifest's `[[routes]]` table.
3890    /// `bucket-direct` does not; the other two are nothing without it.
3891    pub fn is_route_driven(self) -> bool {
3892        matches!(self, FrontDoor::Worker | FrontDoor::Passway)
3893    }
3894
3895    /// The manifest spelling, for error messages.
3896    pub fn as_str(self) -> &'static str {
3897        match self {
3898            FrontDoor::BucketDirect => "bucket-direct",
3899            FrontDoor::Worker => "worker",
3900            FrontDoor::Passway => "passway",
3901        }
3902    }
3903}
3904
3905/// A routing manifest for one domain, from `.yah/domains/<name>.toml`.
3906///
3907/// The domain manifest is the *only* place that knows about path routing:
3908/// services declare static/backend components by opaque ID, and this
3909/// manifest binds those components to URL paths on a public-facing
3910/// domain. Generated Worker bundles consume this. See
3911/// `.yah/docs/working/W118-yah-domain-tiers.md` (R347).
3912#[derive(Debug, Clone, Serialize, Deserialize)]
3913#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3914pub struct DomainConfig {
3915    pub schema_version: u32,
3916    /// Stable identifier for this domain (file stem of the manifest).
3917    /// Example: `"yah-dev"` for the `yah.dev` zone.
3918    pub name: String,
3919    /// The fully-qualified domain this manifest routes for. Example:
3920    /// `"yah.dev"`, `"app.yah.dev"`.
3921    pub domain: String,
3922    /// Which front door serves this domain (R594-F12). **Required** — a
3923    /// default here would silently re-create the defect the field exists to
3924    /// close. Cross-checked against `routes` / `worker_bundle_path` by
3925    /// [`DomainConfig::validate_front_door`] at load time.
3926    pub front_door: FrontDoor,
3927    /// Public CDN bucket name. Static-mode route components publish into
3928    /// this bucket. Owned by the domain, *not* by any single service.
3929    pub cdn_bucket: String,
3930    /// Optional path (relative to workspace root) where the generated
3931    /// Worker bundle lands. `None` while the bundle generator (R347-F4)
3932    /// is still being wired up.
3933    #[serde(default, skip_serializing_if = "Option::is_none")]
3934    pub worker_bundle_path: Option<String>,
3935    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3936    pub routes: Vec<DomainRoute>,
3937}
3938
3939/// One entry in a [`DomainConfig`]'s route table.
3940///
3941/// The `mode` discriminator picks the variant's body via serde's
3942/// internally-tagged enum representation. Path patterns follow the
3943/// Worker convention: a trailing `*` matches everything underneath.
3944#[derive(Debug, Clone, Serialize, Deserialize)]
3945#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3946pub struct DomainRoute {
3947    /// URL pattern this route matches. Examples: `"/"`, `"/dashboard/*"`,
3948    /// `"/camp/ws"`.
3949    pub path: String,
3950    /// Response headers the front door sets on every response served under
3951    /// this route (R746). Empty by default.
3952    ///
3953    /// This is the manifest's answer to "who decides a path's response
3954    /// headers". Before it existed the answer was *nobody*: a `_headers` file
3955    /// is a Cloudflare Pages / Netlify convention, and neither of this
3956    /// repo's front doors reads one — a Worker returns what it fetched from
3957    /// R2, and R2 serves only the object's own httpMetadata. So a site could
3958    /// carry a `_headers` file declaring COOP/COEP and ship without them,
3959    /// which is exactly how it was found: `SharedArrayBuffer` is simply
3960    /// absent in a document served cross-origin-isolation-free, with no
3961    /// error anywhere to say why.
3962    ///
3963    /// Deliberately a free-form `name -> value` map rather than named fields
3964    /// for the isolation headers: the domain manifest has no business
3965    /// knowing which headers a route's payload happens to need. Ordering
3966    /// follows the route table's own rule — first matching route wins, no
3967    /// merging across routes (see the Worker's `applyRouteHeaders`).
3968    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3969    pub headers: BTreeMap<String, String>,
3970    #[serde(flatten)]
3971    pub mode: RouteMode,
3972}
3973
3974/// Body of a [`DomainRoute`]. Three modes:
3975/// - **Static** — Worker reads from the domain's CDN bucket. Component
3976///   ref points at a `kind = "mesofact-static"` (or similar) service
3977///   component.
3978/// - **Backend** — Worker proxies to an HTTP origin owned by a backend
3979///   component (yubaba workload, gateway, etc.).
3980/// - **Redirect** — Worker emits a 30x to the target URL. Used to keep
3981///   old paths alive during domain refactors.
3982#[derive(Debug, Clone, Serialize, Deserialize)]
3983#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3984#[serde(tag = "mode", rename_all = "kebab-case")]
3985pub enum RouteMode {
3986    Static {
3987        /// Component reference `"<service>/<component-id>"`. Validated
3988        /// at [`CloudConfig::load`] time.
3989        component: String,
3990    },
3991    Backend {
3992        /// Component reference `"<service>/<component-id>"`. Validated
3993        /// at [`CloudConfig::load`] time.
3994        component: String,
3995        /// Origin URL the Worker `fetch()`es. Schema-permissive — could
3996        /// be `https://...`, `wss://...`, or a yah-internal mesh URL
3997        /// resolved by yubaba.
3998        origin: String,
3999    },
4000    Redirect {
4001        /// Absolute URL or path the Worker emits a 30x to.
4002        target: String,
4003        /// HTTP status code. Defaults to 308 (permanent + method-preserving)
4004        /// so deprecations don't silently turn POSTs into GETs.
4005        #[serde(default = "default_redirect_status")]
4006        status: u16,
4007    },
4008}
4009
4010fn default_redirect_status() -> u16 {
4011    308
4012}
4013
4014/// Normalize a component `mount` to a storage/URL key prefix: strip the
4015/// surrounding slashes. `"/app"`, `"app/"`, `"/app/"` → `"app"`; `"/"`, `""`
4016/// → `""` (the service root).
4017///
4018/// One producer on purpose — the publisher's key prefix, the route-path
4019/// cross-check and the front door's key lookup must all agree on what `/app`
4020/// means down to the byte, and three copies of `trim_matches('/')` is how they
4021/// stop agreeing.
4022pub fn normalize_mount(raw: &str) -> String {
4023    raw.trim_matches('/').to_string()
4024}
4025
4026/// The key prefix a domain route pattern serves under: `"/*"` → `""`,
4027/// `"/app/*"` and `"/app"` → `"app"`. The twin of [`normalize_mount`] on the
4028/// routing side.
4029pub fn route_path_prefix(path: &str) -> String {
4030    normalize_mount(path.strip_suffix('*').unwrap_or(path))
4031}
4032
4033/// The route-driven domain whose route table binds a component of `service`,
4034/// if any. Used by static publishers to pick up the per-route response
4035/// headers a service's paths were declared with.
4036///
4037/// Deterministic by `BTreeMap` key order when more than one domain routes the
4038/// same service (a legitimate shape: an apex and a staging host serving one
4039/// bundle). Returning the first is a real limitation, not a considered
4040/// choice — the day two such domains want *different* headers for one
4041/// component, this needs the domain identity threaded in rather than inferred.
4042pub fn domain_serving_service<'a>(
4043    domains: &'a BTreeMap<String, DomainConfig>,
4044    service: &str,
4045) -> Option<&'a DomainConfig> {
4046    domains
4047        .values()
4048        .find(|d| d.front_door.is_route_driven() && d.serves_service(service))
4049}
4050
4051/// The `ROUTE_HEADERS` Worker-binding value for `service`, read from the
4052/// workspace's domain manifests. `"[]"` when no route-driven domain routes the
4053/// service, or when the one that does declares no headers.
4054///
4055/// Reads `.yah/domains/` directly rather than taking a loaded [`CloudConfig`]:
4056/// the static reconcilers are handed a per-component [`ReconcileCtx`], not the
4057/// whole workspace config, and threading a config reference through all 22 of
4058/// its construction sites to reach one string would be a wide change for a
4059/// narrow read. Manifest parse errors propagate — a domain file that no longer
4060/// loads is a deploy-stopping fact, not a reason to ship a Worker with the
4061/// headers quietly missing.
4062pub fn route_headers_for_service(workspace_root: &Path, service: &str) -> Result<String> {
4063    let domains = load_domains(&crate::paths::domains_dir(workspace_root))?;
4064    Ok(domain_serving_service(&domains, service)
4065        .map(DomainConfig::route_headers_json)
4066        .unwrap_or_else(|| "[]".to_string()))
4067}
4068
4069impl DomainConfig {
4070    /// Parse a single `.yah/domains/<name>.toml`, rejecting a manifest whose
4071    /// declared front door contradicts its route table
4072    /// ([`Self::validate_front_door`]).
4073    pub fn load(path: &Path) -> Result<Self> {
4074        let src =
4075            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
4076        let dom: Self =
4077            toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
4078        dom.validate_front_door()
4079            .with_context(|| format!("validating {}", path.display()))?;
4080        dom.validate_route_headers()
4081            .with_context(|| format!("validating {}", path.display()))?;
4082        Ok(dom)
4083    }
4084
4085    /// R594-F12 — the front door must agree with the rest of the manifest.
4086    ///
4087    /// - `bucket-direct` is an R2 custom domain: a Worker route table would
4088    ///   never be consulted, so declaring one means the author expected
4089    ///   Worker behaviour (clean URLs, SPA fallback, branded errors) from a
4090    ///   surface that cannot provide it. Rejected rather than silently
4091    ///   ignored. Same for `worker_bundle_path` — nothing would deploy it.
4092    /// - `worker` / `passway` with an empty route table is a silent 404
4093    ///   machine: the front door exists, has nothing to serve, and every
4094    ///   request falls through to the catch-all.
4095    ///
4096    /// Called from [`Self::load`], so both [`CloudConfig::load`] and
4097    /// [`CloudConfig::load_from_config_dir`] enforce it.
4098    pub fn validate_front_door(&self) -> Result<()> {
4099        match self.front_door {
4100            FrontDoor::BucketDirect => {
4101                if let Some(route) = self.routes.first() {
4102                    anyhow::bail!(
4103                        "front_door = \"bucket-direct\" but routes[0].path = \"{}\" — \
4104                         an R2 custom domain never consults a route table, so this \
4105                         route would silently do nothing (no clean URLs, no SPA \
4106                         fallback, no branded errors). Set front_door = \"worker\" \
4107                         (or \"passway\") to keep the routes, or drop the [[routes]] \
4108                         to keep the bucket-direct binding.",
4109                        route.path
4110                    );
4111                }
4112                if let Some(path) = &self.worker_bundle_path {
4113                    anyhow::bail!(
4114                        "front_door = \"bucket-direct\" but worker_bundle_path = \
4115                         \"{path}\" — nothing deploys a Worker bundle for a domain \
4116                         bound straight to R2"
4117                    );
4118                }
4119            }
4120            FrontDoor::Worker | FrontDoor::Passway => {
4121                if self.routes.is_empty() {
4122                    anyhow::bail!(
4123                        "front_door = \"{}\" but [[routes]] is empty — a front door \
4124                         with no route table is a silent 404 machine. Declare at \
4125                         least one route, or set front_door = \"bucket-direct\" if \
4126                         this domain really is served straight from R2.",
4127                        self.front_door.as_str()
4128                    );
4129                }
4130            }
4131        }
4132        Ok(())
4133    }
4134
4135    /// The `ROUTE_HEADERS` Worker binding for this domain (R746) — the route
4136    /// table's `path` + `headers` pairs, in manifest order, with routes that
4137    /// declare no headers dropped. `"[]"` when nothing declares any.
4138    ///
4139    /// Order is load-bearing and must survive serialization: the front door
4140    /// applies the FIRST matching rule, so `/app/*` above `/*` is what gives
4141    /// the app its isolation headers and leaves the marketing site alone.
4142    /// That is why this is a `Vec` of pairs and not a map keyed by path.
4143    ///
4144    /// Infallible by design — [`Self::validate_route_headers`] has already run
4145    /// at [`Self::load`], so by the time a reconciler calls this the table is
4146    /// known to be one both front doors can apply.
4147    pub fn route_headers_json(&self) -> String {
4148        #[derive(Serialize)]
4149        struct Rule<'a> {
4150            path: &'a str,
4151            headers: &'a BTreeMap<String, String>,
4152        }
4153        let rules: Vec<Rule<'_>> = self
4154            .routes
4155            .iter()
4156            .filter(|r| !r.headers.is_empty())
4157            .map(|r| Rule {
4158                path: &r.path,
4159                headers: &r.headers,
4160            })
4161            .collect();
4162        serde_json::to_string(&rules).unwrap_or_else(|_| "[]".to_string())
4163    }
4164
4165    /// R749-T5 — everything [`Self::route_headers_json`] emits must be
4166    /// *applicable*, checked here where the table is PRODUCED.
4167    ///
4168    /// That method serializes a typed struct, so the table's JSON *shape* is
4169    /// sound by construction. Its contents are not: a route's `headers` map is
4170    /// a free-form `name -> value` read verbatim out of hand-written TOML, so
4171    /// `"Cross Origin Opener Policy"` (spaces instead of hyphens) or a value
4172    /// carrying a newline ships a structurally-valid table that neither front
4173    /// door can apply — and they fail *differently*, neither naming the
4174    /// manifest line responsible:
4175    ///
4176    /// - **passway** — `mesofact::route_headers::RouteHeaderTable::parse`
4177    ///   refuses the start, so the origin is simply down.
4178    /// - **worker** — `validateRouteHeaderTable` accepts it (it checks shape,
4179    ///   not header validity) and `applyRouteHeaders` then throws inside the
4180    ///   exported `fetch`, which is a 500 on every request, not the
4181    ///   serve-without-the-headers degradation that code intends.
4182    ///
4183    /// So the strictness lives at the producer: a table that cannot be applied
4184    /// fails `yah cloud apply` at manifest load, naming domain, route and
4185    /// header. This is deliberately *not* a second parser — the check is
4186    /// `HeaderName`/`HeaderValue`'s own, the very constructors the passway door
4187    /// runs on the far side, and route *matching* semantics stay defined once,
4188    /// at the doors. Only routes that contribute to the table are checked, so
4189    /// the invariant is exactly "`route_headers_json`'s output parses".
4190    ///
4191    /// Called from [`Self::load`], alongside [`Self::validate_front_door`].
4192    pub fn validate_route_headers(&self) -> Result<()> {
4193        use axum::http::{HeaderName, HeaderValue};
4194
4195        for route in self.routes.iter().filter(|r| !r.headers.is_empty()) {
4196            if route.path.is_empty() {
4197                anyhow::bail!(
4198                    "domain \"{}\" declares response headers on a route whose `path` is \
4199                     empty — a rule that matches nothing (or everything, depending on \
4200                     which front door reads it) is not a policy",
4201                    self.name
4202                );
4203            }
4204            for (name, value) in &route.headers {
4205                HeaderName::try_from(name.as_str()).with_context(|| {
4206                    format!(
4207                        "domain \"{}\" route \"{}\" declares {name:?}, which is not a valid \
4208                         HTTP header name — names are token characters only, so it is \
4209                         `Cross-Origin-Opener-Policy`, never `Cross Origin Opener Policy`",
4210                        self.name, route.path
4211                    )
4212                })?;
4213                HeaderValue::try_from(value.as_str()).with_context(|| {
4214                    format!(
4215                        "domain \"{}\" route \"{}\" declares {name} = {value:?}, which is not \
4216                         a valid HTTP header value — no newlines and no control characters",
4217                        self.name, route.path
4218                    )
4219                })?;
4220            }
4221        }
4222        Ok(())
4223    }
4224
4225    /// Whether this domain's route table binds any component of `service`.
4226    pub fn serves_service(&self, service: &str) -> bool {
4227        self.routes.iter().any(|r| {
4228            r.mode
4229                .component()
4230                .and_then(split_component_ref)
4231                .is_some_and(|(svc, _)| svc == service)
4232        })
4233    }
4234
4235    /// Persist to `.yah/domains/<name>.toml`, creating the domains
4236    /// directory if needed. Create-or-overwrite.
4237    pub fn save(&self, workspace_root: &Path) -> Result<()> {
4238        let dir = crate::paths::domains_dir(workspace_root);
4239        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
4240        let path = crate::paths::domain_toml(workspace_root, &self.name);
4241        let s = toml::to_string_pretty(self)
4242            .with_context(|| format!("serializing domain {}", self.name))?;
4243        std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
4244    }
4245
4246    /// Remove `.yah/domains/<name>.toml`. Returns `false` when the file
4247    /// was already absent.
4248    pub fn delete(workspace_root: &Path, name: &str) -> Result<bool> {
4249        let path = crate::paths::domain_toml(workspace_root, name);
4250        if !path.exists() {
4251            return Ok(false);
4252        }
4253        std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
4254        Ok(true)
4255    }
4256}
4257
4258impl RouteMode {
4259    /// Component reference for static/backend modes; `None` for redirects.
4260    pub fn component(&self) -> Option<&str> {
4261        match self {
4262            Self::Static { component } | Self::Backend { component, .. } => Some(component),
4263            Self::Redirect { .. } => None,
4264        }
4265    }
4266}
4267
4268// ─── Service-group vault (R706 / W294) ───────────────────────────────────────
4269
4270/// A camp's declaration of one cluster secret, from
4271/// `.yah/infra/secrets/<slug>.toml`.
4272///
4273/// This is the *authoring* side of the fleet's cluster-secret store: it names
4274/// where the value lives in the camp (a `fob` vault slot), what the fleet should
4275/// call it, and — the point of R706 — which workloads are allowed to mount it.
4276///
4277/// The declaration is not itself the enforcement point. `yah cloud secret put`
4278/// reads this file, seals the vault value under the cluster KEK, and ships the
4279/// ciphertext **with its access rule** into raft; yubaba's `ClusterResolver`
4280/// evaluates the rule on the node at mount time. Deleting this file does not
4281/// revoke anything — the record in raft is the live authority. That asymmetry is
4282/// deliberate: a rule that lived only in a git-tracked camp file would be
4283/// trivially bypassed by anyone who could reach the fleet without the camp.
4284///
4285/// ```toml
4286/// #:schema ../../schema/secret.toml.schema.json
4287/// schema_version = 1
4288/// name = "cheers/cloud-admin/verify-key"
4289/// vault_slot = "cheers-cloud-admin-verify-key"
4290/// description = "Ed25519 public key yah-cloud-admin verifies operator PASETOs with"
4291///
4292/// [access]
4293/// workloads = [{ workload = "yah-cloud-admin" }]
4294///
4295/// [target]
4296/// kind = "file"
4297/// path = "/run/secrets/cheers-verify.key"
4298/// mode = 0o400
4299/// ```
4300#[derive(Debug, Clone, Serialize, Deserialize)]
4301#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4302pub struct SecretConfig {
4303    pub schema_version: u32,
4304
4305    /// Logical cluster-secret key, as `SecretRef::Cluster { name }` spells it —
4306    /// e.g. `"tls/yah.dev/cert"`, `"cheers/cloud-admin/verify-key"`. May contain
4307    /// `/`; the file stem is a filesystem-safe slug and carries no meaning.
4308    pub name: String,
4309
4310    /// The `fob` vault slot in this camp holding the plaintext value. Read by
4311    /// `yah cloud secret put` at ship time and never recorded anywhere else — in
4312    /// particular the value is not in this file, so the declaration is safe to
4313    /// commit.
4314    pub vault_slot: String,
4315
4316    /// Human note for `yah cloud secret ls`. What this secret is and who minted
4317    /// it — the thing nobody remembers 6 months later.
4318    #[serde(default, skip_serializing_if = "Option::is_none")]
4319    pub description: Option<String>,
4320
4321    /// How the vault slot's text decodes into the bytes the consumer expects.
4322    ///
4323    /// `fob` slots hold strings, but plenty of real secrets are **binary** — an
4324    /// Ed25519 key is exactly 32 raw bytes, and `yah-cloud-admin` rejects a key
4325    /// file of any other length. Without this field the only way to ship such a
4326    /// key would be to hope its bytes happened to be valid UTF-8, which for a
4327    /// random key they are not.
4328    ///
4329    /// Defaults to [`SecretEncoding::Utf8`] — the right answer for tokens,
4330    /// passwords, and PEM, which is most secrets.
4331    #[serde(default)]
4332    pub encoding: SecretEncoding,
4333
4334    /// Who may mount it. Stamped onto the raft record verbatim.
4335    ///
4336    /// Defaults to [`SecretAccess::default`] — the deny-all empty allow-list. A
4337    /// declaration that forgets this field produces a secret nobody can mount,
4338    /// which is the correct direction to fail in.
4339    ///
4340    /// Three forms:
4341    ///
4342    /// ```toml
4343    /// access = "allow_any"                              # explicit escape hatch
4344    ///
4345    /// [access]                                          # named workloads
4346    /// workloads = [{ workload = "yah-cloud-admin" }]
4347    ///
4348    /// [access]                                          # signed recipes (R555-F5)
4349    /// recipes = [{ recipe = "rusty-v8-musl", key = "3d40…" }]
4350    /// ```
4351    ///
4352    /// Use the `recipes` form for a credential a **dispatched build** needs (the
4353    /// R2 write key, the cosign signing key). A remote QED run's workload name
4354    /// is a fresh `forge-<uuid>` every time, so `workloads` cannot name it and
4355    /// `allow_any` over-answers — see W235 §Seam (c) secret scoping. `key` is
4356    /// the hex Ed25519 public key from the recipe's `[admission]` block.
4357    #[serde(default)]
4358    pub access: SecretAccess,
4359
4360    /// Advisory: the mount shape a consuming workload should declare. Not
4361    /// enforced — yubaba honours whatever the `WorkloadSpec` asks for — but it
4362    /// lets `yah cloud secret put` print the exact `SecretMount` to paste, so
4363    /// the consumer and the declaration can't drift on path or mode.
4364    #[serde(default, skip_serializing_if = "Option::is_none")]
4365    pub target: Option<SecretTargetDecl>,
4366}
4367
4368/// How a [`SecretConfig`]'s vault text becomes the bytes delivered to the
4369/// container (R706 / W294).
4370#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
4371#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4372#[serde(rename_all = "kebab-case")]
4373pub enum SecretEncoding {
4374    /// Ship the vault string's UTF-8 bytes verbatim. Tokens, passwords, PEM.
4375    #[default]
4376    Utf8,
4377    /// The vault string is hex; ship the decoded bytes. Use for binary key
4378    /// material — e.g. a raw Ed25519 key, which must land as exactly 32 bytes.
4379    Hex,
4380}
4381
4382/// Advisory mount shape on a [`SecretConfig`]. Mirrors
4383/// `workload_spec::SecretTarget` in a TOML-friendly, externally-tagged-free
4384/// shape (a `kind` discriminator reads better in a hand-written manifest than
4385/// serde's default enum encoding).
4386#[derive(Debug, Clone, Serialize, Deserialize)]
4387#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4388#[serde(tag = "kind", rename_all = "kebab-case")]
4389pub enum SecretTargetDecl {
4390    /// Mounted as a tmpfs-backed file inside the container.
4391    File {
4392        /// Absolute path inside the container.
4393        path: String,
4394        /// Unix permission bits. Defaults to `0o400` (owner-read-only).
4395        #[serde(default = "default_secret_mode")]
4396        mode: u32,
4397    },
4398    /// Injected as an environment variable. Prefer `file` — env vars leak
4399    /// through subprocess environments and log dumps.
4400    EnvVar { name: String },
4401}
4402
4403fn default_secret_mode() -> u32 {
4404    0o400
4405}
4406
4407impl SecretTargetDecl {
4408    /// The `workload_spec` target this declaration describes.
4409    pub fn to_target(&self) -> workload_spec::SecretTarget {
4410        match self {
4411            Self::File { path, mode } => workload_spec::SecretTarget::File {
4412                path: path.into(),
4413                mode: *mode,
4414            },
4415            Self::EnvVar { name } => workload_spec::SecretTarget::EnvVar { name: name.clone() },
4416        }
4417    }
4418}
4419
4420impl SecretConfig {
4421    /// Parse a single `.yah/infra/secrets/<slug>.toml`.
4422    pub fn load(path: &Path) -> Result<Self> {
4423        let src =
4424            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
4425        let cfg: Self =
4426            toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
4427        cfg.validate()
4428            .with_context(|| format!("validating {}", path.display()))?;
4429        Ok(cfg)
4430    }
4431
4432    /// Load every declaration in `dir`, keyed by logical secret name. A missing
4433    /// directory is an empty map (a camp with no cluster secrets is normal).
4434    ///
4435    /// Two files declaring the same `name` is a hard error, not a last-writer-
4436    /// wins merge: they would race to define the access rule for one record, and
4437    /// whichever lost would look correct in git while being inert on the fleet.
4438    pub fn load_dir(dir: &Path) -> Result<BTreeMap<String, Self>> {
4439        let mut out: BTreeMap<String, Self> = BTreeMap::new();
4440        if !dir.exists() {
4441            return Ok(out);
4442        }
4443        for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
4444            let path = entry?.path();
4445            if path.extension().is_none_or(|e| e != "toml") {
4446                continue;
4447            }
4448            let cfg = Self::load(&path)?;
4449            if let Some(prev) = out.insert(cfg.name.clone(), cfg) {
4450                anyhow::bail!(
4451                    "two secret declarations both claim name {:?} (one of them is {}); \
4452                     a cluster secret must have exactly one declaration so its access \
4453                     rule has one author",
4454                    prev.name,
4455                    path.display()
4456                );
4457            }
4458        }
4459        Ok(out)
4460    }
4461
4462    /// Reject declarations that would produce an unusable or dangerous record.
4463    pub fn validate(&self) -> Result<()> {
4464        if self.name.trim().is_empty() {
4465            anyhow::bail!("`name` must not be empty");
4466        }
4467        if self.vault_slot.trim().is_empty() {
4468            anyhow::bail!(
4469                "`vault_slot` must not be empty — it names the fob slot holding the value"
4470            );
4471        }
4472        // A deny-all rule is a *valid* record (it is the fail-closed default the
4473        // resolver relies on) but it is never a useful thing to deliberately
4474        // ship, so catching it here saves an operator the round-trip of
4475        // deploying a workload that mysteriously can't see its own secret.
4476        if let SecretAccess::Workloads(entries) = &self.access {
4477            if entries.is_empty() {
4478                anyhow::bail!(
4479                    "`[access]` admits nobody: list the workloads allowed to mount {:?} \
4480                     (e.g. `workloads = [{{ workload = \"my-service\" }}]`), or set \
4481                     `access = \"allow_any\"` to store it unrestricted",
4482                    self.name
4483                );
4484            }
4485            if let Some(bad) = entries.iter().find(|e| e.workload.trim().is_empty()) {
4486                anyhow::bail!("`[access]` entry has an empty `workload` name: {bad:?}");
4487            }
4488        }
4489        Ok(())
4490    }
4491}
4492
4493#[cfg(test)]
4494mod secret_config_tests {
4495    use super::*;
4496
4497    fn parse(body: &str) -> Result<SecretConfig> {
4498        let cfg: SecretConfig = toml::from_str(body)?;
4499        cfg.validate()?;
4500        Ok(cfg)
4501    }
4502
4503    #[test]
4504    fn minimal_declaration_parses_with_narrow_defaults() {
4505        let cfg = parse(
4506            r#"
4507schema_version = 1
4508name = "svc/token"
4509vault_slot = "svc-token"
4510[access]
4511workloads = [{ workload = "svc" }]
4512"#,
4513        )
4514        .unwrap();
4515
4516        assert_eq!(cfg.encoding, SecretEncoding::Utf8, "text is the default");
4517        assert!(cfg.target.is_none());
4518        // The omitted tenant/namespace must narrow to the singletons, not widen
4519        // to a wildcard.
4520        assert!(cfg
4521            .access
4522            .admits(&workload_spec::secrets::SecretConsumer::workload("svc")));
4523        assert!(!cfg
4524            .access
4525            .admits(&workload_spec::secrets::SecretConsumer::workload("other")));
4526    }
4527
4528    #[test]
4529    fn allow_any_is_spelled_as_a_bare_string() {
4530        // The operator-facing spelling, pinned: `access = "allow_any"`.
4531        let cfg = parse(
4532            r#"
4533schema_version = 1
4534name = "public/thing"
4535vault_slot = "slot"
4536access = "allow_any"
4537"#,
4538        )
4539        .unwrap();
4540        assert_eq!(cfg.access, SecretAccess::AllowAny);
4541    }
4542
4543    #[test]
4544    fn a_declaration_with_no_access_block_is_rejected() {
4545        // Omitting `[access]` defaults to deny-all, which is the correct
4546        // *runtime* default but never a correct authoring intent — so it must
4547        // not silently produce a secret nobody can mount.
4548        let err = parse(
4549            r#"
4550schema_version = 1
4551name = "svc/token"
4552vault_slot = "svc-token"
4553"#,
4554        )
4555        .unwrap_err()
4556        .to_string();
4557        assert!(err.contains("admits nobody"), "got {err}");
4558    }
4559
4560    #[test]
4561    fn empty_name_or_slot_is_rejected() {
4562        assert!(parse(
4563            r#"
4564schema_version = 1
4565name = ""
4566vault_slot = "slot"
4567access = "allow_any"
4568"#
4569        )
4570        .is_err());
4571        assert!(parse(
4572            r#"
4573schema_version = 1
4574name = "x"
4575vault_slot = "  "
4576access = "allow_any"
4577"#
4578        )
4579        .is_err());
4580    }
4581
4582    #[test]
4583    fn target_declaration_maps_onto_the_workload_spec_type() {
4584        let cfg = parse(
4585            r#"
4586schema_version = 1
4587name = "svc/token"
4588vault_slot = "slot"
4589access = "allow_any"
4590[target]
4591kind = "file"
4592path = "/run/secrets/t"
4593"#,
4594        )
4595        .unwrap();
4596        match cfg.target.unwrap().to_target() {
4597            workload_spec::SecretTarget::File { path, mode } => {
4598                assert_eq!(path, std::path::PathBuf::from("/run/secrets/t"));
4599                assert_eq!(mode, 0o400, "owner-read-only by default");
4600            }
4601            other => panic!("expected File, got {other:?}"),
4602        }
4603    }
4604
4605    #[test]
4606    fn load_dir_is_empty_for_a_camp_with_no_secrets() {
4607        let tmp = tempfile::TempDir::new().unwrap();
4608        assert!(SecretConfig::load_dir(&tmp.path().join("nope"))
4609            .unwrap()
4610            .is_empty());
4611    }
4612}
4613
4614/// Split a `"<service>/<component-id>"` ref. Returns `None` if the ref
4615/// isn't shaped like `service/component`.
4616fn split_component_ref(s: &str) -> Option<(&str, &str)> {
4617    let (svc, comp) = s.split_once('/')?;
4618    if svc.is_empty() || comp.is_empty() || comp.contains('/') {
4619        return None;
4620    }
4621    Some((svc, comp))
4622}
4623
4624#[cfg(test)]
4625mod tests {
4626    use super::*;
4627    use std::path::PathBuf;
4628
4629    fn make_machine(name: &str, mesh_tags: Vec<&str>) -> MachineConfig {
4630        MachineConfig {
4631            name: name.into(),
4632            provider: "hetzner".into(),
4633            location: Some("hil".into()),
4634            server_type: Some("ccx13".into()),
4635            hosts_mirrors: vec![],
4636            mesh_tags: mesh_tags.into_iter().map(String::from).collect(),
4637            region: None,
4638            zone: None,
4639            arch: None,
4640            bucket: None,
4641            vendor: None,
4642            nickname: None,
4643            legacy_hostkey_fingerprint: None,
4644            registration: Default::default(),
4645            ssh_keys: vec![],
4646            cloudflared: None,
4647            hosts_operator_bridge: false,
4648            connect: None,
4649            allocatable: None,
4650            taints: vec![],
4651            sovereign_group: None,
4652            sovereign_role: None,
4653        }
4654    }
4655
4656    /// Like [`make_machine`] but with explicit topology axes for F16 tests.
4657    fn make_machine_topo(
4658        name: &str,
4659        provider: &str,
4660        region: &str,
4661        mesh_tags: Vec<&str>,
4662    ) -> MachineConfig {
4663        MachineConfig {
4664            provider: provider.into(),
4665            region: Some(region.into()),
4666            zone: Some(region.into()),
4667            ..make_machine(name, mesh_tags)
4668        }
4669    }
4670
4671    fn make_empty_cfg(machines: Vec<MachineConfig>) -> CloudConfig {
4672        CloudConfig {
4673            workspace_root: PathBuf::new(),
4674            machines,
4675            providers: vec![],
4676            machine_origins: BTreeMap::new(),
4677            provider_origins: BTreeMap::new(),
4678            services: BTreeMap::new(),
4679            domains: BTreeMap::new(),
4680            legacy_mirrors: vec![],
4681            workloads: vec![],
4682            topology: TopologyConfig::default(),
4683            legacy_services: vec![],
4684        }
4685    }
4686
4687    #[test]
4688    fn required_spec_parses_from_provider_fields() {
4689        let toml_src = r#"
4690use = "hetzner-primary"
4691[required]
4692mesh_tags = ["tag:cloud-runner"]
4693"#;
4694        let slot: MirrorProviderSlot = toml::from_str(toml_src).unwrap();
4695        let req = slot.required().expect("required block present");
4696        assert_eq!(req.mesh_tags, vec!["tag:cloud-runner"]);
4697    }
4698
4699    #[test]
4700    fn required_spec_absent_when_field_missing() {
4701        let slot: MirrorProviderSlot = toml::from_str(r#"use = "hetzner-primary""#).unwrap();
4702        assert!(slot.required().is_none());
4703    }
4704
4705    #[test]
4706    fn db_catalog_parses_all_env_blocks() {
4707        // W241 / R571-F8: a service.toml [db] table with dev/pond/cloud.
4708        let toml_src = r#"
4709schema_version = 1
4710name = "scrabcake"
4711domain = "scrabcake.net.yah.dev"
4712
4713[[db.dev]]
4714name = "main"
4715path = "data/dev.sqlite"
4716
4717[[db.pond]]
4718name = "main"
4719port = 5433
4720
4721[[db.pond]]
4722name = "pg"
4723port = 5432
4724kind = "postgres"
4725
4726[[db.cloud]]
4727name = "main"
4728url = "libsql://scrabcake.turso.io"
4729auth_token_env = "SCRABCAKE_TURSO_TOKEN"
4730"#;
4731        let svc: ServiceConfig = toml::from_str(toml_src).unwrap();
4732        assert_eq!(svc.db.dev.len(), 1);
4733        assert_eq!(svc.db.dev[0].path, "data/dev.sqlite");
4734        assert_eq!(svc.db.pond.len(), 2);
4735        assert_eq!(svc.db.pond[0].port, Some(5433));
4736        assert_eq!(svc.db.pond[0].kind, PondDbKind::Turso); // default
4737        assert_eq!(svc.db.pond[1].kind, PondDbKind::Postgres);
4738        assert_eq!(
4739            svc.db.cloud[0].auth_token_env.as_deref(),
4740            Some("SCRABCAKE_TURSO_TOKEN")
4741        );
4742    }
4743
4744    #[test]
4745    fn service_without_db_table_has_empty_catalog() {
4746        let svc: ServiceConfig =
4747            toml::from_str("schema_version = 1\nname = \"s\"\ndomain = \"s.dev\"\n").unwrap();
4748        assert!(svc.db.is_empty());
4749        // And an empty [db] must not appear when re-serialized.
4750        let out = toml::to_string(&svc).unwrap();
4751        assert!(
4752            !out.contains("[db"),
4753            "empty db table should be skipped: {out}"
4754        );
4755    }
4756
4757    #[test]
4758    fn camp_shared_cloud_toml_parses() {
4759        let src = r#"
4760[[cloud]]
4761name = "analytics"
4762url = "postgres://shared/analytics"
4763"#;
4764        let shared: CampCloudDbs = toml::from_str(src).unwrap();
4765        assert_eq!(shared.cloud.len(), 1);
4766        assert_eq!(shared.cloud[0].name, "analytics");
4767    }
4768
4769    #[test]
4770    fn resolve_machine_by_mesh_tags_superset_match() {
4771        let cfg = make_empty_cfg(vec![
4772            make_machine("yah-bnt-1", vec!["tag:primary-yah", "tag:tier-scratch"]),
4773            make_machine("us-west-001", vec!["tag:primary-yah", "tag:cloud-runner"]),
4774        ]);
4775        let picked = cfg
4776            .resolve_machine_by_mesh_tags(&["tag:cloud-runner".into()])
4777            .map(|m| m.name.as_str());
4778        assert_eq!(picked, Some("us-west-001"));
4779    }
4780
4781    #[test]
4782    fn resolve_machine_by_mesh_tags_returns_none_when_no_match() {
4783        let cfg = make_empty_cfg(vec![make_machine("yah-bnt-1", vec!["tag:primary-yah"])]);
4784        assert!(cfg
4785            .resolve_machine_by_mesh_tags(&["tag:cloud-runner".into()])
4786            .is_none());
4787    }
4788
4789    // ─── R590-F1 mesh-tag node-selector admission ───────────────────────────
4790
4791    /// Build a forge WorkloadSpec carrying the R594 node-selector annotation.
4792    /// `selector` is the comma-joined mesh-tag set; `None` omits the annotation
4793    /// entirely (pre-R594 "no constraint").
4794    fn ws_with_selector(selector: Option<&str>) -> WorkloadSpec {
4795        use workload_spec::{ImageRef, TierTag};
4796        let mut ws = WorkloadSpec::for_forge(
4797            "R590-F1-test",
4798            ImageRef {
4799                registry: "docker.io".into(),
4800                repository: "library/busybox".into(),
4801                tag: "latest".into(),
4802                digest: workload_spec::testing::test_digest(),
4803            },
4804            TierTag("infra".into()),
4805            vec![],
4806        );
4807        if let Some(sel) = selector {
4808            ws.annotations.insert(
4809                velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION.into(),
4810                sel.into(),
4811            );
4812        }
4813        ws
4814    }
4815
4816    /// The build-worker fleet shape: one x86 node (us-west-002) and one arm
4817    /// node (a Pi5), both carrying `tag:build-worker`.
4818    fn build_worker_fleet() -> CloudConfig {
4819        make_empty_cfg(vec![
4820            make_machine("us-west-002", vec!["tag:build-worker", "arch:x86"]),
4821            make_machine("pi5-001", vec!["tag:build-worker", "arch:arm"]),
4822        ])
4823    }
4824
4825    #[test]
4826    fn admit_workload_routes_amd64_to_x86_worker() {
4827        let cfg = build_worker_fleet();
4828        let ws = ws_with_selector(Some("tag:build-worker,arch:x86"));
4829        let picked = cfg.admit_workload(&ws).unwrap();
4830        assert_eq!(picked.name, "us-west-002");
4831    }
4832
4833    #[test]
4834    fn admit_workload_routes_arm64_to_pi5_worker() {
4835        let cfg = build_worker_fleet();
4836        let ws = ws_with_selector(Some("tag:build-worker,arch:arm"));
4837        let picked = cfg.admit_workload(&ws).unwrap();
4838        assert_eq!(picked.name, "pi5-001");
4839    }
4840
4841    /// A forge run must be admissible on a build-worker smaller than its own
4842    /// cgroup ceiling.
4843    ///
4844    /// The fleet's arm build-workers are 8 GiB Pi-5s and `for_forge` sets a
4845    /// 32 GiB ceiling, so while admission read `resources.memory_mb` as the
4846    /// capacity floor this returned "no candidates" and *every* offloaded qed
4847    /// step to those nodes failed at dispatch — measured on desktop-release run
4848    /// b04cef47, where the aarch64-linux row died in 1.6s. The other
4849    /// build-workers (16 GiB us-west-003, and the arm Pi-5s) were excluded the
4850    /// same way, leaving one 47 GiB node as the fleet's only legal target for
4851    /// remote CI.
4852    #[test]
4853    fn admit_workload_places_a_forge_run_on_a_worker_smaller_than_its_ceiling() {
4854        let mut pi = make_machine("pi5-001", vec!["tag:build-worker", "arch:arm"]);
4855        pi.allocatable = Some(NodeAllocatable {
4856            memory_mb: 8192,
4857            cpu_millis: 4000,
4858        });
4859        let cfg = make_empty_cfg(vec![pi]);
4860
4861        let ws = ws_with_selector(Some("tag:build-worker,arch:arm"));
4862        assert!(
4863            ws.resources.memory_mb > 8192,
4864            "precondition: the ceiling must exceed the node, or this proves nothing"
4865        );
4866
4867        let picked = cfg
4868            .admit_workload(&ws)
4869            .expect("an 8 GiB build-worker must admit a forge run");
4870        assert_eq!(picked.name, "pi5-001");
4871    }
4872
4873    /// The floor is still enforced — the fix separates two numbers, it does not
4874    /// disable the R572-F5 capacity check.
4875    #[test]
4876    fn admit_workload_still_rejects_a_node_below_the_declared_request() {
4877        let mut tiny = make_machine("tiny-001", vec!["tag:build-worker", "arch:arm"]);
4878        tiny.allocatable = Some(NodeAllocatable {
4879            memory_mb: 512,
4880            cpu_millis: 4000,
4881        });
4882        let cfg = make_empty_cfg(vec![tiny]);
4883
4884        let ws = ws_with_selector(Some("tag:build-worker,arch:arm"));
4885        assert!(
4886            cfg.admit_workload(&ws).is_err(),
4887            "a 512 MiB node cannot satisfy a 2 GiB forge request"
4888        );
4889    }
4890
4891    // ─── R833-F8 imperative node-selector admission ─────────────────────────
4892
4893    /// Build a forge WorkloadSpec carrying the R833-F8 imperative node
4894    /// selector — the operator's `--where=node:<machine>`.
4895    fn ws_pinned_to(node: &str) -> WorkloadSpec {
4896        let mut ws = ws_with_selector(None);
4897        ws.annotations.insert(
4898            velveteen_exec::remote::NODE_SELECTOR_NODE_ANNOTATION.into(),
4899            node.into(),
4900        );
4901        ws
4902    }
4903
4904    /// The ticket's acceptance shape: a named node wins over the
4905    /// declaration-order tie-break that would otherwise decide placement.
4906    /// `us-west-002` is declared first and carries every tag, so an inferred
4907    /// placement lands there; the pin must reach `pi5-001` regardless.
4908    #[test]
4909    fn admit_workload_honours_an_explicitly_named_node() {
4910        let cfg = build_worker_fleet();
4911        assert_eq!(
4912            cfg.admit_workload(&ws_with_selector(Some("tag:build-worker")))
4913                .unwrap()
4914                .name,
4915            "us-west-002",
4916            "precondition: inference elects the first-declared node",
4917        );
4918        assert_eq!(
4919            cfg.admit_workload(&ws_pinned_to("pi5-001")).unwrap().name,
4920            "pi5-001",
4921        );
4922    }
4923
4924    /// A pin at a machine that is not declared fails loud, naming the
4925    /// constraint and the pool — the operator mistyped a node, and silently
4926    /// running the build somewhere else is the one outcome that must not
4927    /// happen.
4928    #[test]
4929    fn admit_workload_refuses_a_node_that_is_not_declared() {
4930        let cfg = build_worker_fleet();
4931        let err = cfg
4932            .admit_workload(&ws_pinned_to("us-west-404"))
4933            .unwrap_err()
4934            .to_string();
4935        assert!(err.contains("required.nodes=[us-west-404]"), "{err}");
4936        assert!(err.contains("us-west-002"), "the pool must be named: {err}");
4937    }
4938
4939    /// The pin narrows the candidate set; it does not suspend the other axes.
4940    /// A named node that cannot fit the workload still refuses, rather than
4941    /// being handed work it has no room for.
4942    #[test]
4943    fn a_pinned_node_is_still_checked_against_capacity() {
4944        let mut tiny = make_machine("tiny-001", vec!["tag:build-worker", "arch:arm"]);
4945        tiny.allocatable = Some(NodeAllocatable {
4946            memory_mb: 512,
4947            cpu_millis: 4000,
4948        });
4949        let cfg = make_empty_cfg(vec![tiny]);
4950        assert!(cfg.admit_workload(&ws_pinned_to("tiny-001")).is_err());
4951    }
4952
4953    /// Inference is untouched: with no node annotation the `nodes` axis is
4954    /// empty, which is "no constraint" — every pre-R833-F8 workload is admitted
4955    /// exactly as before.
4956    #[test]
4957    fn an_unpinned_workload_carries_no_node_constraint() {
4958        assert!(node_selector_node(&ws_with_selector(Some("arch:x86"))).is_none());
4959        assert_eq!(
4960            node_selector_node(&ws_pinned_to("us-west-003")).as_deref(),
4961            Some("us-west-003")
4962        );
4963        assert!(RequiredSpec::default().is_unconstrained());
4964        assert!(!RequiredSpec {
4965            nodes: vec!["us-west-003".into()],
4966            ..Default::default()
4967        }
4968        .is_unconstrained());
4969    }
4970
4971    #[test]
4972    fn admit_workload_rejects_node_missing_required_tag() {
4973        // Only an arm worker exists; an x86 build must NOT land on it.
4974        let cfg = make_empty_cfg(vec![make_machine(
4975            "pi5-001",
4976            vec!["tag:build-worker", "arch:arm"],
4977        )]);
4978        let ws = ws_with_selector(Some("tag:build-worker,arch:x86"));
4979        assert!(cfg.admit_workload(&ws).is_err());
4980    }
4981
4982    /// R555-S1 regression: with TWO nodes carrying the same tag set, which one
4983    /// admits must be decided by *declaration order* (file name), which is the
4984    /// contract `admit_workload` documents — not by `read_dir` order, which is
4985    /// filesystem-dependent and can change when an unrelated file appears in
4986    /// the directory. Written creation-order-reversed so a filesystem that
4987    /// yields creation order (rather than sorted order) trips it without the
4988    /// sort in `load_dir`.
4989    ///
4990    /// Live consequence this guards: `.yah/infra/machines/` carries both
4991    /// us-west-002 and us-west-003 on `[tag:build-worker, arch:x86, os:linux]`,
4992    /// so an x86 QED offload has two equal candidates. Unstable selection means
4993    /// a retried build cannot be relied on to land back on the node whose
4994    /// working state it left behind.
4995    #[test]
4996    fn equally_matching_machines_admit_in_file_name_order() {
4997        let tmp = tempfile::TempDir::new().unwrap();
4998        let machines = tmp.path().join(".yah").join("infra").join("machines");
4999        std::fs::create_dir_all(&machines).unwrap();
5000        let toml_for = |name: &str| {
5001            format!(
5002                r#"name = "{name}"
5003provider = "static"
5004mesh_tags = ["tag:build-worker", "arch:x86"]
5005"#
5006            )
5007        };
5008        // Reverse-of-sorted creation order on purpose.
5009        std::fs::write(machines.join("b-second.toml"), toml_for("b-second")).unwrap();
5010        std::fs::write(machines.join("a-first.toml"), toml_for("a-first")).unwrap();
5011
5012        let cfg = CloudConfig::load(tmp.path()).unwrap();
5013        assert_eq!(
5014            cfg.machines.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(),
5015            vec!["a-first", "b-second"],
5016            "machines must load in file-name order, not read_dir order"
5017        );
5018
5019        let ws = ws_with_selector(Some("tag:build-worker,arch:x86"));
5020        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "a-first");
5021
5022        // R605-T14: the same two nodes, seen as the pool they are. The head is
5023        // what `admit_workload` returns, and the tail is what the dispatcher
5024        // fails over to when the head does not answer — so these two views must
5025        // come from one predicate, not two.
5026        assert_eq!(
5027            cfg.admit_workload_candidates(&ws)
5028                .unwrap()
5029                .iter()
5030                .map(|m| m.name.as_str())
5031                .collect::<Vec<_>>(),
5032            vec!["a-first", "b-second"],
5033            "the pool must be every admissible node, in the same declaration order"
5034        );
5035    }
5036
5037    /// A pool of one is still a pool, and a pool of none is an `Err` that reads
5038    /// exactly like `admit_workload`'s — "nothing admits this" is one failure
5039    /// with one wording, not two.
5040    #[test]
5041    fn admit_workload_candidates_matches_admit_workload_on_the_edges() {
5042        let cfg = make_empty_cfg(vec![
5043            make_machine("x86-box", vec!["tag:build-worker", "arch:x86"]),
5044            make_machine("arm-box", vec!["tag:build-worker", "arch:arm"]),
5045        ]);
5046
5047        let one = ws_with_selector(Some("arch:arm"));
5048        assert_eq!(
5049            cfg.admit_workload_candidates(&one)
5050                .unwrap()
5051                .iter()
5052                .map(|m| m.name.as_str())
5053                .collect::<Vec<_>>(),
5054            vec!["arm-box"],
5055            "only one node carries arch:arm, so the pool is that one node"
5056        );
5057
5058        let none = ws_with_selector(Some("arch:riscv"));
5059        let pool_err = cfg.admit_workload_candidates(&none).unwrap_err().to_string();
5060        let single_err = cfg.admit_workload(&none).unwrap_err().to_string();
5061        assert_eq!(
5062            pool_err, single_err,
5063            "an empty pool must be refused in the same words as an unadmitted workload"
5064        );
5065    }
5066
5067    /// R844-B7 — the wrong-root half of the distinction. A directory with no
5068    /// `.yah/` at all used to load as a valid config with zero machines, so a
5069    /// caller pointed at the wrong directory got a green result that measured
5070    /// nothing. Asserting `load` merely *succeeds* is what let that through;
5071    /// the shape that catches it is a non-zero machine count, or — here — an
5072    /// `Err` naming the path that was looked for.
5073    #[test]
5074    fn loading_a_directory_that_is_not_a_yah_workspace_is_an_error() {
5075        let tmp = tempfile::TempDir::new().unwrap();
5076        // A plausible-looking package root: real files, real subdirectories,
5077        // no `.yah/`. This is exactly what `load_cloud(".")` reads when a test
5078        // runs under `cargo test` from a member crate.
5079        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
5080        std::fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
5081
5082        let err = CloudConfig::load(tmp.path()).expect_err(
5083            "a directory with no .yah/ is the WRONG DIRECTORY, not a fleet with no machines",
5084        );
5085        let msg = format!("{err:#}");
5086        assert!(
5087            msg.contains("not a yah workspace"),
5088            "error must say the root is not a workspace, got: {msg}"
5089        );
5090        assert!(
5091            msg.contains(&tmp.path().join(".yah").display().to_string()),
5092            "error must name the path it looked for so an operator sees the \
5093             wrong-root immediately, got: {msg}"
5094        );
5095    }
5096
5097    /// R844-B7 — the other half, and the reason the check is drawn at `.yah/`
5098    /// rather than at the machine list: a camp that declares no machines is a
5099    /// real workspace and must keep loading. Blanket-erroring on an empty
5100    /// fleet would conflate `unknown` with `answered with none`, which is the
5101    /// exact confusion the check exists to remove.
5102    #[test]
5103    fn a_workspace_with_no_machines_declared_still_loads() {
5104        let tmp = tempfile::TempDir::new().unwrap();
5105        std::fs::create_dir_all(tmp.path().join(".yah")).unwrap();
5106
5107        let cfg = CloudConfig::load(tmp.path())
5108            .expect("a `.yah/` with no infra/machines/ is an empty fleet, not a wrong root");
5109        assert!(cfg.machines.is_empty(), "nothing was declared");
5110        assert!(cfg.services.is_empty());
5111        assert!(cfg.providers.is_empty());
5112
5113        // And an existing-but-empty machines dir is the same answer, not a
5114        // second special case.
5115        std::fs::create_dir_all(crate::paths::machines_dir(tmp.path())).unwrap();
5116        let cfg = CloudConfig::load(tmp.path()).expect("an empty machines/ dir still loads");
5117        assert!(cfg.machines.is_empty());
5118    }
5119
5120    #[test]
5121    fn admit_workload_empty_selector_is_unconstrained() {
5122        // Absent annotation ⇒ no mesh-tag constraint ⇒ first declared machine
5123        // (pre-R594 behavior preserved).
5124        let cfg = build_worker_fleet();
5125        let ws = ws_with_selector(None);
5126        let picked = cfg.admit_workload(&ws).unwrap();
5127        assert_eq!(picked.name, "us-west-002");
5128    }
5129
5130    #[test]
5131    fn node_selector_mesh_tags_trims_and_drops_empties() {
5132        let ws = ws_with_selector(Some(" tag:build-worker , arch:x86 ,"));
5133        assert_eq!(
5134            node_selector_mesh_tags(&ws),
5135            vec!["tag:build-worker".to_string(), "arch:x86".to_string()]
5136        );
5137        assert!(node_selector_mesh_tags(&ws_with_selector(None)).is_empty());
5138    }
5139
5140    // ─── F16 topology-aware resolver ────────────────────────────────────────
5141
5142    fn two_region_fleet() -> CloudConfig {
5143        make_empty_cfg(vec![
5144            make_machine_topo(
5145                "us-west-001",
5146                "hetzner",
5147                "us-west",
5148                vec!["tag:cloud-runner"],
5149            ),
5150            make_machine_topo(
5151                "eu-west-001",
5152                "hetzner",
5153                "eu-west",
5154                vec!["tag:cloud-runner"],
5155            ),
5156        ])
5157    }
5158
5159    #[test]
5160    fn resolve_machine_matches_on_region_plus_mesh_tags() {
5161        let cfg = two_region_fleet();
5162        let req = RequiredSpec {
5163            regions: vec!["us-west".into()],
5164            mesh_tags: vec!["tag:cloud-runner".into()],
5165            ..Default::default()
5166        };
5167        let picked = cfg.resolve_machine(&req).unwrap();
5168        assert_eq!(picked.name, "us-west-001");
5169    }
5170
5171    #[test]
5172    fn resolve_machine_region_disambiguates_same_tag() {
5173        // Both boxes carry tag:cloud-runner; the region axis selects eu-west.
5174        let cfg = two_region_fleet();
5175        let req = RequiredSpec {
5176            regions: vec!["eu-west".into()],
5177            mesh_tags: vec!["tag:cloud-runner".into()],
5178            ..Default::default()
5179        };
5180        assert_eq!(cfg.resolve_machine(&req).unwrap().name, "eu-west-001");
5181    }
5182
5183    #[test]
5184    fn resolve_machine_fails_loud_with_constraint_summary() {
5185        let cfg = two_region_fleet();
5186        let req = RequiredSpec {
5187            regions: vec!["us-central".into()],
5188            mesh_tags: vec!["tag:cloud-runner".into()],
5189            ..Default::default()
5190        };
5191        let err = cfg.resolve_machine(&req).unwrap_err().to_string();
5192        assert!(err.contains("required.regions=[us-central]"), "got: {err}");
5193        assert!(
5194            err.contains("required.mesh_tags=[tag:cloud-runner]"),
5195            "got: {err}"
5196        );
5197        // Names the candidates it rejected.
5198        assert!(err.contains("us-west-001"), "got: {err}");
5199    }
5200
5201    #[test]
5202    fn resolve_machine_provider_axis_filters() {
5203        let cfg = make_empty_cfg(vec![
5204            make_machine_topo("aws-west-1", "aws", "us-west", vec!["tag:cloud-runner"]),
5205            make_machine_topo("hz-west-1", "hetzner", "us-west", vec!["tag:cloud-runner"]),
5206        ]);
5207        let req = RequiredSpec {
5208            regions: vec!["us-west".into()],
5209            providers: vec!["hetzner".into()],
5210            ..Default::default()
5211        };
5212        assert_eq!(cfg.resolve_machine(&req).unwrap().name, "hz-west-1");
5213    }
5214
5215    #[test]
5216    fn unconstrained_required_spec_matches_first_machine() {
5217        let cfg = two_region_fleet();
5218        assert!(RequiredSpec::default().is_unconstrained());
5219        assert_eq!(
5220            cfg.resolve_machine(&RequiredSpec::default()).unwrap().name,
5221            "us-west-001"
5222        );
5223    }
5224
5225    #[test]
5226    fn required_spec_parses_topology_axes_from_toml() {
5227        let toml_src = r#"
5228use = "hetzner-primary"
5229[required]
5230regions = ["us-west"]
5231mesh_tags = ["tag:cloud-runner"]
5232"#;
5233        let slot: MirrorProviderSlot = toml::from_str(toml_src).unwrap();
5234        let req = slot.required().expect("required block present");
5235        assert_eq!(req.regions, vec!["us-west"]);
5236        assert_eq!(req.mesh_tags, vec!["tag:cloud-runner"]);
5237        assert!(req.zones.is_empty());
5238    }
5239
5240    // ─── R844-F8 replica count ──────────────────────────────────────────────
5241
5242    fn three_runner_fleet() -> CloudConfig {
5243        make_empty_cfg(vec![
5244            make_machine_topo("us-east-001", "hetzner", "us-east", vec!["tag:cloud-runner"]),
5245            make_machine_topo(
5246                "us-south-001",
5247                "hetzner",
5248                "us-south",
5249                vec!["tag:cloud-runner"],
5250            ),
5251            make_machine_topo(
5252                "us-west-001",
5253                "hetzner",
5254                "us-west",
5255                vec!["tag:cloud-runner"],
5256            ),
5257        ])
5258    }
5259
5260    #[test]
5261    fn an_absent_replica_count_still_places_exactly_one_machine() {
5262        // The migration is additive: every mirror on disk omits `replicas`, and
5263        // must resolve byte-identically to the pre-R844-F8 answer.
5264        let cfg = three_runner_fleet();
5265        let req = RequiredSpec {
5266            mesh_tags: vec!["tag:cloud-runner".into()],
5267            ..Default::default()
5268        };
5269        assert_eq!(req.replica_count(), 1);
5270        let names: Vec<&str> = cfg
5271            .resolve_machines(&req)
5272            .unwrap()
5273            .iter()
5274            .map(|m| m.name.as_str())
5275            .collect();
5276        assert_eq!(names, vec!["us-east-001"]);
5277        assert_eq!(cfg.resolve_machine(&req).unwrap().name, "us-east-001");
5278    }
5279
5280    #[test]
5281    fn a_replica_count_places_that_many_machines_not_every_match() {
5282        // Three machines match; two are asked for; two are placed. Inferring the
5283        // count from the match count would make adding a box to the fleet
5284        // silently scale a production front door.
5285        let cfg = three_runner_fleet();
5286        let req = RequiredSpec {
5287            mesh_tags: vec!["tag:cloud-runner".into()],
5288            replicas: Some(2),
5289            ..Default::default()
5290        };
5291        let names: Vec<&str> = cfg
5292            .resolve_machines(&req)
5293            .unwrap()
5294            .iter()
5295            .map(|m| m.name.as_str())
5296            .collect();
5297        assert_eq!(names, vec!["us-east-001", "us-south-001"]);
5298    }
5299
5300    #[test]
5301    fn fewer_matches_than_replicas_is_an_error_naming_both_numbers() {
5302        // Never a partial placement: one of two reported as success is the
5303        // subset-that-looks-like-it-worked failure in its purest form.
5304        let cfg = three_runner_fleet();
5305        let req = RequiredSpec {
5306            regions: vec!["us-east".into()],
5307            replicas: Some(2),
5308            ..Default::default()
5309        };
5310        let err = cfg.resolve_machines(&req).unwrap_err().to_string();
5311        assert!(err.contains("only 1 of 2"), "got: {err}");
5312        assert!(err.contains("required.regions=[us-east]"), "got: {err}");
5313        // …and names the pool it searched, like every other placement refusal.
5314        assert!(err.contains("declared machines"), "got: {err}");
5315        assert!(err.contains("us-south-001"), "got: {err}");
5316    }
5317
5318    #[test]
5319    fn zero_replicas_is_refused_rather_than_placing_nothing() {
5320        let cfg = three_runner_fleet();
5321        let req = RequiredSpec {
5322            mesh_tags: vec!["tag:cloud-runner".into()],
5323            replicas: Some(0),
5324            ..Default::default()
5325        };
5326        let err = cfg.resolve_machines(&req).unwrap_err().to_string();
5327        assert!(err.contains("replicas = 0"), "got: {err}");
5328    }
5329
5330    #[test]
5331    fn replicas_parses_from_the_inline_required_form() {
5332        // The INLINE form specifically: `[providers.bundle.required]` as a table
5333        // HEADER ends the slot's table and reparents every key below it.
5334        let slot: MirrorProviderSlot = toml::from_str(
5335            r#"
5336use = "hetzner-primary"
5337port = 8080
5338required = { regions = ["us-east"], mesh_tags = ["tag:cloud-runner"], replicas = 2 }
5339"#,
5340        )
5341        .unwrap();
5342        assert_eq!(
5343            slot.fields().get("port").and_then(|v| v.as_integer()),
5344            Some(8080),
5345            "the inline form leaves the slot's other keys where they were"
5346        );
5347        let req = slot.required().expect("required block present");
5348        assert_eq!(req.replicas, Some(2));
5349        assert_eq!(req.replica_count(), 2);
5350        // A count is not a match axis — it says how many, not which.
5351        assert!(!req.is_unconstrained());
5352        assert!(RequiredSpec {
5353            replicas: Some(2),
5354            ..Default::default()
5355        }
5356        .is_unconstrained());
5357    }
5358
5359    #[test]
5360    fn round_trip_machine() {
5361        let cfg = MachineConfig {
5362            name: "test-pdx-1".into(),
5363            provider: "hetzner".into(),
5364            location: Some("pdx".into()),
5365            server_type: Some("cpx22".into()),
5366            hosts_mirrors: vec!["noisetable".into()],
5367            mesh_tags: vec!["region:pdx".into()],
5368            region: Some("us-west".into()),
5369            zone: Some("pdx".into()),
5370            arch: None,
5371            bucket: Some(BucketSpec {
5372                name: "test-assets-pdx-1".into(),
5373                public_read: false,
5374            }),
5375            vendor: None,
5376            nickname: None,
5377            legacy_hostkey_fingerprint: None,
5378            registration: Default::default(),
5379            ssh_keys: vec![],
5380            cloudflared: None,
5381            hosts_operator_bridge: false,
5382            connect: None,
5383            allocatable: None,
5384            taints: vec![],
5385            sovereign_group: None,
5386            sovereign_role: None,
5387        };
5388        let s = toml::to_string(&cfg).unwrap();
5389        let back: MachineConfig = toml::from_str(&s).unwrap();
5390        assert_eq!(back.name, cfg.name);
5391        assert_eq!(back.location, cfg.location);
5392        assert_eq!(back.region.as_deref(), Some("us-west"));
5393        assert_eq!(back.zone.as_deref(), Some("pdx"));
5394    }
5395
5396    #[test]
5397    fn round_trip_mirror() {
5398        let cfg = LegacyMirrorConfig {
5399            camp: "noisetable".into(),
5400            regions: vec!["pdx".into(), "iad".into()],
5401            workloads: vec!["asset-registry".into()],
5402            cloud_domain: None,
5403        };
5404        let s = toml::to_string(&cfg).unwrap();
5405        let back: LegacyMirrorConfig = toml::from_str(&s).unwrap();
5406        assert_eq!(back.camp, cfg.camp);
5407        assert_eq!(back.regions, cfg.regions);
5408        assert_eq!(back.workloads, cfg.workloads);
5409    }
5410
5411    #[test]
5412    fn mirror_serialises_as_camp_key() {
5413        // Serialised form should use `camp`, not `rig`.
5414        let cfg = LegacyMirrorConfig {
5415            camp: "noisetable".into(),
5416            regions: vec!["pdx".into()],
5417            workloads: vec![],
5418            cloud_domain: None,
5419        };
5420        let s = toml::to_string(&cfg).unwrap();
5421        assert!(
5422            s.contains("camp = "),
5423            "serialised key should be 'camp': {s}"
5424        );
5425        assert!(!s.contains("rig = "), "old key should not appear: {s}");
5426    }
5427
5428    #[test]
5429    fn mirror_rig_alias_still_loads() {
5430        // Old mirrors/*.toml files use `rig = "..."` before the R137 rename;
5431        // the alias keeps them loading until the one-time `sed` migration runs.
5432        let toml_str =
5433            "rig = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = [\"asset-registry\"]\n";
5434        let cfg: LegacyMirrorConfig = toml::from_str(toml_str).unwrap();
5435        assert_eq!(cfg.camp, "noisetable");
5436    }
5437
5438    #[test]
5439    fn mirror_services_alias_still_loads() {
5440        // Old mirrors/*.toml files use `services = [...]`; the alias keeps them
5441        // loading without a migration step.
5442        let toml_str =
5443            "camp = \"noisetable\"\nregions = [\"pdx\"]\nservices = [\"asset-registry\"]\n";
5444        let cfg: LegacyMirrorConfig = toml::from_str(toml_str).unwrap();
5445        assert_eq!(cfg.workloads, vec!["asset-registry"]);
5446    }
5447
5448    #[test]
5449    fn round_trip_service_legacy() {
5450        let cfg = LegacyServiceConfig {
5451            name: "asset-registry".into(),
5452            image: "ghcr.io/noisetable/asset-registry".into(),
5453            version: "v1.0.0".into(),
5454            env: HashMap::new(),
5455            ports: vec![PortMapping {
5456                host: 8080,
5457                container: 8080,
5458            }],
5459            mesh_only: false,
5460            bind_interface: None,
5461            tenant: TenantId::singleton(),
5462        };
5463        let s = toml::to_string(&cfg).unwrap();
5464        let back: LegacyServiceConfig = toml::from_str(&s).unwrap();
5465        assert_eq!(back.name, cfg.name);
5466        assert_eq!(back.image, cfg.image);
5467    }
5468
5469    #[test]
5470    fn service_bind_interface_round_trips() {
5471        let cfg = LegacyServiceConfig {
5472            name: "postgres".into(),
5473            image: "postgres".into(),
5474            version: "16".into(),
5475            env: HashMap::new(),
5476            ports: vec![PortMapping {
5477                host: 5432,
5478                container: 5432,
5479            }],
5480            mesh_only: true,
5481            bind_interface: Some("tailscale0".into()),
5482            tenant: TenantId::singleton(),
5483        };
5484        let s = toml::to_string(&cfg).unwrap();
5485        let back: LegacyServiceConfig = toml::from_str(&s).unwrap();
5486        assert_eq!(back.bind_interface.as_deref(), Some("tailscale0"));
5487    }
5488
5489    #[test]
5490    fn service_bind_interface_absent_is_none() {
5491        let toml_str = "name = \"app\"\nimage = \"app\"\nversion = \"v1\"\n";
5492        let cfg: LegacyServiceConfig = toml::from_str(toml_str).unwrap();
5493        assert!(
5494            cfg.bind_interface.is_none(),
5495            "bind_interface should default to None"
5496        );
5497    }
5498
5499    #[test]
5500    fn service_bind_interface_skipped_when_none() {
5501        let cfg = LegacyServiceConfig {
5502            name: "app".into(),
5503            image: "app".into(),
5504            version: "v1".into(),
5505            env: HashMap::new(),
5506            ports: vec![],
5507            mesh_only: false,
5508            bind_interface: None,
5509            tenant: TenantId::singleton(),
5510        };
5511        let s = toml::to_string(&cfg).unwrap();
5512        assert!(!s.contains("bind_interface"), "None should be skipped: {s}");
5513    }
5514
5515    #[test]
5516    fn load_dir_missing_is_empty() {
5517        let dir = std::path::PathBuf::from("/nonexistent/path");
5518        let result: Vec<MachineConfig> = load_dir(dir).unwrap();
5519        assert!(result.is_empty());
5520    }
5521
5522    #[test]
5523    fn topology_round_trip() {
5524        let topo = TopologyConfig {
5525            assignments: vec![
5526                MirrorAssignment {
5527                    mirror: "noisetable-pdx".into(),
5528                    machine: "noisetable-pdx-1".into(),
5529                },
5530                MirrorAssignment {
5531                    mirror: "noisetable-iad".into(),
5532                    machine: "noisetable-iad-1".into(),
5533                },
5534            ],
5535            buckets: vec![],
5536        };
5537        let s = toml::to_string(&topo).unwrap();
5538        let back: TopologyConfig = toml::from_str(&s).unwrap();
5539        assert_eq!(back.assignments.len(), 2);
5540        assert_eq!(back.assignments[0].mirror, "noisetable-pdx");
5541        assert_eq!(back.assignments[1].machine, "noisetable-iad-1");
5542    }
5543
5544    #[test]
5545    fn topology_absent_returns_default() {
5546        let tmp = tempfile::TempDir::new().unwrap();
5547        let path = tmp.path().join("topology.toml");
5548        // file doesn't exist
5549        let topo = load_topology(path).unwrap();
5550        assert!(topo.assignments.is_empty());
5551    }
5552
5553    /// Helper: lay out a `<workspace_root>/.yah/cloud/` legacy tree for the
5554    /// pre-R215 cargo tests below; returns the legacy cloud_dir for writes.
5555    fn make_legacy_cloud_dir(root: &std::path::Path) -> std::path::PathBuf {
5556        let cloud_dir = root.join(".yah").join("cloud");
5557        std::fs::create_dir_all(&cloud_dir).unwrap();
5558        cloud_dir
5559    }
5560
5561    #[test]
5562    fn cloud_config_load_and_lookup() {
5563        let tmp = tempfile::TempDir::new().unwrap();
5564        let root = tmp.path();
5565        let cloud_dir = make_legacy_cloud_dir(root);
5566
5567        let machine = MachineConfig {
5568            name: "noisetable-pdx-1".into(),
5569            provider: "hetzner".into(),
5570            location: Some("pdx".into()),
5571            server_type: Some("cpx22".into()),
5572            hosts_mirrors: vec!["noisetable".into(), "yah".into()],
5573            mesh_tags: vec!["region:pdx".into(), "tier:t2".into()],
5574            region: None,
5575            zone: None,
5576            arch: None,
5577            bucket: Some(BucketSpec {
5578                name: "noisetable-assets-pdx-1".into(),
5579                public_read: false,
5580            }),
5581            vendor: None,
5582            nickname: None,
5583            legacy_hostkey_fingerprint: None,
5584            registration: Default::default(),
5585            ssh_keys: vec![],
5586            cloudflared: None,
5587            hosts_operator_bridge: false,
5588            connect: None,
5589            allocatable: None,
5590            taints: vec![],
5591            sovereign_group: None,
5592            sovereign_role: None,
5593        };
5594        // Land in the legacy tree so the legacy machine loader picks it up.
5595        machine.save(&cloud_dir).unwrap();
5596
5597        let mirror_toml = "camp = \"noisetable\"\nregions = [\"pdx\", \"iad\", \"fsn\"]\nworkloads = [\"asset-registry\"]\n";
5598        std::fs::create_dir_all(cloud_dir.join("mirrors")).unwrap();
5599        std::fs::write(cloud_dir.join("mirrors/noisetable.toml"), mirror_toml).unwrap();
5600
5601        // Legacy services/ dir (backward compat)
5602        let svc_toml = "name = \"asset-registry\"\nimage = \"ghcr.io/noisetable/asset-registry\"\nversion = \"v1.0.0\"\nmesh_only = false\n";
5603        std::fs::create_dir_all(cloud_dir.join("services")).unwrap();
5604        std::fs::write(cloud_dir.join("services/asset-registry.toml"), svc_toml).unwrap();
5605
5606        let cfg = CloudConfig::load(root).unwrap();
5607
5608        assert_eq!(cfg.machines.len(), 1);
5609        assert_eq!(cfg.legacy_mirrors.len(), 1);
5610        assert_eq!(cfg.legacy_services.len(), 1);
5611        assert_eq!(cfg.workloads.len(), 0); // no workloads/ dir yet
5612        assert!(cfg.services.is_empty(), "no R215+ services/ tree");
5613        assert!(cfg.providers.is_empty(), "no R215+ providers/ tree");
5614
5615        let m = cfg.machine("noisetable-pdx-1").unwrap();
5616        assert_eq!(m.location(), "pdx");
5617        assert_eq!(m.bucket.as_ref().unwrap().name, "noisetable-assets-pdx-1");
5618
5619        let mir = cfg.legacy_mirror("noisetable").unwrap();
5620        assert_eq!(mir.regions, vec!["pdx", "iad", "fsn"]);
5621        assert_eq!(mir.workloads, vec!["asset-registry"]);
5622    }
5623
5624    #[test]
5625    fn mirror_folder_layout_loads() {
5626        // Folder layout: mirrors/<id>/mirror.toml — new preferred form.
5627        let tmp = tempfile::TempDir::new().unwrap();
5628        let root = tmp.path();
5629        let cloud_dir = make_legacy_cloud_dir(root);
5630        let mirror_dir = cloud_dir.join("mirrors").join("yah-com");
5631        std::fs::create_dir_all(&mirror_dir).unwrap();
5632        std::fs::write(
5633            mirror_dir.join("mirror.toml"),
5634            "camp = \"yah\"\nregions = [\"pdx\"]\nworkloads = [\"yah-web\"]\n",
5635        )
5636        .unwrap();
5637
5638        let cfg = CloudConfig::load(root).unwrap();
5639        assert_eq!(cfg.legacy_mirrors.len(), 1);
5640        let mir = cfg.legacy_mirror("yah").unwrap();
5641        assert_eq!(mir.camp, "yah");
5642        assert_eq!(mir.workloads, vec!["yah-web"]);
5643    }
5644
5645    #[test]
5646    fn mirror_folder_and_flat_coexist() {
5647        // Both layouts may coexist in the same mirrors/ directory.
5648        let tmp = tempfile::TempDir::new().unwrap();
5649        let root = tmp.path();
5650        let cloud_dir = make_legacy_cloud_dir(root);
5651        let mirrors_root = cloud_dir.join("mirrors");
5652        std::fs::create_dir_all(&mirrors_root).unwrap();
5653
5654        // Flat legacy mirror
5655        std::fs::write(
5656            mirrors_root.join("noisetable.toml"),
5657            "camp = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = []\n",
5658        )
5659        .unwrap();
5660
5661        // Folder-form mirror
5662        let yah_com_dir = mirrors_root.join("yah-com");
5663        std::fs::create_dir_all(&yah_com_dir).unwrap();
5664        std::fs::write(
5665            yah_com_dir.join("mirror.toml"),
5666            "camp = \"yah\"\nregions = [\"pdx\"]\nworkloads = []\n",
5667        )
5668        .unwrap();
5669
5670        let cfg = CloudConfig::load(root).unwrap();
5671        assert_eq!(cfg.legacy_mirrors.len(), 2);
5672        assert!(cfg.legacy_mirror("noisetable").is_some());
5673        assert!(cfg.legacy_mirror("yah").is_some());
5674    }
5675
5676    #[test]
5677    fn mirror_malformed_fails_with_field_path() {
5678        // A malformed mirror.toml should fail at load with a clear error
5679        // that includes the file path.
5680        let tmp = tempfile::TempDir::new().unwrap();
5681        let root = tmp.path();
5682        let cloud_dir = make_legacy_cloud_dir(root);
5683        let mirror_dir = cloud_dir.join("mirrors").join("bad");
5684        std::fs::create_dir_all(&mirror_dir).unwrap();
5685        // Missing required `camp` field
5686        std::fs::write(
5687            mirror_dir.join("mirror.toml"),
5688            "regions = [\"pdx\"]\nworkloads = []\n",
5689        )
5690        .unwrap();
5691
5692        let err = CloudConfig::load(root).unwrap_err();
5693        let msg = err.to_string();
5694        assert!(
5695            msg.contains("mirror.toml"),
5696            "error should reference the file path, got: {msg}"
5697        );
5698    }
5699
5700    #[test]
5701    fn workload_config_load_and_validate() {
5702        use workload_spec::{
5703            ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
5704            RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
5705        };
5706
5707        let tmp = tempfile::TempDir::new().unwrap();
5708        let root = tmp.path();
5709        let cloud_dir = make_legacy_cloud_dir(root);
5710        std::fs::create_dir_all(cloud_dir.join("workloads")).unwrap();
5711
5712        let spec = WorkloadSpec {
5713            schema_version: SchemaVersion::V1,
5714            name: "asset-registry".into(),
5715            image: ImageRef {
5716                registry: "ghcr.io".into(),
5717                repository: "noisetable/asset-registry".into(),
5718                tag: "v1.0.0".into(),
5719                digest: workload_spec::testing::test_digest(),
5720            },
5721            tier: TierTag("tenant".into()),
5722            replicas: 1,
5723            command: None,
5724            entrypoint: None,
5725            workdir: None,
5726            user: None,
5727            env: vec![],
5728            secrets: vec![],
5729            volumes: vec![],
5730            resources: ResourceLimits {
5731                memory_mb: 256,
5732                cpu_millis: 512,
5733                ephemeral_storage_mb: 512,
5734            },
5735            depends_on: vec![],
5736            healthcheck: None,
5737            restart_policy: RestartPolicy::Always,
5738            archetype: None,
5739            stop_policy: StopPolicy {
5740                signal: 15,
5741                grace_period: workload_spec::Millis::from_secs(10),
5742            },
5743            expose: ExposeSpec {
5744                mesh: MeshExpose {
5745                    identity: MeshIdent("asset-registry.pdx".into()),
5746                    ports: MeshExpose::anonymous_ports([8080]),
5747                    allow_from: vec![],
5748                },
5749                public: None,
5750                operator: None,
5751            },
5752            tenant: TenantId::singleton(),
5753            namespace: NamespaceId::singleton(),
5754            labels: Default::default(),
5755            annotations: Default::default(),
5756        };
5757
5758        let toml_str = toml::to_string_pretty(&spec).unwrap();
5759        std::fs::write(cloud_dir.join("workloads/asset-registry.toml"), &toml_str).unwrap();
5760
5761        let cfg = CloudConfig::load(root).unwrap();
5762        assert_eq!(cfg.workloads.len(), 1);
5763        assert_eq!(cfg.workloads[0].spec.name, "asset-registry");
5764        assert_eq!(cfg.workload("asset-registry").unwrap().spec.replicas, 1);
5765    }
5766
5767    /// Minimal valid spec for the R215+ loader tests below. Kept as a helper so
5768    /// the two tests differ only in *where* the file lands, which is the whole
5769    /// thing under test.
5770    #[cfg(test)]
5771    fn minimal_spec(name: &str, replicas: u32) -> workload_spec::WorkloadSpec {
5772        use workload_spec::{
5773            ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
5774            RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
5775        };
5776        WorkloadSpec {
5777            schema_version: SchemaVersion::V1,
5778            name: name.into(),
5779            image: ImageRef {
5780                registry: "cr.yah.dev".into(),
5781                repository: name.into(),
5782                tag: "v1".into(),
5783                digest: workload_spec::testing::test_digest(),
5784            },
5785            tier: TierTag("infra".into()),
5786            replicas,
5787            command: None,
5788            entrypoint: None,
5789            workdir: None,
5790            user: None,
5791            env: vec![],
5792            secrets: vec![],
5793            volumes: vec![],
5794            resources: ResourceLimits {
5795                memory_mb: 256,
5796                cpu_millis: 250,
5797                ephemeral_storage_mb: 128,
5798            },
5799            depends_on: vec![],
5800            healthcheck: None,
5801            restart_policy: RestartPolicy::Always,
5802            archetype: None,
5803            stop_policy: StopPolicy {
5804                signal: 15,
5805                grace_period: workload_spec::Millis::from_secs(10),
5806            },
5807            expose: ExposeSpec {
5808                mesh: MeshExpose {
5809                    identity: MeshIdent(name.into()),
5810                    ports: MeshExpose::anonymous_ports([4325]),
5811                    allow_from: vec![],
5812                },
5813                public: None,
5814                operator: None,
5815            },
5816            tenant: TenantId::singleton(),
5817            namespace: NamespaceId::singleton(),
5818            labels: Default::default(),
5819            annotations: Default::default(),
5820        }
5821    }
5822
5823    /// R568-T7. Workloads must load from the R215+ tree.
5824    ///
5825    /// Before the fix this function tested, `CloudConfig::load` read workloads
5826    /// ONLY from the pre-R215 `.yah/cloud/workloads/` — which R222-B1 emptied —
5827    /// so in any modern camp `cfg.workload(name)` returned `None` for every
5828    /// name and the entire `yah cloud workload …` surface was unreachable. The
5829    /// CLI's own error text has said `.yah/infra/workloads/` throughout, so the
5830    /// bug read as "you must have typoed the filename".
5831    ///
5832    /// Note the fixture writes NO legacy `.yah/cloud/` dir at all: that is the
5833    /// shape of a real post-R215 camp, and it is exactly the shape the old code
5834    /// could not serve.
5835    #[test]
5836    fn workloads_load_from_the_infra_tree() {
5837        let tmp = tempfile::TempDir::new().unwrap();
5838        let root = tmp.path();
5839        let dir = crate::paths::workloads_dir(root);
5840        std::fs::create_dir_all(&dir).unwrap();
5841        std::fs::write(
5842            dir.join("yah-cloud-admin.toml"),
5843            toml::to_string_pretty(&minimal_spec("yah-cloud-admin", 1)).unwrap(),
5844        )
5845        .unwrap();
5846
5847        let cfg = CloudConfig::load(root).unwrap();
5848        assert_eq!(cfg.workloads.len(), 1);
5849        assert_eq!(
5850            cfg.workload("yah-cloud-admin").unwrap().spec.replicas,
5851            1,
5852            "a workload declared under .yah/infra/workloads/ must be resolvable by name"
5853        );
5854    }
5855
5856    /// A camp mid-migration can have both trees. R215+ wins on a name
5857    /// collision — same precedence the machine loader applies — so moving a
5858    /// declaration into `.yah/infra/workloads/` takes effect immediately
5859    /// instead of being silently shadowed by the copy left behind.
5860    #[test]
5861    fn infra_workload_shadows_the_legacy_copy_of_the_same_name() {
5862        let tmp = tempfile::TempDir::new().unwrap();
5863        let root = tmp.path();
5864
5865        let legacy = make_legacy_cloud_dir(root);
5866        std::fs::create_dir_all(legacy.join("workloads")).unwrap();
5867        std::fs::write(
5868            legacy.join("workloads/shared.toml"),
5869            toml::to_string_pretty(&minimal_spec("shared", 9)).unwrap(),
5870        )
5871        .unwrap();
5872        // Legacy-only name, to prove the old tree is still read rather than
5873        // replaced wholesale.
5874        std::fs::write(
5875            legacy.join("workloads/legacy-only.toml"),
5876            toml::to_string_pretty(&minimal_spec("legacy-only", 3)).unwrap(),
5877        )
5878        .unwrap();
5879
5880        let infra = crate::paths::workloads_dir(root);
5881        std::fs::create_dir_all(&infra).unwrap();
5882        std::fs::write(
5883            infra.join("shared.toml"),
5884            toml::to_string_pretty(&minimal_spec("shared", 1)).unwrap(),
5885        )
5886        .unwrap();
5887
5888        let cfg = CloudConfig::load(root).unwrap();
5889        assert_eq!(cfg.workloads.len(), 2, "one `shared`, plus `legacy-only`");
5890        assert_eq!(
5891            cfg.workload("shared").unwrap().spec.replicas,
5892            1,
5893            "the .yah/infra/ copy must win over the legacy one"
5894        );
5895        assert_eq!(cfg.workload("legacy-only").unwrap().spec.replicas, 3);
5896    }
5897
5898    #[test]
5899    fn workload_loader_rejects_bad_spec() {
5900        use workload_spec::{
5901            ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
5902            RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
5903        };
5904
5905        let tmp = tempfile::TempDir::new().unwrap();
5906        let root = tmp.path();
5907        let cloud_dir = make_legacy_cloud_dir(root);
5908        std::fs::create_dir_all(cloud_dir.join("workloads")).unwrap();
5909
5910        // Construct a spec that round-trips through TOML but fails shape
5911        // validation: replicas = 200 is above the max of 100.
5912        let mut spec = WorkloadSpec {
5913            schema_version: SchemaVersion::V1,
5914            name: "asset-registry".into(),
5915            image: ImageRef {
5916                registry: "ghcr.io".into(),
5917                repository: "test/app".into(),
5918                tag: "v1".into(),
5919                digest: workload_spec::testing::test_digest(),
5920            },
5921            tier: TierTag("tenant".into()),
5922            replicas: 200, // ← invalid: exceeds max 100
5923            command: None,
5924            entrypoint: None,
5925            workdir: None,
5926            user: None,
5927            env: vec![],
5928            secrets: vec![],
5929            volumes: vec![],
5930            resources: ResourceLimits {
5931                memory_mb: 256,
5932                cpu_millis: 512,
5933                ephemeral_storage_mb: 512,
5934            },
5935            depends_on: vec![],
5936            healthcheck: None,
5937            restart_policy: RestartPolicy::Always,
5938            archetype: None,
5939            stop_policy: StopPolicy {
5940                signal: 15,
5941                grace_period: workload_spec::Millis::from_secs(10),
5942            },
5943            expose: ExposeSpec {
5944                mesh: MeshExpose {
5945                    identity: MeshIdent("asset-registry.pdx".into()),
5946                    ports: MeshExpose::anonymous_ports([8080]),
5947                    allow_from: vec![],
5948                },
5949                public: None,
5950                operator: None,
5951            },
5952            tenant: TenantId::singleton(),
5953            namespace: NamespaceId::singleton(),
5954            labels: Default::default(),
5955            annotations: Default::default(),
5956        };
5957
5958        let toml_str = toml::to_string_pretty(&spec).unwrap();
5959        std::fs::write(cloud_dir.join("workloads/bad.toml"), &toml_str).unwrap();
5960
5961        let result = CloudConfig::load(root);
5962        assert!(
5963            result.is_err(),
5964            "loading a WorkloadSpec with replicas=200 should return Err"
5965        );
5966        let msg = result.unwrap_err().to_string();
5967        assert!(
5968            msg.contains("shape validation")
5969                || msg.contains("Replicas")
5970                || msg.contains("replicas"),
5971            "error should mention shape validation or replicas field, got: {msg}"
5972        );
5973
5974        // The `spec` binding is only used for the write — suppress warning.
5975        let _ = &mut spec;
5976    }
5977
5978    #[test]
5979    fn workload_config_save_round_trip() {
5980        use workload_spec::{
5981            ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
5982            RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
5983        };
5984
5985        let tmp = tempfile::TempDir::new().unwrap();
5986        let root = tmp.path();
5987
5988        let spec = WorkloadSpec {
5989            schema_version: SchemaVersion::V1,
5990            name: "signing-service".into(),
5991            image: ImageRef {
5992                registry: "ghcr.io".into(),
5993                repository: "noisetable/signing".into(),
5994                tag: "v2.0.0".into(),
5995                digest: workload_spec::testing::test_digest(),
5996            },
5997            tier: TierTag("private".into()),
5998            replicas: 2,
5999            command: None,
6000            entrypoint: None,
6001            workdir: None,
6002            user: None,
6003            env: vec![],
6004            secrets: vec![],
6005            volumes: vec![],
6006            resources: ResourceLimits {
6007                memory_mb: 128,
6008                cpu_millis: 256,
6009                ephemeral_storage_mb: 256,
6010            },
6011            depends_on: vec![],
6012            healthcheck: None,
6013            restart_policy: RestartPolicy::Always,
6014            archetype: None,
6015            stop_policy: StopPolicy {
6016                signal: 15,
6017                grace_period: workload_spec::Millis::from_secs(5),
6018            },
6019            expose: ExposeSpec {
6020                mesh: MeshExpose {
6021                    identity: MeshIdent("signing.pdx".into()),
6022                    ports: MeshExpose::anonymous_ports([9090]),
6023                    allow_from: vec![],
6024                },
6025                public: None,
6026                operator: None,
6027            },
6028            tenant: TenantId::singleton(),
6029            namespace: NamespaceId::singleton(),
6030            labels: Default::default(),
6031            annotations: Default::default(),
6032        };
6033
6034        let wc = WorkloadConfig { spec };
6035        let cloud_dir = make_legacy_cloud_dir(root);
6036        wc.save(&cloud_dir).unwrap();
6037
6038        let loaded = CloudConfig::load(root).unwrap();
6039        assert_eq!(loaded.workloads.len(), 1);
6040        assert_eq!(loaded.workloads[0].spec.name, "signing-service");
6041        assert_eq!(loaded.workloads[0].spec.replicas, 2);
6042    }
6043
6044    #[test]
6045    fn machine_save_write_back_fingerprint() {
6046        let tmp = tempfile::TempDir::new().unwrap();
6047        let root = tmp.path();
6048
6049        let mut machine = MachineConfig {
6050            name: "test-pdx-1".into(),
6051            provider: "hetzner".into(),
6052            location: Some("pdx".into()),
6053            server_type: Some("cpx22".into()),
6054            hosts_mirrors: vec![],
6055            mesh_tags: vec![],
6056            region: None,
6057            zone: None,
6058            arch: None,
6059            bucket: None,
6060            vendor: None,
6061            nickname: None,
6062            legacy_hostkey_fingerprint: None,
6063            registration: Default::default(),
6064            ssh_keys: vec![],
6065            cloudflared: None,
6066            hosts_operator_bridge: false,
6067            connect: None,
6068            allocatable: None,
6069            taints: vec![],
6070            sovereign_group: None,
6071            sovereign_role: None,
6072        };
6073        machine.save(root).unwrap();
6074
6075        // Simulate A4: write back the hostkey fingerprint after provision.
6076        // R707-T1: registration is the write target; the accessor is the read.
6077        machine.registration.hostkey_fingerprint = Some("SHA256:abc123".into());
6078        machine.save(root).unwrap();
6079
6080        let reloaded: Vec<MachineConfig> = load_dir(root.join("machines")).unwrap();
6081        assert_eq!(reloaded.len(), 1);
6082        assert_eq!(reloaded[0].hostkey_fingerprint(), Some("SHA256:abc123"));
6083    }
6084
6085    // ─── New-shape (R222 B2) parse tests ────────────────────────────────────
6086    //
6087    // These mirror the Phase-A manifests committed under `.yah/services/` and
6088    // `.yah/infra/providers/`. Keeping the test strings inline (rather than
6089    // reading the on-disk files) so the loader stays runnable in any workdir
6090    // and so accidental edits to the on-disk files don't silently change
6091    // schema expectations.
6092
6093    #[test]
6094    fn provider_cloudflare_round_trips() {
6095        let src = r#"
6096schema_version = 1
6097id = "cloudflare"
6098kind = "cloudflare"
6099credentials = "keystore://cloudflare/yah"
6100default_zone = "yah.dev"
6101"#;
6102        let cfg: ProviderConfig = toml::from_str(src).unwrap();
6103        assert_eq!(cfg.id, "cloudflare");
6104        assert_eq!(cfg.kind, Provider::Cloudflare);
6105        assert_eq!(
6106            cfg.credentials.as_deref(),
6107            Some("keystore://cloudflare/yah")
6108        );
6109        assert_eq!(
6110            cfg.fields.get("default_zone").and_then(|v| v.as_str()),
6111            Some("yah.dev"),
6112        );
6113        let back = toml::to_string(&cfg).unwrap();
6114        let again: ProviderConfig = toml::from_str(&back).unwrap();
6115        assert_eq!(again.id, cfg.id);
6116        assert_eq!(again.kind, cfg.kind);
6117    }
6118
6119    #[test]
6120    fn provider_hetzner_round_trips() {
6121        let src = r#"
6122schema_version = 1
6123id = "hetzner"
6124kind = "hetzner"
6125credentials = "keystore://hetzner/yah"
6126default_location = "pdx"
6127default_server_type = "cpx11"
6128ssh_keys = []
6129"#;
6130        let cfg: ProviderConfig = toml::from_str(src).unwrap();
6131        assert_eq!(cfg.kind, Provider::Hetzner);
6132        assert_eq!(
6133            cfg.fields.get("default_location").and_then(|v| v.as_str()),
6134            Some("pdx"),
6135        );
6136        assert!(
6137            cfg.fields
6138                .get("ssh_keys")
6139                .map(|v| v.as_array().unwrap().is_empty())
6140                .unwrap_or(false),
6141            "ssh_keys must round-trip as empty array, got {:?}",
6142            cfg.fields.get("ssh_keys"),
6143        );
6144    }
6145
6146    #[test]
6147    fn provider_orbstack_local_container_round_trips() {
6148        let src = r#"
6149schema_version = 1
6150id = "orbstack"
6151kind = "local-container"
6152runtime = "auto"
6153
6154[discovery]
6155orbstack = "~/.orbstack/run/docker.sock"
6156colima   = "~/.colima/default/docker.sock"
6157docker   = "/var/run/docker.sock"
6158"#;
6159        let cfg: ProviderConfig = toml::from_str(src).unwrap();
6160        assert_eq!(cfg.kind, Provider::LocalContainer);
6161        assert_eq!(
6162            cfg.fields.get("runtime").and_then(|v| v.as_str()),
6163            Some("auto"),
6164        );
6165        let discovery = cfg
6166            .fields
6167            .get("discovery")
6168            .and_then(|v| v.as_table())
6169            .expect("discovery table");
6170        assert!(discovery.contains_key("orbstack"));
6171        assert!(discovery.contains_key("colima"));
6172        assert!(discovery.contains_key("docker"));
6173    }
6174
6175    #[test]
6176    fn provider_unknown_kind_fails() {
6177        let src = r#"
6178schema_version = 1
6179id = "made-up"
6180kind = "fly-io"
6181"#;
6182        let err = toml::from_str::<ProviderConfig>(src).unwrap_err();
6183        let msg = err.to_string();
6184        assert!(
6185            msg.contains("kind") || msg.contains("variant"),
6186            "unknown provider kind should surface as a serde error, got: {msg}"
6187        );
6188    }
6189
6190    #[test]
6191    fn service_dev_yah_round_trips() {
6192        let src = r#"
6193schema_version = 1
6194name = "dev-yah"
6195domain = "yah.dev"
6196
6197[[components]]
6198id = "site"
6199kind = "mesofact-static"
6200path = "app/yah/web"
6201role = "static"
6202"#;
6203        let cfg: ServiceConfig = toml::from_str(src).unwrap();
6204        assert_eq!(cfg.name, "dev-yah");
6205        assert_eq!(cfg.domain, "yah.dev");
6206        assert_eq!(cfg.components.len(), 1);
6207        let c = &cfg.components[0];
6208        assert_eq!(c.id, "site");
6209        assert_eq!(c.kind, "mesofact-static");
6210        assert_eq!(c.path, "app/yah/web");
6211        assert_eq!(c.role, "static");
6212        assert!(c.publishes.is_none());
6213
6214        let back = toml::to_string(&cfg).unwrap();
6215        let again: ServiceConfig = toml::from_str(&back).unwrap();
6216        assert_eq!(again.name, cfg.name);
6217        assert_eq!(again.components[0].kind, c.kind);
6218    }
6219
6220    #[test]
6221    fn mirror_prod_cloudflare_reference_parses() {
6222        let src = r#"
6223schema_version = 1
6224shape = "single-machine"
6225
6226[providers.static]
6227use = "cloudflare"
6228bucket = "yah-dev"
6229zone = "yah.dev"
6230dns = { record = "@", type = "CNAME" }
6231"#;
6232        let cfg: MirrorConfig = toml::from_str(src).unwrap();
6233        assert_eq!(cfg.shape, MirrorShape::SingleMachine);
6234        let slot = cfg.providers.get("static").expect("static slot");
6235        assert_eq!(slot.provider_id(), Some("cloudflare"));
6236        assert!(slot.inline_kind().is_none());
6237        if let MirrorProviderSlot::Reference { fields, .. } = slot {
6238            assert_eq!(
6239                fields.get("bucket").and_then(|v| v.as_str()),
6240                Some("yah-dev")
6241            );
6242            assert_eq!(fields.get("zone").and_then(|v| v.as_str()), Some("yah.dev"));
6243            let dns = fields
6244                .get("dns")
6245                .and_then(|v| v.as_table())
6246                .expect("dns table");
6247            assert_eq!(dns.get("record").and_then(|v| v.as_str()), Some("@"));
6248            assert_eq!(dns.get("type").and_then(|v| v.as_str()), Some("CNAME"));
6249        } else {
6250            panic!("expected Reference slot");
6251        }
6252    }
6253
6254    #[test]
6255    fn mirror_local_inline_static_and_orbstack_compute_parse() {
6256        let src = r#"
6257schema_version = 1
6258shape = "local"
6259
6260[providers.static]
6261kind = "local-static"
6262port = 4321
6263artifact_dir = ".yah/infra/state/local/static"
6264
6265[providers.compute]
6266use = "orbstack"
6267"#;
6268        let cfg: MirrorConfig = toml::from_str(src).unwrap();
6269        assert_eq!(cfg.shape, MirrorShape::Local);
6270
6271        let static_slot = cfg.providers.get("static").expect("static slot");
6272        assert_eq!(static_slot.inline_kind(), Some(Provider::LocalStatic));
6273        assert!(static_slot.provider_id().is_none());
6274        if let MirrorProviderSlot::Inline { fields, .. } = static_slot {
6275            assert_eq!(fields.get("port").and_then(|v| v.as_integer()), Some(4321));
6276            assert_eq!(
6277                fields.get("artifact_dir").and_then(|v| v.as_str()),
6278                Some(".yah/infra/state/local/static"),
6279            );
6280        } else {
6281            panic!("expected Inline slot for static");
6282        }
6283
6284        let compute_slot = cfg.providers.get("compute").expect("compute slot");
6285        assert_eq!(compute_slot.provider_id(), Some("orbstack"));
6286    }
6287
6288    #[test]
6289    fn mirror_pond_miniflare_minio_parse() {
6290        // pond-tier mirror: miniflare-container + minio, both inline.
6291        // T1 just needs these inline kinds to parse — the reconciler dispatch
6292        // arrives in R256-T3.
6293        let src = r#"
6294schema_version = 1
6295shape = "local"
6296
6297[providers.static]
6298kind = "miniflare-container"
6299port = 4322
6300bucket = "yah-dev"
6301
6302[providers.object_store]
6303kind = "minio-container"
6304api_port = 9000
6305console_port = 9001
6306bucket = "yah-dev"
6307"#;
6308        let cfg: MirrorConfig = toml::from_str(src).unwrap();
6309        assert_eq!(cfg.shape, MirrorShape::Local);
6310
6311        let static_slot = cfg.providers.get("static").expect("static slot");
6312        assert_eq!(
6313            static_slot.inline_kind(),
6314            Some(Provider::MiniflareContainer)
6315        );
6316        if let MirrorProviderSlot::Inline { fields, .. } = static_slot {
6317            assert_eq!(fields.get("port").and_then(|v| v.as_integer()), Some(4322));
6318            assert_eq!(
6319                fields.get("bucket").and_then(|v| v.as_str()),
6320                Some("yah-dev")
6321            );
6322        } else {
6323            panic!("expected Inline slot for miniflare-container static");
6324        }
6325
6326        let object_store_slot = cfg
6327            .providers
6328            .get("object_store")
6329            .expect("object_store slot");
6330        assert_eq!(
6331            object_store_slot.inline_kind(),
6332            Some(Provider::MinioContainer)
6333        );
6334        if let MirrorProviderSlot::Inline { fields, .. } = object_store_slot {
6335            assert_eq!(
6336                fields.get("api_port").and_then(|v| v.as_integer()),
6337                Some(9000)
6338            );
6339            assert_eq!(
6340                fields.get("console_port").and_then(|v| v.as_integer()),
6341                Some(9001)
6342            );
6343            assert_eq!(
6344                fields.get("bucket").and_then(|v| v.as_str()),
6345                Some("yah-dev")
6346            );
6347        } else {
6348            panic!("expected Inline slot for minio-container object_store");
6349        }
6350    }
6351
6352    #[test]
6353    fn provider_miniflare_container_kind_round_trips() {
6354        // Inline-only kind; never declared as a standalone provider file but
6355        // the enum round-trip is still exercised through ProviderConfig because
6356        // schemars/serde share the variant table.
6357        let cfg = MirrorProviderSlot::Inline {
6358            kind: Provider::MiniflareContainer,
6359            fields: BTreeMap::new(),
6360        };
6361        let s = toml::to_string(&cfg).unwrap();
6362        assert!(
6363            s.contains("kind = \"miniflare-container\""),
6364            "kebab-case wire form expected, got: {s}"
6365        );
6366        let back: MirrorProviderSlot = toml::from_str(&s).unwrap();
6367        assert_eq!(back.inline_kind(), Some(Provider::MiniflareContainer));
6368    }
6369
6370    #[test]
6371    fn provider_minio_container_kind_round_trips() {
6372        let cfg = MirrorProviderSlot::Inline {
6373            kind: Provider::MinioContainer,
6374            fields: BTreeMap::new(),
6375        };
6376        let s = toml::to_string(&cfg).unwrap();
6377        assert!(
6378            s.contains("kind = \"minio-container\""),
6379            "kebab-case wire form expected, got: {s}"
6380        );
6381        let back: MirrorProviderSlot = toml::from_str(&s).unwrap();
6382        assert_eq!(back.inline_kind(), Some(Provider::MinioContainer));
6383    }
6384
6385    #[test]
6386    fn mirror_compute_slot_with_machine_reference_parses() {
6387        // The on-disk prod.toml has a commented-out compute slot; this test
6388        // covers the form Phase B will need once yubaba is provisioned.
6389        let src = r#"
6390schema_version = 1
6391shape = "single-machine"
6392
6393[providers.compute]
6394use = "hetzner"
6395machine = "yah-cloud-1"
6396"#;
6397        let cfg: MirrorConfig = toml::from_str(src).unwrap();
6398        let slot = cfg.providers.get("compute").expect("compute slot");
6399        assert_eq!(slot.provider_id(), Some("hetzner"));
6400        if let MirrorProviderSlot::Reference { fields, .. } = slot {
6401            assert_eq!(
6402                fields.get("machine").and_then(|v| v.as_str()),
6403                Some("yah-cloud-1"),
6404            );
6405        }
6406    }
6407
6408    #[test]
6409    fn machine_yah_cloud_1_round_trips_with_existing_shape() {
6410        // The current machine TOML predates B2 — MachineConfig hasn't been
6411        // reshaped yet. This locks the expected shape so we notice if B3
6412        // accidentally regresses it.
6413        let src = r#"
6414name = "yah-cloud-1"
6415provider = "hetzner"
6416location = "pdx"
6417server_type = "cpx11"
6418hosts_mirrors = []
6419mesh_tags = ["tag:tier-scratch", "tag:primary-yah"]
6420ssh_keys = [111513970, 111525493]
6421"#;
6422        let cfg: MachineConfig = toml::from_str(src).unwrap();
6423        assert_eq!(cfg.name, "yah-cloud-1");
6424        assert_eq!(cfg.provider, "hetzner");
6425        assert_eq!(cfg.ssh_keys.len(), 2);
6426    }
6427
6428    #[test]
6429    fn static_node_omits_location_server_type_and_carries_connect() {
6430        // BYO Phase-0: a `static` node we brought up over SSH has no provider
6431        // DC code or SKU; it declares reach in `[connect]` instead. Must load.
6432        let src = r#"
6433name = "us-south-001"
6434provider = "static"
6435region = "us-south"
6436mesh_tags = ["tag:cloud-runner", "tag:voter-candidate"]
6437
6438[connect]
6439address = "45.32.194.254"
6440ssh = "root@45.32.194.254"
6441yubaba = "http://127.0.0.1:7443"
6442arch = "x86_64"
6443"#;
6444        let cfg: MachineConfig = toml::from_str(src).unwrap();
6445        assert_eq!(cfg.provider, "static");
6446        assert!(cfg.location.is_none());
6447        assert!(cfg.server_type.is_none());
6448        assert_eq!(cfg.location(), ""); // accessor defaults empty
6449        let c = cfg.connect.as_ref().expect("connect block");
6450        assert_eq!(c.ssh, "root@45.32.194.254");
6451        // Loopback is a *declared* reach placeholder, so it stays in [connect]
6452        // verbatim and composes straight through (R707-T1).
6453        assert_eq!(c.yubaba.as_deref(), Some("http://127.0.0.1:7443"));
6454        assert_eq!(cfg.yubaba_url().as_deref(), Some("http://127.0.0.1:7443"));
6455        assert_eq!(cfg.mesh_ipv4(), None);
6456        // Static providers have no driver, so validate() is a no-op pass.
6457        assert!(!provider_has_machine_driver(&cfg.provider));
6458        cfg.validate().unwrap();
6459    }
6460
6461    // ─── R707-T1: declaration / registration split ──────────────────────────
6462
6463    /// The pre-split shape — top-level `hostkey_fingerprint`, mesh IP baked
6464    /// into `[connect].yubaba` — must keep parsing, and must read back through
6465    /// the accessors identically. Every machine TOML in the fleet was written
6466    /// this way, and other camps' inventories still are.
6467    #[test]
6468    fn legacy_shape_still_parses_and_reads_through_accessors() {
6469        let src = r#"
6470name = "us-west-001"
6471provider = "static"
6472region = "us-west"
6473arch = "x86_64"
6474mesh_tags = ["tag:cloud-runner"]
6475hostkey_fingerprint = "SHA256:dmpq"
6476
6477[connect]
6478address = "15.204.89.240"
6479ssh = "debian@15.204.89.240"
6480yubaba = "http://100.64.0.1:7443"
6481"#;
6482        let cfg: MachineConfig = toml::from_str(src).unwrap();
6483        assert_eq!(cfg.hostkey_fingerprint(), Some("SHA256:dmpq"));
6484        assert_eq!(cfg.mesh_ipv4(), Some("100.64.0.1"));
6485        assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.1:7443"));
6486    }
6487
6488    /// The post-split shape reads identically to the legacy one above — same
6489    /// three accessor answers from a file that separates the two halves. This
6490    /// is the "unchanged in meaning" guarantee the fleet migration rests on.
6491    #[test]
6492    fn split_shape_is_equivalent_to_legacy_shape() {
6493        let legacy = r#"
6494name = "m"
6495provider = "static"
6496mesh_tags = []
6497hostkey_fingerprint = "SHA256:dmpq"
6498
6499[connect]
6500address = "15.204.89.240"
6501ssh = "debian@15.204.89.240"
6502yubaba = "http://100.64.0.1:7443"
6503"#;
6504        let split = r#"
6505name = "m"
6506provider = "static"
6507mesh_tags = []
6508
6509[connect]
6510address = "15.204.89.240"
6511ssh = "debian@15.204.89.240"
6512
6513[registration]
6514hostkey_fingerprint = "SHA256:dmpq"
6515mesh_ipv4 = "100.64.0.1"
6516"#;
6517        let old: MachineConfig = toml::from_str(legacy).unwrap();
6518        let new: MachineConfig = toml::from_str(split).unwrap();
6519        assert_eq!(old.hostkey_fingerprint(), new.hostkey_fingerprint());
6520        assert_eq!(old.mesh_ipv4(), new.mesh_ipv4());
6521        assert_eq!(old.yubaba_url(), new.yubaba_url());
6522    }
6523
6524    /// A non-default `[connect].yubaba_port` is declared reach and composes
6525    /// with the observed mesh address rather than being pinned into a URL.
6526    #[test]
6527    fn declared_port_composes_with_observed_mesh_address() {
6528        let src = r#"
6529name = "m"
6530provider = "static"
6531mesh_tags = []
6532
6533[connect]
6534address = "10.0.0.1"
6535ssh = "yah@10.0.0.1"
6536yubaba_port = 9443
6537
6538[registration]
6539mesh_ipv4 = "100.64.0.9"
6540"#;
6541        let cfg: MachineConfig = toml::from_str(src).unwrap();
6542        assert_eq!(cfg.connect.as_ref().unwrap().yubaba_port(), 9443);
6543        assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.9:9443"));
6544    }
6545
6546    /// R605-T10 inverts R707-T6 for the private-literal case, and this is the
6547    /// node it was inverted for: us-west-014's shape, mesh-joined AND declaring
6548    /// a LAN `[connect].yubaba`. R707-T6 made the literal win outright so
6549    /// `rollout::yubaba::membership_to_nodes` could match the dev group's
6550    /// LAN-addressed raft membership — which fused identity into reach and made
6551    /// every automated dial go to an address only bldg-2506 can route.
6552    /// `lan_endpoint()` now serves that match, so the mesh address wins the
6553    /// dial and the literal is inert.
6554    #[test]
6555    fn a_private_literal_loses_to_the_registered_mesh_address() {
6556        let src = r#"
6557name = "us-west-014"
6558provider = "static"
6559mesh_tags = []
6560
6561[connect]
6562address = "192.168.10.14"
6563ssh = "yah@192.168.10.14"
6564yubaba = "http://192.168.10.14:7443"
6565
6566[registration]
6567mesh_ipv4 = "100.64.0.6"
6568"#;
6569        let cfg: MachineConfig = toml::from_str(src).unwrap();
6570        assert_eq!(cfg.mesh_ipv4(), Some("100.64.0.6"), "still mesh-joined");
6571        assert_eq!(
6572            cfg.yubaba_url().as_deref(),
6573            Some("http://100.64.0.6:7443"),
6574            "automation dials the mesh, never the LAN literal"
6575        );
6576        assert_eq!(
6577            cfg.lan_endpoint().as_deref(),
6578            Some("192.168.10.14:7443"),
6579            "the LAN address is still recorded — as identity, not as reach"
6580        );
6581    }
6582
6583    /// The refusal R605-T10 asks for: a node whose ONLY declared reach is a LAN
6584    /// literal is unresolvable, and says so by name rather than returning a URL
6585    /// that will time out. us-west-011's shape before this ticket.
6586    #[test]
6587    fn a_lan_only_node_refuses_with_a_named_reason() {
6588        let src = r#"
6589name = "us-west-011"
6590provider = "static"
6591mesh_tags = []
6592
6593[connect]
6594address = "192.168.10.11"
6595ssh = "yah@192.168.10.11"
6596yubaba = "http://192.168.10.11:7443"
6597"#;
6598        let cfg: MachineConfig = toml::from_str(src).unwrap();
6599        assert_eq!(cfg.yubaba_url(), None);
6600        let err = cfg.reach().unwrap_err();
6601        assert!(err.contains("us-west-011"), "{err}");
6602        assert!(err.contains("192.168.10.11"), "{err}");
6603        assert!(err.contains("mesh_ipv4"), "{err}");
6604    }
6605
6606    /// The loopback placeholder is a genuine declaration ("reach me through the
6607    /// SSH tunnel"), not a LAN literal — 127/8 is not RFC1918. It must keep
6608    /// resolving verbatim; `hub::coordinator::is_loopback_url` is what judges it
6609    /// downstream.
6610    #[test]
6611    fn a_loopback_placeholder_still_resolves_verbatim() {
6612        let src = r#"
6613name = "m"
6614provider = "static"
6615mesh_tags = []
6616
6617[connect]
6618address = "192.168.10.99"
6619ssh = "yah@192.168.10.99"
6620yubaba = "http://127.0.0.1:7443"
6621"#;
6622        let cfg: MachineConfig = toml::from_str(src).unwrap();
6623        assert_eq!(cfg.yubaba_url().as_deref(), Some("http://127.0.0.1:7443"));
6624    }
6625
6626    #[test]
6627    fn private_ranges_are_exactly_rfc1918() {
6628        for lan in [
6629            "http://192.168.10.11:7443",
6630            "http://10.0.0.5:7443",
6631            "http://172.16.4.1:7443",
6632        ] {
6633            assert!(private_ipv4_from_url(lan).is_some(), "{lan}");
6634        }
6635        for not_lan in [
6636            "http://100.64.0.6:7443",  // mesh
6637            "http://127.0.0.1:7443",   // loopback
6638            "http://172.32.0.1:7443",  // just past 172.16/12
6639            "http://45.32.194.254:80", // public
6640            "http://us-west-001:7443", // name, not a literal
6641        ] {
6642            assert!(private_ipv4_from_url(not_lan).is_none(), "{not_lan}");
6643        }
6644    }
6645
6646    /// `normalize` migrates in place: the legacy fingerprint moves into
6647    /// `[registration]`, the mesh IP is lifted out of the URL, and the derived
6648    /// `[connect].yubaba` is cleared so the two halves cannot drift.
6649    #[test]
6650    fn normalize_migrates_legacy_fields_and_is_idempotent() {
6651        let src = r#"
6652name = "m"
6653provider = "static"
6654mesh_tags = []
6655hostkey_fingerprint = "SHA256:dmpq"
6656
6657[connect]
6658address = "15.204.89.240"
6659ssh = "debian@15.204.89.240"
6660yubaba = "http://100.64.0.1:7443"
6661"#;
6662        let mut cfg: MachineConfig = toml::from_str(src).unwrap();
6663        cfg.normalize();
6664        assert!(cfg.legacy_hostkey_fingerprint.is_none());
6665        assert_eq!(
6666            cfg.registration.hostkey_fingerprint.as_deref(),
6667            Some("SHA256:dmpq")
6668        );
6669        assert_eq!(cfg.registration.mesh_ipv4.as_deref(), Some("100.64.0.1"));
6670        assert!(cfg.connect.as_ref().unwrap().yubaba.is_none());
6671        // Accessors still answer the same, and re-running changes nothing.
6672        assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.1:7443"));
6673        let once = format!("{cfg:?}");
6674        cfg.normalize();
6675        assert_eq!(once, format!("{cfg:?}"));
6676    }
6677
6678    /// A loopback `[connect].yubaba` is a declaration ("no mesh address yet —
6679    /// reach me through the SSH tunnel"), not a stale observation, so
6680    /// `normalize` must leave it alone. us-west-003/011/013 depend on this.
6681    #[test]
6682    fn normalize_leaves_pre_mesh_loopback_declaration_intact() {
6683        let src = r#"
6684name = "m"
6685provider = "static"
6686mesh_tags = []
6687
6688[connect]
6689address = "192.168.10.11"
6690ssh = "yah@192.168.10.11"
6691yubaba = "http://127.0.0.1:7443"
6692"#;
6693        let mut cfg: MachineConfig = toml::from_str(src).unwrap();
6694        cfg.normalize();
6695        assert_eq!(
6696            cfg.connect.as_ref().unwrap().yubaba.as_deref(),
6697            Some("http://127.0.0.1:7443")
6698        );
6699        assert!(cfg.registration.is_empty());
6700        assert_eq!(cfg.mesh_ipv4(), None);
6701    }
6702
6703    /// `save` normalizes, so a legacy file that round-trips through the writer
6704    /// comes back on the split shape with nothing lost — the property that
6705    /// keeps `yah cloud machine attach` from re-emitting the old layout.
6706    #[test]
6707    fn save_writes_the_split_shape_from_a_legacy_config() {
6708        let tmp = tempfile::TempDir::new().unwrap();
6709        let root = tmp.path();
6710        let src = r#"
6711name = "m"
6712provider = "static"
6713mesh_tags = []
6714hostkey_fingerprint = "SHA256:dmpq"
6715
6716[connect]
6717address = "15.204.89.240"
6718ssh = "debian@15.204.89.240"
6719yubaba = "http://100.64.0.1:7443"
6720"#;
6721        let cfg: MachineConfig = toml::from_str(src).unwrap();
6722        cfg.save(root).unwrap();
6723
6724        let written = std::fs::read_to_string(root.join("machines/m.toml")).unwrap();
6725        let reg_at = written
6726            .find("[registration]")
6727            .unwrap_or_else(|| panic!("no [registration] table: {written}"));
6728        let fp_at = written
6729            .find("hostkey_fingerprint")
6730            .unwrap_or_else(|| panic!("fingerprint dropped: {written}"));
6731        assert!(
6732            fp_at > reg_at,
6733            "legacy top-level field must not be re-emitted: {written}"
6734        );
6735        assert!(
6736            !written.contains("yubaba ="),
6737            "derived URL must not be re-emitted alongside mesh_ipv4: {written}"
6738        );
6739
6740        let reloaded: MachineConfig = toml::from_str(&written).unwrap();
6741        assert_eq!(reloaded.hostkey_fingerprint(), Some("SHA256:dmpq"));
6742        assert_eq!(
6743            reloaded.yubaba_url().as_deref(),
6744            Some("http://100.64.0.1:7443")
6745        );
6746    }
6747
6748    /// `[registration]` is omitted entirely for a machine nothing has been
6749    /// observed about — a scaffolded declaration stays clean.
6750    #[test]
6751    fn empty_registration_is_omitted_on_serialize() {
6752        let src = r#"
6753name = "m"
6754provider = "static"
6755mesh_tags = []
6756"#;
6757        let cfg: MachineConfig = toml::from_str(src).unwrap();
6758        assert!(cfg.registration.is_empty());
6759        let out = toml::to_string_pretty(&cfg).unwrap();
6760        assert!(!out.contains("[registration]"), "{out}");
6761    }
6762
6763    #[test]
6764    fn driver_provider_without_location_fails_validate() {
6765        // A driver-backed provider (hetzner/vultr) still MUST carry location +
6766        // server_type — the driver can't create a server without them. The
6767        // contract moved from load-time (required field) to provision-time
6768        // (validate), so the TOML loads but validate() rejects it.
6769        let src = r#"
6770name = "us-west-001"
6771provider = "hetzner"
6772mesh_tags = []
6773"#;
6774        let cfg: MachineConfig = toml::from_str(src).unwrap();
6775        assert!(provider_has_machine_driver(&cfg.provider));
6776        let err = cfg.validate().unwrap_err().to_string();
6777        assert!(
6778            err.contains("location"),
6779            "expected location complaint: {err}"
6780        );
6781    }
6782
6783    /// Helper for the new-tree integration tests below: lay out
6784    /// `<workspace>/.yah/{infra,services}/` with `dev-yah` + its mirrors and
6785    /// the three Phase-A providers (cloudflare, hetzner, orbstack).
6786    fn make_new_tree_with_dev_yah(root: &std::path::Path) {
6787        let infra = root.join(".yah").join("infra");
6788        let providers = infra.join("providers");
6789        std::fs::create_dir_all(&providers).unwrap();
6790        std::fs::write(
6791            providers.join("cloudflare.toml"),
6792            r#"schema_version = 1
6793id = "cloudflare"
6794kind = "cloudflare"
6795credentials = "keystore://cloudflare/yah"
6796default_zone = "yah.dev"
6797"#,
6798        )
6799        .unwrap();
6800        std::fs::write(
6801            providers.join("hetzner.toml"),
6802            r#"schema_version = 1
6803id = "hetzner"
6804kind = "hetzner"
6805credentials = "keystore://hetzner/yah"
6806default_location = "pdx"
6807default_server_type = "cpx11"
6808ssh_keys = []
6809"#,
6810        )
6811        .unwrap();
6812        std::fs::write(
6813            providers.join("orbstack.toml"),
6814            r#"schema_version = 1
6815id = "orbstack"
6816kind = "local-container"
6817runtime = "auto"
6818
6819[discovery]
6820orbstack = "~/.orbstack/run/docker.sock"
6821"#,
6822        )
6823        .unwrap();
6824
6825        let svc = root.join(".yah").join("services").join("dev-yah");
6826        std::fs::create_dir_all(svc.join("mirrors")).unwrap();
6827        std::fs::write(
6828            svc.join("service.toml"),
6829            r#"schema_version = 1
6830name = "dev-yah"
6831domain = "yah.dev"
6832
6833[[components]]
6834id = "site"
6835kind = "mesofact-static"
6836path = "app/yah/web"
6837role = "static"
6838"#,
6839        )
6840        .unwrap();
6841        std::fs::write(
6842            svc.join("mirrors/prod.toml"),
6843            r#"schema_version = 1
6844shape = "single-machine"
6845
6846[providers.static]
6847use = "cloudflare"
6848bucket = "yah-dev"
6849zone = "yah.dev"
6850"#,
6851        )
6852        .unwrap();
6853        std::fs::write(
6854            svc.join("mirrors/local.toml"),
6855            r#"schema_version = 1
6856shape = "local"
6857
6858[providers.static]
6859kind = "local-static"
6860port = 4321
6861
6862[providers.compute]
6863use = "orbstack"
6864"#,
6865        )
6866        .unwrap();
6867    }
6868
6869    #[test]
6870    fn cloud_config_load_new_tree_populates_providers_and_services() {
6871        let tmp = tempfile::TempDir::new().unwrap();
6872        let root = tmp.path();
6873        make_new_tree_with_dev_yah(root);
6874
6875        let cfg = CloudConfig::load(root).unwrap();
6876        assert_eq!(cfg.providers.len(), 3, "three providers loaded");
6877        assert!(cfg.provider("cloudflare").is_some());
6878        assert!(cfg.provider("hetzner").is_some());
6879        assert!(cfg.provider("orbstack").is_some());
6880
6881        let dev = cfg.service("dev-yah").expect("dev-yah service");
6882        assert_eq!(dev.service.domain, "yah.dev");
6883        assert_eq!(dev.service.components.len(), 1);
6884        assert_eq!(dev.mirrors.len(), 2);
6885        // Legacy file stems "prod" and "local" are normalised to canonical tier names.
6886        assert!(dev.mirrors.contains_key("cloud"), "prod.toml → cloud tier");
6887        assert!(dev.mirrors.contains_key("dev"), "local.toml → dev tier");
6888        assert_eq!(dev.mirrors["cloud"].shape, MirrorShape::SingleMachine);
6889        assert_eq!(dev.mirrors["dev"].shape, MirrorShape::Local);
6890
6891        // Legacy fields stay empty when no .yah/cloud/ exists.
6892        assert!(cfg.legacy_mirrors.is_empty());
6893        assert!(cfg.legacy_services.is_empty());
6894        assert!(cfg.workloads.is_empty());
6895    }
6896
6897    #[test]
6898    fn cloud_config_cross_ref_fails_on_missing_provider() {
6899        // Mirror references a provider id that doesn't exist.
6900        let tmp = tempfile::TempDir::new().unwrap();
6901        let root = tmp.path();
6902        let svc = root.join(".yah").join("services").join("dev-yah");
6903        std::fs::create_dir_all(svc.join("mirrors")).unwrap();
6904        std::fs::write(
6905            svc.join("service.toml"),
6906            "schema_version = 1\nname = \"dev-yah\"\ndomain = \"yah.dev\"\n",
6907        )
6908        .unwrap();
6909        std::fs::write(
6910            svc.join("mirrors/prod.toml"),
6911            "schema_version = 1\nshape = \"single-machine\"\n\n[providers.static]\nuse = \"fly-io\"\n",
6912        ).unwrap();
6913
6914        let err = CloudConfig::load(root).unwrap_err();
6915        let msg = err.to_string();
6916        assert!(
6917            msg.contains("fly-io"),
6918            "error should name the missing provider id, got: {msg}"
6919        );
6920        assert!(
6921            msg.contains("providers/fly-io.toml") || msg.contains("no such provider"),
6922            "error should hint at remedy, got: {msg}"
6923        );
6924    }
6925
6926    #[test]
6927    fn cloud_config_cross_ref_fails_on_missing_provider_named_by_an_ingress_edge() {
6928        // R845: the edge's own `use` is a provider reference like any other, so
6929        // a typo has to fail here rather than at the Cloudflare arm of apply.
6930        let tmp = tempfile::TempDir::new().unwrap();
6931        let root = tmp.path();
6932        let svc = root.join(".yah").join("services").join("dev-yah");
6933        std::fs::create_dir_all(svc.join("mirrors")).unwrap();
6934        std::fs::write(
6935            svc.join("service.toml"),
6936            "schema_version = 1\nname = \"dev-yah\"\ndomain = \"yah.dev\"\n",
6937        )
6938        .unwrap();
6939        std::fs::write(
6940            svc.join("mirrors/prod.toml"),
6941            "schema_version = 1\nshape = \"single-machine\"\n\n\
6942             [providers.compute]\nkind = \"static\"\nmachine = \"borrowed-01\"\n\
6943             zone = \"a.yah.dev\"\nport = 8080\n\n\
6944             [[ingress]]\nprovider = \"cloudflare-tunnel\"\nuse = \"cloudflar\"\n",
6945        )
6946        .unwrap();
6947
6948        let msg = CloudConfig::load(root).unwrap_err().to_string();
6949        assert!(
6950            msg.contains("ingress[0].use") && msg.contains("cloudflar"),
6951            "error should name the edge and the typo'd id, got: {msg}"
6952        );
6953    }
6954
6955    #[test]
6956    fn cloud_config_cross_ref_passes_on_inline_only_mirror() {
6957        // Inline `kind = "local-static"` doesn't require an infra provider.
6958        let tmp = tempfile::TempDir::new().unwrap();
6959        let root = tmp.path();
6960        let svc = root.join(".yah").join("services").join("local-only");
6961        std::fs::create_dir_all(svc.join("mirrors")).unwrap();
6962        std::fs::write(
6963            svc.join("service.toml"),
6964            "schema_version = 1\nname = \"local-only\"\ndomain = \"local.test\"\n",
6965        )
6966        .unwrap();
6967        std::fs::write(
6968            svc.join("mirrors/local.toml"),
6969            "schema_version = 1\nshape = \"local\"\n\n[providers.static]\nkind = \"local-static\"\nport = 8080\n",
6970        ).unwrap();
6971
6972        // Should load fine: no `use=` references, no providers required.
6973        let cfg = CloudConfig::load(root).unwrap();
6974        assert!(cfg.service("local-only").is_some());
6975    }
6976
6977    fn mirror(src: &str) -> MirrorConfig {
6978        toml::from_str(&format!("schema_version = 1\nshape = \"single-machine\"\n{src}"))
6979            .expect("parse mirror")
6980    }
6981
6982    #[test]
6983    fn passway_machines_reads_both_ingress_spellings_the_same_way() {
6984        // The whole reason this is derived in Rust rather than read off a field
6985        // by the UI: these two mirrors say the identical thing, and a consumer
6986        // that reaches for `ingress_machines` sees the second one as empty.
6987        let scalar = mirror("ingress = \"passway\"\ningress_machines = [\"us-east-001\"]\n");
6988        let edges = mirror(
6989            "[[ingress]]\nprovider = \"passway\"\nmachines = [\"us-east-001\"]\n",
6990        );
6991        assert_eq!(scalar.passway_machines(), Some(vec!["us-east-001".into()]));
6992        assert_eq!(scalar.passway_machines(), edges.passway_machines());
6993    }
6994
6995    #[test]
6996    fn passway_machines_skips_a_cloudflare_tunnel_edge() {
6997        // A cloudflared node publishes through Cloudflare's DNS and does not
6998        // serve `GET /domains/{d}/onboarding`, so naming it here would point
6999        // the custom-domain UI at a node that cannot answer.
7000        let cf_only =
7001            mirror("[[ingress]]\nprovider = \"cloudflare-tunnel\"\nmachines = [\"cf-01\"]\n");
7002        assert_eq!(cf_only.passway_machines(), None);
7003
7004        let mixed = mirror(
7005            "[[ingress]]\nprovider = \"cloudflare-tunnel\"\nmachines = [\"cf-01\"]\n\
7006             slots = [\"static\"]\n\n\
7007             [[ingress]]\nprovider = \"passway\"\nmachines = [\"us-east-001\"]\n\
7008             slots = [\"bundle\"]\n",
7009        );
7010        assert_eq!(mixed.passway_machines(), Some(vec!["us-east-001".into()]));
7011    }
7012
7013    #[test]
7014    fn passway_machines_separates_declared_but_unplaced_from_undeclared() {
7015        // Some(vec![]) means "a passway front door exists, but its placement
7016        // falls back to the fronted slot's and is not knowable from the mirror".
7017        // None means there is no passway front door at all. Collapsing the two
7018        // would make a co-located edge indistinguishable from no edge.
7019        assert_eq!(mirror("ingress = \"passway\"\n").passway_machines(), Some(vec![]));
7020        assert_eq!(mirror("").passway_machines(), None);
7021        assert_eq!(mirror("ingress = \"none\"\n").passway_machines(), None);
7022    }
7023
7024    #[test]
7025    fn passway_machines_is_none_for_a_declaration_that_cannot_mean_anything() {
7026        // `ingress_machines` with no `ingress` is an error `ingress_edges` names
7027        // properly; swallowing it to None here is deliberate, because this is
7028        // read while loading every service in the workspace and hard-failing
7029        // would report an unrelated mirror's shape error from the wrong place.
7030        let orphaned = mirror("ingress_machines = [\"us-east-001\"]\n");
7031        assert!(orphaned.ingress_edges().is_err());
7032        assert_eq!(orphaned.passway_machines(), None);
7033    }
7034
7035    #[test]
7036    fn cloud_config_load_derives_passway_machines_only_for_passway_envs() {
7037        let tmp = tempfile::TempDir::new().unwrap();
7038        let root = tmp.path();
7039        let svc = root.join(".yah").join("services").join("dev-yah");
7040        std::fs::create_dir_all(svc.join("mirrors")).unwrap();
7041        std::fs::write(
7042            svc.join("service.toml"),
7043            "schema_version = 1\nname = \"dev-yah\"\ndomain = \"yah.dev\"\n",
7044        )
7045        .unwrap();
7046        std::fs::write(
7047            svc.join("mirrors/cloud.toml"),
7048            "schema_version = 1\nshape = \"single-machine\"\n\
7049             ingress = \"passway\"\ningress_machines = [\"us-east-001\", \"us-west-001\"]\n\n\
7050             [providers.static]\nkind = \"local-static\"\nport = 8080\n",
7051        )
7052        .unwrap();
7053        std::fs::write(
7054            svc.join("mirrors/local.toml"),
7055            "schema_version = 1\nshape = \"local\"\n\n\
7056             [providers.static]\nkind = \"local-static\"\nport = 8080\n",
7057        )
7058        .unwrap();
7059
7060        let cfg = CloudConfig::load(root).unwrap();
7061        let svc = cfg.service("dev-yah").unwrap();
7062        assert_eq!(
7063            svc.passway_machines.get("cloud"),
7064            Some(&vec!["us-east-001".to_string(), "us-west-001".to_string()])
7065        );
7066        assert!(
7067            !svc.passway_machines.contains_key("local"),
7068            "an env with no front door must be absent, not empty: {:?}",
7069            svc.passway_machines
7070        );
7071    }
7072
7073    #[test]
7074    fn cloud_config_load_coexists_legacy_and_new_trees() {
7075        // Both trees present — both fields populated independently.
7076        let tmp = tempfile::TempDir::new().unwrap();
7077        let root = tmp.path();
7078        make_new_tree_with_dev_yah(root);
7079
7080        let cloud_dir = make_legacy_cloud_dir(root);
7081        std::fs::create_dir_all(cloud_dir.join("mirrors")).unwrap();
7082        std::fs::write(
7083            cloud_dir.join("mirrors/noisetable.toml"),
7084            "camp = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = []\n",
7085        )
7086        .unwrap();
7087
7088        let cfg = CloudConfig::load(root).unwrap();
7089        assert_eq!(cfg.providers.len(), 3);
7090        assert!(cfg.service("dev-yah").is_some());
7091        assert_eq!(cfg.legacy_mirrors.len(), 1);
7092        assert!(cfg.legacy_mirror("noisetable").is_some());
7093    }
7094
7095    #[test]
7096    fn web_workload_round_trips() {
7097        // app/yah/web/workload.toml is parsed as a WorkloadSpec via the
7098        // workload-spec crate. The minimum-viable manifest here exercises
7099        // schema_version + kind + build fields.
7100        //
7101        // The on-disk file uses the abbreviated v1 form (kind + build); the
7102        // full WorkloadSpec is verbose, so this test asserts the new
7103        // mesofact-static abbreviated form parses as raw TOML (B3 will plumb
7104        // it through WorkloadSpec proper).
7105        // `routes` above [build] — it is a top-level field, and TOML would
7106        // scope it into that table if written below the header (R658-B1).
7107        let src = r#"
7108schema_version = 1
7109kind = "mesofact-static"
7110
7111routes = "./routes.ts"
7112
7113[build]
7114command = "bun run build"
7115out_dir = "dist"
7116"#;
7117        let v: toml::Value = toml::from_str(src).unwrap();
7118        assert_eq!(
7119            v.get("schema_version").and_then(|x| x.as_integer()),
7120            Some(1)
7121        );
7122        assert_eq!(
7123            v.get("kind").and_then(|x| x.as_str()),
7124            Some("mesofact-static")
7125        );
7126        let build = v
7127            .get("build")
7128            .and_then(|x| x.as_table())
7129            .expect("build table");
7130        assert_eq!(
7131            build.get("command").and_then(|x| x.as_str()),
7132            Some("bun run build")
7133        );
7134        assert_eq!(build.get("out_dir").and_then(|x| x.as_str()), Some("dist"));
7135    }
7136
7137    // ─── Canonical CRUD: ServiceConfig/MirrorConfig save + delete (R323-F1) ──
7138
7139    #[test]
7140    fn service_config_save_creates_canonical_toml_and_round_trips() {
7141        let tmp = tempfile::TempDir::new().unwrap();
7142        let root = tmp.path();
7143
7144        let svc = ServiceConfig {
7145            schema_version: 1,
7146            name: "dev-yah".into(),
7147            domain: "yah.dev".into(),
7148            db: DbCatalog::default(),
7149            components: vec![ServiceComponent {
7150                mount: None,
7151                id: "site".into(),
7152                kind: "mesofact-static".into(),
7153                path: "app/yah/web".into(),
7154                role: "static".into(),
7155                publishes: Some("static".into()),
7156                wave: 0,
7157                git: None,
7158            }],
7159        };
7160        svc.save(root).unwrap();
7161
7162        // Landed at the canonical path.
7163        let path = crate::paths::service_toml(root, "dev-yah");
7164        assert!(
7165            path.exists(),
7166            "service.toml should exist at {}",
7167            path.display()
7168        );
7169
7170        // Reloads through the full CloudConfig loader (no mirrors yet).
7171        let cfg = CloudConfig::load(root).unwrap();
7172        let loaded = cfg.service("dev-yah").expect("dev-yah service");
7173        assert_eq!(loaded.service.domain, "yah.dev");
7174        assert_eq!(loaded.service.components.len(), 1);
7175        assert_eq!(
7176            loaded.service.components[0].publishes.as_deref(),
7177            Some("static")
7178        );
7179        assert!(loaded.mirrors.is_empty());
7180    }
7181
7182    #[test]
7183    fn service_config_save_overwrites_in_place() {
7184        let tmp = tempfile::TempDir::new().unwrap();
7185        let root = tmp.path();
7186
7187        let mut svc = ServiceConfig {
7188            schema_version: 1,
7189            name: "dev-yah".into(),
7190            domain: "yah.dev".into(),
7191            components: vec![],
7192            db: DbCatalog::default(),
7193        };
7194        svc.save(root).unwrap();
7195        svc.domain = "yah.example".into();
7196        svc.save(root).unwrap();
7197
7198        let cfg = CloudConfig::load(root).unwrap();
7199        assert_eq!(
7200            cfg.service("dev-yah").unwrap().service.domain,
7201            "yah.example"
7202        );
7203    }
7204
7205    #[test]
7206    fn mirror_config_save_round_trips_reference_and_inline_slots() {
7207        let tmp = tempfile::TempDir::new().unwrap();
7208        let root = tmp.path();
7209
7210        // A service must exist so the loader walks the mirrors/ dir.
7211        ServiceConfig {
7212            schema_version: 1,
7213            name: "dev-yah".into(),
7214            domain: "yah.dev".into(),
7215            components: vec![],
7216            db: DbCatalog::default(),
7217        }
7218        .save(root)
7219        .unwrap();
7220
7221        // The cloudflare provider the reference slot points at must resolve,
7222        // or CloudConfig::load's cross-ref check rejects the tree.
7223        let providers = crate::paths::providers_dir(root);
7224        std::fs::create_dir_all(&providers).unwrap();
7225        std::fs::write(
7226            providers.join("cloudflare.toml"),
7227            "schema_version = 1\nid = \"cloudflare\"\nkind = \"cloudflare\"\n",
7228        )
7229        .unwrap();
7230
7231        let mut providers_map = BTreeMap::new();
7232        providers_map.insert(
7233            "static".to_string(),
7234            MirrorProviderSlot::Reference {
7235                provider_id: "cloudflare".into(),
7236                fields: {
7237                    let mut f = BTreeMap::new();
7238                    f.insert("bucket".to_string(), toml::Value::String("yah-dev".into()));
7239                    f
7240                },
7241            },
7242        );
7243        providers_map.insert(
7244            "compute".to_string(),
7245            MirrorProviderSlot::Inline {
7246                kind: Provider::LocalStatic,
7247                fields: {
7248                    let mut f = BTreeMap::new();
7249                    f.insert("port".to_string(), toml::Value::Integer(4321));
7250                    f
7251                },
7252            },
7253        );
7254        let mirror = MirrorConfig {
7255            schema_version: 1,
7256            shape: MirrorShape::SingleMachine,
7257            providers: providers_map,
7258            ingress: Default::default(),
7259            ingress_machines: Vec::new(),
7260            drivers: Default::default(),
7261            asset_aliases: Default::default(),
7262        };
7263        // Save with canonical name; legacy "prod" is normalised to "cloud" on load.
7264        mirror.save(root, "dev-yah", "cloud").unwrap();
7265
7266        let path = crate::paths::service_mirror_toml(root, "dev-yah", "cloud");
7267        assert!(
7268            path.exists(),
7269            "mirror toml should exist at {}",
7270            path.display()
7271        );
7272
7273        let cfg = CloudConfig::load(root).unwrap();
7274        let loaded = &cfg.service("dev-yah").unwrap().mirrors["cloud"];
7275        assert_eq!(loaded.shape, MirrorShape::SingleMachine);
7276        assert_eq!(loaded.providers["static"].provider_id(), Some("cloudflare"));
7277        assert_eq!(
7278            loaded.providers["compute"].inline_kind(),
7279            Some(Provider::LocalStatic)
7280        );
7281    }
7282
7283    #[test]
7284    fn service_delete_removes_dir_and_mirrors() {
7285        let tmp = tempfile::TempDir::new().unwrap();
7286        let root = tmp.path();
7287
7288        let svc = ServiceConfig {
7289            schema_version: 1,
7290            name: "dev-yah".into(),
7291            domain: "yah.dev".into(),
7292            components: vec![],
7293            db: DbCatalog::default(),
7294        };
7295        svc.save(root).unwrap();
7296        MirrorConfig {
7297            schema_version: 1,
7298            shape: MirrorShape::Local,
7299            providers: BTreeMap::new(),
7300            ingress: Default::default(),
7301            ingress_machines: Vec::new(),
7302            drivers: Default::default(),
7303            asset_aliases: Default::default(),
7304        }
7305        .save(root, "dev-yah", "local")
7306        .unwrap();
7307
7308        assert!(
7309            ServiceConfig::delete(root, "dev-yah").unwrap(),
7310            "first delete reports true"
7311        );
7312        assert!(!crate::paths::service_dir(root, "dev-yah").exists());
7313        // Idempotent: deleting again is a no-op that reports false.
7314        assert!(!ServiceConfig::delete(root, "dev-yah").unwrap());
7315
7316        let cfg = CloudConfig::load(root).unwrap();
7317        assert!(cfg.service("dev-yah").is_none());
7318    }
7319
7320    #[test]
7321    fn mirror_delete_leaves_other_mirrors_and_service_intact() {
7322        let tmp = tempfile::TempDir::new().unwrap();
7323        let root = tmp.path();
7324
7325        ServiceConfig {
7326            schema_version: 1,
7327            name: "dev-yah".into(),
7328            domain: "yah.dev".into(),
7329            components: vec![],
7330            db: DbCatalog::default(),
7331        }
7332        .save(root)
7333        .unwrap();
7334        for env in ["prod", "local"] {
7335            MirrorConfig {
7336                schema_version: 1,
7337                shape: MirrorShape::Local,
7338                providers: BTreeMap::new(),
7339                ingress: Default::default(),
7340                ingress_machines: Vec::new(),
7341                drivers: Default::default(),
7342                asset_aliases: Default::default(),
7343            }
7344            .save(root, "dev-yah", env)
7345            .unwrap();
7346        }
7347
7348        assert!(MirrorConfig::delete(root, "dev-yah", "prod").unwrap());
7349        assert!(!MirrorConfig::delete(root, "dev-yah", "prod").unwrap());
7350
7351        let cfg = CloudConfig::load(root).unwrap();
7352        let svc = cfg
7353            .service("dev-yah")
7354            .expect("service survives mirror delete");
7355        // Legacy file stems are normalised on load: "prod" → "cloud", "local" → "dev".
7356        assert!(!svc.mirrors.contains_key("cloud"));
7357        assert!(svc.mirrors.contains_key("dev"));
7358    }
7359
7360    // ─── DomainConfig (R347-F2) ────────────────────────────────────────────
7361
7362    fn write_marketing_service(root: &Path) {
7363        let svc = ServiceConfig {
7364            schema_version: 1,
7365            name: "yah-marketing".into(),
7366            domain: "yah.dev".into(),
7367            db: DbCatalog::default(),
7368            components: vec![ServiceComponent {
7369                mount: None,
7370                id: "site".into(),
7371                kind: "mesofact-static".into(),
7372                path: "app/yah/web".into(),
7373                role: "static".into(),
7374                publishes: None,
7375                wave: 0,
7376                git: None,
7377            }],
7378        };
7379        svc.save(root).unwrap();
7380    }
7381
7382    #[test]
7383    fn round_trip_domain_with_each_route_mode() {
7384        let dom = DomainConfig {
7385            schema_version: 1,
7386            name: "yah-dev".into(),
7387            domain: "yah.dev".into(),
7388            front_door: FrontDoor::Worker,
7389            cdn_bucket: "yah-dev".into(),
7390            worker_bundle_path: Some(".yah/workers/yah-dev/".into()),
7391            routes: vec![
7392                DomainRoute {
7393                    headers: Default::default(),
7394                    path: "/".into(),
7395                    mode: RouteMode::Static {
7396                        component: "yah-marketing/site".into(),
7397                    },
7398                },
7399                DomainRoute {
7400                    headers: Default::default(),
7401                    path: "/dashboard/api/*".into(),
7402                    mode: RouteMode::Backend {
7403                        component: "yah-dashboard/api".into(),
7404                        origin: "https://api.dashboard.yah.dev".into(),
7405                    },
7406                },
7407                DomainRoute {
7408                    headers: Default::default(),
7409                    path: "/old".into(),
7410                    mode: RouteMode::Redirect {
7411                        target: "https://yah.dev/blog".into(),
7412                        status: 308,
7413                    },
7414                },
7415            ],
7416        };
7417        let s = toml::to_string(&dom).unwrap();
7418        let back: DomainConfig = toml::from_str(&s).unwrap();
7419        assert_eq!(back.name, "yah-dev");
7420        assert_eq!(back.routes.len(), 3);
7421        assert!(matches!(back.routes[0].mode, RouteMode::Static { .. }));
7422        assert!(matches!(back.routes[1].mode, RouteMode::Backend { .. }));
7423        assert!(matches!(back.routes[2].mode, RouteMode::Redirect { .. }));
7424    }
7425
7426    #[test]
7427    fn redirect_status_defaults_to_308() {
7428        let src = r#"
7429schema_version = 1
7430name = "yah-dev"
7431domain = "yah.dev"
7432front_door = "worker"
7433cdn_bucket = "yah-dev"
7434
7435[[routes]]
7436path = "/old"
7437mode = "redirect"
7438target = "https://yah.dev/blog"
7439"#;
7440        let dom: DomainConfig = toml::from_str(src).unwrap();
7441        let RouteMode::Redirect { status, .. } = &dom.routes[0].mode else {
7442            panic!("expected redirect");
7443        };
7444        assert_eq!(*status, 308);
7445    }
7446
7447    #[test]
7448    fn missing_domains_dir_is_empty() {
7449        let tmp = tempfile::TempDir::new().unwrap();
7450        // R844-B7: `.yah/` must exist or this is a wrong-root error rather
7451        // than an empty tree. The absent directory under test is `domains/`.
7452        std::fs::create_dir_all(tmp.path().join(".yah")).unwrap();
7453        let cfg = CloudConfig::load(tmp.path()).unwrap();
7454        assert!(cfg.domains.is_empty());
7455    }
7456
7457    #[test]
7458    fn save_reload_roundtrip() {
7459        let tmp = tempfile::TempDir::new().unwrap();
7460        let root = tmp.path();
7461        write_marketing_service(root);
7462
7463        let dom = DomainConfig {
7464            schema_version: 1,
7465            name: "yah-dev".into(),
7466            domain: "yah.dev".into(),
7467            front_door: FrontDoor::Worker,
7468            cdn_bucket: "yah-dev".into(),
7469            worker_bundle_path: None,
7470            routes: vec![DomainRoute {
7471                headers: Default::default(),
7472                path: "/".into(),
7473                mode: RouteMode::Static {
7474                    component: "yah-marketing/site".into(),
7475                },
7476            }],
7477        };
7478        dom.save(root).unwrap();
7479
7480        let cfg = CloudConfig::load(root).unwrap();
7481        let loaded = cfg.domain("yah-dev").expect("yah-dev domain");
7482        assert_eq!(loaded.domain, "yah.dev");
7483        assert_eq!(loaded.routes.len(), 1);
7484    }
7485
7486    #[test]
7487    fn delete_returns_false_when_absent() {
7488        let tmp = tempfile::TempDir::new().unwrap();
7489        assert!(!DomainConfig::delete(tmp.path(), "no-such-domain").unwrap());
7490    }
7491
7492    #[test]
7493    fn delete_returns_true_first_time() {
7494        let tmp = tempfile::TempDir::new().unwrap();
7495        let root = tmp.path();
7496        let dom = DomainConfig {
7497            schema_version: 1,
7498            name: "yah-dev".into(),
7499            domain: "yah.dev".into(),
7500            front_door: FrontDoor::BucketDirect,
7501            cdn_bucket: "yah-dev".into(),
7502            worker_bundle_path: None,
7503            routes: vec![],
7504        };
7505        dom.save(root).unwrap();
7506        assert!(DomainConfig::delete(root, "yah-dev").unwrap());
7507        assert!(!DomainConfig::delete(root, "yah-dev").unwrap());
7508    }
7509
7510    // ---- R594-F12: front-door discriminator ------------------------------
7511
7512    /// Write a raw domain manifest so the tests exercise the deserialize +
7513    /// validate path, not a hand-built struct that skipped serde.
7514    fn write_domain_toml(root: &Path, stem: &str, body: &str) {
7515        let dir = root.join(".yah").join("domains");
7516        std::fs::create_dir_all(&dir).unwrap();
7517        std::fs::write(dir.join(format!("{stem}.toml")), body).unwrap();
7518    }
7519
7520    #[test]
7521    fn front_door_is_required() {
7522        let tmp = tempfile::TempDir::new().unwrap();
7523        let root = tmp.path();
7524        write_marketing_service(root);
7525        write_domain_toml(
7526            root,
7527            "yah-dev",
7528            r#"
7529schema_version = 1
7530name = "yah-dev"
7531domain = "yah.dev"
7532cdn_bucket = "yah-dev"
7533[[routes]]
7534path = "/*"
7535mode = "static"
7536component = "yah-marketing/site"
7537"#,
7538        );
7539        let err = CloudConfig::load(root).unwrap_err().to_string();
7540        // serde's own missing-field message; the point is that omitting the
7541        // discriminator is not a silently-defaulted state.
7542        assert!(err.contains("yah-dev.toml"), "{err}");
7543    }
7544
7545    #[test]
7546    fn bucket_direct_with_routes_is_rejected() {
7547        let tmp = tempfile::TempDir::new().unwrap();
7548        let root = tmp.path();
7549        write_marketing_service(root);
7550        write_domain_toml(
7551            root,
7552            "cdn-yah-dev",
7553            r#"
7554schema_version = 1
7555name = "cdn-yah-dev"
7556domain = "cdn.yah.dev"
7557front_door = "bucket-direct"
7558cdn_bucket = "yah-dev"
7559[[routes]]
7560path = "/docs/*"
7561mode = "static"
7562component = "yah-marketing/site"
7563"#,
7564        );
7565        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7566        assert!(err.contains("front_door"), "{err}");
7567        assert!(err.contains("/docs/*"), "{err}");
7568    }
7569
7570    #[test]
7571    fn bucket_direct_with_worker_bundle_path_is_rejected() {
7572        let tmp = tempfile::TempDir::new().unwrap();
7573        let root = tmp.path();
7574        write_domain_toml(
7575            root,
7576            "cdn-yah-dev",
7577            r#"
7578schema_version = 1
7579name = "cdn-yah-dev"
7580domain = "cdn.yah.dev"
7581front_door = "bucket-direct"
7582cdn_bucket = "yah-dev"
7583worker_bundle_path = ".yah/workers/cdn-yah-dev/"
7584"#,
7585        );
7586        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7587        assert!(err.contains("worker_bundle_path"), "{err}");
7588    }
7589
7590    // ── R746: per-route response headers + component mounts ──────────────────
7591
7592    /// A two-component service: `site` at the root, `app` mounted at `/app`
7593    /// with isolation headers on its route. This is the noisetable.com shape
7594    /// the primitive was built for.
7595    fn write_two_component_service(root: &Path) {
7596        let svc = ServiceConfig {
7597            schema_version: 1,
7598            name: "yah-marketing".into(),
7599            domain: "yah.dev".into(),
7600            db: DbCatalog::default(),
7601            components: vec![
7602                ServiceComponent {
7603                    mount: None,
7604                    id: "site".into(),
7605                    kind: "mesofact-static".into(),
7606                    path: "app/yah/web".into(),
7607                    role: "static".into(),
7608                    publishes: None,
7609                    wave: 0,
7610                    git: None,
7611                },
7612                ServiceComponent {
7613                    mount: Some("/app".into()),
7614                    id: "app".into(),
7615                    kind: "mesofact-static".into(),
7616                    path: "app/browser".into(),
7617                    role: "static".into(),
7618                    publishes: None,
7619                    wave: 0,
7620                    git: None,
7621                },
7622            ],
7623        };
7624        svc.save(root).unwrap();
7625    }
7626
7627    const MOUNTED_DOMAIN: &str = r#"
7628schema_version = 1
7629name = "yah-dev"
7630domain = "yah.dev"
7631front_door = "worker"
7632cdn_bucket = "yah-dev"
7633
7634[[routes]]
7635path = "/app/*"
7636mode = "static"
7637component = "yah-marketing/app"
7638headers = { "Cross-Origin-Opener-Policy" = "same-origin", "Cross-Origin-Embedder-Policy" = "require-corp" }
7639
7640[[routes]]
7641path = "/*"
7642mode = "static"
7643component = "yah-marketing/site"
7644"#;
7645
7646    #[test]
7647    fn a_mounted_component_routed_at_its_mount_loads() {
7648        let tmp = tempfile::TempDir::new().unwrap();
7649        let root = tmp.path();
7650        write_two_component_service(root);
7651        write_domain_toml(root, "yah-dev", MOUNTED_DOMAIN);
7652        let cfg = CloudConfig::load(root).unwrap();
7653        let dom = cfg.domain("yah-dev").unwrap();
7654        assert_eq!(dom.routes.len(), 2);
7655        assert_eq!(
7656            dom.routes[0].headers.get("Cross-Origin-Opener-Policy").map(String::as_str),
7657            Some("same-origin")
7658        );
7659        assert!(dom.routes[1].headers.is_empty());
7660    }
7661
7662    /// The header table reaches the Worker in MANIFEST order with headerless
7663    /// routes dropped. Order is the whole contract — the front door applies the
7664    /// first match, so `/app/*` before `/*` is what isolates the app without
7665    /// isolating the marketing site.
7666    #[test]
7667    fn route_headers_json_preserves_order_and_drops_headerless_routes() {
7668        let tmp = tempfile::TempDir::new().unwrap();
7669        let root = tmp.path();
7670        write_two_component_service(root);
7671        write_domain_toml(root, "yah-dev", MOUNTED_DOMAIN);
7672        let cfg = CloudConfig::load(root).unwrap();
7673        let json = cfg.domain("yah-dev").unwrap().route_headers_json();
7674
7675        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
7676        let rules = parsed.as_array().unwrap();
7677        assert_eq!(rules.len(), 1, "the headerless catch-all is dropped: {json}");
7678        assert_eq!(rules[0]["path"], "/app/*");
7679        assert_eq!(rules[0]["headers"]["Cross-Origin-Embedder-Policy"], "require-corp");
7680    }
7681
7682    #[test]
7683    fn route_headers_json_is_an_empty_array_when_nothing_declares_headers() {
7684        let tmp = tempfile::TempDir::new().unwrap();
7685        let root = tmp.path();
7686        write_marketing_service(root);
7687        write_domain_toml(
7688            root,
7689            "yah-dev",
7690            r#"
7691schema_version = 1
7692name = "yah-dev"
7693domain = "yah.dev"
7694front_door = "worker"
7695cdn_bucket = "yah-dev"
7696
7697[[routes]]
7698path = "/*"
7699mode = "static"
7700component = "yah-marketing/site"
7701"#,
7702        );
7703        let cfg = CloudConfig::load(root).unwrap();
7704        assert_eq!(cfg.domain("yah-dev").unwrap().route_headers_json(), "[]");
7705    }
7706
7707    /// The reconciler's own entry point: given a workspace root and a service
7708    /// name, produce the binding value. `"[]"` when nothing routes the service.
7709    #[test]
7710    fn route_headers_for_service_reads_the_workspace_domains() {
7711        let tmp = tempfile::TempDir::new().unwrap();
7712        let root = tmp.path();
7713        write_two_component_service(root);
7714        write_domain_toml(root, "yah-dev", MOUNTED_DOMAIN);
7715        assert!(route_headers_for_service(root, "yah-marketing")
7716            .unwrap()
7717            .contains("require-corp"));
7718        assert_eq!(route_headers_for_service(root, "some-other-svc").unwrap(), "[]");
7719    }
7720
7721    // ---- R749-T5: a broken table fails the DEPLOY, not the edge -----------
7722
7723    /// The manifest's `headers` map is hand-written TOML, so a header name with
7724    /// spaces in it is one keystroke away — and it survives serialization into
7725    /// a structurally-valid table that neither front door can apply. Fail at
7726    /// load, naming the domain, the route and the header, instead of shipping a
7727    /// binding the Worker throws on and an origin that refuses to boot.
7728    #[test]
7729    fn a_route_header_name_that_is_not_a_header_name_fails_the_load() {
7730        let tmp = tempfile::TempDir::new().unwrap();
7731        let root = tmp.path();
7732        write_marketing_service(root);
7733        write_domain_toml(
7734            root,
7735            "yah-dev",
7736            r#"
7737schema_version = 1
7738name = "yah-dev"
7739domain = "yah.dev"
7740front_door = "worker"
7741cdn_bucket = "yah-dev"
7742
7743[[routes]]
7744path = "/*"
7745mode = "static"
7746component = "yah-marketing/site"
7747headers = { "Cross Origin Opener Policy" = "same-origin" }
7748"#,
7749        );
7750        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7751        assert!(err.contains("yah-dev"), "{err}");
7752        assert!(err.contains("/*"), "{err}");
7753        assert!(err.contains("Cross Origin Opener Policy"), "{err}");
7754        assert!(err.contains("not a valid HTTP header name"), "{err}");
7755    }
7756
7757    /// A newline in a value is header injection if it ever reached the wire, so
7758    /// both doors reject it and so does this.
7759    #[test]
7760    fn a_route_header_value_that_is_not_a_header_value_fails_the_load() {
7761        let tmp = tempfile::TempDir::new().unwrap();
7762        let root = tmp.path();
7763        write_marketing_service(root);
7764        write_domain_toml(
7765            root,
7766            "yah-dev",
7767            r#"
7768schema_version = 1
7769name = "yah-dev"
7770domain = "yah.dev"
7771front_door = "worker"
7772cdn_bucket = "yah-dev"
7773
7774[[routes]]
7775path = "/*"
7776mode = "static"
7777component = "yah-marketing/site"
7778headers = { "X-Frame-Options" = "DENY\nSet-Cookie: pwned=1" }
7779"#,
7780        );
7781        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7782        assert!(err.contains("X-Frame-Options"), "{err}");
7783        assert!(err.contains("not a valid HTTP header value"), "{err}");
7784    }
7785
7786    /// The invariant this gate exists to hold: everything `route_headers_json`
7787    /// emits is applicable. A headerless route contributes no rule, so its path
7788    /// is not the table's business — only rules that ship are checked.
7789    #[test]
7790    fn a_headerless_route_is_not_subject_to_the_route_header_gate() {
7791        let tmp = tempfile::TempDir::new().unwrap();
7792        let root = tmp.path();
7793        write_two_component_service(root);
7794        write_domain_toml(root, "yah-dev", MOUNTED_DOMAIN);
7795        let cfg = CloudConfig::load(root).unwrap();
7796        cfg.domain("yah-dev")
7797            .unwrap()
7798            .validate_route_headers()
7799            .unwrap();
7800    }
7801
7802    /// A `bucket-direct` domain has no front door to set headers on, so it must
7803    /// not be picked up as a service's header source.
7804    #[test]
7805    fn route_headers_ignores_domains_that_are_not_route_driven() {
7806        let doms: BTreeMap<String, DomainConfig> = [(
7807            "cdn".to_string(),
7808            DomainConfig {
7809                schema_version: 1,
7810                name: "cdn".into(),
7811                domain: "cdn.yah.dev".into(),
7812                front_door: FrontDoor::BucketDirect,
7813                cdn_bucket: "yah-dev".into(),
7814                worker_bundle_path: None,
7815                routes: vec![],
7816            },
7817        )]
7818        .into_iter()
7819        .collect();
7820        assert!(domain_serving_service(&doms, "yah-marketing").is_none());
7821    }
7822
7823    #[test]
7824    fn a_mount_that_disagrees_with_its_route_path_is_rejected() {
7825        let tmp = tempfile::TempDir::new().unwrap();
7826        let root = tmp.path();
7827        write_two_component_service(root);
7828        write_domain_toml(
7829            root,
7830            "yah-dev",
7831            r#"
7832schema_version = 1
7833name = "yah-dev"
7834domain = "yah.dev"
7835front_door = "worker"
7836cdn_bucket = "yah-dev"
7837
7838[[routes]]
7839path = "/studio/*"
7840mode = "static"
7841component = "yah-marketing/app"
7842"#,
7843        );
7844        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7845        assert!(err.contains("mount = \"/app\""), "{err}");
7846        assert!(err.contains("/studio/*"), "{err}");
7847    }
7848
7849    /// The other direction: routing an unmounted component under a sub-path
7850    /// points requests at a prefix nothing published to.
7851    #[test]
7852    fn routing_an_unmounted_component_under_a_subpath_is_rejected() {
7853        let tmp = tempfile::TempDir::new().unwrap();
7854        let root = tmp.path();
7855        write_marketing_service(root);
7856        write_domain_toml(
7857            root,
7858            "yah-dev",
7859            r#"
7860schema_version = 1
7861name = "yah-dev"
7862domain = "yah.dev"
7863front_door = "worker"
7864cdn_bucket = "yah-dev"
7865
7866[[routes]]
7867path = "/docs/*"
7868mode = "static"
7869component = "yah-marketing/site"
7870"#,
7871        );
7872        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7873        assert!(err.contains("no `mount`"), "{err}");
7874        assert!(err.contains("/docs"), "{err}");
7875    }
7876
7877    #[test]
7878    fn mount_and_route_prefix_normalization_agree() {
7879        for m in ["/app", "app", "app/", "/app/"] {
7880            assert_eq!(normalize_mount(m), "app", "mount {m:?}");
7881        }
7882        assert_eq!(normalize_mount("/"), "");
7883        assert_eq!(route_path_prefix("/*"), "");
7884        assert_eq!(route_path_prefix("/app/*"), "app");
7885        assert_eq!(route_path_prefix("/app"), "app");
7886        assert_eq!(route_path_prefix("/"), "");
7887    }
7888
7889    #[test]
7890    fn worker_with_no_routes_is_rejected() {
7891        let tmp = tempfile::TempDir::new().unwrap();
7892        let root = tmp.path();
7893        write_domain_toml(
7894            root,
7895            "yah-dev",
7896            r#"
7897schema_version = 1
7898name = "yah-dev"
7899domain = "yah.dev"
7900front_door = "worker"
7901cdn_bucket = "yah-dev"
7902"#,
7903        );
7904        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7905        assert!(err.contains("front_door = \"worker\""), "{err}");
7906        assert!(err.contains("404"), "{err}");
7907    }
7908
7909    #[test]
7910    fn passway_with_no_routes_is_rejected_too() {
7911        let tmp = tempfile::TempDir::new().unwrap();
7912        let root = tmp.path();
7913        write_domain_toml(
7914            root,
7915            "yah-dev",
7916            r#"
7917schema_version = 1
7918name = "yah-dev"
7919domain = "yah.dev"
7920front_door = "passway"
7921cdn_bucket = "yah-dev"
7922"#,
7923        );
7924        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7925        assert!(err.contains("front_door = \"passway\""), "{err}");
7926    }
7927
7928    #[test]
7929    fn bucket_direct_without_routes_loads() {
7930        let tmp = tempfile::TempDir::new().unwrap();
7931        let root = tmp.path();
7932        // Exactly the shape .yah/domains/cdn-yah-dev.toml ships (W175: a pure
7933        // asset tier deliberately has no Worker behaviours).
7934        write_domain_toml(
7935            root,
7936            "cdn-yah-dev",
7937            r#"
7938schema_version = 1
7939name = "cdn-yah-dev"
7940domain = "cdn.yah.dev"
7941front_door = "bucket-direct"
7942cdn_bucket = "yah-dev"
7943"#,
7944        );
7945        let cfg = CloudConfig::load(root).unwrap();
7946        let dom = cfg.domain("cdn-yah-dev").expect("cdn-yah-dev domain");
7947        assert_eq!(dom.front_door, FrontDoor::BucketDirect);
7948        assert!(!dom.front_door.is_route_driven());
7949    }
7950
7951    #[test]
7952    fn front_door_round_trips_through_save() {
7953        let tmp = tempfile::TempDir::new().unwrap();
7954        let root = tmp.path();
7955        write_marketing_service(root);
7956        let dom = DomainConfig {
7957            schema_version: 1,
7958            name: "yah-dev".into(),
7959            domain: "yah.dev".into(),
7960            front_door: FrontDoor::Passway,
7961            cdn_bucket: "yah-dev".into(),
7962            worker_bundle_path: None,
7963            routes: vec![DomainRoute {
7964                headers: Default::default(),
7965                path: "/*".into(),
7966                mode: RouteMode::Static {
7967                    component: "yah-marketing/site".into(),
7968                },
7969            }],
7970        };
7971        dom.save(root).unwrap();
7972        let cfg = CloudConfig::load(root).unwrap();
7973        assert_eq!(
7974            cfg.domain("yah-dev").unwrap().front_door,
7975            FrontDoor::Passway
7976        );
7977    }
7978
7979    // The four manifests this repo actually ships are asserted in
7980    // `tests/live_workspace_smoke.rs` — that's the only place with a
7981    // depth-agnostic path to the live `.yah/` tree and a skip path for the
7982    // standalone mirror checkout.
7983
7984    #[test]
7985    fn cross_ref_bails_on_missing_service() {
7986        let tmp = tempfile::TempDir::new().unwrap();
7987        let root = tmp.path();
7988        // No services declared at all — component ref must fail to resolve.
7989        let dom = DomainConfig {
7990            schema_version: 1,
7991            name: "yah-dev".into(),
7992            domain: "yah.dev".into(),
7993            front_door: FrontDoor::Worker,
7994            cdn_bucket: "yah-dev".into(),
7995            worker_bundle_path: None,
7996            routes: vec![DomainRoute {
7997                headers: Default::default(),
7998                path: "/".into(),
7999                mode: RouteMode::Static {
8000                    component: "yah-marketing/site".into(),
8001                },
8002            }],
8003        };
8004        dom.save(root).unwrap();
8005
8006        let err = CloudConfig::load(root).unwrap_err();
8007        let msg = format!("{err:#}");
8008        assert!(msg.contains("no such service"), "got: {msg}");
8009        assert!(msg.contains("yah-marketing"), "got: {msg}");
8010    }
8011
8012    #[test]
8013    fn cross_ref_bails_on_missing_component() {
8014        let tmp = tempfile::TempDir::new().unwrap();
8015        let root = tmp.path();
8016        write_marketing_service(root); // has component id "site", not "elsewhere"
8017
8018        let dom = DomainConfig {
8019            schema_version: 1,
8020            name: "yah-dev".into(),
8021            domain: "yah.dev".into(),
8022            front_door: FrontDoor::Worker,
8023            cdn_bucket: "yah-dev".into(),
8024            worker_bundle_path: None,
8025            routes: vec![DomainRoute {
8026                headers: Default::default(),
8027                path: "/".into(),
8028                mode: RouteMode::Static {
8029                    component: "yah-marketing/elsewhere".into(),
8030                },
8031            }],
8032        };
8033        dom.save(root).unwrap();
8034
8035        let err = CloudConfig::load(root).unwrap_err();
8036        let msg = format!("{err:#}");
8037        assert!(msg.contains("no component with id"), "got: {msg}");
8038        assert!(msg.contains("elsewhere"), "got: {msg}");
8039    }
8040
8041    #[test]
8042    fn cross_ref_bails_on_malformed_ref() {
8043        let tmp = tempfile::TempDir::new().unwrap();
8044        let root = tmp.path();
8045        write_marketing_service(root);
8046
8047        let dom = DomainConfig {
8048            schema_version: 1,
8049            name: "yah-dev".into(),
8050            domain: "yah.dev".into(),
8051            front_door: FrontDoor::Worker,
8052            cdn_bucket: "yah-dev".into(),
8053            worker_bundle_path: None,
8054            routes: vec![DomainRoute {
8055                headers: Default::default(),
8056                path: "/".into(),
8057                mode: RouteMode::Static {
8058                    component: "no-slash-here".into(),
8059                },
8060            }],
8061        };
8062        dom.save(root).unwrap();
8063
8064        let err = CloudConfig::load(root).unwrap_err();
8065        let msg = format!("{err:#}");
8066        assert!(msg.contains("expected"), "got: {msg}");
8067    }
8068
8069    #[test]
8070    fn redirect_routes_skip_component_validation() {
8071        let tmp = tempfile::TempDir::new().unwrap();
8072        let root = tmp.path();
8073        // No services at all — redirect must still load cleanly because it
8074        // references nothing.
8075        let dom = DomainConfig {
8076            schema_version: 1,
8077            name: "yah-dev".into(),
8078            domain: "yah.dev".into(),
8079            front_door: FrontDoor::Worker,
8080            cdn_bucket: "yah-dev".into(),
8081            worker_bundle_path: None,
8082            routes: vec![DomainRoute {
8083                headers: Default::default(),
8084                path: "/old".into(),
8085                mode: RouteMode::Redirect {
8086                    target: "https://yah.dev/blog".into(),
8087                    status: 308,
8088                },
8089            }],
8090        };
8091        dom.save(root).unwrap();
8092
8093        let cfg = CloudConfig::load(root).unwrap();
8094        assert!(cfg.domain("yah-dev").is_some());
8095    }
8096
8097    #[test]
8098    fn name_must_match_file_stem() {
8099        let tmp = tempfile::TempDir::new().unwrap();
8100        let root = tmp.path();
8101        // Hand-write a file whose stem disagrees with its `name`.
8102        let dir = root.join(".yah").join("domains");
8103        std::fs::create_dir_all(&dir).unwrap();
8104        std::fs::write(
8105            dir.join("yah-dev.toml"),
8106            r#"schema_version = 1
8107name = "different-name"
8108domain = "yah.dev"
8109front_door = "bucket-direct"
8110cdn_bucket = "yah-dev"
8111"#,
8112        )
8113        .unwrap();
8114
8115        let err = CloudConfig::load(root).unwrap_err();
8116        let msg = format!("{err:#}");
8117        assert!(msg.contains("must match the file stem"), "got: {msg}");
8118    }
8119
8120    #[test]
8121    fn net_alias_tier_subdomain_manifest_loads_and_cross_refs() {
8122        // R561-F2: a per-tenant subdomain manifest on the net.yah.dev wildcard
8123        // alias tier is just a DomainConfig whose `domain` is `<name>.net.yah.dev`
8124        // and whose static route cross-refs the tenant's service component.
8125        // This is exactly the shape .yah/domains/scrabcake-net-yah-dev.toml ships.
8126        let tmp = tempfile::TempDir::new().unwrap();
8127        let root = tmp.path();
8128        write_marketing_service(root); // service "yah-marketing", component "site"
8129
8130        let dom = DomainConfig {
8131            schema_version: 1,
8132            name: "tenant-net-yah-dev".into(),
8133            domain: "tenant.net.yah.dev".into(),
8134            front_door: FrontDoor::Worker,
8135            cdn_bucket: "net-yah-dev".into(), // shared per-tier bucket
8136            worker_bundle_path: None,
8137            routes: vec![DomainRoute {
8138                headers: Default::default(),
8139                path: "/*".into(),
8140                mode: RouteMode::Static {
8141                    component: "yah-marketing/site".into(),
8142                },
8143            }],
8144        };
8145        dom.save(root).unwrap();
8146
8147        let cfg = CloudConfig::load(root).unwrap();
8148        let dom = cfg
8149            .domain("tenant-net-yah-dev")
8150            .expect("net-tier subdomain manifest should load");
8151        assert_eq!(dom.domain, "tenant.net.yah.dev");
8152        assert_eq!(dom.cdn_bucket, "net-yah-dev");
8153    }
8154
8155    // ─── R572-F3: NodeAllocatable + taints ──────────────────────────────────
8156
8157    #[test]
8158    fn machine_allocatable_round_trips() {
8159        let toml_src = r#"
8160name = "us-west-001"
8161provider = "static"
8162mesh_tags = ["tag:cloud-runner"]
8163[allocatable]
8164memory_mb = 3800
8165cpu_millis = 2000
8166"#;
8167        let m: MachineConfig = toml::from_str(toml_src).unwrap();
8168        let a = m.allocatable.as_ref().expect("allocatable should parse");
8169        assert_eq!(a.memory_mb, 3800);
8170        assert_eq!(a.cpu_millis, 2000);
8171
8172        let s = toml::to_string(&m).unwrap();
8173        let back: MachineConfig = toml::from_str(&s).unwrap();
8174        let a2 = back.allocatable.as_ref().unwrap();
8175        assert_eq!(a2.memory_mb, 3800);
8176        assert_eq!(a2.cpu_millis, 2000);
8177    }
8178
8179    #[test]
8180    fn machine_taints_round_trips() {
8181        let toml_src = r#"
8182name = "us-south-001"
8183provider = "static"
8184mesh_tags = ["tag:cloud-runner"]
8185taints = ["no-appliance"]
8186"#;
8187        let m: MachineConfig = toml::from_str(toml_src).unwrap();
8188        assert_eq!(m.taints, vec!["no-appliance"]);
8189
8190        let s = toml::to_string(&m).unwrap();
8191        let back: MachineConfig = toml::from_str(&s).unwrap();
8192        assert_eq!(back.taints, vec!["no-appliance"]);
8193    }
8194
8195    #[test]
8196    fn machine_allocatable_absent_is_none() {
8197        let toml_src = "name = \"node\"\nprovider = \"static\"\nmesh_tags = []\n";
8198        let m: MachineConfig = toml::from_str(toml_src).unwrap();
8199        assert!(m.allocatable.is_none());
8200        assert!(m.taints.is_empty());
8201    }
8202
8203    #[test]
8204    fn machine_allocatable_skipped_when_none() {
8205        let m = make_machine("node", vec![]);
8206        let s = toml::to_string(&m).unwrap();
8207        assert!(
8208            !s.contains("allocatable"),
8209            "None allocatable must be omitted: {s}"
8210        );
8211        assert!(!s.contains("taints"), "empty taints must be omitted: {s}");
8212    }
8213
8214    #[test]
8215    fn machine_multiple_taints_round_trip() {
8216        let toml_src = r#"
8217name = "quarantined"
8218provider = "static"
8219mesh_tags = ["tag:build-worker"]
8220taints = ["no-server", "no-appliance", "no-job"]
8221"#;
8222        let m: MachineConfig = toml::from_str(toml_src).unwrap();
8223        assert_eq!(m.taints.len(), 3);
8224        assert!(m.taints.contains(&"no-server".to_string()));
8225        assert!(m.taints.contains(&"no-appliance".to_string()));
8226        assert!(m.taints.contains(&"no-job".to_string()));
8227        // R742-T4: every key here is one the scheduler reads. This fixture
8228        // used to carry `no-voter`, which none of them is.
8229        assert!(m.inert_taints().is_empty());
8230    }
8231
8232    // ─── R742-T4 (W305): inert-taint classification ─────────────────────────
8233
8234    #[test]
8235    fn every_archetype_repel_key_is_live() {
8236        for arch in LifecycleArchetype::ALL {
8237            let key = format!("no-{}", arch.taint_key());
8238            assert_eq!(
8239                taint_effect(&key),
8240                TaintEffect::Repels(arch),
8241                "{key} must repel {arch:?}"
8242            );
8243        }
8244    }
8245
8246    #[test]
8247    fn public_ip_is_an_affinity_key_not_an_inert_one() {
8248        assert_eq!(
8249            taint_effect(workload_spec::PUBLIC_IP_TAINT),
8250            TaintEffect::Attracts
8251        );
8252    }
8253
8254    #[test]
8255    fn a_free_form_taint_is_inert_and_says_so() {
8256        // W305's headline example: `taints = ["qa"]` parsed clean and did
8257        // nothing. Environment is not expressible as a taint.
8258        assert_eq!(taint_effect("qa"), TaintEffect::Inert);
8259        // And the one that actually cost fleet state: `no-voter` reads as an
8260        // exclusion and excludes nothing — "voter" is not an archetype.
8261        assert_eq!(taint_effect("no-voter"), TaintEffect::Inert);
8262        // A near-miss on a real key is inert too, not silently forgiven.
8263        assert_eq!(taint_effect("no-servers"), TaintEffect::Inert);
8264
8265        let m = make_machine_with_capacity(
8266            "dev-pi",
8267            8192,
8268            4000,
8269            vec!["no-appliance", "no-voter", "qa"],
8270        );
8271        assert_eq!(m.inert_taints(), vec!["no-voter", "qa"]);
8272    }
8273
8274    #[test]
8275    fn an_inert_taint_changes_no_placement_decision() {
8276        // The reason this is a lint and not a behaviour change: the guard's
8277        // whole premise is that these keys are invisible to `matches`.
8278        let clean = make_machine_with_capacity("n", 8192, 4000, vec![]);
8279        let noisy = make_machine_with_capacity("n", 8192, 4000, vec!["no-voter", "qa"]);
8280        for arch in LifecycleArchetype::ALL {
8281            let req = RequiredSpec {
8282                repel_archetype: Some(arch),
8283                ..Default::default()
8284            };
8285            assert_eq!(req.matches(&clean), req.matches(&noisy));
8286        }
8287    }
8288
8289    #[test]
8290    fn live_taint_keys_lists_the_whole_legal_vocabulary() {
8291        assert_eq!(
8292            live_taint_keys(),
8293            vec!["no-appliance", "no-job", "no-server", "public-ip"]
8294        );
8295    }
8296
8297    // ─── R742-F1 (W305): sovereign groups ───────────────────────────────────
8298
8299    /// A machine in `group`, with the role left unwritten — which is the state
8300    /// of every machine TOML that predates R605-F12 and resolves to `voter`.
8301    fn in_group(name: &str, group: Option<&str>) -> MachineConfig {
8302        MachineConfig {
8303            sovereign_group: group.map(String::from),
8304            ..make_machine(name, vec![])
8305        }
8306    }
8307
8308    /// A machine in `group` with its quorum eligibility stated (R605-F12).
8309    fn in_group_as(name: &str, group: &str, role: SovereignRole) -> MachineConfig {
8310        MachineConfig {
8311            sovereign_group: Some(group.to_string()),
8312            sovereign_role: Some(role),
8313            ..make_machine(name, vec![])
8314        }
8315    }
8316
8317    #[test]
8318    fn a_join_within_one_sovereign_group_is_permitted() {
8319        assert_eq!(
8320            judge_join(
8321                &in_group("us-west-013", Some("dev")),
8322                &in_group("us-west-011", Some("dev")),
8323            ),
8324            JoinVerdict::Permit
8325        );
8326    }
8327
8328    /// The case the field exists for: before it, the only thing standing
8329    /// between a dev Pi and the prod quorum was a comment in a TOML.
8330    #[test]
8331    fn a_cross_group_join_is_refused_naming_both_groups() {
8332        let verdict = judge_join(
8333            &in_group("us-west-011", Some("dev")),
8334            &in_group("us-west-001", Some("prod")),
8335        );
8336        let JoinVerdict::Refuse(msg) = verdict else {
8337            panic!("a dev node joining prod must be refused: {verdict:?}");
8338        };
8339        // A refusal that does not name what it saw is one the operator has to
8340        // go and reconstruct, so it gets worked around instead of fixed.
8341        assert!(msg.contains("us-west-011") && msg.contains("us-west-001"), "{msg}");
8342        assert!(msg.contains("dev") && msg.contains("prod"), "{msg}");
8343    }
8344
8345    /// `None` is a declaration ("standalone, in no group"), not a gap — so
8346    /// growing prod with an unstamped box is a cross-group join too, and the
8347    /// refusal has to say which file makes it legal.
8348    #[test]
8349    fn an_undeclared_node_cannot_join_a_declared_group() {
8350        let verdict = judge_join(
8351            &in_group("us-west-002", None),
8352            &in_group("us-west-001", Some("prod")),
8353        );
8354        let JoinVerdict::Refuse(msg) = verdict else {
8355            panic!("an unstamped node joining prod must be refused: {verdict:?}");
8356        };
8357        assert!(
8358            msg.contains(".yah/infra/machines/us-west-002.toml"),
8359            "the refusal must name the file to stamp: {msg}"
8360        );
8361    }
8362
8363    #[test]
8364    fn a_declared_node_cannot_join_a_standalone_target() {
8365        // us-west-003 is `mode: standalone` on purpose; it is not a group of
8366        // one waiting to be grown.
8367        let verdict = judge_join(
8368            &in_group("us-west-001", Some("prod")),
8369            &in_group("us-west-003", None),
8370        );
8371        assert!(matches!(verdict, JoinVerdict::Refuse(msg) if msg.contains("us-west-003")));
8372    }
8373
8374    #[test]
8375    fn two_undeclared_nodes_cannot_form_an_undeclared_group() {
8376        let verdict = judge_join(
8377            &in_group("us-west-002", None),
8378            &in_group("us-west-015", None),
8379        );
8380        assert!(
8381            matches!(&verdict, JoinVerdict::Refuse(msg) if msg.contains("us-west-002")
8382                && msg.contains("us-west-015")),
8383            "forming a group nobody declared must be refused, naming both: {verdict:?}"
8384        );
8385    }
8386
8387    // ─── R605-F12: the voting axis ──────────────────────────────────────────
8388
8389    /// The whole ticket in one assertion. us-west-003 is a member of prod —
8390    /// same secrets, same upgrade cadence, same destruction — and must never
8391    /// hold a prod raft seat. Before the role axis, the only thing refusing it
8392    /// was its *absent* group stamp, so writing down the truth above would have
8393    /// removed the guard.
8394    #[test]
8395    fn a_non_voting_member_is_refused_into_its_own_group() {
8396        let verdict = judge_join(
8397            &in_group_as("us-west-003", "prod", SovereignRole::NonVoter),
8398            &in_group_as("us-west-001", "prod", SovereignRole::Voter),
8399        );
8400        let JoinVerdict::Refuse(msg) = verdict else {
8401            panic!("a non-voting prod member must not join the prod quorum: {verdict:?}");
8402        };
8403        assert!(msg.contains("us-west-003") && msg.contains("NON-VOTING"), "{msg}");
8404        // The refusal must not blame the group: both sides say "prod", and a
8405        // cross-group message here would read as a bug in the check itself.
8406        assert!(!msg.contains("cross-group"), "{msg}");
8407        assert!(
8408            msg.contains(".yah/infra/machines/us-west-003.toml"),
8409            "the refusal must name the file that decides it: {msg}"
8410        );
8411    }
8412
8413    /// Read from the other end: a box declared non-voting has no quorum seat to
8414    /// be grown, so it cannot be a join target either.
8415    #[test]
8416    fn a_non_voting_target_has_no_quorum_to_grow() {
8417        let verdict = judge_join(
8418            &in_group_as("us-west-001", "prod", SovereignRole::Voter),
8419            &in_group_as("us-west-003", "prod", SovereignRole::NonVoter),
8420        );
8421        assert!(
8422            matches!(&verdict, JoinVerdict::Refuse(msg) if msg.contains("the target")
8423                && msg.contains("us-west-003")),
8424            "{verdict:?}"
8425        );
8426    }
8427
8428    /// A non-voter joining a *standalone* target is refused for two reasons at
8429    /// once, and the message must pick the one whose fix would actually work.
8430    /// Naming the role here would send the operator to flip `sovereign_role`
8431    /// and come back to the same refusal.
8432    #[test]
8433    fn a_refusal_names_the_group_when_fixing_the_role_would_not_help() {
8434        let verdict = judge_join(
8435            &in_group_as("us-west-003", "prod", SovereignRole::NonVoter),
8436            &in_group("us-west-002", None),
8437        );
8438        let JoinVerdict::Refuse(msg) = verdict else {
8439            panic!("a standalone target has no group to join: {verdict:?}");
8440        };
8441        assert!(
8442            msg.contains(".yah/infra/machines/us-west-002.toml"),
8443            "the refusal must point at the target's missing group stamp: {msg}"
8444        );
8445        assert!(!msg.contains("NON-VOTING"), "{msg}");
8446    }
8447
8448    /// The back-compat seam, pinned: the six nodes stamped before R605-F12
8449    /// write no role, and an absent role means what declaring a group has
8450    /// always meant. If this flips, the live prod and dev quorums stop being
8451    /// growable on a config the operator never edited.
8452    #[test]
8453    fn an_unwritten_role_still_joins_its_group() {
8454        let joiner = in_group("us-west-013", Some("dev"));
8455        assert_eq!(joiner.sovereign_role, None);
8456        assert_eq!(
8457            judge_join(&joiner, &in_group("us-west-011", Some("dev"))),
8458            JoinVerdict::Permit
8459        );
8460        assert_eq!(
8461            judge_join(
8462                &joiner,
8463                &in_group_as("us-west-011", "dev", SovereignRole::Voter)
8464            ),
8465            JoinVerdict::Permit
8466        );
8467    }
8468
8469    /// A non-voting member is still a *member*, and the two claims must not be
8470    /// conflated: `sovereign_membership()` reports the group either way, so a
8471    /// consumer asking "is this box in prod's blast radius" gets yes.
8472    #[test]
8473    fn a_non_voter_is_still_in_the_group_it_names() {
8474        let m = in_group_as("us-west-003", "prod", SovereignRole::NonVoter);
8475        assert_eq!(m.sovereign_membership().group, Some("prod"));
8476        assert!(!m.sovereign_membership().role.is_voter());
8477
8478        // …and the group-membership query the fleet reads is unaffected by it.
8479        let cfg = make_empty_cfg(vec![
8480            m,
8481            in_group_as("us-west-001", "prod", SovereignRole::Voter),
8482        ]);
8483        assert_eq!(
8484            cfg.machines_in_group("prod")
8485                .iter()
8486                .map(|m| m.name.as_str())
8487                .collect::<Vec<_>>(),
8488            vec!["us-west-003", "us-west-001"]
8489        );
8490    }
8491
8492    /// The role travels through TOML in one spelling, and an absent one stays
8493    /// absent on the way back out — otherwise every machine file would grow a
8494    /// `sovereign_role = "voter"` line the operator never wrote, and the
8495    /// unroled-member lint would have nothing left to find.
8496    #[test]
8497    fn sovereign_role_round_trips_and_is_omitted_when_unwritten() {
8498        let m: MachineConfig = toml::from_str(
8499            r#"
8500name = "us-west-003"
8501provider = "static"
8502region = "us-west"
8503arch = "x86_64"
8504mesh_tags = []
8505sovereign_group = "prod"
8506sovereign_role = "non-voter"
8507"#,
8508        )
8509        .unwrap();
8510        assert_eq!(m.sovereign_role, Some(SovereignRole::NonVoter));
8511        assert!(toml::to_string(&m)
8512            .unwrap()
8513            .contains(r#"sovereign_role = "non-voter""#));
8514
8515        let unwritten = MachineConfig {
8516            sovereign_role: None,
8517            ..m
8518        };
8519        assert!(!toml::to_string(&unwritten)
8520            .unwrap()
8521            .contains("sovereign_role"));
8522    }
8523
8524    /// The invariant the ticket is most explicit about: a sovereign group is a
8525    /// blast radius, not a filter. If this ever fails, `matches` has grown an
8526    /// axis it must not have and dev-mode workloads have silently become
8527    /// unschedulable on the dev group.
8528    #[test]
8529    fn sovereign_group_is_not_a_placement_input() {
8530        let standalone = in_group("n", None);
8531        let grouped = in_group("n", Some("dev"));
8532        let other = in_group("n", Some("prod"));
8533
8534        for spec in [
8535            RequiredSpec::default(),
8536            RequiredSpec {
8537                regions: vec!["us-west".into()],
8538                ..Default::default()
8539            },
8540            RequiredSpec {
8541                repel_archetype: Some(LifecycleArchetype::Appliance),
8542                ..Default::default()
8543            },
8544        ] {
8545            let baseline = spec.matches(&standalone);
8546            assert_eq!(spec.matches(&grouped), baseline);
8547            assert_eq!(spec.matches(&other), baseline);
8548        }
8549    }
8550
8551    // ─── R742-F3 (W305): group → machine set, and group-scoped admission ────
8552
8553    /// The primitive `migrate --to` needs and `rollout plan` still lacks
8554    /// (W314 gap 1): a group exists only as the set of machines naming it, so
8555    /// membership has to be derived rather than declared anywhere.
8556    #[test]
8557    fn machines_in_group_derives_membership_from_the_declarations() {
8558        let cfg = make_empty_cfg(vec![
8559            in_group("us-west-001", Some("prod")),
8560            in_group("us-west-011", Some("dev")),
8561            in_group("us-west-013", Some("dev")),
8562            in_group("us-west-002", None),
8563        ]);
8564
8565        let dev: Vec<&str> = cfg
8566            .machines_in_group("dev")
8567            .iter()
8568            .map(|m| m.name.as_str())
8569            .collect();
8570        assert_eq!(dev, vec!["us-west-011", "us-west-013"]);
8571        assert_eq!(cfg.machines_in_group("prod").len(), 1);
8572
8573        // Standalone is "in no group", not "in a group called none" — so an
8574        // unstamped box is never swept into a migration target.
8575        assert!(cfg.machines_in_group("").is_empty());
8576        assert!(cfg.machines_in_group("staging").is_empty());
8577    }
8578
8579    #[test]
8580    fn declared_sovereign_groups_is_the_vocabulary_a_bad_target_is_named_against() {
8581        let cfg = make_empty_cfg(vec![
8582            in_group("a", Some("prod")),
8583            in_group("b", Some("dev")),
8584            in_group("c", Some("prod")),
8585            in_group("d", None),
8586        ]);
8587        // Sorted + deduped, and standalone contributes nothing.
8588        assert_eq!(cfg.declared_sovereign_groups(), vec!["dev", "prod"]);
8589        assert!(make_empty_cfg(vec![in_group("a", None)])
8590            .declared_sovereign_groups()
8591            .is_empty());
8592    }
8593
8594    /// Group-scoped admission must be the SAME predicate as unscoped
8595    /// admission, only over fewer candidates. If it ever diverges, `migrate`
8596    /// becomes a way to place a workload somewhere `yah cloud apply` would
8597    /// refuse — which is exactly the silent routing-around W305 exists to stop.
8598    #[test]
8599    fn admit_workload_in_group_narrows_candidates_without_changing_the_predicate() {
8600        let mut prod = in_group("us-west-001", Some("prod"));
8601        prod.mesh_tags = vec!["tag:cloud-runner".into()];
8602        let mut dev_repels = in_group("us-west-011", Some("dev"));
8603        dev_repels.taints = vec!["no-appliance".into()];
8604        let mut dev_ok = in_group("us-west-013", Some("dev"));
8605        dev_ok.mesh_tags = vec!["tag:cloud-runner".into()];
8606
8607        let cfg = make_empty_cfg(vec![prod, dev_repels, dev_ok]);
8608
8609        let mut ws = ws_with_selector(None);
8610        ws.archetype = Some(LifecycleArchetype::Appliance);
8611
8612        // Unscoped picks the first match in declaration order.
8613        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "us-west-001");
8614        // Scoped skips the repelling dev node and lands on the other one —
8615        // the taint is honoured, not bypassed.
8616        assert_eq!(
8617            cfg.admit_workload_in_group(&ws, "dev").unwrap().name,
8618            "us-west-013"
8619        );
8620    }
8621
8622    #[test]
8623    fn admit_workload_in_group_distinguishes_an_empty_group_from_a_repelling_one() {
8624        let mut dev = in_group("us-west-011", Some("dev"));
8625        dev.taints = vec!["no-appliance".into()];
8626        let cfg = make_empty_cfg(vec![in_group("us-west-001", Some("prod")), dev]);
8627
8628        let mut ws = ws_with_selector(None);
8629        ws.archetype = Some(LifecycleArchetype::Appliance);
8630
8631        // A group nobody declares names the legal vocabulary, because a typo
8632        // is the realistic cause and "no candidates" would send the operator
8633        // hunting for a placement problem that does not exist.
8634        let missing = cfg.admit_workload_in_group(&ws, "stagng").unwrap_err().to_string();
8635        assert!(missing.contains("no machine declares"), "{missing}");
8636        assert!(missing.contains("dev") && missing.contains("prod"), "{missing}");
8637
8638        // A group that exists but refuses names the machines it tried.
8639        let repelled = cfg.admit_workload_in_group(&ws, "dev").unwrap_err().to_string();
8640        assert!(repelled.contains("us-west-011"), "{repelled}");
8641    }
8642
8643    #[test]
8644    fn sovereign_group_round_trips_and_is_omitted_when_standalone() {
8645        let src = r#"
8646name = "us-west-011"
8647provider = "static"
8648mesh_tags = []
8649sovereign_group = "dev"
8650"#;
8651        let m: MachineConfig = toml::from_str(src).unwrap();
8652        assert_eq!(m.sovereign_group.as_deref(), Some("dev"));
8653        assert!(toml::to_string(&m).unwrap().contains("sovereign_group"));
8654
8655        // A machine that predates the field parses as standalone and does not
8656        // grow the key back on write.
8657        let legacy: MachineConfig =
8658            toml::from_str("name = \"us-west-002\"\nprovider = \"static\"\nmesh_tags = []\n")
8659                .unwrap();
8660        assert_eq!(legacy.sovereign_group, None);
8661        assert!(!toml::to_string(&legacy).unwrap().contains("sovereign_group"));
8662    }
8663
8664    // ─── R572-F5: capacity floor + absolute (untolerable) taints ────────────
8665
8666    fn make_machine_with_capacity(
8667        name: &str,
8668        memory_mb: u32,
8669        cpu_millis: u32,
8670        taints: Vec<&str>,
8671    ) -> MachineConfig {
8672        MachineConfig {
8673            allocatable: Some(NodeAllocatable {
8674                memory_mb,
8675                cpu_millis,
8676            }),
8677            taints: taints.into_iter().map(String::from).collect(),
8678            ..make_machine(name, vec![])
8679        }
8680    }
8681
8682    fn server_spec(memory_mb: u32, cpu_millis: u32) -> WorkloadSpec {
8683        use workload_spec::{ImageRef, LifecycleArchetype, ResourceLimits, TierTag};
8684        let mut ws = WorkloadSpec::for_forge(
8685            "f5-test",
8686            ImageRef {
8687                registry: "localhost".into(),
8688                repository: "test".into(),
8689                tag: "latest".into(),
8690                digest: workload_spec::testing::test_digest(),
8691            },
8692            TierTag("infra".into()),
8693            vec![],
8694        );
8695        ws.archetype = Some(LifecycleArchetype::Server);
8696        ws.resources = ResourceLimits {
8697            memory_mb,
8698            cpu_millis,
8699            ephemeral_storage_mb: 0,
8700        };
8701        // These are SERVER specs that borrow `for_forge` as a constructor
8702        // shortcut, so drop the forge memory request it stamps on — otherwise
8703        // every spec here silently requests the forge default instead of the
8704        // `memory_mb` the caller passed, and the capacity-floor tests below
8705        // stop testing their own argument. A server workload declares no
8706        // request, which is the documented fall-back-to-`resources.memory_mb`
8707        // path (`WorkloadSpec::memory_request_mb`).
8708        ws.annotations
8709            .remove(workload_spec::MEMORY_REQUEST_ANNOTATION);
8710        ws
8711    }
8712
8713    fn appliance_spec_ws(memory_mb: u32, cpu_millis: u32) -> WorkloadSpec {
8714        use workload_spec::LifecycleArchetype;
8715        let mut ws = server_spec(memory_mb, cpu_millis);
8716        ws.archetype = Some(LifecycleArchetype::Appliance);
8717        ws
8718    }
8719
8720    #[test]
8721    fn capacity_floor_rejects_undersized_node() {
8722        let cfg = make_empty_cfg(vec![make_machine_with_capacity("small", 256, 500, vec![])]);
8723        let ws = server_spec(512, 1000); // demands more than available
8724        assert!(cfg.admit_workload(&ws).is_err());
8725    }
8726
8727    #[test]
8728    fn capacity_floor_accepts_exact_fit() {
8729        let cfg = make_empty_cfg(vec![make_machine_with_capacity("exact", 512, 1000, vec![])]);
8730        let ws = server_spec(512, 1000);
8731        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "exact");
8732    }
8733
8734    #[test]
8735    fn capacity_floor_passes_when_allocatable_absent() {
8736        // A machine with no allocatable block skips the capacity check (no data).
8737        let cfg = make_empty_cfg(vec![make_machine("no-alloc", vec![])]);
8738        let ws = server_spec(99999, 99999); // would exceed any real node
8739        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "no-alloc");
8740    }
8741
8742    #[test]
8743    fn taint_repulsion_blocks_appliance_on_no_appliance_node() {
8744        let cfg = make_empty_cfg(vec![make_machine_with_capacity(
8745            "south",
8746            1024,
8747            2000,
8748            vec!["no-appliance"],
8749        )]);
8750        let ws = appliance_spec_ws(256, 500);
8751        assert!(
8752            cfg.admit_workload(&ws).is_err(),
8753            "appliance must be repelled by no-appliance taint"
8754        );
8755    }
8756
8757    #[test]
8758    fn taint_repulsion_allows_server_on_no_appliance_node() {
8759        // "no-appliance" only repels Appliance workloads; servers are unaffected.
8760        let cfg = make_empty_cfg(vec![make_machine_with_capacity(
8761            "south",
8762            1024,
8763            2000,
8764            vec!["no-appliance"],
8765        )]);
8766        let ws = server_spec(256, 500);
8767        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "south");
8768    }
8769
8770    #[test]
8771    fn taint_repulsion_job_not_blocked_by_no_server() {
8772        use workload_spec::LifecycleArchetype;
8773        let cfg = make_empty_cfg(vec![make_machine_with_capacity(
8774            "build-box",
8775            8192,
8776            4000,
8777            vec!["no-server", "no-appliance"],
8778        )]);
8779        let mut ws = server_spec(256, 500);
8780        ws.archetype = Some(LifecycleArchetype::Job);
8781        // Job only repelled by "no-job"; "no-server" and "no-appliance" don't affect it.
8782        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "build-box");
8783    }
8784
8785    #[test]
8786    fn requires_taint_affinity_blocks_placement_without_it() {
8787        use workload_spec::{LifecycleArchetype, PUBLIC_IP_TAINT, REQUIRES_TAINT_ANNOTATION};
8788        // Simulate the passway ingress appliance: requires "public-ip" taint.
8789        let mut ws = appliance_spec_ws(256, 512);
8790        ws.archetype = Some(LifecycleArchetype::Appliance);
8791        ws.annotations
8792            .insert(REQUIRES_TAINT_ANNOTATION.into(), PUBLIC_IP_TAINT.into());
8793
8794        // Node without the taint: rejected.
8795        let cfg = make_empty_cfg(vec![make_machine_with_capacity(
8796            "no-pip",
8797            2048,
8798            2000,
8799            vec![],
8800        )]);
8801        assert!(cfg.admit_workload(&ws).is_err());
8802
8803        // Node with the taint: accepted.
8804        let cfg = make_empty_cfg(vec![make_machine_with_capacity(
8805            "pub-node",
8806            2048,
8807            2000,
8808            vec!["public-ip"],
8809        )]);
8810        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "pub-node");
8811    }
8812
8813    #[test]
8814    fn w244_fleet_scenario_appliance_rejected_from_south_and_west002() {
8815        // Full W244 fleet table scenario:
8816        // us-west-001/east-001: no taints, large capacity → appliance lands here
8817        // us-south-001: no-appliance taint → appliance rejected
8818        // us-west-002: no-server, no-appliance → appliance rejected
8819        let cfg = make_empty_cfg(vec![
8820            make_machine_with_capacity("us-south-001", 512, 1000, vec!["no-appliance"]),
8821            make_machine_with_capacity(
8822                "us-west-002",
8823                16384,
8824                8000,
8825                vec!["no-server", "no-appliance"],
8826            ),
8827            make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
8828        ]);
8829        let ws = appliance_spec_ws(256, 500);
8830        // Skips south (no-appliance) and west-002 (no-appliance), lands on west-001.
8831        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "us-west-001");
8832    }
8833
8834    #[test]
8835    fn w244_fleet_scenario_job_lands_on_west002_first() {
8836        use workload_spec::LifecycleArchetype;
8837        // Jobs should prefer (or at least land on) the job-only box.
8838        let cfg = make_empty_cfg(vec![
8839            make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
8840            make_machine_with_capacity(
8841                "us-west-002",
8842                16384,
8843                8000,
8844                vec!["no-server", "no-appliance"],
8845            ),
8846        ]);
8847        let mut ws = server_spec(256, 500);
8848        ws.archetype = Some(LifecycleArchetype::Job);
8849        // No fleet node declares `no-job`, so a Job is repelled by nothing;
8850        // west-001 comes first in declaration order (greedy, no preference),
8851        // which is the expected tie-break. Note this is *absence of a repel
8852        // key*, not toleration — no workload can tolerate a taint (W305).
8853        let picked = cfg.admit_workload(&ws).unwrap();
8854        // Both are eligible.
8855        assert!(
8856            picked.name == "us-west-001" || picked.name == "us-west-002",
8857            "job must land on an eligible node, got {}",
8858            picked.name
8859        );
8860    }
8861
8862    #[test]
8863    fn r569_f4_macos_node_taints_keep_cloud_critical_off_but_admit_build_jobs() {
8864        use workload_spec::LifecycleArchetype;
8865        // R569-F4: the headless M2 (us-west-015) joins the fleet as a
8866        // build-worker but must never take cloud-critical load. It carries the
8867        // same repel set as the x86 build-worker (`no-server, no-appliance` —
8868        // see .yah/infra/machines/us-west-015.toml). This pins that intent:
8869        // with a plain cloud node available beside the Mac, every
8870        // cloud-critical archetype lands on the cloud node and never the Mac;
8871        // build Jobs (the Mac's actual purpose) remain eligible on it.
8872        //
8873        // R742-T4: `no-voter` used to sit in this set and in the TOML. It was
8874        // never read here — there is no "voter" workload archetype — and
8875        // R569-F3's learner-only join is what actually keeps the box out of
8876        // quorum. It is now rejected by `yah cloud validate` as inert.
8877        let mac_taints = vec!["no-server", "no-appliance"];
8878        let fleet = || {
8879            make_empty_cfg(vec![
8880                make_machine_with_capacity("us-west-015", 24576, 8000, mac_taints.clone()),
8881                make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
8882            ])
8883        };
8884
8885        // A cloud-critical Server workload is repelled from the Mac and lands
8886        // on the untainted cloud node.
8887        let cfg = fleet();
8888        assert_eq!(
8889            cfg.admit_workload(&server_spec(256, 500)).unwrap().name,
8890            "us-west-001",
8891            "a Server workload must never land on the no-server Mac node"
8892        );
8893
8894        // Same for an Appliance (pinned/stateful cloud-critical) workload.
8895        let cfg = fleet();
8896        assert_eq!(
8897            cfg.admit_workload(&appliance_spec_ws(256, 500))
8898                .unwrap()
8899                .name,
8900            "us-west-001",
8901            "an Appliance workload must never land on the no-appliance Mac node"
8902        );
8903
8904        // Sharpest repulsion proof: with ONLY the Mac in the fleet, a
8905        // cloud-critical Server workload is rejected outright — the taint keeps
8906        // it off even when that means nowhere to run.
8907        let mac_only = make_empty_cfg(vec![make_machine_with_capacity(
8908            "us-west-015",
8909            24576,
8910            8000,
8911            mac_taints.clone(),
8912        )]);
8913        assert!(
8914            mac_only.admit_workload(&server_spec(256, 500)).is_err(),
8915            "a Server workload must be repelled from a Mac-only fleet, not admitted"
8916        );
8917
8918        // But the Mac's real job — build/forge workloads — IS admitted on it:
8919        // it tolerates every fleet taint (there is no `no-job`).
8920        let mut job = server_spec(256, 500);
8921        job.archetype = Some(LifecycleArchetype::Job);
8922        assert_eq!(
8923            mac_only.admit_workload(&job).unwrap().name,
8924            "us-west-015",
8925            "a build Job must still be admitted on the Mac build-worker"
8926        );
8927    }
8928
8929    // ─── R615-F1: linked infra sources (`.yah/infra/sources.toml`) ─────────
8930
8931    #[test]
8932    fn sources_load_is_empty_when_the_file_is_absent() {
8933        // "Every camp without linked infra has none" — which today is every
8934        // camp — must not be an error.
8935        let tmp = tempfile::TempDir::new().unwrap();
8936        let cfg = SourcesConfig::load(tmp.path()).unwrap();
8937        assert_eq!(cfg, SourcesConfig::default());
8938        assert!(cfg.source.is_empty());
8939        assert_eq!(cfg.schema_version, 1);
8940    }
8941
8942    #[test]
8943    fn sources_parses_a_path_kind_exactly_like_w274s_example() {
8944        let tmp = tempfile::TempDir::new().unwrap();
8945        std::fs::write(
8946            tmp.path().join("sources.toml"),
8947            r#"
8948schema_version = 1
8949
8950[[source]]
8951owner = "yah"
8952kind  = "path"
8953path  = "../yah"
8954mode  = "read-only"
8955"#,
8956        )
8957        .unwrap();
8958        let cfg = SourcesConfig::load(tmp.path()).unwrap();
8959        assert_eq!(cfg.source.len(), 1);
8960        let s = &cfg.source[0];
8961        assert_eq!(s.owner, "yah");
8962        assert_eq!(s.mode, SourceMode::ReadOnly);
8963        assert!(s.select.is_empty());
8964        match &s.kind {
8965            InfraSourceKind::Path { path } => assert_eq!(path, "../yah"),
8966            other => panic!("expected Path, got {other:?}"),
8967        }
8968    }
8969
8970    #[test]
8971    fn sources_parses_a_git_kind_reusing_gitsource_verbatim() {
8972        let tmp = tempfile::TempDir::new().unwrap();
8973        std::fs::write(
8974            tmp.path().join("sources.toml"),
8975            r#"
8976schema_version = 1
8977
8978[[source]]
8979owner  = "yah"
8980kind   = "git"
8981repo   = "git@github.com:yah-ai/infra.git"
8982ref    = "main"
8983subdir = "infra"
8984select = ["tag:cloud-runner"]
8985mode   = "read-only"
8986"#,
8987        )
8988        .unwrap();
8989        let cfg = SourcesConfig::load(tmp.path()).unwrap();
8990        assert_eq!(cfg.source.len(), 1);
8991        let s = &cfg.source[0];
8992        assert_eq!(s.select, vec!["tag:cloud-runner".to_string()]);
8993        match &s.kind {
8994            InfraSourceKind::Git(git) => {
8995                assert_eq!(git.repo, "git@github.com:yah-ai/infra.git");
8996                assert_eq!(git.r#ref, "main");
8997                assert_eq!(git.subdir.as_deref(), Some("infra"));
8998            }
8999            other => panic!("expected Git, got {other:?}"),
9000        }
9001    }
9002
9003    #[test]
9004    fn sources_mode_defaults_to_read_only_and_manage_is_explicit() {
9005        let tmp = tempfile::TempDir::new().unwrap();
9006        std::fs::write(
9007            tmp.path().join("sources.toml"),
9008            r#"
9009schema_version = 1
9010
9011[[source]]
9012owner = "a"
9013kind  = "path"
9014path  = "../a"
9015
9016[[source]]
9017owner = "b"
9018kind  = "path"
9019path  = "../b"
9020mode  = "manage"
9021"#,
9022        )
9023        .unwrap();
9024        let cfg = SourcesConfig::load(tmp.path()).unwrap();
9025        assert_eq!(cfg.source[0].mode, SourceMode::ReadOnly, "omitted mode = read-only");
9026        assert_eq!(cfg.source[1].mode, SourceMode::Manage);
9027    }
9028
9029    #[test]
9030    fn sources_preserves_declaration_order() {
9031        // Overlay order matters (R615-F2) when two sources name the same
9032        // machine — the list must round-trip in file order, not be reordered
9033        // by owner or kind.
9034        let tmp = tempfile::TempDir::new().unwrap();
9035        std::fs::write(
9036            tmp.path().join("sources.toml"),
9037            r#"
9038schema_version = 1
9039
9040[[source]]
9041owner = "second"
9042kind  = "path"
9043path  = "../second"
9044
9045[[source]]
9046owner = "first"
9047kind  = "path"
9048path  = "../first"
9049"#,
9050        )
9051        .unwrap();
9052        let cfg = SourcesConfig::load(tmp.path()).unwrap();
9053        let owners: Vec<&str> = cfg.source.iter().map(|s| s.owner.as_str()).collect();
9054        assert_eq!(owners, vec!["second", "first"]);
9055    }
9056
9057    #[test]
9058    fn sources_round_trips_through_serialize() {
9059        let cfg = SourcesConfig {
9060            schema_version: 1,
9061            source: vec![
9062                InfraSource {
9063                    owner: "yah".into(),
9064                    kind: InfraSourceKind::Path {
9065                        path: "../yah".into(),
9066                    },
9067                    mode: SourceMode::ReadOnly,
9068                    select: vec![],
9069                },
9070                InfraSource {
9071                    owner: "yah".into(),
9072                    kind: InfraSourceKind::Git(GitSource {
9073                        repo: "git@github.com:yah-ai/infra.git".into(),
9074                        r#ref: "main".into(),
9075                        subdir: Some("infra".into()),
9076                    }),
9077                    mode: SourceMode::Manage,
9078                    select: vec!["tag:cloud-runner".into()],
9079                },
9080            ],
9081        };
9082        let toml_str = toml::to_string_pretty(&cfg).unwrap();
9083        let reloaded: SourcesConfig = toml::from_str(&toml_str).unwrap();
9084        assert_eq!(reloaded, cfg, "round-trip through TOML must be lossless:\n{toml_str}");
9085    }
9086
9087    // ─── R615-F2: overlay loader in CloudConfig::load ───────────────────────
9088
9089    fn write_min_machine(dir: &Path, name: &str, extra_toml: &str) {
9090        std::fs::create_dir_all(dir).unwrap();
9091        // `extra_toml` supplies `mesh_tags` when the caller cares about it;
9092        // otherwise default to the empty list. Never hardcode `mesh_tags`
9093        // here as well as in `extra_toml` -- TOML rejects a duplicate key.
9094        let mesh_tags = if extra_toml.contains("mesh_tags") {
9095            String::new()
9096        } else {
9097            "mesh_tags = []\n".to_string()
9098        };
9099        std::fs::write(
9100            dir.join(format!("{name}.toml")),
9101            format!("name = \"{name}\"\nprovider = \"static\"\n{mesh_tags}{extra_toml}"),
9102        )
9103        .unwrap();
9104    }
9105
9106    fn write_min_provider(dir: &Path, id: &str) {
9107        std::fs::create_dir_all(dir).unwrap();
9108        std::fs::write(
9109            dir.join(format!("{id}.toml")),
9110            format!("schema_version = 1\nid = \"{id}\"\nkind = \"static\"\n"),
9111        )
9112        .unwrap();
9113    }
9114
9115    fn write_sources_toml(camp_root: &Path, body: &str) {
9116        let dir = camp_root.join(".yah/infra");
9117        std::fs::create_dir_all(&dir).unwrap();
9118        std::fs::write(dir.join("sources.toml"), body).unwrap();
9119    }
9120
9121    #[test]
9122    fn load_with_no_sources_toml_is_unchanged() {
9123        let tmp = tempfile::TempDir::new().unwrap();
9124        write_min_machine(&tmp.path().join(".yah/infra/machines"), "local-1", "");
9125        let cfg = CloudConfig::load(tmp.path()).unwrap();
9126        assert_eq!(cfg.machines.len(), 1);
9127        assert!(cfg.machine_origins.is_empty());
9128        assert!(cfg.provider_origins.is_empty());
9129    }
9130
9131    #[test]
9132    fn path_source_overlays_machines_and_providers_tagged_with_origin() {
9133        let camp = tempfile::TempDir::new().unwrap();
9134        let other = tempfile::TempDir::new().unwrap();
9135        write_min_machine(&other.path().join(".yah/infra/machines"), "borrowed-1", "");
9136        write_min_provider(&other.path().join(".yah/infra/providers"), "borrowed-provider");
9137        write_sources_toml(
9138            camp.path(),
9139            &format!(
9140                "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
9141                other.path().display()
9142            ),
9143        );
9144
9145        let cfg = CloudConfig::load(camp.path()).unwrap();
9146        assert_eq!(cfg.machines.len(), 1);
9147        assert_eq!(cfg.machines[0].name, "borrowed-1");
9148        assert_eq!(cfg.providers.len(), 1);
9149        assert_eq!(cfg.providers[0].id, "borrowed-provider");
9150
9151        let origin = cfg.machine_origins.get("borrowed-1").expect("origin recorded");
9152        assert_eq!(origin.owner, "other");
9153        assert_eq!(origin.mode, SourceMode::ReadOnly);
9154        assert!(origin.source.starts_with("path:"));
9155        assert_eq!(
9156            cfg.provider_origins.get("borrowed-provider").unwrap().owner,
9157            "other"
9158        );
9159    }
9160
9161    #[test]
9162    fn camp_local_wins_on_name_collision_and_carries_no_origin() {
9163        let camp = tempfile::TempDir::new().unwrap();
9164        let other = tempfile::TempDir::new().unwrap();
9165        // Both declare a machine named "shared" -- camp-local's copy must win,
9166        // and it must never gain an origin tag.
9167        write_min_machine(&camp.path().join(".yah/infra/machines"), "shared", "");
9168        write_min_machine(
9169            &other.path().join(".yah/infra/machines"),
9170            "shared",
9171            "nickname = \"the borrowed one\"\n",
9172        );
9173        write_sources_toml(
9174            camp.path(),
9175            &format!(
9176                "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
9177                other.path().display()
9178            ),
9179        );
9180
9181        let cfg = CloudConfig::load(camp.path()).unwrap();
9182        assert_eq!(cfg.machines.len(), 1, "the name collides, so exactly one entry");
9183        assert_eq!(cfg.machines[0].nickname, None, "camp-local's copy, not the borrowed one");
9184        assert!(
9185            !cfg.machine_origins.contains_key("shared"),
9186            "camp-local entries never carry an origin tag"
9187        );
9188    }
9189
9190    #[test]
9191    fn an_earlier_source_wins_over_a_later_one_on_collision() {
9192        let camp = tempfile::TempDir::new().unwrap();
9193        let first = tempfile::TempDir::new().unwrap();
9194        let second = tempfile::TempDir::new().unwrap();
9195        write_min_machine(&first.path().join(".yah/infra/machines"), "dup", "");
9196        write_min_machine(&second.path().join(".yah/infra/machines"), "dup", "");
9197        write_sources_toml(
9198            camp.path(),
9199            &format!(
9200                "schema_version = 1\n\n[[source]]\nowner = \"first\"\nkind = \"path\"\npath = \"{}\"\n\n[[source]]\nowner = \"second\"\nkind = \"path\"\npath = \"{}\"\n",
9201                first.path().display(),
9202                second.path().display()
9203            ),
9204        );
9205
9206        let cfg = CloudConfig::load(camp.path()).unwrap();
9207        assert_eq!(cfg.machines.len(), 1);
9208        assert_eq!(cfg.machine_origins.get("dup").unwrap().owner, "first");
9209    }
9210
9211    #[test]
9212    fn select_filters_borrowed_machines_by_name_or_mesh_tag() {
9213        let camp = tempfile::TempDir::new().unwrap();
9214        let other = tempfile::TempDir::new().unwrap();
9215        write_min_machine(&other.path().join(".yah/infra/machines"), "runner-1", "mesh_tags = [\"tag:cloud-runner\"]\n");
9216        write_min_machine(&other.path().join(".yah/infra/machines"), "excluded-1", "");
9217        write_sources_toml(
9218            camp.path(),
9219            &format!(
9220                "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\nselect = [\"tag:cloud-runner\"]\n",
9221                other.path().display()
9222            ),
9223        );
9224
9225        let cfg = CloudConfig::load(camp.path()).unwrap();
9226        assert_eq!(cfg.machines.len(), 1);
9227        assert_eq!(cfg.machines[0].name, "runner-1");
9228    }
9229
9230    #[test]
9231    fn one_unparseable_foreign_machine_does_not_sink_the_rest_of_the_directory_or_the_load() {
9232        let camp = tempfile::TempDir::new().unwrap();
9233        let other = tempfile::TempDir::new().unwrap();
9234        let dir = other.path().join(".yah/infra/machines");
9235        write_min_machine(&dir, "good", "");
9236        // Schema-skew gotcha: a foreign machine this binary's MachineConfig
9237        // can't parse at all (not just an unknown field -- MachineConfig has
9238        // no deny_unknown_fields, so this has to fail on a TYPE, not a name).
9239        std::fs::write(dir.join("bad.toml"), "name = 1\nprovider = 2\n").unwrap();
9240        write_sources_toml(
9241            camp.path(),
9242            &format!(
9243                "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
9244                other.path().display()
9245            ),
9246        );
9247
9248        // Must not error at all -- camp-local load must never fail because a
9249        // source it doesn't own has one bad file.
9250        let cfg = CloudConfig::load(camp.path()).unwrap();
9251        assert_eq!(cfg.machines.len(), 1, "the good entry still loads");
9252        assert_eq!(cfg.machines[0].name, "good");
9253    }
9254
9255    #[test]
9256    fn an_unsynced_git_source_overlays_nothing_and_is_not_an_error() {
9257        // No `yah infra sync` (R615-T3) has ever run, so the cache dir this
9258        // resolves to doesn't exist. Must be silent, not fatal.
9259        let camp = tempfile::TempDir::new().unwrap();
9260        write_sources_toml(
9261            camp.path(),
9262            "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\n",
9263        );
9264        let cfg = CloudConfig::load(camp.path()).unwrap();
9265        assert!(cfg.machines.is_empty());
9266        assert!(cfg.machine_origins.is_empty());
9267    }
9268
9269    #[test]
9270    fn a_synced_git_source_reads_from_the_cache_dir_not_the_repo_path() {
9271        // No `subdir` declared -- the checkout ROOT is the infra root.
9272        let camp = tempfile::TempDir::new().unwrap();
9273        let cache = crate::paths::infra_source_cache_dir(camp.path(), "yah");
9274        write_min_machine(&cache.join("machines"), "synced-1", "");
9275        write_sources_toml(
9276            camp.path(),
9277            "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\n",
9278        );
9279        let cfg = CloudConfig::load(camp.path()).unwrap();
9280        assert_eq!(cfg.machines.len(), 1);
9281        assert_eq!(cfg.machines[0].name, "synced-1");
9282        assert!(cfg.machine_origins.get("synced-1").unwrap().source.starts_with("git:"));
9283    }
9284
9285    #[test]
9286    fn a_git_sources_subdir_is_honoured_like_the_component_case() {
9287        // W274's own example declares `subdir = "infra"` for a monorepo whose
9288        // registry lives under a subdirectory of the clone rather than at its
9289        // root -- prove `infra_root` actually reads it, not just `.subdir` on
9290        // GitSource parsing (R615-F1 already covers that half).
9291        let camp = tempfile::TempDir::new().unwrap();
9292        let cache = crate::paths::infra_source_cache_dir(camp.path(), "yah");
9293        write_min_machine(&cache.join("infra").join("machines"), "subdir-1", "");
9294        // Also plant a decoy at the checkout root to prove the root itself is
9295        // NOT read when a subdir is declared.
9296        write_min_machine(&cache.join("machines"), "root-decoy", "");
9297        write_sources_toml(
9298            camp.path(),
9299            "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\nsubdir = \"infra\"\n",
9300        );
9301        let cfg = CloudConfig::load(camp.path()).unwrap();
9302        assert_eq!(cfg.machines.len(), 1);
9303        assert_eq!(cfg.machines[0].name, "subdir-1");
9304    }
9305
9306    #[test]
9307    fn load_from_config_dir_never_applies_sources_overlay() {
9308        // R615-F2's explicit decision: multi-root sibling trees don't inherit
9309        // the classic .yah/infra/sources.toml. Prove it rather than assert it
9310        // silently -- a sources.toml sitting at workspace_root/.yah/infra/
9311        // must NOT leak into a load_from_config_dir call even though both
9312        // share the same workspace_root.
9313        let camp = tempfile::TempDir::new().unwrap();
9314        let other = tempfile::TempDir::new().unwrap();
9315        write_min_machine(&other.path().join(".yah/infra/machines"), "borrowed-1", "");
9316        write_sources_toml(
9317            camp.path(),
9318            &format!(
9319                "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
9320                other.path().display()
9321            ),
9322        );
9323        let sibling_config_dir = camp.path().join(".noisetable");
9324        std::fs::create_dir_all(&sibling_config_dir).unwrap();
9325
9326        let cfg = CloudConfig::load_from_config_dir(&sibling_config_dir, camp.path()).unwrap();
9327        assert!(cfg.machines.is_empty(), "sources.toml must not apply here");
9328        assert!(cfg.machine_origins.is_empty());
9329    }
9330
9331    // ─── R615-T5: `inherit_machines` retirement — cutover proof ────────────
9332
9333    /// The successor to R615-T5's parity proof. That earlier pair of tests
9334    /// asserted the legacy `[infra].inherit_machines` redirect and an
9335    /// equivalent `kind = "path"` source resolved the same machine set, and
9336    /// that the two coexisted without duplicating rows. Both claims were about
9337    /// a mechanism that no longer exists, so they retired with it — what has
9338    /// to hold *now* is the other half of the same guarantee: a camp that
9339    /// declares only `sources.toml` resolves the shared root exactly as the
9340    /// redirect used to, and a stale `inherit_machines` key left behind in
9341    /// `camp.toml` changes nothing.
9342    ///
9343    /// That stale-key case is not hypothetical: it is precisely the state a
9344    /// camp is in between the code cutover and someone tidying its
9345    /// `camp.toml`, and a silent re-resolution there would double-count the
9346    /// borrowed nodes or hide their origin badge.
9347    #[test]
9348    fn a_stale_inherit_machines_key_does_not_change_what_sources_toml_resolves() {
9349        let shared = tempfile::TempDir::new().unwrap();
9350        write_min_machine(&shared.path().join(".yah/infra/machines"), "shared-node-1", "");
9351        write_min_machine(&shared.path().join(".yah/infra/machines"), "shared-node-2", "");
9352
9353        let sources_toml = format!(
9354            "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"path\"\npath = \"{}\"\nmode = \"read-only\"\n",
9355            shared.path().display()
9356        );
9357
9358        // Camp A: migrated cleanly — sources.toml only.
9359        let clean = tempfile::TempDir::new().unwrap();
9360        write_sources_toml(clean.path(), &sources_toml);
9361
9362        // Camp B: mid-migration — same source, plus the retired key still
9363        // sitting in camp.toml pointing at the same root.
9364        let stale = tempfile::TempDir::new().unwrap();
9365        std::fs::create_dir_all(stale.path().join(".yah")).unwrap();
9366        std::fs::write(
9367            stale.path().join(".yah/camp.toml"),
9368            format!(
9369                "[infra]\ninherit_machines = \"{}\"\n",
9370                shared.path().display()
9371            ),
9372        )
9373        .unwrap();
9374        write_sources_toml(stale.path(), &sources_toml);
9375
9376        let via_clean = CloudConfig::load(clean.path()).unwrap();
9377        let via_stale = CloudConfig::load(stale.path()).unwrap();
9378
9379        let names = |cfg: &CloudConfig| {
9380            let mut v: Vec<String> = cfg.machines.iter().map(|m| m.name.clone()).collect();
9381            v.sort();
9382            v
9383        };
9384        assert_eq!(
9385            names(&via_clean),
9386            names(&via_stale),
9387            "a leftover inherit_machines key must be inert — the retired redirect is gone"
9388        );
9389        assert_eq!(names(&via_clean), vec!["shared-node-1", "shared-node-2"]);
9390
9391        // And both are *borrowed*, not camp-local. This is the operator-facing
9392        // win the stopgap could never deliver: under the old redirect these
9393        // resolved with no origin at all, indistinguishable from locally-owned
9394        // nodes.
9395        assert_eq!(via_clean.machine_origins.len(), 2);
9396        assert_eq!(via_stale.machine_origins.len(), 2);
9397        for origin in via_stale.machine_origins.values() {
9398            assert_eq!(origin.owner, "yah");
9399            assert_eq!(origin.mode, SourceMode::ReadOnly);
9400        }
9401    }
9402}