Skip to content

Detect configs expired from the swarm and re-store them - #1973

Open
mpretty-cyro wants to merge 14 commits into
session-foundation:devfrom
mpretty-cyro:feature/config-recovery
Open

Detect configs expired from the swarm and re-store them#1973
mpretty-cyro wants to merge 14 commits into
session-foundation:devfrom
mpretty-cyro:feature/config-recovery

Conversation

@mpretty-cyro

@mpretty-cyro mpretty-cyro commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Config recovery: put back config messages that expired from the swarm

A config message has a 30-day TTL. A client that is offline long enough — or whose TTL refresh
never landed — comes back to a swarm that has dropped it. Today nothing notices and nothing
repairs it: the device still holds the config locally, so it looks healthy, while a new device
restoring from seed gets nothing.

This detects that and puts the message back. It rests on config encryption being deterministic:
re-storing an unchanged config reproduces its original message hash, so this is the same message
returning to where it was, not new state competing with old.

What changes

Extend-only TTL refresh. The refresh piggybacked on every poll now sets extend rather than
leaving the flag unset. That is a fix in its own right — the previous code built a request whose
signature and wire flag could disagree — and it is also what makes the server return the
unchanged array everything below reads.

Detection. The expire sub-response names which hashes the swarm still holds. A hash absent
from both its arrays is missing. One responsive snode reporting absence is enough: re-storing is
idempotent, so a false positive costs one redundant request, while waiting for consensus leans on
the replication that is itself the unreliable part.

Recovery, for our own configs and for groups. Clean configs only, active hashes only, and only
once we know our local state is level with the swarm — otherwise we would re-upload a view we
already know is behind. Group members are covered, not just admins.

GroupKeys recovery from retained bytes. libSession now keeps the raw bytes of each active
keys message, so an expired one can be pushed back verbatim. That is what makes it work for a
member: a keys message is admin-signed and padded from the group secret key, so nobody can
regenerate one — but bytes already held land on the same hash. Every retained message goes back,
not just the missing one, because a member holding part of a generation does not get the key.

A back-fill for the bytes we never captured. Retention happens when libSession loads a keys
message. A device that merged a group's keys before retention existed holds the key but not the
bytes, and cannot repair that group later. The back-fill re-fetches and re-merges so the bytes are
captured. It runs proactively rather than on detection — by the time detection fires, the message
it needed to fetch is gone.

A force rekey, as a last resort. When every keys message is gone from the swarm and nothing
here can restore it, an admin mints a new generation. This is the only irreversible, universally
visible write in the change, and most of its code is about not doing it when it was not needed.

Two changes outside the feature's own files

Both are flagged because a reviewer should decide on them separately.

StoreGroupConfigSubRequest now accepts either credential. It threw on an empty secretKey
while its own build() said it would sign with the subaccount key — so it forbade a capability it
claimed to have, and member recovery could not have worked. The signer already supported it. An
admin push that omitted its secret key previously threw and will now attempt subaccount signing.

The retrieve length check admits exactly one length. It accepted N or N+1 because the
expire sub-request is conditional. Sub-responses are paired to sub-requests by index, so a
response missing one retrieve result passed the lower bound and shifted every later namespace onto
its neighbour's messages — with the last one handed the expire result as its message list. This
is on the polling hot path: a short response now fails the poll and retries in seconds, rather than
being silently misattributed, which never self-corrects. Read from the request builder, not
observed on the wire.

What this cannot do

  • A group whose keys message has already expired and whose bytes nobody retained is beyond the
    back-fill. That is the population the force rekey exists for.
  • GroupKeys cannot be re-serialised, only replayed. A device holding no bytes for a keys hash
    cannot repair it, and reports the group as unavailable to this device rather than as broken.
  • An admin that has just rekeyed holds no bytes for its own message until it loads it back from
    the swarm — retention is on load, not on create. Nothing here treats admins as privileged for
    keys recovery.

What cannot go wrong here

No config can be lost by this change. A re-store is byte-identical, lands on the same message
hash and does not bump the seqno, so it cannot overwrite or compete with newer state — that is the
property the whole feature rests on. The only deletion is of hashes push() itself hands back as
superseded, and only for configs whose replacement is confirmed stored.

A reviewer's instinct here is to worry about config loss. The thing actually worth scrutinising is
the force rekey, whose worst case is below.

Known gaps

No poller-level test drives the back-fill or the force rekey through a real poll. Both are
unit-tested through their own entry points; the caller's half — that the poller computes the
freshness signal correctly and calls these at the right moment — is asserted nowhere. iOS and
Android have the same gap; it is a shared limitation rather than a Desktop shortfall.

The force rekey's worst case is a member quietly losing access to a group. The rekey encrypts
the new key to this device's view of the members. Issued from a stale view, it silently excludes
anyone added since that view was last updated — no error, no failed send, and the excluded member
simply stops being able to read the group. And it fires precisely on devices whose config state is
known to be degraded, so "stale view" is the expected condition rather than the unlucky one.

What guards it: the rekey asks the recovery store whether this swarm was marked level during the
poll running now
, not merely at some point since the process started. The store stamps each mark
with the poll it came from, so a mark that predates the current poll is rejected. A caller cannot
pass a value it prefers.

The residual, honestly: the marking site is still trusted. If the poller ever marks a swarm level
when it should not, this believes it. That reduces the failure class — a stale mark can no longer
authorise a rekey — rather than removing it.

The force rekey's storm guard is in memory, so a restart clears it. It bounds an admin to one
rekey per group per 24 hours per running process, not absolutely. In practice the chain is
self-limiting — the precondition requires a back-fill to have run and failed, that record is also
cleared by a restart, and after a successful rekey the back-fill finds the new keys message on the
swarm and succeeds, which blocks the rekey. The gap is the window where the just-pushed keys
message is not yet retrievable: there, a restart could produce a second rekey.

Nothing here has run against a real swarm. Everything is mocked at the network boundary. In
particular, that extend: true makes the storage server return unchanged is read from
request_handler.cpp and never observed, and the server's hash derivation is read-verified only.
An end-to-end pass is worth having before shipping.

Dependencies

⚠️ This does not build against a released wrapper yet. activeKeyMessages() needs a
libsession-util change that is open but not yet merged — libsession-util#123 — and a
libsession_util_nodejs release that does not exist. package.json still pins v0.7.4, which does not carry it.

The chain is: libsession-util#123 merged → wrapper released → pin bumped. Until then the numbers below
are measured against a locally built wrapper.

Testing

1052 passing, 0 failing, tsc 0 errors.

Measured against:

component commit
Desktop 776a14a69
libsession_util_nodejs config-recovery-keys-messages @ 4ed2d44 (local build)
libsession-util 97aafbbd

Both wrapper trees clean apart from the submodule pointer.

Every behavioural rule is mutation-verified: change a guard and a named test fails.

The force rekey can be dropped by deleting one file. Verified by doing it — removing
configRecoveryForceRekey.ts, its test and its single call site returns the suite to exactly its
pre-rekey number with the back-fill series still green. Nothing in the back-fill depends on it.

@mpretty-cyro mpretty-cyro self-assigned this Aug 5, 2026
@mpretty-cyro
mpretty-cyro requested a review from Bilb August 5, 2026 05:54
Comment thread ts/session/apis/snode_api/configExpiryDetection.ts Outdated
Comment thread ts/session/apis/snode_api/retrieveRequest.ts
Comment thread ts/session/apis/snode_api/configRecovery.ts Outdated
Comment on lines +34 to +37
* Note: group configs are detected but NOT recovered here. The NodeJS wrapper's
* `MetaGroupWrapper::push()` returns null for any sub-config whose `needs_push()` is false, and
* §4.2 only lets us re-store a config that is clean — i.e. exactly when it returns null. There is
* no way to reach the bytes until the wrapper exposes an unconditional serialise.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this PR goal is not to help with recovering for groups specifically?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh... of course Claude would explicitly tell me the implementations are consistent and they wouldn't be and I just missed that there were no group recovery functions in the file as both iOS and Android have a single "restore" function that handles both so I just assumed that restoreUserConfigs was correctly doing both 😞

Comment thread ts/session/apis/snode_api/swarmPolling.ts Outdated
@mpretty-cyro
mpretty-cyro marked this pull request as draft August 16, 2026 23:26
…tore them

Config messages have a 30-day TTL that is refreshed by the expire bump already
piggybacked on every poll. A device offline past the TTL loses its config from
the swarm and nothing noticed. This reads the bump's own answer to detect that
case and re-stores from local state. Reasoning for each rule is in the code.

- Detect missing config hashes from the `unchanged` array the server returns for
  an extend-only expire; send shortenOrExtend: 'extend' rather than '' so the
  array is returned at all. An absent key means detection is unavailable, not
  that every config is gone.
- Re-store on a later poll, only once local state is level with the swarm, so a
  stale local config can never overwrite a newer remote one.
- Chunk the recovery batch at 20 sub-requests inclusive of the obsolete-hash
  delete and stop at the first failing batch. The server rejects the whole
  sequence above 20, and a single config can split into ~66 parts.
- Bar a settled hash for an hour rather than for the session; a desktop session
  can run for days and outlive the TTL.
- Issue the obsolete-hash delete only for restores that fully landed.
- Retrieve now requires exactly one response length rather than accepting N or
  N+1. The old tolerance let a response missing one retrieve result pass, after
  which every namespace took its neighbour's messages and the last one was handed
  the expire result. iOS and Android both reject a short response already.
Group configs were detected but never recovered — recoverIfNeeded returned at
`!isUsFromCache` before even the level-with-swarm check. The guards and
bookkeeping are now shared and only the inspect-and-restore step branches.

Three things about the group path that read like bugs and are not:

- GroupKeys is detect-only. A keys message cannot be re-emitted; only an admin
  rekey replaces one. A missing keys hash is reported and settled, never retried.
- a member gets an EMPTY obsolete-hash list. push() hands the superseded hashes
  back only if (!is_readonly()) while clearing them either way, so empty is the
  expected result rather than a failure.
- a member could not act on a non-empty list anyway: its subaccount token carries
  Read+Write but not Delete. Member-driven recovery re-stores and never prunes.

Kicked and destroyed are checked separately on purpose: libsession sets
kicked=false when a group is destroyed, so checking one reads as complete and
silently misses the other population.

StoreGroupConfigSubRequest now takes optional authData and requires EITHER
credential rather than specifically the admin key. Member recovery could not have
worked without this: the constructor threw on an empty secretKey while its own
build() comment described subaccount signing, so it forbade a capability it
claimed to have. The signer already supported it. This is a shared request type
on the normal push path — an admin push that omitted its secretKey previously
threw and will now attempt subaccount signing.

Review items:

- recoverIfNeeded is no longer awaited by the poller; a recovery round is up to
  20 sub-requests per batch and was holding the shared poll loop. It cannot
  produce an unhandled rejection because it catches its own errors. Voiding it
  makes overlapping rounds possible, so rounds are now guarded per pubkey and
  cleared in a finally — the guard holds the round's promise rather than a
  marker, which is also what lets a test await a round the poller does not.
- per-swarm state is typed AccountPubkey (PubkeyType | GroupPubkeyType) rather
  than string; it is never a snode key. Narrowed at the detection boundary with a
  real is03/is05 check rather than a cast, since something that is neither names
  no swarm we could recover.
- removed all references to the design document and its section numbers. It
  exists in no repo, so every one was a dangling pointer; the section numbers
  were worse, reading like a reference to something in this tree. One comment was
  a bare section number with no prose and has had its substance written in.
- the retrieve length check now admits exactly one length instead of two.
  Sub-responses are paired to sub-requests BY INDEX, and the old check accepted
  either N or N+1, so a response missing one retrieve result passed and shifted
  every later namespace onto its neighbour's messages — with the last one handed
  the expire result as its message list. Behaviour change on the polling path: a
  short response now fails the poll (retries in seconds) instead of being
  silently misattributed (never self-corrects). Read from the request builder,
  not observed on the wire.

Tests: 14 new, covering V16/V16a/V16b/V19/V20/V21, kicked, destroyed, clean-only,
level-with-swarm, and the in-flight guard including its release on failure. All
mutation-verified. One earlier test asserting "group swarms are not re-stored on
Desktop" was removed: it had stopped being true, and it had only kept passing
because that file never stubbed the group wrappers, so the lookup threw and the
early return did the work.
…node check

The failed sub-responses in these two vectors carried no `unchanged` array, so
the eligibility check excluded them as unreadable and never consulted `failed`.
The `failed` term could have been deleted at any point with the whole suite
green — verified by doing exactly that: nothing died until these fixtures gained
the array, and then precisely V5 and V6 died.

Carrying the array also makes them the shape the term exists for. A node that
reports failure while still returning arrays is the dangerous input: read as
usable, its empty arrays become authority and every requested hash is reported
missing — a false positive that re-stores configs the swarm still holds, on the
word of a node that told us it failed, and then prunes the older copy.

Found via the shared vector table, whose V5 row omitted the array and propagated
the blind spot to whoever implemented it faithfully.
…ne twice

V8c is the mixed case: unreadable sub-responses alongside a readable one. It had
two unreadable nodes that were both excluded by the SAME guard — the failed node
carried no `unchanged` array, so the readability check excluded it and its
`failed` flag was decorative.

Each unreadable node now trips only its own guard: the failed one carries full
arrays, the other omits `unchanged`. Verified by mutation in both directions —
V8c now dies when either guard is deleted, where before it was insensitive to
`failed` entirely.

Same shape as the V5/V6 fix in 80f60ad62: a vector naming only the field that
must be wrong gets a fixture that trips some other guard first.
libSession now retains each active keys message verbatim, so a keys message that
expired from the swarm can be pushed back unchanged. That is what makes this work
for a MEMBER: a keys message is admin-signed with padding derived from the group
secret key, so nobody can regenerate one, but bytes already held land on the same
hash. We never reconstruct and never re-sign.

Three consequences that are not obvious from the rule:

- ALL retained keys messages go back, not only the ones reported missing. A
  generation is the full rekey plus every supplemental issued against it, and a
  member receiving only part of one does not get the key. The accessor is
  hash-keyed and carries no generation, so grouping by generation is not
  expressible; re-storing everything is a superset of the affected generation and
  the extras are byte-identical no-op TTL refreshes.
- no delete on this path. A keys message supersedes nothing, so there is never an
  obsolete-hash list to prune.
- pushForRecovery() is now only called when groupInfo or groupMember actually
  needs restoring. It drains obsolete hashes as a side effect, and a keys-only
  recovery has no reason to pay that.

The expired flag is handled at both ends, because one alone is insufficient:

- the poller DEFERS it. Flagging a group expired while we hold the bytes to
  repair it would tell the user it is gone at the moment we can fix it, and
  raising-then-clearing is a visible flicker.
- a landed keys re-store CLEARS an existing flag eagerly rather than relying on
  the reactive clear. That path fires when config messages are received, but we
  just re-stored messages we already hold and may never re-handle them, so the
  flag could sit set forever over keys that are back on the swarm.

Vectors V23, V23a, V23b, V23c, V23d, plus a member variant and a failure
counterpart for the flag clear. All four rules mutation-verified.

Also corrects comments this change falsified — "a keys message cannot be
re-emitted" was true when written and is now the opposite of the behaviour.

Requires libsession_util_nodejs to expose activeKeyMessages(), which needs the
unmerged libsession-util change. Built and tested here against a local wrapper
build; package.json pins a release that does not carry it yet, and must be bumped once one does.
…ogress

Cold review found a re-push storm. The backoff reset was gated on
`anyPartLanded` — any store sub-request returning 200 — while barring is gated on
a config landing in FULL. Every part of a multipart config goes back on every
attempt, so a config whose parts half-land sends the identical request next
round. One part that always succeeds beside one that always fails therefore reset
the counter forever: nothing barred, backoffMsFor(0) is 0, full re-send on every
poll, indefinitely. Measured before the fix at 10 polls -> 10 sends, 0 barred.

The doc comment asserted the opposite of what the file did ("the parts that
stored are barred and the next round is strictly smaller"), and the test asserted
only that a retry happened — which is equally true of the storm. That is why it
survived: with the all-parts rule, "it retried" cannot distinguish progress from
a loop.

The predicate is now "did anything become BARRED", not "did anything return 200".
Only the first makes the next round smaller. Note it is still not simply `stored`
— with several configs in flight, one landing in full genuinely shrinks the next
round, and backing off there would penalise a converging swarm. The new tests
bracket both sides: the storm test dies if the predicate widens back to any-200,
the converging test dies if it narrows to total-success.

Also from the same review:

- keys recovery is no longer gated on `needsPush` (ruling v139). That gate exists
  so local state cannot overwrite newer remote state; keys recovery replays the
  exact bytes the swarm already had, so it cannot overwrite anything, and a
  pending rekey produces a new message at a new generation. Gating keys on a
  dirty groupInfo excluded groups in active use — the population most likely to
  need them. Info and members stay gated, and a test pins that.

- the expired flag is now set from detection. Nothing set it before: the only
  path was the poller's empty-fetch branch, which requires holding NO config
  hashes and so cannot fire for a device whose hashes are exactly what reported
  them missing. V16, V16a and V23c had no implementation. Raised when every keys
  hash is gone and no bytes are held, and after a keys re-store we attempted and
  failed; still deferred while a repair is in hand, still cleared eagerly on
  success. The comment claiming this waited on wrapper attribution was stale —
  that blocker went with activeHashesByConfig.

- `missingHashesByPubkey` no longer grows without bound. Same leak
  pruneExpiredBars was written for, one map over, against the same long-lived
  sessions. The two pruners are order-dependent: pruneSettledDetections reads
  hashSettledAt and pruneExpiredBars empties it, so run the other way round the
  leak survives with both present and looking correct. Commented at the call site.

- a rationale citing MAX_RECOVERY_ROUNDS_PER_SWARM, which was replaced by the
  backoff and no longer exists. Behaviour was right, stated reason was not.

- mergeMultipleRetrieveResults set each namespace's code from `find(...)?.code ||
  200`: the first entry across all polled snodes, so the verdict depended on
  result ordering and was arbitrary in both directions, and `|| 200` defaulted a
  missing code to success. Its only consumer decides whether we are level with
  the swarm, where "unanswered read as answered" is the failure that matters. Now
  a namespace answered if any snode returned 200, with no success default.

1002 passing / 0 failing. Every fix mutation-verified.
…e for

The comment above these declarations recorded that the scoping is non-uniform —
Sets session-scoped, hashSettledAt time-bounded — and stopped there. Recording a
difference without saying what it constrains reads as trivia, so a reader takes
it as background rather than as a rule, and reaches for the predicate for a
purpose it does not suit.

That already happened: localStateIsLevelWithSwarm was proposed as the guard on a
force-rekey. It cannot be one. Session-scoped here means PROCESS-LIFETIME, which
on Desktop is weeks — nothing ages these Sets out, so the predicate answers "was
level at some point since startup", never "is level now", and the staleness it
permits is unbounded.

That is correct for its current consumer: recovery is a cheap idempotent
re-store, so acting on a stale verdict costs a redundant request. It is wrong for
anything irreversible. A rekey encrypts to THIS DEVICE'S view of the members
(libsession keys.cpp:376-386 builds member_xpks from the passed Members config),
so a stale "level" verdict authorises a write from a members list we already know
may be behind, silently dropping anyone added since. It fails OPEN.

The general rule, at the declarations rather than any call site because that is
where the next reader arrives: check a value's lifetime against what YOU are
about to do with it, not against what its existing caller does.

Note hashSettledAt was made time-bounded for exactly this reason, and the same
reasoning was never applied one declaration up. The lesson and its counterexample
sat eleven lines apart in this file, both written by me — a rule filed under the
mechanism that produced it does not generalise; filed under the property it
protects, it does.

Doc-only. No behaviour change.
… fetch

libSession retains the raw bytes of a keys message when it loads one, and only
then. A device that merged a group's keys before retention existed holds the key
but not the bytes, so it cannot put that message back if it ever expires — and it
has no way to notice.

This re-fetches the keys namespace and re-merges it, which captures the bytes.
Re-loading a keys message we already hold the key for is a no-op for key state
(insert_key early-returns) but not for retention: that path still stores the
bytes and flags a dump.

It runs PROACTIVELY, beside recovery rather than inside it. Recovery acts on a
hash the swarm has LOST; this acts on one the swarm still HAS. Hanging it off the
detection path would look correct and be nearly useless, because by the time
detection fires the message it needed to fetch is gone.

Three things that are easy to get wrong and are pinned by tests:

- IT MUST PERSIST THE DUMP. Retention lives in the config dump, so a merge that
  captures bytes without persisting loses them on restart — the back-fill appears
  to work and silently does not, and every in-process assertion passes either
  way. Worse here than elsewhere: saveDumpsToDb is stubbed in the suite's
  beforeEach for an unrelated reason, so an implementation that never persists
  would pass the whole file. The test therefore asserts the merge ran as a
  separate PREMISE before asserting the persist, or "it was called" is satisfied
  by a path that exited early.

- THE ATTEMPT RECORD MEANS "ATTEMPTED AND THE BYTES ARE STILL ABSENT", not "the
  fetch came back empty". A fetch returning messages that still do not restore
  the bytes is equally a failed attempt; recording only the empty case leaves the
  group looking un-attempted forever while re-fetching the same useless messages.
  The two are indistinguishable in any fixture where the swarm holds nothing —
  which is the first fixture anyone writes — so there is a test with a swarm that
  returns something and still leaves the bytes missing. Credit to the iOS client,
  which shipped this wrong and said so.

- IT CALLS THE RETRIEVE LAYER DIRECTLY, never the poll wrapper. pollNodeForKey
  writes the namespace cursor from whatever it fetched, and this asks with no
  last_hash, so routing through it would advance the cursor past messages the
  poll never consumed. Verified the retrieve is pure: the only writers of that
  cursor are that one call site and the Data helper it calls.

The record is in memory deliberately. Persisting it would be a sticky negative,
letting a later force-rekey fire on evidence gathered weeks ago after the swarm
had changed underneath it. Forgetting on restart delays by one poll cycle, which
is the safe direction. It is cleared on success, because it is read as "this
device cannot repair this group" and a device that just retained the bytes can.

A throw records nothing: we never learned whether the bytes are obtainable, and a
failure recorded there would let the rekey act on evidence we do not have.

1043 passing / 0 failing, tsc 0. Measured against libSession 97aafbbd via a local
wrapper build (config-recovery-keys-messages @ 4ed2d44); package.json still pins
v0.7.4, which does not carry activeKeyMessages yet.
…the keys

Last resort for a group whose keys messages are gone from the swarm and whose
bytes nothing here retained: an admin mints a new generation so the group becomes
usable again.

This is the only irreversible, universally visible write in config recovery.
Everything else replays bytes the swarm already had — byte-identical, same hash,
invisible to other clients. This creates new state every member on every version
must process, and it cannot be undone. Almost all of the code is about not doing
it when it was not needed.

Preconditions, each with a test that starts from a fixture which WOULD rekey and
changes exactly one thing:

- our members view must be level with the swarm AS OF THE CALLING POLL. The rekey
  encrypts to this device's view of the members, and it fires precisely on
  devices whose config state is known to be degraded, so a member added while we
  were away and not yet merged would be silently dropped. The caller computes the
  value and passes it; refusing on it here rather than trusting the caller is
  what makes the refusal testable at all.
- a back-fill must have RUN and still come up short. "We hold no bytes" cannot
  distinguish that from "no back-fill has ever run" — identical on a fresh
  install, a restored backup, or before the first poll completes, and only one of
  them justifies this.
- every keys hash must be beyond us. One surviving keys message still admits a
  new device, so the group is not stuck.
- admin only, and not for a kicked or destroyed group.
- once per group per session, with a cooldown. Without it every poll that still
  sees the old preconditions mints another generation.

Lives in its own file with one entry point and no dependency from the back-fill
to it, so it can be removed by deleting the file. Verified by doing exactly that
rather than by arguing it: with this file, its test and its call site deleted,
the suite returns to 1043 passing and all eight back-fill tests still pass.

The freshness value passed to it is the same expression the enclosing branch
computes, not a literal `true` written inside that branch — a literal would be
correct only until someone moved the call.

1050 passing / 0 failing, tsc 0, measured against libSession 97aafbbd via a local
wrapper build (config-recovery-keys-messages @ 4ed2d44).
… in no repo

Both cited the shared design doc — one a section number, one a version — in
comments that already carried their own reasoning, so removing the citation costs
nothing. A bare section number is the worse of the two: it reads like a reference
to something in this repository.

Found by widening the sweep from this round's files to every file the branch
touches. The earlier sweep was scoped to the four files I had just edited, which
is why these survived it: they predate that round.
…ent permits

Four comments referred to a design document that exists in no repository. Two
were bare citations that cost nothing to drop. Two were worse than citations,
because they outsourced the obligation itself and left the reader nothing local
to act on:

- allConfigNamespacesAnswered said we gate the whole swarm rather than the
  individual namespace because "the spec allows either, and the coarser one
  cannot be got subtly wrong". A reader cannot check that. It now gives the local
  reason: gating per namespace means mapping each hash back to the namespace it
  came from and keeping that mapping correct as either side changes, and getting
  it wrong means acting on a config we are ignorant about, silently. Gating the
  whole swarm is coarser — we skip a repair we could safely have made — but it
  fails by doing nothing, which the next poll fixes.

- the detection vectors' header said "don't relax one to make an implementation
  pass, change the spec", which is an instruction the reader cannot follow. It
  now says what to do: a vector failing here is a disagreement between clients,
  so the other two implementations are the thing to check, and relaxing it
  removes the only evidence they have diverged.

Found by widening the sweep pattern rather than the file set. The file set was
already every file this branch touches; the previous pattern searched for section
symbols and "spec v", and these use neither. A sweep has two independent
dimensions and a count over files says nothing about the regex — so the pattern
belongs in the report beside the count.

Retained deliberately: "until v0.6.20 exposed" names a real released artefact
anyone can look up, which is the opposite of the problem here.
The force rekey took its freshness precondition as a parameter: the poller
computed "our members view is level as of this poll" and passed it in. That made
the refusal testable, but nothing stopped a caller passing whatever it liked, and
the value the poller computed was correct only for as long as the call stayed
inside the branch that computed it.

The store now stamps each level mark with a token that changes when a poll begins
for that swarm, and the rekey asks the store rather than the caller.

One field serves both readers, deliberately not two:

  has(pubkey)               were we EVER level this session   — recovery
  stored === current token  are we level AS OF THIS POLL      — the force rekey

Recovery is a cheap idempotent re-store, so a stale verdict costs a redundant
request and the sticky reading suits it. A rekey encrypts to this device's view
of the members and cannot be undone, so it needs the poll-scoped one. Two fields
would drift; one value read two ways cannot.

The token is minted when a poll STARTS. Taken at the end it would name the poll
that just finished, so the rekey would compare a mark against its own poll and
always agree — the check would typecheck, read correctly and never refuse.

The poll-scoped reader fails closed: a swarm never polled, never marked, or
withdrawn answers false, because its only consumer is an irreversible write and
"we do not know" must not read as "yes". The sticky merge-incomplete withdrawal
still deletes the entry outright, which correctly makes both readings false.

Checked the writer list rather than assuming: both production writers are poll
completions inside pollOnceForKey. Neither stamps a token outside a poll.

V25e pins it, asserting on the rekey count rather than the return value so a
refusal cannot be confused with a throw: begun-but-unmarked refuses, marked-this-
poll proceeds, and a new poll makes the earlier mark stale. It also asserts the
STICKY reading is still true at that last step — otherwise the test would pass
if both readings had gone false together, which is the wrong reason.

Still in memory. A persisted stamp would be the sticky negative this design has
already ruled against once.

1051 passing / 0 failing, tsc 0.
… it blocks

The 24h cooldown had no test on either side. Its reachable job is throttling
retries after a rekey that threw — on success the once-per-session set refuses
first, so the cooldown never gets asked. A stuck cooldown would therefore strand
a group whose rekey failed, and for a full day would be indistinguishable from a
working one.

Three steps: repeated attempts inside the window stay at one, two hours later
still one, a further twenty-three and it tries again. The middle step is what
pins the interval — without it a silent regression to an hour passes.
…doing

Nine comments described a state the code is not in — an old predicate name, a
bound that was once looser, an assertion this test once made. A reader a year
from now never saw the change, so the prose spends their attention without
telling them what to preserve. Each now states the contract and what breaks if
it is broken, which is the part that stops the old thing coming back.

Also drops the 25 warning markers this branch added to comments. The base repo
uses none in the files touched here; the emphasis was doing work a bus message
needs and a source file does not.
@mpretty-cyro
mpretty-cyro force-pushed the feature/config-recovery branch from 9bf26a3 to 776a14a Compare September 8, 2026 06:30
@mpretty-cyro
mpretty-cyro marked this pull request as ready for review September 8, 2026 06:33
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