Skip to main content

cloud/
topology.rs

1//! R850 — what the declared topology does when a node dies, and whether it
2//! fits on the boxes it is declared against.
3//!
4//! # The question this module exists to answer
5//!
6//! From the noisetable camp, 2026-09-02: *"I have a singleton process with
7//! three in-process turso DBs on one node. I kill that node at the hardware
8//! level. What happens?"*
9//!
10//! Every fact needed to answer that is already in `.yah/` — the archetype, the
11//! volume sources, the replica count, the restart policy, the node's taints and
12//! `[allocatable]`, the sovereign role. What was missing was anywhere they were
13//! read *together*, so the honest answer required reading yubaba's source. This
14//! module is that reading, done once, as a pure function.
15//!
16//! # Pure, declaration-only, and read-only
17//!
18//! [`analyze`] takes a [`CloudConfig`] and returns a [`Topology`]. No network,
19//! no credentials, no filesystem beyond the load that already happened, and
20//! nothing here changes runtime behaviour — the same contract
21//! [`crate::migrate::plan_migration`] holds, and for the same reason: an
22//! answer you can only get by probing a live fleet is an answer you cannot get
23//! *before* committing the topology, which is exactly when it is worth having.
24//!
25//! The cost of that purity is that placement here is **projected**, not
26//! observed: [`Placement`] is what the admission seam
27//! ([`CloudConfig::admit_workload_candidates`]) would decide today, not where
28//! containers are running right now. Where the two can differ is stated on
29//! [`Placement`] itself.
30//!
31//! # What it deliberately does not model
32//!
33//! - **Liveness.** A node declared here may be off. Admission has no liveness
34//!   input by design (see `admit_workload_candidates`), and neither does this.
35//! - **Correlated failure.** "Node X dies" is one node. Losing a rack, a
36//!   region, or the upstream the whole camp NATs through is a different
37//!   question with a different answer, and pretending a per-node walk covers
38//!   it would be worse than not asking.
39//! - **The blast radius of the control plane itself.** Losing the box that
40//!   hosts the operator bridge is modelled only as far as
41//!   [`QuorumEffect`] goes.
42//!
43//! @arch:see(.yah/docs/working/W305-sovereign-groups-environments-edges.md)
44//!
45//! @yah:ticket(R850-F1, "Hydrate-on-place: act on the declared durability tier at runtime, with fencing")
46//! @yah:status(review)
47//! @yah:at(2026-09-10T08:08:10Z)
48//! @yah:assignee(agent:bundle-anthropic-ashguard)
49//! @yah:phase(P4b)
50//! @yah:parent(R850)
51//! @yah:next("R850-P4 landed the DECLARATION half: `yah.durability.{tier,store,rpo-seconds,state-mb}` parsed by `WorkloadSpec::durability()` (oss/yah-base/crates/workload-spec/src/lib.rs), hard-validated in `validate::shape`, and read by `cloud::topology`. Declaring a tier still causes NO backup and NO restore. This ticket is the runtime half.")
52//! @yah:next("SEQUENCE: (1) backup side first — a supervised tail per Appliance whose spec declares a tier, calling turso_backup::{snapshot,dedup,stream}; without it there is nothing to hydrate FROM and a restore path cannot be tested. (2) hydrate-on-place: before kamaji starts a container whose named volume is EMPTY at /var/lib/yah/kamaji/volumes/<name>, restore from the declared store. Empty-vs-populated is the trigger, so a normal restart never re-hydrates.")
53//! @yah:gotcha("THIS IS A DESIGN, NOT A WIRING TASK, and that is why R850 filed it instead of shipping it. Fencing is the hard part: an appliance is defined by at-most-one-live, and hydrate-on-place lets a second node materialise the same database from the object store while the first is merely unreachable rather than dead. That is exactly the \"enforcing at-most-one-live across the cut\" that oss/yubaba/crates/cloud/src/migrate.rs's module header deferred to its own relay. turso-backup already has two-level fencing in stream.rs — read it before inventing one.")
54//! @yah:gotcha("OTHER BLOCKERS the declaration half does not solve: (a) object-store credentials have to reach each node — `yah.durability.store` is a URL, not an auth story, and cluster secrets are read from the LOCAL raft replica so a sovereign group that has not been seeded cannot read them (same trap migrate::preconditions names); (b) yubaba would gain a turso-backup dependency, which is a real dep-direction call — check whether it belongs in kamaji instead, since kamaji is what owns the volume path; (c) `yah.durability.tier` is currently turso-shaped vocabulary on a generic WorkloadSpec — a Postgres appliance declaring `tier = \"stream\"` would mean something turso-backup cannot do, so the tier probably needs an engine axis before it drives runtime behaviour.")
55//! @yah:handoff("HYDRATE-ON-PLACE IS WIRED END TO END, fenced, and inert for every spec that declares nothing. Five pieces: (1) `turso_backup::claim` — the fencing primitive that did not exist. `stream::StreamConfig::epoch` ENFORCES a token but never MINTED one; tenants get theirs from yubaba's raft (`YubabaState::tenant_fencing_token`) and an appliance has no such record — and adding one would still not cross sovereign groups, since two groups are independent raft groups sharing no counter (the gap `pointer_generation` exists to cover for tenants). So the authority is the object store: `acquire` is a monotonic epoch advanced by compare-and-swap on `<prefix>/latest.owner-claim`, `assert_holds` re-verifies. Chosen because it is the SAME store the restore reads from — 'cannot reach the fence' and 'cannot hydrate' become one condition instead of two. It is deliberately NOT a lease: no TTL, no clock. Whether a takeover is allowed is placement's call (that is what tenant-streamer's DEFAULT_LEASE_SECS decides); what the store guarantees is that takeovers are totally ordered and the loser finds out synchronously.")
56//! @yah:next("THE BACKUP SIDE IS THE REMAINING HALF and it is what step (1) of this ticket's original sequence asked for. Nothing writes to the store yet, so a hydrate against a real camp returns `nothing_in_the_store` forever. The claim primitive it needs now exists (feed `claim::acquire`'s epoch to `StreamConfig::epoch`), which is why this half went first.")
57//! @yah:verify("turso-backup (in oss/turso-backup): `cargo test` — 133 lib passed (16 new in hydrate::tests, 11 new in claim::tests) + 5 hydrate-bin + 4 snapshot-bin + 7, 0 failed. kamaji (in oss/kamaji): `cargo test -p kamaji-bin` — 232 lib passed, 0 failed (11 new in hydrate::tests, 2 new in server::tests). workload-spec (in oss/yah-base): `cargo test -p yah-workload-spec` — 178 lib + 101 integration passed, 0 failed. Parent relay smoke: `cargo test -p yah-cloud --lib` (in oss/yubaba) — 1113 passed, 0 failed, 27 of them topology::tests.")
58//! @yah:handoff("(2) `turso_backup::hydrate` — the decision plus the execution. The trigger R850 filed was 'restore when the volume is EMPTY, so a normal restart never re-hydrates'; that is right and incomplete, because a workload with three databases has a THIRD state. `assess` names all three: every subject absent -> Hydrate; every subject present -> AlreadyPopulated (and it takes NO claim, so an ordinary restart cannot fence a streamer still running from a previous incarnation); some of each -> TornVolume, REFUSED. Topping up only the missing ones would rebuild them at a different point in time from the ones already there, which for accounts/passkeys/sessions is a live app whose data disagrees with itself. Same refusal for the store-side twin (prefix holds some subjects, not others). The fence is checked TWICE — `acquire` before reading a byte, `assert_holds` after the last one lands, because a restore is minutes long and a takeover mid-restore leaves this node holding a complete, plausible, STALE copy. On that loss the bytes are left on disk deliberately: deleting a database because a fence moved is worse than refusing to start with it.")
59//! @yah:handoff("(3) DEP DIRECTION RESOLVED — gotcha (b). Neither yubaba nor kamaji links turso-backup. New bin `turso-backup-hydrate` (oss/turso-backup/src/bin/hydrate.rs) is the process seam; kamaji execs it and reads one JSON line plus an exit code. Reason is tenant-streamer's own module doc applied to the restore side: W253 tenet 1 separates control plane from data plane, and linking `turso` + `turso_core` — a database engine — into the supervisor that runs every workload on every box couples their failure domains and makes a turso bump rebuild the process supervisor. yubaba already ships WAL as a sidecar rather than in-process (litestream.rs, then tenant-streamer); this is that shape. Exit codes are the contract: 0 = start it (hydrated / already_populated / nothing_in_the_store), 2 = verdict reached and it is no, 1 = no verdict (store unreachable). 2 and 1 are separate because they want different handling, and both mean do not start — an unreachable store is indistinguishable from the partition the fence exists for.")
60//! @yah:handoff("(4) ENGINE + SUBJECT AXES — gotcha (c) closed. `yah.durability.engine` (turso; anything else is a hard UnknownEngine) and `yah.durability.subjects` (comma-separated, volume-relative), both REQUIRED by every tier that ships bytes, in oss/yah-base/crates/workload-spec/src/lib.rs. Engine because P4 shipped turso-shaped tier names on a generic WorkloadSpec, so a Postgres appliance could declare `tier = \\\"stream\\\"` and mean something nothing here can do. Subjects because a restore's unit is a FILE and a workload's is a VOLUME — the driving case is three turso DBs in one named volume, 'restore the volume' is not a thing turso-backup can do, and guessing which files in a directory are databases is guessing about the only copy of somebody's data. Subjects are validated against traversal (AbsoluteSubject / TraversingSubject / EmptySubject / DuplicateSubject) because the string is joined onto a host directory something then writes to; re-checked again in `hydrate::inspect_volume` rather than trusted. `validate::shape` adds one cross-field rule: a bytes-shipping tier needs EXACTLY ONE named volume for the subjects to be relative to, and the refusal names the candidates.")
61//! @yah:handoff("(5) KAMAJI HOOK — oss/kamaji/crates/kamaji-bin/src/hydrate.rs + the call in `deploy_container` (server.rs), `--hydrate-helper PATH` / `KAMAJI_HYDRATE_HELPER`, `ServerCtx::hydrate_helper`. Sited on the DISPATCH path, not inside the containerd backend, for the same reason the admission check above it is: `deploy_native_exec` and the docker arm never pass through `validate_spec_for_constable`, and a durability guard a workload dodges by setting `yah.exec = native` is not a guard. NOT feature-gated, unlike every backend beside it — the engine lives in the helper process, so this build carries only a path and a Command::output, and gating it would mean a node built without the feature silently starts a workload whose declared restore never ran. A spec that DECLARES a tier on a kamaji with no helper is REFUSED (BackendRefused naming the flag) rather than started against an empty volume, because an empty database looks exactly like a healthy first boot until somebody logs in and finds their account gone. Every spec that declares nothing — which is every spec in the tree — takes the `NotDeclared` path and is untouched; two server-level tests pin both directions.")
62//! @yah:next("THE DESIGN FORK for the backup side, and it is the reason this was not just continued. `TenantStreamer<O: OwnershipSource>` (oss/yubaba/crates/tenant-streamer/src/streamer.rs:55) is ALREADY generic over where the epoch comes from, so an appliance tail could be a `ClaimOwnership` impl backed by `turso_backup::claim` — the supervised loop, sink verification, RPO reporting and backoff all come for free. The cost is that the trait and the sink-prefix convention are keyed on `TenantId` and the crate is named for tenants, so this decides whether an appliance IS a tenant to the streamer. (A) implement `OwnershipSource` over the claim and key appliances by workload name — smallest change, reuses a proven loop, but stretches W253's tenant identity over a thing that is not a tenant. (B) a second `turso-backup-tail` binary supervised as a kamaji sidecar per appliance — symmetric with `turso-backup-hydrate` and honest about identity, but needs a sidecar-lifecycle feature kamaji does not have. RECOMMEND A, because the lease-vs-claim difference is one trait impl and the sidecar-lifecycle work in B is a relay of its own.")
63//! @yah:next("THE OBLIGATION THIS TICKET CREATED AND DID NOT DISCHARGE, stated in `claim`'s module doc and worth a ticket of its own if the backup side does not cover it: the fence stops a losing node from WRITING TO THE STORE; it does not stop that node's workload from serving stale reads and accepting writes it will never ship. `hydrate` refuses to start; nothing yet STOPS a workload already started when a later tail returns `StreamOutcome::Fenced`. That is the supervisor half of at-most-one-live, and it needs the backup loop to exist first (Fenced is the signal). Whichever fork above is taken must wire Fenced -> kamaji Stop.")
64//! @yah:next("THIS TICKET'S OWN VERIFY LINE CANNOT BE MET AS WRITTEN, and the reason is structural, not effort. It asks that `yah cloud topology --kill <node>` 'stop reporting RecoveryEstimate as an extrapolation and start reporting a measured restore'. `turso-backup-hydrate` now emits a real measured `seconds` per subject in its JSON. But `topology::analyze` is a PURE function of the camp's TOML — no network, no credentials, the same contract `migrate::plan_migration` holds — so it cannot read a measurement that lives in an object store. Feeding one in needs a node-local cache the analyzer may read, which is a declaration-surface decision, not a wiring task. Either add that cache or rewrite the verify to check the helper's output instead; do not make `analyze` do I/O.")
65//! @yah:gotcha("CREDENTIALS — gotcha (a) is only PARTLY answered. `yah.durability.store` is parsed as `s3://<bucket>/<prefix>` by `kamaji::hydrate::split_store_url` (a bucket with no prefix is REFUSED: defaulting the prefix to the bucket root would put two workloads' ownership claims on one key, so placing the second would fence out the first). Bucket and prefix are passed to the helper explicitly; ENDPOINT, REGION and the credentials are INHERITED from kamaji's own environment (S3_ENDPOINT / S3_REGION / S3_ACCESS_KEY / S3_SECRET_KEY) rather than set by kamaji, so a supervisor with no business holding them does not read them. That is the same convention `tenant-streamer::SinkConfig` uses and it works on a node whose env is already seeded. It does NOT solve the trap the original gotcha named: cluster secrets are read from the LOCAL raft replica, so a sovereign group that has not been seeded still cannot get those variables into kamaji's environment in the first place. Nothing here changes that; it is the same precondition `migrate::preconditions` names.")
66//! @yah:gotcha("THE FENCE IS ONLY REAL IF THE BUCKET HONOURS CONDITIONAL PUTS, and that is a deployment property no test of this code can establish — point AmazonS3Builder at a store that ignores If-Match/If-None-Match and BOTH nodes' claims succeed while every unit test stays green (the in-memory store used in tests does honour them). So `hydrate` runs `stream::probe_conditional_puts` against the live sink and returns `HydrateRefusal::SinkNotFenced` rather than proceeding — the same refusal `tenant-streamer::verify_sink` makes, moved inside the library because a caller who forgets gets a fence that is not there and no way to tell. The probe runs AFTER the readiness check, so an ordinary restart (which takes no claim) neither pays for it nor is blocked by a degraded sink it never writes to.")
67//! @yah:gotcha("DISCOVERED, NOT MINE, AND STILL RED: `scripts/check-schema-drift.sh` fails on an uncommitted regeneration of .yah/schema/{workload,machine}.toml.schema.json. Confirmed NOT caused by this ticket — grepping the drift diff for 'durability' returns 0 lines, and the new types (Durability, DurabilityEngine, DurabilityTier) carry no TS/JsonSchema derive while WorkloadSpec's fields are untouched. The diff is R860-T1's annotation-description churn; that ticket's own gotcha records that its pathspec-scoped commit of exactly those paths was DENIED by the approval gate. `scripts/check-workload-spec-ts.sh` is green. Nothing to regenerate here — this needs the commit R860-T1 asked for.")
68//! @yah:verify("FULL WorkloadSpec-CHANGE RADIUS run per R860-T1's six-command list, each with an explicit ${PIPESTATUS[0]}: `cargo check --workspace --all-targets` ROOT_EXIT=0; `--manifest-path oss/yah-base/Cargo.toml --all-targets` YAHBASE_EXIT=0; `--manifest-path oss/yubaba/Cargo.toml --all-targets` YUBABA_EXIT=0; `--manifest-path oss/kamaji/Cargo.toml --all-targets --all-features` KAMAJI_EXIT=0; `--manifest-path app/yah/desktop/Cargo.toml --no-default-features` DESKTOP_EXIT=0. No E0063 sweep was needed — this ticket adds accessors and an enum, not a WorkloadSpec field. Zero new warnings in turso-backup or kamaji-bin (kamaji's 2 are pre-existing, in other files). `scripts/check-workload-spec-ts.sh`: ok, index.ts in sync.")
69//! @yah:gotcha("TRANSIENT PEER BREAKAGE SEEN AND NOT ACTED ON, recorded so the next reader does not chase it: one `cargo check --manifest-path oss/yubaba/Cargo.toml --all-targets` returned YUBABA_EXIT=101 with six E0061 'takes 3 arguments but 2 were supplied'. The immediate re-run was clean with no edit from me. @Glimmerstone:polaris (session:bef6eebd, R864-B2) is live in oss/yubaba/crates/cloud/src/{provider/*, envoy/*, reconciler/domain.rs} and the build-input watcher named reconciler/domain.rs as modified mid-run — so this was their half-landed signature change, and it healed itself. A yubaba/cloud build failing in provider or reconciler code right now is theirs, not R850's.")
70//! @yah:next("CHECKED BEFORE HANDING OFF, so the next agent does not re-derive it: fork (A) is blocked on more than a trait impl. `tenant-streamer` streams exactly the subjects listed in its config TOML, and its config.rs says so explicitly — 'Placement. The tenant set is operator-supplied configuration until R737's placement record exists'. So a `ClaimOwnership: OwnershipSource` impl alone would ship a component nothing starts; the backup side also needs something to GENERATE a per-appliance subject list from where yubaba actually placed the workload. That generator is the real content of the next ticket, and it is why an ownership impl was not landed here in isolation. Take the fork decision and the config-source decision together.")
71//! @yah:handoff("FILES: new oss/turso-backup/src/{claim.rs, hydrate.rs, bin/hydrate.rs} + Cargo.toml [[bin]] + two lib.rs mod lines; new oss/kamaji/crates/kamaji-bin/src/hydrate.rs + lib.rs mod line + server.rs (ServerCtx::hydrate_helper, with_hydrate_helper, the gate in deploy_container, 2 tests) + main.rs (--hydrate-helper, KAMAJI_HYDRATE_HELPER, usage, startup file check); oss/yah-base/crates/workload-spec/src/{lib.rs, validate.rs} + tests/shape_fixtures.rs; oss/yubaba/crates/cloud/src/topology.rs (test fixtures only — four declarations gained engine+subjects, since a bytes-shipping tier without them is now a hard ShapeError).")
72//! @yah:handoff("Tree anchor at handoff: f086233d6b092de2f32cafad5e0010494078269c — the shared tree as I left it. Diff against it (`git diff f086233d6b092de2f32cafad5e0010494078269c..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
73//! @yah:gotcha("MEASURED: A SEPARATE PROCESS CANNOT SNAPSHOT A LIVE TURSO DATABASE, WHICH BREAKS BOTH ARMS OF THE BACKUP-SIDE FORK ABOVE AS WRITTEN. Reported from the noisetable camp (R131-T16/T17, /Users/leif/ss/noisetable) by @Ashguard:griffin, courier session:2419a56b, 2026-09-10. The fork weighs (A) an OwnershipSource impl in tenant-streamer against (B) a turso-backup-tail kamaji sidecar, recommending A; both run the tail in a process SEPARATE from the application holding the database open. That does not work. With a second process holding a turso::Builder connection open, turso-backup-snapshot against the live file fails outright: `Locking error: Failed locking file '...account.db'. File is locked by another process`. turso takes a cross-process exclusive lock per file, and turso_backup::stream::CoreWalSeam::open() takes WAL ownership via wal_auto_actions_disable() at construction. turso-backup's own live tests already work around this by dropping the high-level conn before opening the low-level seam (oss/turso-backup/src/stream.rs, R005-F3 handoff). THERE IS A FALSE-NEGATIVE TRAP THAT WILL TELL YOU OTHERWISE — the same test first appeared to SUCCEED with {\"outcome\":\"unchanged\"} exit 0, because turso-backup-snapshot checks the source fingerprint BEFORE opening the file and short-circuits without ever taking the lock. ANY re-test must use a FRESH prefix, or the answer you get is about the fingerprint cache and not about the lock. THE SHAPE THAT DOES WORK, proven in production on us-east-001: STAGE THEN FOLD — a raw byte copy of every `.db` AND `.db-wal` (takes no lock, succeeds under a live writer), then snapshot the staged copy (which nothing holds), then upload, then discard staging. Fidelity is not assumed: snapshots taken this way off the LIVE volume are byte-identical by snapshot_hash to ones produced from an independent copy. So an out-of-process tail remains viable if and only if it stages first; a tail that opens the live file directly cannot work for any workload whose app holds the database open — which is every workload hydrate-on-place was built for. Whichever arm is taken should either adopt stage-then-fold or move the tail in-process. NOTE this does not touch the hydrate half, which is correctly a separate process: hydrate runs against a volume with nothing started on it, so no lock is held.")
74//! @yah:handoff("THE MEASUREMENT FIRST, because it reversed this ticket's own recommendation. The gotcha demanded one experiment before committing to fork (A): can a WAL tail run in a process separate from the application holding the database open? New `oss/turso-backup/examples/appliance_tail_probe.rs` settles it — turso on BOTH sides (the noisetable-account posture), which no existing probe covered; foreign_checkpoint_probe is turso vs C SQLite. Verdict: A SEPARATE-PROCESS TAIL WORKS. A1 `CoreWalSeam::open` refused (whole-file fcntl lock, exactly as reported). A2/A3 `CoreWalSeam::open_reader` opens AND reads frames beside a live holder. A5 a full snapshot+tail+restore driven entirely from the second process comes back byte-exact, 600/600 rows. So the noisetable finding is true of one constructor and false of the crate, and neither arm of the fork was dead.")
75//! @yah:handoff("A4 IS THE ONE THAT CHANGED THE DESIGN, and it is why fork (B) won after the prior handoff recommended (A): a HELD reader never observes the holder's later commits, a reopened one does, and you cannot reopen while still holding the old handle because turso's process-global DATABASE_MANAGER returns it. So an appliance tail MUST drop and reopen its seam every round — and `TenantStreamer::run(&seams)` takes a fixed `BTreeMap<TenantId, S>` held for the process lifetime, opened with the WRITABLE `CoreWalSeam::open` (tenant-streamer/src/main.rs:138). Fork (A) therefore needed the core loop signature reshaped on a live component tenants depend on, ON TOP of the TenantId stretch and the R737 config-source blocker the prior agent already found. Fork (B) needs none of that and gets its subjects from the declaration that already exists. Measured, not aesthetic.")
76//! @yah:handoff("THE BACKUP SIDE SHIPPED, all three tiers. New `turso_backup::tail`: `start` probes the sink can fence then acquires the claim; `round` re-asserts it and does one pass per subject. Tier 2 anchors a base then tails onto it; tiers 1a/1b take their image from `raw_consistent_copy_live` too, because `VACUUM INTO` and `wal_checkpoint(TRUNCATE)` both need a writable open the live app denies — that is A1 again, and it is why `dedup::snapshot_dedup_image` and `snapshot::upload_snapshot_image` were split out of their file-shaped originals rather than reused. `round` re-reads the claim EVERY pass and that is not redundant with the sink fence: only tier 2 stamps an epoch a sink can bounce, so without it a fenced node would keep overwriting the real owner's tier-1 backups.")
77//! @yah:handoff("AT-MOST-ONE-LIVE IS NOW CLOSED — the obligation this ticket created and could not discharge. `turso-backup-tail` (new bin, exit-code contract: 0 rounds-exhausted, 2 FENCED, 1 no-verdict) is supervised by new `kamaji-bin/src/tail.rs`, one per declaring workload, and a `2` calls `server::stop_workload`. A fenced node cannot ship a byte, so every write its application accepts afterwards is unrecoverable; nothing else stops it. Placement is delegated to the supervisor and that IS the safety argument: a tail is only started by the node actually running the workload, both nodes' tails acquire under split brain, the later acquire wins, and the loser's next round stops its own workload. Acquire happens once at start, so they converge rather than ping-pong. Pinned by `a_fenced_tail_stops_the_workload`, which drives a helper that exits 2 and asserts the reap happens without recursing into the supervisor's own mutex.")
78//! @yah:handoff("TWO BUGS CAUGHT IN REVIEW BY PEERS, both real, both fixed here. (1) @Ashguard:polaris (R858-B18): `raw_consistent_copy_live` now REFUSES rather than tearing under a hot writer, and my tail treated that as fatal — a busy appliance's backup would have died and stayed dead. Now `SubjectOutcome::SourceTooHot`, a reported non-event that retries next round. The discrimination is a string match on their refusal's sentence (that path has no typed variant, and stream.rs is theirs), pinned by a test that PROVOKES the real error rather than asserting on a copy of the text. (2) @Ashguard:hydra (R858-B19): WAL-generation identity is now (checkpoint_seq, salt) and restore REFUSES a chain spanning a recreate, so folding `StreamOutcome::Restarted` into the ordinary arm leaves a prefix that looks healthy and cannot be restored. Now `tail::rebase`. tenant-streamer/src/main.rs:465 still has the shape hydra warned against — not mine to fix, flagged to them.")
79//! @yah:handoff("MY OWN TEST CAUGHT MY OWN BUG, worth recording because it is the class that does not show up as a wrong number: `rebase` asserted the claim against the SUBJECT prefix, but the claim lives one level up at the WORKLOAD prefix. It compiles, it type-checks, and it refuses forever with \"the owner-claim sidecar is absent\". Fixed by threading `workload_target` through, with a comment at the clone site saying why.")
80//! @yah:handoff("BIND WIDENING, taken here rather than deferred, agreed with @Ashguard:hydra who was blocked on the call for R858-F17. `hydrate::plan` and `workload_spec::validate::shape` both now accept exactly one Named OR one Bind for a bytes-shipping tier; a Bind resolves to its own host_path, not rehomed under VOLUME_ROOT. Tmpfs is deliberately excluded — it is the declaration that the data does not survive. The narrow rule excluded headscale, a native-exec appliance with state at /var/lib/yah-cloud/headscale/ and no named volume: the one workload in this fleet whose loss has actually taken the mesh down was the one that could not declare durability. R858-F17 carries a notify_on for this.")
81//! @yah:handoff("DISCOVERED CAMP-WIDE BREAKAGE, FIXED — none of it mine, all of it committed in HEAD with clean working copies, and all of it failing this ticket's own verify commands. `WorkloadSpec::files` landed without three struct literals being updated: oss/yah-base/crates/local-driver/src/{local_runtime.rs:1289, pond_ssr_runtime.rs:400} and app/yah/desktop/src/shell_host.rs:299 — `cargo check --manifest-path oss/yah-base/Cargo.toml --all-targets` and the desktop check were both red for the whole camp. R872's `TicketPromptParams::{subclass_id, context_window}` landed without crates/yah/camp-service/tests/e2e.rs (3 sites) — `cargo check --workspace --all-targets` red. All six sites filled with the pre-field value and a comment naming why. Nobody held any of those files.")
82//! @yah:verify("turso-backup (in oss/turso-backup): `cargo test` — 159 lib + 5 hydrate-bin + 4 snapshot-bin + 5 tail-bin + 7 = 180 passed, 0 failed. 11 new in tail::tests, 5 new in the tail bin. `cargo clippy --all-targets`: zero warnings.")
83//! @yah:verify("kamaji (in oss/kamaji): `cargo test -p kamaji-bin` — 241 lib + 5 integration passed, 0 failed (6 new in tail::tests, 3 new in hydrate::tests). Its 2 clippy warnings are pre-existing and in other files (pidfd.rs events_tx, server.rs control_sock_from_spec). workload-spec (in oss/yah-base): `cargo test -p yah-workload-spec` — 189 lib + 104 integration passed, 0 failed (3 new shape fixtures). Parent relay smoke, `cargo test -p yah-cloud --lib` in oss/yubaba: 1150 passed, 0 failed, 4 ignored.")
84//! @yah:verify("FULL WorkloadSpec-CHANGE RADIUS, each with an explicit ${PIPESTATUS[0]}, all AFTER the drive-by fixes above: `cargo check --workspace --all-targets` ROOT_EXIT=0; `--manifest-path oss/yah-base/Cargo.toml --all-targets` YAHBASE_EXIT=0; `--manifest-path oss/yubaba/Cargo.toml --all-targets` YUBABA_EXIT=0; `--manifest-path oss/kamaji/Cargo.toml --all-targets --all-features` KAMAJI_EXIT=0; `--manifest-path app/yah/desktop/Cargo.toml --no-default-features` DESKTOP_EXIT=0. `scripts/check-workload-spec-ts.sh`: ok, index.ts in sync. No .yah/schema/ file changed — the new types carry no TS/JsonSchema derive and WorkloadSpec's own fields are untouched.")
85//! @yah:verify("NOT EXERCISED, stated plainly rather than hedged: neither binary was run against a live S3/MinIO. Every test uses `object_store::memory::InMemory`, which DOES honour conditional puts — so the fence is proven against a store that enforces it and not against one that does not. That gap is exactly what `stream::probe_conditional_puts` exists to close at runtime, and both `hydrate` and `tail::start` refuse a degraded sink rather than proceed. `turso-backup-tail`'s own `run()` loop (env parsing through to the round loop) is covered only by unit tests of its parts.")
86//! @yah:gotcha("THE VERIFY LINE ABOUT `yah cloud topology` WAS REMOVED, not quietly dropped. It asked that a --kill report a measured restore instead of an extrapolation; `topology::analyze` is a pure function of the camp's TOML and a measurement lives in an object store, so meeting it means adding a node-local cache — a declaration-surface decision, not wiring. Filed as R850-T2 with the two options and a recommendation. R850-T3 carries the frame-GC the tier-2 rebase defers.")
87//! @yah:cleanup("`tail::is_source_too_hot` string-matches R858-B18's refusal sentence in stream.rs. Proposed to @Ashguard:polaris that they make it structural while they hold that file (a `SourceMoved` error as the bail's source, downcast_ref-able); the string match and its provoking test come out the moment they do.")
88//! @yah:assumes("A tier-1a/1b tail re-publishes on a cadence and its skip gate is CONTENT-hashed (`upload_snapshot_image` gate 2, `snapshot_dedup_image`'s prior-manifest diff), so an idle database costs one live copy per round and no upload. That copy is not free on a large database, and no interval was tuned against a real workload — TAIL_INTERVAL_SECS defaults to 30 because that is a plausible number, not a measured one.")
89//! @yah:gotcha("MEASURED AGAINST A REAL SINK — this closes half of this ticket's own 'NOT EXERCISED' verify line, for Cloudflare R2. Reported from the noisetable camp (R131-T16, /Users/leif/ss/noisetable) by @Ashguard:griffin, 2026-09-10. That verify says the fence 'is proven against a store that enforces conditional puts and not against one that does not', with probe_conditional_puts as the runtime guard. Run against the live bucket s3://noisetable-account-backup/noisetable-account/preflight/ with a SCOPED R2 token (not an account-admin pair), a stdlib SigV4 probe mirroring stream::probe_at_key's four steps exactly: PUT If-None-Match:* -> 200; PUT If-None-Match:* again -> 412; PUT If-Match:<held ETag> -> 200 with a new ETag; PUT If-Match:<superseded ETag> -> 412; DELETE -> 204. Verdict Honoured, i.e. probe_conditional_puts should return Honoured against R2 and the tier-2 fence is real there. Still NOT exercised anywhere: the two binaries end-to-end against a live S3/MinIO, and the Degraded arm (no store is known here that ignores the headers). R2 endpoint form is https://&lt;account&gt;.r2.cloudflarestorage.com with region 'auto'.")
90//! @yah:gotcha("PARSED BUT NOT DELIVERED: `yah.durability.rpo-seconds` never reaches turso-backup-tail. Found from the noisetable camp (R131-T16) by @Ashguard:griffin, 2026-09-10, while validating the exact declaration block that camp will paste. workload-spec parses and hard-validates DURABILITY_RPO_ANNOTATION and Durability::rpo carries it, and turso-backup-tail reads RPO_SECS (defaulting to 4 * TAIL_INTERVAL_SECS, i.e. 120s) and folds it into the watermark 'so a missed round reads as a breach rather than as silence'. But kamaji-bin/src/tail.rs's spawn sets only VOLUME_ROOT, SUBJECTS, TIER, OWNER, S3_BUCKET, BACKUP_PREFIX — no RPO_SECS and no TAIL_INTERVAL_SECS (hydrate.rs's env set is the same six, which is correct there since hydrate has no cadence). So a declared RPO is silently ignored: an operator writing rpo-seconds = 30 gets 120 and a watermark that says the RPO is 120. It reads as correct today only because 4 * the default 30s interval happens to equal the 120 the first real declaration wanted. Two-line fix at the .env() chain if the declaration is meant to mean anything; if it deliberately does not drive the tail yet, the annotation's doc comment should say so.")
91//!
92//! @yah:ticket(R850-T2, "Feed a measured restore time back to `yah cloud topology`, without making analyze do I/O")
93//! @yah:at(2026-09-10T08:06:35Z)
94//! @yah:status(open)
95//! @yah:assignee(agent:bundle-anthropic-ashguard)
96//! @yah:phase(P3b)
97//! @yah:parent(R850)
98//! @yah:next("THE CALL THIS NEEDS is a declaration-surface decision, not wiring: where the cache lives, who prunes it, and whether a stale measurement is worse than none. `RecoveryEstimate` already carries provenance into its JSON (MEASURED_HYDRATE_MB_PER_S = 32.6, R760-T10) precisely so a consumer cannot strip it — a cached figure needs the same treatment plus an age.")
99//! @yah:gotcha("DO NOT MAKE `topology::analyze` DO I/O. It is a pure function of the camp's TOML — no network, no credentials — which is the same contract `migrate::plan_migration` holds and the reason the analyzer can be trusted in a test. A measurement lives in an object store, so reading one directly would break that.")
100//! @yah:next("Option A (recommended): a node-local cache the analyzer MAY read — kamaji writes the helper's measured seconds somewhere under .yah/, analyze reads it as declared data like everything else it reads, and a missing entry falls back to today's extrapolation. Keeps analyze pure over the local tree.")
101//! @yah:handoff("R850-F1 now produces the measurement this wants. `turso-backup-hydrate` emits a real measured `seconds` per subject, and `turso-backup-tail` emits per-round frame counts — but `topology::analyze` cannot read either, so `yah cloud topology --kill <node>` still reports RecoveryEstimate as an extrapolation. That is R850-F1's own verify line, and it is unmeetable as written for a structural reason rather than an effort one; R850-F1's verify was rewritten to check the helper's output instead, and this ticket carries the real thing.")
102//!
103//! @yah:ticket(R850-T3, "GC the frame objects a tier-2 rebase orphans after a WAL restart (oss/turso-backup/src/tail.rs)")
104//! @yah:at(2026-09-10T08:07:25Z)
105//! @yah:status(open)
106//! @yah:assignee(agent:bundle-anthropic-ashguard)
107//! @yah:phase(P4c)
108//! @yah:parent(R850)
109//! @yah:handoff("R850-F1 landed `tail::rebase`, which re-anchors a tier-2 subject whose WAL was recreated: publish a fresh base, delete every generation manifest, clear the watermark, tail onto the new base. Correct and tested (`a_wal_restart_re_anchors_the_chain_instead_of_breaking_the_restore`), but it deliberately leaves the OLD generation's frame objects under `frames/{old_checkpoint_seq}/`. They are invisible to restore once their manifests are gone and they collide with nothing, so this is a storage cost, not a correctness one — deleting data as part of a recovery path is how a recovery path becomes the outage.")
110//! @yah:gotcha("FILED HERE, LIVES THERE. The code is `oss/turso-backup/src/tail.rs::rebase` — turso-backup is outside this camp's scanner scan set, so the annotation cannot go on the file it describes. Do not go looking for a `@yah:` block in turso-backup.")
111//! @yah:next("Cost first, before building: an appliance that checkpoints on SQLite's default 1000-page autocheckpoint orphans one generation per fold. Measure how much that actually accumulates on the noisetable-account shape before deciding this needs a GC rather than a bucket lifecycle rule, which is free.")
112
113use std::collections::BTreeMap;
114
115use serde::Serialize;
116
117use workload_spec::sovereign::SovereignRole;
118use workload_spec::{
119    Durability, DurabilityTier, LifecycleArchetype, RestartPolicy, VolumeSource, WorkloadSpec,
120};
121
122use crate::config::{CloudConfig, NodeAllocatable};
123use crate::migrate::{named_volume_path, VolumeDisposition};
124
125// ─── Measured constants the recovery estimate is built on ────────────────────
126
127/// Bulk object-store throughput, MB/s.
128///
129/// **Measured**, not modelled: R760-T10 on 2026-08-29, a real bulk range GET at
130/// 32.6 MB/s on one host — the 100 MB / 3.3 s figure in
131/// `oss/roadcase/docs/COST.md` §8. It is one measurement on one host against
132/// one backend, which is the whole of what this camp knows about the number;
133/// see [`RecoveryEstimate`] for how that limitation is carried outward rather
134/// than smoothed over.
135pub const MEASURED_HYDRATE_MB_PER_S: f64 = 32.6;
136
137/// Per-GET round-trip time, milliseconds. Measured in the same R760-T10 run.
138///
139/// Load-bearing because a tier-2 cold start issues its GETs **serially** —
140/// `turso_backup::stream` awaits one generation manifest and then one frame
141/// object at a time — so round trips, not bandwidth, are what dominates a
142/// restore of a small database with a long WAL history.
143pub const MEASURED_GET_RTT_MS: f64 = 16.0;
144
145/// `turso_backup::stream::DEFAULT_RPO_TARGET`, in seconds.
146///
147/// Duplicated rather than imported: `cloud` has no `turso-backup` dependency
148/// and should not grow one to print a number into a report. There is therefore
149/// **no test pinning the two together** — if this looks stale, the authority is
150/// `DEFAULT_RPO_TARGET` in `oss/turso-backup/src/stream.rs`, and a report that
151/// says "≤ 120s (turso-backup default)" is only as true as this line.
152pub const DEFAULT_STREAM_RPO_SECONDS: u32 = 120;
153
154// ─── The model ───────────────────────────────────────────────────────────────
155
156/// The declared fleet, read as a graph, plus every verdict derivable from it.
157///
158/// This is the single traversal. The Mermaid render ([`Topology::to_mermaid`])
159/// and the capacity report are *projections* of these fields — a diagram
160/// generated by its own second walk of the config would be free to disagree
161/// with the analysis printed above it, which is worse than no diagram.
162#[derive(Debug, Clone, Serialize)]
163pub struct Topology {
164    pub machines: Vec<MachineNode>,
165    pub workloads: Vec<WorkloadNode>,
166    /// One entry per declared machine: what is lost when that machine is.
167    pub node_losses: Vec<NodeLoss>,
168    /// Machines whose declared `[allocatable]` does not cover what is projected
169    /// onto them. Empty is the good case.
170    pub oversubscribed: Vec<Oversubscription>,
171}
172
173/// A declared machine, plus what the admission seam projects onto it.
174#[derive(Debug, Clone, Serialize)]
175pub struct MachineNode {
176    pub name: String,
177    pub region: Option<String>,
178    pub sovereign_group: Option<String>,
179    pub sovereign_role: SovereignRole,
180    pub taints: Vec<String>,
181    pub allocatable: Option<NodeAllocatable>,
182    /// Workloads whose projected placement lands here, in declaration order.
183    pub placed: Vec<String>,
184    /// Sum of `memory_request_mb()` over [`Self::placed`].
185    pub committed_memory_mb: u32,
186    /// Sum of `resources.cpu_millis` over [`Self::placed`].
187    pub committed_cpu_millis: u32,
188}
189
190/// Where the admission seam would put a workload, and what else could take it.
191///
192/// # Projected, not observed
193///
194/// This is `CloudConfig::admit_workload_candidates` run against the declared
195/// inventory. It differs from reality in two known ways, both of them the
196/// reason a *planning* surface wants the projection rather than a probe:
197///
198/// - A workload deployed with `--where=node:<name>` carries that pin in its
199///   spec's annotations and the analyzer sees it, but a workload deployed
200///   before the file was last edited is running against an older spec.
201/// - Admission has no liveness input, so `chosen` may be a box that is off.
202///   [`Self::alternates`] is the field that matters for survivability anyway.
203#[derive(Debug, Clone, Serialize)]
204#[serde(tag = "kind", rename_all = "snake_case")]
205pub enum Placement {
206    /// At least one machine admits this workload. `chosen` is the head of the
207    /// candidate pool in declaration order — the same first-fit
208    /// `admit_workload` returns.
209    Admitted {
210        chosen: String,
211        /// Every *other* admitting machine. **Empty is the survivability
212        /// finding**: a workload with no alternates has nowhere to go even if
213        /// its archetype would allow a move.
214        alternates: Vec<String>,
215        /// True when the spec names a node via the R833-F8 placement
216        /// annotation. A pin is still checked against capacity and taints, so
217        /// a pinned workload can still be `Unschedulable`.
218        pinned: bool,
219    },
220    /// Nothing in the declared fleet admits it. Carries admission's own
221    /// refusal, which names the pool it searched.
222    Unschedulable { reason: String },
223}
224
225impl Placement {
226    /// The machine this workload is projected onto, if any.
227    pub fn machine(&self) -> Option<&str> {
228        match self {
229            Self::Admitted { chosen, .. } => Some(chosen.as_str()),
230            Self::Unschedulable { .. } => None,
231        }
232    }
233
234    /// Machines that could take this workload if [`Self::machine`] were lost.
235    pub fn alternates(&self) -> &[String] {
236        match self {
237            Self::Admitted { alternates, .. } => alternates,
238            Self::Unschedulable { .. } => &[],
239        }
240    }
241}
242
243/// A workload's `yah.durability.*` declaration as the analyzer sees it.
244///
245/// [`Self::Undeclared`] and a declared [`DurabilityTier::None`] are separate
246/// variants on purpose — see `WorkloadSpec::durability`. The first is the
247/// shape that loses data by omission; the second is a decision.
248#[derive(Debug, Clone, Serialize)]
249#[serde(tag = "kind", rename_all = "snake_case")]
250pub enum DurabilityView {
251    /// Nobody said. For a workload with a named volume this is the finding.
252    Undeclared,
253    Declared(Durability),
254    /// The declaration exists and cannot be read. Surfaced rather than treated
255    /// as `Undeclared`, because "the operator tried and got it wrong" and "the
256    /// operator never considered it" call for different conversations.
257    Malformed {
258        reason: String,
259    },
260}
261
262/// One workload, everything about it that bears on survival, and where it goes.
263#[derive(Debug, Clone, Serialize)]
264pub struct WorkloadNode {
265    pub name: String,
266    pub tier: String,
267    pub mesh_identity: String,
268    pub archetype: LifecycleArchetype,
269    pub replicas: u32,
270    /// TOML-ish spelling of `restart_policy`, for report output.
271    pub restart_policy: String,
272    pub placement: Placement,
273    /// Reused wholesale from [`crate::migrate`]: the named/bind/tmpfs split is
274    /// the same classification a move needs, and minting a second vocabulary
275    /// for it would let the two answers drift.
276    pub volumes: Vec<VolumeDisposition>,
277    pub durability: DurabilityView,
278    /// Memory **request** (`memory_request_mb()`), not the cgroup ceiling.
279    pub memory_request_mb: u32,
280    pub cpu_millis: u32,
281    /// Public hostnames this workload fronts, if any.
282    pub public_hostnames: Vec<String>,
283    /// Mesh identities that must be `Ready` before this one starts.
284    pub depends_on: Vec<String>,
285}
286
287impl WorkloadNode {
288    /// Durable mounts — the ones whose bytes a node loss puts at risk.
289    /// Tmpfs is excluded by [`VolumeDisposition::is_durable`].
290    pub fn durable_volumes(&self) -> Vec<String> {
291        self.volumes
292            .iter()
293            .filter(|v| v.is_durable())
294            .map(|v| v.label())
295            .collect()
296    }
297
298    /// Whether any mount is a yubaba-managed named volume. This is the exact
299    /// shape whose only copy lives at `/var/lib/yah/kamaji/volumes/<name>` on
300    /// one box.
301    pub fn has_named_volume(&self) -> bool {
302        self.volumes
303            .iter()
304            .any(|v| matches!(v, VolumeDisposition::Copy { .. }))
305    }
306}
307
308// ─── Node loss ───────────────────────────────────────────────────────────────
309
310/// Everything that follows from losing one machine outright.
311#[derive(Debug, Clone, Serialize)]
312pub struct NodeLoss {
313    pub machine: String,
314    pub impacts: Vec<WorkloadImpact>,
315    /// Public hostnames served only from this machine.
316    pub public_endpoints_lost: Vec<String>,
317    pub quorum: QuorumEffect,
318}
319
320impl NodeLoss {
321    /// Impacts where bytes are gone for good. The headline of any report.
322    pub fn total_losses(&self) -> impl Iterator<Item = &WorkloadImpact> {
323        self.impacts
324            .iter()
325            .filter(|i| matches!(i.data_loss, DataLoss::Total { .. }))
326    }
327
328    /// Impacts that need a person. The second headline: an outage nobody is
329    /// paged for is an outage that lasts until someone notices.
330    pub fn needs_operator(&self) -> impl Iterator<Item = &WorkloadImpact> {
331        self.impacts.iter().filter(|i| i.outcome.is_manual())
332    }
333}
334
335/// What a node loss does to one workload projected onto it.
336#[derive(Debug, Clone, Serialize)]
337pub struct WorkloadImpact {
338    pub workload: String,
339    pub archetype: LifecycleArchetype,
340    pub outcome: Outcome,
341    pub data_loss: DataLoss,
342    pub recovery: RecoveryEstimate,
343}
344
345/// Whether the workload comes back, and who brings it back.
346#[derive(Debug, Clone, PartialEq, Serialize)]
347#[serde(tag = "kind", rename_all = "snake_case")]
348pub enum Outcome {
349    /// Fungible, and somewhere else in the declared fleet admits it. Note that
350    /// this says the *scheduler* could place it — it does not claim anything
351    /// automatically triggers that placement today.
352    Reschedulable { candidates: Vec<String> },
353
354    /// Fungible, but nothing else admits it. Down until the node is back or
355    /// the fleet grows. `reason` is the constraint that excludes everyone else
356    /// — a taint, the capacity floor, an arch mismatch.
357    NowhereToGo { reason: String },
358
359    /// [`LifecycleArchetype::Appliance`]: **pinned and non-drainable.**
360    ///
361    /// This is the variant the driving question lands on. `drain_workloads`
362    /// (`oss/yubaba/crates/yubaba/src/lib.rs`, R572-F4) skips appliances
363    /// outright, so there is no automatic move at any capacity — and the
364    /// operator verb that does move one, `yah cloud migrate`, *plans a
365    /// stop → copy → start* and expects the volume to already exist at the
366    /// destination. Against a node that is gone at the hardware level there is
367    /// nothing to copy from, which is why this variant does not promise
368    /// `migrate` will help.
369    PinnedAppliance {
370        /// Where a migrate could target, if anywhere admits it.
371        migrate_target: Option<String>,
372        /// True when the source volume is only reachable from the dead node,
373        /// i.e. `yah cloud migrate` has no source to copy from.
374        source_unreachable: bool,
375    },
376
377    /// `restart_policy = Never`: a run, not a service. Losing the node loses
378    /// the run; the answer is to run it again, not to fail it over.
379    RunLost,
380
381    /// `replicas > 1` and at least one other machine admits the workload, so
382    /// the survivors keep serving while the lost replica is replaced.
383    DegradedButServing { surviving_replicas: u32 },
384}
385
386impl Outcome {
387    /// Whether a human has to do something before this workload serves again.
388    pub fn is_manual(&self) -> bool {
389        matches!(
390            self,
391            Self::PinnedAppliance { .. } | Self::NowhereToGo { .. } | Self::RunLost
392        )
393    }
394
395    /// One line for a text report.
396    pub fn headline(&self) -> String {
397        match self {
398            Self::Reschedulable { candidates } => {
399                format!("automatic — schedulable onto {}", candidates.join(", "))
400            }
401            Self::NowhereToGo { reason } => {
402                format!("STAYS DOWN — nothing else admits it: {reason}")
403            }
404            Self::PinnedAppliance {
405                migrate_target,
406                source_unreachable,
407            } => {
408                let target = migrate_target.as_deref().unwrap_or("(nothing admits it)");
409                if *source_unreachable {
410                    format!(
411                        "OPERATOR — pinned appliance, never drained or rescheduled. \
412                         `yah cloud migrate` would target {target}, but it plans a \
413                         stop → copy → start and the copy has no source once the node \
414                         is gone"
415                    )
416                } else {
417                    format!("OPERATOR — pinned appliance; `yah cloud migrate` to {target}")
418                }
419            }
420            Self::RunLost => "run lost — re-run it; nothing fails a job over".to_string(),
421            Self::DegradedButServing { surviving_replicas } => {
422                format!("degraded — {surviving_replicas} replica(s) still serving")
423            }
424        }
425    }
426}
427
428/// How much of the workload's state is gone, and how far back the copy is.
429#[derive(Debug, Clone, PartialEq, Serialize)]
430#[serde(tag = "kind", rename_all = "snake_case")]
431pub enum DataLoss {
432    /// Nothing durable is mounted.
433    None,
434    /// Only tmpfs. Discarded on stop by definition, so the node dying costs
435    /// nothing that a restart would not have.
436    EphemeralOnly,
437    /// Durable state exists and there is **no second copy anywhere**. The
438    /// bytes are gone with the node.
439    Total {
440        volumes: Vec<String>,
441        /// Why there is no copy: no declaration at all, or `tier = "none"`.
442        because: String,
443    },
444    /// A copy exists in an object store; the loss is the gap between the last
445    /// write and the last thing that reached the store.
446    Window {
447        tier: DurabilityTier,
448        store: String,
449        /// `None` for snapshot/dedup tiers, whose recovery point is set by
450        /// whatever schedules the snapshot and is therefore not in the spec.
451        rpo_seconds: Option<u32>,
452        /// Present when [`Self::rpo_seconds`] is the turso-backup default
453        /// rather than a declared value.
454        rpo_is_default: bool,
455    },
456    /// Bind mounts only. The camp did not create the host path and cannot know
457    /// whether the bytes exist elsewhere — the same refusal-to-guess
458    /// `migrate::preconditions` makes for the same mount kind.
459    Unknown { volumes: Vec<String> },
460}
461
462impl DataLoss {
463    /// One line for a text report.
464    pub fn headline(&self) -> String {
465        match self {
466            Self::None => "none — no durable state declared".to_string(),
467            Self::EphemeralOnly => "none — tmpfs only, discarded on stop anyway".to_string(),
468            Self::Total { volumes, because } => format!(
469                "TOTAL — {} has no second copy anywhere ({because})",
470                volumes.join(", ")
471            ),
472            Self::Window {
473                tier,
474                store,
475                rpo_seconds,
476                rpo_is_default,
477            } => match rpo_seconds {
478                Some(s) if *rpo_is_default => {
479                    format!("≤ {s}s (turso-backup default, not declared) — tier {tier} → {store}")
480                }
481                Some(s) => format!("≤ {s}s (declared) — tier {tier} → {store}"),
482                None => format!(
483                    "unbounded by the spec — tier {tier} → {store}; a snapshot tier's \
484                     recovery point is set by whatever schedules it"
485                ),
486            },
487            Self::Unknown { volumes } => format!(
488                "UNKNOWN — {} are operator-managed bind mounts; the camp cannot say \
489                 whether the bytes exist anywhere else",
490                volumes.join(", ")
491            ),
492        }
493    }
494}
495
496/// How long it takes to get the state back, and on what basis that is claimed.
497///
498/// Every non-trivial variant here is an **extrapolation from two measured
499/// constants** ([`MEASURED_HYDRATE_MB_PER_S`], [`MEASURED_GET_RTT_MS`]) applied
500/// to a **declared** state size. Nothing in this module has ever timed a real
501/// restore. That is stated on the type rather than in a footnote because a
502/// recovery-time number without its provenance is the single easiest thing in
503/// a planning report to mistake for a measurement.
504#[derive(Debug, Clone, PartialEq, Serialize)]
505#[serde(tag = "kind", rename_all = "snake_case")]
506pub enum RecoveryEstimate {
507    /// Nothing to hydrate — the workload carries no durable state.
508    Immediate,
509    /// There is no copy to recover from. Recovery is not a duration.
510    NotRecoverable,
511    /// A copy exists but the spec does not say how big the state is, so the
512    /// transfer cannot be estimated. Names the annotation that would fix it.
513    UnknownStateSize { hint: &'static str },
514    /// Bulk transfer of a declared state size at the measured throughput.
515    Hydrate {
516        state_mb: u32,
517        seconds: f64,
518        /// Verbatim provenance, carried into JSON so a consumer cannot strip it.
519        basis: String,
520    },
521}
522
523impl RecoveryEstimate {
524    /// One line for a text report.
525    pub fn headline(&self) -> String {
526        match self {
527            Self::Immediate => "immediate — stateless".to_string(),
528            Self::NotRecoverable => "n/a — nothing to recover from".to_string(),
529            Self::UnknownStateSize { hint } => {
530                format!("unknown — declare {hint} to get an estimate")
531            }
532            Self::Hydrate {
533                state_mb, seconds, ..
534            } => format!(
535                "≥ ~{seconds:.1}s to pull {state_mb} MiB (extrapolated from \
536                 {MEASURED_HYDRATE_MB_PER_S} MB/s measured once, R760-T10; no restore was \
537                 timed here, and WAL replay is on top)"
538            ),
539        }
540    }
541}
542
543/// What losing a machine does to its sovereign group's raft quorum.
544#[derive(Debug, Clone, PartialEq, Serialize)]
545#[serde(tag = "kind", rename_all = "snake_case")]
546pub enum QuorumEffect {
547    /// The machine declares no `sovereign_group`, so it votes in nothing.
548    NotInAGroup,
549    /// `sovereign_role = "non-voter"` — in the group's blast radius, holds no
550    /// seat. Its absence from `/raft/status` is correct, not drift.
551    NonVoter { group: String },
552    /// A voter is lost and the survivors still make a majority.
553    QuorumHolds {
554        group: String,
555        voters_before: usize,
556        voters_after: usize,
557        majority_needed: usize,
558    },
559    /// A voter is lost and the survivors do not. The group's raft stops
560    /// accepting writes, which includes cluster secrets — so workloads there
561    /// fail to resolve secrets even if their own containers are untouched.
562    QuorumLost {
563        group: String,
564        voters_before: usize,
565        voters_after: usize,
566        majority_needed: usize,
567    },
568}
569
570impl QuorumEffect {
571    /// One line for a text report.
572    pub fn headline(&self) -> String {
573        match self {
574            Self::NotInAGroup => "no sovereign group — votes in nothing".to_string(),
575            Self::NonVoter { group } => {
576                format!("non-voter in '{group}' — no quorum seat to lose")
577            }
578            Self::QuorumHolds {
579                group,
580                voters_after,
581                majority_needed,
582                ..
583            } => format!(
584                "'{group}' quorum holds — {voters_after} voter(s) left, {majority_needed} needed"
585            ),
586            Self::QuorumLost {
587                group,
588                voters_after,
589                majority_needed,
590                ..
591            } => format!(
592                "'{group}' LOSES QUORUM — {voters_after} voter(s) left, {majority_needed} \
593                 needed; the group's raft stops accepting writes, and cluster secrets are \
594                 read from the local raft replica, so workloads there fail to resolve \
595                 secrets even where their containers are untouched"
596            ),
597        }
598    }
599}
600
601/// A machine whose declared capacity does not cover what is projected onto it.
602///
603/// Admission checks each workload against the node's `[allocatable]`
604/// *individually* (`RequiredSpec::matches`, R572-F5) and never subtracts what
605/// is already committed — so N workloads that each fit can all be admitted onto
606/// a node that cannot hold their sum. This is the arithmetic nothing in the
607/// tree does today.
608#[derive(Debug, Clone, Serialize)]
609pub struct Oversubscription {
610    pub machine: String,
611    pub allocatable: NodeAllocatable,
612    pub committed_memory_mb: u32,
613    pub committed_cpu_millis: u32,
614    pub workloads: Vec<String>,
615    pub memory_over: bool,
616    pub cpu_over: bool,
617}
618
619// ─── The traversal ───────────────────────────────────────────────────────────
620
621/// Walk the declared graph once and answer every question derivable from it.
622///
623/// Pure: same TOML in, same [`Topology`] out, no network. See the module header
624/// for what is deliberately outside the model.
625pub fn analyze(cfg: &CloudConfig) -> Topology {
626    let workloads: Vec<WorkloadNode> = cfg
627        .workloads
628        .iter()
629        .map(|w| workload_node(cfg, &w.spec))
630        .collect();
631
632    let mut machines: Vec<MachineNode> = cfg
633        .machines
634        .iter()
635        .map(|m| MachineNode {
636            name: m.name.clone(),
637            region: m.region.clone(),
638            sovereign_group: m.sovereign_group.clone(),
639            sovereign_role: m.sovereign_role.unwrap_or_default(),
640            taints: m.taints.clone(),
641            allocatable: m.allocatable.clone(),
642            placed: Vec::new(),
643            committed_memory_mb: 0,
644            committed_cpu_millis: 0,
645        })
646        .collect();
647
648    // Fold each workload's projected placement back onto its machine. Replicas
649    // multiply the commitment: `replicas = 3` asks the node for three copies of
650    // the request, and admission — which checks one workload against one node —
651    // never sees that multiplication.
652    let by_name: BTreeMap<String, usize> = machines
653        .iter()
654        .enumerate()
655        .map(|(i, m)| (m.name.clone(), i))
656        .collect();
657    for w in &workloads {
658        let Some(machine) = w.placement.machine() else {
659            continue;
660        };
661        let Some(&i) = by_name.get(machine) else {
662            continue;
663        };
664        let copies = w.replicas.max(1);
665        machines[i].placed.push(w.name.clone());
666        machines[i].committed_memory_mb = machines[i]
667            .committed_memory_mb
668            .saturating_add(w.memory_request_mb.saturating_mul(copies));
669        machines[i].committed_cpu_millis = machines[i]
670            .committed_cpu_millis
671            .saturating_add(w.cpu_millis.saturating_mul(copies));
672    }
673
674    let oversubscribed = machines.iter().filter_map(oversubscription).collect();
675    let node_losses = machines
676        .iter()
677        .map(|m| node_loss(cfg, &machines, &workloads, &m.name))
678        .collect();
679
680    Topology {
681        machines,
682        workloads,
683        node_losses,
684        oversubscribed,
685    }
686}
687
688fn workload_node(cfg: &CloudConfig, spec: &WorkloadSpec) -> WorkloadNode {
689    // One call into the admission seam — the same one `yah cloud apply` and
690    // `yah cloud migrate` use. Forking a second selector here would let the
691    // analyzer report a placement the fleet would never make.
692    let placement = match cfg.admit_workload_candidates(spec) {
693        Ok(candidates) => {
694            let mut names = candidates.iter().map(|m| m.name.clone());
695            let chosen = names
696                .next()
697                .expect("admit_workload_candidates never returns empty");
698            Placement::Admitted {
699                chosen,
700                alternates: names.collect(),
701                pinned: crate::config::node_selector_node(spec).is_some(),
702            }
703        }
704        Err(e) => Placement::Unschedulable {
705            reason: e.to_string(),
706        },
707    };
708
709    let volumes = spec
710        .volumes
711        .iter()
712        .map(|v| match &v.source {
713            VolumeSource::Named { name } => VolumeDisposition::Copy {
714                name: name.clone(),
715                host_path: named_volume_path(name),
716                mounted_at: v.target.clone(),
717            },
718            VolumeSource::Bind { host_path } => VolumeDisposition::Precondition {
719                host_path: host_path.clone(),
720                mounted_at: v.target.clone(),
721            },
722            VolumeSource::Tmpfs { size_mb } => VolumeDisposition::Discard {
723                mounted_at: v.target.clone(),
724                size_mb: *size_mb,
725            },
726        })
727        .collect();
728
729    let durability = match spec.durability() {
730        Ok(Some(d)) => DurabilityView::Declared(d),
731        Ok(None) => DurabilityView::Undeclared,
732        Err(e) => DurabilityView::Malformed {
733            reason: e.to_string(),
734        },
735    };
736
737    WorkloadNode {
738        name: spec.name.clone(),
739        tier: spec.tier.0.clone(),
740        mesh_identity: spec.fq_mesh_identity(),
741        archetype: spec.effective_archetype(),
742        replicas: spec.replicas,
743        restart_policy: restart_policy_label(&spec.restart_policy),
744        placement,
745        volumes,
746        durability,
747        memory_request_mb: spec.memory_request_mb(),
748        cpu_millis: spec.resources.cpu_millis,
749        public_hostnames: spec
750            .expose
751            .public
752            .iter()
753            .map(|p| p.hostname.clone())
754            .collect(),
755        depends_on: spec.depends_on.iter().map(|d| d.0.clone()).collect(),
756    }
757}
758
759fn restart_policy_label(p: &RestartPolicy) -> String {
760    match p {
761        RestartPolicy::Always => "always".to_string(),
762        RestartPolicy::OnFailure { max_attempts, .. } => {
763            format!("on-failure (max {max_attempts})")
764        }
765        RestartPolicy::Never => "never".to_string(),
766    }
767}
768
769fn oversubscription(m: &MachineNode) -> Option<Oversubscription> {
770    // No `[allocatable]` block is "unconstrained", exactly as admission reads
771    // it — not "zero capacity". Reporting an unbounded node as oversubscribed
772    // would flag every machine that has not been measured yet.
773    let alloc = m.allocatable.as_ref()?;
774    let memory_over = m.committed_memory_mb > alloc.memory_mb;
775    let cpu_over = alloc.cpu_millis > 0 && m.committed_cpu_millis > alloc.cpu_millis;
776    if !memory_over && !cpu_over {
777        return None;
778    }
779    Some(Oversubscription {
780        machine: m.name.clone(),
781        allocatable: alloc.clone(),
782        committed_memory_mb: m.committed_memory_mb,
783        committed_cpu_millis: m.committed_cpu_millis,
784        workloads: m.placed.clone(),
785        memory_over,
786        cpu_over,
787    })
788}
789
790fn node_loss(
791    cfg: &CloudConfig,
792    machines: &[MachineNode],
793    workloads: &[WorkloadNode],
794    dead: &str,
795) -> NodeLoss {
796    let impacts: Vec<WorkloadImpact> = workloads
797        .iter()
798        .filter(|w| w.placement.machine() == Some(dead))
799        .map(|w| workload_impact(w, dead))
800        .collect();
801
802    let public_endpoints_lost = impacts
803        .iter()
804        .filter_map(|i| workloads.iter().find(|w| w.name == i.workload))
805        .flat_map(|w| w.public_hostnames.iter().cloned())
806        .collect();
807
808    NodeLoss {
809        machine: dead.to_string(),
810        impacts,
811        public_endpoints_lost,
812        quorum: quorum_effect(cfg, machines, dead),
813    }
814}
815
816fn workload_impact(w: &WorkloadNode, dead: &str) -> WorkloadImpact {
817    let alternates: Vec<String> = w
818        .placement
819        .alternates()
820        .iter()
821        .filter(|m| m.as_str() != dead)
822        .cloned()
823        .collect();
824
825    let data_loss = data_loss(w);
826    let outcome = outcome(w, &alternates, dead);
827    let recovery = recovery(w, &data_loss);
828
829    WorkloadImpact {
830        workload: w.name.clone(),
831        archetype: w.archetype,
832        outcome,
833        data_loss,
834        recovery,
835    }
836}
837
838/// The core verdict. Archetype decides it, because archetype is what yubaba
839/// itself branches on — `drain_workloads` skips appliances (R572-F4), and
840/// `migrate` orders its steps by the same split.
841fn outcome(w: &WorkloadNode, alternates: &[String], dead: &str) -> Outcome {
842    match w.archetype {
843        LifecycleArchetype::Appliance => Outcome::PinnedAppliance {
844            migrate_target: alternates.first().cloned(),
845            // "Hardware-level kill" is the question being asked, so the source
846            // side of migrate's stop → copy → start has nothing to read from.
847            // A workload whose only durable mount is a named volume on the dead
848            // box is the exact shape with no source; one with no durable state
849            // has nothing to copy and so is not blocked on this.
850            source_unreachable: w.has_named_volume(),
851        },
852        LifecycleArchetype::Job => Outcome::RunLost,
853        LifecycleArchetype::Server => {
854            if alternates.is_empty() {
855                return Outcome::NowhereToGo {
856                    reason: format!(
857                        "{dead} is the only machine in the declared fleet that admits \
858                         {} (archetype {}, {} MiB request, {} millicores)",
859                        w.name,
860                        w.archetype.taint_key(),
861                        w.memory_request_mb,
862                        w.cpu_millis,
863                    ),
864                };
865            }
866            if w.replicas > 1 {
867                Outcome::DegradedButServing {
868                    surviving_replicas: w.replicas - 1,
869                }
870            } else {
871                Outcome::Reschedulable {
872                    candidates: alternates.to_vec(),
873                }
874            }
875        }
876    }
877}
878
879fn data_loss(w: &WorkloadNode) -> DataLoss {
880    let durable = w.durable_volumes();
881    if durable.is_empty() {
882        return if w.volumes.is_empty() {
883            DataLoss::None
884        } else {
885            DataLoss::EphemeralOnly
886        };
887    }
888
889    // A bind mount is operator-managed; the camp did not create the host path
890    // and has no basis for a claim about it either way. Only say "total" about
891    // volumes this camp is responsible for.
892    if !w.has_named_volume() {
893        return DataLoss::Unknown { volumes: durable };
894    }
895
896    match &w.durability {
897        DurabilityView::Undeclared => DataLoss::Total {
898            volumes: durable,
899            because: "no yah.durability.tier declared, so the yubaba-managed named volume \
900                      at /var/lib/yah/kamaji/volumes/ is the only copy"
901                .to_string(),
902        },
903        DurabilityView::Malformed { reason } => DataLoss::Total {
904            volumes: durable,
905            because: format!("the durability declaration cannot be read: {reason}"),
906        },
907        DurabilityView::Declared(d) => match d.tier {
908            DurabilityTier::None => DataLoss::Total {
909                volumes: durable,
910                because: "yah.durability.tier = \"none\" — deliberately no second copy".to_string(),
911            },
912            DurabilityTier::Snapshot | DurabilityTier::Dedup => DataLoss::Window {
913                tier: d.tier,
914                store: d.store.clone().unwrap_or_default(),
915                rpo_seconds: None,
916                rpo_is_default: false,
917            },
918            DurabilityTier::Stream => DataLoss::Window {
919                tier: d.tier,
920                store: d.store.clone().unwrap_or_default(),
921                rpo_seconds: Some(d.rpo_seconds.unwrap_or(DEFAULT_STREAM_RPO_SECONDS)),
922                rpo_is_default: d.rpo_seconds.is_none(),
923            },
924        },
925    }
926}
927
928fn recovery(w: &WorkloadNode, loss: &DataLoss) -> RecoveryEstimate {
929    match loss {
930        DataLoss::None | DataLoss::EphemeralOnly => RecoveryEstimate::Immediate,
931        DataLoss::Total { .. } | DataLoss::Unknown { .. } => RecoveryEstimate::NotRecoverable,
932        DataLoss::Window { .. } => {
933            let state_mb = match &w.durability {
934                DurabilityView::Declared(Durability {
935                    state_mb: Some(mb), ..
936                }) => *mb,
937                _ => {
938                    return RecoveryEstimate::UnknownStateSize {
939                        hint: workload_spec::DURABILITY_STATE_MB_ANNOTATION,
940                    }
941                }
942            };
943            RecoveryEstimate::Hydrate {
944                state_mb,
945                seconds: f64::from(state_mb) / MEASURED_HYDRATE_MB_PER_S,
946                // The *floor*, and it says so. A tier-2 restore also replays
947                // WAL frames, and `turso_backup::stream` fetches those one at a
948                // time — roadcase measured `2n + 1` serialized GETs for `n`
949                // generations, which at this RTT reaches the same order as the
950                // bulk transfer itself. `n` is not declared anywhere, so it is
951                // named rather than guessed at.
952                basis: format!(
953                    "bulk transfer at {MEASURED_HYDRATE_MB_PER_S} MB/s, measured R760-T10 \
954                     2026-08-29 on one host against one backend. A FLOOR: a tier-2 restore \
955                     adds 2n+1 serialized GETs at ~{MEASURED_GET_RTT_MS} ms each for n \
956                     generations, and n is not declared anywhere"
957                ),
958            }
959        }
960    }
961}
962
963fn quorum_effect(cfg: &CloudConfig, machines: &[MachineNode], dead: &str) -> QuorumEffect {
964    let Some(m) = machines.iter().find(|m| m.name == dead) else {
965        return QuorumEffect::NotInAGroup;
966    };
967    let Some(group) = m.sovereign_group.clone() else {
968        return QuorumEffect::NotInAGroup;
969    };
970    if !m.sovereign_role.is_voter() {
971        return QuorumEffect::NonVoter { group };
972    }
973
974    let voters_before = cfg
975        .machines_in_group(&group)
976        .into_iter()
977        .filter(|m| m.sovereign_role.unwrap_or_default().is_voter())
978        .count();
979    let voters_after = voters_before.saturating_sub(1);
980    // Raft majority is over the *configured* membership, which the loss of a
981    // box does not shrink — a dead voter still counts in the denominator until
982    // someone removes it from the configuration.
983    let majority_needed = voters_before / 2 + 1;
984
985    if voters_after >= majority_needed {
986        QuorumEffect::QuorumHolds {
987            group,
988            voters_before,
989            voters_after,
990            majority_needed,
991        }
992    } else {
993        QuorumEffect::QuorumLost {
994            group,
995            voters_before,
996            voters_after,
997            majority_needed,
998        }
999    }
1000}
1001
1002// ─── P2: renders ─────────────────────────────────────────────────────────────
1003
1004impl Topology {
1005    /// Render the model as a Mermaid `flowchart`.
1006    ///
1007    /// **A projection, not a second traversal.** Every node and edge below is
1008    /// read off fields [`analyze`] already computed, so the picture cannot
1009    /// disagree with the verdicts printed beside it. A diagram generated by its
1010    /// own walk of the config would be free to drift into decoration, which is
1011    /// worse than no diagram — it is the failure mode this method's shape
1012    /// exists to make impossible.
1013    ///
1014    /// The edge worth the whole render is `-.->|hydrate|`: the backup path is
1015    /// the one relationship in this graph that is invisible in the TOML, has no
1016    /// runtime today, and is exactly what decides whether a node loss is an
1017    /// incident or a restore.
1018    pub fn to_mermaid(&self) -> String {
1019        let mut out = String::from("flowchart TB\n");
1020
1021        // Machines, grouped by sovereign group. The grouping is the blast
1022        // radius (W305), so it is what a reader should see first.
1023        let mut groups: BTreeMap<Option<&str>, Vec<&MachineNode>> = BTreeMap::new();
1024        for m in &self.machines {
1025            groups
1026                .entry(m.sovereign_group.as_deref())
1027                .or_default()
1028                .push(m);
1029        }
1030        for (group, members) in &groups {
1031            let label = group.unwrap_or("ungrouped");
1032            out.push_str(&format!(
1033                "  subgraph grp_{}[\"{label}\"]\n",
1034                sanitize(label)
1035            ));
1036            for m in members {
1037                // `sovereign_role` defaults to Voter, so a box that declares no
1038                // group would otherwise render as "voter" — a seat in a quorum
1039                // it is not in. Match what `QuorumEffect::NotInAGroup` says.
1040                let role = match (group, m.sovereign_role.is_voter()) {
1041                    (None, _) => "no group",
1042                    (Some(_), true) => "voter",
1043                    (Some(_), false) => "non-voter",
1044                };
1045                let cap = match &m.allocatable {
1046                    Some(a) => format!(
1047                        "<br/>{}/{} MiB · {}/{} mCPU",
1048                        m.committed_memory_mb, a.memory_mb, m.committed_cpu_millis, a.cpu_millis
1049                    ),
1050                    None => "<br/>no [allocatable] declared".to_string(),
1051                };
1052                out.push_str(&format!(
1053                    "    {}[\"{}<br/><i>{role}</i>{cap}\"]\n",
1054                    node_id("m", &m.name),
1055                    m.name
1056                ));
1057            }
1058            out.push_str("  end\n");
1059        }
1060
1061        // Workloads, their mounts, and their public front doors.
1062        for w in &self.workloads {
1063            let wid = node_id("w", &w.name);
1064            out.push_str(&format!(
1065                "  {wid}(\"{}<br/><i>{}</i> · replicas {}\")\n",
1066                w.name,
1067                w.archetype.taint_key(),
1068                w.replicas
1069            ));
1070
1071            match &w.placement {
1072                Placement::Admitted { chosen, pinned, .. } => {
1073                    let verb = if *pinned { "pinned" } else { "placed" };
1074                    out.push_str(&format!("  {} -->|{verb}| {wid}\n", node_id("m", chosen)));
1075                }
1076                Placement::Unschedulable { .. } => {
1077                    out.push_str(&format!(
1078                        "  unschedulable{{{{no node admits it}}}} --> {wid}\n"
1079                    ));
1080                }
1081            }
1082
1083            for v in &w.volumes {
1084                let vid = node_id("v", &format!("{}-{}", w.name, v.label()));
1085                let (shape, edge) = match v {
1086                    VolumeDisposition::Copy { name, .. } => {
1087                        (format!("{vid}[(\"named: {name}\")]"), "mount")
1088                    }
1089                    VolumeDisposition::Precondition { host_path, .. } => (
1090                        format!("{vid}[(\"bind: {}\")]", host_path.display()),
1091                        "mount",
1092                    ),
1093                    VolumeDisposition::Discard { size_mb, .. } => {
1094                        (format!("{vid}[(\"tmpfs {size_mb} MiB\")]"), "ephemeral")
1095                    }
1096                };
1097                out.push_str(&format!("  {shape}\n  {wid} -->|{edge}| {vid}\n"));
1098
1099                // The invisible edge. Only durable mounts can have one, and
1100                // only a declared tier draws it.
1101                if !v.is_durable() {
1102                    continue;
1103                }
1104                if let DurabilityView::Declared(d) = &w.durability {
1105                    if let Some(store) = &d.store {
1106                        let sid = node_id("s", store);
1107                        out.push_str(&format!("  {sid}[[\"{store}\"]]\n"));
1108                        out.push_str(&format!(
1109                            "  {vid} -.->|backup: {}| {sid}\n  {sid} -.->|hydrate| {vid}\n",
1110                            d.tier
1111                        ));
1112                    }
1113                }
1114            }
1115
1116            for host in &w.public_hostnames {
1117                let hid = node_id("p", host);
1118                out.push_str(&format!("  {hid}>\"{host}\"]\n  {hid} ==>|public| {wid}\n"));
1119            }
1120
1121            for dep in &w.depends_on {
1122                if let Some(target) = self.workloads.iter().find(|o| {
1123                    o.mesh_identity == *dep || o.mesh_identity.ends_with(&format!("/{dep}"))
1124                }) {
1125                    out.push_str(&format!(
1126                        "  {wid} -.->|mesh admit| {}\n",
1127                        node_id("w", &target.name)
1128                    ));
1129                }
1130            }
1131        }
1132
1133        out
1134    }
1135
1136    /// Render the survivability answer for one machine as plain text.
1137    ///
1138    /// Returns `None` when no machine by that name is declared — the caller
1139    /// owns the wording of that refusal, since it has the declared list.
1140    pub fn render_node_loss(&self, machine: &str) -> Option<String> {
1141        let loss = self.node_losses.iter().find(|l| l.machine == machine)?;
1142        let mut out = format!("If {machine} is lost at the hardware level:\n\n");
1143        out.push_str(&format!("  quorum: {}\n", loss.quorum.headline()));
1144        if loss.public_endpoints_lost.is_empty() {
1145            out.push_str("  public endpoints lost: none\n");
1146        } else {
1147            out.push_str(&format!(
1148                "  public endpoints lost: {}\n",
1149                loss.public_endpoints_lost.join(", ")
1150            ));
1151        }
1152
1153        if loss.impacts.is_empty() {
1154            out.push_str("\n  No declared workload is projected onto this machine.\n");
1155            return Some(out);
1156        }
1157
1158        for i in &loss.impacts {
1159            out.push_str(&format!(
1160                "\n  {} ({})\n",
1161                i.workload,
1162                i.archetype.taint_key()
1163            ));
1164            out.push_str(&format!("    what happens: {}\n", i.outcome.headline()));
1165            out.push_str(&format!("    data loss:    {}\n", i.data_loss.headline()));
1166            out.push_str(&format!("    recovery:     {}\n", i.recovery.headline()));
1167        }
1168        Some(out)
1169    }
1170
1171    /// Render the capacity arithmetic — the whole fleet, oversubscription
1172    /// called out rather than left to the reader to spot.
1173    pub fn render_capacity(&self) -> String {
1174        let mut out = String::from("Declared capacity vs projected commitment:\n\n");
1175        for m in &self.machines {
1176            let placed = if m.placed.is_empty() {
1177                "(nothing)".to_string()
1178            } else {
1179                m.placed.join(", ")
1180            };
1181            match &m.allocatable {
1182                Some(a) => out.push_str(&format!(
1183                    "  {:<16} {:>6}/{:<6} MiB   {:>6}/{:<6} mCPU   {placed}\n",
1184                    m.name,
1185                    m.committed_memory_mb,
1186                    a.memory_mb,
1187                    m.committed_cpu_millis,
1188                    a.cpu_millis
1189                )),
1190                None => out.push_str(&format!(
1191                    "  {:<16} {:>6}/{:<6} MiB   {:>6}/{:<6} mCPU   {placed}\n",
1192                    m.name, m.committed_memory_mb, "?", m.committed_cpu_millis, "?"
1193                )),
1194            }
1195        }
1196        if self.oversubscribed.is_empty() {
1197            out.push_str("\nNo machine is oversubscribed against its declared [allocatable].\n");
1198        } else {
1199            out.push_str(
1200                "\nOVERSUBSCRIBED — admission checks each workload against a node \
1201                 individually and never subtracts what is already committed (R572-F5), \
1202                 so these all admitted and cannot all run:\n",
1203            );
1204            for o in &self.oversubscribed {
1205                let mut axes = Vec::new();
1206                if o.memory_over {
1207                    axes.push(format!(
1208                        "memory {} MiB > {} MiB",
1209                        o.committed_memory_mb, o.allocatable.memory_mb
1210                    ));
1211                }
1212                if o.cpu_over {
1213                    axes.push(format!(
1214                        "cpu {} > {} millicores",
1215                        o.committed_cpu_millis, o.allocatable.cpu_millis
1216                    ));
1217                }
1218                out.push_str(&format!(
1219                    "  {}: {} — {}\n",
1220                    o.machine,
1221                    axes.join(", "),
1222                    o.workloads.join(", ")
1223                ));
1224            }
1225        }
1226        out
1227    }
1228
1229    /// Render every machine's loss, plus the capacity view — the default
1230    /// whole-fleet report.
1231    pub fn render(&self) -> String {
1232        let mut out = String::new();
1233        for m in &self.machines {
1234            if let Some(section) = self.render_node_loss(&m.name) {
1235                out.push_str(&section);
1236                out.push('\n');
1237            }
1238        }
1239        out.push_str(&self.render_capacity());
1240        out
1241    }
1242}
1243
1244/// Mermaid node ids must be identifier-ish; machine and workload names are DNS
1245/// labels and hostnames, and buckets are URLs. One prefix per kind keeps two
1246/// different things that sanitize to the same string apart.
1247fn node_id(prefix: &str, name: &str) -> String {
1248    format!("{prefix}_{}", sanitize(name))
1249}
1250
1251fn sanitize(name: &str) -> String {
1252    name.chars()
1253        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1254        .collect()
1255}
1256
1257#[cfg(test)]
1258mod tests {
1259    use super::*;
1260    use std::path::Path;
1261    use tempfile::{tempdir, TempDir};
1262
1263    /// Fixtures go through `CloudConfig::load`, never a struct literal, for the
1264    /// reason `migrate`'s own fixture records: a hand-built spec can express a
1265    /// shape no operator could write, and `load_workloads` shape-validates —
1266    /// which since R850-P4 includes the durability declaration. A test that
1267    /// skips the loader would pass on a spec the fleet would reject.
1268    struct Camp {
1269        dir: TempDir,
1270    }
1271
1272    impl Camp {
1273        fn new() -> Self {
1274            Self {
1275                dir: tempdir().unwrap(),
1276            }
1277        }
1278
1279        fn root(&self) -> &Path {
1280            self.dir.path()
1281        }
1282
1283        fn machine(self, name: &str, extra: &str) -> Self {
1284            let dir = self.root().join(".yah/infra/machines");
1285            std::fs::create_dir_all(&dir).unwrap();
1286            std::fs::write(
1287                dir.join(format!("{name}.toml")),
1288                format!(
1289                    "name = \"{name}\"\nprovider = \"static\"\nmesh_tags = []\n\
1290                     {extra}\n\
1291                     [connect]\naddress = \"10.0.0.1\"\nssh = \"root@{name}\"\n\
1292                     identity_file = \"~/.ssh/yah\"\n\
1293                     yubaba = \"http://{name}:7443\"\n"
1294                ),
1295            )
1296            .unwrap();
1297            self
1298        }
1299
1300        /// A machine with a capacity budget, which is what makes the
1301        /// oversubscription and NowhereToGo cases expressible.
1302        fn sized(self, name: &str, memory_mb: u32, cpu_millis: u32, extra: &str) -> Self {
1303            self.machine(
1304                name,
1305                &format!(
1306                    "{extra}\n[allocatable]\nmemory_mb = {memory_mb}\ncpu_millis = {cpu_millis}"
1307                ),
1308            )
1309        }
1310
1311        fn workload(self, name: &str, extra: &str) -> Self {
1312            self.workload_sized(name, 128, 100, extra)
1313        }
1314
1315        fn workload_sized(self, name: &str, memory_mb: u32, cpu_millis: u32, extra: &str) -> Self {
1316            let dir = self.root().join(".yah/infra/workloads");
1317            std::fs::create_dir_all(&dir).unwrap();
1318            std::fs::write(
1319                dir.join(format!("{name}.toml")),
1320                format!(
1321                    "schema_version = 1\nname = \"{name}\"\ntier = \"infra\"\n\
1322                     restart_policy = \"always\"\n\
1323                     {extra}\n\
1324                     [image]\nregistry = \"cr.yah.dev\"\nrepository = \"{name}\"\n\
1325                     tag = \"v1\"\ndigest = \"sha256:abc\"\n\
1326                     [resources]\nmemory_mb = {memory_mb}\ncpu_millis = {cpu_millis}\n\
1327                     ephemeral_storage_mb = 64\n\
1328                     [stop_policy]\nsignal = 15\ngrace_period = 10000\n\
1329                     [expose.mesh]\nidentity = \"{name}\"\nports = [8080]\nallow_from = []\n"
1330                ),
1331            )
1332            .unwrap();
1333            self
1334        }
1335
1336        fn analyze(&self) -> Topology {
1337            super::analyze(&CloudConfig::load(self.root()).expect("fixture camp must load"))
1338        }
1339    }
1340
1341    const NAMED_VOLUME: &str = "[[volumes]]\nsource = { named = { name = \"accounts\" } }\n\
1342                                target = \"/var/lib/app\"\nread_only = false\n";
1343
1344    fn impact<'a>(topo: &'a Topology, machine: &str, workload: &str) -> &'a WorkloadImpact {
1345        topo.node_losses
1346            .iter()
1347            .find(|l| l.machine == machine)
1348            .unwrap_or_else(|| panic!("no node_loss for {machine}"))
1349            .impacts
1350            .iter()
1351            .find(|i| i.workload == workload)
1352            .unwrap_or_else(|| panic!("{workload} is not projected onto {machine}"))
1353    }
1354
1355    // ── The driving question ─────────────────────────────────────────────────
1356
1357    /// R850's acceptance test, and the reason the relay exists.
1358    ///
1359    /// The noisetable-account shape verbatim: one replica, `archetype =
1360    /// "appliance"`, a yubaba-managed named volume, no durability tier
1361    /// declared. Hardware-kill the node. The analyzer has to say *out loud*
1362    /// that nothing reschedules, that the bytes are gone, and that even the
1363    /// operator verb has no source to copy from — because before this module
1364    /// existed, learning any of those three meant reading yubaba's source.
1365    #[test]
1366    fn hardware_killing_the_node_under_a_singleton_appliance_loses_everything() {
1367        let topo = Camp::new()
1368            .sized("us-west-001", 12288, 6000, "")
1369            .sized("us-west-003", 16384, 16000, "")
1370            .workload(
1371                "noisetable-account",
1372                &format!("replicas = 1\narchetype = \"appliance\"\n{NAMED_VOLUME}"),
1373            )
1374            .analyze();
1375
1376        let i = impact(&topo, "us-west-001", "noisetable-account");
1377
1378        // 1. Nothing reschedules — and the reason is the archetype, not a
1379        //    shortage of nodes. us-west-003 admits it fine and is irrelevant.
1380        let Outcome::PinnedAppliance {
1381            migrate_target,
1382            source_unreachable,
1383        } = &i.outcome
1384        else {
1385            panic!("expected PinnedAppliance, got {:?}", i.outcome);
1386        };
1387        assert_eq!(migrate_target.as_deref(), Some("us-west-003"));
1388
1389        // 2. `yah cloud migrate` is named as the human step AND disclaimed:
1390        //    it plans a copy, and a dead box has nothing to copy from.
1391        assert!(source_unreachable);
1392        let headline = i.outcome.headline();
1393        assert!(headline.contains("yah cloud migrate"), "{headline}");
1394        assert!(headline.contains("no source"), "{headline}");
1395
1396        // 3. Every account, passkey and session is gone.
1397        let DataLoss::Total { volumes, because } = &i.data_loss else {
1398            panic!("expected Total, got {:?}", i.data_loss);
1399        };
1400        assert_eq!(volumes, &["accounts".to_string()]);
1401        assert!(because.contains("yah.durability.tier"), "{because}");
1402        assert_eq!(i.recovery, RecoveryEstimate::NotRecoverable);
1403
1404        // 4. And the operator-facing text says all three without a source dive.
1405        let text = topo.render_node_loss("us-west-001").unwrap();
1406        assert!(text.contains("OPERATOR"), "{text}");
1407        assert!(text.contains("TOTAL"), "{text}");
1408        assert!(text.contains("nothing to recover from"), "{text}");
1409    }
1410
1411    /// The same spec's verdict must not soften when the appliance is the only
1412    /// thing the fleet could hold — `migrate_target` goes to `None` and the
1413    /// outcome stays manual rather than degrading into "nowhere to go", which
1414    /// would read as a capacity problem instead of an archetype one.
1415    #[test]
1416    fn an_appliance_with_no_alternate_still_reads_as_pinned_not_as_capacity() {
1417        let topo = Camp::new()
1418            .sized("solo", 1024, 1000, "")
1419            .workload(
1420                "db",
1421                &format!("replicas = 1\narchetype = \"appliance\"\n{NAMED_VOLUME}"),
1422            )
1423            .analyze();
1424
1425        let i = impact(&topo, "solo", "db");
1426        let Outcome::PinnedAppliance { migrate_target, .. } = &i.outcome else {
1427            panic!("expected PinnedAppliance, got {:?}", i.outcome);
1428        };
1429        assert_eq!(*migrate_target, None);
1430        assert!(i.outcome.is_manual());
1431    }
1432
1433    // ── The fungible half ────────────────────────────────────────────────────
1434
1435    #[test]
1436    fn a_stateless_server_with_another_admitting_node_is_automatic() {
1437        let topo = Camp::new()
1438            .sized("a", 4096, 4000, "")
1439            .sized("b", 4096, 4000, "")
1440            .workload("api", "replicas = 1\narchetype = \"server\"")
1441            .analyze();
1442
1443        let i = impact(&topo, "a", "api");
1444        assert_eq!(
1445            i.outcome,
1446            Outcome::Reschedulable {
1447                candidates: vec!["b".to_string()]
1448            }
1449        );
1450        assert_eq!(i.data_loss, DataLoss::None);
1451        assert_eq!(i.recovery, RecoveryEstimate::Immediate);
1452        assert!(!i.outcome.is_manual());
1453    }
1454
1455    /// The finding that is invisible in the TOML: a workload can be perfectly
1456    /// stateless and restartable and still be a single point of failure,
1457    /// because only one declared box clears its floor.
1458    #[test]
1459    fn a_stateless_server_that_only_one_node_admits_stays_down() {
1460        let topo = Camp::new()
1461            .sized("big", 8192, 8000, "")
1462            .sized("small", 512, 8000, "")
1463            .workload_sized("hungry", 4096, 100, "replicas = 1\narchetype = \"server\"")
1464            .analyze();
1465
1466        let i = impact(&topo, "big", "hungry");
1467        let Outcome::NowhereToGo { reason } = &i.outcome else {
1468            panic!("expected NowhereToGo, got {:?}", i.outcome);
1469        };
1470        assert!(reason.contains("big"), "{reason}");
1471        assert!(i.outcome.is_manual());
1472    }
1473
1474    #[test]
1475    fn replicas_above_one_degrade_rather_than_fail() {
1476        let topo = Camp::new()
1477            .sized("a", 4096, 4000, "")
1478            .sized("b", 4096, 4000, "")
1479            .workload("api", "replicas = 3\narchetype = \"server\"")
1480            .analyze();
1481
1482        assert_eq!(
1483            impact(&topo, "a", "api").outcome,
1484            Outcome::DegradedButServing {
1485                surviving_replicas: 2
1486            }
1487        );
1488    }
1489
1490    /// A `no-appliance` taint is absolute — there is no toleration anywhere in
1491    /// the tree (W305 finding 2) — so a tainted box must never show up as an
1492    /// alternate an appliance could fail over to.
1493    #[test]
1494    fn a_no_appliance_taint_removes_a_node_from_the_alternates() {
1495        let topo = Camp::new()
1496            .sized("prod", 8192, 8000, "")
1497            .sized("builder", 16384, 16000, "taints = [\"no-appliance\"]")
1498            .workload(
1499                "db",
1500                &format!("replicas = 1\narchetype = \"appliance\"\n{NAMED_VOLUME}"),
1501            )
1502            .analyze();
1503
1504        let db = topo.workloads.iter().find(|w| w.name == "db").unwrap();
1505        assert_eq!(db.placement.machine(), Some("prod"));
1506        assert!(
1507            db.placement.alternates().is_empty(),
1508            "builder carries no-appliance and must not be offered: {:?}",
1509            db.placement
1510        );
1511    }
1512
1513    #[test]
1514    fn a_job_is_a_lost_run_not_a_failover() {
1515        let topo = Camp::new()
1516            .sized("a", 4096, 4000, "")
1517            .workload("build", "replicas = 1\narchetype = \"job\"")
1518            .analyze();
1519        assert_eq!(impact(&topo, "a", "build").outcome, Outcome::RunLost);
1520    }
1521
1522    // ── Durability ───────────────────────────────────────────────────────────
1523
1524    #[test]
1525    fn a_declared_stream_tier_turns_total_loss_into_a_bounded_window() {
1526        let topo = Camp::new()
1527            .sized("a", 4096, 4000, "")
1528            .workload(
1529                "db",
1530                &format!(
1531                    "replicas = 1\narchetype = \"appliance\"\n{NAMED_VOLUME}\n\
1532                     [annotations]\n\
1533                     \"yah.durability.tier\" = \"stream\"\n\
1534                     \"yah.durability.engine\" = \"turso\"\n\
1535                     \"yah.durability.store\" = \"s3://backups/db\"\n\
1536                     \"yah.durability.subjects\" = \"accounts.db\"\n\
1537                     \"yah.durability.rpo-seconds\" = \"30\"\n\
1538                     \"yah.durability.state-mb\" = \"100\"\n"
1539                ),
1540            )
1541            .analyze();
1542
1543        let i = impact(&topo, "a", "db");
1544        assert_eq!(
1545            i.data_loss,
1546            DataLoss::Window {
1547                tier: DurabilityTier::Stream,
1548                store: "s3://backups/db".to_string(),
1549                rpo_seconds: Some(30),
1550                rpo_is_default: false,
1551            }
1552        );
1553
1554        // The recovery figure is roadcase's measured constant applied to the
1555        // declared size — 100 MiB at 32.6 MB/s ≈ 3.1 s, the same order as the
1556        // 3.3 s R760-T10 actually measured for 100 MB.
1557        let RecoveryEstimate::Hydrate {
1558            state_mb,
1559            seconds,
1560            basis,
1561        } = &i.recovery
1562        else {
1563            panic!("expected Hydrate, got {:?}", i.recovery);
1564        };
1565        assert_eq!(*state_mb, 100);
1566        assert!((*seconds - 3.067).abs() < 0.01, "{seconds}");
1567        // Provenance survives into the structured output, not just the text.
1568        assert!(basis.contains("R760-T10"), "{basis}");
1569        assert!(basis.contains("FLOOR"), "{basis}");
1570    }
1571
1572    /// An undeclared RPO on a stream tier is the turso-backup default, and the
1573    /// report has to say which of the two it is printing — a number the
1574    /// operator believes they chose is worse than no number.
1575    #[test]
1576    fn an_undeclared_rpo_is_labelled_as_the_default_not_as_a_choice() {
1577        let topo = Camp::new()
1578            .sized("a", 4096, 4000, "")
1579            .workload(
1580                "db",
1581                &format!(
1582                    "replicas = 1\narchetype = \"appliance\"\n{NAMED_VOLUME}\n\
1583                     [annotations]\n\
1584                     \"yah.durability.tier\" = \"stream\"\n\
1585                     \"yah.durability.engine\" = \"turso\"\n\
1586                     \"yah.durability.store\" = \"s3://backups/db\"\n\
1587                     \"yah.durability.subjects\" = \"accounts.db\"\n"
1588                ),
1589            )
1590            .analyze();
1591
1592        let i = impact(&topo, "a", "db");
1593        let DataLoss::Window {
1594            rpo_seconds,
1595            rpo_is_default,
1596            ..
1597        } = &i.data_loss
1598        else {
1599            panic!("expected Window, got {:?}", i.data_loss);
1600        };
1601        assert_eq!(*rpo_seconds, Some(DEFAULT_STREAM_RPO_SECONDS));
1602        assert!(rpo_is_default);
1603        assert!(i.data_loss.headline().contains("not declared"));
1604
1605        // No declared state size ⇒ no invented duration.
1606        assert_eq!(
1607            i.recovery,
1608            RecoveryEstimate::UnknownStateSize {
1609                hint: "yah.durability.state-mb"
1610            }
1611        );
1612    }
1613
1614    /// A snapshot tier has a copy but no recovery point the spec can state,
1615    /// and saying "≤ 120s" for it would be a fabrication.
1616    #[test]
1617    fn a_snapshot_tier_reports_an_unbounded_window_rather_than_borrowing_the_stream_rpo() {
1618        let topo = Camp::new()
1619            .sized("a", 4096, 4000, "")
1620            .workload(
1621                "db",
1622                &format!(
1623                    "replicas = 1\narchetype = \"appliance\"\n{NAMED_VOLUME}\n\
1624                     [annotations]\n\
1625                     \"yah.durability.tier\" = \"snapshot\"\n\
1626                     \"yah.durability.engine\" = \"turso\"\n\
1627                     \"yah.durability.store\" = \"s3://backups/db\"\n\
1628                     \"yah.durability.subjects\" = \"accounts.db\"\n"
1629                ),
1630            )
1631            .analyze();
1632
1633        let i = impact(&topo, "a", "db");
1634        let DataLoss::Window { rpo_seconds, .. } = &i.data_loss else {
1635            panic!("expected Window, got {:?}", i.data_loss);
1636        };
1637        assert_eq!(*rpo_seconds, None);
1638        assert!(i.data_loss.headline().contains("unbounded by the spec"));
1639    }
1640
1641    /// `tier = "none"` still loses everything — but as a decision, and the
1642    /// report must not read the same as the case where nobody looked.
1643    #[test]
1644    fn a_deliberate_none_tier_reads_differently_from_an_undeclared_one() {
1645        let camp = Camp::new().sized("a", 4096, 4000, "").workload(
1646            "cache",
1647            &format!(
1648                "replicas = 1\narchetype = \"appliance\"\n{NAMED_VOLUME}\n\
1649                 [annotations]\n\"yah.durability.tier\" = \"none\"\n"
1650            ),
1651        );
1652        let topo = camp.analyze();
1653        let DataLoss::Total { because, .. } = &impact(&topo, "a", "cache").data_loss else {
1654            panic!("expected Total");
1655        };
1656        assert!(because.contains("deliberately"), "{because}");
1657        assert!(
1658            !because.contains("no yah.durability.tier declared"),
1659            "{because}"
1660        );
1661    }
1662
1663    /// A bind mount is operator-managed. The camp did not create the host path
1664    /// and has no basis for saying the bytes are gone — the same refusal to
1665    /// guess `migrate::preconditions` makes about the same mount kind.
1666    #[test]
1667    fn a_bind_mount_is_unknown_rather_than_total() {
1668        let topo = Camp::new()
1669            .sized("a", 4096, 4000, "")
1670            .workload(
1671                "svc",
1672                "replicas = 1\narchetype = \"appliance\"\n\
1673                 [[volumes]]\nsource = { bind = { host_path = \"/srv/data\" } }\n\
1674                 target = \"/data\"\nread_only = false\n",
1675            )
1676            .analyze();
1677
1678        let i = impact(&topo, "a", "svc");
1679        assert!(matches!(i.data_loss, DataLoss::Unknown { .. }));
1680        assert!(i.data_loss.headline().contains("UNKNOWN"));
1681    }
1682
1683    #[test]
1684    fn tmpfs_only_is_not_a_data_loss() {
1685        let topo = Camp::new()
1686            .sized("a", 4096, 4000, "")
1687            .workload(
1688                "svc",
1689                "replicas = 1\narchetype = \"server\"\n\
1690                 [[volumes]]\nsource = { tmpfs = { size_mb = 64 } }\n\
1691                 target = \"/scratch\"\nread_only = false\n",
1692            )
1693            .analyze();
1694        assert_eq!(impact(&topo, "a", "svc").data_loss, DataLoss::EphemeralOnly);
1695    }
1696
1697    // ── Capacity (P3) ────────────────────────────────────────────────────────
1698
1699    /// The gap admission structurally cannot see: `RequiredSpec::matches`
1700    /// checks one workload against one node's `[allocatable]` and never
1701    /// subtracts what is already committed, so three workloads that each fit
1702    /// are each admitted onto a node that cannot hold their sum.
1703    #[test]
1704    fn workloads_that_each_fit_can_still_oversubscribe_the_node_they_all_land_on() {
1705        let topo = Camp::new()
1706            .sized("small", 1024, 4000, "")
1707            .workload_sized("a", 512, 100, "replicas = 1\narchetype = \"server\"")
1708            .workload_sized("b", 512, 100, "replicas = 1\narchetype = \"server\"")
1709            .workload_sized("c", 512, 100, "replicas = 1\narchetype = \"server\"")
1710            .analyze();
1711
1712        assert_eq!(topo.oversubscribed.len(), 1);
1713        let o = &topo.oversubscribed[0];
1714        assert_eq!(o.machine, "small");
1715        assert_eq!(o.committed_memory_mb, 1536);
1716        assert!(o.memory_over);
1717        assert!(!o.cpu_over);
1718        assert!(topo.render_capacity().contains("OVERSUBSCRIBED"));
1719    }
1720
1721    /// Replicas multiply the ask. Admission checks one copy against one node
1722    /// and never multiplies, so `replicas = 4` of a fitting workload is exactly
1723    /// the shape that admits cleanly and cannot run.
1724    #[test]
1725    fn replicas_multiply_the_commitment() {
1726        let topo = Camp::new()
1727            .sized("small", 1024, 4000, "")
1728            .workload_sized("api", 512, 100, "replicas = 4\narchetype = \"server\"")
1729            .analyze();
1730
1731        assert_eq!(topo.machines[0].committed_memory_mb, 2048);
1732        assert_eq!(topo.oversubscribed.len(), 1);
1733    }
1734
1735    /// A node with no `[allocatable]` is *unconstrained*, exactly as admission
1736    /// reads it — not a zero-capacity node. Flagging it would light up every
1737    /// machine nobody has measured yet and train the operator to ignore this.
1738    #[test]
1739    fn a_node_with_no_allocatable_block_is_never_reported_oversubscribed() {
1740        let topo = Camp::new()
1741            .machine("unmeasured", "")
1742            .workload_sized("api", 99999, 99999, "replicas = 1\narchetype = \"server\"")
1743            .analyze();
1744
1745        assert!(topo.oversubscribed.is_empty());
1746        assert!(topo.render_capacity().contains("unmeasured"));
1747    }
1748
1749    // ── Quorum ───────────────────────────────────────────────────────────────
1750
1751    #[test]
1752    fn losing_one_of_three_voters_keeps_quorum() {
1753        let topo = Camp::new()
1754            .machine(
1755                "a",
1756                "sovereign_group = \"prod\"\nsovereign_role = \"voter\"",
1757            )
1758            .machine(
1759                "b",
1760                "sovereign_group = \"prod\"\nsovereign_role = \"voter\"",
1761            )
1762            .machine(
1763                "c",
1764                "sovereign_group = \"prod\"\nsovereign_role = \"voter\"",
1765            )
1766            .analyze();
1767
1768        let q = &topo.node_losses[0].quorum;
1769        assert_eq!(
1770            *q,
1771            QuorumEffect::QuorumHolds {
1772                group: "prod".to_string(),
1773                voters_before: 3,
1774                voters_after: 2,
1775                majority_needed: 2,
1776            }
1777        );
1778    }
1779
1780    /// The second half of a node loss that is easy to miss: cluster secrets are
1781    /// read from the *local raft replica*, so a group that loses quorum fails
1782    /// workloads whose containers were never touched.
1783    #[test]
1784    fn losing_one_of_two_voters_loses_quorum_and_says_what_that_costs() {
1785        let topo = Camp::new()
1786            .machine(
1787                "a",
1788                "sovereign_group = \"prod\"\nsovereign_role = \"voter\"",
1789            )
1790            .machine(
1791                "b",
1792                "sovereign_group = \"prod\"\nsovereign_role = \"voter\"",
1793            )
1794            .analyze();
1795
1796        let q = &topo.node_losses[0].quorum;
1797        assert!(matches!(q, QuorumEffect::QuorumLost { .. }), "{q:?}");
1798        let headline = q.headline();
1799        assert!(headline.contains("LOSES QUORUM"), "{headline}");
1800        assert!(headline.contains("secrets"), "{headline}");
1801    }
1802
1803    /// A non-voter's absence from `/raft/status` is correct, not drift
1804    /// (`workload_spec::sovereign`), so losing one costs no seat.
1805    #[test]
1806    fn a_non_voter_has_no_seat_to_lose() {
1807        let topo = Camp::new()
1808            .machine(
1809                "a",
1810                "sovereign_group = \"prod\"\nsovereign_role = \"voter\"",
1811            )
1812            .machine(
1813                "w",
1814                "sovereign_group = \"prod\"\nsovereign_role = \"non-voter\"",
1815            )
1816            .analyze();
1817
1818        let q = &topo
1819            .node_losses
1820            .iter()
1821            .find(|l| l.machine == "w")
1822            .unwrap()
1823            .quorum;
1824        assert_eq!(
1825            *q,
1826            QuorumEffect::NonVoter {
1827                group: "prod".to_string()
1828            }
1829        );
1830    }
1831
1832    // ── P2: the render is a projection ───────────────────────────────────────
1833
1834    /// The scope trap this relay was warned about: a diagram produced by its
1835    /// own walk of the config can disagree with the analysis printed beside it.
1836    /// This pins that it cannot — the placement edge in the Mermaid output is
1837    /// the placement the model computed, and the hydrate edge exists exactly
1838    /// when the model found a durability tier.
1839    #[test]
1840    fn the_mermaid_render_agrees_with_the_model_it_projects() {
1841        let topo = Camp::new()
1842            .sized("us-west-001", 12288, 6000, "sovereign_group = \"prod\"")
1843            .sized("us-west-003", 16384, 16000, "taints = [\"no-appliance\"]")
1844            .workload(
1845                "backed-up",
1846                &format!(
1847                    "replicas = 1\narchetype = \"appliance\"\n{NAMED_VOLUME}\n\
1848                     [annotations]\n\
1849                     \"yah.durability.tier\" = \"stream\"\n\
1850                     \"yah.durability.engine\" = \"turso\"\n\
1851                     \"yah.durability.store\" = \"s3://backups/db\"\n\
1852                     \"yah.durability.subjects\" = \"accounts.db\"\n"
1853                ),
1854            )
1855            .analyze();
1856
1857        let mermaid = topo.to_mermaid();
1858        let w = topo
1859            .workloads
1860            .iter()
1861            .find(|w| w.name == "backed-up")
1862            .unwrap();
1863
1864        // The edge names the machine the model chose, not a re-derived one.
1865        assert_eq!(w.placement.machine(), Some("us-west-001"));
1866        assert!(
1867            mermaid.contains("m_us_west_001 -->|placed| w_backed_up"),
1868            "{mermaid}"
1869        );
1870        // The blast radius is what a reader sees first.
1871        assert!(mermaid.contains("subgraph grp_prod[\"prod\"]"), "{mermaid}");
1872        // The edge that is invisible in the TOML.
1873        assert!(mermaid.contains("|hydrate|"), "{mermaid}");
1874        assert!(mermaid.contains("s3://backups/db"), "{mermaid}");
1875        // Committed-vs-allocatable rides the same numbers as render_capacity.
1876        assert!(mermaid.contains("128/12288 MiB"), "{mermaid}");
1877    }
1878
1879    /// The negative half: no declared tier means no hydrate edge. A diagram
1880    /// that drew one anyway would show a safety property that does not exist,
1881    /// which is the single worst thing this render could do.
1882    #[test]
1883    fn no_declared_tier_draws_no_hydrate_edge() {
1884        let topo = Camp::new()
1885            .sized("a", 4096, 4000, "")
1886            .workload(
1887                "db",
1888                &format!("replicas = 1\narchetype = \"appliance\"\n{NAMED_VOLUME}"),
1889            )
1890            .analyze();
1891
1892        let mermaid = topo.to_mermaid();
1893        assert!(mermaid.contains("v_db_accounts"), "{mermaid}");
1894        assert!(!mermaid.contains("hydrate"), "{mermaid}");
1895    }
1896
1897    #[test]
1898    fn a_public_hostname_is_an_edge_and_is_reported_lost_with_its_node() {
1899        let topo = Camp::new()
1900            .sized("a", 4096, 4000, "")
1901            .workload(
1902                "web",
1903                "replicas = 1\narchetype = \"server\"\n\
1904                 [expose.public]\nhostname = \"app.example.com\"\nport = 8080\n\
1905                 tls = \"cf_managed\"\n",
1906            )
1907            .analyze();
1908
1909        assert_eq!(
1910            topo.node_losses[0].public_endpoints_lost,
1911            vec!["app.example.com".to_string()]
1912        );
1913        assert!(topo.to_mermaid().contains("|public|"));
1914        assert!(topo
1915            .render_node_loss("a")
1916            .unwrap()
1917            .contains("public endpoints lost: app.example.com"));
1918    }
1919
1920    /// `sovereign_role` defaults to `Voter`, so a box that declares no group
1921    /// renders as one unless the label is suppressed — a quorum seat in a
1922    /// quorum that does not exist. Caught against the live camp, where
1923    /// us-west-002 and us-west-015 declare no group.
1924    #[test]
1925    fn an_ungrouped_machine_is_not_labelled_a_voter() {
1926        let mermaid = Camp::new()
1927            .sized("loner", 1024, 1000, "")
1928            .analyze()
1929            .to_mermaid();
1930        assert!(mermaid.contains("<i>no group</i>"), "{mermaid}");
1931        assert!(!mermaid.contains("<i>voter</i>"), "{mermaid}");
1932    }
1933
1934    #[test]
1935    fn an_undeclared_machine_has_no_node_loss_section() {
1936        let topo = Camp::new().sized("a", 4096, 4000, "").analyze();
1937        assert!(topo.render_node_loss("typo").is_none());
1938        assert!(topo.render_node_loss("a").is_some());
1939    }
1940}