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://<account>.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(§ion);
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}