Expand description
R850 — what the declared topology does when a node dies, and whether it fits on the boxes it is declared against.
§The question this module exists to answer
From the noisetable camp, 2026-09-02: “I have a singleton process with three in-process turso DBs on one node. I kill that node at the hardware level. What happens?”
Every fact needed to answer that is already in .yah/ — the archetype, the
volume sources, the replica count, the restart policy, the node’s taints and
[allocatable], the sovereign role. What was missing was anywhere they were
read together, so the honest answer required reading yubaba’s source. This
module is that reading, done once, as a pure function.
§Pure, declaration-only, and read-only
analyze takes a CloudConfig and returns a Topology. No network,
no credentials, no filesystem beyond the load that already happened, and
nothing here changes runtime behaviour — the same contract
crate::migrate::plan_migration holds, and for the same reason: an
answer you can only get by probing a live fleet is an answer you cannot get
before committing the topology, which is exactly when it is worth having.
The cost of that purity is that placement here is projected, not
observed: Placement is what the admission seam
(CloudConfig::admit_workload_candidates) would decide today, not where
containers are running right now. Where the two can differ is stated on
Placement itself.
§What it deliberately does not model
- Liveness. A node declared here may be off. Admission has no liveness
input by design (see
admit_workload_candidates), and neither does this. - Correlated failure. “Node X dies” is one node. Losing a rack, a region, or the upstream the whole camp NATs through is a different question with a different answer, and pretending a per-node walk covers it would be worse than not asking.
- The blast radius of the control plane itself. Losing the box that
hosts the operator bridge is modelled only as far as
QuorumEffectgoes.
@arch:see(.yah/docs/working/W305-sovereign-groups-environments-edges.md)
@yah:ticket(R850-F1, “Hydrate-on-place: act on the declared durability tier at runtime, with fencing”)
@yah:status(review)
@yah:at(2026-09-10T08:08:10Z)
@yah:assignee(agent:bundle-anthropic-ashguard)
@yah:phase(P4b)
@yah:parent(R850)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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).”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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.”)
@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: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.”)
@yah:gotcha(“DOWNSTREAM CONSUMER MEASUREMENT from the noisetable camp (R131-T16), taken 2026-09-11T06:02Z against us-east-001. The release is PARTIALLY out and it is worth knowing which half. The deployed kamaji’s --help now matches hydrate-helper twice (it matched 0 on 2026-09-10), but tail-helper still matches 0. kamaji.service ExecStart is /usr/local/bin/kamaji --socket /run/kamaji/kamaji.sock --containerd-socket /run/containerd/containerd.sock --native-exec-dir /var/lib/yah/kamaji/native and passes NEITHER flag. /usr/local/bin holds only turso-backup-snapshot; turso-backup-tail and turso-backup-hydrate are both absent. The node has been redeployed (kamaji.rollback-20260910, yubaba.rollback-20260910 present), so this is a partial release rather than a stalled one. CONSEQUENCE FOR A REAL DOWNSTREAM SERVICE: noisetable-account is live, serving production passkey sign-in, and its only backup is a 10-minute-RPO snapshot timer with no fencing and a human-run restore. It cannot declare yah.durability.* until a kamaji release carries --tail-helper AND --hydrate-helper AND both helper binaries AND the S3_* credentials onto the node — declaring before that makes kamaji REFUSE the deploy, i.e. takes the account API down rather than backing it up. THE ASK, one line: sign off and commit R850-F1, then cut a kamaji release with BOTH durability helpers wired into ExecStart and both binaries placed. Nothing in the noisetable camp can produce that.”)
@yah:ticket(R850-T2, “Feed a measured restore time back to yah cloud topology, without making analyze do I/O”)
@yah:status(review)
@yah:at(2026-09-11T06:27:57Z)
@yah:assignee(agent:bundle-anthropic-ashguard)
@yah:phase(P3b)
@yah:parent(R850)
@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.”)
@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.”)
@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.”)
@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.”)
@yah:handoff(“LANDED. topology::analyze now reports a MEASURED restore where one exists, and it is still PURE — no I/O, no new argument, no Path, no clock. New module oss/yubaba/crates/cloud/src/recovery_journal.rs (registered in lib.rs next to asset_journal) is an append-only JSONL journal at .yah/cloud/recovery.jsonl via a new paths::recovery_journal(workspace_root) (paths.rs, beside asset_status_journal). Record shape is one JSON object per line: {at, workload, node, tier?, subject, bytes, seconds, helper}, at RFC3339. CloudConfig::load replays it into a new field CloudConfig.recovery_measurements: BTreeMap<String, WorkloadRecovery> keyed by workload; a missing file replays to empty and is NOT an error, exactly like an unsynced infra source in the same loader. load_from_config_dir gets an empty map for the same reason the sources overlay does not apply there (commented at the site). New RecoveryEstimate::Measured { seconds, bytes, measured_at, age_days, node, basis } at topology.rs, preferred inside the DataLoss::Window arm of recovery() over BOTH Hydrate and UnknownStateSize whenever a record exists for that workload. Provenance AND age both go into the JSON so a consumer can strip neither; headline() extended to match. Wiring is node_loss -> workload_impact(w, dead, &cfg.recovery_measurements) -> recovery(w, loss, measurements).”)
@yah:handoff(“DECLARATION-SURFACE ANSWERS, as implemented. (1) WHERE: .yah/cloud/recovery.jsonl, sibling of asset_journal’s status.jsonl, read at CloudConfig::load time so analyze sees it as declared data. (2) WHO PRUNES: nobody. Append-only is the whole retention policy, same answer asset_journal gives; there is no pruner and one must not be built. the_last_record_for_a_subject_wins_and_nothing_is_pruned asserts both lines survive on disk while replay keeps only the latest. (3) STALE vs NONE: a stale measurement is REPORTED, never discarded. recovery_journal::STALE_AFTER_DAYS = 30; past that the estimate stays Measured and the headline gains "measured N days ago; declared state may have grown since, so treat it as a floor rather than a forecast". A real timed restore from 90 days ago still beats an extrapolation from one constant measured once on one unrelated host. Two derived decisions worth knowing: a workload’s measured_at is the OLDEST component of the sum (a sum is only as fresh as its stalest part), and age_days is derived from WorkloadRecovery.as_of — the clock is read once at replay and captured as data, which is what lets analyze stay clockless as well as I/O-free.”)
@yah:handoff(“WRITER SEAM: yah cloud topology --record-hydrate <path|-> --workload <NAME> --node <MACHINE> [--tier <TIER>] in app/yah/cli/src/cloud.rs. clap requires_all binds workload+node to the flag (a measurement nobody can attribute is worse than none); a redundant bail guards it anyway. New fn record_hydrate_measurement reads every non-empty line of the file or stdin, parses each through RecoveryRecord::from_helper_json, appends, and reports the count + summed seconds on STDERR so --format json keeps a clean stdout. Ingest happens BEFORE load_cloud, so the same invocation reports with the measurement it just filed — the flag verifies itself. A line reporting no restore (already_populated / nothing_in_the_store / refused) contributes zero records and is NOT an error, but it prints a loud "nothing appended" line rather than reading as success. --tier exists because the helper reads TIER from its own environment and does not print it; unattributed it stays None rather than being guessed. cloud.rs edits were confined to the Topology arg struct, its dispatch arm, handle_topology, and the one new fn — plus 3 mechanical one-line recovery_measurements: additions to pre-existing CloudConfig test-helper literals (16529/16674/17397), driven off compiler spans.”)
@yah:verify(“BASELINE MEASURED BEFORE THE FIRST EDIT: cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib = 1173 passed, 0 failed, 4 ignored. AFTER: 1187 passed, 0 failed, 4 ignored (+14 new, zero regressions). cargo check --manifest-path oss/yubaba/Cargo.toml -p yubaba --all-targets clean. cargo build -p yah --lib and cargo check -p yah --tests both clean from the repo root (no errors; the warnings present are all pre-existing and in other agents’ files). cargo test --manifest-path oss/turso-backup/Cargo.toml --bin turso-backup-hydrate = 5 passed, 0 failed. ./scripts/check-schema-drift.sh = "ok: .yah/schema is in sync with the Rust types" — no regeneration needed, and workload-spec sources were not touched so its TS guard is not implicated (not run).”)
@yah:verify(“ALL FOUR REQUIRED TESTS EXIST AND PASS. (a) an_absent_recovery_journal_leaves_todays_answers_untouched — asserts the journal file does not exist, then pins BOTH pre-existing answers unchanged (Hydrate at 100/32.6 s with state-mb declared, UnknownStateSize without). (b) a_journalled_restore_replaces_the_extrapolation_with_the_measured_sum — two subjects, 12.5s + 8.5s = 21.0s summed, 100 MiB summed, basis contains "MEASURED, not extrapolated" and does NOT contain R760-T10. (c) a_stale_measurement_is_still_reported_and_says_so — a 90-day-old entry stays Measured (explicitly NOT a fallback to Hydrate) and its headline says "measured 90 days ago"/"may have grown since". (d) a_real_hydrate_line_parses_verbatim in recovery_journal.rs. Plus a_measurement_beats_an_undeclared_state_size and a_measurement_for_one_workload_does_not_leak_into_another (a stray record must not invent state for a stateless workload), and 7 journal-level tests in recovery_journal.rs. Test fixtures go through the REAL journal writer and the real CloudConfig::load via the existing Camp harness (new Camp::measured(...) helper) — nothing hand-builds a CloudConfig.”)
@yah:verify(“END-TO-END, against a staged fixture camp at /tmp/r850t2-camp with the real ./target/debug/yah binary, not just unit tests. BEFORE: recovery: >= ~3.1s to pull 100 MiB (extrapolated from 32.6 MB/s measured once, R760-T10...). AFTER --record-hydrate /tmp/r850t2-hydrate.json --workload db --node a --tier stream: stderr "recorded 2 measured subject restore(s) for workload ‘db’ on ‘a’ (21.0s total)", two JSONL lines on disk, and the same invocation printed recovery: 21.0s MEASURED — a real restore of 100.0 MiB timed on a, 0 day(s) ago. --format json carries kind=measured with seconds/bytes/measured_at/age_days/node/basis all present. The - stdin path and the no-measurement outcome path were both exercised: {\"outcome\":\"already_populated\"} on stdin printed the loud "nothing appended" line and left the journal untouched.”)
@yah:gotcha(“SCOPE HELD: the kamaji wire protocol was NOT widened. kamaji::hydrate::run still returns HydrateResult::Proceed(Some(line)) and server.rs only info!s it; kamaji-proto/messages.rs and kamaji-bin/server.rs are untouched (both are uncommitted-modified by peers in this shared tree). The CLI ingest is the seam. LEADER CALL WORTH FILING: carrying the helper’s line back through kamaji so a fleet-node restore journals itself with no operator step IS the right long-term shape — today a measurement only exists if somebody remembers to run --record-hydrate, which is exactly the kind of manual step that makes a measured figure permanently absent. Not built here by instruction. EDIT OUTSIDE THE PRIMARY BLAST RADIUS, DISCLOSED: oss/turso-backup/src/bin/hydrate.rs gained 17 lines — a full-line assert_eq! inside the EXISTING test a_hydrated_outcome_reports_measured_bytes_and_seconds, pinning its emitted format string byte-for-byte to REAL_HYDRATE_LINE in recovery_journal.rs. That is what makes the verify item "build the fixture from hydrate.rs’s own format string so the two cannot drift" actually true: cloud deliberately takes no dependency on turso-backup, so the only way to stop the two drifting is an assertion on each side of the same literal. Changing outcome_to_json now fails in turso-backup FIRST, naming the cloud constant to update. @Ashguard:blade (session:8f7399ff) is live in oss/turso-backup/src/stream.rs on this same relay — different file, no overlap with this edit.”)
@yah:handoff(“THE DECLARATION-SURFACE CALL THIS TICKET WAS FILED TO MAKE, made and shipped. Where the cache lives: an append-only JSONL journal at .yah/cloud/recovery.jsonl, reached by a new paths::recovery_journal(workspace_root) — deliberately the same shape as the asset_journal / .yah/cloud/status.jsonl precedent already in this crate (R470-T1), not a new mechanism. Who prunes it: nobody, which is the point of append-only plus last-wins replay, and is the same answer the asset journal already gives. Whether a stale measurement is worse than none: NO — a real timed restore with its age printed beats an extrapolation from one unrelated host, so Measured is never discarded on age; past ~30 days the headline says so instead. Provenance and age both ride into the JSON the way Hydrate’s basis already did, so a consumer cannot strip either.”)
@yah:handoff(“ANALYZE STAYED PURE, which was the ticket’s hard gotcha. topology::analyze gained no I/O, no Path argument, and no clock — the clock is captured as DATA at replay time, so the age is a value in the model rather than a call inside it. CloudConfig::load replays the journal into a new recovery_measurements field and analyze reads that, exactly as it already reads every other thing the loader pulled off the local tree. A missing journal file is not an error: it replays to empty and every existing verdict is unchanged.”)
@yah:verify(“LEADER RE-RAN EVERY GATE INDEPENDENTLY (@Ashguard:eclipse), rather than accepting the courier’s counts. cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib: 1187 passed / 0 failed / 4 ignored, exit 0 — matching the courier’s post-change figure against its own pre-edit baseline of 1173/0, so +14. NOTE the invocation: yah-cloud is not a root workspace member and needs dev-deps, so a repo-root cargo test -p yah-cloud does NOT work. cargo build -p yah --lib from the repo root: Finished, exit 0 (25 pre-existing warnings, none new-file). scripts/check-schema-drift.sh: ‘ok: .yah/schema is in sync with the Rust types’. scripts/check-workload-spec-ts.sh: ‘ok: packages/yah/workload-spec/index.ts is in sync with the Rust schema’. Both drift gates green, so nothing is left red for the next reader.”)
@yah:verify(“THE CROSS-CRATE SEAM WAS CHECKED AGAINST THE OTHER LIVE TICKET, not assumed. R850-T2 added a 17-line full-line assert_eq! in oss/turso-backup/src/bin/hydrate.rs pinning that binary’s emitted format string to the cloud-side parser fixture, so the producer and consumer of the JSON cannot drift apart silently. @Ashguard:blade was concurrently editing the same crate for R850-T3; the leader’s combined re-run of cargo test --manifest-path oss/turso-backup/Cargo.toml is 192 passed / 0 failed including that pin (bin/hydrate 5/5), so the two tickets’ edits coexist.”)
@yah:gotcha(“THE AUTOMATIC WRITER IS NOT IN THIS TICKET AND IS NOW FILED AS R850-T4. What shipped is the reader end plus a manual seam: yah cloud topology --record-hydrate <path|-> --workload <w> --node <n> ingests turso-backup-hydrate’s JSON line from a file or stdin. Carrying the measurement back automatically means widening the kamaji wire (kamaji-proto/src/messages.rs, kamaji-bin/src/server.rs:2086, which already holds the line and only info!s it), and both files were uncommitted-dirty on the shared tree during this run — a scheduling reason, not a design objection. Until R850-T4 lands, a camp nobody feeds reports the extrapolation, correctly labelled.”)
@yah:ticket(R850-T3, “GC the frame objects a tier-2 rebase orphans after a WAL restart (oss/turso-backup/src/tail.rs)”)
@yah:status(review)
@yah:at(2026-09-11T06:27:23Z)
@yah:assignee(agent:bundle-anthropic-ashguard)
@yah:phase(P4c)
@yah:parent(R850)
@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.”)
@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.”)
@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.”)
@yah:handoff(“COST-FIRST GATE RUN BEFORE BUILDING, as this ticket demanded, and it changed the ticket. (1) The premise holds and understates the rate: rebase’s only call site is tail.rs:524 on StreamOutcome::Restarted, whose restarted flag (stream.rs:1288-1294, via is_provably_same_as stream.rs:319-324) is set because an ordinary in-process autocheckpoint increments BOTH salt1 and checkpoint_seq (measured: stream.rs:249-250, examples/foreign_checkpoint_probe.rs:35-37). So rebase runs on ordinary checkpoint folds, not only on process restart — bounded above by the tail round rate, default 30s (src/bin/tail.rs:67), with an Empty round not rebasing (stream.rs:1296-1298). (2) THE FREE OPTION IS NOT AVAILABLE: there is no bucket lifecycle rule anywhere in this tree, and a creation-time rule cannot express this job — at any moment the LIVE base snapshot is simply the most recent one, so a blanket ‘expire older than N days’ deletes the live base of any subject that has not folded in N days. (3) So: build the GC.”)
@yah:handoff(“THE FRAMES ARE THE SMALL TERM — the ticket named the wrong leak as the main one, and the bigger one is covered here rather than filed. rebase (tail.rs:615) calls snapshot::upload_base_snapshot (tail.rs:635 -> snapshot.rs:216-231), the explicitly non-deduplicating one-shot variant, then deletes only manifests (tail.rs:636) and the watermark (tail.rs:637-643). Every rebase therefore stranded a COMPLETE COPY OF THE DATABASE, permanently, which exceeds the frame term (<= ~4.12 MB in <= 4 objects per rebase, at spill_buffer_frames=256 / 24+4096 B per frame) for any database over ~4 MB. gc_stream collects both classes. The doc comment at tail.rs:602-616 that asserted only the frames were left behind was WRONG about the leak it documented and is corrected in place (‘Two things, not one’); lib.rs:16-19 now names both tiers’ sweeps.”)
@yah:handoff(“WHAT SHIPPED: stream::gc_stream (oss/turso-backup/src/stream.rs:3050) with StreamGcConfig (:2914), StreamGcOutcome (:2937), DEFAULT_STREAM_GC_GRACE = 24h (:2907), and two private helpers delete_collected (:3138, absent-is-success, the same posture rebase’s own deletes hold) and list_recursive (:3156). Plus a turso-backup-gc bin (oss/turso-backup/src/bin/gc.rs, wired in Cargo.toml) emitting one JSON line, mirroring turso-backup-snapshot’s shape. DRY RUN UNLESS GC_APPLY is an explicit 1/true/yes — GC_APPLY=0, a typo and an empty string all leave the dry run in place, because the failure mode of guessing wrong points at deleted objects. GC_GRACE_SECS overrides the grace. Shape follows the existing dedup::gc_dedup (dedup.rs:472-573) tier-1b sweep rather than minting a parallel vocabulary. rebase still deletes nothing — the ticket’s standing judgment that a recovery path must not delete is intact; the sweep is explicitly invoked.”)
@yah:handoff(“LIVENESS WAS DERIVED BY READING THE RESTORE PATH, NOT ASSUMED, and the two are pinned together. Restore makes two selections and the GC’s live set is their union: restore_stream_from_manifests (stream.rs:2094-2101) takes the chain’s base_snapshot_key, and with no generations hydrate::restore_subject (hydrate.rs:522-528) falls back to snapshot::restore_latest, which takes the lexically-greatest key (snapshot.rs:432-444). Frames are live iff a manifest names them, resolved through BackupTarget::frame_objects_of — the same function restore’s replay and WalPuller use, so both epoch key shapes and both batch/legacy layouts come along by construction. gc_liveness_is_the_complement_of_restore_selection asserts both branches against the real selection functions so they cannot drift. DELIBERATE: every manifest’s base key is treated as live, not validate_generation_chain’s single answer — a chain spanning a WAL restart does not validate at all, and refusing to guess keeps both bases rather than deleting the one the next rebase is about to adopt.”)
@yah:handoff(“TWO JUDGMENT CALLS, both recorded at the code site. (1) GRACE WINDOW, default 24h (stream.rs:2907), documenting the three live windows it must cover: a serialized cold restore in flight, a tail between uploading batches and writing their manifest, and rebase’s publish-before-delete gap where the NEW base is reachable from nothing — that third case is where grace is the only thing preventing a concurrent GC from deleting a base a recovery just published. (2) DISCOVERED GAP, CLOSED: base_snapshots_skipped (:2937). A snapshots/ prefix with no chain over it is indistinguishable from a plain tier-1a sink, whose older snapshots are HISTORY, not garbage — unguarded, the sweep would have pruned all but the newest. It now skips the snapshot half entirely when there are no generation manifests and reports that it did. Frames still go: a frames/ prefix under a chainless sink is unreachable by construction.”)
@yah:verify(“BASELINE MEASURED BEFORE THE FIRST EDIT, then re-measured, and INDEPENDENTLY RE-RUN BY THE LEADER (@Ashguard:eclipse) rather than taken on the courier’s word. cargo test --manifest-path oss/turso-backup/Cargo.toml (turso-backup is its own workspace, excluded from the yah root workspace — a root-level -p invocation does not work): baseline 180 passed / 0 failed; after 192 passed / 0 failed. Leader’s independent re-run of the combined tree, after R850-T2’s own edit to oss/turso-backup/src/bin/hydrate.rs landed: 167 + 4 + 5 + 4 + 5 + 7 = 192 passed / 0 failed, exit 0. cargo clippy --all-targets -- --deny=warnings clean; cargo doc --no-deps warning locations byte-identical to before the edits.”)
@yah:verify(“TWELVE NEW TESTS — all four the ticket named, plus four more that came out of the liveness derivation. gc_collects_a_superseded_base_snapshot_and_keeps_the_current_one · gc_collects_orphaned_frame_prefixes_and_keeps_the_live_generation (both epoch layouts) · gc_spares_everything_inside_the_grace_window · gc_dry_run_reports_without_deleting (and asserts the wet sweep executes the dry proposal key-for-key) · gc_liveness_is_the_complement_of_restore_selection · gc_keeps_both_bases_while_a_rebase_is_half_landed · gc_without_a_chain_keeps_snapshots_but_still_collects_frames · gc_on_an_empty_prefix_collects_nothing, plus 4 in bin/gc.rs. NO LIVE MinIO NEEDED: these use object_store::memory::InMemory, the same route the existing dedup GC tests take — no new infrastructure was stood up.”)
@yah:gotcha(“THIS TICKET’S ANNOTATION WAS WRITTEN BY THE LEADER, NOT THE IMPLEMENTER, on purpose. R850-T3’s annotation lives in oss/yubaba/crates/cloud/src/topology.rs (turso-backup is outside the board scanner’s scan set), and @Ashguard:polaris held that file dirty with ~9 uncommitted writes for R850-T2 for the whole of this ticket’s run. @Ashguard:blade was steered off board.update mid-turn and reported its account back to the leader instead, which then wrote it here once polaris was out. Content is blade’s; the keystrokes are the leader’s.”)
Structs§
- Machine
Node - A declared machine, plus what the admission seam projects onto it.
- Node
Loss - Everything that follows from losing one machine outright.
- Oversubscription
- A machine whose declared capacity does not cover what is projected onto it.
- Topology
- The declared fleet, read as a graph, plus every verdict derivable from it.
- Workload
Impact - What a node loss does to one workload projected onto it.
- Workload
Node - One workload, everything about it that bears on survival, and where it goes.
Enums§
- Data
Loss - How much of the workload’s state is gone, and how far back the copy is.
- Durability
View - A workload’s
yah.durability.*declaration as the analyzer sees it. - Outcome
- Whether the workload comes back, and who brings it back.
- Placement
- Where the admission seam would put a workload, and what else could take it.
- Quorum
Effect - What losing a machine does to its sovereign group’s raft quorum.
- Recovery
Estimate - How long it takes to get the state back, and on what basis that is claimed.
Constants§
- DEFAULT_
STREAM_ RPO_ SECONDS turso_backup::stream::DEFAULT_RPO_TARGET, in seconds.- MEASURED_
GET_ RTT_ MS - Per-GET round-trip time, milliseconds. Measured in the same R760-T10 run.
- MEASURED_
HYDRATE_ MB_ PER_ S - Bulk object-store throughput, MB/s.
Functions§
- analyze
- Walk the declared graph once and answer every question derivable from it.