Skip to content

fix(migration): never return a partial delegate export as Ok - #103

Merged
sanity merged 3 commits into
mainfrom
fix-100-partial-export
Oct 15, 2026
Merged

sanity merged 3 commits into
mainfrom
fix-100-partial-export

Conversation

@sanity

@sanity sanity commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Problem

DeltaPredecessorIo::fetch_secrets treated a sub-request that got no reply as "nothing there" and returned Ok with whatever the other sub-requests produced. The wasm channel reports a timeout as Ok(None), so if GetKnownSites, GetSigningKey, GetSigningKeyForPrefix or GetSiteState timed out after probe_executable succeeded, the export was silently partial. freenet-migrate imported it, recorded the predecessor Done, and never asked for the missing secrets again. With localStorage available (a local node), that lasts for the life of the browser profile, and nothing reports an error. freenet-migrate's contract says a partial export must be an Err.

Approach

  • fetch_secrets classifies every sub-reply as the value, a definite "nothing usable" answer, or a failure. Failures are silence, a reply of the wrong kind, and an unrecognised in-band error, and they now return FetchError. The predecessor is reported Unresponsive, gets no marker, and is re-walked on the next load.
  • The definite answers stay answers, so a healthy predecessor is not re-walked forever. They are: signing-key absence in both the current -- wording and the V1–V5 em-dash wording (confirmed in the V1–V5 WASM blobs), a stored key that isn't 32 bytes, the known-sites decode error, and site-state absence for this prefix. Treating a permanently corrupt known-sites list as a failure would have withheld that predecessor's signing keys forever, so it proceeds and extra_prefixes drives the per-site probes instead.
  • The catch the issue doesn't mention: silence was also the expected result of sending an old generation a request its WASM can't deserialize. The delegate returns DelegateError, the node reports it as a client-API error with no delegate key, and connection.rs drops it. Just mapping silence to Err would therefore have made every pre-V8 predecessor Unresponsive on every load, because they all get GetSigningKeyForPrefix. Their legacy-slot keys would never have been imported. So those requests are now never sent. PredecessorCapabilities::of_generation gates them: GetKnownSites V3+ (index 2), GetSigningKey V4+ (3), GetSiteState V5+ (4), GetSigningKeyForPrefix V8+ (7). The GetSigningKey threshold came out of review (Codex, plus three of the Claude lenses): the first commit assumed it was V1+. It isn't, so V1–V3 would have been Unresponsive forever. V1–V3 keys have no export request at all, so neither path can recover them (documented). I found each threshold by locating the git blob of ui/public/contracts/site_delegate.wasm whose BLAKE3 matches each registry entry's code_hash, then checking it for the variant name. A test pins the registry hashes on either side of each threshold. A side benefit: old generations no longer burn a 10 s timeout per unsupported request.
  • Review fixes in the second commit:
    • The V1–V5 em-dash absence is now narrowed in the reply registry to public-key and signing-key awaits, so a concurrent sweep probe's "no key" can't fail a healthy export.
    • Timed-out awaits are pruned from the registry, so they no longer swallow a later reply.
    • The doc comments now spell out the all-or-nothing bound and the bound on a mis-correlated absence reply.
  • FetchError holds no reply payload, because a mis-routed SigningKey reply would carry key material into the report's Debug string.
  • The delegate WASM is unchanged, so there is no re-key and no migration entry. This is UI-only.

Testing

New tests in delegate_migration.rs:

  • a_silent_export_sub_request_is_retried_never_sealed silences each of the four sub-requests in turn. It asserts the predecessor is Unresponsive and has no Done marker, then checks that a healthy retry with the same markers recovers everything.
  • a_reply_that_does_not_answer_an_export_sub_request_is_retried covers wrong-kind replies and unrecognised errors.
  • a_definite_nothing_usable_answer_is_not_a_failure covers the V1–V5 em-dash absence and the known-sites decode error.
  • old_generations_are_only_sent_requests_they_understand uses V1, V5 and modern fixtures. It checks the request log and that none of them is Unresponsive.
  • generation_capabilities_match_the_registry pins the thresholds to the registry's code hashes.
  • capabilities_are_keyed_by_the_real_delegate_key builds the lineage from the real LEGACY_DELEGATES, where delegate key and code hash differ.
  • a_failure_part_way_through_the_per_site_loop_fails_the_fetch, a_timed_out_await_does_not_swallow_a_later_reply and the_v1_v5_signing_key_absence_only_answers_signing_key_awaits cover the rest of the review fixes.

I also changed the differential FakeNode to model an unsupported request as silence, which is what really happens, not as an in-band "unsupported request" error. Fixtures now get realistic generation numbers, and a fixture list no real lineage could produce now panics.

Mutation checks:

  • Reverting fetch_secrets to skip silence and wrong-kind replies fails the first two tests.
  • Making every predecessor "fully capable" fails the old-generation test plus two existing differential tests.
  • Each review fix was mutation-checked against its test: the GetSigningKey gate, slot pruning, em-dash narrowing, and keying the map by code hash.

cargo fmt, cargo clippy --all-targets -D warnings, cargo test (all green) and cargo check -p delta-ui --target wasm32-unknown-unknown all pass locally.

Closes #100

[AI-assisted - Claude]
https://claude.ai/code/session_01GKBuAzByqMPegsXcUYJb7h

DeltaPredecessorIo::fetch_secrets treated an unanswered sub-request
(GetKnownSites, GetSigningKey, GetSigningKeyForPrefix, GetSiteState) as
"nothing there" and returned Ok with whatever else came back. The wasm
channel reports a timeout as Ok(None), so a lost reply after a successful
executability probe produced a smaller export; freenet-migrate imported it
and sealed the predecessor Done, and the missing secrets were never asked
for again.

fetch_secrets now fails with FetchError on silence, on a reply of the
wrong kind, and on an unrecognised in-band error, so the predecessor is
reported Unresponsive, earns no marker, and is retried on the next load.
The delegate's definite "nothing usable" answers (signing-key absence in
both the V1-V5 and current wording, a corrupt stored key, the known-sites
decode error, and the per-prefix site-state absence) stay answers.

Silence was previously also the expected result of sending an old
generation a request its WASM cannot deserialize (the node reports that
as a client-API error the UI drops). Those requests are now never sent:
PredecessorCapabilities gates them by generation index (GetKnownSites
V3+, GetSiteState V5+, GetSigningKeyForPrefix V8+), with the thresholds
measured against each registry entry's WASM blob and pinned by test.
The differential FakeNode now models an unsupported request as silence
rather than an in-band error, and gives fixtures realistic generations.

Closes #100

Claude-Session: https://claude.ai/code/session_01GKBuAzByqMPegsXcUYJb7h
Review of the first commit found that GetSigningKey is NOT understood by
every generation: it arrived in V4 (index 3). V1-V3 cannot deserialize
it, so with silence now failing the fetch they would have been reported
Unresponsive on every load. Added a signing_key capability
(FIRST_WITH_SIGNING_KEY = 3), verified against the WASM blobs and the
DelegateRequest enum history, and pinned in the registry test (now with
index 0 and a GetSigningKey column). V1-V3 keys have no export request
at all, so neither this adapter nor the hand-rolled sweep can recover
them; documented.

Also from review:
- narrow the V1-V5 em-dash signing-key absence in the reply registry to
  PublicKey/SigningKey awaits, so a concurrent sweep probe's "no key"
  cannot resolve an export sub-request and fail a healthy fetch;
- prune timed-out awaits from the registry, so a dead await no longer
  swallows a later reply meant for a live one;
- fixtures can pin an explicit generation (V3/V4 band now testable),
  and the shipped-sweep oracle no longer reads V1-V3 legacy slots;
- tests for real delegate-key capability lookup, a mid-loop failure,
  a corrupt stored key, and another site's backup-absence error;
- document the all-or-nothing and mis-correlated-absence bounds.

Claude-Session: https://claude.ai/code/session_01GKBuAzByqMPegsXcUYJb7h
- the mid-loop test now silences the SECOND prefix (extra prefixes are
  probed first), so a loop that stops early fails it;
- pin the four remaining fail-closed arms (unrecognised error for
  GetKnownSites/GetSigningKey, wrong-kind reply for
  GetSigningKeyForPrefix/GetSiteState);
- add a V4 fixture (GetSigningKey but no GetSiteState) to the
  old-generations test;
- document that a V1-V3 predecessor holding an unexportable key is sealed
  and reads complete, and why; correct three stale comments.

Claude-Session: https://claude.ai/code/session_01GKBuAzByqMPegsXcUYJb7h
@sanity

sanity commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

Consolidated review (Full tier: data-migration surface)

Round 1 (5e03197)

  • Lenses: code-first, testing, skeptical and big-picture, all independent Claude subagents, plus Codex (codex review --base origin/main).
  • Blocking, found independently by Codex, testing and skeptical: GetSigningKey is not understood by V1–V3. It first appears in V4 (index 3), which I checked against both the WASM blobs and the DelegateRequest enum history. With silence now failing the fetch, V1–V3 would have been Unresponsive on every load. Fixed in e4beb26 with a signing_key capability, pinned in the registry test.
  • Also fixed in e4beb26:
    • The V1–V5 em-dash absence error is now narrowed in the reply registry to public-key and signing-key awaits. Before, a concurrent sweep probe's "no key" could fail a healthy export.
    • Timed-out awaits are now pruned from the registry, so they no longer swallow later replies.
    • Fixtures can pin a generation, so the V3/V4 bands are now testable.
    • New tests cover keying the capability map by the real delegate key (not the code hash), a corrupt stored key, and another site's backup-absence error.
    • The doc comments now cover the all-or-nothing bound and the mis-correlated-absence bound.
  • Optional and not done: big-picture suggested a comment sweep of common/src/state.rs (V7 to V8 naming). Touching common/ risks the delegate WASM build inputs, and that naming refers to a different version scheme. Left alone.
  • Done outside this PR: big-picture's other suggestion, a sentence in the freenet-app-migration skill.

Round 2 (e4beb26)

  • Lenses: skeptical, testing, Codex.
  • No blocking findings. Codex: "no actionable correctness regressions".
  • Fixed in 34fd855:
    • The mid-loop test silenced the first prefix, not the second, so an early break survived. It now silences the second, and a mutation check confirms it.
    • Four fail-closed arms were unpinned; they now have tests.
    • Added a V4 behavioural fixture.
    • Corrected three stale comments.
  • Dismissed with reasons:
    • V1–V3 predecessor holding a key is sealed and reads complete. That key has no export request in any generation, so no client-side path can recover it. Failing the fetch would re-walk the predecessor forever with the same result, and would also withhold V3's site list. This is now documented on PredecessorCapabilities.
    • A permanently slow GetSiteState withholds the predecessor's keys. This is the all-or-nothing contract the issue asks for (fetch_secrets must not return a partial export). It surfaces as Unresponsive, not as a silent loss, and the hand-rolled sweep still re-stores keys. Documented on DeltaPredecessorIo.
    • The shipped_sweep oracle filter has no test of its own. It is model fidelity in the oracle, not production code.

Round 3 (34fd855, tests and comments only): Codex reports no regression.

CI is green on 34fd855. The delegate and contract WASM are unchanged, so there is no re-key.

[AI-assisted - Claude]

@sanity
sanity merged commit 240565c into main Oct 15, 2026
4 checks passed
@sanity
sanity deleted the fix-100-partial-export branch October 15, 2026 15:13
sanity added a commit that referenced this pull request Oct 15, 2026
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.

fix(migration): a timed-out sub-request makes fetch_secrets return a partial export that gets sealed

1 participant