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, Locality, 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///
201/// @yah:ticket(R860-T5, "Model per-node native-exec capability as an admission axis (W338 §Placement consequences 3 / R858-T4 gap)")
202/// @yah:status(review)
203/// @yah:phase(P1)
204/// @yah:at(2026-09-05T18:29:19Z)
205/// @yah:assignee(agent:bundle-anthropic-ashguard)
206/// @yah:parent(R860)
207/// @yah:next("Cheapest defensible shape: express it on MachineConfig, which already has the two vocabularies — `mesh_tags: Vec<String>` (config.rs:246, superset match, already carries `arch:`/`os:`/`tag:build-worker`) and `taints: Vec<String>` (config.rs:337). A `native-exec` mesh tag required by any group member whose kind is native is a one-line admission axis in `admission_spec()`. Whichever is chosen, it must be declared in .yah/infra/machines/*.toml for the nodes that actually run kamaji with --native-exec-dir, and `check_inert_taints` (config.rs:703) lints unread taint keys dead — so a taint nobody reads will be flagged.")
208/// @yah:verify("cargo test -p cloud --lib config")
209/// @arch:see(.yah/docs/working/W338-workload-dependencies-and-appliance-composition.md)
210/// @yah:depends_on(R860-T4)
211/// @yah:gotcha("Verified 2026-09-04: native-exec capability is modelled NOWHERE in placement — `rg \"native\" oss/yubaba/crates/cloud/src/config.rs` returns zero hits, and the raft state machine models no member attributes, labels or taints at all (`rg \"taint|capabilit|labels|mesh_tag\"` over raft/{mod,store,network}.rs yields one unrelated comment at raft/store.rs:591). Native-exec is a node-local kamaji startup decision today: `--native-exec-dir` (oss/kamaji/crates/kamaji-bin/src/main.rs:152-156, :51-55) plus the `native-exec` cargo feature (kamaji-bin/src/server.rs:329-330). A node without it refuses the deploy at dispatch time and nothing upstream can see that in advance — which is exactly the deploy-time surprise W338 wants turned into a placement precondition.")
212/// @yah:handoff("NATIVE-EXEC IS NOW A PLACEMENT PRECONDITION, NOT A DISPATCH-TIME SURPRISE. New `pub const NATIVE_EXEC_MESH_TAG: &str = \"cap:native-exec\"` in oss/yubaba/crates/cloud/src/config.rs (declared just above `node_selector_mesh_tags`), and one axis in `admission_spec()` immediately after the R860-T4 group loop: if ANY member of `placement_group(ws, declared)` returns true from `WorkloadSpec::wants_native_exec()`, the tag is appended to the derived `RequiredSpec.mesh_tags` (deduped). No new field on `RequiredSpec`, no signature change anywhere, no wire or serde change — the mesh_tags axis is already an AND-ed superset check against `machine.mesh_tags` in `matches` and is already rendered by `describe`, so a refusal now reads `required.mesh_tags=[...,cap:native-exec]`.")
213/// @yah:handoff("ITEM 1 — HOW A NATIVE WORKLOAD IS DETECTED, settled by opening the type rather than guessing. There is no `kind` on `WorkloadSpec`: on the wire a native workload is still `Workload::Container(WorkloadSpec)`, and the ONLY difference is the annotation `yah.exec = native`, read through `WorkloadSpec::wants_native_exec()` (oss/yah-base/crates/workload-spec/src/lib.rs:2939; consts `NATIVE_EXEC_ANNOTATION` / `NATIVE_EXEC_VALUE` at :3402/:3407). That accessor is what the admission axis calls — matching kamaji, whose `deploy_container` checks the same marker first and routes to `deploy_native_exec` (oss/kamaji/crates/kamaji-bin/src/server.rs). The `yah.exec` key is a substrate selector with a second value, `microvm` (`wants_microvm`, same key, R605-F8), so per-node microVM capability is the obvious sibling axis and is NOT modelled here — see next-steps.")
214/// @yah:handoff("ITEM 2 — DECLARATIONS LANDED ON TWO NODES, FROM READINGS RECORDED IN-REPO, NOT INFERRED. `cap:native-exec` added to `mesh_tags` in .yah/infra/machines/us-west-001.toml and .yah/infra/machines/us-west-003.toml, each with a comment naming its evidence and its re-check condition. us-west-001: the R858 gotcha in its own header records a `ps` reading taken on the box 2026-09-05 — pid 515908 is `/usr/local/bin/kamaji --native-exec-dir /var/lib/yah/kamaji/native`, supervising headscale as a native child. us-west-003: its header's 'THE DEPLOYED KAMAJI PREDATES THE microVM BACKEND' note quotes the box's actual ExecStart, read over ssh 2026-09-01, carrying `--native-exec-dir /var/lib/yah/kamaji/native` (corroborated by .yah/docs/architecture/A043-yah-on-machine-daemons.md's @yah:verify for the same probe). Both comments say plainly that the capability lives in the systemd unit's ExecStart, not in the TOML, so it must be re-checked after any roll.")
215/// @yah:handoff("ITEM 2, THE NEGATIVES — TWO NODES ARE KNOWN NOT TO HAVE IT AND WERE DELIBERATELY LEFT UNSET. us-south-001: kamaji refused headscale there 2026-09-03 with 'native backend not configured — start kamaji with --native-exec-dir' (the R858 chain, quoted in .yah/infra/machines/us-west-001.toml and W267). I did NOT edit us-south-001.toml — it was already dirty in the working tree at the anchor SHA and @Ashguard:eclipse is live on R858, so I left it alone rather than race it; the mechanism fails closed there, which is the correct state. us-west-015 (the sole darwin builder): W254-darwin-build-nodes.md's own next-step records that its kamaji is built/started `--docker` only. I added a comment to us-west-015.toml explaining that the tag is deliberately absent, that this is the node where the axis changes an error message (a darwin build row is native by construction, so it is now refused at ELECTION naming cap:native-exec instead of reaching the box and being refused by kamaji), and the exact enable sequence: rebuild with `--features native-exec`, restart with `--native-exec-dir <dir>`, THEN add the tag. us-west-002/011/013/014 are unestablished from the repo and left unset. THE OPERATOR-FACING ANSWER: the file is `.yah/infra/machines/<node>.toml` and the key is `mesh_tags`; add the literal string `cap:native-exec` to that array, and only after the roll.")
216/// @yah:handoff("DECISIONS THE BRIEF LEFT OPEN, all recorded in doc comments at the site. (1) MESH TAG, NOT TAINT — as recommended, and the doc says why in the terms the brief asked for: mesh tags are positive capability with superset matching ('this node CAN'), which is the claim being made; a taint is repulsion and would have to be inverted to `no-native-exec` on every node LACKING the backend (declaration burden on the majority, and silently wrong for a node nobody has edited) AND taught to `taint_effect`, or `check_inert_taints` would correctly lint the key dead. (2) THE `cap:` NAMESPACE IS NEW. Live prefixes are `tag:` (operator-assigned role), `arch:`/`os:` (silicon and userland facts, emitted as requirements by qed::platform::build_worker_mesh_tags), and `tier:` which R763 RETIRED for architecture and reserved for the environment axis — so reusing any of them would have stated the wrong kind of fact. A capability the daemon was configured with is none of those. Nothing validates tag prefixes (only `check_retired_arch_tags` looks at one), so this costs no wiring. (3) COMPUTED OVER THE GROUP, not the requirer — that is literally W338's sentence ('supply = self specs must be placeable where their requirer lands'), and the second test proves it: an ordinary container requirer with a `local` edge to a native provider is pulled onto a capable node. (4) FAILS CLOSED, accepted deliberately: an undeclared node is simply not a candidate, so an undeclared fleet reports 'no node admits' at election rather than dispatching to a node that refuses. Nothing in `.yah/infra/workloads/` is native-marked today (only yah-cloud-admin.toml exists there), so the only live consumer is the qed darwin build row, where failing closed is strictly the better error.")
217/// @yah:handoff("BLAST RADIUS, MEASURED. `admission_spec` is private and its callers are unchanged: `admit_workload` / `admit_workload_candidates` / `admit_workload_in_group` (config.rs), reached from app/yah/cli/src/cloud.rs (deploy, rolling, topology analyzer), app/yah/cli/src/yubaba_client.rs `elect_node`, and cloud/src/migrate.rs. The headscale appliance path inside yubaba (headscale_appliance.rs) does NOT go through admission — it is node-internal — so nothing eclipse holds on R858 is touched by this. Files edited, in full: oss/yubaba/crates/cloud/src/config.rs; .yah/infra/machines/{us-west-001,us-west-003,us-west-015}.toml. Nothing in oss/yubaba/crates/yubaba/ was opened, and oss/kamaji/crates/kamaji-bin/src/server.rs was READ ONLY (to confirm the marker check), per @Ashguard:hydra's contention triage.")
218/// @yah:handoff("ONE SCOPE ADDITION, stated loudly rather than slipped in: `MachineConfig::mesh_tags` (config.rs:256) had NO doc comment at all — the operator-facing declaration key for four tag namespaces was undocumented. I gave it one enumerating `tag:` / `arch:`+`os:` / the new `cap:` / retired `tier:`, and noting that nothing validates the prefix (which is why the two lints exist). CONSEQUENCE TO KNOW: that field's doc is the source of the `mesh_tags` description in the GENERATED .yah/schema/machine.toml.schema.json, so it is schema-drift-affecting — see the gotcha.")
219/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I found it and left it. Quote this SHA rather than 'HEAD' in any revert/restore instruction; to undo a hunk, read it with `git show 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2:<path>` and put it back with Edit, never `git checkout`/`restore` (they restore whole files and would delete peers' uncommitted work).")
220/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I left it. Diff against it (`git diff 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
221/// @yah:next("MICROVM IS THE IDENTICAL UNMODELLED GAP, one line away. `yah.exec` is a substrate selector with a second value: `WorkloadSpec::wants_microvm()` (workload-spec/src/lib.rs, R605-F8), and kamaji constructs MicroVmRuntime only when started with `--microvm-dir` — A043's probe records that us-west-003's deployed kamaji has `--native-exec-dir` but NOT `--microvm-dir`, so a microvm-marked deploy is refused there by exactly the same dispatch-time surprise this ticket removed for native. The shape is `cap:microvm` alongside NATIVE_EXEC_MESH_TAG in the same `if` in `admission_spec`. Not done here because no node in the fleet can host one yet (R605-F14 must land a guest kernel + rootfs first), so declaring the tag anywhere today would be the wrong fact.")
222/// @yah:next("us-south-001 needs `cap:native-exec` DECIDED, not defaulted, and it is the R858 node. It is the one machine the repo positively records as LACKING the backend (kamaji refused headscale there 2026-09-03), so leaving the tag off is correct TODAY — but if R858's fix is 'give us-south-001 a native-capable kamaji' rather than 'stop moving headscale', then the roll and the tag must land together, in that order. I left .yah/infra/machines/us-south-001.toml untouched because it was already dirty at anchor 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 and @Ashguard:eclipse is live on R858.")
223/// @yah:next("R860-T6 (`supply = \"self\"` provisioning) inherits this for free — `admission_spec` already requires the capability of the whole group, so a self-provisioned native member cannot be elected onto a node that cannot run it. What T6 must still not do is re-elect per member: reuse the node URL `elect_node` returned for the requirer, per R860-T4's handoff.")
224/// @yah:verify("BASELINE RECORDED BEFORE EDITING, at tree anchor 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2: `cargo test -p yah-cloud --lib` from oss/yubaba = 1090 passed / 0 failed / 4 ignored, exit 0 — exactly the count the brief predicted. AFTER: 1093 passed / 0 failed / 4 ignored, exit 0 (+3, exactly the three tests added). `cargo check -p yah-cloud --all-targets` exit 0 and `cargo check -p yubaba --all-targets` exit 0 (yubaba consumes cloud, so it is where any signature change would surface — there is none). Every exit code echoed explicitly via an `EXIT=$?` / `${PIPESTATUS[0]}` marker and read back, never inferred from an empty grep. The four `yah-cloud` warnings are all pre-existing and in other files (object-store r2.rs, reconciler/mesofact_static.rs unused imports, app_manifest.rs, reconciler/mod.rs non_snake_case); config.rs contributes none.")
225/// @yah:verify("NEW TESTS (config.rs `mod tests`, R860-T5 section at the end, after the R860-T4 block). (1) a_node_without_the_native_exec_capability_cannot_host_a_native_workload — a `yah.exec = native` spec is refused by a bare node with an error naming `cap:native-exec`, and admitted by a node declaring it, with both nodes in the same fleet so the choice is provably the tag. (2) a_local_edge_to_a_native_provider_makes_the_requirer_need_the_capability — an ordinary container requirer (asserted `!wants_native_exec()`) with a `local` edge to a native provider lands on the capable node, while the SAME spec without the edge still lands on the plain one, so the constraint provably comes from the group. (3) a_group_with_no_native_member_does_not_require_the_capability — the regression guard: the axis is absent from `admission_spec`'s mesh_tags and a group with a local edge between two ordinary specs still admits on a node declaring nothing. Helper `native_spec()` asserts the marker reads back through `wants_native_exec()` before the test uses it, so a typo cannot make the test pass vacuously.")
226/// @yah:verify("Machine-config lints were considered and are unaffected by construction: `check_inert_taints` reads `taints` (I touched none), and `check_retired_arch_tags` flags only the `tier:` prefix. `cap:` is a new namespace and nothing validates prefixes, so no lint fires and no lint needs teaching.")
227/// @yah:gotcha("SCHEMA DRIFT IS EXPECTED FROM THIS TICKET AND WAS ALREADY RED BEFORE IT. `.yah/schema/machine.toml.schema.json` is generated from `cloud::config` by `cargo run -p xtask -- emit-schemas`, and MachineConfig's DOC COMMENT is what the generator emits as its `description` — which means (a) my new `mesh_tags` doc changes it, and (b) so does this very handoff, because R860-T5's @yah: annotation block lives inside MachineConfig's doc at config.rs:201. That file was ALSO already dirty in the working tree at anchor 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2, before I touched anything — `scripts/check-schema-drift.sh` compares the regenerated tree against git, so it is red for any uncommitted schema edit regardless of author. Regenerate with `cargo run -p xtask -- emit-schemas` (or `scripts/check-schema-drift.sh --update`) when the root target dir is not contended; the pre-commit hook no longer does it (disabled 2026-08-15, see CLAUDE.md).")
228/// @yah:verify("SCHEMA REGENERATED IN THIS SESSION, so the drift gate is not left for the next reader: `cargo run --quiet -p xtask -- emit-schemas` exit 0, run from the repo root after the handoff was written (so it captures the annotation text too). Two files moved. `.yah/schema/machine.toml.schema.json`: MachineConfig's `description` grows by this ticket's annotation block, plus a genuinely new `mesh_tags.description` from the doc comment I added. `.yah/schema/workload.toml.schema.json`: +104 lines that are NOT mine — the `Locality` / `Requirement` / `Supply` / `WorkloadSpec.requires` types R860-T1 landed had never been emitted, so the sibling ticket's schema drift was still outstanding and my regen swept it in. Derived artifacts are not ownable (shared-tree doctrine), so this is deliberate rather than accidental; @Ashguard, whoever picks up R860-T1's review should know the schema now describes `requires`.")
229/// @yah:verify("FINAL RE-RUN AFTER THE HANDOFF ANNOTATION WAS WRITTEN INTO config.rs (the board write edits MachineConfig's doc block, so the file changed under the earlier green): `cargo test -p yah-cloud --lib` = 1093 passed / 0 failed / 4 ignored, exit 0. Unchanged. Note for anyone reading the camp build rail's skew warnings on this session: the one `SUSPECT RESULT` it emitted names `oss/yubaba/crates/cloud/src/config.rs` as modified mid-run, and that modification was MY OWN board_handoff annotation write, not a peer — the two authoritative runs (full lib test, and both cargo checks) each came back `Input closure unchanged across the whole run: no skew`.")
230/// @yah:verify("All builds were run with `CARGO_TARGET_DIR=/tmp/r860t5-target` rather than the shared oss/yubaba/target, following R860-T4's recorded gotcha — a peer (session:83093d9d) held the shared target lock for the entire session (20+ minutes of `cargo check -p yubaba --lib`). Costs one cold dep build, then every subsequent run is seconds. Worth reaching for immediately when the queue message says you are behind someone.")
231/// @yah:handoff("LEADER RE-VERIFIED (session:69b18855, independent of the courier's self-report). `cargo test -p yah-cloud --lib` from oss/yubaba: 1093 passed / 0 failed / 4 ignored, exit 0, against the 1090/0/4 baseline this relay's own T4 established — +3 = exactly its new tests. Axis confirmed by content: `NATIVE_EXEC_MESH_TAG = \"cap:native-exec\"` at config.rs:2304, appended to the derived `RequiredSpec.mesh_tags` at :2112-2114 when any `placement_group` member returns true from `WorkloadSpec::wants_native_exec()`. No new `RequiredSpec` field, no signature change, no wire change — it rides the existing AND-ed superset check, so a refusal now reads `required.mesh_tags=[...,cap:native-exec]`.")
232/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I left it. Diff against it (`git diff 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
233/// @yah:verify("MACHINE DECLARATIONS AUDITED FOR PROVENANCE, because a wrong capability declaration is worse than an absent one. Both are traceable to measurements ALREADY RECORDED IN-REPO, not inferred: us-west-001 from the `ps` reading at us-west-001.toml:21 (pid 517125, ppid 515908 = `/usr/local/bin/kamaji --native-exec-dir /var/lib/yah/kamaji/native`, cgroup `0::/yubaba.slice/kamaji.service/native`, 2026-09-05); us-west-003 from the actual ExecStart read over ssh 2026-09-01 at us-west-003.toml:141. us-west-015 was deliberately left WITHOUT the tag and carries enable instructions at :207-217 — unknown fails closed, which is the correct direction. No node was guessed at and nothing was probed live.")
234/// @yah:handoff("THIS TICKET MODELS THE EXACT DRIFT THAT CAUSED THE 25-HOUR MESH OUTAGE, which is worth stating because it turns an abstract W338 bullet into a measured one. us-west-001.toml:8 records the root-cause chain: on 2026-09-03T06:03:03Z leadership moved to us-south-001, which tried to deploy headscale and kamaji refused — \\\"workload requests native host execution (yah.exec=native) but no native backend is available (native backend not configured — start kamaji with --native-exec-dir)\\\" — then the systemd fallback failed too, both at WARN, and the mesh had no coordination server for 25 hours. us-west-001.toml:10 names it explicitly as \\\"a silent per-node capability drift that placement does not model\\\". After this ticket, placement models it: a group needing native exec can no longer be admitted onto a node that has not declared `cap:native-exec`.")
235/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I left it. Diff against it (`git diff 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
236/// @yah:verify("RE-VERIFIED AT HEAD 00ee20d1 (session:aa5e882d, 2026-09-05). `NATIVE_EXEC_MESH_TAG` present in oss/yubaba/crates/cloud/src/config.rs (declared above `node_selector_mesh_tags`, appended to the derived `RequiredSpec.mesh_tags` when any `placement_group` member wants native exec). `cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib` = 1141 passed / 0 failed / 4 ignored, exit 0.")
237/// @yah:cleanup("cap:microvm remains the identical unmodelled axis, one line from done in the same `if` in `admission_spec`. Deliberately NOT taken: no node in the fleet can host a microvm until R605-F14 lands a guest kernel + rootfs, so declaring the tag today would assert a false fact. Do it when R605-F14 lands, not before.")
238#[derive(Debug, Clone, Serialize, Deserialize)]
239#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
240pub struct MachineConfig {
241    pub name: String,
242    pub provider: String,
243    /// Who the hardware actually comes from (`"ovh"`, `"vultr"`, `"on-prem"`).
244    ///
245    /// Deliberately *not* [`provider`](Self::provider), which selects the
246    /// auto-provision driver: a box we rented by hand and brought up over SSH
247    /// is `provider = "static"` for its whole life, and writing the vendor
248    /// there instead would flip it driver-backed and make
249    /// [`validate`](Self::validate) demand `location` + `server_type` it has no
250    /// answer for. The two axes genuinely differ — vendor is who bills you,
251    /// `provider` is who yah can call an API against.
252    ///
253    /// Worth recording because vendor-scoped policy is invisible in every other
254    /// field and decides real work: outbound port 25, rDNS/PTR control, IP
255    /// reputation, egress billing. It survived only in TOML prose until now,
256    /// which made it ungreppable at exactly the moment you need it.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub vendor: Option<String>,
259    /// Human label for the box (`"gamer"`, `"the GEEKOM"`). Free-form and never
260    /// matched on — [`name`](Self::name) stays the identity everywhere. This is
261    /// only so operators and agents can say which box they mean out loud.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub nickname: Option<String>,
264    /// Provider DC code (e.g. Hetzner `"hil"`). **Provisioning-only**: required
265    /// iff the provider has an auto-provision driver ([`provider_has_machine_driver`]);
266    /// a BYO `static` node we brought up over SSH has no such code. Optional at
267    /// load time so static machine.tomls omit it; [`MachineConfig::validate`]
268    /// enforces presence at the right moment for driver-backed providers.
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub location: Option<String>,
271    /// Provider SKU/size (e.g. Hetzner `"ccx13"`). Provisioning-only, same
272    /// optionality contract as [`location`](Self::location).
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub server_type: Option<String>,
275    /// **Deprecated (R330-F16).** A machine should describe *itself* (region,
276    /// zone, provider, mesh_tags); *which* mirrors run on it is derived by the
277    /// reconciler from each mirror's `required` placement spec, not declared
278    /// here. Now optional + omitted-when-empty so new machine.tomls leave it
279    /// out. The legacy `resolve_mirror_machine` topology fallback still reads
280    /// it until yubaba's reverse-index supersedes the topology.toml path; once
281    /// that lands, this field and its readers are removed wholesale.
282    #[serde(default, skip_serializing_if = "Vec::is_empty")]
283    pub hosts_mirrors: Vec<String>,
284    /// Positive placement facts about this node, matched as a **superset**:
285    /// a workload is admitted only where every tag it requires is present, so
286    /// adding a tag can only ever make a machine match more, never fewer.
287    ///
288    /// Four namespaces are live, and they are not interchangeable:
289    /// - `tag:<role>` — a role the operator assigns (`tag:build-worker`,
290    ///   `tag:qed`, `tag:cloud-runner`, `tag:mac-builder`);
291    /// - `arch:<x86|arm>` / `os:<linux|darwin>` — facts about the silicon and
292    ///   userland, emitted as *requirements* by
293    ///   [`qed::platform::build_worker_mesh_tags`];
294    /// - `cap:<capability>` — something the node's daemons were configured to
295    ///   be able to do. Today just [`NATIVE_EXEC_MESH_TAG`] (R860-T5);
296    /// - `tier:` is **retired** for architecture (R763) and reserved for the
297    ///   environment axis — [`crate::validate::check_retired_arch_tags`]
298    ///   flags a machine still carrying `tier:<arch>`.
299    ///
300    /// Nothing validates the prefix, which is why the lint above exists: a tag
301    /// nobody requires is silently inert, and a *stale* one silently stops
302    /// matching and reports "no node" rather than "wrong tag".
303    pub mesh_tags: Vec<String>,
304    /// Canonical geo region label (latency axis), e.g. `"us-west"`. F16's three
305    /// topology axes are orthogonal: `region` = geo (latency), `zone` = failure
306    /// domain within a region (HA), `provider` = network/cost. `region` is
307    /// distinct from `location` (the provider's DC code, e.g. Hetzner `"hil"`):
308    /// `location` is provider-scoped, `region` is our provider-neutral label.
309    /// Optional for backward-compat; a machine without it never satisfies a
310    /// `required.regions` constraint.
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub region: Option<String>,
313    /// Failure-domain label within a region (HA axis), e.g. `"hil"`. For
314    /// single-DC Hetzner this typically mirrors `location`. F16 placement
315    /// matches `required.zones` against this. Optional for backward-compat.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub zone: Option<String>,
318    /// Declared CPU architecture (`"x86_64"` / `"aarch64"`). A machine has
319    /// exactly one — it's a first-class property of the box, not a reach
320    /// detail and not a mesh tag. Drives the yubaba release triple. Optional
321    /// only because there's no provider API to probe it (static nodes declare
322    /// it; a driver-backed provider may leave it unset until known).
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub arch: Option<String>,
325    pub bucket: Option<BucketSpec>,
326    /// **Legacy location, superseded by `[registration].hostkey_fingerprint`**
327    /// (R707-T1). Still deserialized so machine TOMLs written before the split
328    /// keep parsing; never *read* directly — go through
329    /// [`MachineConfig::hostkey_fingerprint`], which prefers the registration
330    /// block. [`MachineConfig::normalize`] folds this into `registration`, and
331    /// [`MachineConfig::save`] normalizes before writing, so a load→save cycle
332    /// migrates the file rather than dropping the value.
333    #[serde(
334        rename = "hostkey_fingerprint",
335        default,
336        skip_serializing_if = "Option::is_none"
337    )]
338    pub legacy_hostkey_fingerprint: Option<String>,
339    /// Provider-side SSH-key IDs (Hetzner: from `GET /v1/ssh_keys`)
340    /// authorized for `root` at create time. Defaults to empty for
341    /// backwards-compat with existing machine declarations; an empty
342    /// list yields a Hetzner-emailed random root password (which the
343    /// driver currently discards). Populate this when you want pre-mesh
344    /// SSH access for bootstrap deploys or recovery.
345    #[serde(default, skip_serializing_if = "Vec::is_empty")]
346    pub ssh_keys: Vec<u64>,
347    /// Cloudflare Tunnel ID this machine joins (e.g. `abc123.cfargotunnel.com`).
348    /// `None` → no tunnel (mesh-only node, no public ingress).
349    /// When set, `yah cloud machine provision` reads `cloudflare-tunnel-token`
350    /// from the keys vault and injects the cloudflared install block into
351    /// cloud-init so the new machine connects to CF edge on first boot.
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub cloudflared: Option<String>,
354    /// Provider-issued floating/reserved IP that follows **public-ingress
355    /// ownership** onto this box — R859-F2 (W267 §Tier 1).
356    ///
357    /// The value is the provider's own identifier, opaque here and interpreted
358    /// only by the matching adapter: a Hetzner numeric floating-IP id as a
359    /// string, an OVH Additional-IP address (`"51.81.85.200"`), a Vultr
360    /// reserved-IP UUID. Same "the adapter is the boundary" convention
361    /// [`crate::envoy::floating_ip::FloatingIpAssignInput::ip_id`] documents.
362    ///
363    /// # Why it lives on the machine
364    ///
365    /// [`crate::envoy::floating_ip`] shipped the `floating_ip.*` verbs and
366    /// three provider adapters with no config anywhere saying *which* floating
367    /// IP is "the" ingress IP — the gap R594-F5 recorded and deliberately left.
368    /// This is that field, and it sits beside [`cloudflared`](Self::cloudflared)
369    /// on purpose: that is already the per-node "how the world reaches this
370    /// box" handle, and a floating IP is the sovereign-tier answer to the same
371    /// question. `[[ingress]]`'s
372    /// [`tunnel_id`](crate::config::IngressEdge::tunnel_id) is the *service*
373    /// side of ingress identity — which cohort a given service fronts through —
374    /// and a floating IP is not per-service: one IP moves between boxes, so it
375    /// cannot be partitioned by slot or hostname.
376    ///
377    /// # Absent means "no floating-IP path", never an error
378    ///
379    /// Most machines have none, and that is the normal case: mesh-only nodes,
380    /// boxes behind a Cloudflare tunnel, and every provider without a
381    /// floating-IP adapter. The effector skips such a machine cleanly rather
382    /// than refusing — see
383    /// [`plan_ingress_owner_effect`](crate::provider::floating_ip::plan_ingress_owner_effect).
384    ///
385    /// # The cohort has to agree
386    ///
387    /// Every machine that can hold the same ingress IP must declare the *same*
388    /// id: the IP is one resource that moves, so two ids inside one
389    /// [`sovereign_group`](Self::sovereign_group) means an ownership flip
390    /// silently reassigns a *different* IP than the one currently serving
391    /// traffic. `yah cloud validate` refuses that
392    /// ([`crate::validate::check_ingress_floating_ip`]) rather than leaving it
393    /// to be discovered during a failover.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub ingress_floating_ip: Option<String>,
396    /// When `true`, this machine hosts operator-bridge workloads (Tailscale
397    /// operator access to mesh-internal services). `yah cloud machine provision`
398    /// will install tailscaled and run `tailscale up` during cloud-init via the
399    /// `{{OPERATOR_BRIDGE_BLOCK}}` placeholder. Defaults to `false` for
400    /// backward-compat with existing machine declarations.
401    #[serde(default)]
402    pub hosts_operator_bridge: bool,
403    /// BYO `static`-node reach descriptor. Static nodes have no provider API to
404    /// probe, so how the camp reaches them (SSH user@host + the yubaba URL,
405    /// which is loopback until the WireGuard mesh lands) is *declared* here.
406    /// `None` for driver-backed providers (Hetzner/Vultr), whose address is
407    /// resolved from the provider API / mesh at provision time.
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub connect: Option<ConnectSpec>,
410    /// Static node capacity (R572-F3). Declares the node's total hardware
411    /// budget; F5's scheduler subtracts committed workload requests from this
412    /// to check whether a new workload fits. Absent means unconstrained.
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub allocatable: Option<NodeAllocatable>,
415    /// Placement taint keys (R572-F3). **There is no toleration** — a
416    /// `no-<archetype>` taint is an absolute block, not a preference
417    /// (W305/R742-T4; the pre-2026-08-11 "repel-unless-tolerate" wording here
418    /// described an `unless` that was never built).
419    ///
420    /// A key in this list influences placement in exactly one of two ways, and
421    /// [`taint_effect`] is the authority on which:
422    ///
423    /// - **repulsion** — `"no-server"` / `"no-appliance"` / `"no-job"` reject
424    ///   workloads of that [`LifecycleArchetype`] outright;
425    /// - **affinity** — a key in [`AFFINITY_TAINT_KEYS`] (today just
426    ///   `"public-ip"`) that a workload names in
427    ///   `yah.placement.requires-taint`, which then *requires* this node.
428    ///
429    /// Anything else is **inert**: it parses, it round-trips, and no scheduler
430    /// decision can ever read it. `yah cloud validate` rejects such keys
431    /// (`validate::check_inert_taints`) rather than letting them sit looking
432    /// load-bearing — which is how `no-voter` spent months asserting a
433    /// falsehood on three nodes. Facts about a node that are not placement
434    /// inputs belong in [`mesh_tags`](Self::mesh_tags) or a comment.
435    #[serde(default, skip_serializing_if = "Vec::is_empty")]
436    pub taints: Vec<String>,
437    /// Which consensus group this node belongs to — W305/R742-F1. `None` means
438    /// standalone: in no group at all, which is us-west-002 and us-west-015.
439    ///
440    /// Membership is not by itself quorum eligibility; that is
441    /// [`sovereign_role`](Self::sovereign_role), added by R605-F12 because
442    /// us-west-003 is in prod's blast radius *and* must never vote in it.
443    ///
444    /// **Not a placement input.** It is deliberately absent from
445    /// [`RequiredSpec::matches`], and adding it there would be a category
446    /// error: a sovereign group is a *blast radius*, not a filter. Nothing
447    /// about "which quorum does this box vote in" should decide where a
448    /// workload runs — that is what made the fleet express three unrelated
449    /// properties through one taint list and get all three wrong (W305).
450    ///
451    /// What it *is* for is refusal. [`judge_join`] answers "may this node join
452    /// that node's cluster", and the answer is no unless both declare the same
453    /// group. Before this field the only guard was a comment in three machine
454    /// TOMLs saying "never run a raft join against this box from a shell
455    /// pointed at prod" — habit, with no mechanism behind it, which is the
456    /// same class of guard W257 §8 admitted to.
457    ///
458    /// # Why `sovereign_group` and not `raft_group`
459    ///
460    /// Raft is today's mechanism (operator, 2026-08-10). A field named for the
461    /// mechanism goes stale the day the mechanism is swapped, and every
462    /// consumer that reads it inherits the lie. `sovereign` names what the
463    /// group *has* — its own authority, its own upgrade cadence, its own
464    /// destruction — which stays true under any consensus protocol.
465    ///
466    /// Note the word already appears in this tree as prose (W267's title, the
467    /// `IngressProvider::Passway` doc comment's "sovereign edge"). That is an
468    /// adjective meaning "self-hosted, not SaaS"; this is the first time it
469    /// carries structure.
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub sovereign_group: Option<String>,
472    /// Whether this node may hold a seat in its group's quorum — R605-F12.
473    /// Meaningless without [`sovereign_group`](Self::sovereign_group): a
474    /// standalone box has no quorum to be eligible for.
475    ///
476    /// **`None` is "not written", not a third role.** Read it through
477    /// [`sovereign_membership`](Self::sovereign_membership), which resolves the
478    /// absence to [`SovereignRole::Voter`] — what declaring a group has always
479    /// meant, so the six nodes stamped before this field keep their seats
480    /// without an edit. The distinction is kept only so
481    /// [`crate::validate::check_unroled_sovereign_members`] can tell an
482    /// operator who *chose* voter from one who never considered the question;
483    /// no join decision reads the `Option` directly.
484    ///
485    /// # Why this is not a taint
486    ///
487    /// It was, once: `no-voter` sat in [`taints`](Self::taints) on three nodes
488    /// for months, read by nothing, and R742-T4 removed it because the taint
489    /// list is a *placement* vocabulary and this is not a placement input (see
490    /// [`taint_effect`]). Nor is it a second group label. It is a modifier on
491    /// the membership this node already declares, which is why it lives beside
492    /// the group and is judged with it in one predicate,
493    /// [`workload_spec::sovereign::join_permitted`].
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub sovereign_role: Option<SovereignRole>,
496    /// `[registration]` — the observed half (R707-T1). Empty until the box has
497    /// been attached / mesh-joined. See [`MachineRegistration`].
498    #[serde(default, skip_serializing_if = "MachineRegistration::is_empty")]
499    pub registration: MachineRegistration,
500}
501
502/// True iff `provider` has an auto-provision driver (create/destroy via API).
503/// Driver-backed providers require `location` + `server_type`; BYO `static`
504/// nodes (brought up over SSH) do not. The cloud-vs-vps distinction the fleet
505/// cares about lives here — at the provider-capability layer — not as a
506/// separate machine type (W242 BYO Phase-0 decision).
507pub fn provider_has_machine_driver(provider: &str) -> bool {
508    matches!(provider, "hetzner" | "vultr" | "digitalocean")
509}
510
511/// Taint keys a workload may name in `yah.placement.requires-taint` to
512/// *require* a node (W305/R742-T4 affinity vocabulary).
513///
514/// This is a closed list on purpose. `WorkloadSpec::requires_taint` returns
515/// free text, but every producer in the tree is code — `passway_ingress.rs`
516/// and `cloudflared_ingress.rs`, both emitting
517/// [`workload_spec::PUBLIC_IP_TAINT`] — and no on-disk `workload.toml` sets the
518/// annotation at all. So the set of keys a node can usefully carry for
519/// affinity is knowable at compile time, which is what lets
520/// [`taint_effect`] call anything outside it inert instead of guessing.
521///
522/// **Adding an affinity key means adding it here**, in the same change that
523/// teaches a workload to require it. That coupling is the point: it makes the
524/// node side and the workload side impossible to land apart.
525pub const AFFINITY_TAINT_KEYS: &[&str] = &[workload_spec::PUBLIC_IP_TAINT];
526
527/// How a key in [`MachineConfig::taints`] can affect placement.
528///
529/// W305 finding 1: before R742-T4 nothing asked this question, so a key that
530/// no scheduler path could read — `"qa"`, `"no-voter"` — parsed, validated,
531/// and quietly did nothing. Both of the findings that cost real fleet state
532/// were invisible for exactly that reason.
533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
534pub enum TaintEffect {
535    /// `"no-<archetype>"`: rejects workloads of that archetype outright. Read
536    /// by [`RequiredSpec::matches`] via `repel_archetype`.
537    Repels(LifecycleArchetype),
538    /// A key in [`AFFINITY_TAINT_KEYS`]: a workload naming it in
539    /// `yah.placement.requires-taint` is restricted to nodes carrying it.
540    Attracts,
541    /// Neither. No placement decision can read this key.
542    Inert,
543}
544
545/// Classify one node taint key. See [`TaintEffect`].
546///
547/// The repulsion half is derived from [`LifecycleArchetype::ALL`] rather than
548/// a literal list, so a fourth archetype makes `no-<its key>` live without an
549/// edit here.
550pub fn taint_effect(key: &str) -> TaintEffect {
551    if let Some(arch) = LifecycleArchetype::ALL
552        .into_iter()
553        .find(|a| key == format!("no-{}", a.taint_key()))
554    {
555        return TaintEffect::Repels(arch);
556    }
557    if AFFINITY_TAINT_KEYS.contains(&key) {
558        return TaintEffect::Attracts;
559    }
560    TaintEffect::Inert
561}
562
563/// Every key the scheduler *can* act on, sorted — for error messages that
564/// tell the operator what the legal vocabulary actually is instead of only
565/// what was wrong.
566pub fn live_taint_keys() -> Vec<String> {
567    let mut keys: Vec<String> = LifecycleArchetype::ALL
568        .into_iter()
569        .map(|a| format!("no-{}", a.taint_key()))
570        .chain(AFFINITY_TAINT_KEYS.iter().map(|k| (*k).to_string()))
571        .collect();
572    keys.sort();
573    keys
574}
575
576/// What [`judge_join`] decided about one proposed cluster join.
577///
578/// Shaped like yubaba's `PromotionVerdict` / `GeographyVerdict` and for the
579/// same reason: the rule stays unit-testable without a live cluster, and a
580/// refusal carries its reason from the place that knows it.
581#[derive(Debug, Clone, PartialEq, Eq)]
582pub enum JoinVerdict {
583    /// Both nodes declare the same sovereign group and both are voters. The
584    /// join is within one blast radius and grows a quorum both sides are
585    /// eligible for.
586    Permit,
587    /// The join is refused. Carries an operator-readable reason naming both
588    /// declared values and the file to edit — a refusal that only says
589    /// "invalid" gets worked around rather than fixed.
590    Refuse(String),
591}
592
593/// May `joiner` join the cluster `target` belongs to? — W305/R742-F1.
594///
595/// **A join is permitted iff both nodes declare the same non-`None`
596/// [`sovereign_group`](MachineConfig::sovereign_group) and both are
597/// [`SovereignRole::Voter`].** One rule, no special cases, and it makes the
598/// declaration mandatory before any quorum grows.
599///
600/// The case this exists for is two *different* declared groups: joining a dev
601/// Pi into prod is refused rather than trusted, where today the only guard is
602/// a comment saying not to do it. But an undeclared node is refused too, and
603/// that is the deliberate half — `None` means "in no group", not "unknown", so
604/// growing prod with an unstamped box is exactly as much a cross-group join as
605/// the dev case is. Failing open there would leave the operator believing a
606/// guarantee that was never evaluated, which is the reasoning
607/// `QuorumGeography::judge` already applies to untagged voters.
608///
609/// No legitimate flow pays for that strictness: prod and dev are both stamped,
610/// and us-west-002/015 are deliberately in no group at all. Adding a real
611/// member means declaring it first, which is the point.
612///
613/// # The non-voting refusal (R605-F12)
614///
615/// Same group and still refused, when either side declares
616/// [`SovereignRole::NonVoter`]. This is the case a group label alone could not
617/// express. us-west-003 is a residential-uplink build box the operator counts
618/// as part of prod — same secrets, same upgrade cadence, same destruction — and
619/// which must never hold a prod raft seat, because a home-internet partition
620/// should not be able to stall the quorum. Until R605-F12 the only thing
621/// refusing it was its *absent* stamp, so recording the operator's real intent
622/// (`sovereign_group = "prod"`) would have removed the guard. Now the intent
623/// and the guard are the same two lines.
624///
625/// Note what this is not: the refusal here is about *voting*, and it says
626/// nothing about the mesh. One mesh spans the whole fleet regardless of group
627/// or role (operator, 2026-08-19); a non-voter is reachable, schedulable and
628/// rollable like any other node.
629///
630/// This is the **camp-side** rendering of the rule. The predicate itself lives
631/// in [`workload_spec::sovereign::join_permitted`] because yubaba's
632/// `POST /raft/add-learner` gate asks the same question and cannot see this
633/// crate (there is deliberately no yubaba → cloud edge). Only the prose is
634/// duplicated, and it has to be: a refusal here names
635/// `.yah/infra/machines/<name>.toml`, while the node-side one has no machine
636/// name in hand and must also name `yubaba serve --sovereign-group`.
637///
638/// The node-side gate is *narrower* on purpose, and the difference is worth
639/// knowing when reading either: a daemon started without `--sovereign-group`
640/// has declared nothing rather than declared standalone, so yubaba resolves
641/// that unknown before it judges, and its gate is in force only once the
642/// cluster being joined declares a group. See `yubaba::sovereign_group`.
643pub fn judge_join(joiner: &MachineConfig, target: &MachineConfig) -> JoinVerdict {
644    let stamp_hint = |m: &MachineConfig| {
645        format!(
646            "declare `sovereign_group = \"<group>\"` in .yah/infra/machines/{}.toml",
647            m.name
648        )
649    };
650    let role_hint = |m: &MachineConfig| {
651        format!(
652            "set `sovereign_role = \"voter\"` in .yah/infra/machines/{}.toml",
653            m.name
654        )
655    };
656    if workload_spec::sovereign::join_permitted(
657        joiner.sovereign_membership(),
658        target.sovereign_membership(),
659    ) {
660        return JoinVerdict::Permit;
661    }
662    let (j, t) = (
663        joiner.sovereign_group.as_deref(),
664        target.sovereign_group.as_deref(),
665    );
666    // Everything below is a refusal; the only permitted shape returned above.
667    //
668    // R605-F12: when both sides name the SAME group, the role is the only thing
669    // left that can have refused, and it gets its own message. Falling through
670    // to the arms below would print "cross-group join refused: 'us-west-003' is
671    // in "prod" and 'us-west-001' is in "prod"" — a message that reads as a bug
672    // in the check rather than a decision about the fleet.
673    //
674    // Deliberately not hoisted above the group comparison. A non-voting joiner
675    // whose target is standalone is refused for *both* reasons, and naming the
676    // role there would send the operator to fix a field that would not have
677    // made the join legal anyway.
678    if let (Some(a), Some(b)) = (j, t) {
679        if a == b {
680            for (m, side, other) in [
681                (joiner, "the joiner", &target.name),
682                (target, "the target", &joiner.name),
683            ] {
684                if m.sovereign_membership().role.is_voter() {
685                    continue;
686                }
687                return JoinVerdict::Refuse(format!(
688                    "join refused: {side} '{}' is a NON-VOTING member of sovereign group {a:?}, \
689                     the same group as '{other}'. It is inside that blast radius — same secrets, \
690                     same upgrade cadence, same destruction — but declares itself ineligible for \
691                     the quorum, so this is refused by declaration rather than by omission. If it \
692                     should genuinely vote, {}; if it should not, this refusal is the field doing \
693                     its job and the join is the thing to reconsider.",
694                    m.name,
695                    role_hint(m),
696                ));
697            }
698        }
699    }
700    match (j, t) {
701        (Some(a), Some(b)) => JoinVerdict::Refuse(format!(
702            "cross-group join refused: '{}' is in sovereign group {a:?} and '{}' is in {b:?}. \
703             These are separate blast radii — separate quorums, separate upgrade cadences, \
704             separately destroyable — and merging them is not something a join can undo. If \
705             the move is genuinely intended, restamp '{}' to {b:?} first and treat it as \
706             leaving its old group.",
707            joiner.name,
708            target.name,
709            joiner.name,
710        )),
711        (None, Some(b)) => JoinVerdict::Refuse(format!(
712            "join refused: '{}' declares no sovereign_group, so it is standalone — in no \
713             group — while '{}' is in {b:?}. That is a cross-group join, not an unchecked \
714             one. To make '{}' a member of {b:?}, {}.",
715            joiner.name,
716            target.name,
717            joiner.name,
718            stamp_hint(joiner),
719        )),
720        (Some(a), None) => JoinVerdict::Refuse(format!(
721            "join refused: '{}' is in sovereign group {a:?} but '{}' declares none, so the \
722             target is standalone and has no group to join. Either {}, or found the group on \
723             '{}' rather than growing it.",
724            joiner.name,
725            target.name,
726            stamp_hint(target),
727            joiner.name,
728        )),
729        (None, None) => JoinVerdict::Refuse(format!(
730            "join refused: neither '{}' nor '{}' declares a sovereign_group, so this join \
731             would form a group nobody declared and nothing could later reason about. Name \
732             the group on both boxes first: {}, and the same for '{}'.",
733            joiner.name,
734            target.name,
735            stamp_hint(joiner),
736            target.name,
737        )),
738    }
739}
740
741impl MachineConfig {
742    /// This node's declared place in a sovereign group, as the shared join rule
743    /// wants it — R605-F12.
744    ///
745    /// The one place `sovereign_role`'s `None` is resolved. Absence means
746    /// [`SovereignRole::Voter`], which is what declaring a group meant before
747    /// the role existed; resolving it here rather than at each call site is what
748    /// keeps the camp-side and node-side gates from disagreeing about a node
749    /// that never wrote the field.
750    pub fn sovereign_membership(&self) -> Membership<'_> {
751        Membership {
752            group: self.sovereign_group.as_deref(),
753            role: self.sovereign_role.unwrap_or_default(),
754        }
755    }
756
757    /// Provider DC code, or `""` when omitted (static nodes). Most readers want
758    /// a `&str`; the driver-backed provision/status paths still go through
759    /// [`validate`](Self::validate) which guarantees presence for those.
760    pub fn location(&self) -> &str {
761        self.location.as_deref().unwrap_or("")
762    }
763
764    /// Provider SKU, or `""` when omitted (static nodes).
765    pub fn server_type(&self) -> &str {
766        self.server_type.as_deref().unwrap_or("")
767    }
768
769    /// Enforce the provisioning-only-field contract: a machine whose provider
770    /// has an auto-provision driver MUST declare `location` + `server_type`
771    /// (the driver can't create a server without them). Static nodes may omit
772    /// both. Call this before any provision/diff that assumes a driver.
773    pub fn validate(&self) -> Result<()> {
774        if provider_has_machine_driver(&self.provider) {
775            if self.location.is_none() {
776                anyhow::bail!(
777                    "machine '{}' (provider '{}') has an auto-provision driver but no `location`",
778                    self.name,
779                    self.provider
780                );
781            }
782            if self.server_type.is_none() {
783                anyhow::bail!(
784                    "machine '{}' (provider '{}') has an auto-provision driver but no `server_type`",
785                    self.name,
786                    self.provider
787                );
788            }
789        }
790        Ok(())
791    }
792
793    /// Declared taints that no placement decision can read (W305/R742-T4).
794    ///
795    /// Deliberately **not** folded into [`validate`](Self::validate): that
796    /// guard runs on the provision/diff hot path and answers a different
797    /// question (can the driver create this server). An inert taint is a lint
798    /// — it never breaks an operation in flight, it just means the file is
799    /// asserting something the scheduler will not honour. `yah cloud validate`
800    /// is where the operator asks for that judgement; see
801    /// [`crate::validate::check_inert_taints`].
802    pub fn inert_taints(&self) -> Vec<&str> {
803        self.taints
804            .iter()
805            .filter(|t| taint_effect(t) == TaintEffect::Inert)
806            .map(String::as_str)
807            .collect()
808    }
809
810    /// Yubaba's TOFU'd hostkey fingerprint, from `[registration]` and falling
811    /// back to the pre-R707-T1 top-level field. **The only read path** — a
812    /// caller that reaches for `legacy_hostkey_fingerprint` directly sees
813    /// `None` on every migrated machine.
814    pub fn hostkey_fingerprint(&self) -> Option<&str> {
815        self.registration
816            .hostkey_fingerprint
817            .as_deref()
818            .or(self.legacy_hostkey_fingerprint.as_deref())
819    }
820
821    /// Record (or clear) the observed hostkey fingerprint. Writes
822    /// `[registration]` and drops any pre-R707-T1 top-level value, so the two
823    /// locations can never disagree after a writeback.
824    pub fn set_hostkey_fingerprint(&mut self, fingerprint: Option<String>) {
825        self.registration.hostkey_fingerprint = fingerprint;
826        self.legacy_hostkey_fingerprint = None;
827    }
828
829    /// Mesh (tailnet) IPv4 for this node, or `None` pre-mesh.
830    ///
831    /// Prefers `[registration].mesh_ipv4`; falls back to the host of a legacy
832    /// `[connect].yubaba` URL when that host is in the `100.64.0.0/10` CGNAT
833    /// range the mesh uses. A loopback placeholder (`http://127.0.0.1:7443`,
834    /// meaning "pre-mesh, reachable only through an SSH tunnel") is *not* a
835    /// mesh address and yields `None`.
836    pub fn mesh_ipv4(&self) -> Option<&str> {
837        if let Some(ip) = self.registration.mesh_ipv4.as_deref() {
838            return Some(ip);
839        }
840        let url = self.connect.as_ref()?.yubaba.as_deref()?;
841        mesh_ipv4_from_url(url)
842    }
843
844    /// Base URL for this node's yubaba, or `None` when no reach resolves.
845    ///
846    /// Thin wrapper over [`reach`](Self::reach) for the many call sites that
847    /// only branch on presence. Prefer `reach` anywhere the operator sees the
848    /// outcome — a `None` here throws away a refusal that names exactly which
849    /// address is missing.
850    pub fn yubaba_url(&self) -> Option<String> {
851        self.reach().ok()
852    }
853
854    /// The **one** address automation dials for this node — mesh-only.
855    ///
856    /// `Err` is a *named refusal*, not an absence: a node with no mesh address
857    /// is unresolvable to every automated path, and R605-T10's whole complaint
858    /// is that this used to surface as a connect timeout against an address the
859    /// caller has no route to.
860    ///
861    /// Resolution order:
862    ///
863    /// 1. A declared `[connect].yubaba` on a **private** host (10/8,
864    ///    172.16/12, 192.168/16) is **not dialed** — see below.
865    /// 2. Any other declared `[connect].yubaba` wins verbatim. That includes
866    ///    the pre-mesh loopback placeholder (`http://127.0.0.1:7443`, "I have
867    ///    no mesh address; reach me through the SSH tunnel to `ssh`"), which is
868    ///    a genuine declaration and stays honoured.
869    /// 3. Otherwise `[registration].mesh_ipv4` composed with
870    ///    `[connect].yubaba_port`.
871    ///
872    /// **Why a LAN literal loses (R605-T10, operator 2026-08-19).** The LAN
873    /// address is an emergency break-glass route, never an official one, and
874    /// automation must ALWAYS assume the caller is not on that LAN — this camp
875    /// sits on 192.168.22.0/22 with no route to the fleet's 192.168.10.0/24 at
876    /// all. Writing one into the field every resolver dials does not sit beside
877    /// the mesh route, it *overrides* it: R707-T6 made a declared literal beat
878    /// `mesh_ipv4` outright, so us-west-011 (mesh-joined, healthy) was elected
879    /// for every aarch64 build and then dialed at an address that answers only
880    /// from inside bldg-2506.
881    ///
882    /// **What R707-T6 wanted is preserved elsewhere.** Its forcing case was
883    /// identity, not reach: the dev raft group advertises LAN addrs
884    /// (`192.168.10.11:7443`, verified live off `/raft/status` 2026-08-27), and
885    /// `rollout::yubaba::membership_to_nodes` has to map those back to declared
886    /// machines. That match now runs against [`lan_endpoint`](Self::lan_endpoint),
887    /// which is composed from the break-glass `[connect].address` metadata and
888    /// is never dialed — so the two concerns the old precedence rule fused are
889    /// split, and the literal can stop squatting a dialed field.
890    ///
891    /// The LAN address itself STAYS in the machine TOML. It is useful metadata
892    /// and the manual `ssh` path is entitled to it; it is only disconnected
893    /// from every automated process.
894    pub fn reach(&self) -> Result<String, String> {
895        let Some(connect) = self.connect.as_ref() else {
896            return Err(format!(
897                "machine {:?} declares no [connect] block, so nothing knows how to reach it \
898                 \u{2192} declare one, or leave it unprovisioned and out of placement",
899                self.name
900            ));
901        };
902        let mesh = || {
903            self.registration
904                .mesh_ipv4
905                .as_deref()
906                .map(|ip| format!("http://{ip}:{}", connect.yubaba_port()))
907        };
908        if let Some(literal) = &connect.yubaba {
909            let Some(lan) = private_ipv4_from_url(literal) else {
910                return Ok(literal.clone());
911            };
912            return mesh().ok_or_else(|| {
913                format!(
914                    "machine {:?} is unresolvable to automation: its only declared yubaba reach \
915                     is the private literal {:?} and it has no [registration].mesh_ipv4\n\
916                     \u{2192} a LAN address is an emergency break-glass route, never an official \
917                     one (R605-T10) — every automated path assumes the caller is NOT on {}/24\n\
918                     \u{2192} mesh-join the box and record `mesh_ipv4` under [registration], then \
919                     delete `[connect].yubaba` so the port composes with it",
920                    self.name,
921                    literal,
922                    lan.rsplit_once('.').map(|(net, _)| net).unwrap_or(lan),
923                )
924            });
925        }
926        mesh().ok_or_else(|| {
927            format!(
928                "machine {:?} has no [registration].mesh_ipv4 and declares no \
929                 [connect].yubaba, so no automated path can reach it\n\
930                 \u{2192} mesh-join the box and record its tailnet address, or taint it out of \
931                 placement — do not point `[connect].yubaba` at a LAN address (R605-T10)",
932                self.name
933            )
934        })
935    }
936
937    /// The LAN `host:port` this node's yubaba answers on, composed from the
938    /// break-glass `[connect].address` metadata plus the declared port.
939    ///
940    /// **Identity only — never dial this.** It exists so a raft membership
941    /// entry that names a node by its LAN address can be mapped back to the
942    /// declared machine (`rollout::yubaba::membership_to_nodes`) without that
943    /// address having to live in a field a resolver reads. `None` when the
944    /// machine is unprovisioned.
945    pub fn lan_endpoint(&self) -> Option<String> {
946        let connect = self.connect.as_ref()?;
947        Some(format!("{}:{}", connect.address, connect.yubaba_port()))
948    }
949
950    /// Fold the pre-R707-T1 top-level `hostkey_fingerprint` into
951    /// `[registration]`, and lift a mesh IP out of a legacy `[connect].yubaba`
952    /// URL. Idempotent; a machine already on the split shape is untouched.
953    ///
954    /// [`save`](Self::save) calls this, so writing a machine TOML migrates it
955    /// rather than round-tripping the old shape back out.
956    pub fn normalize(&mut self) {
957        if let Some(fp) = self.legacy_hostkey_fingerprint.take() {
958            self.registration.hostkey_fingerprint.get_or_insert(fp);
959        }
960        if self.registration.mesh_ipv4.is_none() {
961            if let Some(ip) = self
962                .connect
963                .as_ref()
964                .and_then(|c| c.yubaba.as_deref())
965                .and_then(mesh_ipv4_from_url)
966                .map(str::to_string)
967            {
968                self.registration.mesh_ipv4 = Some(ip);
969                // The URL was pure derivation from mesh IP + port; keep only
970                // the declared half so the two can't drift apart.
971                if let Some(c) = self.connect.as_mut() {
972                    c.yubaba = None;
973                }
974            }
975        }
976    }
977
978    /// Persist to `<cloud_dir>/machines/<name>.toml`, creating the dir if needed.
979    ///
980    /// ⚠ Serializes the struct, so **operator comments in the target file are
981    /// lost**. Pre-existing behaviour, not introduced here, but it is why
982    /// registration writeback (`yah cloud machine attach`) goes through
983    /// [`crate::state::MachineState`] and the comment-preserving path in the
984    /// CLI rather than calling this on a hand-authored inventory file.
985    pub fn save(&self, cloud_dir: &Path) -> Result<()> {
986        let dir = cloud_dir.join("machines");
987        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
988        let path = dir.join(format!("{}.toml", self.name));
989        let mut normalized = self.clone();
990        normalized.normalize();
991        let s = toml::to_string_pretty(&normalized)
992            .with_context(|| format!("serializing machine {}", self.name))?;
993        std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
994    }
995}
996
997/// Host of an `http://host:port` URL iff it is a mesh (headscale) IPv4 in the
998/// `100.64.0.0/10` CGNAT range. String-level rather than URL-parsed: the
999/// inventory format is stable and this crate carries no URL dependency (same
1000/// reasoning as `fleet_metrics::extract_host` and
1001/// `hub::coordinator::is_loopback_url`).
1002fn mesh_ipv4_from_url(url: &str) -> Option<&str> {
1003    let host = ipv4_host_of(url)?;
1004    let ip: std::net::Ipv4Addr = host.parse().ok()?;
1005    let [a, b, ..] = ip.octets();
1006    // 100.64.0.0/10 ⇒ first octet 100, second octet 64..=127.
1007    (a == 100 && (64..=127).contains(&b)).then_some(host)
1008}
1009
1010/// Host of an `http://host:port` URL iff it is an **RFC1918 private** IPv4 —
1011/// `10/8`, `172.16/12`, `192.168/16`. `None` for anything else, loopback and
1012/// the `100.64/10` mesh range included: neither is a LAN literal.
1013///
1014/// The judgement R605-T10 turns on. A private literal is only ever reachable
1015/// from inside one building, so it is metadata about where the box physically
1016/// sits and never an address automation may dial — see
1017/// [`MachineConfig::reach`] and [`crate::validate::check_lan_dial_targets`].
1018pub fn private_ipv4_from_url(url: &str) -> Option<&str> {
1019    let host = ipv4_host_of(url)?;
1020    is_private_ipv4(host).then_some(host)
1021}
1022
1023/// Whether a bare host string is an RFC1918 private IPv4 literal.
1024pub fn is_private_ipv4(host: &str) -> bool {
1025    let Ok(ip) = host.parse::<std::net::Ipv4Addr>() else {
1026        return false;
1027    };
1028    ip.is_private()
1029}
1030
1031/// Bare host of a `[scheme://]host[:port][/path]` string.
1032fn ipv4_host_of(url: &str) -> Option<&str> {
1033    let after_scheme = url.split("://").nth(1).unwrap_or(url);
1034    after_scheme.split(['/', ':']).next()
1035}
1036
1037/// Declared **reach** for a BYO `static` node (no provider API). Lives under
1038/// `[connect]` in the machine TOML.
1039///
1040/// Reach only — how the camp gets to the box. *Permission* is a separate axis
1041/// that belongs to cheers' scopes (W295 §"Deliberately deferred"); the two
1042/// collapse in practice today (mesh membership grants everything) and the data
1043/// model must not fuse them, so do not add an authorization field here.
1044///
1045/// `address` and `ssh` stay whole, literal, operator-authored strings even
1046/// though their values often *look* derived. They are not: us-west-001 dials
1047/// SSH over its public IP while us-west-002 was deliberately repointed at its
1048/// tailnet IP (R608-F10) precisely because the LAN address is unreachable
1049/// off-LAN. Decomposing them into user + host and recomposing would silently
1050/// undo per-machine decisions like that one. `yubaba` is the field that *was*
1051/// derived — mesh IP plus a fixed port, rewritten by mesh-join — so that is
1052/// where R707-T1 cut.
1053#[derive(Debug, Clone, Serialize, Deserialize)]
1054#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1055pub struct ConnectSpec {
1056    /// Reachable IPv4/host for the box, e.g. `"45.32.194.254"`. Declared: which
1057    /// of a machine's several addresses the camp should use is an operator
1058    /// choice (public IP vs. LAN IP vs. tailnet IP).
1059    pub address: String,
1060    /// SSH target the camp dials for bootstrap + (pre-mesh) tunneled deploys,
1061    /// e.g. `"root@45.32.194.254"` or `"struc@100.64.0.4"`. Uses the operator's
1062    /// `~/.ssh/yah` key. Declared, whole — see the type doc.
1063    pub ssh: String,
1064    /// Port yubaba listens on. Declared reach; defaults to 7443 when omitted,
1065    /// which is every machine in the fleet today. Composed with the *observed*
1066    /// [`MachineRegistration::mesh_ipv4`] by [`MachineConfig::yubaba_url`].
1067    #[serde(default, skip_serializing_if = "Option::is_none")]
1068    pub yubaba_port: Option<u16>,
1069    /// Explicit yubaba base URL, overriding the composed form.
1070    ///
1071    /// Two live uses, both genuine declarations: a pre-mesh node saying
1072    /// `"http://127.0.0.1:7443"` — "I have no mesh address; reach me through
1073    /// the SSH tunnel to `ssh`" — and any node whose yubaba is not at
1074    /// `mesh_ipv4:port`. A URL here whose host *is* a mesh IP is the
1075    /// pre-R707-T1 shape; [`MachineConfig::normalize`] lifts it into
1076    /// `[registration].mesh_ipv4` and clears this field so the two cannot
1077    /// drift apart.
1078    #[serde(default, skip_serializing_if = "Option::is_none")]
1079    pub yubaba: Option<String>,
1080}
1081
1082/// Default yubaba listen port, used when `[connect].yubaba_port` is omitted.
1083pub const DEFAULT_YUBABA_PORT: u16 = 7443;
1084
1085impl ConnectSpec {
1086    /// Declared yubaba port, defaulting to [`DEFAULT_YUBABA_PORT`].
1087    pub fn yubaba_port(&self) -> u16 {
1088        self.yubaba_port.unwrap_or(DEFAULT_YUBABA_PORT)
1089    }
1090}
1091
1092#[derive(Debug, Clone, Serialize, Deserialize)]
1093#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1094pub struct BucketSpec {
1095    pub name: String,
1096    pub public_read: bool,
1097}
1098
1099/// Per-camp mirror declaration from `.yah/cloud/mirrors/<id>/mirror.toml`
1100/// (folder form) or the legacy `.yah/cloud/mirrors/<id>.toml` (flat form).
1101///
1102/// The folder form is preferred for new mirrors so that per-mirror secrets
1103/// and override files can sit next to `mirror.toml` without polluting the
1104/// top-level `mirrors/` directory.
1105#[derive(Debug, Clone, Serialize, Deserialize)]
1106pub struct LegacyMirrorConfig {
1107    /// Logical camp name this mirror hosts, e.g. `"yah"` or `"noisetable"`.
1108    ///
1109    /// Serialised as `camp`; accepts the legacy `rig` spelling for files that
1110    /// predate the R137 rig→camp rename (one-time migration: `sed -i ''
1111    /// 's/^rig = /camp = /' ~/.yah/cloud/mirrors/*.toml`).
1112    #[serde(rename = "camp", alias = "rig")]
1113    pub camp: String,
1114    pub regions: Vec<String>,
1115    /// Workload names deployed as part of this mirror (references `workloads/<name>.toml`).
1116    /// Renamed from `services` in R092-F1; use `yah cloud config migrate-services-to-workloads`
1117    /// on repos that still have the old `services/` layout.
1118    #[serde(alias = "services")]
1119    pub workloads: Vec<String>,
1120    /// Base domain for Cloudflare-fronted services on this mirror's machines.
1121    /// Combined with the machine's `location` to build virtual-host names:
1122    /// e.g. `cloud_domain = "cloud.noisetable.example"` on machine in location
1123    /// `pdx` → Caddyfile site address `pdx.cloud.noisetable.example`.
1124    /// Optional: if unset the Caddyfile falls back to `:port` listeners.
1125    #[serde(default, skip_serializing_if = "Option::is_none")]
1126    pub cloud_domain: Option<String>,
1127}
1128
1129/// Error from loading or validating a single workload TOML file.
1130#[derive(Debug, Error)]
1131pub enum WorkloadConfigError {
1132    #[error("reading {path}: {source}")]
1133    Io {
1134        path: String,
1135        source: std::io::Error,
1136    },
1137    #[error("parsing {path}: {source}")]
1138    Toml {
1139        path: String,
1140        source: toml::de::Error,
1141    },
1142    #[error("invalid WorkloadSpec in {path}: {source}")]
1143    Shape {
1144        path: String,
1145        source: validate::ShapeError,
1146    },
1147}
1148
1149/// A workload declaration loaded from `.yah/cloud/workloads/<name>.toml`.
1150///
1151/// Each file is the human-authored TOML serialization of a [`WorkloadSpec`].
1152/// On load, the spec is validated against the shape layer; failures surface as
1153/// a [`CloudConfigError::Workload`] with the file path and field path.
1154#[derive(Debug, Clone, Serialize, Deserialize)]
1155pub struct WorkloadConfig {
1156    /// The validated spec.
1157    #[serde(flatten)]
1158    pub spec: WorkloadSpec,
1159}
1160
1161impl WorkloadConfig {
1162    /// Persist to `<cloud_dir>/workloads/<name>.toml`, creating the dir if needed.
1163    pub fn save(&self, cloud_dir: &Path) -> Result<()> {
1164        let dir = cloud_dir.join("workloads");
1165        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
1166        let path = dir.join(format!("{}.toml", self.spec.name));
1167        let s = toml::to_string_pretty(self)
1168            .with_context(|| format!("serializing workload {}", self.spec.name))?;
1169        std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
1170    }
1171}
1172
1173/// Error surfaced by [`CloudConfig::load`] when a workload TOML fails validation.
1174#[derive(Debug, Error)]
1175pub enum CloudConfigError {
1176    #[error(transparent)]
1177    Anyhow(#[from] anyhow::Error),
1178    #[error("workload validation failed: {0}")]
1179    Workload(WorkloadConfigError),
1180}
1181
1182/// Mirror-to-machine assignment table from `.yah/cloud/topology.toml`.
1183///
1184/// Declares which logical mirror names are assigned to which machines.
1185/// This is the source-canonical placement until yubaba raft observes it
1186/// (per the migration tracker in the arch doc).
1187#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1188pub struct TopologyConfig {
1189    /// Mirror→machine assignments.
1190    #[serde(default)]
1191    pub assignments: Vec<MirrorAssignment>,
1192    /// Declared buckets, logged by `yah cloud bucket create`.
1193    /// Source-canonical until yubaba raft observes actual placement.
1194    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1195    pub buckets: Vec<BucketLogEntry>,
1196}
1197
1198impl TopologyConfig {
1199    /// Load from a `topology.toml` file, returning `Default` when absent.
1200    pub fn load(path: &Path) -> Result<Self> {
1201        if !path.exists() {
1202            return Ok(Self::default());
1203        }
1204        let s =
1205            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1206        toml::from_str(&s).with_context(|| format!("parsing {}", path.display()))
1207    }
1208
1209    /// Persist to `topology.toml`, creating parent dirs if needed.
1210    pub fn save(&self, path: &Path) -> Result<()> {
1211        if let Some(parent) = path.parent() {
1212            std::fs::create_dir_all(parent)
1213                .with_context(|| format!("creating {}", parent.display()))?;
1214        }
1215        let s = toml::to_string_pretty(self).context("serializing topology")?;
1216        std::fs::write(path, s).with_context(|| format!("writing {}", path.display()))
1217    }
1218
1219    /// Find a declared bucket by name.
1220    pub fn bucket_by_name(&self, name: &str) -> Option<&BucketLogEntry> {
1221        self.buckets.iter().find(|b| b.name == name)
1222    }
1223
1224    /// Find a mutable declared bucket by name.
1225    pub fn bucket_by_name_mut(&mut self, name: &str) -> Option<&mut BucketLogEntry> {
1226        self.buckets.iter_mut().find(|b| b.name == name)
1227    }
1228
1229    /// Returns true if the bucket is declared as cross-machine (no owning machine).
1230    pub fn is_cross_machine_bucket(&self, name: &str) -> bool {
1231        self.buckets
1232            .iter()
1233            .any(|b| b.name == name && b.machine.is_none())
1234    }
1235}
1236
1237/// One mirror→machine placement entry in `topology.toml`.
1238#[derive(Debug, Clone, Serialize, Deserialize)]
1239pub struct MirrorAssignment {
1240    /// Logical mirror name, e.g. `"noisetable-pdx"`.
1241    pub mirror: String,
1242    /// Machine that hosts this mirror, e.g. `"noisetable-pdx-1"`.
1243    pub machine: String,
1244}
1245
1246/// A bucket declaration logged in `topology.toml` by `yah cloud bucket create`.
1247#[derive(Debug, Clone, Serialize, Deserialize)]
1248pub struct BucketLogEntry {
1249    pub name: String,
1250    /// Machine that owns this bucket. `None` marks it as cross-machine
1251    /// (no single-machine ownership; requires an explicit declaration in
1252    /// `topology.toml` before `yah cloud bucket create` will proceed without
1253    /// `--machine`).
1254    #[serde(default, skip_serializing_if = "Option::is_none")]
1255    pub machine: Option<String>,
1256    /// Logical location of the bucket, e.g. `"pdx"`.
1257    pub location: String,
1258    /// Current declared policy: `"private"` | `"public-read"` | `"signed-only"`.
1259    #[serde(default = "default_bucket_policy")]
1260    pub policy: String,
1261}
1262
1263fn default_bucket_policy() -> String {
1264    "private".to_string()
1265}
1266
1267/// Per-service config from `.yah/cloud/services/<name>.toml`.
1268///
1269/// **Deprecated.** The `services/` layout was replaced by `workloads/` in R092-F1.
1270/// Kept to allow in-place reads for repos that haven't migrated yet; use
1271/// `yah cloud config migrate-services-to-workloads` to upgrade.
1272#[derive(Debug, Clone, Serialize, Deserialize)]
1273pub struct LegacyServiceConfig {
1274    pub name: String,
1275    pub image: String,
1276    pub version: String,
1277    #[serde(default)]
1278    pub env: HashMap<String, String>,
1279    #[serde(default)]
1280    pub ports: Vec<PortMapping>,
1281    #[serde(default)]
1282    pub mesh_only: bool,
1283    /// Network interface this service binds to exclusively (e.g. `"tailscale0"`).
1284    ///
1285    /// When set the compose renderer emits `network_mode: "host"` and the
1286    /// service is NOT joined to the shared compose bridge network. The service
1287    /// process must bind its listen socket to the named interface's IP — for
1288    /// Postgres this means setting `POSTGRES_LISTEN_ADDRESSES` to the node's
1289    /// `tailscale ip --4` output at first boot. See [`crate::mesh_service`] for
1290    /// the standard pg_hba.conf snippet and ufw rules to pair with this field.
1291    #[serde(default, skip_serializing_if = "Option::is_none")]
1292    pub bind_interface: Option<String>,
1293
1294    /// Tenant this service belongs to (W206 isolation axis). Absent in the
1295    /// service TOML → [`TenantId::singleton`], keeping single-tenant machines
1296    /// on one shared compose network. When a machine hosts services from two
1297    /// or more distinct tenants, the compose renderer (R558-T2) splits them
1298    /// into per-tenant `<tenant>-<tier>` networks so cross-tenant stacks on the
1299    /// same host are not bridged together.
1300    #[serde(default = "TenantId::singleton")]
1301    pub tenant: TenantId,
1302}
1303
1304#[derive(Debug, Clone, Serialize, Deserialize)]
1305pub struct PortMapping {
1306    pub host: u16,
1307    pub container: u16,
1308}
1309
1310/// A loaded service plus its per-environment mirrors.
1311///
1312/// Wraps the `service.toml` body and the directory of `mirrors/<env>.toml`
1313/// files that project the service onto concrete infra.
1314#[derive(Debug, Clone, Serialize, Deserialize)]
1315pub struct ServiceWithMirrors {
1316    pub service: ServiceConfig,
1317    /// Mirrors keyed by environment name (file stem of `mirrors/<env>.toml`).
1318    pub mirrors: BTreeMap<String, MirrorConfig>,
1319    /// Transform recipe names keyed by component id. Populated from each
1320    /// static-asset component's `workload.toml` at load time — not stored
1321    /// in service.toml. Only present for components that declare
1322    /// `[asset.derive.transform] recipe = "..."`.
1323    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1324    pub component_transform_recipes: BTreeMap<String, String>,
1325    /// Nodes each mirror's passway front door is placed on, keyed by env —
1326    /// exactly what [`MirrorConfig::passway_machines`] returns, with the envs
1327    /// that declare no passway edge left out.
1328    ///
1329    /// Derived at load time like `component_transform_recipes` above: it is
1330    /// stored in no TOML file. It exists so that a consumer of this wire type —
1331    /// the desktop `service_list` command, and through it the Services tab's
1332    /// custom-domain panel — never reconciles the two `ingress` spellings
1333    /// itself. An env present here with an **empty** list is a passway edge
1334    /// whose placement is co-located rather than declared; see
1335    /// [`MirrorConfig::passway_machines`] for why that is a different answer
1336    /// from being absent.
1337    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1338    pub passway_machines: BTreeMap<String, Vec<String>>,
1339}
1340
1341/// All cloud config loaded from a workspace root (the parent of `.yah/`).
1342///
1343/// Reads two trees:
1344/// - `.yah/infra/` — `machines/`, `providers/`
1345/// - `.yah/services/<svc>/` — `service.toml` + `mirrors/<env>.toml`
1346///
1347/// Pre-R215 fields (`legacy_mirrors`, `legacy_services`, `workloads`,
1348/// `topology`) are still populated from `.yah/cloud/` when present so
1349/// pre-R215 callers (compose.rs, bucket commands) keep compiling — they
1350/// just see empty collections in a post-B1 workspace where the legacy
1351/// data was deleted. These fields are scheduled for removal in B3-T3.
1352#[derive(Debug)]
1353pub struct CloudConfig {
1354    /// Workspace root that was loaded — useful for path-resolving
1355    /// component references on a [`ServiceComponent`].
1356    pub workspace_root: std::path::PathBuf,
1357
1358    // ─── R215+ tree ────────────────────────────────────────────────────────
1359    /// `.yah/infra/machines/<name>.toml`
1360    pub machines: Vec<MachineConfig>,
1361    /// `.yah/infra/providers/<id>.toml`
1362    pub providers: Vec<ProviderConfig>,
1363    /// Provenance for every entry in `machines` that came from a linked
1364    /// `.yah/infra/sources.toml` source rather than this camp's own
1365    /// `.yah/infra/machines/` (R615-F2 / W274). Keyed by
1366    /// [`MachineConfig::name`]; a name absent here is camp-local. Empty from
1367    /// [`CloudConfig::load_from_config_dir`] — see its doc for why sources
1368    /// don't apply to multi-root sibling trees.
1369    pub machine_origins: BTreeMap<String, InfraOrigin>,
1370    /// Same as [`machine_origins`](Self::machine_origins), keyed by
1371    /// [`ProviderConfig::id`].
1372    pub provider_origins: BTreeMap<String, InfraOrigin>,
1373    /// `.yah/services/<svc>/` — service.toml plus mirrors/<env>.toml.
1374    pub services: BTreeMap<String, ServiceWithMirrors>,
1375    /// `.yah/domains/<name>.toml` — public-facing routing manifests
1376    /// (R347). Single file per domain; no nested per-env tree because
1377    /// domains themselves aren't projected onto infra — they describe
1378    /// how a Worker bundle ingresses requests onto services.
1379    pub domains: BTreeMap<String, DomainConfig>,
1380
1381    // ─── Pre-R215 legacy (slated for removal in B3-T3) ────────────────────
1382    /// Legacy mirrors from `.yah/cloud/mirrors/`.
1383    pub legacy_mirrors: Vec<LegacyMirrorConfig>,
1384    /// Workloads from `.yah/cloud/workloads/*.toml` (R092-F1 schema).
1385    pub workloads: Vec<WorkloadConfig>,
1386    /// Topology from `.yah/cloud/topology.toml` (mirror→machine assignments).
1387    pub topology: TopologyConfig,
1388    /// Legacy services from `.yah/cloud/services/*.toml` (pre-R092 layout).
1389    pub legacy_services: Vec<LegacyServiceConfig>,
1390}
1391
1392impl CloudConfig {
1393    /// Load all cloud config rooted at `workspace_root` (the parent of `.yah/`).
1394    ///
1395    /// Reads the R215+ tree (`.yah/infra/`, `.yah/services/<svc>/`) eagerly
1396    /// and the pre-R215 `.yah/cloud/` tree opportunistically. Returns `Err`
1397    /// immediately if any TOML fails to parse or a workload TOML fails
1398    /// shape validation; the error includes the file path and field path.
1399    ///
1400    /// Cross-ref validation runs after both trees finish loading: every
1401    /// `mirror.providers.X.use = "<id>"` must resolve to a real provider
1402    /// declared under `.yah/infra/providers/`.
1403    ///
1404    /// R844-B7 — **a missing `.yah/` is a wrong-root error, not an empty
1405    /// fleet.** Every sub-loader below tolerates a missing directory by
1406    /// returning empty, so before this check a call against the wrong
1407    /// directory produced a perfectly valid `CloudConfig` with zero machines,
1408    /// zero services and zero providers. Nothing downstream can tell that
1409    /// apart from a camp that genuinely declares nothing, so the failure
1410    /// surfaces as an operation that silently does nothing to nothing: a
1411    /// collate that renders no backends, a fanout that asks no nodes, a
1412    /// rollout that plans against an empty fleet. It was found the hard way —
1413    /// a live-fleet test in `app/yah/cli` called this with `"."`, which under
1414    /// `cargo test` is the *package* root, and passed while measuring nothing.
1415    ///
1416    /// The line is drawn at `.yah/` and only there: a workspace whose
1417    /// `.yah/infra/machines/` is absent or empty is a real, if unusual, camp
1418    /// with an empty fleet and still loads. `unknown` is not `answered with
1419    /// none`.
1420    pub fn load(workspace_root: &Path) -> Result<Self> {
1421        let yah_dir = crate::paths::yah_dir(workspace_root);
1422        if !yah_dir.is_dir() {
1423            anyhow::bail!(
1424                "not a yah workspace: no {} — expected the camp root (the parent \
1425                 of `.yah/`), got {}. This is a wrong-root error, not an empty \
1426                 fleet; a camp with no machines declared still has a `.yah/`.",
1427                yah_dir.display(),
1428                workspace_root.display(),
1429            );
1430        }
1431
1432        let mut providers = load_providers(&crate::paths::providers_dir(workspace_root))?;
1433        let services = load_services(&crate::paths::services_dir(workspace_root), workspace_root)?;
1434        let domains = load_domains(&crate::paths::domains_dir(workspace_root))?;
1435
1436        Self::cross_ref_validate(&providers, &services, &domains)?;
1437
1438        // Legacy `.yah/cloud/` reads — empty in post-B1 workspaces. Wrapped in
1439        // a helper so a missing tree is silent (no error, no warning).
1440        let cloud_dir = crate::paths::legacy_cloud_dir(workspace_root);
1441        let (legacy_machines, legacy_mirrors, legacy_workloads, topology, legacy_services) =
1442            if cloud_dir.exists() {
1443                (
1444                    load_dir::<MachineConfig>(cloud_dir.join("machines"))?,
1445                    load_mirrors(cloud_dir.join("mirrors"))?,
1446                    load_workloads(cloud_dir.join("workloads"))?,
1447                    load_topology(cloud_dir.join("topology.toml"))?,
1448                    load_dir::<LegacyServiceConfig>(cloud_dir.join("services"))?,
1449                )
1450            } else {
1451                Default::default()
1452            };
1453
1454        // Workloads come from `.yah/infra/workloads/` (R215+). R568-T7: before
1455        // that path was read here, this field was populated *only* from the
1456        // legacy tree above — which R222-B1 emptied — so `cfg.workload(name)`
1457        // resolved nothing in every post-R215 camp and `yah cloud workload
1458        // deploy` could not find any declaration at all. The bug survived
1459        // because the only workloads ever deployed were forge/QED runs, which
1460        // build their spec in memory and never come through here. Same
1461        // dedupe-by-name shape as machines below: R215+ wins.
1462        let mut workloads = load_workloads(crate::paths::workloads_dir(workspace_root))?;
1463        let workload_names: std::collections::HashSet<String> =
1464            workloads.iter().map(|w| w.spec.name.clone()).collect();
1465        for w in legacy_workloads {
1466            if !workload_names.contains(&w.spec.name) {
1467                workloads.push(w);
1468            }
1469        }
1470
1471        // Machines come from `.yah/infra/machines/` (R215+); the pre-R215
1472        // tree shouldn't have any since B1 moved them, but if it does we
1473        // dedupe by name (R215 wins).
1474        let mut machines = load_dir::<MachineConfig>(crate::paths::machines_dir(workspace_root))?;
1475        let names: std::collections::HashSet<String> =
1476            machines.iter().map(|m| m.name.clone()).collect();
1477        for m in legacy_machines {
1478            if !names.contains(&m.name) {
1479                machines.push(m);
1480            }
1481        }
1482
1483        // R615-F2: overlay every linked `.yah/infra/sources.toml` source's
1484        // machines/providers UNDER what's already loaded above, so camp-local
1485        // (including the legacy-tree entries just merged in) always wins on a
1486        // name collision. `SourcesConfig::load` itself never touches the
1487        // network — git sources are read from `yah infra sync`'s cache
1488        // (R615-T3), so this call keeps `load()`'s whole offline contract.
1489        let sources = SourcesConfig::load(&crate::paths::infra_dir(workspace_root))?;
1490        let mut machine_origins = BTreeMap::new();
1491        let mut provider_origins = BTreeMap::new();
1492        overlay_infra_sources(
1493            workspace_root,
1494            &sources,
1495            &mut machines,
1496            &mut providers,
1497            &mut machine_origins,
1498            &mut provider_origins,
1499        );
1500
1501        Ok(Self {
1502            workspace_root: workspace_root.to_path_buf(),
1503            machines,
1504            providers,
1505            machine_origins,
1506            provider_origins,
1507            services,
1508            domains,
1509            legacy_mirrors,
1510            workloads,
1511            topology,
1512            legacy_services,
1513        })
1514    }
1515
1516    /// Load the R215+ tree (`infra/`, `services/`, `domains/`) rooted at an
1517    /// arbitrary config directory instead of the hardcoded `.yah/`. This is the
1518    /// building block for multi-root deployments (W206 config layout (b), sibling
1519    /// `.noisetable/` trees) — see [`crate::multi_root`]. Part of R558-F4.
1520    ///
1521    /// `config_dir` is the `.X/` directory itself (e.g. `<parent>/.noisetable`);
1522    /// `workspace_root` remains the camp dir (the config dir's parent) so a
1523    /// component's `path` reference resolves against the same tree the classic
1524    /// [`CloudConfig::load`] uses. The legacy `.yah/cloud/` reads are skipped —
1525    /// multi-root deployments are post-R215 by construction — so `legacy_*`,
1526    /// `workloads`, and `topology` come back empty. Machines are read from
1527    /// `config_dir/infra/machines` directly (sibling trees declare their own
1528    /// inventory or none).
1529    ///
1530    /// R615-F2 decision, explicit rather than silent: **sources.toml overlay
1531    /// does NOT apply here.** This function
1532    /// exists specifically because a multi-root sibling tree (W206 layout
1533    /// (b), e.g. `.noisetable/`) is a *second config root inside the same
1534    /// camp*, not a second camp — `config_dir` is already wherever the
1535    /// caller decided this tree's infra lives, and `.yah/infra/sources.toml`
1536    /// (singular, tied to `paths::infra_dir(workspace_root)`) has no
1537    /// well-defined meaning for an arbitrary `config_dir` that isn't that
1538    /// path. A sibling tree that wants borrowed infra declares its own
1539    /// `sources.toml` under whichever root actually calls
1540    /// [`CloudConfig::load`] for it; `machine_origins`/`provider_origins`
1541    /// come back empty here, not wrong — there is nothing to overlay.
1542    pub fn load_from_config_dir(config_dir: &Path, workspace_root: &Path) -> Result<Self> {
1543        let providers = load_providers(&config_dir.join("infra").join("providers"))?;
1544        let services = load_services(&config_dir.join("services"), workspace_root)?;
1545        let domains = load_domains(&config_dir.join("domains"))?;
1546
1547        Self::cross_ref_validate(&providers, &services, &domains)?;
1548
1549        let machines = load_dir::<MachineConfig>(config_dir.join("infra").join("machines"))?;
1550
1551        Ok(Self {
1552            workspace_root: workspace_root.to_path_buf(),
1553            machines,
1554            providers,
1555            machine_origins: BTreeMap::new(),
1556            provider_origins: BTreeMap::new(),
1557            services,
1558            domains,
1559            legacy_mirrors: vec![],
1560            workloads: vec![],
1561            topology: TopologyConfig::default(),
1562            legacy_services: vec![],
1563        })
1564    }
1565
1566    /// Cross-reference validation shared by [`CloudConfig::load`] and
1567    /// [`CloudConfig::load_from_config_dir`]: every mirror `providers.X.use =
1568    /// "<id>"` must resolve to a declared provider, and every domain route's
1569    /// `component = "<service>/<component-id>"` must resolve to a real component.
1570    fn cross_ref_validate(
1571        providers: &[ProviderConfig],
1572        services: &BTreeMap<String, ServiceWithMirrors>,
1573        domains: &BTreeMap<String, DomainConfig>,
1574    ) -> Result<()> {
1575        // Mirror `use = "<id>"` slots must resolve to a declared provider.
1576        let provider_ids: std::collections::HashSet<&str> =
1577            providers.iter().map(|p| p.id.as_str()).collect();
1578        for (svc_name, svc) in services {
1579            for (env, mirror) in &svc.mirrors {
1580                for (slot, body) in &mirror.providers {
1581                    if let Some(id) = body.provider_id() {
1582                        if !provider_ids.contains(id) {
1583                            anyhow::bail!(
1584                                "services/{svc_name}/mirrors/{env}.toml: \
1585                                 providers.{slot}.use = \"{id}\" — no such provider; \
1586                                 declare it at infra/providers/{id}.toml"
1587                            );
1588                        }
1589                    }
1590                }
1591                // An `[[ingress]]` edge's own `use` is the same kind of
1592                // reference (R845) and gets the same check: a typo there is
1593                // otherwise invisible until `yah cloud apply` reaches the
1594                // Cloudflare arm and fails on a missing provider file.
1595                for (idx, edge) in mirror.ingress_edge_slice().iter().enumerate() {
1596                    if let Some(id) = edge.provider_id.as_deref() {
1597                        if !provider_ids.contains(id) {
1598                            anyhow::bail!(
1599                                "services/{svc_name}/mirrors/{env}.toml: \
1600                                 ingress[{idx}].use = \"{id}\" — no such provider; \
1601                                 declare it at infra/providers/{id}.toml"
1602                            );
1603                        }
1604                    }
1605                }
1606            }
1607        }
1608
1609        // Every domain route's `component = "<service>/<component-id>"` must
1610        // resolve to a real component.
1611        for (dom_name, dom) in domains {
1612            for (idx, route) in dom.routes.iter().enumerate() {
1613                let Some(component_ref) = route.mode.component() else {
1614                    continue; // redirects don't reference components
1615                };
1616                let Some((svc_name, comp_id)) = split_component_ref(component_ref) else {
1617                    anyhow::bail!(
1618                        "domains/{dom_name}.toml: routes[{idx}].component = \
1619                         \"{component_ref}\" — expected \"<service>/<component-id>\""
1620                    );
1621                };
1622                let Some(svc) = services.get(svc_name) else {
1623                    anyhow::bail!(
1624                        "domains/{dom_name}.toml: routes[{idx}].component = \
1625                         \"{component_ref}\" — no such service \"{svc_name}\" \
1626                         under services/"
1627                    );
1628                };
1629                let Some(component) = svc.service.components.iter().find(|c| c.id == comp_id)
1630                else {
1631                    anyhow::bail!(
1632                        "domains/{dom_name}.toml: routes[{idx}].component = \
1633                         \"{component_ref}\" — service \"{svc_name}\" has no \
1634                         component with id \"{comp_id}\""
1635                    );
1636                };
1637
1638                // R746: a mounted component must be routed where it publishes.
1639                // The publisher writes its bundle under the mount and the front
1640                // door looks a request up by its own path, so a route path and
1641                // a mount that disagree produce a 404 with its cause two files
1642                // away. Checked in both directions, since either one alone is
1643                // the same silent miss.
1644                //
1645                // Static routes only: `mount` is a *storage* prefix, and a
1646                // backend route proxies to an origin that owns its own paths.
1647                if !matches!(route.mode, RouteMode::Static { .. }) {
1648                    continue;
1649                }
1650                let mount = component.mount.as_deref().map(normalize_mount);
1651                let route_prefix = route_path_prefix(&route.path);
1652                if let Some(mount) = mount {
1653                    if mount != route_prefix {
1654                        anyhow::bail!(
1655                            "domains/{dom_name}.toml: routes[{idx}].path = \
1656                             \"{path}\" serves \"{component_ref}\", which \
1657                             declares mount = \"/{mount}\" — a mounted \
1658                             component publishes under its mount, so the route \
1659                             must be \"/{mount}\" or \"/{mount}/*\" (or drop \
1660                             the mount to serve from the service root)",
1661                            path = route.path,
1662                        );
1663                    }
1664                } else if !route_prefix.is_empty() {
1665                    anyhow::bail!(
1666                        "domains/{dom_name}.toml: routes[{idx}].path = \
1667                         \"{path}\" serves \"{component_ref}\", which declares \
1668                         no `mount` — its bundle publishes at the service root, \
1669                         so nothing is stored under \"/{route_prefix}\". Set \
1670                         mount = \"/{route_prefix}\" on the component, or route \
1671                         it at \"/*\"",
1672                        path = route.path,
1673                    );
1674                }
1675            }
1676        }
1677        Ok(())
1678    }
1679
1680    /// Look up a domain manifest by name (file stem under `.yah/domains/`).
1681    pub fn domain(&self, name: &str) -> Option<&DomainConfig> {
1682        self.domains.get(name)
1683    }
1684
1685    pub fn machine(&self, name: &str) -> Option<&MachineConfig> {
1686        self.machines.iter().find(|m| m.name == name)
1687    }
1688
1689    /// Look up a provider by id (matches `provider.id`, not the file stem).
1690    pub fn provider(&self, id: &str) -> Option<&ProviderConfig> {
1691        self.providers.iter().find(|p| p.id == id)
1692    }
1693
1694    /// Look up a service by name (matches `service.toml`'s `name` field).
1695    pub fn service(&self, name: &str) -> Option<&ServiceWithMirrors> {
1696        self.services.get(name)
1697    }
1698
1699    /// Look up a legacy mirror by camp name (pre-R215 .yah/cloud/mirrors/).
1700    pub fn legacy_mirror(&self, camp: &str) -> Option<&LegacyMirrorConfig> {
1701        self.legacy_mirrors.iter().find(|m| m.camp == camp)
1702    }
1703
1704    pub fn workload(&self, name: &str) -> Option<&WorkloadConfig> {
1705        self.workloads.iter().find(|w| w.spec.name == name)
1706    }
1707
1708    /// Every machine declaring `sovereign_group == group`, in declaration order.
1709    ///
1710    /// W305/R742-F3. A sovereign group has no file of its own — it exists only
1711    /// as the set of machines that name the same string — so "which boxes are
1712    /// the dev cluster" has to be *derived*, and before this it was not derived
1713    /// anywhere: `yah cloud rollout plan` still takes a hand-listed
1714    /// `--voter us-west-011 --voter us-west-013 …` for a fact the machine TOMLs
1715    /// already state (W314 gap 1).
1716    ///
1717    /// **This is not placement.** Resolving a group to its members is a
1718    /// *lookup*, and it stays outside [`RequiredSpec`] on purpose — see
1719    /// [`MachineConfig::sovereign_group`]. `migrate` calls this to pick the
1720    /// candidate set it then admits a workload against; nothing here filters
1721    /// scheduling, and adding `sovereign_group` to `matches` would still be the
1722    /// category error that doc warns about.
1723    ///
1724    /// An empty result means no machine declares `group`, which is
1725    /// indistinguishable from a typo — callers should say so with
1726    /// [`Self::declared_sovereign_groups`] rather than reporting "no
1727    /// candidates".
1728    pub fn machines_in_group(&self, group: &str) -> Vec<&MachineConfig> {
1729        self.machines
1730            .iter()
1731            .filter(|m| m.sovereign_group.as_deref() == Some(group))
1732            .collect()
1733    }
1734
1735    /// Every distinct `sovereign_group` declared by any machine, sorted.
1736    ///
1737    /// Exists so a bad `--to` names the real vocabulary instead of complaining
1738    /// abstractly — the same fail-loud shape [`taint_effect`]'s legal-key list
1739    /// gives `check_inert_taints`. Standalone machines (`None`) contribute
1740    /// nothing: "in no group" is not a group you can migrate *to*.
1741    pub fn declared_sovereign_groups(&self) -> Vec<&str> {
1742        let mut groups: Vec<&str> = self
1743            .machines
1744            .iter()
1745            .filter_map(|m| m.sovereign_group.as_deref())
1746            .collect();
1747        groups.sort_unstable();
1748        groups.dedup();
1749        groups
1750    }
1751
1752    /// F16 placement v1: the first machine satisfying every hard axis of `req`
1753    /// (region/zone/provider membership + mesh_tags superset). Declaration order
1754    /// in `.yah/infra/machines/` decides ties — deterministic-greedy, no
1755    /// backtracking. A fully-unconstrained `req` matches the first machine.
1756    ///
1757    /// Fails loud with the constraint summary and the candidate machine names
1758    /// when nothing matches, so `yah cloud apply` surfaces *why* placement
1759    /// failed instead of a silent empty set.
1760    pub fn resolve_machine(&self, req: &RequiredSpec) -> Result<&MachineConfig> {
1761        resolve_machine_among(&self.machines, req)
1762    }
1763
1764    /// F16 placement at horizontal scale: the first
1765    /// [`RequiredSpec::replica_count`] machines satisfying every hard axis of
1766    /// `req`, in declaration order (R844-F8).
1767    ///
1768    /// The N-valued form of [`Self::resolve_machine`], which is the N=1 case of
1769    /// this and not a different selector — both land in [`select_matching`].
1770    /// That shared bottom is what makes the deploy resolver
1771    /// (`reconciler::mesofact_bundle::resolve_bundle_machines`, which calls
1772    /// this) and the ingress planner's
1773    /// (`reconciler::ingress::resolve_ingress_placements`, which calls
1774    /// [`resolve_machines_among`] over the same `machines` slice) agree on the
1775    /// same N machines **by construction**. They must agree set-for-set, not
1776    /// merely in count: a front door aimed at nodes the workload was never
1777    /// deployed to renders a *subset* of the backends, which is the failure that
1778    /// looks like it worked.
1779    pub fn resolve_machines(&self, req: &RequiredSpec) -> Result<Vec<&MachineConfig>> {
1780        resolve_machines_among(&self.machines, req)
1781    }
1782
1783    /// F16 placement: first machine whose `mesh_tags` is a superset of
1784    /// `required`. Declaration order in `.yah/infra/machines/` decides ties.
1785    /// Empty `required` matches the first machine; callers should treat
1786    /// empty-required as "no constraint" and skip this lookup.
1787    ///
1788    /// Back-compat thin wrapper over [`CloudConfig::resolve_machine`] for the
1789    /// mesh-tags-only call sites that predate the topology axes.
1790    pub fn resolve_machine_by_mesh_tags(&self, required: &[String]) -> Option<&MachineConfig> {
1791        let req = RequiredSpec {
1792            mesh_tags: required.to_vec(),
1793            ..Default::default()
1794        };
1795        self.resolve_machine(&req).ok()
1796    }
1797
1798    /// Admission: resolve the target machine for a remote [`WorkloadSpec`],
1799    /// honoring the R594 mesh-tag node-selector annotation
1800    /// (`velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION` =
1801    /// `yah.node-selector.mesh-tags`, comma-joined).
1802    ///
1803    /// The producer side (`velveteen_exec::remote::build_workload_spec`, R594) writes
1804    /// `TaskLocation::RemoteAny.mesh_tags` — e.g. `[tag:build-worker, arch:x86]`
1805    /// from [`qed::platform::build_worker_mesh_tags`] — into the workload's
1806    /// annotations. This is the consumer: candidates are restricted to machines
1807    /// whose `mesh_tags` are a **superset** of the requested set, so an amd64
1808    /// build lands on the `arch:x86` build-worker (us-west-002) and an arm64
1809    /// build on a `arch:arm` Pi5. Declaration order in `.yah/infra/machines/`
1810    /// breaks ties.
1811    ///
1812    /// An absent or empty annotation means "no mesh-tag constraint" — pre-R594
1813    /// behavior (any node), matching [`RequiredSpec::is_unconstrained`].
1814    ///
1815    /// This is the single admission seam: R572-F5 extends it with the capacity
1816    /// floor (workload request fits node allocatable−committed) and taint
1817    /// repulsion/affinity by enriching [`RequiredSpec::matches`] /
1818    /// [`Self::resolve_machine`]. Do not fork a second selector.
1819    pub fn admit_workload(&self, ws: &WorkloadSpec) -> Result<&MachineConfig> {
1820        self.resolve_machine(&admission_spec(ws, &self.workloads))
1821    }
1822
1823    /// Every machine that admits `ws`, in declaration order — the *pool*
1824    /// [`Self::admit_workload`] returns the head of (R605-T14).
1825    ///
1826    /// # Why a pool and not just the winner
1827    ///
1828    /// `tag:build-worker` is a statement that the tagged boxes are
1829    /// **interchangeable**: a build is booked against the tag, not against
1830    /// `us-west-002`. Returning one machine forced every caller to act as if it
1831    /// were booked against a name, and admission has no liveness input — so a
1832    /// tagged box that is asleep won the file-name tie-break and its builds
1833    /// failed rather than landing on the identical box next to it. That is
1834    /// exactly what happened on 2026-09-03 when `us-west-002` regained the tag.
1835    ///
1836    /// The fix is **not** to teach this function about liveness. It stays a pure
1837    /// function of the declared inventory (see `xtask/tests/fleet_build_placement.rs`
1838    /// on why a placement pin that needs the network is a flake). It hands the
1839    /// dispatcher the whole interchangeable set instead, and the dispatcher —
1840    /// which has the network — probes and fails over within it:
1841    /// `app/yah/cli/src/yubaba_client.rs`'s `MeshYubabaClient::deploy`.
1842    ///
1843    /// Order is the declaration order `admit_workload` already used, and callers
1844    /// should preserve it as their preference order rather than load-balancing
1845    /// across it: a retried build wants the node still holding its warm
1846    /// `target/`, which is the same reason [`first_match`] is deliberately
1847    /// first-fit.
1848    ///
1849    /// `Err` — never `Ok(vec![])` — when nothing admits `ws`, carrying the same
1850    /// message [`Self::admit_workload`] would have produced. "No node admits
1851    /// this" and "the pool is empty" are the same failure and must read the same.
1852    pub fn admit_workload_candidates(&self, ws: &WorkloadSpec) -> Result<Vec<&MachineConfig>> {
1853        let req = admission_spec(ws, &self.workloads);
1854        let all: Vec<&MachineConfig> = self.machines.iter().collect();
1855        let matched = matching(&all, &req);
1856        if matched.is_empty() {
1857            // Delegate the wording so the two paths cannot drift apart.
1858            return Err(first_match(&all, &req, DECLARED_POOL, EMPTY_DECLARED_POOL)
1859                .expect_err("matching() found nothing, so first_match cannot succeed"));
1860        }
1861        Ok(matched)
1862    }
1863
1864    /// [`Self::admit_workload`] restricted to the machines of one sovereign
1865    /// group (W305/R742-F3, `yah cloud migrate --to <group>`).
1866    ///
1867    /// Same [`RequiredSpec`], same [`RequiredSpec::matches`], same
1868    /// declaration-order tie-break — only the candidate *set* differs. That is
1869    /// the whole reason this is a narrowing of the admission seam rather than a
1870    /// second selector: a workload that cannot be scheduled onto a group's
1871    /// boxes must fail here for exactly the reason it would fail anywhere else,
1872    /// and `no-appliance` on the dev Pis (W305 finding 2) is precisely the case
1873    /// that must not be silently routed around by a migration verb.
1874    ///
1875    /// `Err` when the group has no members *or* when no member admits `ws`; the
1876    /// two are different mistakes, so callers wanting to tell them apart should
1877    /// check [`Self::machines_in_group`] first.
1878    pub fn admit_workload_in_group(
1879        &self,
1880        ws: &WorkloadSpec,
1881        group: &str,
1882    ) -> Result<&MachineConfig> {
1883        let members = self.machines_in_group(group);
1884        let empty_pool = format!(
1885            "(no machine declares sovereign_group = \"{group}\" — declared groups: {})",
1886            match self.declared_sovereign_groups().as_slice() {
1887                [] => "(none)".to_string(),
1888                gs => gs.join(", "),
1889            }
1890        );
1891        first_match(
1892            &members,
1893            &admission_spec(ws, &self.workloads),
1894            &format!("machines in sovereign group '{group}'"),
1895            &empty_pool,
1896        )
1897    }
1898}
1899
1900/// **The** placement selector: the first candidate satisfying every axis of
1901/// `req`, declaration order breaking ties, deterministic-greedy with no
1902/// backtracking.
1903///
1904/// Every path that picks a machine goes through here, and the only thing any
1905/// of them varies is *which machines are candidates* — never the predicate.
1906/// [`CloudConfig::resolve_machine`] passes the whole fleet;
1907/// [`CloudConfig::admit_workload_in_group`] passes one sovereign group's
1908/// members. That split is the point: a candidate-set narrowing composes with
1909/// the [`RequiredSpec`] axes for free, whereas expressing the same narrowing
1910/// *as* an axis would put facts like blast radius into a filter they must
1911/// never be in (see [`MachineConfig::sovereign_group`]).
1912///
1913/// So a new placement scope is a new candidate set plus a `pool` label, and a
1914/// new placement *constraint* is a field on [`RequiredSpec`] — those are the
1915/// two extension points, and neither is a second selector. `pool` and
1916/// `empty_pool` exist only so the failure names the set it actually searched;
1917/// a refusal that says "no candidates" without saying *among what* is one the
1918/// operator has to reconstruct by hand.
1919/// F16 placement v1 resolution over an explicit machine list — the
1920/// `.machines`-only half of [`CloudConfig::resolve_machine`], for callers that
1921/// have loaded just the machines tree rather than the whole cross-ref-validated
1922/// config.
1923///
1924/// R772: `resolve_ingress_placements` (`reconciler::ingress`) is the reason
1925/// this is `pub(crate)` rather than staying folded into
1926/// `CloudConfig::resolve_machine` — ingress collation walks every mirror in
1927/// the workspace and has no business hard-failing over an unrelated mirror's
1928/// `providers.X.use = "<id>"` typo, which is what going through
1929/// `CloudConfig::load`'s cross-ref validation would do. "Do not fork a second
1930/// selector" (see the module doc above) still holds: this is the *same*
1931/// [`first_match`], just handed a narrower candidate set than `self.machines`.
1932pub(crate) fn resolve_machine_among<'a>(
1933    machines: &'a [MachineConfig],
1934    req: &RequiredSpec,
1935) -> Result<&'a MachineConfig> {
1936    let all: Vec<&MachineConfig> = machines.iter().collect();
1937    first_match(&all, req, DECLARED_POOL, EMPTY_DECLARED_POOL)
1938}
1939
1940/// R844-F8: [`resolve_machine_among`] widened to the constraint's own replica
1941/// count — the first [`RequiredSpec::replica_count`] matching machines, in the
1942/// same declaration order, from the same candidate slice.
1943///
1944/// **The one entry point both resolvers share.**
1945/// `reconciler::ingress::resolve_ingress_placements` calls this directly and
1946/// `reconciler::mesofact_bundle::resolve_bundle_machines` reaches it through
1947/// [`CloudConfig::resolve_machines`], both over `cfg.machines` — so the ingress
1948/// planner and the deployer cannot pick different subsets. That is a structural
1949/// guarantee, not a tested coincidence, and it has to be: discovery aimed at a
1950/// node the bundle was never placed on publishes a hostname with a dead
1951/// backend behind it, and at scale > 1 the front door still answers from the
1952/// nodes that *did* get it.
1953///
1954/// Determinism is therefore part of correctness here. `machines` arrives in
1955/// file-name order (`load_dir`, pinned by
1956/// `machines_load_in_file_name_order_not_read_dir_order`), and selection is a
1957/// stable prefix of that order — so "the first two matching" is the same two
1958/// on both sides of the same tree.
1959pub(crate) fn resolve_machines_among<'a>(
1960    machines: &'a [MachineConfig],
1961    req: &RequiredSpec,
1962) -> Result<Vec<&'a MachineConfig>> {
1963    let all: Vec<&MachineConfig> = machines.iter().collect();
1964    select_matching(
1965        &all,
1966        req,
1967        req.replica_count(),
1968        DECLARED_POOL,
1969        EMPTY_DECLARED_POOL,
1970    )
1971}
1972
1973const DECLARED_POOL: &str = "declared machines";
1974const EMPTY_DECLARED_POOL: &str = "(no machines declared under .yah/infra/machines/)";
1975
1976fn first_match<'a>(
1977    candidates: &[&'a MachineConfig],
1978    req: &RequiredSpec,
1979    pool: &str,
1980    empty_pool: &str,
1981) -> Result<&'a MachineConfig> {
1982    Ok(select_matching(candidates, req, 1, pool, empty_pool)?
1983        .into_iter()
1984        .next()
1985        .expect("select_matching errors rather than returning short"))
1986}
1987
1988/// The N-selecting core of the placement selector: the first `want` candidates
1989/// satisfying `req`, in candidate order (R844-F8).
1990///
1991/// [`first_match`] is this with `want = 1`, which is why widening a caller to a
1992/// replica count cannot introduce a second selector — the predicate, the
1993/// ordering and the failure vocabulary are all one implementation.
1994///
1995/// **A shortfall is an error.** Matching one machine when two were asked for
1996/// returns `Err` naming both numbers and the pool searched, never a one-element
1997/// vec: a half-placed workload that reports success is worse than a failed
1998/// apply, because the front door then publishes a hostname whose backend set is
1999/// quietly smaller than declared. `want = 0` is the same mistake spelled
2000/// differently and is refused for the same reason.
2001fn select_matching<'a>(
2002    candidates: &[&'a MachineConfig],
2003    req: &RequiredSpec,
2004    want: usize,
2005    pool: &str,
2006    empty_pool: &str,
2007) -> Result<Vec<&'a MachineConfig>> {
2008    let names = || {
2009        if candidates.is_empty() {
2010            empty_pool.to_string()
2011        } else {
2012            candidates
2013                .iter()
2014                .map(|m| m.name.as_str())
2015                .collect::<Vec<_>>()
2016                .join(", ")
2017        }
2018    };
2019
2020    if want == 0 {
2021        anyhow::bail!(
2022            "replicas = 0 places {} on nothing — a placement that deploys to no machine is \
2023             a typo, not a scale-down; remove the slot instead",
2024            req.describe()
2025        );
2026    }
2027
2028    let mut matched = matching(candidates, req);
2029    if matched.len() >= want {
2030        matched.truncate(want);
2031        return Ok(matched);
2032    }
2033
2034    if want == 1 {
2035        anyhow::bail!(
2036            "no candidates matching {} — {pool}: {}",
2037            req.describe(),
2038            names()
2039        );
2040    }
2041    anyhow::bail!(
2042        "only {} of {want} machines match {} — placing fewer than the declared \
2043         `replicas = {want}` would publish a smaller backend set than the mirror asks for; \
2044         {pool}: {}",
2045        matched.len(),
2046        req.describe(),
2047        names()
2048    )
2049}
2050
2051/// The predicate itself, applied to every candidate in order — the one place
2052/// `req.matches` is called on a set.
2053///
2054/// [`select_matching`] takes a prefix of this; [`CloudConfig::admit_workload_candidates`]
2055/// takes all of it. Keeping both on this function is what makes "the pool the
2056/// dispatcher failed over within" and "the machine admission picked" the same
2057/// answer by construction rather than by two filters that happen to agree.
2058fn matching<'a>(candidates: &[&'a MachineConfig], req: &RequiredSpec) -> Vec<&'a MachineConfig> {
2059    candidates
2060        .iter()
2061        .copied()
2062        .filter(|m| req.matches(m))
2063        .collect()
2064}
2065
2066/// The [`RequiredSpec`] a workload is admitted against — the single place the
2067/// axes are derived from a [`WorkloadSpec`].
2068///
2069/// Extracted from [`CloudConfig::admit_workload`] so that
2070/// [`CloudConfig::admit_workload_in_group`] narrows the candidate set without
2071/// restating the axes. Forking that derivation is how the two paths would
2072/// silently disagree about whether a workload fits a node.
2073///
2074/// # It admits a group, not a workload (R860-T4 / W338)
2075///
2076/// The axes come from [`placement_group`] — `ws` plus the transitive closure of
2077/// its `local` requirement edges — because those members are placed together or
2078/// not at all. Capacity is their **sum**, archetype repulsion their **union**,
2079/// and mesh tags their union too. `prefer-local` and `anywhere` edges bind
2080/// nothing: a spec with neither `requires` nor `depends_on` local edges has a
2081/// group of exactly itself and resolves byte-identically to the pre-R860 axes.
2082///
2083/// This is the **only** gate. Node election is CLI-side
2084/// (`MeshYubabaClient::elect_node`, which picks a live member of the pool this
2085/// produces); the yubaba node process accepts whatever it is handed and never
2086/// re-checks placement, so a wrong group here is not caught downstream.
2087///
2088/// @yah:ticket(R860-T4, "Admission: place the transitive closure of `local` edges as one group, not one workload")
2089/// @yah:status(review)
2090/// @yah:phase(P1)
2091/// @yah:at(2026-09-05T18:29:13Z)
2092/// @yah:assignee(agent:bundle-anthropic-ashguard)
2093/// @yah:parent(R860)
2094/// @yah:next("W338 §Placement consequences 1 and 2. `admission_spec()` (config.rs:1974-1998) derives its axes from ONE spec; it must derive them from the group — the transitive closure of `local` requirement edges over `effective_requirements()`. `prefer-local` and `anywhere` edges do NOT bind the group. Three consequences: memory/cpu floor becomes the SUM of the group's requests, not the requirer's alone; `repel_archetype` becomes the union over members (so a group containing an Appliance is repelled by `no-appliance` even if the requirer is a Server); and the group is non-drainable if ANY member is an Appliance, which today is a per-workload check at yubaba/src/lib.rs:3117-3128 and now has to be computed over a set.")
2095/// @yah:verify("cargo test -p cloud --lib config")
2096/// @yah:gotcha("Node election is CLI-side, not cluster-side: `MeshYubabaClient::elect_node` (app/yah/cli/src/yubaba_client.rs:235-268) calls `admit_workload_candidates` (config.rs:1753), picks one node, and POSTs the deploy there. The yubaba node process never decides placement — it accepts whatever it is handed. So group admission has to be right in `config.rs` because there is no second gate downstream to catch it.")
2097/// @arch:see(.yah/docs/working/W338-workload-dependencies-and-appliance-composition.md)
2098/// @yah:depends_on(R860-T1)
2099/// @yah:handoff("ADMISSION NOW PLACES A GROUP, NOT A WORKLOAD. `admission_spec` (oss/yubaba/crates/cloud/src/config.rs:2009) takes `(ws, declared: &[WorkloadConfig])` and derives every axis from `placement_group(ws, declared)` (:2108) — the transitive closure of `local` requirement edges over `effective_requirements()`, traversing `Requirement::provides` where present and resolving by ident against `cfg.workloads` (.yah/infra/workloads/) otherwise, mesh-identity first and workload name second. Capacity is the SUM of the members' `memory_request_mb()` / `resources.cpu_millis` (saturating). Only `local` binds: `prefer-local` and `anywhere` (which every legacy `depends_on` folds into) are skipped, so a spec without local edges has a group of exactly itself and its axes are bit-identical to the pre-R860 derivation.")
2100/// @yah:handoff("REPEL BECAME A SET. `RequiredSpec::repel_archetype: Option<LifecycleArchetype>` is now `repel_archetypes: Vec<LifecycleArchetype>` (config.rs:3878), the union over group members; `matches` (:3971) rejects a node carrying `no-<taint_key()>` for ANY of them, `describe` emits one `not-tainted(...)` part per archetype, `is_unconstrained` tests `is_empty()`. The field is `#[serde(skip)]`, so no wire or schema drift, and grep over app/ crates/ oss/ xtask/ finds no other referent of the old name and no `RequiredSpec { .. }` literal outside config.rs — the rename is contained. `admit_workload` / `admit_workload_candidates` / `admit_workload_in_group` signatures are unchanged; all three now pass `&self.workloads`.")
2101/// @yah:handoff("CYCLE GUARD, AND THE BUG IT TOOK TO GET RIGHT. The walker keeps TWO visited lists: `in_group` (member mesh identities) and `expanded` (requirement idents already resolved). The first version used one list and was silently wrong in the common case — a requirement's ident IS its provider's mesh identity, so marking the ident before resolving made every provider look already-present and `placement_group` returned a group of one. Five of the new tests caught it. If you refactor this, keep the two questions separate.")
2102/// @yah:handoff("ELECT_NODE NEEDS NO CHANGE FOR THIS TICKET — read it (app/yah/cli/src/yubaba_client.rs:235-268). It calls `admit_workload_candidates`, so it now receives a pool already filtered to nodes that can host the WHOLE group, then probes for liveness within it. That is correct for T4 because only the requirer is deployed today. It becomes load-bearing at R860-T6: `supply = \"self\"` provisioning MUST reuse the node URL `elect_node` returned for the requirer and must not re-elect per member — the probe is liveness-sensitive, so a second election can legally return a different member of the same pool and split the group across two nodes.")
2103/// @yah:handoff("DECISIONS THE BRIEF DID NOT COVER, all recorded in doc comments at the site. (1) `mesh_tags` are UNIONED over the group — the axis is already a superset/AND check, so a node that cannot host one member cannot host the group; zero regression risk since nothing in the tree declares `requires` yet. (2) `nodes` (the R833-F8 operator pin) stays REQUIRER-ONLY: it is a membership list, so intersecting two members' pins can yield an empty vec, which the axis reads as no-constraint — the exact inverse of the conflict. (3) `requires_taint` is a single Option: the requirer's wins, else the first member declaring one. Two members demanding DIFFERENT taints is not representable and would be an unplaceable group; widening that axis to a set is a follow-up if a real case appears. (4) An unresolvable `local` ident is SKIPPED, not an error — admission is a pure function of the declared inventory and must not start refusing deploys over a provider a later ticket declares; the cost is that its request does not count toward the floor, which is the exposure `depends_on` has always had.")
2104/// @yah:handoff("DRAINABILITY: placement half landed, node half deliberately NOT touched. `group_is_drainable(members)` (config.rs:2160) is the set-valued predicate W338 §Placement consequences 2 asks for — false as soon as any member is an Appliance — and the `no-appliance` repulsion that follows from it is enforced through `repel_archetypes`. The node-side loop `drain_workloads` (oss/yubaba/crates/yubaba/src/lib.rs, the R572-F4 archetype_registry skip) still decides per workload and knows nothing about requirement edges, so a Server bound to an Appliance by a `local` edge would still be drained alone. Not fixed here for two reasons: that file has three sessions live in it (the brief named them), and the fix needs group edges plumbed to the node process, which is R860-T6's rail rather than a local edit. `yubaba` already depends on `cloud`, so the predicate is directly callable from there when that plumbing exists.")
2105/// @yah:verify("BASELINE recorded before editing, tree anchor 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2: `cargo test -p yah-cloud --lib` (from oss/yubaba) = 1081 passed, 0 failed, 4 ignored, exit 0. AFTER: 1090 passed, 0 failed, 4 ignored, exit 0 — +9, exactly the nine tests added. `cargo check -p yah-cloud --all-targets` exit 0, and `cargo check -p yubaba --all-targets` exit 0 as well (yubaba consumes `cloud`, so it is where the `repel_archetypes` rename would have surfaced). Every exit code echoed explicitly, never inferred from an empty grep.")
2106/// @yah:verify("NEW TESTS (config.rs `mod tests`, R860-T4 section at the end): a_local_edge_binds_the_provider_into_the_placement_group; prefer_local_and_anywhere_edges_do_not_bind_the_group (covers a legacy `depends_on` too); the_group_is_the_transitive_closure_and_traverses_inline_provides; an_ident_cycle_closes_the_group_instead_of_looping_forever; an_unresolvable_local_ident_is_skipped_rather_than_refused; the_capacity_floor_is_the_sum_of_the_group_not_the_requirer_alone (a 300 MiB node refuses two 256 MiB members and the error names memory_mb>=512; a 512 MiB node admits); a_server_requiring_an_appliance_locally_is_repelled_by_no_appliance (same requirer alone still lands on the tainted Pi, so the repulsion provably comes from the edge); a_group_containing_an_appliance_is_not_drainable; a_spec_with_no_local_edges_admits_exactly_as_it_did_before.")
2107/// @yah:gotcha("The camp's `yah build run` rail killed three consecutive verification runs against the shared oss/yubaba/target dir: each ended with only `Blocking waiting for file lock on build directory` in the log and no exit code, after 121s / 720s. The green result above was obtained with `CARGO_TARGET_DIR=/tmp/r860t4-target`, which sidesteps the contended lock at the cost of one cold dep build. Worth reaching for directly when the yubaba target dir is busy rather than burning three cycles discovering it.")
2108/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I left it. Diff against it (`git diff 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
2109/// @yah:next("R860-T6 (supply = \"self\"): deploy the group's non-requirer members onto the node `elect_node` already returned for the requirer — do NOT re-elect per member, or a liveness probe can split the group across two nodes. `placement_group` (config.rs:2108) hands you the member specs in traversal order, requirer first.")
2110/// @yah:next("Node-side drain is still per-workload: teach `drain_workloads` (oss/yubaba/crates/yubaba/src/lib.rs) to consult `cloud::config::group_is_drainable` over the requesting workload's placement group once R860-T6 plumbs group membership to the node. Left untouched here on purpose — three sessions were live in that file.")
2111/// @yah:handoff("LEADER RE-VERIFIED (session:69b18855, independent of the courier's self-report). `cargo test -p yah-cloud --lib` from oss/yubaba: 1090 passed / 0 failed / 4 ignored, exit 0, against the courier's recorded 1081/0/4 baseline — +9 = exactly its new tests. Confirmed by content in config.rs: `placement_group` :2120 with the `req.locality != Locality::Local` guard at :2136 (so `prefer-local` and `anywhere` correctly do NOT bind), `group_is_drainable` :2172, and `RequiredSpec::repel_archetype: Option&lt;_&gt;` widened to `repel_archetypes: Vec&lt;_&gt;` at :3890 with the union built at :2039-2050 and enforced at :4004/:4044. The repel rename is `#[serde(skip)]`, so no wire or schema drift.")
2112/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I left it. Diff against it (`git diff 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
2113/// @yah:verify("cargo test -p yah-cloud --lib (from oss/yubaba): 1090 passed / 0 failed / 4 ignored, exit 0, vs a 1081/0/4 baseline. Exit codes echoed explicitly throughout rather than inferred from an empty grep — the trap that cost R860-T1 three misses.")
2114/// @yah:gotcha("CORRECTION FROM R860-T6, and the leader propagated the error so it is worth naming: this ticket's handoff asserted \\\"`yubaba` already depends on `cloud`, so the predicate is directly callable from there\\\". THAT IS WRONG. `cloud` is a DEV-dependency of yubaba only — oss/yubaba/crates/yubaba/Cargo.toml:150-152, under the comment \\\"Integration test harness\\\" — and cloud's own Cargo.toml records that the runtime yubaba→cloud edge was DELIBERATELY avoided from R374-F3 onward. The leader repeated the claim verbatim in R860-T6's dispatch brief; T6's courier checked it against the manifest instead of trusting it, which is the only reason it did not become a runtime dependency inversion. Resolution: `group_is_drainable`'s body moved down to `workload_spec::group_is_drainable` (workload-spec/src/lib.rs:2365), the shared home both crates already depend on, and `cloud::config::group_is_drainable` (config.rs:2240) now delegates to it keeping its signature. Verified after the move: yah-cloud still 1093/0/4, yah-workload-spec 171+98/0.")
2115/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I left it. Diff against it (`git diff 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
2116/// @yah:verify("RE-VERIFIED AT HEAD 00ee20d1 (session:aa5e882d, 2026-09-05). `cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib` = 1141 passed / 0 failed / 4 ignored, exit 0 (was 1093 at the first leader's check, 1110 at the second; the deltas are peers' tests). Group placement confirmed by content in oss/yubaba/crates/cloud/src/config.rs: `placement_group` derivation at :2073/:2108, `repel_archetypes: Vec&lt;LifecycleArchetype&gt;` at :3878. NOTE FOR ANYONE RE-RUNNING THIS: `cargo test -p yah-cloud --lib` from the repo root FAILS with \"package `yah-cloud` cannot be tested because it requires dev-dependencies and is not a member of the workspace\" — yah-cloud lives in the oss/yubaba workspace, so the invocation needs `--manifest-path oss/yubaba/Cargo.toml`.")
2117fn admission_spec(ws: &WorkloadSpec, declared: &[WorkloadConfig]) -> RequiredSpec {
2118    let group = placement_group(ws, declared);
2119
2120    // Capacity is the group's demand, not the requirer's (W338 §Placement
2121    // consequences 1). Saturating rather than wrapping: an absurd declared
2122    // request must read as "nothing is big enough", never as a small number.
2123    //
2124    // `memory_request_mb()` and NOT `resources.memory_mb`: the latter is a
2125    // cgroup ceiling, and reading a ceiling as a floor made `for_forge`'s
2126    // deliberately-roomy 32 GiB limit mean "only place me on a 32 GiB node".
2127    // That excluded every build-worker in the fleet but one. The accessor falls
2128    // back to `resources.memory_mb` when no request is declared, so specs that
2129    // never set one are admitted exactly as before.
2130    let mut memory_mb: u32 = 0;
2131    let mut cpu_millis: u32 = 0;
2132    // R572-F5 taint repulsion, unioned over the group (W338 §Placement
2133    // consequences 2): a group is non-drainable — and `no-appliance`-repelled —
2134    // if *any* member is an Appliance, even when the requirer is a Server.
2135    let mut repel_archetypes: Vec<LifecycleArchetype> = Vec::new();
2136    // Mesh tags are already AND-ed (a machine must be a superset), so unioning
2137    // them over the group is the same predicate applied to every member: a node
2138    // that cannot host one member cannot host the group.
2139    let mut mesh_tags = node_selector_mesh_tags(ws);
2140
2141    for member in &group {
2142        memory_mb = memory_mb.saturating_add(member.memory_request_mb());
2143        cpu_millis = cpu_millis.saturating_add(member.resources.cpu_millis);
2144        let arch = member.effective_archetype();
2145        if !repel_archetypes.contains(&arch) {
2146            repel_archetypes.push(arch);
2147        }
2148        for tag in node_selector_mesh_tags(member) {
2149            if !mesh_tags.contains(&tag) {
2150                mesh_tags.push(tag);
2151            }
2152        }
2153    }
2154
2155    // R860-T5 / W338 §Placement consequences 3: per-node native-exec
2156    // capability. Computed over the group for the same reason every other axis
2157    // is — a `local` edge to a native provider makes the *requirer* unplaceable
2158    // on a node without the backend, even when the requirer is an ordinary
2159    // container workload. This is the `supply = "self"` precondition W338 names:
2160    // a self-supplied native provider has to be placeable where its requirer
2161    // lands, and until now nothing upstream could see whether it was.
2162    //
2163    // Appended to `mesh_tags` rather than given its own field: the axis is
2164    // already an AND-ed superset check against `machine.mesh_tags`, `describe`
2165    // already renders it, and `RequiredSpec` needs no new shape. See
2166    // [`NATIVE_EXEC_MESH_TAG`] for why a tag and not a taint.
2167    if group.iter().any(WorkloadSpec::wants_native_exec)
2168        && !mesh_tags.iter().any(|t| t == NATIVE_EXEC_MESH_TAG)
2169    {
2170        mesh_tags.push(NATIVE_EXEC_MESH_TAG.to_string());
2171    }
2172
2173    RequiredSpec {
2174        mesh_tags,
2175        // R833-F8: imperative node pin. Derived here alongside the inferred
2176        // mesh tags rather than short-circuiting the resolver, so a pinned
2177        // workload is still checked against capacity and taints.
2178        //
2179        // Requirer-only on purpose: the pin is what the operator typed on
2180        // *this* deploy, and `nodes` is a membership list, so intersecting two
2181        // members' pins could yield an empty vec — which this axis reads as "no
2182        // constraint", i.e. the exact opposite of the conflict it represents.
2183        nodes: node_selector_node(ws).into_iter().collect(),
2184        memory_mb,
2185        cpu_millis,
2186        repel_archetypes,
2187        // R572-F5: taint affinity from the requires-taint annotation. The
2188        // requirer's wins; otherwise the first member that declares one, since
2189        // the group shares a node and this axis holds a single key. Two members
2190        // demanding *different* taints is not representable here and would be
2191        // an unplaceable group anyway — see the R860-T4 handoff.
2192        requires_taint: group
2193            .iter()
2194            .find_map(|m| m.requires_taint().map(str::to_owned)),
2195        ..Default::default()
2196    }
2197}
2198
2199/// The workloads that must be placed together with `ws`: the transitive closure
2200/// of `local` requirement edges over [`WorkloadSpec::effective_requirements`],
2201/// starting at the requirer (R860-T4 / W338 §"Each member keeps its own mesh
2202/// identity").
2203///
2204/// **Only `local` binds.** `prefer-local` explicitly "never blocks placement"
2205/// (W338's locality table) and `anywhere` is an ordinary service dependency —
2206/// treating either as a co-scheduling constraint would turn every `depends_on`
2207/// in the tree into one, since the legacy field folds in as `anywhere` + `wait`.
2208///
2209/// A group is **not** a new addressable object: every member keeps its own mesh
2210/// identity, spec and healthcheck (W338). This function returns the members'
2211/// specs so admission can take the sum / union over them, and nothing here
2212/// deploys, provisions or tears anything down — `supply = "self"` provisioning
2213/// is R860-T6 and per-node native-exec capability is R860-T5.
2214///
2215/// Two ways a member is reached, in this order:
2216/// - [`Requirement::provides`], the inline spec a `supply = "self"` requirement
2217///   carries;
2218/// - otherwise an ident lookup against `declared` (`.yah/infra/workloads/`),
2219///   matched on mesh identity first and on workload name second, because those
2220///   coincide for every spec in the tree today but the requirement is written in
2221///   the mesh-identity currency.
2222///
2223/// An ident that resolves to neither is **skipped**, not an error: admission is
2224/// a pure function of the declared inventory and must not start failing deploys
2225/// over a provider that a not-yet-written ticket will declare. The cost is that
2226/// its request does not count toward the floor, which is the same exposure
2227/// `depends_on` has always had.
2228///
2229/// **Cycle-guarded.** `validate::check_requires` bounds `provides` *nesting* to
2230/// depth 1 but nothing stops two separately-declared specs from requiring each
2231/// other, and this closure would otherwise not terminate. Each requirement ident
2232/// is resolved at most once and each member joins the group at most once, so a
2233/// cycle simply closes the group.
2234pub fn placement_group(ws: &WorkloadSpec, declared: &[WorkloadConfig]) -> Vec<WorkloadSpec> {
2235    let mut members = vec![ws.clone()];
2236    // Two separate visited sets, because the two questions differ: `in_group`
2237    // stops a workload being added twice, `expanded` stops an ident being
2238    // resolved twice. Folding them into one list makes the ident of a member
2239    // already in the group indistinguishable from the member itself — and since
2240    // a requirement's ident *is* its provider's mesh identity, that reads every
2241    // provider as already-present and silently returns a group of one.
2242    let mut in_group: Vec<String> = vec![group_key(ws)];
2243    let mut expanded: Vec<String> = Vec::new();
2244    let mut next = 0;
2245
2246    while next < members.len() {
2247        let requirements = members[next].effective_requirements();
2248        next += 1;
2249        for req in requirements {
2250            if req.locality != Locality::Local {
2251                continue;
2252            }
2253            if expanded.contains(&req.ident.0) {
2254                continue;
2255            }
2256            expanded.push(req.ident.0.clone());
2257
2258            let provider = match req.provides.as_deref() {
2259                Some(spec) => spec.clone(),
2260                None => match resolve_requirement_ident(&req.ident, declared) {
2261                    Some(spec) => spec,
2262                    None => continue,
2263                },
2264            };
2265            let key = group_key(&provider);
2266            if in_group.contains(&key) {
2267                continue;
2268            }
2269            in_group.push(key);
2270            members.push(provider);
2271        }
2272    }
2273
2274    members
2275}
2276
2277/// Whether a placement group may be drained off its node (W338 §Placement
2278/// consequences 2): false as soon as **any** member is an Appliance.
2279///
2280/// The set-valued form of the per-workload check the node itself makes in
2281/// `drain_workloads` (`oss/yubaba/crates/yubaba/src/lib.rs`), which skips an
2282/// Appliance by its own archetype and knows nothing about requirement edges. A
2283/// `Server` bound to an Appliance by a `local` edge has to move with it or not
2284/// at all, so draining it alone breaks the group the same way placing it alone
2285/// would.
2286///
2287/// R860-T6 moved the body to [`workload_spec::group_is_drainable`] and left this
2288/// signature untouched. The node's `drain_workloads` needs the identical
2289/// predicate, and yubaba has no runtime dependency on this crate by design
2290/// (R374-F3) — so the one implementation now lives in the crate both sides
2291/// already depend on, rather than being copied into the second caller.
2292pub fn group_is_drainable(members: &[WorkloadSpec]) -> bool {
2293    workload_spec::group_is_drainable(members)
2294}
2295
2296/// Identity a placement-group member is deduplicated by — its mesh identity,
2297/// which is the currency [`Requirement::ident`] is written in.
2298fn group_key(ws: &WorkloadSpec) -> String {
2299    ws.expose.mesh.identity.0.clone()
2300}
2301
2302/// Resolve a requirement's ident to a separately-declared spec: mesh identity
2303/// first, workload file name second.
2304fn resolve_requirement_ident(
2305    ident: &workload_spec::MeshIdent,
2306    declared: &[WorkloadConfig],
2307) -> Option<WorkloadSpec> {
2308    declared
2309        .iter()
2310        .find(|w| w.spec.expose.mesh.identity == *ident)
2311        .or_else(|| declared.iter().find(|w| w.spec.name == ident.0))
2312        .map(|w| w.spec.clone())
2313}
2314
2315/// The mesh tag a node declares to advertise that its kamaji can run **native**
2316/// (fork+exec) workloads — R860-T5 / W338 §"Placement consequences" 3.
2317///
2318/// A workload marked `yah.exec = native` ([`WorkloadSpec::wants_native_exec`])
2319/// is not containerized: kamaji fork+execs it on the node's own userland. That
2320/// backend only exists when the node's kamaji was **built** with the
2321/// `native-exec` cargo feature and **started** with `--native-exec-dir`
2322/// (`oss/kamaji/crates/kamaji-bin/src/main.rs`). Both are node-local startup
2323/// decisions, invisible to everything upstream — so before this tag, placement
2324/// happily elected a node whose kamaji then refused the deploy with
2325/// `BackendRefused: ... no native backend is available (native backend not
2326/// configured — start kamaji with --native-exec-dir)`. That is exactly how the
2327/// mesh lost its coordination server for 25 hours on 2026-09-03 (R858: raft
2328/// leadership moved headscale, a native workload, to `us-south-001`, which has
2329/// no such kamaji). [`admission_spec`] now requires this tag whenever any
2330/// placement-group member is native, which turns that dispatch-time surprise
2331/// into a placement precondition.
2332///
2333/// # Why a mesh tag and not a taint
2334///
2335/// The two vocabularies on [`MachineConfig`] mean opposite things. `mesh_tags`
2336/// are **positive capability** matched as a superset — "this node CAN" — which
2337/// is precisely the claim being made, and an extra tag on a machine can only
2338/// ever make it match *more* requirement sets, so declaring it is regression-
2339/// free. `taints` are **repulsion** — "keep this class off" — and would have to
2340/// be inverted (`no-native-exec` on every node lacking the backend, i.e. the
2341/// declaration burden falls on the majority) *and* taught to
2342/// [`taint_effect`], or [`crate::validate::check_inert_taints`] would correctly
2343/// lint the key dead.
2344///
2345/// # The `cap:` namespace
2346///
2347/// New here. The live prefixes are `tag:` (role — `tag:build-worker`,
2348/// `tag:qed`, `tag:cloud-runner`), `arch:` and `os:` (facts about the silicon
2349/// and userland), and `tier:` is reserved for the environment axis (R763, see
2350/// [`crate::validate::check_retired_arch_tags`]). A *capability the daemon was
2351/// configured with* is none of those: it is not a role an operator assigns and
2352/// not a property of the hardware, it is a fact about how kamaji was started,
2353/// and it changes when the node is rolled. Nothing validates tag prefixes, so
2354/// this costs no wiring.
2355///
2356/// # Fails closed
2357///
2358/// A node that does not declare it is not a candidate. An undeclared fleet
2359/// therefore reports "no node admits" at election time rather than dispatching
2360/// to a node that will refuse — the refusal moves earlier and names the
2361/// constraint, which is the whole point. Declared today (from readings recorded
2362/// in-repo, not inferred) on `us-west-001` and `us-west-003`; see those
2363/// machines' TOMLs for the evidence and the date.
2364pub const NATIVE_EXEC_MESH_TAG: &str = "cap:native-exec";
2365
2366/// Parse the R594 mesh-tag node-selector off a workload's annotations into the
2367/// requested tag set. Absent annotation or empty value ⇒ empty vec ("no
2368/// constraint"). Whitespace around each comma-separated tag is trimmed and
2369/// empty segments are dropped, so `"tag:build-worker, arch:x86"` and
2370/// `"tag:build-worker,arch:x86"` parse identically.
2371pub fn node_selector_mesh_tags(ws: &WorkloadSpec) -> Vec<String> {
2372    ws.annotations
2373        .get(velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION)
2374        .map(|v| {
2375            v.split(',')
2376                .map(str::trim)
2377                .filter(|s| !s.is_empty())
2378                .map(String::from)
2379                .collect()
2380        })
2381        .unwrap_or_default()
2382}
2383
2384/// Parse the R833-F8 imperative node-selector off a workload's annotations —
2385/// the single machine `name` the operator pinned the run to
2386/// (`--where=node:us-west-003`). Absent or blank ⇒ `None` ("no constraint"),
2387/// which is every workload built before this axis existed.
2388///
2389/// One node, not a list: the annotation exists to express "run it *there*", and
2390/// a comma-joined set would be a worse spelling of the mesh-tag selector that
2391/// already handles "any of these".
2392pub fn node_selector_node(ws: &WorkloadSpec) -> Option<String> {
2393    ws.annotations
2394        .get(velveteen_exec::remote::NODE_SELECTOR_NODE_ANNOTATION)
2395        .map(|v| v.trim())
2396        .filter(|v| !v.is_empty())
2397        .map(String::from)
2398}
2399
2400/// Load every `.yah/infra/providers/*.toml` into a [`ProviderConfig`] list.
2401/// Missing directory → empty list.
2402fn load_providers(dir: &Path) -> Result<Vec<ProviderConfig>> {
2403    if !dir.exists() {
2404        return Ok(vec![]);
2405    }
2406    let mut items = vec![];
2407    let mut entries: Vec<_> = std::fs::read_dir(dir)
2408        .with_context(|| format!("reading {}", dir.display()))?
2409        .filter_map(|e| e.ok())
2410        .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
2411        .collect();
2412    entries.sort_by_key(|e| e.file_name());
2413    for entry in entries {
2414        items.push(ProviderConfig::load(&entry.path())?);
2415    }
2416    Ok(items)
2417}
2418
2419/// Map legacy mirror file stems to their canonical tier names.
2420///
2421/// Canonical tiers: `dev` / `pond` / `cloud` / `ha`.
2422/// Legacy stems pre-R362: `local` (dev tier), `local-sim` / `sim` (pond tier), `prod` (cloud tier).
2423/// Both forms are accepted; canonical names are preferred for new files.
2424pub fn canonical_tier(stem: &str) -> &str {
2425    match stem {
2426        "local" => "dev",
2427        "local-sim" | "sim" => "pond",
2428        "prod" => "cloud",
2429        other => other,
2430    }
2431}
2432
2433/// Walk `.yah/services/<svc>/` for every service and its mirrors.
2434/// Missing directory → empty map. Mirror file stems are normalized to canonical
2435/// tier names via [`canonical_tier`] so callers always see `dev/pond/cloud/ha`.
2436fn load_services(
2437    dir: &Path,
2438    workspace_root: &Path,
2439) -> Result<BTreeMap<String, ServiceWithMirrors>> {
2440    if !dir.exists() {
2441        return Ok(BTreeMap::new());
2442    }
2443    let mut out = BTreeMap::new();
2444    let mut entries: Vec<_> = std::fs::read_dir(dir)
2445        .with_context(|| format!("reading {}", dir.display()))?
2446        .filter_map(|e| e.ok())
2447        .filter(|e| e.path().is_dir())
2448        .collect();
2449    entries.sort_by_key(|e| e.file_name());
2450
2451    for entry in entries {
2452        let svc_dir = entry.path();
2453        let service_toml = svc_dir.join("service.toml");
2454        if !service_toml.exists() {
2455            // Skip directories without a service.toml — leaves room for
2456            // future siblings (e.g. `secrets/`, `README.md`) without
2457            // triggering false-positive parse errors.
2458            continue;
2459        }
2460        let service = ServiceConfig::load(&service_toml)?;
2461        let mut mirrors = BTreeMap::new();
2462        let mirrors_dir = svc_dir.join("mirrors");
2463        if mirrors_dir.exists() {
2464            let mut menv: Vec<_> = std::fs::read_dir(&mirrors_dir)
2465                .with_context(|| format!("reading {}", mirrors_dir.display()))?
2466                .filter_map(|e| e.ok())
2467                .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
2468                .collect();
2469            menv.sort_by_key(|e| e.file_name());
2470            for m in menv {
2471                let path = m.path();
2472                let stem = path
2473                    .file_stem()
2474                    .and_then(|s| s.to_str())
2475                    .unwrap_or("")
2476                    .to_string();
2477                let tier = canonical_tier(&stem).to_string();
2478                // Last-write wins if both legacy and canonical forms coexist
2479                // (e.g. local-sim.toml + pond.toml). Sort order ensures the
2480                // canonical file (pond.toml) wins because 'p' > 'l'.
2481                mirrors.insert(tier, MirrorConfig::load(&path)?);
2482            }
2483        }
2484        let mut component_transform_recipes = BTreeMap::new();
2485        for component in &service.components {
2486            if component.kind == "static-asset" {
2487                if let Some(recipe) =
2488                    read_component_transform_recipe(workspace_root, &component.path)
2489                {
2490                    component_transform_recipes.insert(component.id.clone(), recipe);
2491                }
2492            }
2493        }
2494        let passway_machines = mirrors
2495            .iter()
2496            .filter_map(|(env, m)| m.passway_machines().map(|ms| (env.clone(), ms)))
2497            .collect();
2498        out.insert(
2499            service.name.clone(),
2500            ServiceWithMirrors {
2501                service,
2502                mirrors,
2503                component_transform_recipes,
2504                passway_machines,
2505            },
2506        );
2507    }
2508    Ok(out)
2509}
2510
2511/// Read the first transform recipe name from a component's `workload.toml`.
2512/// Returns `None` when the file is absent or has no `[asset.derive.transform]`
2513/// section. Best-effort — parse failures are silently ignored so a malformed
2514/// workload.toml doesn't abort the entire service catalog load.
2515fn read_component_transform_recipe(workspace_root: &Path, component_path: &str) -> Option<String> {
2516    let workload_path = workspace_root.join(component_path).join("workload.toml");
2517    let text = std::fs::read_to_string(&workload_path).ok()?;
2518    let value: toml::Value = toml::from_str(&text).ok()?;
2519    let assets = value.get("asset")?.as_array()?;
2520    for asset in assets {
2521        if let Some(recipe) = asset
2522            .get("derive")
2523            .and_then(|d| d.get("transform"))
2524            .and_then(|t| t.get("recipe"))
2525            .and_then(|r| r.as_str())
2526        {
2527            return Some(recipe.to_string());
2528        }
2529    }
2530    None
2531}
2532
2533/// Load every `.yah/domains/*.toml` into a [`DomainConfig`] map keyed by
2534/// file stem. Missing directory → empty map.
2535fn load_domains(dir: &Path) -> Result<BTreeMap<String, DomainConfig>> {
2536    if !dir.exists() {
2537        return Ok(BTreeMap::new());
2538    }
2539    let mut out = BTreeMap::new();
2540    let mut entries: Vec<_> = std::fs::read_dir(dir)
2541        .with_context(|| format!("reading {}", dir.display()))?
2542        .filter_map(|e| e.ok())
2543        .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
2544        .collect();
2545    entries.sort_by_key(|e| e.file_name());
2546    for entry in entries {
2547        let path = entry.path();
2548        let stem = path
2549            .file_stem()
2550            .and_then(|s| s.to_str())
2551            .unwrap_or("")
2552            .to_string();
2553        let dom = DomainConfig::load(&path)?;
2554        if dom.name != stem {
2555            anyhow::bail!(
2556                "domains/{}.toml: name = \"{}\" must match the file stem",
2557                stem,
2558                dom.name
2559            );
2560        }
2561        out.insert(dom.name.clone(), dom);
2562    }
2563    Ok(out)
2564}
2565
2566/// Load and shape-validate all `*.toml` files in `dir` as [`WorkloadConfig`].
2567fn load_workloads(dir: std::path::PathBuf) -> Result<Vec<WorkloadConfig>> {
2568    if !dir.exists() {
2569        return Ok(vec![]);
2570    }
2571    let mut items = vec![];
2572    let mut entries: Vec<_> = std::fs::read_dir(&dir)
2573        .with_context(|| format!("reading {}", dir.display()))?
2574        .filter_map(|e| e.ok())
2575        .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
2576        .collect();
2577    entries.sort_by_key(|e| e.file_name());
2578
2579    for entry in entries {
2580        let path = entry.path();
2581        let path_str = path.display().to_string();
2582        let src =
2583            std::fs::read_to_string(&path).with_context(|| format!("reading {}", path_str))?;
2584        let spec: WorkloadSpec =
2585            toml::from_str(&src).with_context(|| format!("parsing {}", path_str))?;
2586
2587        // Shape-validate before accepting into the loaded config.
2588        validate::shape(&spec)
2589            .map_err(|e| anyhow::anyhow!("workload {} failed shape validation: {e}", path_str))?;
2590
2591        items.push(WorkloadConfig { spec });
2592    }
2593    Ok(items)
2594}
2595
2596/// Load all mirror configs from the `mirrors/` directory.
2597///
2598/// Handles two layouts that may coexist:
2599/// - **Folder**: `mirrors/<id>/mirror.toml` — preferred; allows secrets and
2600///   per-mirror overrides to live next to the config file.
2601/// - **Flat**: `mirrors/<id>.toml` — legacy; still supported.
2602///
2603/// Each file is parsed as [`LegacyMirrorConfig`]. A malformed file returns an error
2604/// that includes the file path and the TOML field path + line/column, so the
2605/// caller can surface it to the user directly.
2606fn load_mirrors(dir: std::path::PathBuf) -> Result<Vec<LegacyMirrorConfig>> {
2607    if !dir.exists() {
2608        return Ok(vec![]);
2609    }
2610    let mut mirrors = vec![];
2611    let mut entries: Vec<_> = std::fs::read_dir(&dir)
2612        .with_context(|| format!("reading {}", dir.display()))?
2613        .filter_map(|e| e.ok())
2614        .collect();
2615    entries.sort_by_key(|e| e.file_name());
2616
2617    for entry in entries {
2618        let path = entry.path();
2619        if path.is_dir() {
2620            // Folder layout: mirrors/<id>/mirror.toml
2621            let mirror_toml = path.join("mirror.toml");
2622            if mirror_toml.exists() {
2623                let src = std::fs::read_to_string(&mirror_toml)
2624                    .with_context(|| format!("reading {}", mirror_toml.display()))?;
2625                let cfg: LegacyMirrorConfig = toml::from_str(&src)
2626                    .with_context(|| format!("parsing {}", mirror_toml.display()))?;
2627                mirrors.push(cfg);
2628            }
2629        } else if path.extension().map_or(false, |e| e == "toml") {
2630            // Flat layout: mirrors/<id>.toml
2631            let src = std::fs::read_to_string(&path)
2632                .with_context(|| format!("reading {}", path.display()))?;
2633            let cfg: LegacyMirrorConfig =
2634                toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
2635            mirrors.push(cfg);
2636        }
2637    }
2638    Ok(mirrors)
2639}
2640
2641/// Load `topology.toml` if it exists; return a default (empty) topology otherwise.
2642fn load_topology(path: std::path::PathBuf) -> Result<TopologyConfig> {
2643    if !path.exists() {
2644        return Ok(TopologyConfig::default());
2645    }
2646    let src =
2647        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
2648    toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2649}
2650
2651/// R555-S1: entries are sorted by file name before parsing, so "declaration
2652/// order in `.yah/infra/machines/` breaks ties" — the contract
2653/// [`CloudConfig::admit_workload`] documents — is actually true. `read_dir`
2654/// yields filesystem order, which is unspecified and differs between APFS and
2655/// a hashed-dir ext4; without the sort, *which* of two equally-matching nodes a
2656/// workload admits to could change when an unrelated file is added to the
2657/// directory. That was latent while each tag set had one match and became
2658/// observable the day us-west-003 joined us-west-002 on
2659/// `[tag:build-worker, arch:x86, os:linux]`. Same sort `load_providers` has
2660/// always done.
2661fn load_dir<T: for<'de> Deserialize<'de>>(dir: std::path::PathBuf) -> Result<Vec<T>> {
2662    if !dir.exists() {
2663        return Ok(vec![]);
2664    }
2665    let mut entries: Vec<_> = std::fs::read_dir(&dir)
2666        .with_context(|| format!("reading {}", dir.display()))?
2667        .collect::<std::io::Result<Vec<_>>>()
2668        .with_context(|| format!("reading {}", dir.display()))?;
2669    entries.sort_by_key(|e| e.file_name());
2670
2671    let mut items = vec![];
2672    for entry in entries {
2673        let path = entry.path();
2674        if path.extension().map_or(false, |e| e == "toml") {
2675            let src = std::fs::read_to_string(&path)
2676                .with_context(|| format!("reading {}", path.display()))?;
2677            let item: T =
2678                toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
2679            items.push(item);
2680        }
2681    }
2682    Ok(items)
2683}
2684
2685// ─── New manifest shapes (R222 B2) ───────────────────────────────────────────
2686//
2687// The post-R215 layout splits substrate from service declarations:
2688//
2689//   .yah/infra/providers/<id>.toml      → ProviderConfig
2690//   .yah/services/<svc>/service.toml    → ServiceConfig
2691//   .yah/services/<svc>/mirrors/<env>.toml → MirrorConfig
2692//
2693// CloudConfig::load still reads the legacy layout — B3 swaps in these types
2694// and removes the Legacy* shapes plus TopologyConfig.
2695
2696/// Tag for the infrastructure provider kind. Drives which fields are valid in
2697/// a [`ProviderConfig`] body or a [`MirrorProviderSlot::Inline`] block.
2698///
2699/// Two flavors:
2700/// - **Account/runtime providers** (`cloudflare`, `hetzner`, `local-container`)
2701///   live as files under `.yah/infra/providers/<id>.toml` and are referenced
2702///   from a mirror via `use = "<id>"`.
2703/// - **Inline-only providers** (`local-static`, `miniflare-container`,
2704///   `minio-container`) declare an operator-local stand-in directly inside a
2705///   mirror via `kind = "..."`. They carry no credentials and have no provider
2706///   file. The container-backed kinds ride on top of whichever
2707///   `local-container` runtime is declared in infra (orbstack/colima/docker);
2708///   the reconciler resolves the runtime at up-time.
2709#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2710#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2711#[serde(rename_all = "kebab-case")]
2712pub enum Provider {
2713    /// Cloudflare account: R2 buckets, DNS, Workers, Tunnels.
2714    Cloudflare,
2715    /// Hetzner Cloud + Object Storage account.
2716    Hetzner,
2717    /// Vultr cloud VPS — auto-provisioned via the `cloud.vps.*` Envoy
2718    /// (`VultrEnvoy`), the burst/scaling counterpart to Hetzner. Driver-backed.
2719    Vultr,
2720    /// BYO bare/static node (OVH, on-prem, anything we did NOT provision via a
2721    /// cloud API). Brought up over SSH (`stand-up-yubaba.sh` / `yah cloud
2722    /// machine bootstrap`); reach is declared in the machine's `[connect]`
2723    /// block. No create/destroy driver — placement-only.
2724    Static,
2725    /// Built-in static-file server bound to localhost. Inline-only; never
2726    /// declared as a standalone provider file because it carries no creds.
2727    LocalStatic,
2728    /// Local container runtime (orbstack/colima/docker). Configured by a
2729    /// provider file under `.yah/infra/providers/` so the discovery hints +
2730    /// runtime override sit in one place.
2731    LocalContainer,
2732    /// Dev-tier compute: the component runs as a kamaji-supervised host
2733    /// process against the operator's real workspace, no container and no
2734    /// build step per edit. Inline-only — it carries no credentials, and
2735    /// "the machine you are sitting at" is not an account to point at.
2736    /// See `reconciler::local_process`.
2737    LocalProcess,
2738    /// Containerized miniflare (workerd subprocess) fronting MinIO — the
2739    /// pond-tier stand-in for a CF Worker + R2 static surface. Inline-only;
2740    /// the reconciler spawns miniflare via the JS runtime and starts a MinIO
2741    /// container on the local-container runtime.
2742    MiniflareContainer,
2743    /// Containerized MinIO providing an S3-compatible API — the pond-tier
2744    /// stand-in for Cloudflare R2. Inline-only; the reconciler spins up the
2745    /// container on the local-container runtime and auto-creates the declared
2746    /// bucket on first up.
2747    MinioContainer,
2748    /// Dev-tier PostgreSQL — a real server speaking real pgwire on loopback,
2749    /// supervised by kamaji as the `yah-pg-dev` workload (W265, R584-F1). No
2750    /// docker daemon: the driver fetches a per-arch PostgreSQL tarball on first
2751    /// run and `initdb`s a cluster under `.yah/infra/state/dev/pg/`.
2752    ///
2753    /// Inline-only — it carries no credentials worth a provider file (the
2754    /// cluster is loopback-bound with a fixed dev password). Declared under
2755    /// [`MirrorConfig::drivers`], not `providers`:
2756    ///
2757    /// ```toml
2758    /// [drivers.pg]
2759    /// kind = "local-pg-dev"
2760    /// ```
2761    LocalPgDev,
2762}
2763
2764/// A provider account/runtime binding from `.yah/infra/providers/<id>.toml`.
2765///
2766/// The `kind` discriminator picks the schema for the remaining fields. Strict
2767/// on `kind` (unknown values are a parse error); permissive on per-kind fields
2768/// (carried as a free-form map so this loader stays stable as new fields land).
2769/// B3/B4 will tighten by introducing typed variants alongside JSON Schema.
2770#[derive(Debug, Clone, Serialize, Deserialize)]
2771#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2772pub struct ProviderConfig {
2773    pub schema_version: u32,
2774    pub id: String,
2775    pub kind: Provider,
2776    /// Reference into the OS keystore for live credentials (e.g.
2777    /// `"keystore://cloudflare/yah"`). `None` for providers that don't need
2778    /// creds (local-static, optionally local-container).
2779    #[serde(default, skip_serializing_if = "Option::is_none")]
2780    pub credentials: Option<String>,
2781    /// Kind-specific fields. Examples:
2782    /// - cloudflare: `default_zone`
2783    /// - hetzner:    `default_location`, `default_server_type`, `ssh_keys`
2784    /// - local-container: `runtime`, `discovery`
2785    #[serde(flatten)]
2786    #[cfg_attr(
2787        feature = "json-schema",
2788        schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
2789    )]
2790    pub fields: BTreeMap<String, toml::Value>,
2791}
2792
2793impl ProviderConfig {
2794    /// Parse a single `providers/<id>.toml` file.
2795    pub fn load(path: &Path) -> Result<Self> {
2796        let src =
2797            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
2798        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2799    }
2800}
2801
2802/// An operator-facing service declaration from
2803/// `.yah/services/<svc>/service.toml`.
2804///
2805/// A service groups one or more components (a static surface, a containerized
2806/// API, an almanac…) under a single domain. Mirrors project the service onto
2807/// concrete infra; see [`MirrorConfig`].
2808#[derive(Debug, Clone, Serialize, Deserialize)]
2809#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2810pub struct ServiceConfig {
2811    pub schema_version: u32,
2812    pub name: String,
2813    pub domain: String,
2814    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2815    pub components: Vec<ServiceComponent>,
2816    /// Databases this service exposes, grouped by environment (W241). Every
2817    /// entry becomes a data-workbench / `sql_*` catalog id of the shape
2818    /// `<env>:<service>:<name>` (e.g. `pond:scrabcake:main`). Optional and
2819    /// default-empty — services without databases omit the `[db]` table
2820    /// entirely.
2821    #[serde(default, skip_serializing_if = "DbCatalog::is_empty")]
2822    pub db: DbCatalog,
2823}
2824
2825impl ServiceConfig {
2826    /// Parse a single `services/<svc>/service.toml` file.
2827    pub fn load(path: &Path) -> Result<Self> {
2828        let src =
2829            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
2830        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2831    }
2832
2833    /// Persist to `.yah/services/<name>/service.toml`, creating the service
2834    /// directory if needed. Create-or-overwrite — the canonical replacement
2835    /// for the legacy `sites.json` write path. `workspace_root` is the camp
2836    /// dir (the parent of `.yah/`).
2837    pub fn save(&self, workspace_root: &Path) -> Result<()> {
2838        let dir = crate::paths::service_dir(workspace_root, &self.name);
2839        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
2840        let path = crate::paths::service_toml(workspace_root, &self.name);
2841        let s = toml::to_string_pretty(self)
2842            .with_context(|| format!("serializing service {}", self.name))?;
2843        std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
2844    }
2845
2846    /// Remove `.yah/services/<name>/` and everything under it (service.toml
2847    /// plus its `mirrors/`). Returns `false` when the directory was already
2848    /// absent, so callers can distinguish "deleted" from "no-op".
2849    pub fn delete(workspace_root: &Path, name: &str) -> Result<bool> {
2850        let dir = crate::paths::service_dir(workspace_root, name);
2851        if !dir.exists() {
2852            return Ok(false);
2853        }
2854        std::fs::remove_dir_all(&dir).with_context(|| format!("removing {}", dir.display()))?;
2855        Ok(true)
2856    }
2857}
2858
2859/// A git source for a component (R561-F1, "BYO git").
2860///
2861/// When a [`ServiceComponent`] sets `git`, the component's code is NOT in this
2862/// workspace — it lives in an external repo that the reconciler shallow-clones
2863/// into a source cache before build (approach A: clone-at-reconcile, so config
2864/// load + validation stay offline). The component's `path` is then interpreted
2865/// relative to `<checkout>/<subdir>` instead of the workspace root.
2866#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2867#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2868pub struct GitSource {
2869    /// Clone URL (https or ssh) of the tenant repo.
2870    pub repo: String,
2871    /// Branch, tag, or commit SHA to check out. Defaults to `"main"`.
2872    #[serde(default = "default_git_ref")]
2873    pub r#ref: String,
2874    /// Optional sub-directory within the repo that the workspace is rooted at
2875    /// (e.g. a monorepo's `site/`). `path` is resolved relative to this.
2876    #[serde(default, skip_serializing_if = "Option::is_none")]
2877    pub subdir: Option<String>,
2878}
2879
2880fn default_git_ref() -> String {
2881    "main".to_string()
2882}
2883
2884/// How to reach an external infra root (R615-F1 / W274, "linked infra
2885/// sources"): a filesystem link to a sibling camp's live tree, or a git
2886/// checkout of an extracted infra repo.
2887#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2888#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2889#[serde(tag = "kind", rename_all = "kebab-case")]
2890pub enum InfraSourceKind {
2891    /// Filesystem link — reads the owner's live tree. The dev-loop shortcut,
2892    /// and the whole story until W274's "infra as its own repo" end-state.
2893    /// `path` is relative to *this* camp's root; infra is read from
2894    /// `<path>/.yah/infra/`.
2895    Path {
2896        path: String,
2897    },
2898    /// Git link — reused verbatim from [`GitSource`] (R561, "BYO git"),
2899    /// lifted here from "a component's code" to "a camp's infra registry."
2900    /// Loading stays offline (W274 §3): `yah infra sync` (R615-T3) is what
2901    /// clones/pulls this into `.yah/cache/infra/<owner>/`; `CloudConfig::load`
2902    /// only ever reads that cache, never the network.
2903    Git(GitSource),
2904}
2905
2906/// Write-gate for a linked [`InfraSource`] (R615-F1 / W274).
2907///
2908/// An enum, not a bool: the two states today are "borrower renders/plans but
2909/// cannot reconcile" and "this camp genuinely co-administers the shared
2910/// root," and a future read-write-with-approval tier is a third variant, not
2911/// a renamed boolean.
2912#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2913#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2914#[serde(rename_all = "kebab-case")]
2915pub enum SourceMode {
2916    /// Borrower can render and plan against the linked entries but cannot
2917    /// reconcile/mutate them — the owner remains the single manager. Default:
2918    /// a borrower is opt-in to write access, never opt-out of the safe state.
2919    #[default]
2920    ReadOnly,
2921    /// Escape hatch for a camp that genuinely co-administers a shared root.
2922    Manage,
2923}
2924
2925/// One `[[source]]` entry in `.yah/infra/sources.toml` (R615-F1 / W274) — an
2926/// external infra root this camp borrows machines/providers from.
2927#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2928#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2929pub struct InfraSource {
2930    /// Logical owner name, badged in the Infra tab (e.g. `"yah"`). Distinct
2931    /// from any camp/repo name the `kind` resolves through — this is what an
2932    /// operator sees on a borrowed row, not a path.
2933    pub owner: String,
2934    #[serde(flatten)]
2935    pub kind: InfraSourceKind,
2936    #[serde(default)]
2937    pub mode: SourceMode,
2938    /// Optional filter — name globs or mesh-tag selectors — to borrow a
2939    /// subset of the source root rather than everything it declares. Empty
2940    /// (the default) borrows everything.
2941    #[serde(default)]
2942    pub select: Vec<String>,
2943}
2944
2945fn default_sources_schema_version() -> u32 {
2946    1
2947}
2948
2949/// `.yah/infra/sources.toml` — the ordered list of external infra roots this
2950/// camp borrows from (R615-F1 / W274).
2951#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2952#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2953pub struct SourcesConfig {
2954    #[serde(default = "default_sources_schema_version")]
2955    pub schema_version: u32,
2956    /// `[[source]]` entries, in declaration order — overlay order matters
2957    /// when two linked sources both name the same machine (R615-F2).
2958    #[serde(default, rename = "source")]
2959    pub source: Vec<InfraSource>,
2960}
2961
2962impl Default for SourcesConfig {
2963    fn default() -> Self {
2964        Self {
2965            schema_version: default_sources_schema_version(),
2966            source: Vec::new(),
2967        }
2968    }
2969}
2970
2971impl SourcesConfig {
2972    /// Load `<infra_dir>/sources.toml`. A missing file is not an error —
2973    /// every camp without linked infra has none, which today is every camp —
2974    /// and yields an empty source list rather than `Err`.
2975    pub fn load(infra_dir: &Path) -> Result<Self> {
2976        let path = infra_dir.join("sources.toml");
2977        if !path.exists() {
2978            return Ok(Self::default());
2979        }
2980        let src =
2981            std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
2982        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2983    }
2984}
2985
2986impl InfraSource {
2987    /// Human-readable descriptor of *which* source this is, for
2988    /// [`InfraOrigin::source`] — distinguishes two linked sources from the
2989    /// same owner. Never includes credentials: `GitSource.repo` is a clone
2990    /// URL (https/ssh), the same thing R561 already treats as safe to log,
2991    /// with any real secret resolved separately via `keystore://` (W274's
2992    /// own precedent).
2993    fn describe(&self) -> String {
2994        match &self.kind {
2995            InfraSourceKind::Path { path } => format!("path:{path}"),
2996            InfraSourceKind::Git(g) => format!("git:{}@{}", g.repo, g.r#ref),
2997        }
2998    }
2999
3000    /// Resolve this source to an infra root directory (R615-F2 / W274 §3).
3001    /// Does no I/O and touches no network: `path` sources read the owner's
3002    /// live tree directly; `git` sources read wherever `yah infra sync`
3003    /// (R615-T3) last synced to, which may not exist yet (an unsynced git
3004    /// source overlays nothing, not an error — see [`load_dir_tolerant`]).
3005    ///
3006    /// `git.subdir` (reused verbatim from [`GitSource`]/R561) is honoured
3007    /// exactly like the component case: the checkout root when unset, or
3008    /// `<checkout>/<subdir>` when set — e.g. `subdir = "infra"` for a
3009    /// monorepo whose infra registry lives under `infra/` rather than at the
3010    /// clone's root. `yah infra sync` (R615-T3) clones into the *checkout*
3011    /// root ([`crate::paths::infra_source_cache_dir`]), never into a
3012    /// subdir-suffixed path, so this is the one place that appends `subdir`.
3013    fn infra_root(&self, workspace_root: &Path) -> std::path::PathBuf {
3014        match &self.kind {
3015            InfraSourceKind::Path { path } => workspace_root.join(path).join(".yah").join("infra"),
3016            InfraSourceKind::Git(g) => {
3017                let checkout = crate::paths::infra_source_cache_dir(workspace_root, &self.owner);
3018                match g.subdir.as_deref() {
3019                    Some(subdir) => checkout.join(subdir),
3020                    None => checkout,
3021                }
3022            }
3023        }
3024    }
3025}
3026
3027/// Provenance for a [`MachineConfig`] or [`ProviderConfig`] pulled in from a
3028/// linked `.yah/infra/sources.toml` entry, rather than declared in this
3029/// camp's own `.yah/infra/` (R615-F2 / W274).
3030///
3031/// Lives in [`CloudConfig::machine_origins`] / `provider_origins`, keyed by
3032/// name/id, rather than as a field on `MachineConfig`/`ProviderConfig`
3033/// themselves: those two types are constructed by struct literal in test
3034/// helpers across several crates (including ones this ticket has no reason to
3035/// touch), so widening either shape would ripple out past this crate for no
3036/// semantic gain — origin is a property of *this load*, not an inherent
3037/// property of the machine/provider. A name absent from the map is
3038/// camp-local; present means borrowed, and the Infra tab (R615-F4) / reconcile
3039/// gating (`InfraSource::mode`, copied onto `mode` below) read it from here.
3040#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3041#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3042pub struct InfraOrigin {
3043    /// The [`InfraSource::owner`] that supplied this entry, e.g. `"yah"`.
3044    pub owner: String,
3045    /// Which source, rendered — see [`InfraSource::describe`].
3046    pub source: String,
3047    /// The write-gate that applied when this entry was overlaid — copied
3048    /// from [`InfraSource::mode`] so a caller holding just the machine/
3049    /// provider doesn't need the source list in hand to know it's borrowed
3050    /// read-only.
3051    pub mode: SourceMode,
3052}
3053
3054/// Like [`load_dir`], but tolerant **per file**: a foreign infra root (an
3055/// owner's live tree, or a synced git checkout) can carry entries this
3056/// binary's `T` predates — noisetable's pre-migration machines used an older
3057/// schema than yah's, and the reverse will happen too as each side evolves
3058/// independently. One unparseable file on a source this camp doesn't own must
3059/// never sink every other entry in the same directory, let alone this camp's
3060/// own load (R615-F2 gotcha). Contrast [`load_dir`], which stays strict for
3061/// camp-local files, where a malformed TOML genuinely should be a hard error.
3062///
3063/// Returns the entries that parsed, plus `(path, error)` for every file that
3064/// didn't — the caller logs those, it doesn't drop them silently. A missing
3065/// or unreadable directory yields `(vec![], vec![])`, same "no entries" as
3066/// `load_dir`'s `!dir.exists()` case (an unsynced git source, or a source
3067/// root with no `providers/` at all, are both normal, not warnings).
3068fn load_dir_tolerant<T: for<'de> Deserialize<'de>>(
3069    dir: &Path,
3070) -> (Vec<T>, Vec<(std::path::PathBuf, anyhow::Error)>) {
3071    let Ok(read_dir) = std::fs::read_dir(dir) else {
3072        return (Vec::new(), Vec::new());
3073    };
3074    let mut entries: Vec<_> = read_dir.filter_map(|e| e.ok()).collect();
3075    entries.sort_by_key(|e| e.file_name());
3076
3077    let mut items = Vec::new();
3078    let mut skipped = Vec::new();
3079    for entry in entries {
3080        let path = entry.path();
3081        if path.extension().map_or(true, |e| e != "toml") {
3082            continue;
3083        }
3084        let parsed = std::fs::read_to_string(&path)
3085            .with_context(|| format!("reading {}", path.display()))
3086            .and_then(|src| {
3087                toml::from_str::<T>(&src).with_context(|| format!("parsing {}", path.display()))
3088            });
3089        match parsed {
3090            Ok(item) => items.push(item),
3091            Err(e) => skipped.push((path, e)),
3092        }
3093    }
3094    (items, skipped)
3095}
3096
3097/// Whether a borrowed machine passes an [`InfraSource::select`] filter
3098/// (R615-F2 / W274). Empty `select` borrows everything. A non-empty `select`
3099/// entry matches either the machine's exact `name` or literal membership in
3100/// its `mesh_tags` — the one shape W274's own example uses
3101/// (`select = ["tag:cloud-runner"]`). Not a glob engine: mesh tags are
3102/// already flat strings compared for exact equality everywhere else in this
3103/// crate (see `resolve_machine_by_mesh_tags`), so a select entry is that same
3104/// comparison, not a new pattern language.
3105fn machine_matches_select(machine: &MachineConfig, select: &[String]) -> bool {
3106    select.is_empty()
3107        || select
3108            .iter()
3109            .any(|s| *s == machine.name || machine.mesh_tags.contains(s))
3110}
3111
3112/// Overlay every linked `.yah/infra/sources.toml` source's machines and
3113/// providers into `machines`/`providers`, recording provenance into
3114/// `machine_origins`/`provider_origins` (R615-F2 / W274). Must be called
3115/// AFTER camp-local entries are already in both vectors and both origin maps
3116/// are seeded with every camp-local name/id already `HashSet`-tracked as
3117/// "seen": collision resolution is "first writer wins," so seeding with
3118/// camp-local first is what makes camp-local win over every source, and an
3119/// earlier source win over a later one.
3120///
3121/// `select` filters which machines a source contributes; it does not apply
3122/// to providers (nothing in W274 or the source ticket describes a
3123/// provider-scoped filter — every provider a source declares either overlays
3124/// whole or, on a name collision, doesn't).
3125fn overlay_infra_sources(
3126    workspace_root: &Path,
3127    sources: &SourcesConfig,
3128    machines: &mut Vec<MachineConfig>,
3129    providers: &mut Vec<ProviderConfig>,
3130    machine_origins: &mut BTreeMap<String, InfraOrigin>,
3131    provider_origins: &mut BTreeMap<String, InfraOrigin>,
3132) {
3133    let mut seen_machine_names: std::collections::HashSet<String> =
3134        machines.iter().map(|m| m.name.clone()).collect();
3135    let mut seen_provider_ids: std::collections::HashSet<String> =
3136        providers.iter().map(|p| p.id.clone()).collect();
3137
3138    for source in &sources.source {
3139        let root = source.infra_root(workspace_root);
3140        let origin = InfraOrigin {
3141            owner: source.owner.clone(),
3142            source: source.describe(),
3143            mode: source.mode,
3144        };
3145
3146        let (foreign_machines, skipped) = load_dir_tolerant::<MachineConfig>(&root.join("machines"));
3147        for (path, e) in skipped {
3148            tracing::warn!(
3149                "infra source {:?} ({}): skipping unparseable machine {}: {e:#}",
3150                source.owner,
3151                root.display(),
3152                path.display()
3153            );
3154        }
3155        for m in foreign_machines {
3156            if seen_machine_names.contains(&m.name) {
3157                continue; // camp-local, or an earlier source, already claimed this name
3158            }
3159            if !machine_matches_select(&m, &source.select) {
3160                continue;
3161            }
3162            seen_machine_names.insert(m.name.clone());
3163            machine_origins.insert(m.name.clone(), origin.clone());
3164            machines.push(m);
3165        }
3166
3167        let (foreign_providers, skipped) = load_dir_tolerant::<ProviderConfig>(&root.join("providers"));
3168        for (path, e) in skipped {
3169            tracing::warn!(
3170                "infra source {:?} ({}): skipping unparseable provider {}: {e:#}",
3171                source.owner,
3172                root.display(),
3173                path.display()
3174            );
3175        }
3176        for p in foreign_providers {
3177            if seen_provider_ids.contains(&p.id) {
3178                continue;
3179            }
3180            seen_provider_ids.insert(p.id.clone());
3181            provider_origins.insert(p.id.clone(), origin.clone());
3182            providers.push(p);
3183        }
3184    }
3185}
3186
3187/// One component of a [`ServiceConfig`]. The `kind` (e.g. `"mesofact-static"`,
3188/// `"almanac"`, `"container"`) selects which reconciler runs against the
3189/// pointed-at workload manifest.
3190#[derive(Debug, Clone, Serialize, Deserialize)]
3191#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3192pub struct ServiceComponent {
3193    pub id: String,
3194    pub kind: String,
3195    /// Path of the directory holding this component's `workload.toml`. Relative
3196    /// to the workspace root for in-tree components, or to the materialized
3197    /// `<checkout>/<subdir>` when [`git`](Self::git) is set.
3198    pub path: String,
3199    /// Optional external git source (R561-F1). When set, the component's code
3200    /// is materialized by shallow-clone before build; see [`GitSource`].
3201    #[serde(default, skip_serializing_if = "Option::is_none")]
3202    pub git: Option<GitSource>,
3203    /// Operator-facing role label, e.g. `"static"`, `"dynamic"`, `"compute"`.
3204    pub role: String,
3205    /// Optional artifact kind this component publishes (`"static"`,
3206    /// `"container-image"`, …). Drives mirror provider-slot routing.
3207    #[serde(default, skip_serializing_if = "Option::is_none")]
3208    pub publishes: Option<String>,
3209    /// URL sub-path a static component's build output is published under,
3210    /// relative to the service's publish prefix (R746). `None` = the service
3211    /// root, which is what every pre-R746 component means.
3212    ///
3213    /// Static publishers lay a component's `out_dir` down at
3214    /// `<bucket>/<service>/<env>/…` and the front door fetches
3215    /// `${ASSET_ORIGIN}/<request path>` — the request path *is* the key. So a
3216    /// service with two static components had them overwrite each other at
3217    /// one prefix, and there was no way to say "this bundle serves under
3218    /// /app". `mount` is that: it appends to the publish prefix, which makes
3219    /// the URL sub-path and the storage sub-path the same string by
3220    /// construction rather than by two manifests agreeing.
3221    ///
3222    /// Cross-checked against the domain route that names the component
3223    /// ([`CloudConfig::cross_ref_validate`]): a component mounted at `/app`
3224    /// must be routed at `/app` or `/app/*`, because a disagreement means
3225    /// requests land on a prefix nothing published to — a 404 whose cause is
3226    /// two files apart.
3227    #[serde(default, skip_serializing_if = "Option::is_none")]
3228    pub mount: Option<String>,
3229    /// Sync-wave index (0-based). Components in wave 0 roll out in parallel
3230    /// first; the reconciler waits for all wave-N components to become healthy
3231    /// before starting wave N+1. Defaults to 0 (all components in one wave).
3232    #[serde(default, skip_serializing_if = "is_zero_u32")]
3233    pub wave: u32,
3234}
3235
3236#[inline]
3237fn is_zero_u32(n: &u32) -> bool {
3238    *n == 0
3239}
3240
3241/// A service's declared databases, grouped by environment (W241 §Sections).
3242/// Parsed from the `[db]` table of `service.toml`; each `[[db.<env>]]` array
3243/// entry names one database. The environment tag drives backend selection at
3244/// query time (see the data-workbench's `db.query` / the `sql_*` MCP tools):
3245/// `dev` = local file, `pond` = a DB inside the running pond container stack
3246/// (reached on a declared localhost port), `cloud` = a remote libSQL/Turso or
3247/// Postgres endpoint whose auth comes from an env var (never stored in TOML).
3248#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3249#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3250pub struct DbCatalog {
3251    /// Local-file SQLite databases used in dev mode.
3252    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3253    pub dev: Vec<DevDb>,
3254    /// Databases running inside the pond container stack.
3255    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3256    pub pond: Vec<PondDb>,
3257    /// Remote cloud databases (Turso, Postgres).
3258    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3259    pub cloud: Vec<CloudDb>,
3260}
3261
3262impl DbCatalog {
3263    /// True when no database is declared in any environment. Lets
3264    /// [`ServiceConfig`] skip serializing an empty `[db]` table.
3265    pub fn is_empty(&self) -> bool {
3266        self.dev.is_empty() && self.pond.is_empty() && self.cloud.is_empty()
3267    }
3268}
3269
3270/// A dev-mode local SQLite database (`[[db.dev]]`). `path` is resolved
3271/// relative to the workspace root and opened as a local file — read/write, no
3272/// network, no auth.
3273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3274#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3275pub struct DevDb {
3276    /// Logical name, unique within the service's `dev` list. Forms the `name`
3277    /// segment of the catalog id `dev:<service>:<name>`.
3278    pub name: String,
3279    /// On-disk SQLite path, relative to the workspace root (or absolute).
3280    pub path: String,
3281}
3282
3283/// A database running inside the pond container stack (`[[db.pond]]`). The
3284/// pond publishes the DB on a localhost TCP port; the hub connects to
3285/// `127.0.0.1:<port>` when the pond is up and returns a clear error when it is
3286/// not. Either `port` (defaulting to a libSQL/`sqld` HTTP endpoint) or a full
3287/// `url` must be given.
3288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3289#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3290pub struct PondDb {
3291    /// Logical name, unique within the service's `pond` list.
3292    pub name: String,
3293    /// Localhost TCP port the pond publishes the DB on. Interpreted per
3294    /// [`kind`](Self::kind). Mutually complete with `url` (provide one).
3295    #[serde(default, skip_serializing_if = "Option::is_none")]
3296    pub port: Option<u16>,
3297    /// Full connection URL, overriding `port` when set (e.g. a non-localhost
3298    /// host or an explicit scheme).
3299    #[serde(default, skip_serializing_if = "Option::is_none")]
3300    pub url: Option<String>,
3301    /// Wire protocol the pond DB speaks. Selects how a bare `port` becomes a
3302    /// URL: `turso` → `http://127.0.0.1:<port>` (libSQL/`sqld` over Hrana),
3303    /// `postgres` → `postgres://127.0.0.1:<port>`.
3304    #[serde(default)]
3305    pub kind: PondDbKind,
3306}
3307
3308/// Wire protocol of a [`PondDb`].
3309#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
3310#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3311#[serde(rename_all = "kebab-case")]
3312pub enum PondDbKind {
3313    /// libSQL / `sqld` over Hrana HTTP — the default.
3314    #[default]
3315    Turso,
3316    /// PostgreSQL wire protocol.
3317    Postgres,
3318}
3319
3320/// A remote cloud database (`[[db.cloud]]`). The connection `url` is stored in
3321/// TOML but the credential never is — `auth_token_env` names an environment
3322/// variable the daemon reads at connect time, so the same declaration works
3323/// whether the token is provisioned service-locally or camp-shared (W241;
3324/// operator confirmed both scopes are needed). A camp-wide cloud DB not owned
3325/// by any single service is declared identically in `.yah/db/cloud.toml`.
3326#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3327#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3328pub struct CloudDb {
3329    /// Logical name, unique within its `cloud` list.
3330    pub name: String,
3331    /// Connection URL: `libsql://…` / `http(s)://…` (Turso, `sqld`) or
3332    /// `postgres://…`.
3333    pub url: String,
3334    /// Name of the environment variable holding the auth token. Resolved in
3335    /// the daemon at connect time (value never stored on disk). For a libSQL
3336    /// URL the token is threaded as `?auth_token=…`.
3337    #[serde(default, skip_serializing_if = "Option::is_none")]
3338    pub auth_token_env: Option<String>,
3339}
3340
3341/// A camp-shared cloud database catalog, parsed from `.yah/db/cloud.toml`.
3342/// These are cloud DBs not owned by any single service — declared once at camp
3343/// scope and addressed as `cloud:<name>` (two-segment id), distinct from a
3344/// service-local `cloud:<service>:<name>`.
3345#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3346#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3347pub struct CampCloudDbs {
3348    #[serde(default, rename = "cloud", skip_serializing_if = "Vec::is_empty")]
3349    pub cloud: Vec<CloudDb>,
3350}
3351
3352impl CampCloudDbs {
3353    /// Load `<camp_root>/.yah/db/cloud.toml`, or an empty catalog if the file
3354    /// is absent (the common case — most camps declare no shared cloud DBs).
3355    pub fn load(camp_root: &Path) -> Result<Self> {
3356        let path = camp_root.join(".yah/db/cloud.toml");
3357        if !path.exists() {
3358            return Ok(Self::default());
3359        }
3360        let src = std::fs::read_to_string(&path)
3361            .with_context(|| format!("reading {}", path.display()))?;
3362        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
3363    }
3364}
3365
3366/// Topological shape of a mirror — how its providers sit relative to each other.
3367#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3368#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3369#[serde(rename_all = "kebab-case")]
3370pub enum MirrorShape {
3371    /// Single machine hosts compute (and any non-Cloudflare-fronted static).
3372    SingleMachine,
3373    /// Operator-local dev mirror — static via built-in file server, compute
3374    /// via the local container runtime.
3375    Local,
3376    /// Multi-machine deployment (machines listed per provider slot).
3377    MultiMachine,
3378}
3379
3380/// Which public-ingress provider fronts this mirror's compute (W267, R594-F11).
3381///
3382/// Both arms answer exactly one question — *given these local workload ports,
3383/// make them publicly reachable at these hostnames* — and they differ only in
3384/// where the ingress rules live and who supervises the front door:
3385///
3386/// | | [`CloudflareTunnel`](Self::CloudflareTunnel) | [`Passway`](Self::Passway) |
3387/// |---|---|---|
3388/// | Ingress rules live | Cloudflare's API (token-form tunnels are remotely-managed) | the pingora `Backends` set in the proxy process |
3389/// | How they get there | an API call per deployed workload | passway polls `GET /service-records?ready=true` |
3390/// | Front door lifecycle | a kamaji-supervised `cloudflared` appliance | a kamaji-supervised passway appliance |
3391///
3392/// Flipping this field is the whole tier ladder: rented edge → sovereign edge
3393/// is a one-line mirror edit, not a rewrite. The provider owns **addressing**
3394/// and never **rendering** — the W173 render cube stays in mesofact's manifest.
3395#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
3396#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3397#[serde(rename_all = "kebab-case")]
3398pub enum IngressProvider {
3399    /// No public front door for this mirror. The default: a mirror that
3400    /// publishes to R2 behind a Worker, or a mesh-only compute tier, has no
3401    /// ingress provider to reconcile.
3402    #[default]
3403    None,
3404    /// Rented edge — `cloudflared` dials *out* from the node to Cloudflare's
3405    /// edge. Zero inbound ports, no TLS to manage on the box, hostname rules
3406    /// held in Cloudflare's API.
3407    CloudflareTunnel,
3408    /// Sovereign edge — passway terminates TLS on the node and load-balances
3409    /// an upstream set discovered from yubaba's service records.
3410    Passway,
3411}
3412
3413impl IngressProvider {
3414    /// `true` when this mirror declares a front door that has to be reconciled.
3415    pub fn is_declared(self) -> bool {
3416        !matches!(self, Self::None)
3417    }
3418
3419    /// Kebab-case wire name, as it appears in `mirrors/<env>.toml`.
3420    pub fn as_str(self) -> &'static str {
3421        match self {
3422            Self::None => "none",
3423            Self::CloudflareTunnel => "cloudflare-tunnel",
3424            Self::Passway => "passway",
3425        }
3426    }
3427}
3428
3429/// One declared **edge**: a front door, the slots it fronts, and the nodes it
3430/// is placed on (W305 F2).
3431///
3432/// A mirror declares a *list* of these, which is what lets one service mix
3433/// front doors — cloudflare for the public web tier, passway for an internal or
3434/// high-throughput one. Before this, [`MirrorConfig::ingress`] was a single
3435/// [`IngressProvider`], so a mirror could **swap** front doors but never mix
3436/// them.
3437///
3438/// ```toml
3439/// [[ingress]]
3440/// provider = "passway"
3441/// machines = ["us-east-001", "us-south-001"]
3442/// slots    = ["bundle"]
3443///
3444/// [[ingress]]
3445/// provider  = "cloudflare-tunnel"
3446/// hostnames = ["issues.yah.dev"]
3447/// ```
3448///
3449/// **The per-node appliance is derived from this, never declared beside it.**
3450/// An edge does invoke a cloudflared or passway process on a box, but that is a
3451/// *consequence* of the service's declaration:
3452/// [`collate_front_doors`](crate::reconciler::collate_front_doors) walks every
3453/// service and derives what each node must run. Declaring it node-side too is
3454/// what produces two sources of truth for one fact.
3455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3456#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3457pub struct IngressEdge {
3458    /// Which front door this edge is. [`IngressProvider::None`] is rejected at
3459    /// plan time — an edge that fronts with nothing is always a typo, never an
3460    /// intent (write no edge instead).
3461    pub provider: IngressProvider,
3462    /// Nodes this front door is placed on — **independent of where the fronted
3463    /// workload runs** (R330-F37).
3464    ///
3465    /// Empty falls back to the fronted slot's own `machine` / `machines`, which
3466    /// is the co-located shape every mirror had before front-door placement was
3467    /// expressible. Listing several is what lets the ingress tier and the
3468    /// service tier scale independently: **N front doors over ONE deployment**,
3469    /// one rendered copy, so no cache coherence to settle.
3470    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3471    pub machines: Vec<String>,
3472    /// Provider slot roles this edge fronts (`"bundle"`, `"compute"`, …).
3473    ///
3474    /// One of the two selectors. With a single edge both may be empty, meaning
3475    /// "every fronted slot" — the legacy shape. With **several** edges a
3476    /// selector is mandatory on each, and the partition must be total and
3477    /// disjoint: a slot claimed by no edge, or by two, is an error naming it.
3478    /// An implicit catch-all across mixed front doors would silently publish a
3479    /// service through the wrong one.
3480    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3481    pub slots: Vec<String>,
3482    /// Public hostnames this edge fronts — the other selector, for partitioning
3483    /// by what the world dials rather than by which slot serves it.
3484    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3485    pub hostnames: Vec<String>,
3486    /// Cloudflare Tunnel id this edge publishes through, overriding the
3487    /// fronting machine's [`MachineConfig::cloudflared`].
3488    ///
3489    /// This is W267 Gap 3's real fix, and it is the *service* side of it: a node
3490    /// can join two cohorts' orange networks, and since §Granularity argues the
3491    /// tunnel credential **is** the isolation boundary, which cohort a given
3492    /// service fronts through is a property of the service, not of the box.
3493    /// `MachineConfig.cloudflared` stays as the per-node default (one tunnel is
3494    /// the common case, and the credential does live on the node), but it is no
3495    /// longer the only way to say it — so the node never has to enumerate
3496    /// cohorts.
3497    #[serde(default, skip_serializing_if = "Option::is_none")]
3498    pub tunnel_id: Option<String>,
3499    /// Infra provider id whose credentials this edge's front door authenticates
3500    /// with — `use = "cloudflare"`, resolved through
3501    /// `.yah/infra/providers/<id>.toml` exactly as a slot's `use` is.
3502    ///
3503    /// Same split as [`tunnel_id`](Self::tunnel_id), one field over: whose
3504    /// Cloudflare account holds the tunnel is a property of the **front door**,
3505    /// not of the box that runs the compute. Without this the account was read
3506    /// off the fronted slot's own `use`, which conflates two unrelated facts —
3507    /// and is unwritable for a slot whose compute provider is `kind = "static"`
3508    /// (a borrowed bare box: placement only, no credentials). Such a mirror had
3509    /// no way to name a Cloudflare account at all, short of writing
3510    /// `use = "cloudflare"` on the compute slot and lying about what runs it
3511    /// (R845).
3512    ///
3513    /// `None` falls back to the fronted slot's `use`, which is what every
3514    /// mirror written before this field meant.
3515    #[serde(default, rename = "use", skip_serializing_if = "Option::is_none")]
3516    pub provider_id: Option<String>,
3517}
3518
3519impl IngressEdge {
3520    /// An edge with no selector — fronts every fronted slot, legal only when it
3521    /// is the mirror's only edge.
3522    pub fn all_slots(provider: IngressProvider, machines: Vec<String>) -> Self {
3523        Self {
3524            provider,
3525            machines,
3526            slots: Vec::new(),
3527            hostnames: Vec::new(),
3528            tunnel_id: None,
3529            provider_id: None,
3530        }
3531    }
3532
3533    /// `true` when this edge names which slots/hostnames it fronts.
3534    pub fn has_selector(&self) -> bool {
3535        !self.slots.is_empty() || !self.hostnames.is_empty()
3536    }
3537
3538    /// Does this edge claim the rule derived from `slot` publishing `hostname`?
3539    ///
3540    /// A selectorless edge claims everything; that is checked to be
3541    /// unambiguous (one edge only) before this is consulted.
3542    pub fn claims(&self, slot: &str, hostname: &str) -> bool {
3543        if !self.has_selector() {
3544            return true;
3545        }
3546        self.slots.iter().any(|s| s == slot) || self.hostnames.iter().any(|h| h == hostname)
3547    }
3548
3549    /// Human-readable identity for an error message — the provider plus
3550    /// whichever selector was written.
3551    pub fn label(&self) -> String {
3552        let sel = match (self.slots.is_empty(), self.hostnames.is_empty()) {
3553            (true, true) => "no selector".to_string(),
3554            (false, true) => format!("slots = {:?}", self.slots),
3555            (true, false) => format!("hostnames = {:?}", self.hostnames),
3556            (false, false) => format!("slots = {:?} + hostnames = {:?}", self.slots, self.hostnames),
3557        };
3558        format!("[[ingress]] provider = {:?} ({sel})", self.provider.as_str())
3559    }
3560}
3561
3562/// A mirror's `ingress` declaration, in either spelling.
3563///
3564/// The list is the general form; the bare provider is shorthand for the single
3565/// edge fronting everything, and is kept rather than migrated because it is the
3566/// honest spelling for the common case — one service, one front door. Both
3567/// normalize to the same `Vec<IngressEdge>` through
3568/// [`MirrorConfig::ingress_edges`], so nothing downstream branches on which was
3569/// written.
3570#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3571#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3572#[serde(untagged)]
3573pub enum IngressDecl {
3574    /// `ingress = "passway"` — one edge fronting every fronted slot, placed by
3575    /// the sibling [`MirrorConfig::ingress_machines`].
3576    Provider(IngressProvider),
3577    /// `[[ingress]]` — one entry per declared edge.
3578    Edges(Vec<IngressEdge>),
3579}
3580
3581/// Hand-written because `#[serde(untagged)]` throws the real error away.
3582///
3583/// A derived untagged `Deserialize` tries each variant and, on failure, reports
3584/// only `data did not match any variant of untagged enum IngressDecl` — so a
3585/// misspelled `provider = "passwya"` says nothing about providers, nothing about
3586/// the legal values, and points at the `[[ingress]]` header rather than the
3587/// field. Dispatching on the input shape first means each arm's own error
3588/// survives: a bad string names the legal provider vocabulary, a bad edge table
3589/// names the offending field.
3590impl<'de> Deserialize<'de> for IngressDecl {
3591    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
3592        struct DeclVisitor;
3593
3594        impl<'de> serde::de::Visitor<'de> for DeclVisitor {
3595            type Value = IngressDecl;
3596
3597            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
3598                f.write_str(
3599                    "a provider name (`ingress = \"passway\"`) or a list of edge tables \
3600                     (`[[ingress]]`)",
3601                )
3602            }
3603
3604            fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
3605                IngressProvider::deserialize(serde::de::value::StrDeserializer::new(v))
3606                    .map(IngressDecl::Provider)
3607            }
3608
3609            fn visit_seq<A: serde::de::SeqAccess<'de>>(
3610                self,
3611                seq: A,
3612            ) -> std::result::Result<Self::Value, A::Error> {
3613                Vec::<IngressEdge>::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))
3614                    .map(IngressDecl::Edges)
3615            }
3616        }
3617
3618        d.deserialize_any(DeclVisitor)
3619    }
3620}
3621
3622/// No front door — the shape of every mirror that publishes to R2 behind a
3623/// Worker, or runs a mesh-only compute tier.
3624impl Default for IngressDecl {
3625    fn default() -> Self {
3626        Self::Provider(IngressProvider::None)
3627    }
3628}
3629
3630impl IngressDecl {
3631    /// `true` when this mirror declares no front door at all.
3632    pub fn is_absent(&self) -> bool {
3633        match self {
3634            Self::Provider(p) => !p.is_declared(),
3635            Self::Edges(e) => e.is_empty(),
3636        }
3637    }
3638}
3639
3640impl From<IngressProvider> for IngressDecl {
3641    fn from(p: IngressProvider) -> Self {
3642        Self::Provider(p)
3643    }
3644}
3645
3646/// A service mirror — the projection of a [`ServiceConfig`] onto concrete
3647/// infra. Lives at `.yah/services/<svc>/mirrors/<env>.toml`.
3648#[derive(Debug, Clone, Serialize, Deserialize)]
3649#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3650pub struct MirrorConfig {
3651    pub schema_version: u32,
3652    pub shape: MirrorShape,
3653    /// Public-ingress edges fronting this mirror (W267, W305 F2). Defaults to
3654    /// none.
3655    ///
3656    /// Two spellings, one meaning — see [`IngressDecl`]. `ingress = "passway"`
3657    /// is one edge fronting everything; `[[ingress]]` entries declare several,
3658    /// each naming its provider plus the slots or hostnames it fronts. Read it
3659    /// through [`ingress_edges`](Self::ingress_edges), never by matching on the
3660    /// enum, so the two spellings cannot drift apart.
3661    ///
3662    /// Declared at mirror scope rather than per provider slot because a front
3663    /// door does **fan-in**: one `cloudflared` (or one passway) on a node
3664    /// multiplexes every hostname→port rule it fronts, so pinning one to a
3665    /// single slot would mint one edge connection per slot for no gain. An
3666    /// edge's `slots` selector is the general form of that — it groups slots
3667    /// behind one front door, it does not split a front door per slot.
3668    #[serde(default, skip_serializing_if = "IngressDecl::is_absent")]
3669    pub ingress: IngressDecl,
3670    /// Machines the front door is placed on — **independent of where the
3671    /// fronted workload runs** (R330-F37).
3672    ///
3673    /// The single-edge spelling of [`IngressEdge::machines`]: it applies to the
3674    /// one edge `ingress = "<provider>"` declares, and combining it with
3675    /// `[[ingress]]` entries is an error rather than a silent precedence rule.
3676    ///
3677    /// Empty (the default) keeps the pre-existing behaviour: the front door is
3678    /// co-located with the fronted slot's own `machine` / `machines`. That was
3679    /// never a design choice, it was an artifact of bundles binding
3680    /// `127.0.0.1` — nothing off-node could reach a workload, so a proxy had to
3681    /// sit on top of it. R599-F12 landed mesh binding, which removes the
3682    /// constraint: passway is a reverse proxy, and a valid front door needs a
3683    /// cert and an upstream it can *reach*, not a local copy of the service.
3684    ///
3685    /// Listing several machines is what lets the ingress tier and the service
3686    /// tier scale independently — **N front doors over ONE deployment**. There
3687    /// is still exactly one rendered copy of the site, so fanning the front door
3688    /// out introduces no cache-coherence problem; that only appears if you
3689    /// deploy the *workload* to every node instead.
3690    ///
3691    /// ```toml
3692    /// ingress = "passway"
3693    /// ingress_machines = ["us-east-001", "us-west-001"]
3694    /// ```
3695    ///
3696    /// Declaring this without [`ingress`](Self::ingress) is an error, not a
3697    /// no-op — it always means the operator expected a front door somewhere.
3698    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3699    pub ingress_machines: Vec<String>,
3700    /// Provider slots, keyed by role (`"static"`, `"compute"`, …). Each value
3701    /// either references a provider declared under `.yah/infra/providers/` or
3702    /// inlines a local-only provider (no creds, no infra file).
3703    ///
3704    /// A role is normally service-wide — one slot serves every component that
3705    /// shares it — but [`ReconcileCtx::slot`](crate::reconciler::ReconcileCtx::slot)
3706    /// looks up the component-qualified key `"<role>:<component id>"` first.
3707    /// A service with two components of the same role (e.g. two
3708    /// `mesofact-static` components under one mirror) declares
3709    /// `providers."static:<id>"` per component to give each its own port;
3710    /// omitting the qualifier keeps the pre-existing single-slot behavior.
3711    #[serde(default)]
3712    pub providers: BTreeMap<String, MirrorProviderSlot>,
3713    /// Capability→driver bindings, keyed by **capability** (`"pg"`, `"s3"`, …)
3714    /// rather than by slot role (W265 §Drivers).
3715    ///
3716    /// This is the generalization of [`Self::providers`]: `providers.static` /
3717    /// `providers.object_store` are the special case where the slot name and
3718    /// the capability happen to coincide, and keying by capability is what stops
3719    /// the slot enum growing one arm per tier-specific implementation. A service
3720    /// says "I need pg"; the mirror says which implementation of pg *this tier*
3721    /// uses; the app talks the same wire protocol either way and never forks.
3722    ///
3723    /// ```toml
3724    /// [drivers.pg]
3725    /// kind = "local-pg-dev"     # dev  — kamaji-supervised loopback postgres
3726    /// ```
3727    ///
3728    /// Additive in P1: `drivers` lands *alongside* `providers`, and migrating
3729    /// the existing `providers.static` / `providers.object_store` declarations
3730    /// over is a separate pass (W265 §"Open follow-ups"). A mirror that declares
3731    /// neither is unchanged.
3732    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3733    pub drivers: BTreeMap<String, MirrorProviderSlot>,
3734    /// Per-environment alias overrides for `kind = "static-asset"` components.
3735    ///
3736    /// Keys are logical names (e.g. `"whisper-default"`); values must be
3737    /// filenames present in the component's `workload.toml` catalog.
3738    /// **Resolution only** — this table may never introduce a filename absent
3739    /// from the catalog. Validated against the workload catalog at sync time.
3740    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3741    pub asset_aliases: BTreeMap<String, String>,
3742}
3743
3744impl MirrorConfig {
3745    /// This mirror's declared edges, with both spellings normalized (W305 F2).
3746    ///
3747    /// The single place `ingress` + `ingress_machines` are reconciled, so no
3748    /// consumer has to know which spelling was written. Returns an empty vec
3749    /// when the mirror declares no front door.
3750    ///
3751    /// Errors are the declarations that cannot mean anything:
3752    ///
3753    /// - `ingress_machines` with no `ingress` — front-door placement with no
3754    ///   front door to place, always a typo (R330-F37);
3755    /// - `ingress_machines` alongside `[[ingress]]` — placement declared twice,
3756    ///   in a form where one silently wins;
3757    /// - `provider = "none"` on an edge — an edge that fronts with nothing.
3758    /// The `[[ingress]]` entries exactly as written, without normalizing the
3759    /// scalar spelling or validating anything.
3760    ///
3761    /// [`ingress_edges`](Self::ingress_edges) is the one to reach for; this
3762    /// exists for the checks that must run *before* a mirror is known to be
3763    /// well-formed — cross-reference validation walks every mirror in the
3764    /// workspace, and hard-failing there on an unrelated mirror's shape error
3765    /// would report the wrong file. Empty for the scalar spelling, which has no
3766    /// edge table to carry per-edge fields.
3767    pub fn ingress_edge_slice(&self) -> &[IngressEdge] {
3768        match &self.ingress {
3769            IngressDecl::Edges(edges) => edges,
3770            IngressDecl::Provider(_) => &[],
3771        }
3772    }
3773
3774    pub fn ingress_edges(&self) -> Result<Vec<IngressEdge>> {
3775        match &self.ingress {
3776            IngressDecl::Provider(p) if !p.is_declared() => {
3777                if !self.ingress_machines.is_empty() {
3778                    bail!(
3779                        "mirror declares `ingress_machines = {:?}` but no `ingress` provider — \
3780                         front-door placement with no front door to place. Add \
3781                         `ingress = \"passway\"` (or \"cloudflare-tunnel\"), or drop \
3782                         `ingress_machines`.",
3783                        self.ingress_machines
3784                    );
3785                }
3786                Ok(Vec::new())
3787            }
3788            IngressDecl::Provider(p) => Ok(vec![IngressEdge::all_slots(
3789                *p,
3790                self.ingress_machines.clone(),
3791            )]),
3792            IngressDecl::Edges(edges) => {
3793                if !self.ingress_machines.is_empty() {
3794                    bail!(
3795                        "mirror declares both `[[ingress]]` edges and the single-edge \
3796                         `ingress_machines = {:?}` — front-door placement stated twice. Move \
3797                         those names onto the edge they place: `machines = [...]` inside the \
3798                         `[[ingress]]` entry.",
3799                        self.ingress_machines
3800                    );
3801                }
3802                for edge in edges {
3803                    if !edge.provider.is_declared() {
3804                        bail!(
3805                            "{}: `provider = \"none\"` fronts nothing. An edge exists to name a \
3806                             front door — delete the entry instead.",
3807                            edge.label()
3808                        );
3809                    }
3810                }
3811                Ok(edges.clone())
3812            }
3813        }
3814    }
3815
3816    /// Nodes this mirror's **passway** front doors are placed on, in
3817    /// declaration order and de-duplicated — or `None` when the mirror declares
3818    /// no passway edge at all.
3819    ///
3820    /// `Some(vec![])` is a real and different answer from `None`: a passway edge
3821    /// is declared but names no machine, so its placement falls back to the
3822    /// fronted slot's own. That fallback is placement *resolution* — it belongs
3823    /// to [`IngressRule::machines`](crate::reconciler::IngressRule::machines)
3824    /// and the plan it is built from, not to a mirror read in isolation — so it
3825    /// is reported as "declared, placement unknown from here" rather than
3826    /// half-derived. A caller that needs a node to dial has to say so.
3827    ///
3828    /// Passway-only because the caller is tenant DNS onboarding: only a passway
3829    /// node serves yubaba's `GET /domains/{domain}/onboarding`. A
3830    /// cloudflare-tunnel edge publishes through Cloudflare's own DNS and has no
3831    /// such record to hand a tenant, so folding its machines in would point the
3832    /// UI at a node that cannot answer.
3833    ///
3834    /// Read through [`ingress_edges`](Self::ingress_edges), so both spellings
3835    /// are covered by construction. A declaration that cannot mean anything
3836    /// (`ingress_machines` with no `ingress`, or both spellings at once) reads
3837    /// as `None` rather than propagating an error: those are reported by
3838    /// cross-reference validation, which can name the offending file.
3839    pub fn passway_machines(&self) -> Option<Vec<String>> {
3840        let edges = self.ingress_edges().ok()?;
3841        let mut declared = false;
3842        let mut machines: Vec<String> = Vec::new();
3843        for edge in edges
3844            .iter()
3845            .filter(|e| matches!(e.provider, IngressProvider::Passway))
3846        {
3847            declared = true;
3848            for m in &edge.machines {
3849                if !machines.iter().any(|seen| seen == m) {
3850                    machines.push(m.clone());
3851                }
3852            }
3853        }
3854        declared.then_some(machines)
3855    }
3856
3857    /// Parse a single `mirrors/<env>.toml` file.
3858    pub fn load(path: &Path) -> Result<Self> {
3859        let src =
3860            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
3861        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
3862    }
3863
3864    /// Persist to `.yah/services/<service>/mirrors/<env>.toml`, creating the
3865    /// `mirrors/` directory if needed. Create-or-overwrite. The mirror file is
3866    /// named by `env` (its stem); `service` selects the owning service dir.
3867    pub fn save(&self, workspace_root: &Path, service: &str, env: &str) -> Result<()> {
3868        let dir = crate::paths::service_mirrors_dir(workspace_root, service);
3869        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
3870        let path = crate::paths::service_mirror_toml(workspace_root, service, env);
3871        let s = toml::to_string_pretty(self)
3872            .with_context(|| format!("serializing mirror {service}/{env}"))?;
3873        std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
3874    }
3875
3876    /// Remove `.yah/services/<service>/mirrors/<env>.toml`. Returns `false`
3877    /// when the file was already absent. Leaves the service and its other
3878    /// mirrors untouched.
3879    ///
3880    /// Also checks legacy stems (e.g. `local-sim` when `env = "pond"`) so
3881    /// deleting a canonical tier name removes whichever file exists on disk.
3882    pub fn delete(workspace_root: &Path, service: &str, env: &str) -> Result<bool> {
3883        let path = crate::paths::service_mirror_toml(workspace_root, service, env);
3884        if path.exists() {
3885            std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
3886            return Ok(true);
3887        }
3888        // Try legacy file stems for canonical tier names.
3889        let legacy: &[&str] = match env {
3890            "dev" => &["local"],
3891            "pond" => &["local-sim", "sim"],
3892            "cloud" => &["prod"],
3893            _ => &[],
3894        };
3895        for stem in legacy {
3896            let alt = crate::paths::service_mirror_toml(workspace_root, service, stem);
3897            if alt.exists() {
3898                std::fs::remove_file(&alt)
3899                    .with_context(|| format!("removing {}", alt.display()))?;
3900                return Ok(true);
3901            }
3902        }
3903        Ok(false)
3904    }
3905}
3906
3907/// A provider slot inside a [`MirrorConfig`]. Two shapes:
3908/// - **Reference** (`use = "<provider-id>"`) — point at an infra-declared
3909///   provider; extra fields are slot-specific (bucket, zone, dns, …).
3910/// - **Inline** (`kind = "local-*"`) — for providers that need no infra
3911///   declaration because they carry no credentials.
3912#[derive(Debug, Clone, Serialize, Deserialize)]
3913#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3914#[serde(untagged)]
3915pub enum MirrorProviderSlot {
3916    Reference {
3917        #[serde(rename = "use")]
3918        provider_id: String,
3919        #[serde(flatten)]
3920        #[cfg_attr(
3921            feature = "json-schema",
3922            schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
3923        )]
3924        fields: BTreeMap<String, toml::Value>,
3925    },
3926    Inline {
3927        kind: Provider,
3928        #[serde(flatten)]
3929        #[cfg_attr(
3930            feature = "json-schema",
3931            schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
3932        )]
3933        fields: BTreeMap<String, toml::Value>,
3934    },
3935}
3936
3937impl MirrorProviderSlot {
3938    /// Provider id this slot references, or `None` for inline slots.
3939    pub fn provider_id(&self) -> Option<&str> {
3940        match self {
3941            Self::Reference { provider_id, .. } => Some(provider_id),
3942            Self::Inline { .. } => None,
3943        }
3944    }
3945
3946    /// Provider kind for inline slots, or `None` for reference slots
3947    /// (resolve via the referenced [`ProviderConfig`]).
3948    pub fn inline_kind(&self) -> Option<Provider> {
3949        match self {
3950            Self::Reference { .. } => None,
3951            Self::Inline { kind, .. } => Some(*kind),
3952        }
3953    }
3954
3955    pub fn fields(&self) -> &BTreeMap<String, toml::Value> {
3956        match self {
3957            Self::Reference { fields, .. } | Self::Inline { fields, .. } => fields,
3958        }
3959    }
3960
3961    /// F16 placement: parse the optional `required = { … }` sub-table on this
3962    /// slot. Returns `None` when absent or unparseable (callers treat as no
3963    /// constraint). See [`RequiredSpec`] for the field grammar.
3964    pub fn required(&self) -> Option<RequiredSpec> {
3965        let v = self.fields().get("required")?.clone();
3966        v.try_into().ok()
3967    }
3968}
3969
3970/// F16 placement constraints declared on a [`MirrorProviderSlot`], lives under
3971/// `[providers.<role>] required = { regions = [...], mesh_tags = [...] }` in
3972/// `mirrors/<env>.toml`.
3973///
3974/// Hard (must-satisfy) axes, all AND-ed together:
3975/// - `regions` / `zones` / `providers` — *membership*: the machine's
3976///   `region` / `zone` / `provider` must be one of the listed values.
3977/// - `mesh_tags` — *superset*: the machine's `mesh_tags` must contain every
3978///   listed tag.
3979/// - `memory_mb` / `cpu_millis` — *capacity floor* (R572-F5): the machine's
3980///   `allocatable` budget must cover the demand. `0` = no constraint.
3981/// - `repel_archetypes` — *taint repulsion* (R572-F5, widened to a set by
3982///   R860-T4): the machine must not carry `"no-<archetype.taint_key()>"` for
3983///   **any** archetype in the placement group. Empty = no repulsion check.
3984///   Absolute — see [`Self::repel_archetypes`].
3985/// - `requires_taint` — *taint affinity* (R572-F5): the machine must carry
3986///   this taint key (in `taints` or `mesh_tags`). `None` = no affinity.
3987///
3988/// These two are the **only** readers of [`MachineConfig::taints`], which is
3989/// what makes [`taint_effect`]'s closed vocabulary well-founded.
3990///
3991/// [`MachineConfig::sovereign_group`] is deliberately **not** an axis here and
3992/// must not become one (W305/R742-F1). A sovereign group is a blast radius,
3993/// not a filter: which quorum a box votes in says nothing about whether a
3994/// workload may run on it, and a dev-group node exists precisely so dev-mode
3995/// services — stateful ones included — can be scheduled onto it. Filtering on
3996/// it would re-make the mistake W305 exists to undo, where one mechanism
3997/// silently carried three unrelated properties.
3998///
3999/// An empty / zero / None on every axis means "no constraint on that axis".
4000/// A fully-unconstrained `RequiredSpec` matches every machine (see
4001/// [`RequiredSpec::is_unconstrained`]).
4002#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4003#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4004pub struct RequiredSpec {
4005    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4006    pub regions: Vec<String>,
4007    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4008    pub zones: Vec<String>,
4009    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4010    pub providers: Vec<String>,
4011    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4012    pub mesh_tags: Vec<String>,
4013
4014    /// R833-F8: **imperative** placement — the machine must be one of these by
4015    /// `name`. Empty (the default) = no constraint, which is every pre-R833-F8
4016    /// caller.
4017    ///
4018    /// This is the one axis that is not a *capability* the scheduler infers.
4019    /// The operator typed `--where=node:us-west-003`, so it composes with the
4020    /// other axes exactly like the rest — a named node that fails the capacity
4021    /// floor or carries a repelling taint still does not match, and the refusal
4022    /// names why rather than silently placing the work somewhere else.
4023    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4024    pub nodes: Vec<String>,
4025
4026    /// R572-F5: minimum memory (MiB) the target node must have in its
4027    /// declared `allocatable` budget. `0` = no constraint. Filled by
4028    /// [`CloudConfig::admit_workload`] from the workload's
4029    /// `memory_request_mb()` — its placement **request**, which is not the
4030    /// same number as the `resources.memory_mb` cgroup **ceiling**.
4031    #[serde(default, skip_serializing_if = "is_zero_u32")]
4032    pub memory_mb: u32,
4033    /// R572-F5: minimum CPU (millicores) the target node must have in its
4034    /// declared `allocatable` budget. `0` = no constraint. Filled by
4035    /// [`CloudConfig::admit_workload`] from the workload's `resources.cpu_millis`.
4036    #[serde(default, skip_serializing_if = "is_zero_u32")]
4037    pub cpu_millis: u32,
4038    /// R572-F5: effective archetypes of the workloads being placed. The
4039    /// scheduler rejects any node carrying `"no-<archetype.taint_key()>"` for
4040    /// any of them. Empty = no repulsion check (backwards-compat for callers
4041    /// that don't thread a spec through).
4042    ///
4043    /// **A set, not a single value (R860-T4 / W338 §Placement consequences
4044    /// 2).** [`admission_spec`] places the requirer's whole *placement group* —
4045    /// the transitive closure of `local` requirement edges — so the axis is the
4046    /// union of the members' archetypes. A `Server` that requires an
4047    /// `Appliance` `local` must be repelled by `no-appliance`, because the two
4048    /// land on one node or neither does; collapsing that back to one archetype
4049    /// would place the group on a node that rejects half of it.
4050    ///
4051    /// **This is an absolute block, not a preference.**
4052    /// [`CloudConfig::admit_workload`] sets it unconditionally from the
4053    /// group's effective archetypes, and nothing in the tree tolerates a
4054    /// taint — so a workload cannot opt out of a `no-<archetype>` node
4055    /// (W305 finding 2 / R742-T4). Adding toleration means giving
4056    /// `WorkloadSpec` a tolerations list and consulting it here; until then,
4057    /// do not describe this as "repel-unless-tolerate".
4058    #[serde(skip)]
4059    pub repel_archetypes: Vec<LifecycleArchetype>,
4060    /// R572-F5: taint the workload requires the target node to carry
4061    /// (annotation `yah.placement.requires-taint`). The node must have the
4062    /// key in its `taints` list or `mesh_tags`. `None` = no affinity constraint.
4063    #[serde(skip)]
4064    pub requires_taint: Option<String>,
4065
4066    /// R844-F8: **how many** machines this constraint places onto. `None` — the
4067    /// only shape on disk before this field — means one, so every mirror in the
4068    /// tree resolves byte-identically across the change.
4069    ///
4070    /// This is not a match axis: it never appears in [`Self::matches`] and never
4071    /// changes whether a given machine qualifies. It is the *cardinality* of the
4072    /// answer, which is why it lives here rather than as another filter — the
4073    /// operator declares what is required and how many of it, and the scheduler
4074    /// picks which.
4075    ///
4076    /// **Declared, never inferred.** The count is emphatically not "how many
4077    /// machines happen to match": deriving it that way would make adding a box
4078    /// to the fleet silently scale a production front door. A constraint that
4079    /// matches four machines and asks for two places on two.
4080    ///
4081    /// **Fewer matches than asked is an error** ([`select_matching`]), not a
4082    /// partial placement. Placing one of two and reporting success is the
4083    /// subset-that-looks-like-it-worked failure R844 exists to close.
4084    ///
4085    /// Deliberately absent from [`Self::is_unconstrained`], which answers "does
4086    /// every machine match" — a question about the predicate, not the count. A
4087    /// `required = { replicas = 2 }` with no axis is therefore still
4088    /// unconstrained, and the deploy side still refuses it as an
4089    /// underspecified placement.
4090    #[serde(default, skip_serializing_if = "Option::is_none")]
4091    pub replicas: Option<u32>,
4092}
4093
4094impl RequiredSpec {
4095    /// How many machines this constraint places onto — [`Self::replicas`],
4096    /// resolving the absent case to the pre-R844-F8 answer of one.
4097    ///
4098    /// The single place that default is spelled, so the ingress planner and the
4099    /// deploy resolver cannot disagree about what "no replica count" means.
4100    pub fn replica_count(&self) -> usize {
4101        self.replicas.unwrap_or(1) as usize
4102    }
4103
4104    /// True when no axis carries a constraint — every machine matches.
4105    pub fn is_unconstrained(&self) -> bool {
4106        self.regions.is_empty()
4107            && self.zones.is_empty()
4108            && self.providers.is_empty()
4109            && self.mesh_tags.is_empty()
4110            && self.nodes.is_empty()
4111            && self.memory_mb == 0
4112            && self.cpu_millis == 0
4113            && self.repel_archetypes.is_empty()
4114            && self.requires_taint.is_none()
4115    }
4116
4117    /// Whether `machine` satisfies every hard axis.
4118    ///
4119    /// - Membership axes (region/zone/provider): machine must carry the field
4120    ///   and it must appear in the constraint list.
4121    /// - `mesh_tags`: machine tags must be a superset of the required set.
4122    /// - **R572-F5 capacity floor**: `machine.allocatable.{memory,cpu}` must
4123    ///   cover `self.{memory,cpu}`. A machine with no `allocatable` block passes
4124    ///   unconditionally (capacity unknown → no constraint enforced).
4125    /// - **R572-F5 taint repulsion**: machine must not carry the taint
4126    ///   `"no-<archetype.taint_key()>"` for *any* archetype in
4127    ///   [`Self::repel_archetypes`] — every class in the placement group, not
4128    ///   just the requirer's (R860-T4). Absolute — the workload has no way to
4129    ///   tolerate it (W305 finding 2).
4130    /// - **R572-F5 taint affinity**: if `requires_taint` is set, the machine
4131    ///   must carry that key in its `taints` list or `mesh_tags`.
4132    ///
4133    /// Any *other* taint on the machine is ignored here, which is precisely
4134    /// why [`crate::validate::check_inert_taints`] refuses to let one be
4135    /// declared: it would read as a constraint and be none.
4136    pub fn matches(&self, machine: &MachineConfig) -> bool {
4137        let member_ok = |constraint: &[String], value: Option<&str>| -> bool {
4138            constraint.is_empty() || value.map_or(false, |v| constraint.iter().any(|c| c == v))
4139        };
4140
4141        // R833-F8: imperative node pin, checked first because it is the axis a
4142        // human asserted rather than one the scheduler derived — a refusal
4143        // should read "us-west-003 does not match" and not lead with a tag set
4144        // the operator never typed.
4145        if !member_ok(&self.nodes, Some(machine.name.as_str())) {
4146            return false;
4147        }
4148
4149        // Membership + mesh-tags (pre-existing axes).
4150        if !member_ok(&self.regions, machine.region.as_deref())
4151            || !member_ok(&self.zones, machine.zone.as_deref())
4152            || !member_ok(&self.providers, Some(machine.provider.as_str()))
4153            || !self
4154                .mesh_tags
4155                .iter()
4156                .all(|t| machine.mesh_tags.iter().any(|mt| mt == t))
4157        {
4158            return false;
4159        }
4160
4161        // R572-F5: capacity floor. Skipped when machine has no allocatable
4162        // declaration (unknown capacity → passes, consistent with pre-F5 behaviour).
4163        if self.memory_mb > 0 || self.cpu_millis > 0 {
4164            if let Some(alloc) = &machine.allocatable {
4165                if self.memory_mb > alloc.memory_mb || self.cpu_millis > alloc.cpu_millis {
4166                    return false;
4167                }
4168            }
4169        }
4170
4171        // R572-F5: taint repulsion. A node taint "no-<archetype>" rejects the
4172        // workload class outright — there is no toleration list to consult.
4173        for arch in &self.repel_archetypes {
4174            let repel_key = format!("no-{}", arch.taint_key());
4175            if machine.taints.iter().any(|t| *t == repel_key) {
4176                return false;
4177            }
4178        }
4179
4180        // R572-F5: taint affinity. Machine must carry the required taint key
4181        // in either its `taints` list or `mesh_tags`.
4182        if let Some(req) = &self.requires_taint {
4183            let has_it = machine.taints.iter().any(|t| t == req)
4184                || machine.mesh_tags.iter().any(|t| t == req);
4185            if !has_it {
4186                return false;
4187            }
4188        }
4189
4190        true
4191    }
4192
4193    /// Human-readable summary of the constraints, for fail-loud error messages.
4194    /// Example: `required.regions=[us-west] + required.mesh_tags=[tag:cloud-runner]`.
4195    pub fn describe(&self) -> String {
4196        let mut parts = Vec::new();
4197        let mut push = |label: &str, vals: &[String]| {
4198            if !vals.is_empty() {
4199                parts.push(format!("required.{label}=[{}]", vals.join(",")));
4200            }
4201        };
4202        push("nodes", &self.nodes);
4203        push("regions", &self.regions);
4204        push("zones", &self.zones);
4205        push("providers", &self.providers);
4206        push("mesh_tags", &self.mesh_tags);
4207        if self.memory_mb > 0 {
4208            parts.push(format!("memory_mb>={}", self.memory_mb));
4209        }
4210        if self.cpu_millis > 0 {
4211            parts.push(format!("cpu_millis>={}", self.cpu_millis));
4212        }
4213        for arch in &self.repel_archetypes {
4214            parts.push(format!("not-tainted(no-{})", arch.taint_key()));
4215        }
4216        if let Some(req) = &self.requires_taint {
4217            parts.push(format!("requires_taint={req}"));
4218        }
4219        if parts.is_empty() {
4220            "no constraints".to_string()
4221        } else {
4222            parts.join(" + ")
4223        }
4224    }
4225}
4226
4227/// Which front door actually serves a domain's requests (R594-F12).
4228///
4229/// Every domain manifest must say this out loud. Before it existed the
4230/// difference between "R2 serves this hostname directly" and "a Worker
4231/// serves it" was expressed *only* by whether the file happened to carry
4232/// `[[routes]]` — so binding a route-carrying domain straight to R2 was
4233/// accepted silently and served 200s on its SSG half while losing clean
4234/// URLs, SPA shell fallback, deferred-route pointers and branded error
4235/// pages. All of those live in the Worker
4236/// (`oss/mesofact/packages/mesofact-edge/src/router.ts`) or in
4237/// mesofact-serve; an R2 custom domain has none of them.
4238///
4239/// The vocabulary mirrors `scripts/cf-apex-mode.sh` (worker | grey | orange)
4240/// — this moves the choice into the config where it can be checked instead
4241/// of living in one bash script.
4242///
4243/// A front door does **fan-in** only. The render cube (SSG / SPA / SSR /
4244/// deferred / 404) is mesofact's manifest, not this one — see W173 and
4245/// `.yah/docs/working/W267-sovereign-public-ingress.md`
4246/// §"Two front doors, one render contract".
4247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4248#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4249#[serde(rename_all = "kebab-case")]
4250pub enum FrontDoor {
4251    /// Cloudflare R2 custom domain. Requests hit R2 objects with edge
4252    /// caching and nothing else — no clean URLs, no SPA fallback, no
4253    /// branded errors. Correct for a pure asset tier (W175's verdict for
4254    /// `cdn.yah.dev`) and wrong for anything that renders pages.
4255    /// Implies zero `[[routes]]` and no `worker_bundle_path`.
4256    BucketDirect,
4257    /// Cloudflare Worker generated from this manifest's route table.
4258    Worker,
4259    /// Sovereign L7 ingress — the `passway` proxy on yah-owned metal
4260    /// (`oss/passway`, W267). Same route table as `worker`; different
4261    /// machine terminates TLS.
4262    Passway,
4263}
4264
4265impl FrontDoor {
4266    /// Whether this front door consumes the manifest's `[[routes]]` table.
4267    /// `bucket-direct` does not; the other two are nothing without it.
4268    pub fn is_route_driven(self) -> bool {
4269        matches!(self, FrontDoor::Worker | FrontDoor::Passway)
4270    }
4271
4272    /// The manifest spelling, for error messages.
4273    pub fn as_str(self) -> &'static str {
4274        match self {
4275            FrontDoor::BucketDirect => "bucket-direct",
4276            FrontDoor::Worker => "worker",
4277            FrontDoor::Passway => "passway",
4278        }
4279    }
4280}
4281
4282/// A routing manifest for one domain, from `.yah/domains/<name>.toml`.
4283///
4284/// The domain manifest is the *only* place that knows about path routing:
4285/// services declare static/backend components by opaque ID, and this
4286/// manifest binds those components to URL paths on a public-facing
4287/// domain. Generated Worker bundles consume this. See
4288/// `.yah/docs/working/W118-yah-domain-tiers.md` (R347).
4289#[derive(Debug, Clone, Serialize, Deserialize)]
4290#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4291pub struct DomainConfig {
4292    pub schema_version: u32,
4293    /// Stable identifier for this domain (file stem of the manifest).
4294    /// Example: `"yah-dev"` for the `yah.dev` zone.
4295    pub name: String,
4296    /// The fully-qualified domain this manifest routes for. Example:
4297    /// `"yah.dev"`, `"app.yah.dev"`.
4298    pub domain: String,
4299    /// Which front door serves this domain (R594-F12). **Required** — a
4300    /// default here would silently re-create the defect the field exists to
4301    /// close. Cross-checked against `routes` / `worker_bundle_path` by
4302    /// [`DomainConfig::validate_front_door`] at load time.
4303    pub front_door: FrontDoor,
4304    /// Public CDN bucket name. Static-mode route components publish into
4305    /// this bucket. Owned by the domain, *not* by any single service.
4306    pub cdn_bucket: String,
4307    /// Optional path (relative to workspace root) where the generated
4308    /// Worker bundle lands. `None` while the bundle generator (R347-F4)
4309    /// is still being wired up.
4310    #[serde(default, skip_serializing_if = "Option::is_none")]
4311    pub worker_bundle_path: Option<String>,
4312    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4313    pub routes: Vec<DomainRoute>,
4314}
4315
4316/// One entry in a [`DomainConfig`]'s route table.
4317///
4318/// The `mode` discriminator picks the variant's body via serde's
4319/// internally-tagged enum representation. Path patterns follow the
4320/// Worker convention: a trailing `*` matches everything underneath.
4321#[derive(Debug, Clone, Serialize, Deserialize)]
4322#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4323pub struct DomainRoute {
4324    /// URL pattern this route matches. Examples: `"/"`, `"/dashboard/*"`,
4325    /// `"/camp/ws"`.
4326    pub path: String,
4327    /// Response headers the front door sets on every response served under
4328    /// this route (R746). Empty by default.
4329    ///
4330    /// This is the manifest's answer to "who decides a path's response
4331    /// headers". Before it existed the answer was *nobody*: a `_headers` file
4332    /// is a Cloudflare Pages / Netlify convention, and neither of this
4333    /// repo's front doors reads one — a Worker returns what it fetched from
4334    /// R2, and R2 serves only the object's own httpMetadata. So a site could
4335    /// carry a `_headers` file declaring COOP/COEP and ship without them,
4336    /// which is exactly how it was found: `SharedArrayBuffer` is simply
4337    /// absent in a document served cross-origin-isolation-free, with no
4338    /// error anywhere to say why.
4339    ///
4340    /// Deliberately a free-form `name -> value` map rather than named fields
4341    /// for the isolation headers: the domain manifest has no business
4342    /// knowing which headers a route's payload happens to need. Ordering
4343    /// follows the route table's own rule — first matching route wins, no
4344    /// merging across routes (see the Worker's `applyRouteHeaders`).
4345    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
4346    pub headers: BTreeMap<String, String>,
4347    #[serde(flatten)]
4348    pub mode: RouteMode,
4349}
4350
4351/// Body of a [`DomainRoute`]. Three modes:
4352/// - **Static** — Worker reads from the domain's CDN bucket. Component
4353///   ref points at a `kind = "mesofact-static"` (or similar) service
4354///   component.
4355/// - **Backend** — Worker proxies to an HTTP origin owned by a backend
4356///   component (yubaba workload, gateway, etc.).
4357/// - **Redirect** — Worker emits a 30x to the target URL. Used to keep
4358///   old paths alive during domain refactors.
4359#[derive(Debug, Clone, Serialize, Deserialize)]
4360#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4361#[serde(tag = "mode", rename_all = "kebab-case")]
4362pub enum RouteMode {
4363    Static {
4364        /// Component reference `"<service>/<component-id>"`. Validated
4365        /// at [`CloudConfig::load`] time.
4366        component: String,
4367    },
4368    Backend {
4369        /// Component reference `"<service>/<component-id>"`. Validated
4370        /// at [`CloudConfig::load`] time.
4371        component: String,
4372        /// Origin URL the Worker `fetch()`es. Schema-permissive — could
4373        /// be `https://...`, `wss://...`, or a yah-internal mesh URL
4374        /// resolved by yubaba.
4375        origin: String,
4376    },
4377    Redirect {
4378        /// Absolute URL or path the Worker emits a 30x to.
4379        target: String,
4380        /// HTTP status code. Defaults to 308 (permanent + method-preserving)
4381        /// so deprecations don't silently turn POSTs into GETs.
4382        #[serde(default = "default_redirect_status")]
4383        status: u16,
4384    },
4385}
4386
4387fn default_redirect_status() -> u16 {
4388    308
4389}
4390
4391/// Normalize a component `mount` to a storage/URL key prefix: strip the
4392/// surrounding slashes. `"/app"`, `"app/"`, `"/app/"` → `"app"`; `"/"`, `""`
4393/// → `""` (the service root).
4394///
4395/// One producer on purpose — the publisher's key prefix, the route-path
4396/// cross-check and the front door's key lookup must all agree on what `/app`
4397/// means down to the byte, and three copies of `trim_matches('/')` is how they
4398/// stop agreeing.
4399pub fn normalize_mount(raw: &str) -> String {
4400    raw.trim_matches('/').to_string()
4401}
4402
4403/// The key prefix a domain route pattern serves under: `"/*"` → `""`,
4404/// `"/app/*"` and `"/app"` → `"app"`. The twin of [`normalize_mount`] on the
4405/// routing side.
4406pub fn route_path_prefix(path: &str) -> String {
4407    normalize_mount(path.strip_suffix('*').unwrap_or(path))
4408}
4409
4410/// The route-driven domain whose route table binds a component of `service`,
4411/// if any. Used by static publishers to pick up the per-route response
4412/// headers a service's paths were declared with.
4413///
4414/// Deterministic by `BTreeMap` key order when more than one domain routes the
4415/// same service (a legitimate shape: an apex and a staging host serving one
4416/// bundle). Returning the first is a real limitation, not a considered
4417/// choice — the day two such domains want *different* headers for one
4418/// component, this needs the domain identity threaded in rather than inferred.
4419pub fn domain_serving_service<'a>(
4420    domains: &'a BTreeMap<String, DomainConfig>,
4421    service: &str,
4422) -> Option<&'a DomainConfig> {
4423    domains
4424        .values()
4425        .find(|d| d.front_door.is_route_driven() && d.serves_service(service))
4426}
4427
4428/// The `ROUTE_HEADERS` Worker-binding value for `service`, read from the
4429/// workspace's domain manifests. `"[]"` when no route-driven domain routes the
4430/// service, or when the one that does declares no headers.
4431///
4432/// Reads `.yah/domains/` directly rather than taking a loaded [`CloudConfig`]:
4433/// the static reconcilers are handed a per-component [`ReconcileCtx`], not the
4434/// whole workspace config, and threading a config reference through all 22 of
4435/// its construction sites to reach one string would be a wide change for a
4436/// narrow read. Manifest parse errors propagate — a domain file that no longer
4437/// loads is a deploy-stopping fact, not a reason to ship a Worker with the
4438/// headers quietly missing.
4439pub fn route_headers_for_service(workspace_root: &Path, service: &str) -> Result<String> {
4440    let domains = load_domains(&crate::paths::domains_dir(workspace_root))?;
4441    Ok(domain_serving_service(&domains, service)
4442        .map(DomainConfig::route_headers_json)
4443        .unwrap_or_else(|| "[]".to_string()))
4444}
4445
4446impl DomainConfig {
4447    /// Parse a single `.yah/domains/<name>.toml`, rejecting a manifest whose
4448    /// declared front door contradicts its route table
4449    /// ([`Self::validate_front_door`]).
4450    pub fn load(path: &Path) -> Result<Self> {
4451        let src =
4452            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
4453        let dom: Self =
4454            toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
4455        dom.validate_front_door()
4456            .with_context(|| format!("validating {}", path.display()))?;
4457        dom.validate_route_headers()
4458            .with_context(|| format!("validating {}", path.display()))?;
4459        Ok(dom)
4460    }
4461
4462    /// R594-F12 — the front door must agree with the rest of the manifest.
4463    ///
4464    /// - `bucket-direct` is an R2 custom domain: a Worker route table would
4465    ///   never be consulted, so declaring one means the author expected
4466    ///   Worker behaviour (clean URLs, SPA fallback, branded errors) from a
4467    ///   surface that cannot provide it. Rejected rather than silently
4468    ///   ignored. Same for `worker_bundle_path` — nothing would deploy it.
4469    /// - `worker` / `passway` with an empty route table is a silent 404
4470    ///   machine: the front door exists, has nothing to serve, and every
4471    ///   request falls through to the catch-all.
4472    ///
4473    /// Called from [`Self::load`], so both [`CloudConfig::load`] and
4474    /// [`CloudConfig::load_from_config_dir`] enforce it.
4475    pub fn validate_front_door(&self) -> Result<()> {
4476        match self.front_door {
4477            FrontDoor::BucketDirect => {
4478                if let Some(route) = self.routes.first() {
4479                    anyhow::bail!(
4480                        "front_door = \"bucket-direct\" but routes[0].path = \"{}\" — \
4481                         an R2 custom domain never consults a route table, so this \
4482                         route would silently do nothing (no clean URLs, no SPA \
4483                         fallback, no branded errors). Set front_door = \"worker\" \
4484                         (or \"passway\") to keep the routes, or drop the [[routes]] \
4485                         to keep the bucket-direct binding.",
4486                        route.path
4487                    );
4488                }
4489                if let Some(path) = &self.worker_bundle_path {
4490                    anyhow::bail!(
4491                        "front_door = \"bucket-direct\" but worker_bundle_path = \
4492                         \"{path}\" — nothing deploys a Worker bundle for a domain \
4493                         bound straight to R2"
4494                    );
4495                }
4496            }
4497            FrontDoor::Worker | FrontDoor::Passway => {
4498                if self.routes.is_empty() {
4499                    anyhow::bail!(
4500                        "front_door = \"{}\" but [[routes]] is empty — a front door \
4501                         with no route table is a silent 404 machine. Declare at \
4502                         least one route, or set front_door = \"bucket-direct\" if \
4503                         this domain really is served straight from R2.",
4504                        self.front_door.as_str()
4505                    );
4506                }
4507            }
4508        }
4509        Ok(())
4510    }
4511
4512    /// The `ROUTE_HEADERS` Worker binding for this domain (R746) — the route
4513    /// table's `path` + `headers` pairs, in manifest order, with routes that
4514    /// declare no headers dropped. `"[]"` when nothing declares any.
4515    ///
4516    /// Order is load-bearing and must survive serialization: the front door
4517    /// applies the FIRST matching rule, so `/app/*` above `/*` is what gives
4518    /// the app its isolation headers and leaves the marketing site alone.
4519    /// That is why this is a `Vec` of pairs and not a map keyed by path.
4520    ///
4521    /// Infallible by design — [`Self::validate_route_headers`] has already run
4522    /// at [`Self::load`], so by the time a reconciler calls this the table is
4523    /// known to be one both front doors can apply.
4524    pub fn route_headers_json(&self) -> String {
4525        #[derive(Serialize)]
4526        struct Rule<'a> {
4527            path: &'a str,
4528            headers: &'a BTreeMap<String, String>,
4529        }
4530        let rules: Vec<Rule<'_>> = self
4531            .routes
4532            .iter()
4533            .filter(|r| !r.headers.is_empty())
4534            .map(|r| Rule {
4535                path: &r.path,
4536                headers: &r.headers,
4537            })
4538            .collect();
4539        serde_json::to_string(&rules).unwrap_or_else(|_| "[]".to_string())
4540    }
4541
4542    /// R749-T5 — everything [`Self::route_headers_json`] emits must be
4543    /// *applicable*, checked here where the table is PRODUCED.
4544    ///
4545    /// That method serializes a typed struct, so the table's JSON *shape* is
4546    /// sound by construction. Its contents are not: a route's `headers` map is
4547    /// a free-form `name -> value` read verbatim out of hand-written TOML, so
4548    /// `"Cross Origin Opener Policy"` (spaces instead of hyphens) or a value
4549    /// carrying a newline ships a structurally-valid table that neither front
4550    /// door can apply — and they fail *differently*, neither naming the
4551    /// manifest line responsible:
4552    ///
4553    /// - **passway** — `mesofact::route_headers::RouteHeaderTable::parse`
4554    ///   refuses the start, so the origin is simply down.
4555    /// - **worker** — `validateRouteHeaderTable` accepts it (it checks shape,
4556    ///   not header validity) and `applyRouteHeaders` then throws inside the
4557    ///   exported `fetch`, which is a 500 on every request, not the
4558    ///   serve-without-the-headers degradation that code intends.
4559    ///
4560    /// So the strictness lives at the producer: a table that cannot be applied
4561    /// fails `yah cloud apply` at manifest load, naming domain, route and
4562    /// header. This is deliberately *not* a second parser — the check is
4563    /// `HeaderName`/`HeaderValue`'s own, the very constructors the passway door
4564    /// runs on the far side, and route *matching* semantics stay defined once,
4565    /// at the doors. Only routes that contribute to the table are checked, so
4566    /// the invariant is exactly "`route_headers_json`'s output parses".
4567    ///
4568    /// Called from [`Self::load`], alongside [`Self::validate_front_door`].
4569    pub fn validate_route_headers(&self) -> Result<()> {
4570        use axum::http::{HeaderName, HeaderValue};
4571
4572        for route in self.routes.iter().filter(|r| !r.headers.is_empty()) {
4573            if route.path.is_empty() {
4574                anyhow::bail!(
4575                    "domain \"{}\" declares response headers on a route whose `path` is \
4576                     empty — a rule that matches nothing (or everything, depending on \
4577                     which front door reads it) is not a policy",
4578                    self.name
4579                );
4580            }
4581            for (name, value) in &route.headers {
4582                HeaderName::try_from(name.as_str()).with_context(|| {
4583                    format!(
4584                        "domain \"{}\" route \"{}\" declares {name:?}, which is not a valid \
4585                         HTTP header name — names are token characters only, so it is \
4586                         `Cross-Origin-Opener-Policy`, never `Cross Origin Opener Policy`",
4587                        self.name, route.path
4588                    )
4589                })?;
4590                HeaderValue::try_from(value.as_str()).with_context(|| {
4591                    format!(
4592                        "domain \"{}\" route \"{}\" declares {name} = {value:?}, which is not \
4593                         a valid HTTP header value — no newlines and no control characters",
4594                        self.name, route.path
4595                    )
4596                })?;
4597            }
4598        }
4599        Ok(())
4600    }
4601
4602    /// Whether this domain's route table binds any component of `service`.
4603    pub fn serves_service(&self, service: &str) -> bool {
4604        self.routes.iter().any(|r| {
4605            r.mode
4606                .component()
4607                .and_then(split_component_ref)
4608                .is_some_and(|(svc, _)| svc == service)
4609        })
4610    }
4611
4612    /// Persist to `.yah/domains/<name>.toml`, creating the domains
4613    /// directory if needed. Create-or-overwrite.
4614    pub fn save(&self, workspace_root: &Path) -> Result<()> {
4615        let dir = crate::paths::domains_dir(workspace_root);
4616        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
4617        let path = crate::paths::domain_toml(workspace_root, &self.name);
4618        let s = toml::to_string_pretty(self)
4619            .with_context(|| format!("serializing domain {}", self.name))?;
4620        std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
4621    }
4622
4623    /// Remove `.yah/domains/<name>.toml`. Returns `false` when the file
4624    /// was already absent.
4625    pub fn delete(workspace_root: &Path, name: &str) -> Result<bool> {
4626        let path = crate::paths::domain_toml(workspace_root, name);
4627        if !path.exists() {
4628            return Ok(false);
4629        }
4630        std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
4631        Ok(true)
4632    }
4633}
4634
4635impl RouteMode {
4636    /// Component reference for static/backend modes; `None` for redirects.
4637    pub fn component(&self) -> Option<&str> {
4638        match self {
4639            Self::Static { component } | Self::Backend { component, .. } => Some(component),
4640            Self::Redirect { .. } => None,
4641        }
4642    }
4643}
4644
4645// ─── Service-group vault (R706 / W294) ───────────────────────────────────────
4646
4647/// A camp's declaration of one cluster secret, from
4648/// `.yah/infra/secrets/<slug>.toml`.
4649///
4650/// This is the *authoring* side of the fleet's cluster-secret store: it names
4651/// where the value lives in the camp (a `fob` vault slot), what the fleet should
4652/// call it, and — the point of R706 — which workloads are allowed to mount it.
4653///
4654/// The declaration is not itself the enforcement point. `yah cloud secret put`
4655/// reads this file, seals the vault value under the cluster KEK, and ships the
4656/// ciphertext **with its access rule** into raft; yubaba's `ClusterResolver`
4657/// evaluates the rule on the node at mount time. Deleting this file does not
4658/// revoke anything — the record in raft is the live authority. That asymmetry is
4659/// deliberate: a rule that lived only in a git-tracked camp file would be
4660/// trivially bypassed by anyone who could reach the fleet without the camp.
4661///
4662/// ```toml
4663/// #:schema ../../schema/secret.toml.schema.json
4664/// schema_version = 1
4665/// name = "cheers/cloud-admin/verify-key"
4666/// vault_slot = "cheers-cloud-admin-verify-key"
4667/// description = "Ed25519 public key yah-cloud-admin verifies operator PASETOs with"
4668///
4669/// [access]
4670/// workloads = [{ workload = "yah-cloud-admin" }]
4671///
4672/// [target]
4673/// kind = "file"
4674/// path = "/run/secrets/cheers-verify.key"
4675/// mode = 0o400
4676/// ```
4677#[derive(Debug, Clone, Serialize, Deserialize)]
4678#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4679pub struct SecretConfig {
4680    pub schema_version: u32,
4681
4682    /// Logical cluster-secret key, as `SecretRef::Cluster { name }` spells it —
4683    /// e.g. `"tls/yah.dev/cert"`, `"cheers/cloud-admin/verify-key"`. May contain
4684    /// `/`; the file stem is a filesystem-safe slug and carries no meaning.
4685    pub name: String,
4686
4687    /// The `fob` vault slot in this camp holding the plaintext value. Read by
4688    /// `yah cloud secret put` at ship time and never recorded anywhere else — in
4689    /// particular the value is not in this file, so the declaration is safe to
4690    /// commit.
4691    pub vault_slot: String,
4692
4693    /// Human note for `yah cloud secret ls`. What this secret is and who minted
4694    /// it — the thing nobody remembers 6 months later.
4695    #[serde(default, skip_serializing_if = "Option::is_none")]
4696    pub description: Option<String>,
4697
4698    /// How the vault slot's text decodes into the bytes the consumer expects.
4699    ///
4700    /// `fob` slots hold strings, but plenty of real secrets are **binary** — an
4701    /// Ed25519 key is exactly 32 raw bytes, and `yah-cloud-admin` rejects a key
4702    /// file of any other length. Without this field the only way to ship such a
4703    /// key would be to hope its bytes happened to be valid UTF-8, which for a
4704    /// random key they are not.
4705    ///
4706    /// Defaults to [`SecretEncoding::Utf8`] — the right answer for tokens,
4707    /// passwords, and PEM, which is most secrets.
4708    #[serde(default)]
4709    pub encoding: SecretEncoding,
4710
4711    /// Who may mount it. Stamped onto the raft record verbatim.
4712    ///
4713    /// Defaults to [`SecretAccess::default`] — the deny-all empty allow-list. A
4714    /// declaration that forgets this field produces a secret nobody can mount,
4715    /// which is the correct direction to fail in.
4716    ///
4717    /// Three forms:
4718    ///
4719    /// ```toml
4720    /// access = "allow_any"                              # explicit escape hatch
4721    ///
4722    /// [access]                                          # named workloads
4723    /// workloads = [{ workload = "yah-cloud-admin" }]
4724    ///
4725    /// [access]                                          # signed recipes (R555-F5)
4726    /// recipes = [{ recipe = "rusty-v8-musl", key = "3d40…" }]
4727    /// ```
4728    ///
4729    /// Use the `recipes` form for a credential a **dispatched build** needs (the
4730    /// R2 write key, the cosign signing key). A remote QED run's workload name
4731    /// is a fresh `forge-<uuid>` every time, so `workloads` cannot name it and
4732    /// `allow_any` over-answers — see W235 §Seam (c) secret scoping. `key` is
4733    /// the hex Ed25519 public key from the recipe's `[admission]` block.
4734    #[serde(default)]
4735    pub access: SecretAccess,
4736
4737    /// Advisory: the mount shape a consuming workload should declare. Not
4738    /// enforced — yubaba honours whatever the `WorkloadSpec` asks for — but it
4739    /// lets `yah cloud secret put` print the exact `SecretMount` to paste, so
4740    /// the consumer and the declaration can't drift on path or mode.
4741    #[serde(default, skip_serializing_if = "Option::is_none")]
4742    pub target: Option<SecretTargetDecl>,
4743}
4744
4745/// How a [`SecretConfig`]'s vault text becomes the bytes delivered to the
4746/// container (R706 / W294).
4747#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
4748#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4749#[serde(rename_all = "kebab-case")]
4750pub enum SecretEncoding {
4751    /// Ship the vault string's UTF-8 bytes verbatim. Tokens, passwords, PEM.
4752    #[default]
4753    Utf8,
4754    /// The vault string is hex; ship the decoded bytes. Use for binary key
4755    /// material — e.g. a raw Ed25519 key, which must land as exactly 32 bytes.
4756    Hex,
4757}
4758
4759/// Advisory mount shape on a [`SecretConfig`]. Mirrors
4760/// `workload_spec::SecretTarget` in a TOML-friendly, externally-tagged-free
4761/// shape (a `kind` discriminator reads better in a hand-written manifest than
4762/// serde's default enum encoding).
4763#[derive(Debug, Clone, Serialize, Deserialize)]
4764#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4765#[serde(tag = "kind", rename_all = "kebab-case")]
4766pub enum SecretTargetDecl {
4767    /// Mounted as a tmpfs-backed file inside the container.
4768    File {
4769        /// Absolute path inside the container.
4770        path: String,
4771        /// Unix permission bits. Defaults to `0o400` (owner-read-only).
4772        #[serde(default = "default_secret_mode")]
4773        mode: u32,
4774    },
4775    /// Injected as an environment variable. Prefer `file` — env vars leak
4776    /// through subprocess environments and log dumps.
4777    EnvVar { name: String },
4778}
4779
4780fn default_secret_mode() -> u32 {
4781    0o400
4782}
4783
4784impl SecretTargetDecl {
4785    /// The `workload_spec` target this declaration describes.
4786    pub fn to_target(&self) -> workload_spec::SecretTarget {
4787        match self {
4788            Self::File { path, mode } => workload_spec::SecretTarget::File {
4789                path: path.into(),
4790                mode: *mode,
4791            },
4792            Self::EnvVar { name } => workload_spec::SecretTarget::EnvVar { name: name.clone() },
4793        }
4794    }
4795}
4796
4797impl SecretConfig {
4798    /// Parse a single `.yah/infra/secrets/<slug>.toml`.
4799    pub fn load(path: &Path) -> Result<Self> {
4800        let src =
4801            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
4802        let cfg: Self =
4803            toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
4804        cfg.validate()
4805            .with_context(|| format!("validating {}", path.display()))?;
4806        Ok(cfg)
4807    }
4808
4809    /// Load every declaration in `dir`, keyed by logical secret name. A missing
4810    /// directory is an empty map (a camp with no cluster secrets is normal).
4811    ///
4812    /// Two files declaring the same `name` is a hard error, not a last-writer-
4813    /// wins merge: they would race to define the access rule for one record, and
4814    /// whichever lost would look correct in git while being inert on the fleet.
4815    pub fn load_dir(dir: &Path) -> Result<BTreeMap<String, Self>> {
4816        let mut out: BTreeMap<String, Self> = BTreeMap::new();
4817        if !dir.exists() {
4818            return Ok(out);
4819        }
4820        for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
4821            let path = entry?.path();
4822            if path.extension().is_none_or(|e| e != "toml") {
4823                continue;
4824            }
4825            let cfg = Self::load(&path)?;
4826            if let Some(prev) = out.insert(cfg.name.clone(), cfg) {
4827                anyhow::bail!(
4828                    "two secret declarations both claim name {:?} (one of them is {}); \
4829                     a cluster secret must have exactly one declaration so its access \
4830                     rule has one author",
4831                    prev.name,
4832                    path.display()
4833                );
4834            }
4835        }
4836        Ok(out)
4837    }
4838
4839    /// Reject declarations that would produce an unusable or dangerous record.
4840    pub fn validate(&self) -> Result<()> {
4841        if self.name.trim().is_empty() {
4842            anyhow::bail!("`name` must not be empty");
4843        }
4844        if self.vault_slot.trim().is_empty() {
4845            anyhow::bail!(
4846                "`vault_slot` must not be empty — it names the fob slot holding the value"
4847            );
4848        }
4849        // A deny-all rule is a *valid* record (it is the fail-closed default the
4850        // resolver relies on) but it is never a useful thing to deliberately
4851        // ship, so catching it here saves an operator the round-trip of
4852        // deploying a workload that mysteriously can't see its own secret.
4853        if let SecretAccess::Workloads(entries) = &self.access {
4854            if entries.is_empty() {
4855                anyhow::bail!(
4856                    "`[access]` admits nobody: list the workloads allowed to mount {:?} \
4857                     (e.g. `workloads = [{{ workload = \"my-service\" }}]`), or set \
4858                     `access = \"allow_any\"` to store it unrestricted",
4859                    self.name
4860                );
4861            }
4862            if let Some(bad) = entries.iter().find(|e| e.workload.trim().is_empty()) {
4863                anyhow::bail!("`[access]` entry has an empty `workload` name: {bad:?}");
4864            }
4865        }
4866        Ok(())
4867    }
4868}
4869
4870#[cfg(test)]
4871mod secret_config_tests {
4872    use super::*;
4873
4874    fn parse(body: &str) -> Result<SecretConfig> {
4875        let cfg: SecretConfig = toml::from_str(body)?;
4876        cfg.validate()?;
4877        Ok(cfg)
4878    }
4879
4880    #[test]
4881    fn minimal_declaration_parses_with_narrow_defaults() {
4882        let cfg = parse(
4883            r#"
4884schema_version = 1
4885name = "svc/token"
4886vault_slot = "svc-token"
4887[access]
4888workloads = [{ workload = "svc" }]
4889"#,
4890        )
4891        .unwrap();
4892
4893        assert_eq!(cfg.encoding, SecretEncoding::Utf8, "text is the default");
4894        assert!(cfg.target.is_none());
4895        // The omitted tenant/namespace must narrow to the singletons, not widen
4896        // to a wildcard.
4897        assert!(cfg
4898            .access
4899            .admits(&workload_spec::secrets::SecretConsumer::workload("svc")));
4900        assert!(!cfg
4901            .access
4902            .admits(&workload_spec::secrets::SecretConsumer::workload("other")));
4903    }
4904
4905    #[test]
4906    fn allow_any_is_spelled_as_a_bare_string() {
4907        // The operator-facing spelling, pinned: `access = "allow_any"`.
4908        let cfg = parse(
4909            r#"
4910schema_version = 1
4911name = "public/thing"
4912vault_slot = "slot"
4913access = "allow_any"
4914"#,
4915        )
4916        .unwrap();
4917        assert_eq!(cfg.access, SecretAccess::AllowAny);
4918    }
4919
4920    #[test]
4921    fn a_declaration_with_no_access_block_is_rejected() {
4922        // Omitting `[access]` defaults to deny-all, which is the correct
4923        // *runtime* default but never a correct authoring intent — so it must
4924        // not silently produce a secret nobody can mount.
4925        let err = parse(
4926            r#"
4927schema_version = 1
4928name = "svc/token"
4929vault_slot = "svc-token"
4930"#,
4931        )
4932        .unwrap_err()
4933        .to_string();
4934        assert!(err.contains("admits nobody"), "got {err}");
4935    }
4936
4937    #[test]
4938    fn empty_name_or_slot_is_rejected() {
4939        assert!(parse(
4940            r#"
4941schema_version = 1
4942name = ""
4943vault_slot = "slot"
4944access = "allow_any"
4945"#
4946        )
4947        .is_err());
4948        assert!(parse(
4949            r#"
4950schema_version = 1
4951name = "x"
4952vault_slot = "  "
4953access = "allow_any"
4954"#
4955        )
4956        .is_err());
4957    }
4958
4959    #[test]
4960    fn target_declaration_maps_onto_the_workload_spec_type() {
4961        let cfg = parse(
4962            r#"
4963schema_version = 1
4964name = "svc/token"
4965vault_slot = "slot"
4966access = "allow_any"
4967[target]
4968kind = "file"
4969path = "/run/secrets/t"
4970"#,
4971        )
4972        .unwrap();
4973        match cfg.target.unwrap().to_target() {
4974            workload_spec::SecretTarget::File { path, mode } => {
4975                assert_eq!(path, std::path::PathBuf::from("/run/secrets/t"));
4976                assert_eq!(mode, 0o400, "owner-read-only by default");
4977            }
4978            other => panic!("expected File, got {other:?}"),
4979        }
4980    }
4981
4982    #[test]
4983    fn load_dir_is_empty_for_a_camp_with_no_secrets() {
4984        let tmp = tempfile::TempDir::new().unwrap();
4985        assert!(SecretConfig::load_dir(&tmp.path().join("nope"))
4986            .unwrap()
4987            .is_empty());
4988    }
4989}
4990
4991/// Split a `"<service>/<component-id>"` ref. Returns `None` if the ref
4992/// isn't shaped like `service/component`.
4993fn split_component_ref(s: &str) -> Option<(&str, &str)> {
4994    let (svc, comp) = s.split_once('/')?;
4995    if svc.is_empty() || comp.is_empty() || comp.contains('/') {
4996        return None;
4997    }
4998    Some((svc, comp))
4999}
5000
5001#[cfg(test)]
5002mod tests {
5003    use super::*;
5004    use std::path::PathBuf;
5005
5006    fn make_machine(name: &str, mesh_tags: Vec<&str>) -> MachineConfig {
5007        MachineConfig {
5008            name: name.into(),
5009            provider: "hetzner".into(),
5010            location: Some("hil".into()),
5011            server_type: Some("ccx13".into()),
5012            hosts_mirrors: vec![],
5013            mesh_tags: mesh_tags.into_iter().map(String::from).collect(),
5014            region: None,
5015            zone: None,
5016            arch: None,
5017            bucket: None,
5018            vendor: None,
5019            nickname: None,
5020            legacy_hostkey_fingerprint: None,
5021            registration: Default::default(),
5022            ssh_keys: vec![],
5023            cloudflared: None,
5024            hosts_operator_bridge: false,
5025            connect: None,
5026            allocatable: None,
5027            taints: vec![],
5028            sovereign_group: None,
5029            sovereign_role: None,
5030            ingress_floating_ip: None,
5031        }
5032    }
5033
5034    /// Like [`make_machine`] but with explicit topology axes for F16 tests.
5035    fn make_machine_topo(
5036        name: &str,
5037        provider: &str,
5038        region: &str,
5039        mesh_tags: Vec<&str>,
5040    ) -> MachineConfig {
5041        MachineConfig {
5042            provider: provider.into(),
5043            region: Some(region.into()),
5044            zone: Some(region.into()),
5045            ..make_machine(name, mesh_tags)
5046        }
5047    }
5048
5049    fn make_empty_cfg(machines: Vec<MachineConfig>) -> CloudConfig {
5050        CloudConfig {
5051            workspace_root: PathBuf::new(),
5052            machines,
5053            providers: vec![],
5054            machine_origins: BTreeMap::new(),
5055            provider_origins: BTreeMap::new(),
5056            services: BTreeMap::new(),
5057            domains: BTreeMap::new(),
5058            legacy_mirrors: vec![],
5059            workloads: vec![],
5060            topology: TopologyConfig::default(),
5061            legacy_services: vec![],
5062        }
5063    }
5064
5065    #[test]
5066    fn required_spec_parses_from_provider_fields() {
5067        let toml_src = r#"
5068use = "hetzner-primary"
5069[required]
5070mesh_tags = ["tag:cloud-runner"]
5071"#;
5072        let slot: MirrorProviderSlot = toml::from_str(toml_src).unwrap();
5073        let req = slot.required().expect("required block present");
5074        assert_eq!(req.mesh_tags, vec!["tag:cloud-runner"]);
5075    }
5076
5077    #[test]
5078    fn required_spec_absent_when_field_missing() {
5079        let slot: MirrorProviderSlot = toml::from_str(r#"use = "hetzner-primary""#).unwrap();
5080        assert!(slot.required().is_none());
5081    }
5082
5083    #[test]
5084    fn db_catalog_parses_all_env_blocks() {
5085        // W241 / R571-F8: a service.toml [db] table with dev/pond/cloud.
5086        let toml_src = r#"
5087schema_version = 1
5088name = "scrabcake"
5089domain = "scrabcake.net.yah.dev"
5090
5091[[db.dev]]
5092name = "main"
5093path = "data/dev.sqlite"
5094
5095[[db.pond]]
5096name = "main"
5097port = 5433
5098
5099[[db.pond]]
5100name = "pg"
5101port = 5432
5102kind = "postgres"
5103
5104[[db.cloud]]
5105name = "main"
5106url = "libsql://scrabcake.turso.io"
5107auth_token_env = "SCRABCAKE_TURSO_TOKEN"
5108"#;
5109        let svc: ServiceConfig = toml::from_str(toml_src).unwrap();
5110        assert_eq!(svc.db.dev.len(), 1);
5111        assert_eq!(svc.db.dev[0].path, "data/dev.sqlite");
5112        assert_eq!(svc.db.pond.len(), 2);
5113        assert_eq!(svc.db.pond[0].port, Some(5433));
5114        assert_eq!(svc.db.pond[0].kind, PondDbKind::Turso); // default
5115        assert_eq!(svc.db.pond[1].kind, PondDbKind::Postgres);
5116        assert_eq!(
5117            svc.db.cloud[0].auth_token_env.as_deref(),
5118            Some("SCRABCAKE_TURSO_TOKEN")
5119        );
5120    }
5121
5122    #[test]
5123    fn service_without_db_table_has_empty_catalog() {
5124        let svc: ServiceConfig =
5125            toml::from_str("schema_version = 1\nname = \"s\"\ndomain = \"s.dev\"\n").unwrap();
5126        assert!(svc.db.is_empty());
5127        // And an empty [db] must not appear when re-serialized.
5128        let out = toml::to_string(&svc).unwrap();
5129        assert!(
5130            !out.contains("[db"),
5131            "empty db table should be skipped: {out}"
5132        );
5133    }
5134
5135    #[test]
5136    fn camp_shared_cloud_toml_parses() {
5137        let src = r#"
5138[[cloud]]
5139name = "analytics"
5140url = "postgres://shared/analytics"
5141"#;
5142        let shared: CampCloudDbs = toml::from_str(src).unwrap();
5143        assert_eq!(shared.cloud.len(), 1);
5144        assert_eq!(shared.cloud[0].name, "analytics");
5145    }
5146
5147    #[test]
5148    fn resolve_machine_by_mesh_tags_superset_match() {
5149        let cfg = make_empty_cfg(vec![
5150            make_machine("yah-bnt-1", vec!["tag:primary-yah", "tag:tier-scratch"]),
5151            make_machine("us-west-001", vec!["tag:primary-yah", "tag:cloud-runner"]),
5152        ]);
5153        let picked = cfg
5154            .resolve_machine_by_mesh_tags(&["tag:cloud-runner".into()])
5155            .map(|m| m.name.as_str());
5156        assert_eq!(picked, Some("us-west-001"));
5157    }
5158
5159    #[test]
5160    fn resolve_machine_by_mesh_tags_returns_none_when_no_match() {
5161        let cfg = make_empty_cfg(vec![make_machine("yah-bnt-1", vec!["tag:primary-yah"])]);
5162        assert!(cfg
5163            .resolve_machine_by_mesh_tags(&["tag:cloud-runner".into()])
5164            .is_none());
5165    }
5166
5167    // ─── R590-F1 mesh-tag node-selector admission ───────────────────────────
5168
5169    /// Build a forge WorkloadSpec carrying the R594 node-selector annotation.
5170    /// `selector` is the comma-joined mesh-tag set; `None` omits the annotation
5171    /// entirely (pre-R594 "no constraint").
5172    fn ws_with_selector(selector: Option<&str>) -> WorkloadSpec {
5173        use workload_spec::{ImageRef, TierTag};
5174        let mut ws = WorkloadSpec::for_forge(
5175            "R590-F1-test",
5176            ImageRef {
5177                registry: "docker.io".into(),
5178                repository: "library/busybox".into(),
5179                tag: "latest".into(),
5180                digest: workload_spec::testing::test_digest(),
5181            },
5182            TierTag("infra".into()),
5183            vec![],
5184        );
5185        if let Some(sel) = selector {
5186            ws.annotations.insert(
5187                velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION.into(),
5188                sel.into(),
5189            );
5190        }
5191        ws
5192    }
5193
5194    /// The build-worker fleet shape: one x86 node (us-west-002) and one arm
5195    /// node (a Pi5), both carrying `tag:build-worker`.
5196    fn build_worker_fleet() -> CloudConfig {
5197        make_empty_cfg(vec![
5198            make_machine("us-west-002", vec!["tag:build-worker", "arch:x86"]),
5199            make_machine("pi5-001", vec!["tag:build-worker", "arch:arm"]),
5200        ])
5201    }
5202
5203    #[test]
5204    fn admit_workload_routes_amd64_to_x86_worker() {
5205        let cfg = build_worker_fleet();
5206        let ws = ws_with_selector(Some("tag:build-worker,arch:x86"));
5207        let picked = cfg.admit_workload(&ws).unwrap();
5208        assert_eq!(picked.name, "us-west-002");
5209    }
5210
5211    #[test]
5212    fn admit_workload_routes_arm64_to_pi5_worker() {
5213        let cfg = build_worker_fleet();
5214        let ws = ws_with_selector(Some("tag:build-worker,arch:arm"));
5215        let picked = cfg.admit_workload(&ws).unwrap();
5216        assert_eq!(picked.name, "pi5-001");
5217    }
5218
5219    /// A forge run must be admissible on a build-worker smaller than its own
5220    /// cgroup ceiling.
5221    ///
5222    /// The fleet's arm build-workers are 8 GiB Pi-5s and `for_forge` sets a
5223    /// 32 GiB ceiling, so while admission read `resources.memory_mb` as the
5224    /// capacity floor this returned "no candidates" and *every* offloaded qed
5225    /// step to those nodes failed at dispatch — measured on desktop-release run
5226    /// b04cef47, where the aarch64-linux row died in 1.6s. The other
5227    /// build-workers (16 GiB us-west-003, and the arm Pi-5s) were excluded the
5228    /// same way, leaving one 47 GiB node as the fleet's only legal target for
5229    /// remote CI.
5230    #[test]
5231    fn admit_workload_places_a_forge_run_on_a_worker_smaller_than_its_ceiling() {
5232        let mut pi = make_machine("pi5-001", vec!["tag:build-worker", "arch:arm"]);
5233        pi.allocatable = Some(NodeAllocatable {
5234            memory_mb: 8192,
5235            cpu_millis: 4000,
5236        });
5237        let cfg = make_empty_cfg(vec![pi]);
5238
5239        let ws = ws_with_selector(Some("tag:build-worker,arch:arm"));
5240        assert!(
5241            ws.resources.memory_mb > 8192,
5242            "precondition: the ceiling must exceed the node, or this proves nothing"
5243        );
5244
5245        let picked = cfg
5246            .admit_workload(&ws)
5247            .expect("an 8 GiB build-worker must admit a forge run");
5248        assert_eq!(picked.name, "pi5-001");
5249    }
5250
5251    /// The floor is still enforced — the fix separates two numbers, it does not
5252    /// disable the R572-F5 capacity check.
5253    #[test]
5254    fn admit_workload_still_rejects_a_node_below_the_declared_request() {
5255        let mut tiny = make_machine("tiny-001", vec!["tag:build-worker", "arch:arm"]);
5256        tiny.allocatable = Some(NodeAllocatable {
5257            memory_mb: 512,
5258            cpu_millis: 4000,
5259        });
5260        let cfg = make_empty_cfg(vec![tiny]);
5261
5262        let ws = ws_with_selector(Some("tag:build-worker,arch:arm"));
5263        assert!(
5264            cfg.admit_workload(&ws).is_err(),
5265            "a 512 MiB node cannot satisfy a 2 GiB forge request"
5266        );
5267    }
5268
5269    // ─── R833-F8 imperative node-selector admission ─────────────────────────
5270
5271    /// Build a forge WorkloadSpec carrying the R833-F8 imperative node
5272    /// selector — the operator's `--where=node:<machine>`.
5273    fn ws_pinned_to(node: &str) -> WorkloadSpec {
5274        let mut ws = ws_with_selector(None);
5275        ws.annotations.insert(
5276            velveteen_exec::remote::NODE_SELECTOR_NODE_ANNOTATION.into(),
5277            node.into(),
5278        );
5279        ws
5280    }
5281
5282    /// The ticket's acceptance shape: a named node wins over the
5283    /// declaration-order tie-break that would otherwise decide placement.
5284    /// `us-west-002` is declared first and carries every tag, so an inferred
5285    /// placement lands there; the pin must reach `pi5-001` regardless.
5286    #[test]
5287    fn admit_workload_honours_an_explicitly_named_node() {
5288        let cfg = build_worker_fleet();
5289        assert_eq!(
5290            cfg.admit_workload(&ws_with_selector(Some("tag:build-worker")))
5291                .unwrap()
5292                .name,
5293            "us-west-002",
5294            "precondition: inference elects the first-declared node",
5295        );
5296        assert_eq!(
5297            cfg.admit_workload(&ws_pinned_to("pi5-001")).unwrap().name,
5298            "pi5-001",
5299        );
5300    }
5301
5302    /// A pin at a machine that is not declared fails loud, naming the
5303    /// constraint and the pool — the operator mistyped a node, and silently
5304    /// running the build somewhere else is the one outcome that must not
5305    /// happen.
5306    #[test]
5307    fn admit_workload_refuses_a_node_that_is_not_declared() {
5308        let cfg = build_worker_fleet();
5309        let err = cfg
5310            .admit_workload(&ws_pinned_to("us-west-404"))
5311            .unwrap_err()
5312            .to_string();
5313        assert!(err.contains("required.nodes=[us-west-404]"), "{err}");
5314        assert!(err.contains("us-west-002"), "the pool must be named: {err}");
5315    }
5316
5317    /// The pin narrows the candidate set; it does not suspend the other axes.
5318    /// A named node that cannot fit the workload still refuses, rather than
5319    /// being handed work it has no room for.
5320    #[test]
5321    fn a_pinned_node_is_still_checked_against_capacity() {
5322        let mut tiny = make_machine("tiny-001", vec!["tag:build-worker", "arch:arm"]);
5323        tiny.allocatable = Some(NodeAllocatable {
5324            memory_mb: 512,
5325            cpu_millis: 4000,
5326        });
5327        let cfg = make_empty_cfg(vec![tiny]);
5328        assert!(cfg.admit_workload(&ws_pinned_to("tiny-001")).is_err());
5329    }
5330
5331    /// Inference is untouched: with no node annotation the `nodes` axis is
5332    /// empty, which is "no constraint" — every pre-R833-F8 workload is admitted
5333    /// exactly as before.
5334    #[test]
5335    fn an_unpinned_workload_carries_no_node_constraint() {
5336        assert!(node_selector_node(&ws_with_selector(Some("arch:x86"))).is_none());
5337        assert_eq!(
5338            node_selector_node(&ws_pinned_to("us-west-003")).as_deref(),
5339            Some("us-west-003")
5340        );
5341        assert!(RequiredSpec::default().is_unconstrained());
5342        assert!(!RequiredSpec {
5343            nodes: vec!["us-west-003".into()],
5344            ..Default::default()
5345        }
5346        .is_unconstrained());
5347    }
5348
5349    #[test]
5350    fn admit_workload_rejects_node_missing_required_tag() {
5351        // Only an arm worker exists; an x86 build must NOT land on it.
5352        let cfg = make_empty_cfg(vec![make_machine(
5353            "pi5-001",
5354            vec!["tag:build-worker", "arch:arm"],
5355        )]);
5356        let ws = ws_with_selector(Some("tag:build-worker,arch:x86"));
5357        assert!(cfg.admit_workload(&ws).is_err());
5358    }
5359
5360    /// R555-S1 regression: with TWO nodes carrying the same tag set, which one
5361    /// admits must be decided by *declaration order* (file name), which is the
5362    /// contract `admit_workload` documents — not by `read_dir` order, which is
5363    /// filesystem-dependent and can change when an unrelated file appears in
5364    /// the directory. Written creation-order-reversed so a filesystem that
5365    /// yields creation order (rather than sorted order) trips it without the
5366    /// sort in `load_dir`.
5367    ///
5368    /// Live consequence this guards: `.yah/infra/machines/` carries both
5369    /// us-west-002 and us-west-003 on `[tag:build-worker, arch:x86, os:linux]`,
5370    /// so an x86 QED offload has two equal candidates. Unstable selection means
5371    /// a retried build cannot be relied on to land back on the node whose
5372    /// working state it left behind.
5373    #[test]
5374    fn equally_matching_machines_admit_in_file_name_order() {
5375        let tmp = tempfile::TempDir::new().unwrap();
5376        let machines = tmp.path().join(".yah").join("infra").join("machines");
5377        std::fs::create_dir_all(&machines).unwrap();
5378        let toml_for = |name: &str| {
5379            format!(
5380                r#"name = "{name}"
5381provider = "static"
5382mesh_tags = ["tag:build-worker", "arch:x86"]
5383"#
5384            )
5385        };
5386        // Reverse-of-sorted creation order on purpose.
5387        std::fs::write(machines.join("b-second.toml"), toml_for("b-second")).unwrap();
5388        std::fs::write(machines.join("a-first.toml"), toml_for("a-first")).unwrap();
5389
5390        let cfg = CloudConfig::load(tmp.path()).unwrap();
5391        assert_eq!(
5392            cfg.machines.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(),
5393            vec!["a-first", "b-second"],
5394            "machines must load in file-name order, not read_dir order"
5395        );
5396
5397        let ws = ws_with_selector(Some("tag:build-worker,arch:x86"));
5398        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "a-first");
5399
5400        // R605-T14: the same two nodes, seen as the pool they are. The head is
5401        // what `admit_workload` returns, and the tail is what the dispatcher
5402        // fails over to when the head does not answer — so these two views must
5403        // come from one predicate, not two.
5404        assert_eq!(
5405            cfg.admit_workload_candidates(&ws)
5406                .unwrap()
5407                .iter()
5408                .map(|m| m.name.as_str())
5409                .collect::<Vec<_>>(),
5410            vec!["a-first", "b-second"],
5411            "the pool must be every admissible node, in the same declaration order"
5412        );
5413    }
5414
5415    /// A pool of one is still a pool, and a pool of none is an `Err` that reads
5416    /// exactly like `admit_workload`'s — "nothing admits this" is one failure
5417    /// with one wording, not two.
5418    #[test]
5419    fn admit_workload_candidates_matches_admit_workload_on_the_edges() {
5420        let cfg = make_empty_cfg(vec![
5421            make_machine("x86-box", vec!["tag:build-worker", "arch:x86"]),
5422            make_machine("arm-box", vec!["tag:build-worker", "arch:arm"]),
5423        ]);
5424
5425        let one = ws_with_selector(Some("arch:arm"));
5426        assert_eq!(
5427            cfg.admit_workload_candidates(&one)
5428                .unwrap()
5429                .iter()
5430                .map(|m| m.name.as_str())
5431                .collect::<Vec<_>>(),
5432            vec!["arm-box"],
5433            "only one node carries arch:arm, so the pool is that one node"
5434        );
5435
5436        let none = ws_with_selector(Some("arch:riscv"));
5437        let pool_err = cfg.admit_workload_candidates(&none).unwrap_err().to_string();
5438        let single_err = cfg.admit_workload(&none).unwrap_err().to_string();
5439        assert_eq!(
5440            pool_err, single_err,
5441            "an empty pool must be refused in the same words as an unadmitted workload"
5442        );
5443    }
5444
5445    /// R844-B7 — the wrong-root half of the distinction. A directory with no
5446    /// `.yah/` at all used to load as a valid config with zero machines, so a
5447    /// caller pointed at the wrong directory got a green result that measured
5448    /// nothing. Asserting `load` merely *succeeds* is what let that through;
5449    /// the shape that catches it is a non-zero machine count, or — here — an
5450    /// `Err` naming the path that was looked for.
5451    #[test]
5452    fn loading_a_directory_that_is_not_a_yah_workspace_is_an_error() {
5453        let tmp = tempfile::TempDir::new().unwrap();
5454        // A plausible-looking package root: real files, real subdirectories,
5455        // no `.yah/`. This is exactly what `load_cloud(".")` reads when a test
5456        // runs under `cargo test` from a member crate.
5457        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
5458        std::fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
5459
5460        let err = CloudConfig::load(tmp.path()).expect_err(
5461            "a directory with no .yah/ is the WRONG DIRECTORY, not a fleet with no machines",
5462        );
5463        let msg = format!("{err:#}");
5464        assert!(
5465            msg.contains("not a yah workspace"),
5466            "error must say the root is not a workspace, got: {msg}"
5467        );
5468        assert!(
5469            msg.contains(&tmp.path().join(".yah").display().to_string()),
5470            "error must name the path it looked for so an operator sees the \
5471             wrong-root immediately, got: {msg}"
5472        );
5473    }
5474
5475    /// R844-B7 — the other half, and the reason the check is drawn at `.yah/`
5476    /// rather than at the machine list: a camp that declares no machines is a
5477    /// real workspace and must keep loading. Blanket-erroring on an empty
5478    /// fleet would conflate `unknown` with `answered with none`, which is the
5479    /// exact confusion the check exists to remove.
5480    #[test]
5481    fn a_workspace_with_no_machines_declared_still_loads() {
5482        let tmp = tempfile::TempDir::new().unwrap();
5483        std::fs::create_dir_all(tmp.path().join(".yah")).unwrap();
5484
5485        let cfg = CloudConfig::load(tmp.path())
5486            .expect("a `.yah/` with no infra/machines/ is an empty fleet, not a wrong root");
5487        assert!(cfg.machines.is_empty(), "nothing was declared");
5488        assert!(cfg.services.is_empty());
5489        assert!(cfg.providers.is_empty());
5490
5491        // And an existing-but-empty machines dir is the same answer, not a
5492        // second special case.
5493        std::fs::create_dir_all(crate::paths::machines_dir(tmp.path())).unwrap();
5494        let cfg = CloudConfig::load(tmp.path()).expect("an empty machines/ dir still loads");
5495        assert!(cfg.machines.is_empty());
5496    }
5497
5498    #[test]
5499    fn admit_workload_empty_selector_is_unconstrained() {
5500        // Absent annotation ⇒ no mesh-tag constraint ⇒ first declared machine
5501        // (pre-R594 behavior preserved).
5502        let cfg = build_worker_fleet();
5503        let ws = ws_with_selector(None);
5504        let picked = cfg.admit_workload(&ws).unwrap();
5505        assert_eq!(picked.name, "us-west-002");
5506    }
5507
5508    #[test]
5509    fn node_selector_mesh_tags_trims_and_drops_empties() {
5510        let ws = ws_with_selector(Some(" tag:build-worker , arch:x86 ,"));
5511        assert_eq!(
5512            node_selector_mesh_tags(&ws),
5513            vec!["tag:build-worker".to_string(), "arch:x86".to_string()]
5514        );
5515        assert!(node_selector_mesh_tags(&ws_with_selector(None)).is_empty());
5516    }
5517
5518    // ─── F16 topology-aware resolver ────────────────────────────────────────
5519
5520    fn two_region_fleet() -> CloudConfig {
5521        make_empty_cfg(vec![
5522            make_machine_topo(
5523                "us-west-001",
5524                "hetzner",
5525                "us-west",
5526                vec!["tag:cloud-runner"],
5527            ),
5528            make_machine_topo(
5529                "eu-west-001",
5530                "hetzner",
5531                "eu-west",
5532                vec!["tag:cloud-runner"],
5533            ),
5534        ])
5535    }
5536
5537    #[test]
5538    fn resolve_machine_matches_on_region_plus_mesh_tags() {
5539        let cfg = two_region_fleet();
5540        let req = RequiredSpec {
5541            regions: vec!["us-west".into()],
5542            mesh_tags: vec!["tag:cloud-runner".into()],
5543            ..Default::default()
5544        };
5545        let picked = cfg.resolve_machine(&req).unwrap();
5546        assert_eq!(picked.name, "us-west-001");
5547    }
5548
5549    #[test]
5550    fn resolve_machine_region_disambiguates_same_tag() {
5551        // Both boxes carry tag:cloud-runner; the region axis selects eu-west.
5552        let cfg = two_region_fleet();
5553        let req = RequiredSpec {
5554            regions: vec!["eu-west".into()],
5555            mesh_tags: vec!["tag:cloud-runner".into()],
5556            ..Default::default()
5557        };
5558        assert_eq!(cfg.resolve_machine(&req).unwrap().name, "eu-west-001");
5559    }
5560
5561    #[test]
5562    fn resolve_machine_fails_loud_with_constraint_summary() {
5563        let cfg = two_region_fleet();
5564        let req = RequiredSpec {
5565            regions: vec!["us-central".into()],
5566            mesh_tags: vec!["tag:cloud-runner".into()],
5567            ..Default::default()
5568        };
5569        let err = cfg.resolve_machine(&req).unwrap_err().to_string();
5570        assert!(err.contains("required.regions=[us-central]"), "got: {err}");
5571        assert!(
5572            err.contains("required.mesh_tags=[tag:cloud-runner]"),
5573            "got: {err}"
5574        );
5575        // Names the candidates it rejected.
5576        assert!(err.contains("us-west-001"), "got: {err}");
5577    }
5578
5579    #[test]
5580    fn resolve_machine_provider_axis_filters() {
5581        let cfg = make_empty_cfg(vec![
5582            make_machine_topo("aws-west-1", "aws", "us-west", vec!["tag:cloud-runner"]),
5583            make_machine_topo("hz-west-1", "hetzner", "us-west", vec!["tag:cloud-runner"]),
5584        ]);
5585        let req = RequiredSpec {
5586            regions: vec!["us-west".into()],
5587            providers: vec!["hetzner".into()],
5588            ..Default::default()
5589        };
5590        assert_eq!(cfg.resolve_machine(&req).unwrap().name, "hz-west-1");
5591    }
5592
5593    #[test]
5594    fn unconstrained_required_spec_matches_first_machine() {
5595        let cfg = two_region_fleet();
5596        assert!(RequiredSpec::default().is_unconstrained());
5597        assert_eq!(
5598            cfg.resolve_machine(&RequiredSpec::default()).unwrap().name,
5599            "us-west-001"
5600        );
5601    }
5602
5603    #[test]
5604    fn required_spec_parses_topology_axes_from_toml() {
5605        let toml_src = r#"
5606use = "hetzner-primary"
5607[required]
5608regions = ["us-west"]
5609mesh_tags = ["tag:cloud-runner"]
5610"#;
5611        let slot: MirrorProviderSlot = toml::from_str(toml_src).unwrap();
5612        let req = slot.required().expect("required block present");
5613        assert_eq!(req.regions, vec!["us-west"]);
5614        assert_eq!(req.mesh_tags, vec!["tag:cloud-runner"]);
5615        assert!(req.zones.is_empty());
5616    }
5617
5618    // ─── R844-F8 replica count ──────────────────────────────────────────────
5619
5620    fn three_runner_fleet() -> CloudConfig {
5621        make_empty_cfg(vec![
5622            make_machine_topo("us-east-001", "hetzner", "us-east", vec!["tag:cloud-runner"]),
5623            make_machine_topo(
5624                "us-south-001",
5625                "hetzner",
5626                "us-south",
5627                vec!["tag:cloud-runner"],
5628            ),
5629            make_machine_topo(
5630                "us-west-001",
5631                "hetzner",
5632                "us-west",
5633                vec!["tag:cloud-runner"],
5634            ),
5635        ])
5636    }
5637
5638    #[test]
5639    fn an_absent_replica_count_still_places_exactly_one_machine() {
5640        // The migration is additive: every mirror on disk omits `replicas`, and
5641        // must resolve byte-identically to the pre-R844-F8 answer.
5642        let cfg = three_runner_fleet();
5643        let req = RequiredSpec {
5644            mesh_tags: vec!["tag:cloud-runner".into()],
5645            ..Default::default()
5646        };
5647        assert_eq!(req.replica_count(), 1);
5648        let names: Vec<&str> = cfg
5649            .resolve_machines(&req)
5650            .unwrap()
5651            .iter()
5652            .map(|m| m.name.as_str())
5653            .collect();
5654        assert_eq!(names, vec!["us-east-001"]);
5655        assert_eq!(cfg.resolve_machine(&req).unwrap().name, "us-east-001");
5656    }
5657
5658    #[test]
5659    fn a_replica_count_places_that_many_machines_not_every_match() {
5660        // Three machines match; two are asked for; two are placed. Inferring the
5661        // count from the match count would make adding a box to the fleet
5662        // silently scale a production front door.
5663        let cfg = three_runner_fleet();
5664        let req = RequiredSpec {
5665            mesh_tags: vec!["tag:cloud-runner".into()],
5666            replicas: Some(2),
5667            ..Default::default()
5668        };
5669        let names: Vec<&str> = cfg
5670            .resolve_machines(&req)
5671            .unwrap()
5672            .iter()
5673            .map(|m| m.name.as_str())
5674            .collect();
5675        assert_eq!(names, vec!["us-east-001", "us-south-001"]);
5676    }
5677
5678    #[test]
5679    fn fewer_matches_than_replicas_is_an_error_naming_both_numbers() {
5680        // Never a partial placement: one of two reported as success is the
5681        // subset-that-looks-like-it-worked failure in its purest form.
5682        let cfg = three_runner_fleet();
5683        let req = RequiredSpec {
5684            regions: vec!["us-east".into()],
5685            replicas: Some(2),
5686            ..Default::default()
5687        };
5688        let err = cfg.resolve_machines(&req).unwrap_err().to_string();
5689        assert!(err.contains("only 1 of 2"), "got: {err}");
5690        assert!(err.contains("required.regions=[us-east]"), "got: {err}");
5691        // …and names the pool it searched, like every other placement refusal.
5692        assert!(err.contains("declared machines"), "got: {err}");
5693        assert!(err.contains("us-south-001"), "got: {err}");
5694    }
5695
5696    #[test]
5697    fn zero_replicas_is_refused_rather_than_placing_nothing() {
5698        let cfg = three_runner_fleet();
5699        let req = RequiredSpec {
5700            mesh_tags: vec!["tag:cloud-runner".into()],
5701            replicas: Some(0),
5702            ..Default::default()
5703        };
5704        let err = cfg.resolve_machines(&req).unwrap_err().to_string();
5705        assert!(err.contains("replicas = 0"), "got: {err}");
5706    }
5707
5708    #[test]
5709    fn replicas_parses_from_the_inline_required_form() {
5710        // The INLINE form specifically: `[providers.bundle.required]` as a table
5711        // HEADER ends the slot's table and reparents every key below it.
5712        let slot: MirrorProviderSlot = toml::from_str(
5713            r#"
5714use = "hetzner-primary"
5715port = 8080
5716required = { regions = ["us-east"], mesh_tags = ["tag:cloud-runner"], replicas = 2 }
5717"#,
5718        )
5719        .unwrap();
5720        assert_eq!(
5721            slot.fields().get("port").and_then(|v| v.as_integer()),
5722            Some(8080),
5723            "the inline form leaves the slot's other keys where they were"
5724        );
5725        let req = slot.required().expect("required block present");
5726        assert_eq!(req.replicas, Some(2));
5727        assert_eq!(req.replica_count(), 2);
5728        // A count is not a match axis — it says how many, not which.
5729        assert!(!req.is_unconstrained());
5730        assert!(RequiredSpec {
5731            replicas: Some(2),
5732            ..Default::default()
5733        }
5734        .is_unconstrained());
5735    }
5736
5737    #[test]
5738    fn round_trip_machine() {
5739        let cfg = MachineConfig {
5740            name: "test-pdx-1".into(),
5741            provider: "hetzner".into(),
5742            location: Some("pdx".into()),
5743            server_type: Some("cpx22".into()),
5744            hosts_mirrors: vec!["noisetable".into()],
5745            mesh_tags: vec!["region:pdx".into()],
5746            region: Some("us-west".into()),
5747            zone: Some("pdx".into()),
5748            arch: None,
5749            bucket: Some(BucketSpec {
5750                name: "test-assets-pdx-1".into(),
5751                public_read: false,
5752            }),
5753            vendor: None,
5754            nickname: None,
5755            legacy_hostkey_fingerprint: None,
5756            registration: Default::default(),
5757            ssh_keys: vec![],
5758            cloudflared: None,
5759            hosts_operator_bridge: false,
5760            connect: None,
5761            allocatable: None,
5762            taints: vec![],
5763            sovereign_group: None,
5764            sovereign_role: None,
5765            ingress_floating_ip: None,
5766        };
5767        let s = toml::to_string(&cfg).unwrap();
5768        let back: MachineConfig = toml::from_str(&s).unwrap();
5769        assert_eq!(back.name, cfg.name);
5770        assert_eq!(back.location, cfg.location);
5771        assert_eq!(back.region.as_deref(), Some("us-west"));
5772        assert_eq!(back.zone.as_deref(), Some("pdx"));
5773    }
5774
5775    #[test]
5776    fn round_trip_mirror() {
5777        let cfg = LegacyMirrorConfig {
5778            camp: "noisetable".into(),
5779            regions: vec!["pdx".into(), "iad".into()],
5780            workloads: vec!["asset-registry".into()],
5781            cloud_domain: None,
5782        };
5783        let s = toml::to_string(&cfg).unwrap();
5784        let back: LegacyMirrorConfig = toml::from_str(&s).unwrap();
5785        assert_eq!(back.camp, cfg.camp);
5786        assert_eq!(back.regions, cfg.regions);
5787        assert_eq!(back.workloads, cfg.workloads);
5788    }
5789
5790    #[test]
5791    fn mirror_serialises_as_camp_key() {
5792        // Serialised form should use `camp`, not `rig`.
5793        let cfg = LegacyMirrorConfig {
5794            camp: "noisetable".into(),
5795            regions: vec!["pdx".into()],
5796            workloads: vec![],
5797            cloud_domain: None,
5798        };
5799        let s = toml::to_string(&cfg).unwrap();
5800        assert!(
5801            s.contains("camp = "),
5802            "serialised key should be 'camp': {s}"
5803        );
5804        assert!(!s.contains("rig = "), "old key should not appear: {s}");
5805    }
5806
5807    #[test]
5808    fn mirror_rig_alias_still_loads() {
5809        // Old mirrors/*.toml files use `rig = "..."` before the R137 rename;
5810        // the alias keeps them loading until the one-time `sed` migration runs.
5811        let toml_str =
5812            "rig = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = [\"asset-registry\"]\n";
5813        let cfg: LegacyMirrorConfig = toml::from_str(toml_str).unwrap();
5814        assert_eq!(cfg.camp, "noisetable");
5815    }
5816
5817    #[test]
5818    fn mirror_services_alias_still_loads() {
5819        // Old mirrors/*.toml files use `services = [...]`; the alias keeps them
5820        // loading without a migration step.
5821        let toml_str =
5822            "camp = \"noisetable\"\nregions = [\"pdx\"]\nservices = [\"asset-registry\"]\n";
5823        let cfg: LegacyMirrorConfig = toml::from_str(toml_str).unwrap();
5824        assert_eq!(cfg.workloads, vec!["asset-registry"]);
5825    }
5826
5827    #[test]
5828    fn round_trip_service_legacy() {
5829        let cfg = LegacyServiceConfig {
5830            name: "asset-registry".into(),
5831            image: "ghcr.io/noisetable/asset-registry".into(),
5832            version: "v1.0.0".into(),
5833            env: HashMap::new(),
5834            ports: vec![PortMapping {
5835                host: 8080,
5836                container: 8080,
5837            }],
5838            mesh_only: false,
5839            bind_interface: None,
5840            tenant: TenantId::singleton(),
5841        };
5842        let s = toml::to_string(&cfg).unwrap();
5843        let back: LegacyServiceConfig = toml::from_str(&s).unwrap();
5844        assert_eq!(back.name, cfg.name);
5845        assert_eq!(back.image, cfg.image);
5846    }
5847
5848    #[test]
5849    fn service_bind_interface_round_trips() {
5850        let cfg = LegacyServiceConfig {
5851            name: "postgres".into(),
5852            image: "postgres".into(),
5853            version: "16".into(),
5854            env: HashMap::new(),
5855            ports: vec![PortMapping {
5856                host: 5432,
5857                container: 5432,
5858            }],
5859            mesh_only: true,
5860            bind_interface: Some("tailscale0".into()),
5861            tenant: TenantId::singleton(),
5862        };
5863        let s = toml::to_string(&cfg).unwrap();
5864        let back: LegacyServiceConfig = toml::from_str(&s).unwrap();
5865        assert_eq!(back.bind_interface.as_deref(), Some("tailscale0"));
5866    }
5867
5868    #[test]
5869    fn service_bind_interface_absent_is_none() {
5870        let toml_str = "name = \"app\"\nimage = \"app\"\nversion = \"v1\"\n";
5871        let cfg: LegacyServiceConfig = toml::from_str(toml_str).unwrap();
5872        assert!(
5873            cfg.bind_interface.is_none(),
5874            "bind_interface should default to None"
5875        );
5876    }
5877
5878    #[test]
5879    fn service_bind_interface_skipped_when_none() {
5880        let cfg = LegacyServiceConfig {
5881            name: "app".into(),
5882            image: "app".into(),
5883            version: "v1".into(),
5884            env: HashMap::new(),
5885            ports: vec![],
5886            mesh_only: false,
5887            bind_interface: None,
5888            tenant: TenantId::singleton(),
5889        };
5890        let s = toml::to_string(&cfg).unwrap();
5891        assert!(!s.contains("bind_interface"), "None should be skipped: {s}");
5892    }
5893
5894    #[test]
5895    fn load_dir_missing_is_empty() {
5896        let dir = std::path::PathBuf::from("/nonexistent/path");
5897        let result: Vec<MachineConfig> = load_dir(dir).unwrap();
5898        assert!(result.is_empty());
5899    }
5900
5901    #[test]
5902    fn topology_round_trip() {
5903        let topo = TopologyConfig {
5904            assignments: vec![
5905                MirrorAssignment {
5906                    mirror: "noisetable-pdx".into(),
5907                    machine: "noisetable-pdx-1".into(),
5908                },
5909                MirrorAssignment {
5910                    mirror: "noisetable-iad".into(),
5911                    machine: "noisetable-iad-1".into(),
5912                },
5913            ],
5914            buckets: vec![],
5915        };
5916        let s = toml::to_string(&topo).unwrap();
5917        let back: TopologyConfig = toml::from_str(&s).unwrap();
5918        assert_eq!(back.assignments.len(), 2);
5919        assert_eq!(back.assignments[0].mirror, "noisetable-pdx");
5920        assert_eq!(back.assignments[1].machine, "noisetable-iad-1");
5921    }
5922
5923    #[test]
5924    fn topology_absent_returns_default() {
5925        let tmp = tempfile::TempDir::new().unwrap();
5926        let path = tmp.path().join("topology.toml");
5927        // file doesn't exist
5928        let topo = load_topology(path).unwrap();
5929        assert!(topo.assignments.is_empty());
5930    }
5931
5932    /// Helper: lay out a `<workspace_root>/.yah/cloud/` legacy tree for the
5933    /// pre-R215 cargo tests below; returns the legacy cloud_dir for writes.
5934    fn make_legacy_cloud_dir(root: &std::path::Path) -> std::path::PathBuf {
5935        let cloud_dir = root.join(".yah").join("cloud");
5936        std::fs::create_dir_all(&cloud_dir).unwrap();
5937        cloud_dir
5938    }
5939
5940    #[test]
5941    fn cloud_config_load_and_lookup() {
5942        let tmp = tempfile::TempDir::new().unwrap();
5943        let root = tmp.path();
5944        let cloud_dir = make_legacy_cloud_dir(root);
5945
5946        let machine = MachineConfig {
5947            name: "noisetable-pdx-1".into(),
5948            provider: "hetzner".into(),
5949            location: Some("pdx".into()),
5950            server_type: Some("cpx22".into()),
5951            hosts_mirrors: vec!["noisetable".into(), "yah".into()],
5952            mesh_tags: vec!["region:pdx".into(), "tier:t2".into()],
5953            region: None,
5954            zone: None,
5955            arch: None,
5956            bucket: Some(BucketSpec {
5957                name: "noisetable-assets-pdx-1".into(),
5958                public_read: false,
5959            }),
5960            vendor: None,
5961            nickname: None,
5962            legacy_hostkey_fingerprint: None,
5963            registration: Default::default(),
5964            ssh_keys: vec![],
5965            cloudflared: None,
5966            hosts_operator_bridge: false,
5967            connect: None,
5968            allocatable: None,
5969            taints: vec![],
5970            sovereign_group: None,
5971            sovereign_role: None,
5972            ingress_floating_ip: None,
5973        };
5974        // Land in the legacy tree so the legacy machine loader picks it up.
5975        machine.save(&cloud_dir).unwrap();
5976
5977        let mirror_toml = "camp = \"noisetable\"\nregions = [\"pdx\", \"iad\", \"fsn\"]\nworkloads = [\"asset-registry\"]\n";
5978        std::fs::create_dir_all(cloud_dir.join("mirrors")).unwrap();
5979        std::fs::write(cloud_dir.join("mirrors/noisetable.toml"), mirror_toml).unwrap();
5980
5981        // Legacy services/ dir (backward compat)
5982        let svc_toml = "name = \"asset-registry\"\nimage = \"ghcr.io/noisetable/asset-registry\"\nversion = \"v1.0.0\"\nmesh_only = false\n";
5983        std::fs::create_dir_all(cloud_dir.join("services")).unwrap();
5984        std::fs::write(cloud_dir.join("services/asset-registry.toml"), svc_toml).unwrap();
5985
5986        let cfg = CloudConfig::load(root).unwrap();
5987
5988        assert_eq!(cfg.machines.len(), 1);
5989        assert_eq!(cfg.legacy_mirrors.len(), 1);
5990        assert_eq!(cfg.legacy_services.len(), 1);
5991        assert_eq!(cfg.workloads.len(), 0); // no workloads/ dir yet
5992        assert!(cfg.services.is_empty(), "no R215+ services/ tree");
5993        assert!(cfg.providers.is_empty(), "no R215+ providers/ tree");
5994
5995        let m = cfg.machine("noisetable-pdx-1").unwrap();
5996        assert_eq!(m.location(), "pdx");
5997        assert_eq!(m.bucket.as_ref().unwrap().name, "noisetable-assets-pdx-1");
5998
5999        let mir = cfg.legacy_mirror("noisetable").unwrap();
6000        assert_eq!(mir.regions, vec!["pdx", "iad", "fsn"]);
6001        assert_eq!(mir.workloads, vec!["asset-registry"]);
6002    }
6003
6004    #[test]
6005    fn mirror_folder_layout_loads() {
6006        // Folder layout: mirrors/<id>/mirror.toml — new preferred form.
6007        let tmp = tempfile::TempDir::new().unwrap();
6008        let root = tmp.path();
6009        let cloud_dir = make_legacy_cloud_dir(root);
6010        let mirror_dir = cloud_dir.join("mirrors").join("yah-com");
6011        std::fs::create_dir_all(&mirror_dir).unwrap();
6012        std::fs::write(
6013            mirror_dir.join("mirror.toml"),
6014            "camp = \"yah\"\nregions = [\"pdx\"]\nworkloads = [\"yah-web\"]\n",
6015        )
6016        .unwrap();
6017
6018        let cfg = CloudConfig::load(root).unwrap();
6019        assert_eq!(cfg.legacy_mirrors.len(), 1);
6020        let mir = cfg.legacy_mirror("yah").unwrap();
6021        assert_eq!(mir.camp, "yah");
6022        assert_eq!(mir.workloads, vec!["yah-web"]);
6023    }
6024
6025    #[test]
6026    fn mirror_folder_and_flat_coexist() {
6027        // Both layouts may coexist in the same mirrors/ directory.
6028        let tmp = tempfile::TempDir::new().unwrap();
6029        let root = tmp.path();
6030        let cloud_dir = make_legacy_cloud_dir(root);
6031        let mirrors_root = cloud_dir.join("mirrors");
6032        std::fs::create_dir_all(&mirrors_root).unwrap();
6033
6034        // Flat legacy mirror
6035        std::fs::write(
6036            mirrors_root.join("noisetable.toml"),
6037            "camp = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = []\n",
6038        )
6039        .unwrap();
6040
6041        // Folder-form mirror
6042        let yah_com_dir = mirrors_root.join("yah-com");
6043        std::fs::create_dir_all(&yah_com_dir).unwrap();
6044        std::fs::write(
6045            yah_com_dir.join("mirror.toml"),
6046            "camp = \"yah\"\nregions = [\"pdx\"]\nworkloads = []\n",
6047        )
6048        .unwrap();
6049
6050        let cfg = CloudConfig::load(root).unwrap();
6051        assert_eq!(cfg.legacy_mirrors.len(), 2);
6052        assert!(cfg.legacy_mirror("noisetable").is_some());
6053        assert!(cfg.legacy_mirror("yah").is_some());
6054    }
6055
6056    #[test]
6057    fn mirror_malformed_fails_with_field_path() {
6058        // A malformed mirror.toml should fail at load with a clear error
6059        // that includes the file path.
6060        let tmp = tempfile::TempDir::new().unwrap();
6061        let root = tmp.path();
6062        let cloud_dir = make_legacy_cloud_dir(root);
6063        let mirror_dir = cloud_dir.join("mirrors").join("bad");
6064        std::fs::create_dir_all(&mirror_dir).unwrap();
6065        // Missing required `camp` field
6066        std::fs::write(
6067            mirror_dir.join("mirror.toml"),
6068            "regions = [\"pdx\"]\nworkloads = []\n",
6069        )
6070        .unwrap();
6071
6072        let err = CloudConfig::load(root).unwrap_err();
6073        let msg = err.to_string();
6074        assert!(
6075            msg.contains("mirror.toml"),
6076            "error should reference the file path, got: {msg}"
6077        );
6078    }
6079
6080    #[test]
6081    fn workload_config_load_and_validate() {
6082        use workload_spec::{
6083            ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
6084            RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
6085        };
6086
6087        let tmp = tempfile::TempDir::new().unwrap();
6088        let root = tmp.path();
6089        let cloud_dir = make_legacy_cloud_dir(root);
6090        std::fs::create_dir_all(cloud_dir.join("workloads")).unwrap();
6091
6092        let spec = WorkloadSpec {
6093            schema_version: SchemaVersion::V1,
6094            name: "asset-registry".into(),
6095            image: ImageRef {
6096                registry: "ghcr.io".into(),
6097                repository: "noisetable/asset-registry".into(),
6098                tag: "v1.0.0".into(),
6099                digest: workload_spec::testing::test_digest(),
6100            },
6101            tier: TierTag("tenant".into()),
6102            replicas: 1,
6103            command: None,
6104            entrypoint: None,
6105            workdir: None,
6106            user: None,
6107            env: vec![],
6108            secrets: vec![],
6109            volumes: vec![],
6110            resources: ResourceLimits {
6111                memory_mb: 256,
6112                cpu_millis: 512,
6113                ephemeral_storage_mb: 512,
6114            },
6115            depends_on: vec![],
6116            requires: vec![],
6117            healthcheck: None,
6118            restart_policy: RestartPolicy::Always,
6119            archetype: None,
6120            stop_policy: StopPolicy {
6121                signal: 15,
6122                grace_period: workload_spec::Millis::from_secs(10),
6123            },
6124            expose: ExposeSpec {
6125                mesh: MeshExpose {
6126                    identity: MeshIdent("asset-registry.pdx".into()),
6127                    ports: MeshExpose::anonymous_ports([8080]),
6128                    allow_from: vec![],
6129                },
6130                public: None,
6131                operator: None,
6132            },
6133            tenant: TenantId::singleton(),
6134            namespace: NamespaceId::singleton(),
6135            labels: Default::default(),
6136            annotations: Default::default(),
6137        };
6138
6139        let toml_str = toml::to_string_pretty(&spec).unwrap();
6140        std::fs::write(cloud_dir.join("workloads/asset-registry.toml"), &toml_str).unwrap();
6141
6142        let cfg = CloudConfig::load(root).unwrap();
6143        assert_eq!(cfg.workloads.len(), 1);
6144        assert_eq!(cfg.workloads[0].spec.name, "asset-registry");
6145        assert_eq!(cfg.workload("asset-registry").unwrap().spec.replicas, 1);
6146    }
6147
6148    /// Minimal valid spec for the R215+ loader tests below. Kept as a helper so
6149    /// the two tests differ only in *where* the file lands, which is the whole
6150    /// thing under test.
6151    #[cfg(test)]
6152    fn minimal_spec(name: &str, replicas: u32) -> workload_spec::WorkloadSpec {
6153        use workload_spec::{
6154            ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
6155            RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
6156        };
6157        WorkloadSpec {
6158            schema_version: SchemaVersion::V1,
6159            name: name.into(),
6160            image: ImageRef {
6161                registry: "cr.yah.dev".into(),
6162                repository: name.into(),
6163                tag: "v1".into(),
6164                digest: workload_spec::testing::test_digest(),
6165            },
6166            tier: TierTag("infra".into()),
6167            replicas,
6168            command: None,
6169            entrypoint: None,
6170            workdir: None,
6171            user: None,
6172            env: vec![],
6173            secrets: vec![],
6174            volumes: vec![],
6175            resources: ResourceLimits {
6176                memory_mb: 256,
6177                cpu_millis: 250,
6178                ephemeral_storage_mb: 128,
6179            },
6180            depends_on: vec![],
6181            requires: vec![],
6182            healthcheck: None,
6183            restart_policy: RestartPolicy::Always,
6184            archetype: None,
6185            stop_policy: StopPolicy {
6186                signal: 15,
6187                grace_period: workload_spec::Millis::from_secs(10),
6188            },
6189            expose: ExposeSpec {
6190                mesh: MeshExpose {
6191                    identity: MeshIdent(name.into()),
6192                    ports: MeshExpose::anonymous_ports([4325]),
6193                    allow_from: vec![],
6194                },
6195                public: None,
6196                operator: None,
6197            },
6198            tenant: TenantId::singleton(),
6199            namespace: NamespaceId::singleton(),
6200            labels: Default::default(),
6201            annotations: Default::default(),
6202        }
6203    }
6204
6205    /// R568-T7. Workloads must load from the R215+ tree.
6206    ///
6207    /// Before the fix this function tested, `CloudConfig::load` read workloads
6208    /// ONLY from the pre-R215 `.yah/cloud/workloads/` — which R222-B1 emptied —
6209    /// so in any modern camp `cfg.workload(name)` returned `None` for every
6210    /// name and the entire `yah cloud workload …` surface was unreachable. The
6211    /// CLI's own error text has said `.yah/infra/workloads/` throughout, so the
6212    /// bug read as "you must have typoed the filename".
6213    ///
6214    /// Note the fixture writes NO legacy `.yah/cloud/` dir at all: that is the
6215    /// shape of a real post-R215 camp, and it is exactly the shape the old code
6216    /// could not serve.
6217    #[test]
6218    fn workloads_load_from_the_infra_tree() {
6219        let tmp = tempfile::TempDir::new().unwrap();
6220        let root = tmp.path();
6221        let dir = crate::paths::workloads_dir(root);
6222        std::fs::create_dir_all(&dir).unwrap();
6223        std::fs::write(
6224            dir.join("yah-cloud-admin.toml"),
6225            toml::to_string_pretty(&minimal_spec("yah-cloud-admin", 1)).unwrap(),
6226        )
6227        .unwrap();
6228
6229        let cfg = CloudConfig::load(root).unwrap();
6230        assert_eq!(cfg.workloads.len(), 1);
6231        assert_eq!(
6232            cfg.workload("yah-cloud-admin").unwrap().spec.replicas,
6233            1,
6234            "a workload declared under .yah/infra/workloads/ must be resolvable by name"
6235        );
6236    }
6237
6238    /// A camp mid-migration can have both trees. R215+ wins on a name
6239    /// collision — same precedence the machine loader applies — so moving a
6240    /// declaration into `.yah/infra/workloads/` takes effect immediately
6241    /// instead of being silently shadowed by the copy left behind.
6242    #[test]
6243    fn infra_workload_shadows_the_legacy_copy_of_the_same_name() {
6244        let tmp = tempfile::TempDir::new().unwrap();
6245        let root = tmp.path();
6246
6247        let legacy = make_legacy_cloud_dir(root);
6248        std::fs::create_dir_all(legacy.join("workloads")).unwrap();
6249        std::fs::write(
6250            legacy.join("workloads/shared.toml"),
6251            toml::to_string_pretty(&minimal_spec("shared", 9)).unwrap(),
6252        )
6253        .unwrap();
6254        // Legacy-only name, to prove the old tree is still read rather than
6255        // replaced wholesale.
6256        std::fs::write(
6257            legacy.join("workloads/legacy-only.toml"),
6258            toml::to_string_pretty(&minimal_spec("legacy-only", 3)).unwrap(),
6259        )
6260        .unwrap();
6261
6262        let infra = crate::paths::workloads_dir(root);
6263        std::fs::create_dir_all(&infra).unwrap();
6264        std::fs::write(
6265            infra.join("shared.toml"),
6266            toml::to_string_pretty(&minimal_spec("shared", 1)).unwrap(),
6267        )
6268        .unwrap();
6269
6270        let cfg = CloudConfig::load(root).unwrap();
6271        assert_eq!(cfg.workloads.len(), 2, "one `shared`, plus `legacy-only`");
6272        assert_eq!(
6273            cfg.workload("shared").unwrap().spec.replicas,
6274            1,
6275            "the .yah/infra/ copy must win over the legacy one"
6276        );
6277        assert_eq!(cfg.workload("legacy-only").unwrap().spec.replicas, 3);
6278    }
6279
6280    #[test]
6281    fn workload_loader_rejects_bad_spec() {
6282        use workload_spec::{
6283            ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
6284            RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
6285        };
6286
6287        let tmp = tempfile::TempDir::new().unwrap();
6288        let root = tmp.path();
6289        let cloud_dir = make_legacy_cloud_dir(root);
6290        std::fs::create_dir_all(cloud_dir.join("workloads")).unwrap();
6291
6292        // Construct a spec that round-trips through TOML but fails shape
6293        // validation: replicas = 200 is above the max of 100.
6294        let mut spec = WorkloadSpec {
6295            schema_version: SchemaVersion::V1,
6296            name: "asset-registry".into(),
6297            image: ImageRef {
6298                registry: "ghcr.io".into(),
6299                repository: "test/app".into(),
6300                tag: "v1".into(),
6301                digest: workload_spec::testing::test_digest(),
6302            },
6303            tier: TierTag("tenant".into()),
6304            replicas: 200, // ← invalid: exceeds max 100
6305            command: None,
6306            entrypoint: None,
6307            workdir: None,
6308            user: None,
6309            env: vec![],
6310            secrets: vec![],
6311            volumes: vec![],
6312            resources: ResourceLimits {
6313                memory_mb: 256,
6314                cpu_millis: 512,
6315                ephemeral_storage_mb: 512,
6316            },
6317            depends_on: vec![],
6318            requires: vec![],
6319            healthcheck: None,
6320            restart_policy: RestartPolicy::Always,
6321            archetype: None,
6322            stop_policy: StopPolicy {
6323                signal: 15,
6324                grace_period: workload_spec::Millis::from_secs(10),
6325            },
6326            expose: ExposeSpec {
6327                mesh: MeshExpose {
6328                    identity: MeshIdent("asset-registry.pdx".into()),
6329                    ports: MeshExpose::anonymous_ports([8080]),
6330                    allow_from: vec![],
6331                },
6332                public: None,
6333                operator: None,
6334            },
6335            tenant: TenantId::singleton(),
6336            namespace: NamespaceId::singleton(),
6337            labels: Default::default(),
6338            annotations: Default::default(),
6339        };
6340
6341        let toml_str = toml::to_string_pretty(&spec).unwrap();
6342        std::fs::write(cloud_dir.join("workloads/bad.toml"), &toml_str).unwrap();
6343
6344        let result = CloudConfig::load(root);
6345        assert!(
6346            result.is_err(),
6347            "loading a WorkloadSpec with replicas=200 should return Err"
6348        );
6349        let msg = result.unwrap_err().to_string();
6350        assert!(
6351            msg.contains("shape validation")
6352                || msg.contains("Replicas")
6353                || msg.contains("replicas"),
6354            "error should mention shape validation or replicas field, got: {msg}"
6355        );
6356
6357        // The `spec` binding is only used for the write — suppress warning.
6358        let _ = &mut spec;
6359    }
6360
6361    #[test]
6362    fn workload_config_save_round_trip() {
6363        use workload_spec::{
6364            ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
6365            RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
6366        };
6367
6368        let tmp = tempfile::TempDir::new().unwrap();
6369        let root = tmp.path();
6370
6371        let spec = WorkloadSpec {
6372            schema_version: SchemaVersion::V1,
6373            name: "signing-service".into(),
6374            image: ImageRef {
6375                registry: "ghcr.io".into(),
6376                repository: "noisetable/signing".into(),
6377                tag: "v2.0.0".into(),
6378                digest: workload_spec::testing::test_digest(),
6379            },
6380            tier: TierTag("private".into()),
6381            replicas: 2,
6382            command: None,
6383            entrypoint: None,
6384            workdir: None,
6385            user: None,
6386            env: vec![],
6387            secrets: vec![],
6388            volumes: vec![],
6389            resources: ResourceLimits {
6390                memory_mb: 128,
6391                cpu_millis: 256,
6392                ephemeral_storage_mb: 256,
6393            },
6394            depends_on: vec![],
6395            requires: vec![],
6396            healthcheck: None,
6397            restart_policy: RestartPolicy::Always,
6398            archetype: None,
6399            stop_policy: StopPolicy {
6400                signal: 15,
6401                grace_period: workload_spec::Millis::from_secs(5),
6402            },
6403            expose: ExposeSpec {
6404                mesh: MeshExpose {
6405                    identity: MeshIdent("signing.pdx".into()),
6406                    ports: MeshExpose::anonymous_ports([9090]),
6407                    allow_from: vec![],
6408                },
6409                public: None,
6410                operator: None,
6411            },
6412            tenant: TenantId::singleton(),
6413            namespace: NamespaceId::singleton(),
6414            labels: Default::default(),
6415            annotations: Default::default(),
6416        };
6417
6418        let wc = WorkloadConfig { spec };
6419        let cloud_dir = make_legacy_cloud_dir(root);
6420        wc.save(&cloud_dir).unwrap();
6421
6422        let loaded = CloudConfig::load(root).unwrap();
6423        assert_eq!(loaded.workloads.len(), 1);
6424        assert_eq!(loaded.workloads[0].spec.name, "signing-service");
6425        assert_eq!(loaded.workloads[0].spec.replicas, 2);
6426    }
6427
6428    #[test]
6429    fn machine_save_write_back_fingerprint() {
6430        let tmp = tempfile::TempDir::new().unwrap();
6431        let root = tmp.path();
6432
6433        let mut machine = MachineConfig {
6434            name: "test-pdx-1".into(),
6435            provider: "hetzner".into(),
6436            location: Some("pdx".into()),
6437            server_type: Some("cpx22".into()),
6438            hosts_mirrors: vec![],
6439            mesh_tags: vec![],
6440            region: None,
6441            zone: None,
6442            arch: None,
6443            bucket: None,
6444            vendor: None,
6445            nickname: None,
6446            legacy_hostkey_fingerprint: None,
6447            registration: Default::default(),
6448            ssh_keys: vec![],
6449            cloudflared: None,
6450            hosts_operator_bridge: false,
6451            connect: None,
6452            allocatable: None,
6453            taints: vec![],
6454            sovereign_group: None,
6455            sovereign_role: None,
6456            ingress_floating_ip: None,
6457        };
6458        machine.save(root).unwrap();
6459
6460        // Simulate A4: write back the hostkey fingerprint after provision.
6461        // R707-T1: registration is the write target; the accessor is the read.
6462        machine.registration.hostkey_fingerprint = Some("SHA256:abc123".into());
6463        machine.save(root).unwrap();
6464
6465        let reloaded: Vec<MachineConfig> = load_dir(root.join("machines")).unwrap();
6466        assert_eq!(reloaded.len(), 1);
6467        assert_eq!(reloaded[0].hostkey_fingerprint(), Some("SHA256:abc123"));
6468    }
6469
6470    // ─── New-shape (R222 B2) parse tests ────────────────────────────────────
6471    //
6472    // These mirror the Phase-A manifests committed under `.yah/services/` and
6473    // `.yah/infra/providers/`. Keeping the test strings inline (rather than
6474    // reading the on-disk files) so the loader stays runnable in any workdir
6475    // and so accidental edits to the on-disk files don't silently change
6476    // schema expectations.
6477
6478    #[test]
6479    fn provider_cloudflare_round_trips() {
6480        let src = r#"
6481schema_version = 1
6482id = "cloudflare"
6483kind = "cloudflare"
6484credentials = "keystore://cloudflare/yah"
6485default_zone = "yah.dev"
6486"#;
6487        let cfg: ProviderConfig = toml::from_str(src).unwrap();
6488        assert_eq!(cfg.id, "cloudflare");
6489        assert_eq!(cfg.kind, Provider::Cloudflare);
6490        assert_eq!(
6491            cfg.credentials.as_deref(),
6492            Some("keystore://cloudflare/yah")
6493        );
6494        assert_eq!(
6495            cfg.fields.get("default_zone").and_then(|v| v.as_str()),
6496            Some("yah.dev"),
6497        );
6498        let back = toml::to_string(&cfg).unwrap();
6499        let again: ProviderConfig = toml::from_str(&back).unwrap();
6500        assert_eq!(again.id, cfg.id);
6501        assert_eq!(again.kind, cfg.kind);
6502    }
6503
6504    #[test]
6505    fn provider_hetzner_round_trips() {
6506        let src = r#"
6507schema_version = 1
6508id = "hetzner"
6509kind = "hetzner"
6510credentials = "keystore://hetzner/yah"
6511default_location = "pdx"
6512default_server_type = "cpx11"
6513ssh_keys = []
6514"#;
6515        let cfg: ProviderConfig = toml::from_str(src).unwrap();
6516        assert_eq!(cfg.kind, Provider::Hetzner);
6517        assert_eq!(
6518            cfg.fields.get("default_location").and_then(|v| v.as_str()),
6519            Some("pdx"),
6520        );
6521        assert!(
6522            cfg.fields
6523                .get("ssh_keys")
6524                .map(|v| v.as_array().unwrap().is_empty())
6525                .unwrap_or(false),
6526            "ssh_keys must round-trip as empty array, got {:?}",
6527            cfg.fields.get("ssh_keys"),
6528        );
6529    }
6530
6531    #[test]
6532    fn provider_orbstack_local_container_round_trips() {
6533        let src = r#"
6534schema_version = 1
6535id = "orbstack"
6536kind = "local-container"
6537runtime = "auto"
6538
6539[discovery]
6540orbstack = "~/.orbstack/run/docker.sock"
6541colima   = "~/.colima/default/docker.sock"
6542docker   = "/var/run/docker.sock"
6543"#;
6544        let cfg: ProviderConfig = toml::from_str(src).unwrap();
6545        assert_eq!(cfg.kind, Provider::LocalContainer);
6546        assert_eq!(
6547            cfg.fields.get("runtime").and_then(|v| v.as_str()),
6548            Some("auto"),
6549        );
6550        let discovery = cfg
6551            .fields
6552            .get("discovery")
6553            .and_then(|v| v.as_table())
6554            .expect("discovery table");
6555        assert!(discovery.contains_key("orbstack"));
6556        assert!(discovery.contains_key("colima"));
6557        assert!(discovery.contains_key("docker"));
6558    }
6559
6560    #[test]
6561    fn provider_unknown_kind_fails() {
6562        let src = r#"
6563schema_version = 1
6564id = "made-up"
6565kind = "fly-io"
6566"#;
6567        let err = toml::from_str::<ProviderConfig>(src).unwrap_err();
6568        let msg = err.to_string();
6569        assert!(
6570            msg.contains("kind") || msg.contains("variant"),
6571            "unknown provider kind should surface as a serde error, got: {msg}"
6572        );
6573    }
6574
6575    #[test]
6576    fn service_dev_yah_round_trips() {
6577        let src = r#"
6578schema_version = 1
6579name = "dev-yah"
6580domain = "yah.dev"
6581
6582[[components]]
6583id = "site"
6584kind = "mesofact-static"
6585path = "app/yah/web"
6586role = "static"
6587"#;
6588        let cfg: ServiceConfig = toml::from_str(src).unwrap();
6589        assert_eq!(cfg.name, "dev-yah");
6590        assert_eq!(cfg.domain, "yah.dev");
6591        assert_eq!(cfg.components.len(), 1);
6592        let c = &cfg.components[0];
6593        assert_eq!(c.id, "site");
6594        assert_eq!(c.kind, "mesofact-static");
6595        assert_eq!(c.path, "app/yah/web");
6596        assert_eq!(c.role, "static");
6597        assert!(c.publishes.is_none());
6598
6599        let back = toml::to_string(&cfg).unwrap();
6600        let again: ServiceConfig = toml::from_str(&back).unwrap();
6601        assert_eq!(again.name, cfg.name);
6602        assert_eq!(again.components[0].kind, c.kind);
6603    }
6604
6605    #[test]
6606    fn mirror_prod_cloudflare_reference_parses() {
6607        let src = r#"
6608schema_version = 1
6609shape = "single-machine"
6610
6611[providers.static]
6612use = "cloudflare"
6613bucket = "yah-dev"
6614zone = "yah.dev"
6615dns = { record = "@", type = "CNAME" }
6616"#;
6617        let cfg: MirrorConfig = toml::from_str(src).unwrap();
6618        assert_eq!(cfg.shape, MirrorShape::SingleMachine);
6619        let slot = cfg.providers.get("static").expect("static slot");
6620        assert_eq!(slot.provider_id(), Some("cloudflare"));
6621        assert!(slot.inline_kind().is_none());
6622        if let MirrorProviderSlot::Reference { fields, .. } = slot {
6623            assert_eq!(
6624                fields.get("bucket").and_then(|v| v.as_str()),
6625                Some("yah-dev")
6626            );
6627            assert_eq!(fields.get("zone").and_then(|v| v.as_str()), Some("yah.dev"));
6628            let dns = fields
6629                .get("dns")
6630                .and_then(|v| v.as_table())
6631                .expect("dns table");
6632            assert_eq!(dns.get("record").and_then(|v| v.as_str()), Some("@"));
6633            assert_eq!(dns.get("type").and_then(|v| v.as_str()), Some("CNAME"));
6634        } else {
6635            panic!("expected Reference slot");
6636        }
6637    }
6638
6639    #[test]
6640    fn mirror_local_inline_static_and_orbstack_compute_parse() {
6641        let src = r#"
6642schema_version = 1
6643shape = "local"
6644
6645[providers.static]
6646kind = "local-static"
6647port = 4321
6648artifact_dir = ".yah/infra/state/local/static"
6649
6650[providers.compute]
6651use = "orbstack"
6652"#;
6653        let cfg: MirrorConfig = toml::from_str(src).unwrap();
6654        assert_eq!(cfg.shape, MirrorShape::Local);
6655
6656        let static_slot = cfg.providers.get("static").expect("static slot");
6657        assert_eq!(static_slot.inline_kind(), Some(Provider::LocalStatic));
6658        assert!(static_slot.provider_id().is_none());
6659        if let MirrorProviderSlot::Inline { fields, .. } = static_slot {
6660            assert_eq!(fields.get("port").and_then(|v| v.as_integer()), Some(4321));
6661            assert_eq!(
6662                fields.get("artifact_dir").and_then(|v| v.as_str()),
6663                Some(".yah/infra/state/local/static"),
6664            );
6665        } else {
6666            panic!("expected Inline slot for static");
6667        }
6668
6669        let compute_slot = cfg.providers.get("compute").expect("compute slot");
6670        assert_eq!(compute_slot.provider_id(), Some("orbstack"));
6671    }
6672
6673    #[test]
6674    fn mirror_pond_miniflare_minio_parse() {
6675        // pond-tier mirror: miniflare-container + minio, both inline.
6676        // T1 just needs these inline kinds to parse — the reconciler dispatch
6677        // arrives in R256-T3.
6678        let src = r#"
6679schema_version = 1
6680shape = "local"
6681
6682[providers.static]
6683kind = "miniflare-container"
6684port = 4322
6685bucket = "yah-dev"
6686
6687[providers.object_store]
6688kind = "minio-container"
6689api_port = 9000
6690console_port = 9001
6691bucket = "yah-dev"
6692"#;
6693        let cfg: MirrorConfig = toml::from_str(src).unwrap();
6694        assert_eq!(cfg.shape, MirrorShape::Local);
6695
6696        let static_slot = cfg.providers.get("static").expect("static slot");
6697        assert_eq!(
6698            static_slot.inline_kind(),
6699            Some(Provider::MiniflareContainer)
6700        );
6701        if let MirrorProviderSlot::Inline { fields, .. } = static_slot {
6702            assert_eq!(fields.get("port").and_then(|v| v.as_integer()), Some(4322));
6703            assert_eq!(
6704                fields.get("bucket").and_then(|v| v.as_str()),
6705                Some("yah-dev")
6706            );
6707        } else {
6708            panic!("expected Inline slot for miniflare-container static");
6709        }
6710
6711        let object_store_slot = cfg
6712            .providers
6713            .get("object_store")
6714            .expect("object_store slot");
6715        assert_eq!(
6716            object_store_slot.inline_kind(),
6717            Some(Provider::MinioContainer)
6718        );
6719        if let MirrorProviderSlot::Inline { fields, .. } = object_store_slot {
6720            assert_eq!(
6721                fields.get("api_port").and_then(|v| v.as_integer()),
6722                Some(9000)
6723            );
6724            assert_eq!(
6725                fields.get("console_port").and_then(|v| v.as_integer()),
6726                Some(9001)
6727            );
6728            assert_eq!(
6729                fields.get("bucket").and_then(|v| v.as_str()),
6730                Some("yah-dev")
6731            );
6732        } else {
6733            panic!("expected Inline slot for minio-container object_store");
6734        }
6735    }
6736
6737    #[test]
6738    fn provider_miniflare_container_kind_round_trips() {
6739        // Inline-only kind; never declared as a standalone provider file but
6740        // the enum round-trip is still exercised through ProviderConfig because
6741        // schemars/serde share the variant table.
6742        let cfg = MirrorProviderSlot::Inline {
6743            kind: Provider::MiniflareContainer,
6744            fields: BTreeMap::new(),
6745        };
6746        let s = toml::to_string(&cfg).unwrap();
6747        assert!(
6748            s.contains("kind = \"miniflare-container\""),
6749            "kebab-case wire form expected, got: {s}"
6750        );
6751        let back: MirrorProviderSlot = toml::from_str(&s).unwrap();
6752        assert_eq!(back.inline_kind(), Some(Provider::MiniflareContainer));
6753    }
6754
6755    #[test]
6756    fn provider_minio_container_kind_round_trips() {
6757        let cfg = MirrorProviderSlot::Inline {
6758            kind: Provider::MinioContainer,
6759            fields: BTreeMap::new(),
6760        };
6761        let s = toml::to_string(&cfg).unwrap();
6762        assert!(
6763            s.contains("kind = \"minio-container\""),
6764            "kebab-case wire form expected, got: {s}"
6765        );
6766        let back: MirrorProviderSlot = toml::from_str(&s).unwrap();
6767        assert_eq!(back.inline_kind(), Some(Provider::MinioContainer));
6768    }
6769
6770    #[test]
6771    fn mirror_compute_slot_with_machine_reference_parses() {
6772        // The on-disk prod.toml has a commented-out compute slot; this test
6773        // covers the form Phase B will need once yubaba is provisioned.
6774        let src = r#"
6775schema_version = 1
6776shape = "single-machine"
6777
6778[providers.compute]
6779use = "hetzner"
6780machine = "yah-cloud-1"
6781"#;
6782        let cfg: MirrorConfig = toml::from_str(src).unwrap();
6783        let slot = cfg.providers.get("compute").expect("compute slot");
6784        assert_eq!(slot.provider_id(), Some("hetzner"));
6785        if let MirrorProviderSlot::Reference { fields, .. } = slot {
6786            assert_eq!(
6787                fields.get("machine").and_then(|v| v.as_str()),
6788                Some("yah-cloud-1"),
6789            );
6790        }
6791    }
6792
6793    #[test]
6794    fn machine_yah_cloud_1_round_trips_with_existing_shape() {
6795        // The current machine TOML predates B2 — MachineConfig hasn't been
6796        // reshaped yet. This locks the expected shape so we notice if B3
6797        // accidentally regresses it.
6798        let src = r#"
6799name = "yah-cloud-1"
6800provider = "hetzner"
6801location = "pdx"
6802server_type = "cpx11"
6803hosts_mirrors = []
6804mesh_tags = ["tag:tier-scratch", "tag:primary-yah"]
6805ssh_keys = [111513970, 111525493]
6806"#;
6807        let cfg: MachineConfig = toml::from_str(src).unwrap();
6808        assert_eq!(cfg.name, "yah-cloud-1");
6809        assert_eq!(cfg.provider, "hetzner");
6810        assert_eq!(cfg.ssh_keys.len(), 2);
6811    }
6812
6813    #[test]
6814    fn static_node_omits_location_server_type_and_carries_connect() {
6815        // BYO Phase-0: a `static` node we brought up over SSH has no provider
6816        // DC code or SKU; it declares reach in `[connect]` instead. Must load.
6817        let src = r#"
6818name = "us-south-001"
6819provider = "static"
6820region = "us-south"
6821mesh_tags = ["tag:cloud-runner", "tag:voter-candidate"]
6822
6823[connect]
6824address = "45.32.194.254"
6825ssh = "root@45.32.194.254"
6826yubaba = "http://127.0.0.1:7443"
6827arch = "x86_64"
6828"#;
6829        let cfg: MachineConfig = toml::from_str(src).unwrap();
6830        assert_eq!(cfg.provider, "static");
6831        assert!(cfg.location.is_none());
6832        assert!(cfg.server_type.is_none());
6833        assert_eq!(cfg.location(), ""); // accessor defaults empty
6834        let c = cfg.connect.as_ref().expect("connect block");
6835        assert_eq!(c.ssh, "root@45.32.194.254");
6836        // Loopback is a *declared* reach placeholder, so it stays in [connect]
6837        // verbatim and composes straight through (R707-T1).
6838        assert_eq!(c.yubaba.as_deref(), Some("http://127.0.0.1:7443"));
6839        assert_eq!(cfg.yubaba_url().as_deref(), Some("http://127.0.0.1:7443"));
6840        assert_eq!(cfg.mesh_ipv4(), None);
6841        // Static providers have no driver, so validate() is a no-op pass.
6842        assert!(!provider_has_machine_driver(&cfg.provider));
6843        cfg.validate().unwrap();
6844    }
6845
6846    // ─── R707-T1: declaration / registration split ──────────────────────────
6847
6848    /// The pre-split shape — top-level `hostkey_fingerprint`, mesh IP baked
6849    /// into `[connect].yubaba` — must keep parsing, and must read back through
6850    /// the accessors identically. Every machine TOML in the fleet was written
6851    /// this way, and other camps' inventories still are.
6852    #[test]
6853    fn legacy_shape_still_parses_and_reads_through_accessors() {
6854        let src = r#"
6855name = "us-west-001"
6856provider = "static"
6857region = "us-west"
6858arch = "x86_64"
6859mesh_tags = ["tag:cloud-runner"]
6860hostkey_fingerprint = "SHA256:dmpq"
6861
6862[connect]
6863address = "15.204.89.240"
6864ssh = "debian@15.204.89.240"
6865yubaba = "http://100.64.0.1:7443"
6866"#;
6867        let cfg: MachineConfig = toml::from_str(src).unwrap();
6868        assert_eq!(cfg.hostkey_fingerprint(), Some("SHA256:dmpq"));
6869        assert_eq!(cfg.mesh_ipv4(), Some("100.64.0.1"));
6870        assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.1:7443"));
6871    }
6872
6873    /// The post-split shape reads identically to the legacy one above — same
6874    /// three accessor answers from a file that separates the two halves. This
6875    /// is the "unchanged in meaning" guarantee the fleet migration rests on.
6876    #[test]
6877    fn split_shape_is_equivalent_to_legacy_shape() {
6878        let legacy = r#"
6879name = "m"
6880provider = "static"
6881mesh_tags = []
6882hostkey_fingerprint = "SHA256:dmpq"
6883
6884[connect]
6885address = "15.204.89.240"
6886ssh = "debian@15.204.89.240"
6887yubaba = "http://100.64.0.1:7443"
6888"#;
6889        let split = r#"
6890name = "m"
6891provider = "static"
6892mesh_tags = []
6893
6894[connect]
6895address = "15.204.89.240"
6896ssh = "debian@15.204.89.240"
6897
6898[registration]
6899hostkey_fingerprint = "SHA256:dmpq"
6900mesh_ipv4 = "100.64.0.1"
6901"#;
6902        let old: MachineConfig = toml::from_str(legacy).unwrap();
6903        let new: MachineConfig = toml::from_str(split).unwrap();
6904        assert_eq!(old.hostkey_fingerprint(), new.hostkey_fingerprint());
6905        assert_eq!(old.mesh_ipv4(), new.mesh_ipv4());
6906        assert_eq!(old.yubaba_url(), new.yubaba_url());
6907    }
6908
6909    /// A non-default `[connect].yubaba_port` is declared reach and composes
6910    /// with the observed mesh address rather than being pinned into a URL.
6911    #[test]
6912    fn declared_port_composes_with_observed_mesh_address() {
6913        let src = r#"
6914name = "m"
6915provider = "static"
6916mesh_tags = []
6917
6918[connect]
6919address = "10.0.0.1"
6920ssh = "yah@10.0.0.1"
6921yubaba_port = 9443
6922
6923[registration]
6924mesh_ipv4 = "100.64.0.9"
6925"#;
6926        let cfg: MachineConfig = toml::from_str(src).unwrap();
6927        assert_eq!(cfg.connect.as_ref().unwrap().yubaba_port(), 9443);
6928        assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.9:9443"));
6929    }
6930
6931    /// R605-T10 inverts R707-T6 for the private-literal case, and this is the
6932    /// node it was inverted for: us-west-014's shape, mesh-joined AND declaring
6933    /// a LAN `[connect].yubaba`. R707-T6 made the literal win outright so
6934    /// `rollout::yubaba::membership_to_nodes` could match the dev group's
6935    /// LAN-addressed raft membership — which fused identity into reach and made
6936    /// every automated dial go to an address only bldg-2506 can route.
6937    /// `lan_endpoint()` now serves that match, so the mesh address wins the
6938    /// dial and the literal is inert.
6939    #[test]
6940    fn a_private_literal_loses_to_the_registered_mesh_address() {
6941        let src = r#"
6942name = "us-west-014"
6943provider = "static"
6944mesh_tags = []
6945
6946[connect]
6947address = "192.168.10.14"
6948ssh = "yah@192.168.10.14"
6949yubaba = "http://192.168.10.14:7443"
6950
6951[registration]
6952mesh_ipv4 = "100.64.0.6"
6953"#;
6954        let cfg: MachineConfig = toml::from_str(src).unwrap();
6955        assert_eq!(cfg.mesh_ipv4(), Some("100.64.0.6"), "still mesh-joined");
6956        assert_eq!(
6957            cfg.yubaba_url().as_deref(),
6958            Some("http://100.64.0.6:7443"),
6959            "automation dials the mesh, never the LAN literal"
6960        );
6961        assert_eq!(
6962            cfg.lan_endpoint().as_deref(),
6963            Some("192.168.10.14:7443"),
6964            "the LAN address is still recorded — as identity, not as reach"
6965        );
6966    }
6967
6968    /// The refusal R605-T10 asks for: a node whose ONLY declared reach is a LAN
6969    /// literal is unresolvable, and says so by name rather than returning a URL
6970    /// that will time out. us-west-011's shape before this ticket.
6971    #[test]
6972    fn a_lan_only_node_refuses_with_a_named_reason() {
6973        let src = r#"
6974name = "us-west-011"
6975provider = "static"
6976mesh_tags = []
6977
6978[connect]
6979address = "192.168.10.11"
6980ssh = "yah@192.168.10.11"
6981yubaba = "http://192.168.10.11:7443"
6982"#;
6983        let cfg: MachineConfig = toml::from_str(src).unwrap();
6984        assert_eq!(cfg.yubaba_url(), None);
6985        let err = cfg.reach().unwrap_err();
6986        assert!(err.contains("us-west-011"), "{err}");
6987        assert!(err.contains("192.168.10.11"), "{err}");
6988        assert!(err.contains("mesh_ipv4"), "{err}");
6989    }
6990
6991    /// The loopback placeholder is a genuine declaration ("reach me through the
6992    /// SSH tunnel"), not a LAN literal — 127/8 is not RFC1918. It must keep
6993    /// resolving verbatim; `hub::coordinator::is_loopback_url` is what judges it
6994    /// downstream.
6995    #[test]
6996    fn a_loopback_placeholder_still_resolves_verbatim() {
6997        let src = r#"
6998name = "m"
6999provider = "static"
7000mesh_tags = []
7001
7002[connect]
7003address = "192.168.10.99"
7004ssh = "yah@192.168.10.99"
7005yubaba = "http://127.0.0.1:7443"
7006"#;
7007        let cfg: MachineConfig = toml::from_str(src).unwrap();
7008        assert_eq!(cfg.yubaba_url().as_deref(), Some("http://127.0.0.1:7443"));
7009    }
7010
7011    #[test]
7012    fn private_ranges_are_exactly_rfc1918() {
7013        for lan in [
7014            "http://192.168.10.11:7443",
7015            "http://10.0.0.5:7443",
7016            "http://172.16.4.1:7443",
7017        ] {
7018            assert!(private_ipv4_from_url(lan).is_some(), "{lan}");
7019        }
7020        for not_lan in [
7021            "http://100.64.0.6:7443",  // mesh
7022            "http://127.0.0.1:7443",   // loopback
7023            "http://172.32.0.1:7443",  // just past 172.16/12
7024            "http://45.32.194.254:80", // public
7025            "http://us-west-001:7443", // name, not a literal
7026        ] {
7027            assert!(private_ipv4_from_url(not_lan).is_none(), "{not_lan}");
7028        }
7029    }
7030
7031    /// `normalize` migrates in place: the legacy fingerprint moves into
7032    /// `[registration]`, the mesh IP is lifted out of the URL, and the derived
7033    /// `[connect].yubaba` is cleared so the two halves cannot drift.
7034    #[test]
7035    fn normalize_migrates_legacy_fields_and_is_idempotent() {
7036        let src = r#"
7037name = "m"
7038provider = "static"
7039mesh_tags = []
7040hostkey_fingerprint = "SHA256:dmpq"
7041
7042[connect]
7043address = "15.204.89.240"
7044ssh = "debian@15.204.89.240"
7045yubaba = "http://100.64.0.1:7443"
7046"#;
7047        let mut cfg: MachineConfig = toml::from_str(src).unwrap();
7048        cfg.normalize();
7049        assert!(cfg.legacy_hostkey_fingerprint.is_none());
7050        assert_eq!(
7051            cfg.registration.hostkey_fingerprint.as_deref(),
7052            Some("SHA256:dmpq")
7053        );
7054        assert_eq!(cfg.registration.mesh_ipv4.as_deref(), Some("100.64.0.1"));
7055        assert!(cfg.connect.as_ref().unwrap().yubaba.is_none());
7056        // Accessors still answer the same, and re-running changes nothing.
7057        assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.1:7443"));
7058        let once = format!("{cfg:?}");
7059        cfg.normalize();
7060        assert_eq!(once, format!("{cfg:?}"));
7061    }
7062
7063    /// A loopback `[connect].yubaba` is a declaration ("no mesh address yet —
7064    /// reach me through the SSH tunnel"), not a stale observation, so
7065    /// `normalize` must leave it alone. us-west-003/011/013 depend on this.
7066    #[test]
7067    fn normalize_leaves_pre_mesh_loopback_declaration_intact() {
7068        let src = r#"
7069name = "m"
7070provider = "static"
7071mesh_tags = []
7072
7073[connect]
7074address = "192.168.10.11"
7075ssh = "yah@192.168.10.11"
7076yubaba = "http://127.0.0.1:7443"
7077"#;
7078        let mut cfg: MachineConfig = toml::from_str(src).unwrap();
7079        cfg.normalize();
7080        assert_eq!(
7081            cfg.connect.as_ref().unwrap().yubaba.as_deref(),
7082            Some("http://127.0.0.1:7443")
7083        );
7084        assert!(cfg.registration.is_empty());
7085        assert_eq!(cfg.mesh_ipv4(), None);
7086    }
7087
7088    /// `save` normalizes, so a legacy file that round-trips through the writer
7089    /// comes back on the split shape with nothing lost — the property that
7090    /// keeps `yah cloud machine attach` from re-emitting the old layout.
7091    #[test]
7092    fn save_writes_the_split_shape_from_a_legacy_config() {
7093        let tmp = tempfile::TempDir::new().unwrap();
7094        let root = tmp.path();
7095        let src = r#"
7096name = "m"
7097provider = "static"
7098mesh_tags = []
7099hostkey_fingerprint = "SHA256:dmpq"
7100
7101[connect]
7102address = "15.204.89.240"
7103ssh = "debian@15.204.89.240"
7104yubaba = "http://100.64.0.1:7443"
7105"#;
7106        let cfg: MachineConfig = toml::from_str(src).unwrap();
7107        cfg.save(root).unwrap();
7108
7109        let written = std::fs::read_to_string(root.join("machines/m.toml")).unwrap();
7110        let reg_at = written
7111            .find("[registration]")
7112            .unwrap_or_else(|| panic!("no [registration] table: {written}"));
7113        let fp_at = written
7114            .find("hostkey_fingerprint")
7115            .unwrap_or_else(|| panic!("fingerprint dropped: {written}"));
7116        assert!(
7117            fp_at > reg_at,
7118            "legacy top-level field must not be re-emitted: {written}"
7119        );
7120        assert!(
7121            !written.contains("yubaba ="),
7122            "derived URL must not be re-emitted alongside mesh_ipv4: {written}"
7123        );
7124
7125        let reloaded: MachineConfig = toml::from_str(&written).unwrap();
7126        assert_eq!(reloaded.hostkey_fingerprint(), Some("SHA256:dmpq"));
7127        assert_eq!(
7128            reloaded.yubaba_url().as_deref(),
7129            Some("http://100.64.0.1:7443")
7130        );
7131    }
7132
7133    /// `[registration]` is omitted entirely for a machine nothing has been
7134    /// observed about — a scaffolded declaration stays clean.
7135    #[test]
7136    fn empty_registration_is_omitted_on_serialize() {
7137        let src = r#"
7138name = "m"
7139provider = "static"
7140mesh_tags = []
7141"#;
7142        let cfg: MachineConfig = toml::from_str(src).unwrap();
7143        assert!(cfg.registration.is_empty());
7144        let out = toml::to_string_pretty(&cfg).unwrap();
7145        assert!(!out.contains("[registration]"), "{out}");
7146    }
7147
7148    #[test]
7149    fn driver_provider_without_location_fails_validate() {
7150        // A driver-backed provider (hetzner/vultr) still MUST carry location +
7151        // server_type — the driver can't create a server without them. The
7152        // contract moved from load-time (required field) to provision-time
7153        // (validate), so the TOML loads but validate() rejects it.
7154        let src = r#"
7155name = "us-west-001"
7156provider = "hetzner"
7157mesh_tags = []
7158"#;
7159        let cfg: MachineConfig = toml::from_str(src).unwrap();
7160        assert!(provider_has_machine_driver(&cfg.provider));
7161        let err = cfg.validate().unwrap_err().to_string();
7162        assert!(
7163            err.contains("location"),
7164            "expected location complaint: {err}"
7165        );
7166    }
7167
7168    /// Helper for the new-tree integration tests below: lay out
7169    /// `<workspace>/.yah/{infra,services}/` with `dev-yah` + its mirrors and
7170    /// the three Phase-A providers (cloudflare, hetzner, orbstack).
7171    fn make_new_tree_with_dev_yah(root: &std::path::Path) {
7172        let infra = root.join(".yah").join("infra");
7173        let providers = infra.join("providers");
7174        std::fs::create_dir_all(&providers).unwrap();
7175        std::fs::write(
7176            providers.join("cloudflare.toml"),
7177            r#"schema_version = 1
7178id = "cloudflare"
7179kind = "cloudflare"
7180credentials = "keystore://cloudflare/yah"
7181default_zone = "yah.dev"
7182"#,
7183        )
7184        .unwrap();
7185        std::fs::write(
7186            providers.join("hetzner.toml"),
7187            r#"schema_version = 1
7188id = "hetzner"
7189kind = "hetzner"
7190credentials = "keystore://hetzner/yah"
7191default_location = "pdx"
7192default_server_type = "cpx11"
7193ssh_keys = []
7194"#,
7195        )
7196        .unwrap();
7197        std::fs::write(
7198            providers.join("orbstack.toml"),
7199            r#"schema_version = 1
7200id = "orbstack"
7201kind = "local-container"
7202runtime = "auto"
7203
7204[discovery]
7205orbstack = "~/.orbstack/run/docker.sock"
7206"#,
7207        )
7208        .unwrap();
7209
7210        let svc = root.join(".yah").join("services").join("dev-yah");
7211        std::fs::create_dir_all(svc.join("mirrors")).unwrap();
7212        std::fs::write(
7213            svc.join("service.toml"),
7214            r#"schema_version = 1
7215name = "dev-yah"
7216domain = "yah.dev"
7217
7218[[components]]
7219id = "site"
7220kind = "mesofact-static"
7221path = "app/yah/web"
7222role = "static"
7223"#,
7224        )
7225        .unwrap();
7226        std::fs::write(
7227            svc.join("mirrors/prod.toml"),
7228            r#"schema_version = 1
7229shape = "single-machine"
7230
7231[providers.static]
7232use = "cloudflare"
7233bucket = "yah-dev"
7234zone = "yah.dev"
7235"#,
7236        )
7237        .unwrap();
7238        std::fs::write(
7239            svc.join("mirrors/local.toml"),
7240            r#"schema_version = 1
7241shape = "local"
7242
7243[providers.static]
7244kind = "local-static"
7245port = 4321
7246
7247[providers.compute]
7248use = "orbstack"
7249"#,
7250        )
7251        .unwrap();
7252    }
7253
7254    #[test]
7255    fn cloud_config_load_new_tree_populates_providers_and_services() {
7256        let tmp = tempfile::TempDir::new().unwrap();
7257        let root = tmp.path();
7258        make_new_tree_with_dev_yah(root);
7259
7260        let cfg = CloudConfig::load(root).unwrap();
7261        assert_eq!(cfg.providers.len(), 3, "three providers loaded");
7262        assert!(cfg.provider("cloudflare").is_some());
7263        assert!(cfg.provider("hetzner").is_some());
7264        assert!(cfg.provider("orbstack").is_some());
7265
7266        let dev = cfg.service("dev-yah").expect("dev-yah service");
7267        assert_eq!(dev.service.domain, "yah.dev");
7268        assert_eq!(dev.service.components.len(), 1);
7269        assert_eq!(dev.mirrors.len(), 2);
7270        // Legacy file stems "prod" and "local" are normalised to canonical tier names.
7271        assert!(dev.mirrors.contains_key("cloud"), "prod.toml → cloud tier");
7272        assert!(dev.mirrors.contains_key("dev"), "local.toml → dev tier");
7273        assert_eq!(dev.mirrors["cloud"].shape, MirrorShape::SingleMachine);
7274        assert_eq!(dev.mirrors["dev"].shape, MirrorShape::Local);
7275
7276        // Legacy fields stay empty when no .yah/cloud/ exists.
7277        assert!(cfg.legacy_mirrors.is_empty());
7278        assert!(cfg.legacy_services.is_empty());
7279        assert!(cfg.workloads.is_empty());
7280    }
7281
7282    #[test]
7283    fn cloud_config_cross_ref_fails_on_missing_provider() {
7284        // Mirror references a provider id that doesn't exist.
7285        let tmp = tempfile::TempDir::new().unwrap();
7286        let root = tmp.path();
7287        let svc = root.join(".yah").join("services").join("dev-yah");
7288        std::fs::create_dir_all(svc.join("mirrors")).unwrap();
7289        std::fs::write(
7290            svc.join("service.toml"),
7291            "schema_version = 1\nname = \"dev-yah\"\ndomain = \"yah.dev\"\n",
7292        )
7293        .unwrap();
7294        std::fs::write(
7295            svc.join("mirrors/prod.toml"),
7296            "schema_version = 1\nshape = \"single-machine\"\n\n[providers.static]\nuse = \"fly-io\"\n",
7297        ).unwrap();
7298
7299        let err = CloudConfig::load(root).unwrap_err();
7300        let msg = err.to_string();
7301        assert!(
7302            msg.contains("fly-io"),
7303            "error should name the missing provider id, got: {msg}"
7304        );
7305        assert!(
7306            msg.contains("providers/fly-io.toml") || msg.contains("no such provider"),
7307            "error should hint at remedy, got: {msg}"
7308        );
7309    }
7310
7311    #[test]
7312    fn cloud_config_cross_ref_fails_on_missing_provider_named_by_an_ingress_edge() {
7313        // R845: the edge's own `use` is a provider reference like any other, so
7314        // a typo has to fail here rather than at the Cloudflare arm of apply.
7315        let tmp = tempfile::TempDir::new().unwrap();
7316        let root = tmp.path();
7317        let svc = root.join(".yah").join("services").join("dev-yah");
7318        std::fs::create_dir_all(svc.join("mirrors")).unwrap();
7319        std::fs::write(
7320            svc.join("service.toml"),
7321            "schema_version = 1\nname = \"dev-yah\"\ndomain = \"yah.dev\"\n",
7322        )
7323        .unwrap();
7324        std::fs::write(
7325            svc.join("mirrors/prod.toml"),
7326            "schema_version = 1\nshape = \"single-machine\"\n\n\
7327             [providers.compute]\nkind = \"static\"\nmachine = \"borrowed-01\"\n\
7328             zone = \"a.yah.dev\"\nport = 8080\n\n\
7329             [[ingress]]\nprovider = \"cloudflare-tunnel\"\nuse = \"cloudflar\"\n",
7330        )
7331        .unwrap();
7332
7333        let msg = CloudConfig::load(root).unwrap_err().to_string();
7334        assert!(
7335            msg.contains("ingress[0].use") && msg.contains("cloudflar"),
7336            "error should name the edge and the typo'd id, got: {msg}"
7337        );
7338    }
7339
7340    #[test]
7341    fn cloud_config_cross_ref_passes_on_inline_only_mirror() {
7342        // Inline `kind = "local-static"` doesn't require an infra provider.
7343        let tmp = tempfile::TempDir::new().unwrap();
7344        let root = tmp.path();
7345        let svc = root.join(".yah").join("services").join("local-only");
7346        std::fs::create_dir_all(svc.join("mirrors")).unwrap();
7347        std::fs::write(
7348            svc.join("service.toml"),
7349            "schema_version = 1\nname = \"local-only\"\ndomain = \"local.test\"\n",
7350        )
7351        .unwrap();
7352        std::fs::write(
7353            svc.join("mirrors/local.toml"),
7354            "schema_version = 1\nshape = \"local\"\n\n[providers.static]\nkind = \"local-static\"\nport = 8080\n",
7355        ).unwrap();
7356
7357        // Should load fine: no `use=` references, no providers required.
7358        let cfg = CloudConfig::load(root).unwrap();
7359        assert!(cfg.service("local-only").is_some());
7360    }
7361
7362    fn mirror(src: &str) -> MirrorConfig {
7363        toml::from_str(&format!("schema_version = 1\nshape = \"single-machine\"\n{src}"))
7364            .expect("parse mirror")
7365    }
7366
7367    #[test]
7368    fn passway_machines_reads_both_ingress_spellings_the_same_way() {
7369        // The whole reason this is derived in Rust rather than read off a field
7370        // by the UI: these two mirrors say the identical thing, and a consumer
7371        // that reaches for `ingress_machines` sees the second one as empty.
7372        let scalar = mirror("ingress = \"passway\"\ningress_machines = [\"us-east-001\"]\n");
7373        let edges = mirror(
7374            "[[ingress]]\nprovider = \"passway\"\nmachines = [\"us-east-001\"]\n",
7375        );
7376        assert_eq!(scalar.passway_machines(), Some(vec!["us-east-001".into()]));
7377        assert_eq!(scalar.passway_machines(), edges.passway_machines());
7378    }
7379
7380    #[test]
7381    fn passway_machines_skips_a_cloudflare_tunnel_edge() {
7382        // A cloudflared node publishes through Cloudflare's DNS and does not
7383        // serve `GET /domains/{d}/onboarding`, so naming it here would point
7384        // the custom-domain UI at a node that cannot answer.
7385        let cf_only =
7386            mirror("[[ingress]]\nprovider = \"cloudflare-tunnel\"\nmachines = [\"cf-01\"]\n");
7387        assert_eq!(cf_only.passway_machines(), None);
7388
7389        let mixed = mirror(
7390            "[[ingress]]\nprovider = \"cloudflare-tunnel\"\nmachines = [\"cf-01\"]\n\
7391             slots = [\"static\"]\n\n\
7392             [[ingress]]\nprovider = \"passway\"\nmachines = [\"us-east-001\"]\n\
7393             slots = [\"bundle\"]\n",
7394        );
7395        assert_eq!(mixed.passway_machines(), Some(vec!["us-east-001".into()]));
7396    }
7397
7398    #[test]
7399    fn passway_machines_separates_declared_but_unplaced_from_undeclared() {
7400        // Some(vec![]) means "a passway front door exists, but its placement
7401        // falls back to the fronted slot's and is not knowable from the mirror".
7402        // None means there is no passway front door at all. Collapsing the two
7403        // would make a co-located edge indistinguishable from no edge.
7404        assert_eq!(mirror("ingress = \"passway\"\n").passway_machines(), Some(vec![]));
7405        assert_eq!(mirror("").passway_machines(), None);
7406        assert_eq!(mirror("ingress = \"none\"\n").passway_machines(), None);
7407    }
7408
7409    #[test]
7410    fn passway_machines_is_none_for_a_declaration_that_cannot_mean_anything() {
7411        // `ingress_machines` with no `ingress` is an error `ingress_edges` names
7412        // properly; swallowing it to None here is deliberate, because this is
7413        // read while loading every service in the workspace and hard-failing
7414        // would report an unrelated mirror's shape error from the wrong place.
7415        let orphaned = mirror("ingress_machines = [\"us-east-001\"]\n");
7416        assert!(orphaned.ingress_edges().is_err());
7417        assert_eq!(orphaned.passway_machines(), None);
7418    }
7419
7420    #[test]
7421    fn cloud_config_load_derives_passway_machines_only_for_passway_envs() {
7422        let tmp = tempfile::TempDir::new().unwrap();
7423        let root = tmp.path();
7424        let svc = root.join(".yah").join("services").join("dev-yah");
7425        std::fs::create_dir_all(svc.join("mirrors")).unwrap();
7426        std::fs::write(
7427            svc.join("service.toml"),
7428            "schema_version = 1\nname = \"dev-yah\"\ndomain = \"yah.dev\"\n",
7429        )
7430        .unwrap();
7431        std::fs::write(
7432            svc.join("mirrors/cloud.toml"),
7433            "schema_version = 1\nshape = \"single-machine\"\n\
7434             ingress = \"passway\"\ningress_machines = [\"us-east-001\", \"us-west-001\"]\n\n\
7435             [providers.static]\nkind = \"local-static\"\nport = 8080\n",
7436        )
7437        .unwrap();
7438        std::fs::write(
7439            svc.join("mirrors/local.toml"),
7440            "schema_version = 1\nshape = \"local\"\n\n\
7441             [providers.static]\nkind = \"local-static\"\nport = 8080\n",
7442        )
7443        .unwrap();
7444
7445        let cfg = CloudConfig::load(root).unwrap();
7446        let svc = cfg.service("dev-yah").unwrap();
7447        assert_eq!(
7448            svc.passway_machines.get("cloud"),
7449            Some(&vec!["us-east-001".to_string(), "us-west-001".to_string()])
7450        );
7451        assert!(
7452            !svc.passway_machines.contains_key("local"),
7453            "an env with no front door must be absent, not empty: {:?}",
7454            svc.passway_machines
7455        );
7456    }
7457
7458    #[test]
7459    fn cloud_config_load_coexists_legacy_and_new_trees() {
7460        // Both trees present — both fields populated independently.
7461        let tmp = tempfile::TempDir::new().unwrap();
7462        let root = tmp.path();
7463        make_new_tree_with_dev_yah(root);
7464
7465        let cloud_dir = make_legacy_cloud_dir(root);
7466        std::fs::create_dir_all(cloud_dir.join("mirrors")).unwrap();
7467        std::fs::write(
7468            cloud_dir.join("mirrors/noisetable.toml"),
7469            "camp = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = []\n",
7470        )
7471        .unwrap();
7472
7473        let cfg = CloudConfig::load(root).unwrap();
7474        assert_eq!(cfg.providers.len(), 3);
7475        assert!(cfg.service("dev-yah").is_some());
7476        assert_eq!(cfg.legacy_mirrors.len(), 1);
7477        assert!(cfg.legacy_mirror("noisetable").is_some());
7478    }
7479
7480    #[test]
7481    fn web_workload_round_trips() {
7482        // app/yah/web/workload.toml is parsed as a WorkloadSpec via the
7483        // workload-spec crate. The minimum-viable manifest here exercises
7484        // schema_version + kind + build fields.
7485        //
7486        // The on-disk file uses the abbreviated v1 form (kind + build); the
7487        // full WorkloadSpec is verbose, so this test asserts the new
7488        // mesofact-static abbreviated form parses as raw TOML (B3 will plumb
7489        // it through WorkloadSpec proper).
7490        // `routes` above [build] — it is a top-level field, and TOML would
7491        // scope it into that table if written below the header (R658-B1).
7492        let src = r#"
7493schema_version = 1
7494kind = "mesofact-static"
7495
7496routes = "./routes.ts"
7497
7498[build]
7499command = "bun run build"
7500out_dir = "dist"
7501"#;
7502        let v: toml::Value = toml::from_str(src).unwrap();
7503        assert_eq!(
7504            v.get("schema_version").and_then(|x| x.as_integer()),
7505            Some(1)
7506        );
7507        assert_eq!(
7508            v.get("kind").and_then(|x| x.as_str()),
7509            Some("mesofact-static")
7510        );
7511        let build = v
7512            .get("build")
7513            .and_then(|x| x.as_table())
7514            .expect("build table");
7515        assert_eq!(
7516            build.get("command").and_then(|x| x.as_str()),
7517            Some("bun run build")
7518        );
7519        assert_eq!(build.get("out_dir").and_then(|x| x.as_str()), Some("dist"));
7520    }
7521
7522    // ─── Canonical CRUD: ServiceConfig/MirrorConfig save + delete (R323-F1) ──
7523
7524    #[test]
7525    fn service_config_save_creates_canonical_toml_and_round_trips() {
7526        let tmp = tempfile::TempDir::new().unwrap();
7527        let root = tmp.path();
7528
7529        let svc = ServiceConfig {
7530            schema_version: 1,
7531            name: "dev-yah".into(),
7532            domain: "yah.dev".into(),
7533            db: DbCatalog::default(),
7534            components: vec![ServiceComponent {
7535                mount: None,
7536                id: "site".into(),
7537                kind: "mesofact-static".into(),
7538                path: "app/yah/web".into(),
7539                role: "static".into(),
7540                publishes: Some("static".into()),
7541                wave: 0,
7542                git: None,
7543            }],
7544        };
7545        svc.save(root).unwrap();
7546
7547        // Landed at the canonical path.
7548        let path = crate::paths::service_toml(root, "dev-yah");
7549        assert!(
7550            path.exists(),
7551            "service.toml should exist at {}",
7552            path.display()
7553        );
7554
7555        // Reloads through the full CloudConfig loader (no mirrors yet).
7556        let cfg = CloudConfig::load(root).unwrap();
7557        let loaded = cfg.service("dev-yah").expect("dev-yah service");
7558        assert_eq!(loaded.service.domain, "yah.dev");
7559        assert_eq!(loaded.service.components.len(), 1);
7560        assert_eq!(
7561            loaded.service.components[0].publishes.as_deref(),
7562            Some("static")
7563        );
7564        assert!(loaded.mirrors.is_empty());
7565    }
7566
7567    #[test]
7568    fn service_config_save_overwrites_in_place() {
7569        let tmp = tempfile::TempDir::new().unwrap();
7570        let root = tmp.path();
7571
7572        let mut svc = ServiceConfig {
7573            schema_version: 1,
7574            name: "dev-yah".into(),
7575            domain: "yah.dev".into(),
7576            components: vec![],
7577            db: DbCatalog::default(),
7578        };
7579        svc.save(root).unwrap();
7580        svc.domain = "yah.example".into();
7581        svc.save(root).unwrap();
7582
7583        let cfg = CloudConfig::load(root).unwrap();
7584        assert_eq!(
7585            cfg.service("dev-yah").unwrap().service.domain,
7586            "yah.example"
7587        );
7588    }
7589
7590    #[test]
7591    fn mirror_config_save_round_trips_reference_and_inline_slots() {
7592        let tmp = tempfile::TempDir::new().unwrap();
7593        let root = tmp.path();
7594
7595        // A service must exist so the loader walks the mirrors/ dir.
7596        ServiceConfig {
7597            schema_version: 1,
7598            name: "dev-yah".into(),
7599            domain: "yah.dev".into(),
7600            components: vec![],
7601            db: DbCatalog::default(),
7602        }
7603        .save(root)
7604        .unwrap();
7605
7606        // The cloudflare provider the reference slot points at must resolve,
7607        // or CloudConfig::load's cross-ref check rejects the tree.
7608        let providers = crate::paths::providers_dir(root);
7609        std::fs::create_dir_all(&providers).unwrap();
7610        std::fs::write(
7611            providers.join("cloudflare.toml"),
7612            "schema_version = 1\nid = \"cloudflare\"\nkind = \"cloudflare\"\n",
7613        )
7614        .unwrap();
7615
7616        let mut providers_map = BTreeMap::new();
7617        providers_map.insert(
7618            "static".to_string(),
7619            MirrorProviderSlot::Reference {
7620                provider_id: "cloudflare".into(),
7621                fields: {
7622                    let mut f = BTreeMap::new();
7623                    f.insert("bucket".to_string(), toml::Value::String("yah-dev".into()));
7624                    f
7625                },
7626            },
7627        );
7628        providers_map.insert(
7629            "compute".to_string(),
7630            MirrorProviderSlot::Inline {
7631                kind: Provider::LocalStatic,
7632                fields: {
7633                    let mut f = BTreeMap::new();
7634                    f.insert("port".to_string(), toml::Value::Integer(4321));
7635                    f
7636                },
7637            },
7638        );
7639        let mirror = MirrorConfig {
7640            schema_version: 1,
7641            shape: MirrorShape::SingleMachine,
7642            providers: providers_map,
7643            ingress: Default::default(),
7644            ingress_machines: Vec::new(),
7645            drivers: Default::default(),
7646            asset_aliases: Default::default(),
7647        };
7648        // Save with canonical name; legacy "prod" is normalised to "cloud" on load.
7649        mirror.save(root, "dev-yah", "cloud").unwrap();
7650
7651        let path = crate::paths::service_mirror_toml(root, "dev-yah", "cloud");
7652        assert!(
7653            path.exists(),
7654            "mirror toml should exist at {}",
7655            path.display()
7656        );
7657
7658        let cfg = CloudConfig::load(root).unwrap();
7659        let loaded = &cfg.service("dev-yah").unwrap().mirrors["cloud"];
7660        assert_eq!(loaded.shape, MirrorShape::SingleMachine);
7661        assert_eq!(loaded.providers["static"].provider_id(), Some("cloudflare"));
7662        assert_eq!(
7663            loaded.providers["compute"].inline_kind(),
7664            Some(Provider::LocalStatic)
7665        );
7666    }
7667
7668    #[test]
7669    fn service_delete_removes_dir_and_mirrors() {
7670        let tmp = tempfile::TempDir::new().unwrap();
7671        let root = tmp.path();
7672
7673        let svc = ServiceConfig {
7674            schema_version: 1,
7675            name: "dev-yah".into(),
7676            domain: "yah.dev".into(),
7677            components: vec![],
7678            db: DbCatalog::default(),
7679        };
7680        svc.save(root).unwrap();
7681        MirrorConfig {
7682            schema_version: 1,
7683            shape: MirrorShape::Local,
7684            providers: BTreeMap::new(),
7685            ingress: Default::default(),
7686            ingress_machines: Vec::new(),
7687            drivers: Default::default(),
7688            asset_aliases: Default::default(),
7689        }
7690        .save(root, "dev-yah", "local")
7691        .unwrap();
7692
7693        assert!(
7694            ServiceConfig::delete(root, "dev-yah").unwrap(),
7695            "first delete reports true"
7696        );
7697        assert!(!crate::paths::service_dir(root, "dev-yah").exists());
7698        // Idempotent: deleting again is a no-op that reports false.
7699        assert!(!ServiceConfig::delete(root, "dev-yah").unwrap());
7700
7701        let cfg = CloudConfig::load(root).unwrap();
7702        assert!(cfg.service("dev-yah").is_none());
7703    }
7704
7705    #[test]
7706    fn mirror_delete_leaves_other_mirrors_and_service_intact() {
7707        let tmp = tempfile::TempDir::new().unwrap();
7708        let root = tmp.path();
7709
7710        ServiceConfig {
7711            schema_version: 1,
7712            name: "dev-yah".into(),
7713            domain: "yah.dev".into(),
7714            components: vec![],
7715            db: DbCatalog::default(),
7716        }
7717        .save(root)
7718        .unwrap();
7719        for env in ["prod", "local"] {
7720            MirrorConfig {
7721                schema_version: 1,
7722                shape: MirrorShape::Local,
7723                providers: BTreeMap::new(),
7724                ingress: Default::default(),
7725                ingress_machines: Vec::new(),
7726                drivers: Default::default(),
7727                asset_aliases: Default::default(),
7728            }
7729            .save(root, "dev-yah", env)
7730            .unwrap();
7731        }
7732
7733        assert!(MirrorConfig::delete(root, "dev-yah", "prod").unwrap());
7734        assert!(!MirrorConfig::delete(root, "dev-yah", "prod").unwrap());
7735
7736        let cfg = CloudConfig::load(root).unwrap();
7737        let svc = cfg
7738            .service("dev-yah")
7739            .expect("service survives mirror delete");
7740        // Legacy file stems are normalised on load: "prod" → "cloud", "local" → "dev".
7741        assert!(!svc.mirrors.contains_key("cloud"));
7742        assert!(svc.mirrors.contains_key("dev"));
7743    }
7744
7745    // ─── DomainConfig (R347-F2) ────────────────────────────────────────────
7746
7747    fn write_marketing_service(root: &Path) {
7748        let svc = ServiceConfig {
7749            schema_version: 1,
7750            name: "yah-marketing".into(),
7751            domain: "yah.dev".into(),
7752            db: DbCatalog::default(),
7753            components: vec![ServiceComponent {
7754                mount: None,
7755                id: "site".into(),
7756                kind: "mesofact-static".into(),
7757                path: "app/yah/web".into(),
7758                role: "static".into(),
7759                publishes: None,
7760                wave: 0,
7761                git: None,
7762            }],
7763        };
7764        svc.save(root).unwrap();
7765    }
7766
7767    #[test]
7768    fn round_trip_domain_with_each_route_mode() {
7769        let dom = DomainConfig {
7770            schema_version: 1,
7771            name: "yah-dev".into(),
7772            domain: "yah.dev".into(),
7773            front_door: FrontDoor::Worker,
7774            cdn_bucket: "yah-dev".into(),
7775            worker_bundle_path: Some(".yah/workers/yah-dev/".into()),
7776            routes: vec![
7777                DomainRoute {
7778                    headers: Default::default(),
7779                    path: "/".into(),
7780                    mode: RouteMode::Static {
7781                        component: "yah-marketing/site".into(),
7782                    },
7783                },
7784                DomainRoute {
7785                    headers: Default::default(),
7786                    path: "/dashboard/api/*".into(),
7787                    mode: RouteMode::Backend {
7788                        component: "yah-dashboard/api".into(),
7789                        origin: "https://api.dashboard.yah.dev".into(),
7790                    },
7791                },
7792                DomainRoute {
7793                    headers: Default::default(),
7794                    path: "/old".into(),
7795                    mode: RouteMode::Redirect {
7796                        target: "https://yah.dev/blog".into(),
7797                        status: 308,
7798                    },
7799                },
7800            ],
7801        };
7802        let s = toml::to_string(&dom).unwrap();
7803        let back: DomainConfig = toml::from_str(&s).unwrap();
7804        assert_eq!(back.name, "yah-dev");
7805        assert_eq!(back.routes.len(), 3);
7806        assert!(matches!(back.routes[0].mode, RouteMode::Static { .. }));
7807        assert!(matches!(back.routes[1].mode, RouteMode::Backend { .. }));
7808        assert!(matches!(back.routes[2].mode, RouteMode::Redirect { .. }));
7809    }
7810
7811    #[test]
7812    fn redirect_status_defaults_to_308() {
7813        let src = r#"
7814schema_version = 1
7815name = "yah-dev"
7816domain = "yah.dev"
7817front_door = "worker"
7818cdn_bucket = "yah-dev"
7819
7820[[routes]]
7821path = "/old"
7822mode = "redirect"
7823target = "https://yah.dev/blog"
7824"#;
7825        let dom: DomainConfig = toml::from_str(src).unwrap();
7826        let RouteMode::Redirect { status, .. } = &dom.routes[0].mode else {
7827            panic!("expected redirect");
7828        };
7829        assert_eq!(*status, 308);
7830    }
7831
7832    #[test]
7833    fn missing_domains_dir_is_empty() {
7834        let tmp = tempfile::TempDir::new().unwrap();
7835        // R844-B7: `.yah/` must exist or this is a wrong-root error rather
7836        // than an empty tree. The absent directory under test is `domains/`.
7837        std::fs::create_dir_all(tmp.path().join(".yah")).unwrap();
7838        let cfg = CloudConfig::load(tmp.path()).unwrap();
7839        assert!(cfg.domains.is_empty());
7840    }
7841
7842    #[test]
7843    fn save_reload_roundtrip() {
7844        let tmp = tempfile::TempDir::new().unwrap();
7845        let root = tmp.path();
7846        write_marketing_service(root);
7847
7848        let dom = DomainConfig {
7849            schema_version: 1,
7850            name: "yah-dev".into(),
7851            domain: "yah.dev".into(),
7852            front_door: FrontDoor::Worker,
7853            cdn_bucket: "yah-dev".into(),
7854            worker_bundle_path: None,
7855            routes: vec![DomainRoute {
7856                headers: Default::default(),
7857                path: "/".into(),
7858                mode: RouteMode::Static {
7859                    component: "yah-marketing/site".into(),
7860                },
7861            }],
7862        };
7863        dom.save(root).unwrap();
7864
7865        let cfg = CloudConfig::load(root).unwrap();
7866        let loaded = cfg.domain("yah-dev").expect("yah-dev domain");
7867        assert_eq!(loaded.domain, "yah.dev");
7868        assert_eq!(loaded.routes.len(), 1);
7869    }
7870
7871    #[test]
7872    fn delete_returns_false_when_absent() {
7873        let tmp = tempfile::TempDir::new().unwrap();
7874        assert!(!DomainConfig::delete(tmp.path(), "no-such-domain").unwrap());
7875    }
7876
7877    #[test]
7878    fn delete_returns_true_first_time() {
7879        let tmp = tempfile::TempDir::new().unwrap();
7880        let root = tmp.path();
7881        let dom = DomainConfig {
7882            schema_version: 1,
7883            name: "yah-dev".into(),
7884            domain: "yah.dev".into(),
7885            front_door: FrontDoor::BucketDirect,
7886            cdn_bucket: "yah-dev".into(),
7887            worker_bundle_path: None,
7888            routes: vec![],
7889        };
7890        dom.save(root).unwrap();
7891        assert!(DomainConfig::delete(root, "yah-dev").unwrap());
7892        assert!(!DomainConfig::delete(root, "yah-dev").unwrap());
7893    }
7894
7895    // ---- R594-F12: front-door discriminator ------------------------------
7896
7897    /// Write a raw domain manifest so the tests exercise the deserialize +
7898    /// validate path, not a hand-built struct that skipped serde.
7899    fn write_domain_toml(root: &Path, stem: &str, body: &str) {
7900        let dir = root.join(".yah").join("domains");
7901        std::fs::create_dir_all(&dir).unwrap();
7902        std::fs::write(dir.join(format!("{stem}.toml")), body).unwrap();
7903    }
7904
7905    #[test]
7906    fn front_door_is_required() {
7907        let tmp = tempfile::TempDir::new().unwrap();
7908        let root = tmp.path();
7909        write_marketing_service(root);
7910        write_domain_toml(
7911            root,
7912            "yah-dev",
7913            r#"
7914schema_version = 1
7915name = "yah-dev"
7916domain = "yah.dev"
7917cdn_bucket = "yah-dev"
7918[[routes]]
7919path = "/*"
7920mode = "static"
7921component = "yah-marketing/site"
7922"#,
7923        );
7924        let err = CloudConfig::load(root).unwrap_err().to_string();
7925        // serde's own missing-field message; the point is that omitting the
7926        // discriminator is not a silently-defaulted state.
7927        assert!(err.contains("yah-dev.toml"), "{err}");
7928    }
7929
7930    #[test]
7931    fn bucket_direct_with_routes_is_rejected() {
7932        let tmp = tempfile::TempDir::new().unwrap();
7933        let root = tmp.path();
7934        write_marketing_service(root);
7935        write_domain_toml(
7936            root,
7937            "cdn-yah-dev",
7938            r#"
7939schema_version = 1
7940name = "cdn-yah-dev"
7941domain = "cdn.yah.dev"
7942front_door = "bucket-direct"
7943cdn_bucket = "yah-dev"
7944[[routes]]
7945path = "/docs/*"
7946mode = "static"
7947component = "yah-marketing/site"
7948"#,
7949        );
7950        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7951        assert!(err.contains("front_door"), "{err}");
7952        assert!(err.contains("/docs/*"), "{err}");
7953    }
7954
7955    #[test]
7956    fn bucket_direct_with_worker_bundle_path_is_rejected() {
7957        let tmp = tempfile::TempDir::new().unwrap();
7958        let root = tmp.path();
7959        write_domain_toml(
7960            root,
7961            "cdn-yah-dev",
7962            r#"
7963schema_version = 1
7964name = "cdn-yah-dev"
7965domain = "cdn.yah.dev"
7966front_door = "bucket-direct"
7967cdn_bucket = "yah-dev"
7968worker_bundle_path = ".yah/workers/cdn-yah-dev/"
7969"#,
7970        );
7971        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7972        assert!(err.contains("worker_bundle_path"), "{err}");
7973    }
7974
7975    // ── R746: per-route response headers + component mounts ──────────────────
7976
7977    /// A two-component service: `site` at the root, `app` mounted at `/app`
7978    /// with isolation headers on its route. This is the noisetable.com shape
7979    /// the primitive was built for.
7980    fn write_two_component_service(root: &Path) {
7981        let svc = ServiceConfig {
7982            schema_version: 1,
7983            name: "yah-marketing".into(),
7984            domain: "yah.dev".into(),
7985            db: DbCatalog::default(),
7986            components: vec![
7987                ServiceComponent {
7988                    mount: None,
7989                    id: "site".into(),
7990                    kind: "mesofact-static".into(),
7991                    path: "app/yah/web".into(),
7992                    role: "static".into(),
7993                    publishes: None,
7994                    wave: 0,
7995                    git: None,
7996                },
7997                ServiceComponent {
7998                    mount: Some("/app".into()),
7999                    id: "app".into(),
8000                    kind: "mesofact-static".into(),
8001                    path: "app/browser".into(),
8002                    role: "static".into(),
8003                    publishes: None,
8004                    wave: 0,
8005                    git: None,
8006                },
8007            ],
8008        };
8009        svc.save(root).unwrap();
8010    }
8011
8012    const MOUNTED_DOMAIN: &str = r#"
8013schema_version = 1
8014name = "yah-dev"
8015domain = "yah.dev"
8016front_door = "worker"
8017cdn_bucket = "yah-dev"
8018
8019[[routes]]
8020path = "/app/*"
8021mode = "static"
8022component = "yah-marketing/app"
8023headers = { "Cross-Origin-Opener-Policy" = "same-origin", "Cross-Origin-Embedder-Policy" = "require-corp" }
8024
8025[[routes]]
8026path = "/*"
8027mode = "static"
8028component = "yah-marketing/site"
8029"#;
8030
8031    #[test]
8032    fn a_mounted_component_routed_at_its_mount_loads() {
8033        let tmp = tempfile::TempDir::new().unwrap();
8034        let root = tmp.path();
8035        write_two_component_service(root);
8036        write_domain_toml(root, "yah-dev", MOUNTED_DOMAIN);
8037        let cfg = CloudConfig::load(root).unwrap();
8038        let dom = cfg.domain("yah-dev").unwrap();
8039        assert_eq!(dom.routes.len(), 2);
8040        assert_eq!(
8041            dom.routes[0].headers.get("Cross-Origin-Opener-Policy").map(String::as_str),
8042            Some("same-origin")
8043        );
8044        assert!(dom.routes[1].headers.is_empty());
8045    }
8046
8047    /// The header table reaches the Worker in MANIFEST order with headerless
8048    /// routes dropped. Order is the whole contract — the front door applies the
8049    /// first match, so `/app/*` before `/*` is what isolates the app without
8050    /// isolating the marketing site.
8051    #[test]
8052    fn route_headers_json_preserves_order_and_drops_headerless_routes() {
8053        let tmp = tempfile::TempDir::new().unwrap();
8054        let root = tmp.path();
8055        write_two_component_service(root);
8056        write_domain_toml(root, "yah-dev", MOUNTED_DOMAIN);
8057        let cfg = CloudConfig::load(root).unwrap();
8058        let json = cfg.domain("yah-dev").unwrap().route_headers_json();
8059
8060        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
8061        let rules = parsed.as_array().unwrap();
8062        assert_eq!(rules.len(), 1, "the headerless catch-all is dropped: {json}");
8063        assert_eq!(rules[0]["path"], "/app/*");
8064        assert_eq!(rules[0]["headers"]["Cross-Origin-Embedder-Policy"], "require-corp");
8065    }
8066
8067    #[test]
8068    fn route_headers_json_is_an_empty_array_when_nothing_declares_headers() {
8069        let tmp = tempfile::TempDir::new().unwrap();
8070        let root = tmp.path();
8071        write_marketing_service(root);
8072        write_domain_toml(
8073            root,
8074            "yah-dev",
8075            r#"
8076schema_version = 1
8077name = "yah-dev"
8078domain = "yah.dev"
8079front_door = "worker"
8080cdn_bucket = "yah-dev"
8081
8082[[routes]]
8083path = "/*"
8084mode = "static"
8085component = "yah-marketing/site"
8086"#,
8087        );
8088        let cfg = CloudConfig::load(root).unwrap();
8089        assert_eq!(cfg.domain("yah-dev").unwrap().route_headers_json(), "[]");
8090    }
8091
8092    /// The reconciler's own entry point: given a workspace root and a service
8093    /// name, produce the binding value. `"[]"` when nothing routes the service.
8094    #[test]
8095    fn route_headers_for_service_reads_the_workspace_domains() {
8096        let tmp = tempfile::TempDir::new().unwrap();
8097        let root = tmp.path();
8098        write_two_component_service(root);
8099        write_domain_toml(root, "yah-dev", MOUNTED_DOMAIN);
8100        assert!(route_headers_for_service(root, "yah-marketing")
8101            .unwrap()
8102            .contains("require-corp"));
8103        assert_eq!(route_headers_for_service(root, "some-other-svc").unwrap(), "[]");
8104    }
8105
8106    // ---- R749-T5: a broken table fails the DEPLOY, not the edge -----------
8107
8108    /// The manifest's `headers` map is hand-written TOML, so a header name with
8109    /// spaces in it is one keystroke away — and it survives serialization into
8110    /// a structurally-valid table that neither front door can apply. Fail at
8111    /// load, naming the domain, the route and the header, instead of shipping a
8112    /// binding the Worker throws on and an origin that refuses to boot.
8113    #[test]
8114    fn a_route_header_name_that_is_not_a_header_name_fails_the_load() {
8115        let tmp = tempfile::TempDir::new().unwrap();
8116        let root = tmp.path();
8117        write_marketing_service(root);
8118        write_domain_toml(
8119            root,
8120            "yah-dev",
8121            r#"
8122schema_version = 1
8123name = "yah-dev"
8124domain = "yah.dev"
8125front_door = "worker"
8126cdn_bucket = "yah-dev"
8127
8128[[routes]]
8129path = "/*"
8130mode = "static"
8131component = "yah-marketing/site"
8132headers = { "Cross Origin Opener Policy" = "same-origin" }
8133"#,
8134        );
8135        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
8136        assert!(err.contains("yah-dev"), "{err}");
8137        assert!(err.contains("/*"), "{err}");
8138        assert!(err.contains("Cross Origin Opener Policy"), "{err}");
8139        assert!(err.contains("not a valid HTTP header name"), "{err}");
8140    }
8141
8142    /// A newline in a value is header injection if it ever reached the wire, so
8143    /// both doors reject it and so does this.
8144    #[test]
8145    fn a_route_header_value_that_is_not_a_header_value_fails_the_load() {
8146        let tmp = tempfile::TempDir::new().unwrap();
8147        let root = tmp.path();
8148        write_marketing_service(root);
8149        write_domain_toml(
8150            root,
8151            "yah-dev",
8152            r#"
8153schema_version = 1
8154name = "yah-dev"
8155domain = "yah.dev"
8156front_door = "worker"
8157cdn_bucket = "yah-dev"
8158
8159[[routes]]
8160path = "/*"
8161mode = "static"
8162component = "yah-marketing/site"
8163headers = { "X-Frame-Options" = "DENY\nSet-Cookie: pwned=1" }
8164"#,
8165        );
8166        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
8167        assert!(err.contains("X-Frame-Options"), "{err}");
8168        assert!(err.contains("not a valid HTTP header value"), "{err}");
8169    }
8170
8171    /// The invariant this gate exists to hold: everything `route_headers_json`
8172    /// emits is applicable. A headerless route contributes no rule, so its path
8173    /// is not the table's business — only rules that ship are checked.
8174    #[test]
8175    fn a_headerless_route_is_not_subject_to_the_route_header_gate() {
8176        let tmp = tempfile::TempDir::new().unwrap();
8177        let root = tmp.path();
8178        write_two_component_service(root);
8179        write_domain_toml(root, "yah-dev", MOUNTED_DOMAIN);
8180        let cfg = CloudConfig::load(root).unwrap();
8181        cfg.domain("yah-dev")
8182            .unwrap()
8183            .validate_route_headers()
8184            .unwrap();
8185    }
8186
8187    /// A `bucket-direct` domain has no front door to set headers on, so it must
8188    /// not be picked up as a service's header source.
8189    #[test]
8190    fn route_headers_ignores_domains_that_are_not_route_driven() {
8191        let doms: BTreeMap<String, DomainConfig> = [(
8192            "cdn".to_string(),
8193            DomainConfig {
8194                schema_version: 1,
8195                name: "cdn".into(),
8196                domain: "cdn.yah.dev".into(),
8197                front_door: FrontDoor::BucketDirect,
8198                cdn_bucket: "yah-dev".into(),
8199                worker_bundle_path: None,
8200                routes: vec![],
8201            },
8202        )]
8203        .into_iter()
8204        .collect();
8205        assert!(domain_serving_service(&doms, "yah-marketing").is_none());
8206    }
8207
8208    #[test]
8209    fn a_mount_that_disagrees_with_its_route_path_is_rejected() {
8210        let tmp = tempfile::TempDir::new().unwrap();
8211        let root = tmp.path();
8212        write_two_component_service(root);
8213        write_domain_toml(
8214            root,
8215            "yah-dev",
8216            r#"
8217schema_version = 1
8218name = "yah-dev"
8219domain = "yah.dev"
8220front_door = "worker"
8221cdn_bucket = "yah-dev"
8222
8223[[routes]]
8224path = "/studio/*"
8225mode = "static"
8226component = "yah-marketing/app"
8227"#,
8228        );
8229        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
8230        assert!(err.contains("mount = \"/app\""), "{err}");
8231        assert!(err.contains("/studio/*"), "{err}");
8232    }
8233
8234    /// The other direction: routing an unmounted component under a sub-path
8235    /// points requests at a prefix nothing published to.
8236    #[test]
8237    fn routing_an_unmounted_component_under_a_subpath_is_rejected() {
8238        let tmp = tempfile::TempDir::new().unwrap();
8239        let root = tmp.path();
8240        write_marketing_service(root);
8241        write_domain_toml(
8242            root,
8243            "yah-dev",
8244            r#"
8245schema_version = 1
8246name = "yah-dev"
8247domain = "yah.dev"
8248front_door = "worker"
8249cdn_bucket = "yah-dev"
8250
8251[[routes]]
8252path = "/docs/*"
8253mode = "static"
8254component = "yah-marketing/site"
8255"#,
8256        );
8257        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
8258        assert!(err.contains("no `mount`"), "{err}");
8259        assert!(err.contains("/docs"), "{err}");
8260    }
8261
8262    #[test]
8263    fn mount_and_route_prefix_normalization_agree() {
8264        for m in ["/app", "app", "app/", "/app/"] {
8265            assert_eq!(normalize_mount(m), "app", "mount {m:?}");
8266        }
8267        assert_eq!(normalize_mount("/"), "");
8268        assert_eq!(route_path_prefix("/*"), "");
8269        assert_eq!(route_path_prefix("/app/*"), "app");
8270        assert_eq!(route_path_prefix("/app"), "app");
8271        assert_eq!(route_path_prefix("/"), "");
8272    }
8273
8274    #[test]
8275    fn worker_with_no_routes_is_rejected() {
8276        let tmp = tempfile::TempDir::new().unwrap();
8277        let root = tmp.path();
8278        write_domain_toml(
8279            root,
8280            "yah-dev",
8281            r#"
8282schema_version = 1
8283name = "yah-dev"
8284domain = "yah.dev"
8285front_door = "worker"
8286cdn_bucket = "yah-dev"
8287"#,
8288        );
8289        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
8290        assert!(err.contains("front_door = \"worker\""), "{err}");
8291        assert!(err.contains("404"), "{err}");
8292    }
8293
8294    #[test]
8295    fn passway_with_no_routes_is_rejected_too() {
8296        let tmp = tempfile::TempDir::new().unwrap();
8297        let root = tmp.path();
8298        write_domain_toml(
8299            root,
8300            "yah-dev",
8301            r#"
8302schema_version = 1
8303name = "yah-dev"
8304domain = "yah.dev"
8305front_door = "passway"
8306cdn_bucket = "yah-dev"
8307"#,
8308        );
8309        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
8310        assert!(err.contains("front_door = \"passway\""), "{err}");
8311    }
8312
8313    #[test]
8314    fn bucket_direct_without_routes_loads() {
8315        let tmp = tempfile::TempDir::new().unwrap();
8316        let root = tmp.path();
8317        // Exactly the shape .yah/domains/cdn-yah-dev.toml ships (W175: a pure
8318        // asset tier deliberately has no Worker behaviours).
8319        write_domain_toml(
8320            root,
8321            "cdn-yah-dev",
8322            r#"
8323schema_version = 1
8324name = "cdn-yah-dev"
8325domain = "cdn.yah.dev"
8326front_door = "bucket-direct"
8327cdn_bucket = "yah-dev"
8328"#,
8329        );
8330        let cfg = CloudConfig::load(root).unwrap();
8331        let dom = cfg.domain("cdn-yah-dev").expect("cdn-yah-dev domain");
8332        assert_eq!(dom.front_door, FrontDoor::BucketDirect);
8333        assert!(!dom.front_door.is_route_driven());
8334    }
8335
8336    #[test]
8337    fn front_door_round_trips_through_save() {
8338        let tmp = tempfile::TempDir::new().unwrap();
8339        let root = tmp.path();
8340        write_marketing_service(root);
8341        let dom = DomainConfig {
8342            schema_version: 1,
8343            name: "yah-dev".into(),
8344            domain: "yah.dev".into(),
8345            front_door: FrontDoor::Passway,
8346            cdn_bucket: "yah-dev".into(),
8347            worker_bundle_path: None,
8348            routes: vec![DomainRoute {
8349                headers: Default::default(),
8350                path: "/*".into(),
8351                mode: RouteMode::Static {
8352                    component: "yah-marketing/site".into(),
8353                },
8354            }],
8355        };
8356        dom.save(root).unwrap();
8357        let cfg = CloudConfig::load(root).unwrap();
8358        assert_eq!(
8359            cfg.domain("yah-dev").unwrap().front_door,
8360            FrontDoor::Passway
8361        );
8362    }
8363
8364    // The four manifests this repo actually ships are asserted in
8365    // `tests/live_workspace_smoke.rs` — that's the only place with a
8366    // depth-agnostic path to the live `.yah/` tree and a skip path for the
8367    // standalone mirror checkout.
8368
8369    #[test]
8370    fn cross_ref_bails_on_missing_service() {
8371        let tmp = tempfile::TempDir::new().unwrap();
8372        let root = tmp.path();
8373        // No services declared at all — component ref must fail to resolve.
8374        let dom = DomainConfig {
8375            schema_version: 1,
8376            name: "yah-dev".into(),
8377            domain: "yah.dev".into(),
8378            front_door: FrontDoor::Worker,
8379            cdn_bucket: "yah-dev".into(),
8380            worker_bundle_path: None,
8381            routes: vec![DomainRoute {
8382                headers: Default::default(),
8383                path: "/".into(),
8384                mode: RouteMode::Static {
8385                    component: "yah-marketing/site".into(),
8386                },
8387            }],
8388        };
8389        dom.save(root).unwrap();
8390
8391        let err = CloudConfig::load(root).unwrap_err();
8392        let msg = format!("{err:#}");
8393        assert!(msg.contains("no such service"), "got: {msg}");
8394        assert!(msg.contains("yah-marketing"), "got: {msg}");
8395    }
8396
8397    #[test]
8398    fn cross_ref_bails_on_missing_component() {
8399        let tmp = tempfile::TempDir::new().unwrap();
8400        let root = tmp.path();
8401        write_marketing_service(root); // has component id "site", not "elsewhere"
8402
8403        let dom = DomainConfig {
8404            schema_version: 1,
8405            name: "yah-dev".into(),
8406            domain: "yah.dev".into(),
8407            front_door: FrontDoor::Worker,
8408            cdn_bucket: "yah-dev".into(),
8409            worker_bundle_path: None,
8410            routes: vec![DomainRoute {
8411                headers: Default::default(),
8412                path: "/".into(),
8413                mode: RouteMode::Static {
8414                    component: "yah-marketing/elsewhere".into(),
8415                },
8416            }],
8417        };
8418        dom.save(root).unwrap();
8419
8420        let err = CloudConfig::load(root).unwrap_err();
8421        let msg = format!("{err:#}");
8422        assert!(msg.contains("no component with id"), "got: {msg}");
8423        assert!(msg.contains("elsewhere"), "got: {msg}");
8424    }
8425
8426    #[test]
8427    fn cross_ref_bails_on_malformed_ref() {
8428        let tmp = tempfile::TempDir::new().unwrap();
8429        let root = tmp.path();
8430        write_marketing_service(root);
8431
8432        let dom = DomainConfig {
8433            schema_version: 1,
8434            name: "yah-dev".into(),
8435            domain: "yah.dev".into(),
8436            front_door: FrontDoor::Worker,
8437            cdn_bucket: "yah-dev".into(),
8438            worker_bundle_path: None,
8439            routes: vec![DomainRoute {
8440                headers: Default::default(),
8441                path: "/".into(),
8442                mode: RouteMode::Static {
8443                    component: "no-slash-here".into(),
8444                },
8445            }],
8446        };
8447        dom.save(root).unwrap();
8448
8449        let err = CloudConfig::load(root).unwrap_err();
8450        let msg = format!("{err:#}");
8451        assert!(msg.contains("expected"), "got: {msg}");
8452    }
8453
8454    #[test]
8455    fn redirect_routes_skip_component_validation() {
8456        let tmp = tempfile::TempDir::new().unwrap();
8457        let root = tmp.path();
8458        // No services at all — redirect must still load cleanly because it
8459        // references nothing.
8460        let dom = DomainConfig {
8461            schema_version: 1,
8462            name: "yah-dev".into(),
8463            domain: "yah.dev".into(),
8464            front_door: FrontDoor::Worker,
8465            cdn_bucket: "yah-dev".into(),
8466            worker_bundle_path: None,
8467            routes: vec![DomainRoute {
8468                headers: Default::default(),
8469                path: "/old".into(),
8470                mode: RouteMode::Redirect {
8471                    target: "https://yah.dev/blog".into(),
8472                    status: 308,
8473                },
8474            }],
8475        };
8476        dom.save(root).unwrap();
8477
8478        let cfg = CloudConfig::load(root).unwrap();
8479        assert!(cfg.domain("yah-dev").is_some());
8480    }
8481
8482    #[test]
8483    fn name_must_match_file_stem() {
8484        let tmp = tempfile::TempDir::new().unwrap();
8485        let root = tmp.path();
8486        // Hand-write a file whose stem disagrees with its `name`.
8487        let dir = root.join(".yah").join("domains");
8488        std::fs::create_dir_all(&dir).unwrap();
8489        std::fs::write(
8490            dir.join("yah-dev.toml"),
8491            r#"schema_version = 1
8492name = "different-name"
8493domain = "yah.dev"
8494front_door = "bucket-direct"
8495cdn_bucket = "yah-dev"
8496"#,
8497        )
8498        .unwrap();
8499
8500        let err = CloudConfig::load(root).unwrap_err();
8501        let msg = format!("{err:#}");
8502        assert!(msg.contains("must match the file stem"), "got: {msg}");
8503    }
8504
8505    #[test]
8506    fn net_alias_tier_subdomain_manifest_loads_and_cross_refs() {
8507        // R561-F2: a per-tenant subdomain manifest on the net.yah.dev wildcard
8508        // alias tier is just a DomainConfig whose `domain` is `<name>.net.yah.dev`
8509        // and whose static route cross-refs the tenant's service component.
8510        // This is exactly the shape .yah/domains/scrabcake-net-yah-dev.toml ships.
8511        let tmp = tempfile::TempDir::new().unwrap();
8512        let root = tmp.path();
8513        write_marketing_service(root); // service "yah-marketing", component "site"
8514
8515        let dom = DomainConfig {
8516            schema_version: 1,
8517            name: "tenant-net-yah-dev".into(),
8518            domain: "tenant.net.yah.dev".into(),
8519            front_door: FrontDoor::Worker,
8520            cdn_bucket: "net-yah-dev".into(), // shared per-tier bucket
8521            worker_bundle_path: None,
8522            routes: vec![DomainRoute {
8523                headers: Default::default(),
8524                path: "/*".into(),
8525                mode: RouteMode::Static {
8526                    component: "yah-marketing/site".into(),
8527                },
8528            }],
8529        };
8530        dom.save(root).unwrap();
8531
8532        let cfg = CloudConfig::load(root).unwrap();
8533        let dom = cfg
8534            .domain("tenant-net-yah-dev")
8535            .expect("net-tier subdomain manifest should load");
8536        assert_eq!(dom.domain, "tenant.net.yah.dev");
8537        assert_eq!(dom.cdn_bucket, "net-yah-dev");
8538    }
8539
8540    // ─── R572-F3: NodeAllocatable + taints ──────────────────────────────────
8541
8542    #[test]
8543    fn machine_allocatable_round_trips() {
8544        let toml_src = r#"
8545name = "us-west-001"
8546provider = "static"
8547mesh_tags = ["tag:cloud-runner"]
8548[allocatable]
8549memory_mb = 3800
8550cpu_millis = 2000
8551"#;
8552        let m: MachineConfig = toml::from_str(toml_src).unwrap();
8553        let a = m.allocatable.as_ref().expect("allocatable should parse");
8554        assert_eq!(a.memory_mb, 3800);
8555        assert_eq!(a.cpu_millis, 2000);
8556
8557        let s = toml::to_string(&m).unwrap();
8558        let back: MachineConfig = toml::from_str(&s).unwrap();
8559        let a2 = back.allocatable.as_ref().unwrap();
8560        assert_eq!(a2.memory_mb, 3800);
8561        assert_eq!(a2.cpu_millis, 2000);
8562    }
8563
8564    #[test]
8565    fn machine_taints_round_trips() {
8566        let toml_src = r#"
8567name = "us-south-001"
8568provider = "static"
8569mesh_tags = ["tag:cloud-runner"]
8570taints = ["no-appliance"]
8571"#;
8572        let m: MachineConfig = toml::from_str(toml_src).unwrap();
8573        assert_eq!(m.taints, vec!["no-appliance"]);
8574
8575        let s = toml::to_string(&m).unwrap();
8576        let back: MachineConfig = toml::from_str(&s).unwrap();
8577        assert_eq!(back.taints, vec!["no-appliance"]);
8578    }
8579
8580    #[test]
8581    fn machine_allocatable_absent_is_none() {
8582        let toml_src = "name = \"node\"\nprovider = \"static\"\nmesh_tags = []\n";
8583        let m: MachineConfig = toml::from_str(toml_src).unwrap();
8584        assert!(m.allocatable.is_none());
8585        assert!(m.taints.is_empty());
8586    }
8587
8588    #[test]
8589    fn machine_allocatable_skipped_when_none() {
8590        let m = make_machine("node", vec![]);
8591        let s = toml::to_string(&m).unwrap();
8592        assert!(
8593            !s.contains("allocatable"),
8594            "None allocatable must be omitted: {s}"
8595        );
8596        assert!(!s.contains("taints"), "empty taints must be omitted: {s}");
8597    }
8598
8599    #[test]
8600    fn machine_multiple_taints_round_trip() {
8601        let toml_src = r#"
8602name = "quarantined"
8603provider = "static"
8604mesh_tags = ["tag:build-worker"]
8605taints = ["no-server", "no-appliance", "no-job"]
8606"#;
8607        let m: MachineConfig = toml::from_str(toml_src).unwrap();
8608        assert_eq!(m.taints.len(), 3);
8609        assert!(m.taints.contains(&"no-server".to_string()));
8610        assert!(m.taints.contains(&"no-appliance".to_string()));
8611        assert!(m.taints.contains(&"no-job".to_string()));
8612        // R742-T4: every key here is one the scheduler reads. This fixture
8613        // used to carry `no-voter`, which none of them is.
8614        assert!(m.inert_taints().is_empty());
8615    }
8616
8617    // ─── R742-T4 (W305): inert-taint classification ─────────────────────────
8618
8619    #[test]
8620    fn every_archetype_repel_key_is_live() {
8621        for arch in LifecycleArchetype::ALL {
8622            let key = format!("no-{}", arch.taint_key());
8623            assert_eq!(
8624                taint_effect(&key),
8625                TaintEffect::Repels(arch),
8626                "{key} must repel {arch:?}"
8627            );
8628        }
8629    }
8630
8631    #[test]
8632    fn public_ip_is_an_affinity_key_not_an_inert_one() {
8633        assert_eq!(
8634            taint_effect(workload_spec::PUBLIC_IP_TAINT),
8635            TaintEffect::Attracts
8636        );
8637    }
8638
8639    #[test]
8640    fn a_free_form_taint_is_inert_and_says_so() {
8641        // W305's headline example: `taints = ["qa"]` parsed clean and did
8642        // nothing. Environment is not expressible as a taint.
8643        assert_eq!(taint_effect("qa"), TaintEffect::Inert);
8644        // And the one that actually cost fleet state: `no-voter` reads as an
8645        // exclusion and excludes nothing — "voter" is not an archetype.
8646        assert_eq!(taint_effect("no-voter"), TaintEffect::Inert);
8647        // A near-miss on a real key is inert too, not silently forgiven.
8648        assert_eq!(taint_effect("no-servers"), TaintEffect::Inert);
8649
8650        let m = make_machine_with_capacity(
8651            "dev-pi",
8652            8192,
8653            4000,
8654            vec!["no-appliance", "no-voter", "qa"],
8655        );
8656        assert_eq!(m.inert_taints(), vec!["no-voter", "qa"]);
8657    }
8658
8659    #[test]
8660    fn an_inert_taint_changes_no_placement_decision() {
8661        // The reason this is a lint and not a behaviour change: the guard's
8662        // whole premise is that these keys are invisible to `matches`.
8663        let clean = make_machine_with_capacity("n", 8192, 4000, vec![]);
8664        let noisy = make_machine_with_capacity("n", 8192, 4000, vec!["no-voter", "qa"]);
8665        for arch in LifecycleArchetype::ALL {
8666            let req = RequiredSpec {
8667                repel_archetypes: vec![arch],
8668                ..Default::default()
8669            };
8670            assert_eq!(req.matches(&clean), req.matches(&noisy));
8671        }
8672    }
8673
8674    #[test]
8675    fn live_taint_keys_lists_the_whole_legal_vocabulary() {
8676        assert_eq!(
8677            live_taint_keys(),
8678            vec!["no-appliance", "no-job", "no-server", "public-ip"]
8679        );
8680    }
8681
8682    // ─── R742-F1 (W305): sovereign groups ───────────────────────────────────
8683
8684    /// A machine in `group`, with the role left unwritten — which is the state
8685    /// of every machine TOML that predates R605-F12 and resolves to `voter`.
8686    fn in_group(name: &str, group: Option<&str>) -> MachineConfig {
8687        MachineConfig {
8688            sovereign_group: group.map(String::from),
8689            ..make_machine(name, vec![])
8690        }
8691    }
8692
8693    /// A machine in `group` with its quorum eligibility stated (R605-F12).
8694    fn in_group_as(name: &str, group: &str, role: SovereignRole) -> MachineConfig {
8695        MachineConfig {
8696            sovereign_group: Some(group.to_string()),
8697            sovereign_role: Some(role),
8698            ..make_machine(name, vec![])
8699        }
8700    }
8701
8702    #[test]
8703    fn a_join_within_one_sovereign_group_is_permitted() {
8704        assert_eq!(
8705            judge_join(
8706                &in_group("us-west-013", Some("dev")),
8707                &in_group("us-west-011", Some("dev")),
8708            ),
8709            JoinVerdict::Permit
8710        );
8711    }
8712
8713    /// The case the field exists for: before it, the only thing standing
8714    /// between a dev Pi and the prod quorum was a comment in a TOML.
8715    #[test]
8716    fn a_cross_group_join_is_refused_naming_both_groups() {
8717        let verdict = judge_join(
8718            &in_group("us-west-011", Some("dev")),
8719            &in_group("us-west-001", Some("prod")),
8720        );
8721        let JoinVerdict::Refuse(msg) = verdict else {
8722            panic!("a dev node joining prod must be refused: {verdict:?}");
8723        };
8724        // A refusal that does not name what it saw is one the operator has to
8725        // go and reconstruct, so it gets worked around instead of fixed.
8726        assert!(msg.contains("us-west-011") && msg.contains("us-west-001"), "{msg}");
8727        assert!(msg.contains("dev") && msg.contains("prod"), "{msg}");
8728    }
8729
8730    /// `None` is a declaration ("standalone, in no group"), not a gap — so
8731    /// growing prod with an unstamped box is a cross-group join too, and the
8732    /// refusal has to say which file makes it legal.
8733    #[test]
8734    fn an_undeclared_node_cannot_join_a_declared_group() {
8735        let verdict = judge_join(
8736            &in_group("us-west-002", None),
8737            &in_group("us-west-001", Some("prod")),
8738        );
8739        let JoinVerdict::Refuse(msg) = verdict else {
8740            panic!("an unstamped node joining prod must be refused: {verdict:?}");
8741        };
8742        assert!(
8743            msg.contains(".yah/infra/machines/us-west-002.toml"),
8744            "the refusal must name the file to stamp: {msg}"
8745        );
8746    }
8747
8748    #[test]
8749    fn a_declared_node_cannot_join_a_standalone_target() {
8750        // us-west-003 is `mode: standalone` on purpose; it is not a group of
8751        // one waiting to be grown.
8752        let verdict = judge_join(
8753            &in_group("us-west-001", Some("prod")),
8754            &in_group("us-west-003", None),
8755        );
8756        assert!(matches!(verdict, JoinVerdict::Refuse(msg) if msg.contains("us-west-003")));
8757    }
8758
8759    #[test]
8760    fn two_undeclared_nodes_cannot_form_an_undeclared_group() {
8761        let verdict = judge_join(
8762            &in_group("us-west-002", None),
8763            &in_group("us-west-015", None),
8764        );
8765        assert!(
8766            matches!(&verdict, JoinVerdict::Refuse(msg) if msg.contains("us-west-002")
8767                && msg.contains("us-west-015")),
8768            "forming a group nobody declared must be refused, naming both: {verdict:?}"
8769        );
8770    }
8771
8772    // ─── R605-F12: the voting axis ──────────────────────────────────────────
8773
8774    /// The whole ticket in one assertion. us-west-003 is a member of prod —
8775    /// same secrets, same upgrade cadence, same destruction — and must never
8776    /// hold a prod raft seat. Before the role axis, the only thing refusing it
8777    /// was its *absent* group stamp, so writing down the truth above would have
8778    /// removed the guard.
8779    #[test]
8780    fn a_non_voting_member_is_refused_into_its_own_group() {
8781        let verdict = judge_join(
8782            &in_group_as("us-west-003", "prod", SovereignRole::NonVoter),
8783            &in_group_as("us-west-001", "prod", SovereignRole::Voter),
8784        );
8785        let JoinVerdict::Refuse(msg) = verdict else {
8786            panic!("a non-voting prod member must not join the prod quorum: {verdict:?}");
8787        };
8788        assert!(msg.contains("us-west-003") && msg.contains("NON-VOTING"), "{msg}");
8789        // The refusal must not blame the group: both sides say "prod", and a
8790        // cross-group message here would read as a bug in the check itself.
8791        assert!(!msg.contains("cross-group"), "{msg}");
8792        assert!(
8793            msg.contains(".yah/infra/machines/us-west-003.toml"),
8794            "the refusal must name the file that decides it: {msg}"
8795        );
8796    }
8797
8798    /// Read from the other end: a box declared non-voting has no quorum seat to
8799    /// be grown, so it cannot be a join target either.
8800    #[test]
8801    fn a_non_voting_target_has_no_quorum_to_grow() {
8802        let verdict = judge_join(
8803            &in_group_as("us-west-001", "prod", SovereignRole::Voter),
8804            &in_group_as("us-west-003", "prod", SovereignRole::NonVoter),
8805        );
8806        assert!(
8807            matches!(&verdict, JoinVerdict::Refuse(msg) if msg.contains("the target")
8808                && msg.contains("us-west-003")),
8809            "{verdict:?}"
8810        );
8811    }
8812
8813    /// A non-voter joining a *standalone* target is refused for two reasons at
8814    /// once, and the message must pick the one whose fix would actually work.
8815    /// Naming the role here would send the operator to flip `sovereign_role`
8816    /// and come back to the same refusal.
8817    #[test]
8818    fn a_refusal_names_the_group_when_fixing_the_role_would_not_help() {
8819        let verdict = judge_join(
8820            &in_group_as("us-west-003", "prod", SovereignRole::NonVoter),
8821            &in_group("us-west-002", None),
8822        );
8823        let JoinVerdict::Refuse(msg) = verdict else {
8824            panic!("a standalone target has no group to join: {verdict:?}");
8825        };
8826        assert!(
8827            msg.contains(".yah/infra/machines/us-west-002.toml"),
8828            "the refusal must point at the target's missing group stamp: {msg}"
8829        );
8830        assert!(!msg.contains("NON-VOTING"), "{msg}");
8831    }
8832
8833    /// The back-compat seam, pinned: the six nodes stamped before R605-F12
8834    /// write no role, and an absent role means what declaring a group has
8835    /// always meant. If this flips, the live prod and dev quorums stop being
8836    /// growable on a config the operator never edited.
8837    #[test]
8838    fn an_unwritten_role_still_joins_its_group() {
8839        let joiner = in_group("us-west-013", Some("dev"));
8840        assert_eq!(joiner.sovereign_role, None);
8841        assert_eq!(
8842            judge_join(&joiner, &in_group("us-west-011", Some("dev"))),
8843            JoinVerdict::Permit
8844        );
8845        assert_eq!(
8846            judge_join(
8847                &joiner,
8848                &in_group_as("us-west-011", "dev", SovereignRole::Voter)
8849            ),
8850            JoinVerdict::Permit
8851        );
8852    }
8853
8854    /// A non-voting member is still a *member*, and the two claims must not be
8855    /// conflated: `sovereign_membership()` reports the group either way, so a
8856    /// consumer asking "is this box in prod's blast radius" gets yes.
8857    #[test]
8858    fn a_non_voter_is_still_in_the_group_it_names() {
8859        let m = in_group_as("us-west-003", "prod", SovereignRole::NonVoter);
8860        assert_eq!(m.sovereign_membership().group, Some("prod"));
8861        assert!(!m.sovereign_membership().role.is_voter());
8862
8863        // …and the group-membership query the fleet reads is unaffected by it.
8864        let cfg = make_empty_cfg(vec![
8865            m,
8866            in_group_as("us-west-001", "prod", SovereignRole::Voter),
8867        ]);
8868        assert_eq!(
8869            cfg.machines_in_group("prod")
8870                .iter()
8871                .map(|m| m.name.as_str())
8872                .collect::<Vec<_>>(),
8873            vec!["us-west-003", "us-west-001"]
8874        );
8875    }
8876
8877    /// The role travels through TOML in one spelling, and an absent one stays
8878    /// absent on the way back out — otherwise every machine file would grow a
8879    /// `sovereign_role = "voter"` line the operator never wrote, and the
8880    /// unroled-member lint would have nothing left to find.
8881    #[test]
8882    fn sovereign_role_round_trips_and_is_omitted_when_unwritten() {
8883        let m: MachineConfig = toml::from_str(
8884            r#"
8885name = "us-west-003"
8886provider = "static"
8887region = "us-west"
8888arch = "x86_64"
8889mesh_tags = []
8890sovereign_group = "prod"
8891sovereign_role = "non-voter"
8892"#,
8893        )
8894        .unwrap();
8895        assert_eq!(m.sovereign_role, Some(SovereignRole::NonVoter));
8896        assert!(toml::to_string(&m)
8897            .unwrap()
8898            .contains(r#"sovereign_role = "non-voter""#));
8899
8900        let unwritten = MachineConfig {
8901            sovereign_role: None,
8902            ..m
8903        };
8904        assert!(!toml::to_string(&unwritten)
8905            .unwrap()
8906            .contains("sovereign_role"));
8907    }
8908
8909    /// The invariant the ticket is most explicit about: a sovereign group is a
8910    /// blast radius, not a filter. If this ever fails, `matches` has grown an
8911    /// axis it must not have and dev-mode workloads have silently become
8912    /// unschedulable on the dev group.
8913    #[test]
8914    fn sovereign_group_is_not_a_placement_input() {
8915        let standalone = in_group("n", None);
8916        let grouped = in_group("n", Some("dev"));
8917        let other = in_group("n", Some("prod"));
8918
8919        for spec in [
8920            RequiredSpec::default(),
8921            RequiredSpec {
8922                regions: vec!["us-west".into()],
8923                ..Default::default()
8924            },
8925            RequiredSpec {
8926                repel_archetypes: vec![LifecycleArchetype::Appliance],
8927                ..Default::default()
8928            },
8929        ] {
8930            let baseline = spec.matches(&standalone);
8931            assert_eq!(spec.matches(&grouped), baseline);
8932            assert_eq!(spec.matches(&other), baseline);
8933        }
8934    }
8935
8936    // ─── R742-F3 (W305): group → machine set, and group-scoped admission ────
8937
8938    /// The primitive `migrate --to` needs and `rollout plan` still lacks
8939    /// (W314 gap 1): a group exists only as the set of machines naming it, so
8940    /// membership has to be derived rather than declared anywhere.
8941    #[test]
8942    fn machines_in_group_derives_membership_from_the_declarations() {
8943        let cfg = make_empty_cfg(vec![
8944            in_group("us-west-001", Some("prod")),
8945            in_group("us-west-011", Some("dev")),
8946            in_group("us-west-013", Some("dev")),
8947            in_group("us-west-002", None),
8948        ]);
8949
8950        let dev: Vec<&str> = cfg
8951            .machines_in_group("dev")
8952            .iter()
8953            .map(|m| m.name.as_str())
8954            .collect();
8955        assert_eq!(dev, vec!["us-west-011", "us-west-013"]);
8956        assert_eq!(cfg.machines_in_group("prod").len(), 1);
8957
8958        // Standalone is "in no group", not "in a group called none" — so an
8959        // unstamped box is never swept into a migration target.
8960        assert!(cfg.machines_in_group("").is_empty());
8961        assert!(cfg.machines_in_group("staging").is_empty());
8962    }
8963
8964    #[test]
8965    fn declared_sovereign_groups_is_the_vocabulary_a_bad_target_is_named_against() {
8966        let cfg = make_empty_cfg(vec![
8967            in_group("a", Some("prod")),
8968            in_group("b", Some("dev")),
8969            in_group("c", Some("prod")),
8970            in_group("d", None),
8971        ]);
8972        // Sorted + deduped, and standalone contributes nothing.
8973        assert_eq!(cfg.declared_sovereign_groups(), vec!["dev", "prod"]);
8974        assert!(make_empty_cfg(vec![in_group("a", None)])
8975            .declared_sovereign_groups()
8976            .is_empty());
8977    }
8978
8979    /// Group-scoped admission must be the SAME predicate as unscoped
8980    /// admission, only over fewer candidates. If it ever diverges, `migrate`
8981    /// becomes a way to place a workload somewhere `yah cloud apply` would
8982    /// refuse — which is exactly the silent routing-around W305 exists to stop.
8983    #[test]
8984    fn admit_workload_in_group_narrows_candidates_without_changing_the_predicate() {
8985        let mut prod = in_group("us-west-001", Some("prod"));
8986        prod.mesh_tags = vec!["tag:cloud-runner".into()];
8987        let mut dev_repels = in_group("us-west-011", Some("dev"));
8988        dev_repels.taints = vec!["no-appliance".into()];
8989        let mut dev_ok = in_group("us-west-013", Some("dev"));
8990        dev_ok.mesh_tags = vec!["tag:cloud-runner".into()];
8991
8992        let cfg = make_empty_cfg(vec![prod, dev_repels, dev_ok]);
8993
8994        let mut ws = ws_with_selector(None);
8995        ws.archetype = Some(LifecycleArchetype::Appliance);
8996
8997        // Unscoped picks the first match in declaration order.
8998        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "us-west-001");
8999        // Scoped skips the repelling dev node and lands on the other one —
9000        // the taint is honoured, not bypassed.
9001        assert_eq!(
9002            cfg.admit_workload_in_group(&ws, "dev").unwrap().name,
9003            "us-west-013"
9004        );
9005    }
9006
9007    #[test]
9008    fn admit_workload_in_group_distinguishes_an_empty_group_from_a_repelling_one() {
9009        let mut dev = in_group("us-west-011", Some("dev"));
9010        dev.taints = vec!["no-appliance".into()];
9011        let cfg = make_empty_cfg(vec![in_group("us-west-001", Some("prod")), dev]);
9012
9013        let mut ws = ws_with_selector(None);
9014        ws.archetype = Some(LifecycleArchetype::Appliance);
9015
9016        // A group nobody declares names the legal vocabulary, because a typo
9017        // is the realistic cause and "no candidates" would send the operator
9018        // hunting for a placement problem that does not exist.
9019        let missing = cfg.admit_workload_in_group(&ws, "stagng").unwrap_err().to_string();
9020        assert!(missing.contains("no machine declares"), "{missing}");
9021        assert!(missing.contains("dev") && missing.contains("prod"), "{missing}");
9022
9023        // A group that exists but refuses names the machines it tried.
9024        let repelled = cfg.admit_workload_in_group(&ws, "dev").unwrap_err().to_string();
9025        assert!(repelled.contains("us-west-011"), "{repelled}");
9026    }
9027
9028    #[test]
9029    fn sovereign_group_round_trips_and_is_omitted_when_standalone() {
9030        let src = r#"
9031name = "us-west-011"
9032provider = "static"
9033mesh_tags = []
9034sovereign_group = "dev"
9035"#;
9036        let m: MachineConfig = toml::from_str(src).unwrap();
9037        assert_eq!(m.sovereign_group.as_deref(), Some("dev"));
9038        assert!(toml::to_string(&m).unwrap().contains("sovereign_group"));
9039
9040        // A machine that predates the field parses as standalone and does not
9041        // grow the key back on write.
9042        let legacy: MachineConfig =
9043            toml::from_str("name = \"us-west-002\"\nprovider = \"static\"\nmesh_tags = []\n")
9044                .unwrap();
9045        assert_eq!(legacy.sovereign_group, None);
9046        assert!(!toml::to_string(&legacy).unwrap().contains("sovereign_group"));
9047    }
9048
9049    // ─── R572-F5: capacity floor + absolute (untolerable) taints ────────────
9050
9051    fn make_machine_with_capacity(
9052        name: &str,
9053        memory_mb: u32,
9054        cpu_millis: u32,
9055        taints: Vec<&str>,
9056    ) -> MachineConfig {
9057        MachineConfig {
9058            allocatable: Some(NodeAllocatable {
9059                memory_mb,
9060                cpu_millis,
9061            }),
9062            taints: taints.into_iter().map(String::from).collect(),
9063            ..make_machine(name, vec![])
9064        }
9065    }
9066
9067    fn server_spec(memory_mb: u32, cpu_millis: u32) -> WorkloadSpec {
9068        use workload_spec::{ImageRef, LifecycleArchetype, ResourceLimits, TierTag};
9069        let mut ws = WorkloadSpec::for_forge(
9070            "f5-test",
9071            ImageRef {
9072                registry: "localhost".into(),
9073                repository: "test".into(),
9074                tag: "latest".into(),
9075                digest: workload_spec::testing::test_digest(),
9076            },
9077            TierTag("infra".into()),
9078            vec![],
9079        );
9080        ws.archetype = Some(LifecycleArchetype::Server);
9081        ws.resources = ResourceLimits {
9082            memory_mb,
9083            cpu_millis,
9084            ephemeral_storage_mb: 0,
9085        };
9086        // These are SERVER specs that borrow `for_forge` as a constructor
9087        // shortcut, so drop the forge memory request it stamps on — otherwise
9088        // every spec here silently requests the forge default instead of the
9089        // `memory_mb` the caller passed, and the capacity-floor tests below
9090        // stop testing their own argument. A server workload declares no
9091        // request, which is the documented fall-back-to-`resources.memory_mb`
9092        // path (`WorkloadSpec::memory_request_mb`).
9093        ws.annotations
9094            .remove(workload_spec::MEMORY_REQUEST_ANNOTATION);
9095        ws
9096    }
9097
9098    fn appliance_spec_ws(memory_mb: u32, cpu_millis: u32) -> WorkloadSpec {
9099        use workload_spec::LifecycleArchetype;
9100        let mut ws = server_spec(memory_mb, cpu_millis);
9101        ws.archetype = Some(LifecycleArchetype::Appliance);
9102        ws
9103    }
9104
9105    #[test]
9106    fn capacity_floor_rejects_undersized_node() {
9107        let cfg = make_empty_cfg(vec![make_machine_with_capacity("small", 256, 500, vec![])]);
9108        let ws = server_spec(512, 1000); // demands more than available
9109        assert!(cfg.admit_workload(&ws).is_err());
9110    }
9111
9112    #[test]
9113    fn capacity_floor_accepts_exact_fit() {
9114        let cfg = make_empty_cfg(vec![make_machine_with_capacity("exact", 512, 1000, vec![])]);
9115        let ws = server_spec(512, 1000);
9116        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "exact");
9117    }
9118
9119    #[test]
9120    fn capacity_floor_passes_when_allocatable_absent() {
9121        // A machine with no allocatable block skips the capacity check (no data).
9122        let cfg = make_empty_cfg(vec![make_machine("no-alloc", vec![])]);
9123        let ws = server_spec(99999, 99999); // would exceed any real node
9124        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "no-alloc");
9125    }
9126
9127    #[test]
9128    fn taint_repulsion_blocks_appliance_on_no_appliance_node() {
9129        let cfg = make_empty_cfg(vec![make_machine_with_capacity(
9130            "south",
9131            1024,
9132            2000,
9133            vec!["no-appliance"],
9134        )]);
9135        let ws = appliance_spec_ws(256, 500);
9136        assert!(
9137            cfg.admit_workload(&ws).is_err(),
9138            "appliance must be repelled by no-appliance taint"
9139        );
9140    }
9141
9142    #[test]
9143    fn taint_repulsion_allows_server_on_no_appliance_node() {
9144        // "no-appliance" only repels Appliance workloads; servers are unaffected.
9145        let cfg = make_empty_cfg(vec![make_machine_with_capacity(
9146            "south",
9147            1024,
9148            2000,
9149            vec!["no-appliance"],
9150        )]);
9151        let ws = server_spec(256, 500);
9152        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "south");
9153    }
9154
9155    #[test]
9156    fn taint_repulsion_job_not_blocked_by_no_server() {
9157        use workload_spec::LifecycleArchetype;
9158        let cfg = make_empty_cfg(vec![make_machine_with_capacity(
9159            "build-box",
9160            8192,
9161            4000,
9162            vec!["no-server", "no-appliance"],
9163        )]);
9164        let mut ws = server_spec(256, 500);
9165        ws.archetype = Some(LifecycleArchetype::Job);
9166        // Job only repelled by "no-job"; "no-server" and "no-appliance" don't affect it.
9167        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "build-box");
9168    }
9169
9170    #[test]
9171    fn requires_taint_affinity_blocks_placement_without_it() {
9172        use workload_spec::{LifecycleArchetype, PUBLIC_IP_TAINT, REQUIRES_TAINT_ANNOTATION};
9173        // Simulate the passway ingress appliance: requires "public-ip" taint.
9174        let mut ws = appliance_spec_ws(256, 512);
9175        ws.archetype = Some(LifecycleArchetype::Appliance);
9176        ws.annotations
9177            .insert(REQUIRES_TAINT_ANNOTATION.into(), PUBLIC_IP_TAINT.into());
9178
9179        // Node without the taint: rejected.
9180        let cfg = make_empty_cfg(vec![make_machine_with_capacity(
9181            "no-pip",
9182            2048,
9183            2000,
9184            vec![],
9185        )]);
9186        assert!(cfg.admit_workload(&ws).is_err());
9187
9188        // Node with the taint: accepted.
9189        let cfg = make_empty_cfg(vec![make_machine_with_capacity(
9190            "pub-node",
9191            2048,
9192            2000,
9193            vec!["public-ip"],
9194        )]);
9195        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "pub-node");
9196    }
9197
9198    #[test]
9199    fn w244_fleet_scenario_appliance_rejected_from_south_and_west002() {
9200        // Full W244 fleet table scenario:
9201        // us-west-001/east-001: no taints, large capacity → appliance lands here
9202        // us-south-001: no-appliance taint → appliance rejected
9203        // us-west-002: no-server, no-appliance → appliance rejected
9204        let cfg = make_empty_cfg(vec![
9205            make_machine_with_capacity("us-south-001", 512, 1000, vec!["no-appliance"]),
9206            make_machine_with_capacity(
9207                "us-west-002",
9208                16384,
9209                8000,
9210                vec!["no-server", "no-appliance"],
9211            ),
9212            make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
9213        ]);
9214        let ws = appliance_spec_ws(256, 500);
9215        // Skips south (no-appliance) and west-002 (no-appliance), lands on west-001.
9216        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "us-west-001");
9217    }
9218
9219    #[test]
9220    fn w244_fleet_scenario_job_lands_on_west002_first() {
9221        use workload_spec::LifecycleArchetype;
9222        // Jobs should prefer (or at least land on) the job-only box.
9223        let cfg = make_empty_cfg(vec![
9224            make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
9225            make_machine_with_capacity(
9226                "us-west-002",
9227                16384,
9228                8000,
9229                vec!["no-server", "no-appliance"],
9230            ),
9231        ]);
9232        let mut ws = server_spec(256, 500);
9233        ws.archetype = Some(LifecycleArchetype::Job);
9234        // No fleet node declares `no-job`, so a Job is repelled by nothing;
9235        // west-001 comes first in declaration order (greedy, no preference),
9236        // which is the expected tie-break. Note this is *absence of a repel
9237        // key*, not toleration — no workload can tolerate a taint (W305).
9238        let picked = cfg.admit_workload(&ws).unwrap();
9239        // Both are eligible.
9240        assert!(
9241            picked.name == "us-west-001" || picked.name == "us-west-002",
9242            "job must land on an eligible node, got {}",
9243            picked.name
9244        );
9245    }
9246
9247    #[test]
9248    fn r569_f4_macos_node_taints_keep_cloud_critical_off_but_admit_build_jobs() {
9249        use workload_spec::LifecycleArchetype;
9250        // R569-F4: the headless M2 (us-west-015) joins the fleet as a
9251        // build-worker but must never take cloud-critical load. It carries the
9252        // same repel set as the x86 build-worker (`no-server, no-appliance` —
9253        // see .yah/infra/machines/us-west-015.toml). This pins that intent:
9254        // with a plain cloud node available beside the Mac, every
9255        // cloud-critical archetype lands on the cloud node and never the Mac;
9256        // build Jobs (the Mac's actual purpose) remain eligible on it.
9257        //
9258        // R742-T4: `no-voter` used to sit in this set and in the TOML. It was
9259        // never read here — there is no "voter" workload archetype — and
9260        // R569-F3's learner-only join is what actually keeps the box out of
9261        // quorum. It is now rejected by `yah cloud validate` as inert.
9262        let mac_taints = vec!["no-server", "no-appliance"];
9263        let fleet = || {
9264            make_empty_cfg(vec![
9265                make_machine_with_capacity("us-west-015", 24576, 8000, mac_taints.clone()),
9266                make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
9267            ])
9268        };
9269
9270        // A cloud-critical Server workload is repelled from the Mac and lands
9271        // on the untainted cloud node.
9272        let cfg = fleet();
9273        assert_eq!(
9274            cfg.admit_workload(&server_spec(256, 500)).unwrap().name,
9275            "us-west-001",
9276            "a Server workload must never land on the no-server Mac node"
9277        );
9278
9279        // Same for an Appliance (pinned/stateful cloud-critical) workload.
9280        let cfg = fleet();
9281        assert_eq!(
9282            cfg.admit_workload(&appliance_spec_ws(256, 500))
9283                .unwrap()
9284                .name,
9285            "us-west-001",
9286            "an Appliance workload must never land on the no-appliance Mac node"
9287        );
9288
9289        // Sharpest repulsion proof: with ONLY the Mac in the fleet, a
9290        // cloud-critical Server workload is rejected outright — the taint keeps
9291        // it off even when that means nowhere to run.
9292        let mac_only = make_empty_cfg(vec![make_machine_with_capacity(
9293            "us-west-015",
9294            24576,
9295            8000,
9296            mac_taints.clone(),
9297        )]);
9298        assert!(
9299            mac_only.admit_workload(&server_spec(256, 500)).is_err(),
9300            "a Server workload must be repelled from a Mac-only fleet, not admitted"
9301        );
9302
9303        // But the Mac's real job — build/forge workloads — IS admitted on it:
9304        // it tolerates every fleet taint (there is no `no-job`).
9305        let mut job = server_spec(256, 500);
9306        job.archetype = Some(LifecycleArchetype::Job);
9307        assert_eq!(
9308            mac_only.admit_workload(&job).unwrap().name,
9309            "us-west-015",
9310            "a build Job must still be admitted on the Mac build-worker"
9311        );
9312    }
9313
9314    // ─── R615-F1: linked infra sources (`.yah/infra/sources.toml`) ─────────
9315
9316    #[test]
9317    fn sources_load_is_empty_when_the_file_is_absent() {
9318        // "Every camp without linked infra has none" — which today is every
9319        // camp — must not be an error.
9320        let tmp = tempfile::TempDir::new().unwrap();
9321        let cfg = SourcesConfig::load(tmp.path()).unwrap();
9322        assert_eq!(cfg, SourcesConfig::default());
9323        assert!(cfg.source.is_empty());
9324        assert_eq!(cfg.schema_version, 1);
9325    }
9326
9327    #[test]
9328    fn sources_parses_a_path_kind_exactly_like_w274s_example() {
9329        let tmp = tempfile::TempDir::new().unwrap();
9330        std::fs::write(
9331            tmp.path().join("sources.toml"),
9332            r#"
9333schema_version = 1
9334
9335[[source]]
9336owner = "yah"
9337kind  = "path"
9338path  = "../yah"
9339mode  = "read-only"
9340"#,
9341        )
9342        .unwrap();
9343        let cfg = SourcesConfig::load(tmp.path()).unwrap();
9344        assert_eq!(cfg.source.len(), 1);
9345        let s = &cfg.source[0];
9346        assert_eq!(s.owner, "yah");
9347        assert_eq!(s.mode, SourceMode::ReadOnly);
9348        assert!(s.select.is_empty());
9349        match &s.kind {
9350            InfraSourceKind::Path { path } => assert_eq!(path, "../yah"),
9351            other => panic!("expected Path, got {other:?}"),
9352        }
9353    }
9354
9355    #[test]
9356    fn sources_parses_a_git_kind_reusing_gitsource_verbatim() {
9357        let tmp = tempfile::TempDir::new().unwrap();
9358        std::fs::write(
9359            tmp.path().join("sources.toml"),
9360            r#"
9361schema_version = 1
9362
9363[[source]]
9364owner  = "yah"
9365kind   = "git"
9366repo   = "git@github.com:yah-ai/infra.git"
9367ref    = "main"
9368subdir = "infra"
9369select = ["tag:cloud-runner"]
9370mode   = "read-only"
9371"#,
9372        )
9373        .unwrap();
9374        let cfg = SourcesConfig::load(tmp.path()).unwrap();
9375        assert_eq!(cfg.source.len(), 1);
9376        let s = &cfg.source[0];
9377        assert_eq!(s.select, vec!["tag:cloud-runner".to_string()]);
9378        match &s.kind {
9379            InfraSourceKind::Git(git) => {
9380                assert_eq!(git.repo, "git@github.com:yah-ai/infra.git");
9381                assert_eq!(git.r#ref, "main");
9382                assert_eq!(git.subdir.as_deref(), Some("infra"));
9383            }
9384            other => panic!("expected Git, got {other:?}"),
9385        }
9386    }
9387
9388    #[test]
9389    fn sources_mode_defaults_to_read_only_and_manage_is_explicit() {
9390        let tmp = tempfile::TempDir::new().unwrap();
9391        std::fs::write(
9392            tmp.path().join("sources.toml"),
9393            r#"
9394schema_version = 1
9395
9396[[source]]
9397owner = "a"
9398kind  = "path"
9399path  = "../a"
9400
9401[[source]]
9402owner = "b"
9403kind  = "path"
9404path  = "../b"
9405mode  = "manage"
9406"#,
9407        )
9408        .unwrap();
9409        let cfg = SourcesConfig::load(tmp.path()).unwrap();
9410        assert_eq!(cfg.source[0].mode, SourceMode::ReadOnly, "omitted mode = read-only");
9411        assert_eq!(cfg.source[1].mode, SourceMode::Manage);
9412    }
9413
9414    #[test]
9415    fn sources_preserves_declaration_order() {
9416        // Overlay order matters (R615-F2) when two sources name the same
9417        // machine — the list must round-trip in file order, not be reordered
9418        // by owner or kind.
9419        let tmp = tempfile::TempDir::new().unwrap();
9420        std::fs::write(
9421            tmp.path().join("sources.toml"),
9422            r#"
9423schema_version = 1
9424
9425[[source]]
9426owner = "second"
9427kind  = "path"
9428path  = "../second"
9429
9430[[source]]
9431owner = "first"
9432kind  = "path"
9433path  = "../first"
9434"#,
9435        )
9436        .unwrap();
9437        let cfg = SourcesConfig::load(tmp.path()).unwrap();
9438        let owners: Vec<&str> = cfg.source.iter().map(|s| s.owner.as_str()).collect();
9439        assert_eq!(owners, vec!["second", "first"]);
9440    }
9441
9442    #[test]
9443    fn sources_round_trips_through_serialize() {
9444        let cfg = SourcesConfig {
9445            schema_version: 1,
9446            source: vec![
9447                InfraSource {
9448                    owner: "yah".into(),
9449                    kind: InfraSourceKind::Path {
9450                        path: "../yah".into(),
9451                    },
9452                    mode: SourceMode::ReadOnly,
9453                    select: vec![],
9454                },
9455                InfraSource {
9456                    owner: "yah".into(),
9457                    kind: InfraSourceKind::Git(GitSource {
9458                        repo: "git@github.com:yah-ai/infra.git".into(),
9459                        r#ref: "main".into(),
9460                        subdir: Some("infra".into()),
9461                    }),
9462                    mode: SourceMode::Manage,
9463                    select: vec!["tag:cloud-runner".into()],
9464                },
9465            ],
9466        };
9467        let toml_str = toml::to_string_pretty(&cfg).unwrap();
9468        let reloaded: SourcesConfig = toml::from_str(&toml_str).unwrap();
9469        assert_eq!(reloaded, cfg, "round-trip through TOML must be lossless:\n{toml_str}");
9470    }
9471
9472    // ─── R615-F2: overlay loader in CloudConfig::load ───────────────────────
9473
9474    fn write_min_machine(dir: &Path, name: &str, extra_toml: &str) {
9475        std::fs::create_dir_all(dir).unwrap();
9476        // `extra_toml` supplies `mesh_tags` when the caller cares about it;
9477        // otherwise default to the empty list. Never hardcode `mesh_tags`
9478        // here as well as in `extra_toml` -- TOML rejects a duplicate key.
9479        let mesh_tags = if extra_toml.contains("mesh_tags") {
9480            String::new()
9481        } else {
9482            "mesh_tags = []\n".to_string()
9483        };
9484        std::fs::write(
9485            dir.join(format!("{name}.toml")),
9486            format!("name = \"{name}\"\nprovider = \"static\"\n{mesh_tags}{extra_toml}"),
9487        )
9488        .unwrap();
9489    }
9490
9491    fn write_min_provider(dir: &Path, id: &str) {
9492        std::fs::create_dir_all(dir).unwrap();
9493        std::fs::write(
9494            dir.join(format!("{id}.toml")),
9495            format!("schema_version = 1\nid = \"{id}\"\nkind = \"static\"\n"),
9496        )
9497        .unwrap();
9498    }
9499
9500    fn write_sources_toml(camp_root: &Path, body: &str) {
9501        let dir = camp_root.join(".yah/infra");
9502        std::fs::create_dir_all(&dir).unwrap();
9503        std::fs::write(dir.join("sources.toml"), body).unwrap();
9504    }
9505
9506    #[test]
9507    fn load_with_no_sources_toml_is_unchanged() {
9508        let tmp = tempfile::TempDir::new().unwrap();
9509        write_min_machine(&tmp.path().join(".yah/infra/machines"), "local-1", "");
9510        let cfg = CloudConfig::load(tmp.path()).unwrap();
9511        assert_eq!(cfg.machines.len(), 1);
9512        assert!(cfg.machine_origins.is_empty());
9513        assert!(cfg.provider_origins.is_empty());
9514    }
9515
9516    #[test]
9517    fn path_source_overlays_machines_and_providers_tagged_with_origin() {
9518        let camp = tempfile::TempDir::new().unwrap();
9519        let other = tempfile::TempDir::new().unwrap();
9520        write_min_machine(&other.path().join(".yah/infra/machines"), "borrowed-1", "");
9521        write_min_provider(&other.path().join(".yah/infra/providers"), "borrowed-provider");
9522        write_sources_toml(
9523            camp.path(),
9524            &format!(
9525                "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
9526                other.path().display()
9527            ),
9528        );
9529
9530        let cfg = CloudConfig::load(camp.path()).unwrap();
9531        assert_eq!(cfg.machines.len(), 1);
9532        assert_eq!(cfg.machines[0].name, "borrowed-1");
9533        assert_eq!(cfg.providers.len(), 1);
9534        assert_eq!(cfg.providers[0].id, "borrowed-provider");
9535
9536        let origin = cfg.machine_origins.get("borrowed-1").expect("origin recorded");
9537        assert_eq!(origin.owner, "other");
9538        assert_eq!(origin.mode, SourceMode::ReadOnly);
9539        assert!(origin.source.starts_with("path:"));
9540        assert_eq!(
9541            cfg.provider_origins.get("borrowed-provider").unwrap().owner,
9542            "other"
9543        );
9544    }
9545
9546    #[test]
9547    fn camp_local_wins_on_name_collision_and_carries_no_origin() {
9548        let camp = tempfile::TempDir::new().unwrap();
9549        let other = tempfile::TempDir::new().unwrap();
9550        // Both declare a machine named "shared" -- camp-local's copy must win,
9551        // and it must never gain an origin tag.
9552        write_min_machine(&camp.path().join(".yah/infra/machines"), "shared", "");
9553        write_min_machine(
9554            &other.path().join(".yah/infra/machines"),
9555            "shared",
9556            "nickname = \"the borrowed one\"\n",
9557        );
9558        write_sources_toml(
9559            camp.path(),
9560            &format!(
9561                "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
9562                other.path().display()
9563            ),
9564        );
9565
9566        let cfg = CloudConfig::load(camp.path()).unwrap();
9567        assert_eq!(cfg.machines.len(), 1, "the name collides, so exactly one entry");
9568        assert_eq!(cfg.machines[0].nickname, None, "camp-local's copy, not the borrowed one");
9569        assert!(
9570            !cfg.machine_origins.contains_key("shared"),
9571            "camp-local entries never carry an origin tag"
9572        );
9573    }
9574
9575    #[test]
9576    fn an_earlier_source_wins_over_a_later_one_on_collision() {
9577        let camp = tempfile::TempDir::new().unwrap();
9578        let first = tempfile::TempDir::new().unwrap();
9579        let second = tempfile::TempDir::new().unwrap();
9580        write_min_machine(&first.path().join(".yah/infra/machines"), "dup", "");
9581        write_min_machine(&second.path().join(".yah/infra/machines"), "dup", "");
9582        write_sources_toml(
9583            camp.path(),
9584            &format!(
9585                "schema_version = 1\n\n[[source]]\nowner = \"first\"\nkind = \"path\"\npath = \"{}\"\n\n[[source]]\nowner = \"second\"\nkind = \"path\"\npath = \"{}\"\n",
9586                first.path().display(),
9587                second.path().display()
9588            ),
9589        );
9590
9591        let cfg = CloudConfig::load(camp.path()).unwrap();
9592        assert_eq!(cfg.machines.len(), 1);
9593        assert_eq!(cfg.machine_origins.get("dup").unwrap().owner, "first");
9594    }
9595
9596    #[test]
9597    fn select_filters_borrowed_machines_by_name_or_mesh_tag() {
9598        let camp = tempfile::TempDir::new().unwrap();
9599        let other = tempfile::TempDir::new().unwrap();
9600        write_min_machine(&other.path().join(".yah/infra/machines"), "runner-1", "mesh_tags = [\"tag:cloud-runner\"]\n");
9601        write_min_machine(&other.path().join(".yah/infra/machines"), "excluded-1", "");
9602        write_sources_toml(
9603            camp.path(),
9604            &format!(
9605                "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\nselect = [\"tag:cloud-runner\"]\n",
9606                other.path().display()
9607            ),
9608        );
9609
9610        let cfg = CloudConfig::load(camp.path()).unwrap();
9611        assert_eq!(cfg.machines.len(), 1);
9612        assert_eq!(cfg.machines[0].name, "runner-1");
9613    }
9614
9615    #[test]
9616    fn one_unparseable_foreign_machine_does_not_sink_the_rest_of_the_directory_or_the_load() {
9617        let camp = tempfile::TempDir::new().unwrap();
9618        let other = tempfile::TempDir::new().unwrap();
9619        let dir = other.path().join(".yah/infra/machines");
9620        write_min_machine(&dir, "good", "");
9621        // Schema-skew gotcha: a foreign machine this binary's MachineConfig
9622        // can't parse at all (not just an unknown field -- MachineConfig has
9623        // no deny_unknown_fields, so this has to fail on a TYPE, not a name).
9624        std::fs::write(dir.join("bad.toml"), "name = 1\nprovider = 2\n").unwrap();
9625        write_sources_toml(
9626            camp.path(),
9627            &format!(
9628                "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
9629                other.path().display()
9630            ),
9631        );
9632
9633        // Must not error at all -- camp-local load must never fail because a
9634        // source it doesn't own has one bad file.
9635        let cfg = CloudConfig::load(camp.path()).unwrap();
9636        assert_eq!(cfg.machines.len(), 1, "the good entry still loads");
9637        assert_eq!(cfg.machines[0].name, "good");
9638    }
9639
9640    #[test]
9641    fn an_unsynced_git_source_overlays_nothing_and_is_not_an_error() {
9642        // No `yah infra sync` (R615-T3) has ever run, so the cache dir this
9643        // resolves to doesn't exist. Must be silent, not fatal.
9644        let camp = tempfile::TempDir::new().unwrap();
9645        write_sources_toml(
9646            camp.path(),
9647            "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\n",
9648        );
9649        let cfg = CloudConfig::load(camp.path()).unwrap();
9650        assert!(cfg.machines.is_empty());
9651        assert!(cfg.machine_origins.is_empty());
9652    }
9653
9654    #[test]
9655    fn a_synced_git_source_reads_from_the_cache_dir_not_the_repo_path() {
9656        // No `subdir` declared -- the checkout ROOT is the infra root.
9657        let camp = tempfile::TempDir::new().unwrap();
9658        let cache = crate::paths::infra_source_cache_dir(camp.path(), "yah");
9659        write_min_machine(&cache.join("machines"), "synced-1", "");
9660        write_sources_toml(
9661            camp.path(),
9662            "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\n",
9663        );
9664        let cfg = CloudConfig::load(camp.path()).unwrap();
9665        assert_eq!(cfg.machines.len(), 1);
9666        assert_eq!(cfg.machines[0].name, "synced-1");
9667        assert!(cfg.machine_origins.get("synced-1").unwrap().source.starts_with("git:"));
9668    }
9669
9670    #[test]
9671    fn a_git_sources_subdir_is_honoured_like_the_component_case() {
9672        // W274's own example declares `subdir = "infra"` for a monorepo whose
9673        // registry lives under a subdirectory of the clone rather than at its
9674        // root -- prove `infra_root` actually reads it, not just `.subdir` on
9675        // GitSource parsing (R615-F1 already covers that half).
9676        let camp = tempfile::TempDir::new().unwrap();
9677        let cache = crate::paths::infra_source_cache_dir(camp.path(), "yah");
9678        write_min_machine(&cache.join("infra").join("machines"), "subdir-1", "");
9679        // Also plant a decoy at the checkout root to prove the root itself is
9680        // NOT read when a subdir is declared.
9681        write_min_machine(&cache.join("machines"), "root-decoy", "");
9682        write_sources_toml(
9683            camp.path(),
9684            "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\nsubdir = \"infra\"\n",
9685        );
9686        let cfg = CloudConfig::load(camp.path()).unwrap();
9687        assert_eq!(cfg.machines.len(), 1);
9688        assert_eq!(cfg.machines[0].name, "subdir-1");
9689    }
9690
9691    #[test]
9692    fn load_from_config_dir_never_applies_sources_overlay() {
9693        // R615-F2's explicit decision: multi-root sibling trees don't inherit
9694        // the classic .yah/infra/sources.toml. Prove it rather than assert it
9695        // silently -- a sources.toml sitting at workspace_root/.yah/infra/
9696        // must NOT leak into a load_from_config_dir call even though both
9697        // share the same workspace_root.
9698        let camp = tempfile::TempDir::new().unwrap();
9699        let other = tempfile::TempDir::new().unwrap();
9700        write_min_machine(&other.path().join(".yah/infra/machines"), "borrowed-1", "");
9701        write_sources_toml(
9702            camp.path(),
9703            &format!(
9704                "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
9705                other.path().display()
9706            ),
9707        );
9708        let sibling_config_dir = camp.path().join(".noisetable");
9709        std::fs::create_dir_all(&sibling_config_dir).unwrap();
9710
9711        let cfg = CloudConfig::load_from_config_dir(&sibling_config_dir, camp.path()).unwrap();
9712        assert!(cfg.machines.is_empty(), "sources.toml must not apply here");
9713        assert!(cfg.machine_origins.is_empty());
9714    }
9715
9716    // ─── R615-T5: `inherit_machines` retirement — cutover proof ────────────
9717
9718    /// The successor to R615-T5's parity proof. That earlier pair of tests
9719    /// asserted the legacy `[infra].inherit_machines` redirect and an
9720    /// equivalent `kind = "path"` source resolved the same machine set, and
9721    /// that the two coexisted without duplicating rows. Both claims were about
9722    /// a mechanism that no longer exists, so they retired with it — what has
9723    /// to hold *now* is the other half of the same guarantee: a camp that
9724    /// declares only `sources.toml` resolves the shared root exactly as the
9725    /// redirect used to, and a stale `inherit_machines` key left behind in
9726    /// `camp.toml` changes nothing.
9727    ///
9728    /// That stale-key case is not hypothetical: it is precisely the state a
9729    /// camp is in between the code cutover and someone tidying its
9730    /// `camp.toml`, and a silent re-resolution there would double-count the
9731    /// borrowed nodes or hide their origin badge.
9732    #[test]
9733    fn a_stale_inherit_machines_key_does_not_change_what_sources_toml_resolves() {
9734        let shared = tempfile::TempDir::new().unwrap();
9735        write_min_machine(&shared.path().join(".yah/infra/machines"), "shared-node-1", "");
9736        write_min_machine(&shared.path().join(".yah/infra/machines"), "shared-node-2", "");
9737
9738        let sources_toml = format!(
9739            "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"path\"\npath = \"{}\"\nmode = \"read-only\"\n",
9740            shared.path().display()
9741        );
9742
9743        // Camp A: migrated cleanly — sources.toml only.
9744        let clean = tempfile::TempDir::new().unwrap();
9745        write_sources_toml(clean.path(), &sources_toml);
9746
9747        // Camp B: mid-migration — same source, plus the retired key still
9748        // sitting in camp.toml pointing at the same root.
9749        let stale = tempfile::TempDir::new().unwrap();
9750        std::fs::create_dir_all(stale.path().join(".yah")).unwrap();
9751        std::fs::write(
9752            stale.path().join(".yah/camp.toml"),
9753            format!(
9754                "[infra]\ninherit_machines = \"{}\"\n",
9755                shared.path().display()
9756            ),
9757        )
9758        .unwrap();
9759        write_sources_toml(stale.path(), &sources_toml);
9760
9761        let via_clean = CloudConfig::load(clean.path()).unwrap();
9762        let via_stale = CloudConfig::load(stale.path()).unwrap();
9763
9764        let names = |cfg: &CloudConfig| {
9765            let mut v: Vec<String> = cfg.machines.iter().map(|m| m.name.clone()).collect();
9766            v.sort();
9767            v
9768        };
9769        assert_eq!(
9770            names(&via_clean),
9771            names(&via_stale),
9772            "a leftover inherit_machines key must be inert — the retired redirect is gone"
9773        );
9774        assert_eq!(names(&via_clean), vec!["shared-node-1", "shared-node-2"]);
9775
9776        // And both are *borrowed*, not camp-local. This is the operator-facing
9777        // win the stopgap could never deliver: under the old redirect these
9778        // resolved with no origin at all, indistinguishable from locally-owned
9779        // nodes.
9780        assert_eq!(via_clean.machine_origins.len(), 2);
9781        assert_eq!(via_stale.machine_origins.len(), 2);
9782        for origin in via_stale.machine_origins.values() {
9783            assert_eq!(origin.owner, "yah");
9784            assert_eq!(origin.mode, SourceMode::ReadOnly);
9785        }
9786    }
9787
9788    // ─── R860-T4 (W338): placement groups ───────────────────────────────────
9789
9790    /// One requirement edge, written the way a spec author writes it.
9791    fn requirement(ident: &str, locality: Locality) -> workload_spec::Requirement {
9792        workload_spec::Requirement {
9793            ident: workload_spec::MeshIdent(ident.into()),
9794            locality,
9795            supply: workload_spec::Supply::Wait,
9796            provides: None,
9797        }
9798    }
9799
9800    /// A `minimal_spec` (256 MiB / 250 millicores, Server by inference) that
9801    /// requires the given edges.
9802    fn spec_requiring(name: &str, requires: Vec<workload_spec::Requirement>) -> WorkloadSpec {
9803        WorkloadSpec {
9804            requires,
9805            ..minimal_spec(name, 1)
9806        }
9807    }
9808
9809    /// The declared inventory an ident is resolved against — `.yah/infra/workloads/`.
9810    fn declared(specs: Vec<WorkloadSpec>) -> Vec<WorkloadConfig> {
9811        specs.into_iter().map(|spec| WorkloadConfig { spec }).collect()
9812    }
9813
9814    fn member_names(group: &[WorkloadSpec]) -> Vec<&str> {
9815        group.iter().map(|s| s.name.as_str()).collect()
9816    }
9817
9818    /// The headline case: `local` means "same node", so the two specs are one
9819    /// placement unit and admission has to reason about both.
9820    #[test]
9821    fn a_local_edge_binds_the_provider_into_the_placement_group() {
9822        let inventory = declared(vec![minimal_spec("headscale-replicator", 1)]);
9823        let requirer = spec_requiring(
9824            "headscale",
9825            vec![requirement("headscale-replicator", Locality::Local)],
9826        );
9827
9828        assert_eq!(
9829            member_names(&placement_group(&requirer, &inventory)),
9830            vec!["headscale", "headscale-replicator"]
9831        );
9832    }
9833
9834    /// The edge that must NOT bind. `prefer-local` "never blocks placement"
9835    /// (W338's locality table), and `anywhere` — which is what every legacy
9836    /// `depends_on` folds into — is an ordinary service dependency. Binding
9837    /// either would silently make every dependency in the tree a co-scheduling
9838    /// constraint and start summing unrelated workloads into the capacity floor.
9839    #[test]
9840    fn prefer_local_and_anywhere_edges_do_not_bind_the_group() {
9841        let inventory = declared(vec![
9842            minimal_spec("headscale-db", 1),
9843            minimal_spec("metrics", 1),
9844            minimal_spec("legacy-dep", 1),
9845        ]);
9846
9847        let requirer = WorkloadSpec {
9848            depends_on: vec![workload_spec::MeshIdent("legacy-dep".into())],
9849            ..spec_requiring(
9850                "headscale",
9851                vec![
9852                    requirement("headscale-db", Locality::PreferLocal),
9853                    requirement("metrics", Locality::Anywhere),
9854                ],
9855            )
9856        };
9857
9858        assert_eq!(
9859            member_names(&placement_group(&requirer, &inventory)),
9860            vec!["headscale"]
9861        );
9862    }
9863
9864    /// Transitive, and via the inline spec a `supply = "self"` requirement
9865    /// carries rather than via an ident lookup — the sidecar shape W338's
9866    /// worked example is built on.
9867    #[test]
9868    fn the_group_is_the_transitive_closure_and_traverses_inline_provides() {
9869        let inline = workload_spec::Requirement {
9870            supply: workload_spec::Supply::SelfProvision,
9871            provides: Some(Box::new(minimal_spec("headscale-restore", 1))),
9872            ..requirement("headscale-restore", Locality::Local)
9873        };
9874        let middle = WorkloadSpec {
9875            requires: vec![requirement("wal-shipper", Locality::Local)],
9876            ..minimal_spec("headscale-replicator", 1)
9877        };
9878        let inventory = declared(vec![middle, minimal_spec("wal-shipper", 1)]);
9879
9880        let requirer = spec_requiring(
9881            "headscale",
9882            vec![
9883                inline,
9884                requirement("headscale-replicator", Locality::Local),
9885            ],
9886        );
9887
9888        assert_eq!(
9889            member_names(&placement_group(&requirer, &inventory)),
9890            vec![
9891                "headscale",
9892                "headscale-restore",
9893                "headscale-replicator",
9894                "wal-shipper"
9895            ]
9896        );
9897    }
9898
9899    /// `validate::check_requires` bounds `provides` nesting to depth 1 but
9900    /// cannot stop two separately-declared specs from naming each other. Without
9901    /// the visited set this closure never terminates, so admission would hang
9902    /// rather than refuse — the worst failure shape for a deploy gate.
9903    #[test]
9904    fn an_ident_cycle_closes_the_group_instead_of_looping_forever() {
9905        let b = spec_requiring("b", vec![requirement("a", Locality::Local)]);
9906        let a = spec_requiring("a", vec![requirement("b", Locality::Local)]);
9907        let inventory = declared(vec![a.clone(), b]);
9908
9909        assert_eq!(member_names(&placement_group(&a, &inventory)), vec!["a", "b"]);
9910    }
9911
9912    /// An unresolvable ident is skipped, not fatal: admission is a pure function
9913    /// of the declared inventory, and refusing every deploy whose provider is
9914    /// not yet declared would make `requires` unusable before R860-T6 lands.
9915    #[test]
9916    fn an_unresolvable_local_ident_is_skipped_rather_than_refused() {
9917        let requirer = spec_requiring("headscale", vec![requirement("not-declared", Locality::Local)]);
9918        assert_eq!(
9919            member_names(&placement_group(&requirer, &[])),
9920            vec!["headscale"]
9921        );
9922    }
9923
9924    /// W338 §Placement consequences 1: the capacity floor is the group's sum.
9925    /// A node that fits the requirer alone must refuse the group — placing it
9926    /// there would oversubscribe the node the moment the provider follows.
9927    #[test]
9928    fn the_capacity_floor_is_the_sum_of_the_group_not_the_requirer_alone() {
9929        let provider = minimal_spec("headscale-replicator", 1);
9930        let requirer = spec_requiring(
9931            "headscale",
9932            vec![requirement("headscale-replicator", Locality::Local)],
9933        );
9934        // Two `minimal_spec`s: 256 MiB + 250 millicores each.
9935        let inventory = declared(vec![provider]);
9936
9937        let too_small = CloudConfig {
9938            workloads: inventory.clone(),
9939            ..make_empty_cfg(vec![make_machine_with_capacity("small", 300, 4000, vec![])])
9940        };
9941        let err = too_small.admit_workload(&requirer).unwrap_err().to_string();
9942        assert!(
9943            err.contains("memory_mb>=512"),
9944            "the floor must name the group's summed demand, got: {err}"
9945        );
9946
9947        let big_enough = CloudConfig {
9948            workloads: inventory,
9949            ..make_empty_cfg(vec![make_machine_with_capacity("roomy", 512, 4000, vec![])])
9950        };
9951        assert_eq!(
9952            big_enough.admit_workload(&requirer).unwrap().name,
9953            "roomy",
9954            "a node covering the sum must still admit the group"
9955        );
9956    }
9957
9958    /// W338 §Placement consequences 2, and the reason repulsion is computed over
9959    /// a set at all: the requirer is a `Server`, so the pre-R860 axis would have
9960    /// let it onto a `no-appliance` dev Pi and dragged its Appliance provider
9961    /// there with it.
9962    #[test]
9963    fn a_server_requiring_an_appliance_locally_is_repelled_by_no_appliance() {
9964        let appliance = WorkloadSpec {
9965            archetype: Some(LifecycleArchetype::Appliance),
9966            ..minimal_spec("headscale", 1)
9967        };
9968        let requirer = spec_requiring("headscale-ui", vec![requirement("headscale", Locality::Local)]);
9969        assert_eq!(
9970            requirer.effective_archetype(),
9971            LifecycleArchetype::Server,
9972            "precondition: the requirer itself must not be an Appliance"
9973        );
9974
9975        let cfg = CloudConfig {
9976            workloads: declared(vec![appliance]),
9977            ..make_empty_cfg(vec![
9978                make_machine_with_capacity("dev-pi", 8192, 4000, vec!["no-appliance"]),
9979                make_machine_with_capacity("us-west-001", 8192, 4000, vec![]),
9980            ])
9981        };
9982
9983        assert_eq!(
9984            cfg.admit_workload(&requirer).unwrap().name,
9985            "us-west-001",
9986            "the dev Pi repels the group's Appliance member"
9987        );
9988
9989        // And with the Appliance gone from the group, the same requirer is
9990        // admissible on the same Pi — proving the repulsion came from the edge.
9991        let alone = minimal_spec("headscale-ui", 1);
9992        assert_eq!(cfg.admit_workload(&alone).unwrap().name, "dev-pi");
9993    }
9994
9995    /// The set-valued form of the per-workload drain skip the node makes in
9996    /// `drain_workloads`: one Appliance member pins the whole group.
9997    #[test]
9998    fn a_group_containing_an_appliance_is_not_drainable() {
9999        let server = minimal_spec("headscale-ui", 1);
10000        let appliance = WorkloadSpec {
10001            archetype: Some(LifecycleArchetype::Appliance),
10002            ..minimal_spec("headscale", 1)
10003        };
10004
10005        assert!(group_is_drainable(std::slice::from_ref(&server)));
10006        assert!(!group_is_drainable(&[server, appliance]));
10007    }
10008
10009    /// The regression that matters most: nothing in the tree declares
10010    /// `requires` yet, so every existing spec's group is exactly itself and its
10011    /// admission axes must be bit-identical to the pre-R860 derivation.
10012    #[test]
10013    fn a_spec_with_no_local_edges_admits_exactly_as_it_did_before() {
10014        let ws = ws_with_selector(Some("tag:build-worker,arch:x86"));
10015        let req = admission_spec(&ws, &[]);
10016
10017        assert_eq!(req.mesh_tags, vec!["tag:build-worker", "arch:x86"]);
10018        assert_eq!(req.memory_mb, ws.memory_request_mb());
10019        assert_eq!(req.cpu_millis, ws.resources.cpu_millis);
10020        assert_eq!(req.repel_archetypes, vec![ws.effective_archetype()]);
10021    }
10022
10023    // ─── R860-T5 (W338 §Placement consequences 3): native-exec capability ────
10024
10025    /// A `minimal_spec` carrying the `yah.exec = native` marker — the only way
10026    /// a workload says "fork+exec me on the host" (`WorkloadSpec::
10027    /// wants_native_exec`). It stays a Container workload on the wire; the
10028    /// marker is the whole difference.
10029    fn native_spec(name: &str) -> WorkloadSpec {
10030        let mut ws = minimal_spec(name, 1);
10031        ws.annotations.insert(
10032            workload_spec::NATIVE_EXEC_ANNOTATION.to_string(),
10033            workload_spec::NATIVE_EXEC_VALUE.to_string(),
10034        );
10035        assert!(ws.wants_native_exec(), "precondition: the marker must read back");
10036        ws
10037    }
10038
10039    /// The R858 failure, now caught at placement instead of at dispatch: a node
10040    /// whose kamaji has no `--native-exec-dir` accepted the election and then
10041    /// refused the deploy, and nothing upstream could see it coming.
10042    #[test]
10043    fn a_node_without_the_native_exec_capability_cannot_host_a_native_workload() {
10044        let native = native_spec("headscale");
10045
10046        let incapable = make_empty_cfg(vec![make_machine("us-south-001", vec![])]);
10047        let err = incapable.admit_workload(&native).unwrap_err().to_string();
10048        assert!(
10049            err.contains(NATIVE_EXEC_MESH_TAG),
10050            "the refusal must name the missing capability, got: {err}"
10051        );
10052
10053        let capable = make_empty_cfg(vec![
10054            make_machine("us-south-001", vec![]),
10055            make_machine("us-west-001", vec![NATIVE_EXEC_MESH_TAG]),
10056        ]);
10057        assert_eq!(
10058            capable.admit_workload(&native).unwrap().name,
10059            "us-west-001",
10060            "a node declaring the capability admits the native workload"
10061        );
10062    }
10063
10064    /// W338's actual sentence: a `supply = "self"` spec "must be placeable where
10065    /// its requirer lands". The requirer here is an ordinary container workload
10066    /// — it is the *provider* reached by a `local` edge that needs the host
10067    /// backend, so the capability has to be required of the group, not of the
10068    /// spec being deployed.
10069    #[test]
10070    fn a_local_edge_to_a_native_provider_makes_the_requirer_need_the_capability() {
10071        let requirer = spec_requiring(
10072            "headscale-ui",
10073            vec![requirement("headscale", Locality::Local)],
10074        );
10075        assert!(
10076            !requirer.wants_native_exec(),
10077            "precondition: the requirer itself is an ordinary container workload"
10078        );
10079
10080        let cfg = CloudConfig {
10081            workloads: declared(vec![native_spec("headscale")]),
10082            ..make_empty_cfg(vec![
10083                make_machine("plain", vec![]),
10084                make_machine("us-west-001", vec![NATIVE_EXEC_MESH_TAG]),
10085            ])
10086        };
10087
10088        assert_eq!(
10089            cfg.admit_workload(&requirer).unwrap().name,
10090            "us-west-001",
10091            "the group's native member pulls the requirer onto a capable node"
10092        );
10093
10094        // Without the edge the same requirer is admissible on the plain node,
10095        // so the constraint provably came from the group and not from the spec.
10096        let alone = minimal_spec("headscale-ui", 1);
10097        assert_eq!(cfg.admit_workload(&alone).unwrap().name, "plain");
10098    }
10099
10100    /// The regression guard: nothing in the tree is native-marked today, so
10101    /// every existing spec's axes must be untouched by this ticket.
10102    #[test]
10103    fn a_group_with_no_native_member_does_not_require_the_capability() {
10104        let inventory = declared(vec![minimal_spec("headscale-replicator", 1)]);
10105        let requirer = spec_requiring(
10106            "headscale",
10107            vec![requirement("headscale-replicator", Locality::Local)],
10108        );
10109
10110        let req = admission_spec(&requirer, &inventory);
10111        assert!(
10112            !req.mesh_tags.iter().any(|t| t == NATIVE_EXEC_MESH_TAG),
10113            "no native member ⇒ no capability axis, got: {:?}",
10114            req.mesh_tags
10115        );
10116
10117        // And it still lands on a node that declares nothing at all.
10118        let cfg = CloudConfig {
10119            workloads: inventory,
10120            ..make_empty_cfg(vec![make_machine("plain", vec![])])
10121        };
10122        assert_eq!(cfg.admit_workload(&requirer).unwrap().name, "plain");
10123    }
10124}