Skip to content

chore(release): promote rc-2026.9.2 - #229

Open
jacderida wants to merge 9 commits into
mainfrom
rc-2026.9.2
Open

jacderida wants to merge 9 commits into
mainfrom
rc-2026.9.2

Conversation

@jacderida

Copy link
Copy Markdown
Member

Promotes rc-2026.9.2 to release version(s): 0.19.0.

  • strips -rc.* from [package].version
  • rewrites internal git+branch deps to crates.io version pins
  • regenerates Cargo.lock

Once merged, the release tag will be pushed to fire the publish workflow.

jacderida and others added 9 commits September 8, 2026 22:49
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants