Conversation
…e, and report what the neighbours say A patch on top of the release that replaced the LMDB chunk store, which has already merged and is running on nodes part-way through their migration. It changes no on-disk format and writes no migration state: a node mid-flight re-reads its marker, its first-start time, its shed count and its remaining keys and carries on. A test asserts that rather than the commit claiming it. Two things, and only two. **A node can no longer be made to deny a promise it already made.** Narrowing a promise is fine and is untouched: a node that sheds rotates to a commitment over fewer keys, and `rotate` keeps the old roots as retained slots, so a peer that pinned one can still challenge it and get an answer until it ages out by its gossip TTL. That is the staged narrowing this migration was designed around. The rotation also had a branch that fired when a node had NO responsible keys at all, not a smaller set. It dropped every retained root outright when the node "looked empty", decided from a key count, and that count was wrong in the same direction every time it was fixed: a node whose disk filled before it could copy anything, then one whose file had been dropped from the index by a failed read while its legacy copy was being restored, then a files-only node that had published bytes to disk but not yet indexed them, because a file is published before it is indexed. Each fix closed one window and left another. So the branch is gone, and with it the question. The asymmetry settles it: clearing repudiates a root a peer is still pinning, which answers UnknownCommitment and is graded a confirmed failure on the commitment-bound lane, enforced in every release and not the lane the migration holds off; retiring stops advertising and stays answerable until the gossip TTL lapses. Both set has_current = false. A genuinely empty node cannot answer either way, so retiring costs it nothing it had. **Every node says whether it still has an old chunk store, and reads what its neighbours say.** The state rides the user agent saorsa-core already sends with every signed message and keeps for each peer, so it costs no new message, no new field and no protocol version, and the `node/` prefix that gates DHT membership is preserved. Three states, never folded into two: a directory that could not be read is not one that is not there. It counts what the peers a node is connected to announced, each as of that peer own last start: saorsa-core copies the user agent when it builds the transport, so a node that finishes migrating keeps announcing legacy until it restarts. Two consequences, running in opposite directions, so the tally bounds nothing. A peer announcing legacy may have finished since, so the count can be too high. A node that is offline, or simply not connected to, is absent from it, so the count can be too low. An all-zero tally proves nothing on its own either, because a node connected to nobody produces one; the number of peers seen is what tells that apart. So this can surface nodes that have not finished. It cannot establish that none remain, and no amount of it adds up to that. A peer still on the previous release announces no token and counts as unreported, never as finished. The reporter holds a weak handle to the node. A reporter must not be the reason the thing it observes stays alive: a strong one would keep a dropped node's transport, and its bound port, for as long as the task ran. The signal module is crate-private; neither goal needs new public API. The terraform worker unit passes --enable-logging, without which a node on that path emits nothing at all and none of this is readable.
…low-up fix(storage): stop the migration denying a promise a node already made, and report what the neighbours say
Once a node reaches `Committed`, the copier moves only the keys the rank check refuses to shed. A chunk that turns up in the legacy environment after that, and that this node never agreed to give up, is on no such list: nothing copies it, the shed gate correctly refuses to prove a single copy exists elsewhere, and the startup reconciliation only returns a node to `Bridging` when the file store holds less than it recorded keeping, which the file store never does. The node is terminal in that state. Free disk does not change it and neither does a restart. Seen on one node of 38 in the 2026-09-08 beta cohort: it committed with "nothing has to be shed", then pre-retirement verification found 317 chunks in the legacy environment that were in neither view and re-queued them. It has refused retirement every 30 seconds since. Once #218 removes the LMDB reader those 317 stop being servable, and on the evidence they are the network's only copy. The fix is a third arm in the reconciliation block, next to the two of the same shape: when the marker says `Committed` and there are more legacy-only keys than the node ever approved for shedding, go back to `Bridging`, clear `committed_at_unix` and reset `rebuilds_since_commit`. `open_legacy` already computes the legacy-only set before the marker is read, so this is a comparison against a number that is already in memory. No new I/O, no marker format change. The count, not emptiness, is the test. A node that legitimately shed keeps exactly the keys it is giving up in the legacy environment until they stop being answerable, so a non-empty legacy-only set is its normal state; bouncing it to `Bridging` on every restart would reset its retention clock each time and a regularly restarted node would never retire. The arm fires only on `legacy_only > shed_key_count`. It logs `migration_event = "back_to_bridging"` with both numbers so the case is visible in the beta watch. The upgrade's own restart is enough to take an affected node out of the loop. Tests: `cargo test --lib` 1104 passed, 0 failed; fmt clean; clippy with `-D clippy::panic -D clippy::unwrap_used -D clippy::expect_used` clean on lib and tests. Two tests added, and both were mutation-checked: with the discriminator replaced by `legacy_only > 0`, the new regression test and the existing `the_migration_marker_survives_a_restart` both fail; with the arm disabled, the fire-case test fails. Closes V2-1232 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrjwPBwPqMi3F2sT1yVYEa
fix: reopen the bridge for chunks a committed node never agreed to shed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes
rc-2026.9.2to release version(s): 0.19.0.-rc.*from[package].versionCargo.lockOnce merged, the release tag will be pushed to fire the publish workflow.