Repository navigation
feat: switch the UI to the unified mail delegate - #366
Conversation
The UI is about to move to the unified mail delegate, and the migration from the v0.2.x delegates lands after it. A release in between would show existing users an empty app, so scripts/release.sh now refuses to run while .release-freeze exists. scripts/release-freeze_test.sh shows it refusing (and not refusing without the file); CI runs it. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
`ui/src/mail_delegate/` speaks the freenet-mail-protocol: every call gets a fresh random request id and waits, keyed by that id, for the one response the delegate sends for it. A call that gets no answer in time is Unknown, never "absent". Two backends: the node (registers the candidate delegate once per page load with the pinned v0.2 cipher/nonce, waits for the node's answer to that, then sends), and for the offline build the delegate's own code run in the page over an in-memory store, answering its prompts with their first choice, so offline tests exercise the real protocol rules. node_comms routes the mail delegate's responses to their waiting calls. Nothing calls the client yet. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
`mail_delegate::ops` has one function per thing the UI will ask the delegate: identity create/import/update/confirm and paged delete (loops on Continue without asking again), items with paging and a read-merge-write that retries a lost compare-and-swap up to 3 times with backoff, v1 decrypt (batched), seal, authority and removal signing (hashes sent strictly ascending, as the delegate requires), the v1 AFT parameters, and the consented exports and grant revocation. Tests run against the real delegate code in-page and pin interop with the UI's own v1 crypto both ways: the delegate decrypts what the v0.2 client encrypts, and the v0.2 client opens and verifies what the delegate seals. The module is not called yet; a temporary dead_code allow on it in lib.rs goes when the call sites land in this PR. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Each contact is one `ItemKind::Contact` item keyed by its 24-byte address, holding a versioned record (alias, description, keys, policy, inbox hash). The address book stays the in-memory view: create and delete update it at once and persist from a spawned task (never awaited inside node_comms, which delivers the delegate's answers), reporting a refused or unanswered write in a toast. Contacts load from the delegate at startup in their own task. Contacts still in the v0.2.x identity-management delegate are no longer read; the migration from that delegate carries them over (and releases stay frozen until it lands). Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
A scoped dioxus task is cancelled when the view that started it unmounts, which would drop a contact save or removal; use spawn_forever, as the other delegate writes do. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Settings → Backup & restore gains "Remembered export permission", which sends RevokeExportGrant so every recovery-key export asks again. Toasts from detached tasks: `push_toast` looks its queue up with `use_context`, which a `spawn_forever` task or the node loop cannot do, so a toast pushed there panicked silently and never showed (the contact store's failure toasts had the same bug). The root's queue is now remembered (`toast::set_root_queue`) and `push_toast_detached` pushes onto it. ui/tests/export-permission.spec.ts clicks Revoke against the offline build's in-page delegate and expects the success toast; it failed before the detached-toast fix. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
node_comms can be polled with no scope on the stack, where the scoped `spawn` used for the startup contact load panics (dioxus-core runtime.rs:223, then a RefCell re-borrow), as e2e-real-node showed. Use spawn_forever, which runs on the root scope. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Contacts now load from the mail delegate in their own task, which can finish after the identity rehydrate's prime loop has run, so their inboxes were never primed after a reload (#191). The prime is a function (prime_contact_inbox) called from both places. e2e-real-node's #204 and #221 tests caught it. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The page no longer holds any private key. Identities are created, restored, listed, renamed and deleted in the mail delegate, which signs and decrypts for them: - Sending seals the message once in the delegate (encrypt, then sign); the assignment hash is taken from that same ciphertext, which is also the one stored. - Inbox states are decrypted by the delegate in a task, never in the node loop (which delivers the delegate's answers). Whole-state updates keep what is already decrypted; a removal that lands while a decryption is in flight wins. - Removals, settings changes and new or migrated inbox states are signed by the delegate (removal hashes in ascending order). - The v1 token-generator parameters come from V1AftParams. - Contract-migration markers (#213/#251) live in each identity's IdentitySettings item; the loader records them before the node loop dispatches any migration GET. - CreateIdentity answers with the new identity's recovery key, once, so sign-up can show the backup step (protocol: Reply::CreatedIdentity, golden line appended; guard mutation added). Later reveals in the backup modal and Settings are consented ExportRecoveryKey calls. - Offline builds seed the example identities in the in-page delegate. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Only confirm_identity (for the confirm-identity prompt) and export_profile (no UI yet) are still unused; they carry their own allow with the reason. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
- The delegate test helper checked only that the returned recovery key's signing half gives the identity's address, so a key with the wrong ML-KEM seed passed (the new guard mutation showed it). It now also checks the encapsulation key. - live-node E2E step 3 asserted log lines of the v0.2.x identity delegate, which the UI no longer uses; it now asserts the identities were read back from the mail delegate after the reload (new release-visible log line). - clippy (test target): needless borrow in the inbox test helper. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…wn log Step 3 polled the gateway log for "identities loaded from the mail delegate", which the UI writes with log::info. In the browser that goes to the console only, never to the gateway log, so the check could not pass. The node itself logs "Module cache miss — compiling" with the delegate's code hash the first time it runs a delegate (info level in freenet 0.2.151), and fresh iso nodes start with an empty module cache. Assert that line names the committed candidate's code hash, read from published-contract/candidate/mail_delegate_code_hash, scanning whole log files rather than grepLog's 256 KiB tail. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…message ids Drafts, sent, kept and archived messages, read/deleted marks and settings now live in the unified mail delegate as items owned by the identity's address, instead of the v0.2.x local-state delegate (no longer used). - Message ids are blake3(ciphertext)[..16] for the item; the UI's u64 MessageId is its first 8 bytes. Marks stay on the right message across reloads and however the inbox reorders, where list-position ids drifted. - Every write returns when the delegate acknowledges it. The WriteTracker count and the empty-response-as-ack handling of the old delegate are gone; "Deleting…" now resolves on the write's own acknowledgement (refused → failed, unanswered → couldn't confirm), and the write is never raced against a timer that could drop a multi-step write midway. - Settings writes merge field by field (nested), over compare-and-swap, so two tabs changing different settings keep both. - Local state loads with the identities, outside the node loop. The delivery-state writes the node loop triggers run detached: awaiting the delegate there would deadlock. - The v0.2.x local-state delegate's key tests stay (B3 migrates from it). Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The UI polled the gateway's /permission/pending and answered the node's own permission prompts for the user, per saved per-recipient decisions or "auto-confirm for verified contacts". A permission the app grants itself is not a permission: the node now asks the user, as it is meant to. Removed: the pump and its policy helpers (api.rs), the pending-recipient tracking it read (aft.rs), the Settings card that edited the decisions, and their tests. IdentityAftPrefs.permission_decisions and auto_accept_verified_contacts stay in the stored settings type (old records still decode) but nothing acts on them. The live-node e2e harness answers the node's prompts itself and is unaffected. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…tasks safely Deleting a large identity runs in pages (the delegate answers Continue with how much is left). The user now sees a sticky "Deleting identity `x`… N items left" toast that updates per page and goes when the deletion ends; a small identity finishes in one step and shows none. push_toast finds the queue with use_context, which panics outside a component scope. local_state_failure (only ever called from detached write tasks), the identity loader's error toasts, the migration-landed toast (node loop) and the delete-identity error toast now push onto the app root's queue (push_toast_detached), as the detached-task toast fix already did elsewhere. New detached helpers: push_sticky_toast_detached, update_toast_detached, dismiss_toast_detached. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…essage ids from folded rows Three regressions from the local-state switch (2c16e6e) and the move of identities into the mail delegate (0ffc1fe): - #286 (live node: an identity's row vanished after it was created). The startup load listed the identities, then loaded their local state (one delegate round trip per item) before announcing them, and the announcement replaced the identity list wholesale. An identity created in that window was dropped. Identities are now announced first and upserted; local state loads afterwards and is merged: only the identities the load read are replaced, and global settings only if nothing in the page changed them since the load began. - #289 (live node: "Add to address book" never shown). Received message ids became hash prefixes, so nearly all of them sat above SENT_FOLD_ID_BASE (1 << 48) and every received message was treated as a folded own-Sent row. Received ids now have the top bit cleared, and folded rows use the top half (RECEIVED_ID_LIMIT). An audit of the other id uses found ordering is by time everywhere (stale "newest root_id" comment fixed). - Offline storybook (threads-storybook.spec.ts). The storybook host builds a second User, whose example identities the in-page delegate already held; the second create was refused as a conflict and the expect panicked before the story rendered. Existing example identities are reused. Tests: a startup load replacing only what it read (red against the wholesale replace), received ids staying below the folded range (red without the mask), and thread order by time with anti-chronological ids. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…tities from the delegate
- The offline build deleted an identity from the page's memory only, so
the in-page delegate kept it (restoring its key then said it was
already on this device) and the delegate's paged deletion never ran
offline. Both builds now delete through one shared loop
(identity_store::delete_with_progress), which shows the progress toast.
- CI ran the UI's tests only in the node build, so the in-page delegate's
own tests (mail_delegate::local) never ran there. `cargo make test`
now also runs the UI's library tests in the offline build.
- New tests:
- a large identity reports what is left after each page until it is
gone, against the real delegate (red when progress is not reported);
- Playwright: the key shown at creation is the one a later reveal
exports, and leaves the page when the panel closes;
- Playwright: a deleted identity's key restores it as new, with the
same fingerprint (red against the in-memory-only delete);
- live node: two tabs changing different settings keep both.
Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
scripts/check-release-freeze.sh is the one freeze gate. release.sh runs it before parsing arguments, and publish-email, publish-container and publish-production list it as their first dependency, so none of them can publish while .release-freeze exists. RELEASE_FREEZE_FILE is honoured only together with RELEASE_FREEZE_TEST=1 and is otherwise ignored with a warning. The freeze test now never runs a release: release.sh is invoked only with --check-freeze, which exits right after the gate. It asserts exact exit codes, that release.sh runs the gate before anything else, and that each production publish task runs it first. check-guards-fail.sh shows the test failing when the gate is dropped from release.sh or a publish task, or when the override works without the test flag. RELEASING.md documents the freeze. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…ored Archiving signed and sent the inbox removal at once, while the archive write ran in parallel; when that write failed or went unanswered the message was gone from the inbox and the archive. The removal now runs only after the mail delegate acknowledges the archived copy. Otherwise the optimistic archive is reverted and the user is told the message stays in the inbox. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The v1 token-generator delegate's parameters carry the identity's ML-DSA signing seed. The page fetched them for every identity on every load, to re-register the generator delegates, and kept them in Debug-printable structs. The delegate is now registered on first use, once per page session, by the send path that needs it; loading fetches no key material (test pins it). The parameters are held zeroizing, and the CreateDelegate action carries them in SecretBytes, whose Debug prints only a length. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
… wait out prompts - Rename: the delegate acknowledges UpdateIdentity (and ConfirmIdentity) with the new generation, but the client accepted only an identity reply, so every rename was stored and then reported as failed. Both ops now take the ack. The offline build renames through the in-page delegate too, so the Playwright rename test covers it. Its error toast uses the detached-task API. - Decryption: entries a batch could not fit in the reply budget came back CapReached and were treated as unreadable, so those messages never showed. They are asked for again until each has been answered alone; only a plaintext too large for any reply on its own is skipped. - Prompts: consented calls waited 150 s while the delegate acts on an answer for 600 s, so a late approval could carry out an operation the page had reported as failed. They now wait the delegate's prompt lifetime plus a margin (pinned to the delegate's constant by a test). Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
When reading an identity's migration markers failed or went unanswered, loading fell back to defaults, planned from them and wrote them back, wiping a pending migration marker for good. Unreadable markers are now unknown: nothing is planned or written this session, and the next one reads them again. The planning is a pure function (plan_load) with tests. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The registration was marked done even when sending it failed, so every later request that page load went to a node that might not have the delegate. A failed attempt now goes back to not-started and the next request retries it. A task cancelled mid-registration settles the attempt as failed through a drop guard, so its waiters wake and retry instead of hanging, and a waiter gives up with an error after a bounded time. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Two publish-error paths in identity creation and restore pushed toasts with push_toast inside detached tasks, where reading the toast queue from the component context panicked the task and lost the message. They use push_toast_detached. push_toast itself now checks for a component scope: outside one it logs the misuse and falls back to the root queue instead of panicking (and asserts in debug builds if a scope lacks a queue). Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
- The backup panel took the recovery key as a plain String prop and copied it into its copy and download closures, so closing it left copies behind. Keys are now a SecretString: one zeroizing allocation shared by every clone, with a redacted Debug. The copy path borrows it, and the download body is zeroizing. (What is handed to the browser for display, the clipboard or a download is beyond the page's reach.) - Restored seeds (StoredIdentityKeys) are wiped on drop. The restore form derives the fingerprint preview once when the input is parsed instead of re-deriving the signing key on every render, and clears the pasted text and the parsed backup once the delegate holds the keys. - Restore input over 64 KiB is refused unparsed (a file is not read at all), and a JSON backup whose names exceed the delegate's limits is refused with a clear message. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
- A merged write that lost its compare-and-swap re-read the item and, finding it gone, created it again, so a draft autosave could undo a Discard and a read mark could undo a delete. An item that existed on the first read and is gone on a retry was deleted meanwhile; the write is now dropped. Test: a delete between the read and the write stands. - Opening a message sent its read mark (four delegate round trips) on every render of the view, and could write the kept copy back after the message was deleted or archived. It is now sent once per page, and never for a deleted or archived message. - Item deletes back off between compare-and-swap retries. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
A delivered or failed callback that landed before save_sent had stored the Sent item found nothing to update and was dropped, so the row stayed Pending after a reload. Such a state is now stashed: save_sent writes it with the item, or applies it right after its write if it arrived while the write was in flight. A terminal state being saved is never overridden. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The page caches local mail state by alias and never cleared it, so deleting "alice" and creating a new "alice" in the same page showed the old drafts and sent mail under the new identity and merged the old display name and signature into its settings. Deleting an identity now drops everything cached for its alias, and renaming moves it to the new alias, in both builds. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…age state - Y6: a rename re-keys the identity's own address-book entry (the new name sends to it, the old one no longer does), and renaming onto a contact's name is refused. The page's list takes the new name first; if it refuses, nothing else is relabelled and the rename is reported as unknown. - Y7: the startup merge keeps draft tombstones it cannot confirm (the journal already replayed the discard), so a late save of a draft discarded during the load is still refused. - The startup contact load is journalled: a contact saved or removed while it ran keeps the page's state (a removal is not brought back). - Delivery states waiting for a Sent item are forgotten by the deleted identity's address, the key they are stored under (by alias it matched nothing). - Token-call answers come in send order: a late answer to an expired call no longer ends a newer call's window. - Send and Discard also move the close-flush's edit marker on, so closing the sheet does not fire a save of the draft just removed. (No test: the discard tombstone already turned that save into a no-op, so nothing observable changes.) Tests, each red with its fix removed: - a_rename_moves_the_address_book_entry_to_the_new_name - a_draft_discarded_during_the_startup_load_keeps_its_tombstone - a_contact_changed_during_the_startup_load_keeps_the_pages_state - a_late_answer_to_an_expired_token_call_does_not_end_a_newer_one - a deleted identity's bookkeeping test now keys delivery states by address, as save_sent does (it seeded alias keys, mirroring the bug) Plus a_contact_name_is_not_free_for_an_identity. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…ranch The index's first present read is the failed add's own undo, so blinding it from the third read left the branch under test reading it fine: the test passed with or without the fix (CI's guard run caught it). Blinded from the second read, it fails with the old branch and passes with the fix. Also formats recovery.rs, which CI's rustfmt step rejected. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The live-node suite runs with retries: 0, so the config's trace: "on-first-retry" never fires, and only some tests attach a console listener. A failure that happens only in CI left nothing to diagnose. Every context a test opens (the fixture's and browser.newContext()'s) now records console lines, page errors and a trace, attached on failure only. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
One test starts its own traces, so starting another on every context failed with "Tracing has been already started". On failure every page now attaches a screenshot and the app frame's HTML, alongside the console. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Body-only attachments are kept by a reporter, and CI uploads only test-results/, so the screenshots and DOM never reached the artifact. They are now written as files. The page also logs the state key a Sent row is kept under, so a Sent folder that shows nothing can be told apart from a row that was never written. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
A failing #286 run showed the app restarting right after Send, with no console line saying why. The evidence now includes each frame navigation with its URL, renderer crashes, dialogs and page closes. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
A failing #286 run reloaded right after Send, while the Sent copy's write was still in flight; the reload ended the task and the copy was gone after reload. Only draft saves were counted as pending, so the page could not hold the reload off and the test could not wait for it. Every item write (put, merged put, delete) now holds a pending guard until the delegate answers, and Send counts the send and the Sent save from the moment they are scheduled, before their tasks first run. The send is counted until its token is requested; the inbox update after the token's answer is not. The live-node evidence now also snapshots each page when its context closes, since tests close theirs before afterEach runs. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The test reloaded within a second of Send; when the Sent copy's write was still in flight the reload ended it, and the copy was missing afterwards. That loss is real for a user who reloads that fast, so the test now waits for the page's own pending-write count instead of racing it. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Listening for dialogs turns off Playwright's own handling, so the evidence listener left the app's leave-page prompt unanswered and every reload with a write in flight hung. It now lets that prompt through and dismisses others, as Playwright does with no listener. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The #286 live-node test sometimes finds the view back on Inbox after clicking Sent, with no navigation or error. The folder lives in UserInbox's context, so a remount resets it. These lines show when the view mounts, why app() switched branch, and every folder click. The folder buttons also get stable keys. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…286) A rare failure left the view on Inbox after the click, with the pointer over Sent and no overlay, navigation or console error; it surfaced only as a missing Sent card 30 s later. Assert the selection itself, so the next occurrence fails at the click, beside the console lines that show whether the click reached the app and whether the view re-mounted. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…l rows a closed page stranded A reload after the token was requested ended the page with the message possibly never handed to the node, and no leave-page warning: the send was counted only until its token request. The Sent row then stayed Pending forever, because the stale-send sweep works from the page's in-memory ack queue, which a reload empties. - Each queued pending-ack entry now holds a `pending_saves` guard, so the send counts as unfinished page work from the Send click until its inbox update is answered, failed or swept (at most 90 s). Red on f8e65d3: a_queued_send_counts_as_pending_until_it_resolves_286 (left 0, right 1). - After the startup merge, Sent rows still Pending that no send in this page waits on (left by a session that ended mid-send) are marked Failed, with one notice that they may still have arrived. Resuming them is not safe: re-sealing makes a new ciphertext, so a send that did go out would arrive twice. - When the recipient's inbox key can't be derived, the row is failed and stored Failed at once instead of "staying Pending". Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…; only a deletion marker stops a delete
- An approved multi-step deletion kept its approval only in the record's
`deleting` flag until the last step wrote the `d` marker. If the record
then stopped reading, a restore or migration import of the same keys
rewrote it with `deleting: false`, undoing the deletion. The `d` marker is
now written first, before the record is marked (and re-written on resume
if it does not read). Test
a_restore_cannot_undo_an_approved_deletion_whose_record_went_unreadable,
red without the change ("UserRestore undid an approved deletion"); guard
mutation added.
- The DeleteIdentity precheck and delete_identity_now tested "no marker"
where they meant "no deletion marker", so a create's leftover marker
(`s`) beside an unreadable record made the identity undeletable
(NotFound). Both now test `!= Some(MARKER_DELETING)`; a leftover create
marker with no record and no entry is cleared. Test
a_spent_create_marker_does_not_stop_deleting_an_unreadable_identity, red
without the change; guard mutation added.
- `Space::del` on a key that reads as absent retried its removal only
once more, then trusted a re-read. A key whose reads keep failing and
whose removal was refused twice was reported gone while it stayed, so a
deleted contact came back once it read again (fault seed 1284 at 100
ops / blind 60, red without the change, pinned). It now tries three
times, as for a key known present. A key that cannot be read and whose
removal is refused every time is indistinguishable from an absent one
through the host API; the comment says so.
- Fault harness: a deletion left unfinished is checked with
ListDeletingIdentities and, once certainly started, any import of it
must be refused; `check()` fails if a started deletion is still listed
after the next page. A started deletion whose finishing reply was lost
is recognised from storage, so a later restore is not flagged.
Candidate rebuilt: 9U3oziYn8hLZTqWXchbCDH3fVGZxxeiWpYg5W2Pwg1vE (reproduces).
Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…ueued contact write when it is made
- Contacts: a stored contact that could not be read (`Unreadable`) failed
every save and every remove, and the listing skipped it, so the user
could neither fix nor delete it. A save now writes it with no expected
generation, which the delegate takes as "replace what cannot be read"
(the user is told once), and a remove deletes it without comparing a
generation. Tests saving_a_contact_that_cannot_be_read_replaces_it and
removing_a_contact_that_cannot_be_read_deletes_it; on the old code the
`?` on the read returned the error.
- Markers: `load_markers` already returns any failure, `Unreadable`
included, as an error that `prepare` treats as unknown (plans nothing,
writes nothing; pinned by unreadable_markers_plan_nothing_and_write_nothing).
Documented at the function.
- Contact-load journal: a write queued behind an earlier one for the same
contact was journalled only when it started, possibly after the startup
load had read the contact, so the load could bring back a contact removed
while it ran. `queue_write` now journals at enqueue time. Test
a_removal_queued_during_the_load_is_journalled_at_once, red with the
journal call removed ("the load brought back a contact removed while it
ran").
Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…and the UI offers to repair it
- Delegate: `delete_step` failed on an item index it could not read, so
the approved deletion could never finish and the identity's keys stayed.
An unreadable list cannot be walked (nothing lists the host's keys), so
the deletion now forgets it (`reset_index`, removal needs no read) and
removes everything else, the record with its keys included. The items
that list named stay as unreachable bytes; documented at the code. Test
an_unreadable_item_index_does_not_block_deleting_the_identity, red
before ("partly done, retry: an index record could not be read");
guard mutation added.
- Delegate: the repair prompt for one list now names it and whose it is
("Mail's list of drafts of \"alice\""), asserted in the existing test.
- UI: a list that still answers Busy after retries loads as empty and is
recorded, instead of failing the whole local-state load; "Your
identities" offers "Repair" for each such list (new testids
fm-id-repair-list / -note). Test
an_unreadable_list_loads_empty_and_is_offered_for_repair; a non-Busy
failure still fails the load.
Candidate rebuilt: 5eEvwUhHNEqWQJhjDEEcwfM5iwfCWcPKKgk95gBXpd3P (reproduces).
Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…-marker test for what it checks; log no address per send - Restored a mutation guard for "a failed create marks its slot free to reclaim" (removed in round 9 with no replacement), retargeted to the current reclamation code, with a new test a_failed_create_marks_its_slot_free_to_reclaim. Shown failing with the `f` marker dropped from the reclaim condition. - Renamed a_create_marker_beside_an_unreadable_record_licenses_nothing to a_create_marker_beside_an_unreadable_record_never_skips_the_prompt_and_stays_restorable and corrected its doc: a `c` marker beside an absent record does let a listing unlist the identity (it looks like a create that never finished); the test checks that the deletion still asks and that the identity stays restorable by import and is listed once it reads. - The fault check accepting the one unreadable record as stored but unlisted is intended: nothing can tell an unreadable record from an absent one, and the restore path (import its keys) recovers it, which importing_keys_restores_a_listed_identity_whose_record_cannot_be_read and the test above assert. - Comment at the slot reclamation: another tab's create cannot be in flight there (core runs a delegate's messages one at a time). - local_save_sent no longer logs the identity's address and the message id on every send. Candidate rebuilt: 144Faev4S225vWCbPQ2kyqGZS5E1MXvqN4rqQYEG2eQT (reproduces). Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
check-drift on 07e9b78 failed two guards. "a delete removes a key that reads as absent" named the old two-attempt line. "a deletion answers Deleted only once its marker is confirmed removed" relied on a fault seed whose marker removal was refused twice, which the three-attempt `del` now survives, so the guard passed with its regression applied. It now targets a_deletion_is_not_reported_done_while_its_marker_may_remain, which blinds the marker and refuses its removal three times: passes as written, fails with del_written swapped for del. Candidate unchanged (144Faev4...). Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The guard "re-creating an identity clears its deletion marker" replaced a
line ending in `{` with `if false`, dropping the brace, so the mutated
delegate failed to compile and the script reported "failed, but not as
tests:a_reimported_identity_clears_its_old_deletion_marker". The mutation
is now `if false {`: the create skips clearing its marker, and exactly the
named test fails.
Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Corrupt identity records: - An identity record that reads but does not decode used to fail `all_identities` with Internal, so one damaged record hid every identity. `load_identity` now treats it like an unreadable record: left out of listings, deletable after asking, and restored by importing its keys, which writes over it (same address, same seeds). - `Status` gains `unreadable_identities` (absent when zero, so the earlier encoding is unchanged; new golden line `reply_status_unreadable`), and the UI tells the user, once per load, how many identities could not be read and that their recovery keys restore them. Storage usage: - Removing a key that cannot be read (`del`, `del_written`, `remove_if_present`) now counts one key fewer. - Writing over an unusable value (`Space::write_over_unusable`, used by restores and by replacing an unreadable item) removes it first, so its key is counted once rather than twice. - The bytes of a value the host cannot read cannot be known: nothing else records sizes, and the host reports such a value as absent. They stay counted. The usage record is an upper bound throughout (a failed put already over-counts), so the error only costs this origin budget, never lets it exceed one, and arises only from values the host failed to read. A per-key size table would double every write and could itself become unreadable; a recount would have to walk every key the delegate tracks and still could not size the values it is meant to account for. Fault harness: these changes shift the fault RNG stream, and pinned blind seed 51 then reached a documented limit no earlier seed had: an approved deletion that finds an item list unreadable forgets the list and leaves its items as unreachable bytes (`delete_step`). The checks now excuse exactly the items of the list that was the unreadable key, and nothing else. Guards: new mutations for each fix; the "delete removes a key that reads as absent" guard is retargeted to the rewritten condition. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Fault-harness seeds 765 and 934 (20% faults, one key unreadable) left the contact index naming an item that was never stored, with no marker licensing its removal. Root cause: emptying an index removed its record, setting the existence marker to `p` first and back to `l` if the removal was refused. Both could be refused. The old entries then stayed under `p`, where a record that fails to read is taken for an empty index: a failed create's cleanup saw no entry and dropped the item's marker, and once the record read again it listed the dead entry for good. Reading a record under `p` as empty instead only moves the problem to the marker key (seed 152 under that attempt: the marker went unreadable and the dead body read as live). So no index record may hold an entry its writer did not commit, whichever key fails to read: - An emptied index keeps its record, written as the empty list; it is never removed by a removal that can be refused half-way. Only an identity's deletion (`delete_step`, which now removes the record before its marker) and `reset_index` remove indexes. - A new index is created as the empty list (`p`, record, `l`), and the entry is added only once `l` is set. A record under `p` is always the empty list. Tests, each red on the old code: - an_emptied_index_reads_the_same_whether_its_record_reads_or_not (old: listed `a` while readable, nothing while unreadable) - a_record_under_a_pending_marker_never_lists_an_entry - seeds 765 and 934 pinned in PINNED_BLIND_RUNS plus two guard mutations in check-guards-fail.sh. The candidate is rebuilt (7Ta6WmPPhxmbvJpAc19YujXp1hsDbLTzJoj6MdzG44nv, reproducible). The harness gains FAULT_DUMP=1 to print the store when a check fails. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
`cargo make test` runs the unreadable-key harness at its 6% default. Seeds 765 and 934 only fail at higher rates, where several refusals in a row are common, and were found by hand outside CI. This step runs 2000 seeds x 80 ops at a 20% fault rate with unreadable keys, in release (about 45 s to build and 10 s to run on a dev machine; debug takes ~15x longer per seed). Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
modules/antiflood-tokens/interfaces was the one crate in
scripts/check-excluded-crates.sh without the one-line `[workspace]`
table AGENTS.md says every excluded crate carries. Without it, cargo
run in that directory searches upward past the root that excludes it;
in a worktree nested inside another checkout it reaches that checkout's
Cargo.toml and refuses ("current package believes it's in a workspace
when it's not"), which stopped `cargo make test` there.
The crate keeps its own Cargo.lock (unchanged), so nothing it builds
changes: check-frozen-artifacts and check-wasm-builds pass.
Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
"an index that cannot be read may hold a failed create's entry" mutated put_item to drop a failed create's marker when the index cannot be read. After the previous commit write_index adds an entry only once the index is live (`l`), in one write that lands or changes nothing, so a failed index_add never leaves its entry and the mutation changes no outcome: the guard passed with it applied. The guarantee is guarded by "a new index lists nothing until it is live"; the scenario test still runs, renamed to what it now checks (a_failed_add_to_an_unreadable_index_never_leaks_its_slot). The defensive branch in put_item stays. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The startup sweep marked every Pending Sent row this page was not sending as Failed. The in-flight set is per page, so a send still in flight in another open tab was marked Failed in the shared store, and the terminal guard then refused that tab's Delivered: a delivered message stored as Failed, with a notice inviting a resend. - Only rows older than the stale-send timeout are swept, by `sent_at`. - A swept row is stored as presumed failed (`presumed_failed`, omitted when false, so other rows keep their bytes). A Delivered replaces a presumed Failed; a Failed a send actually saw stays terminal (#288). Tests: a_recent_pending_row_from_another_tab_is_not_stranded fails with the age check removed; the presumed-override unit test and the_sending_tabs_delivery_wins_over_a_presumed_failure (against the in-page delegate) fail with the override disabled. All pass with the fix. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…n origin The usage record was trusted absolutely. When it read as absent (an unreadable record) it was taken as zero, so the origin could write past its budget for good. And it only ever drifted up (a counted write the host then refused, the bytes of an unreadable key on removal, a phantom key from replacing an unreadable value), which could leave an origin permanently at CapReached with nothing stored to delete. - A usage record that reads as absent, or does not decode, is unknown: the next write recounts what is stored first. Deletes leave an unknown record alone. - A write the record would refuse recounts first, unless a finished recount already found exactly that usage (a store really full is not recounted on every refused write). - The recount walks every key the delegate can name: bookkeeping, every index and its marker, prompts, identity records and markers, listed items and their pending markers (`store::pending_item_rel`, moved from handlers). A key that must exist but cannot be read counts as UNREADABLE_VALUE_BYTES. It reads RECOUNT_STEP keys per call and resumes where the last call stopped (Busy until done); it is idempotent. - The "upper bound throughout" doc was false and is rewritten. - Fault harness: a new invariant, never stuck at CapReached after healing. Requests now carry distinct ids: a call may also refuse an earlier request's pushed-out prompt, and with one shared id that refusal looked like a second response to this request (seed 1386 at 3000 seeds hit it once the recount shifted the fault draws). - Stale comments fixed (handlers.rs index removal order; the failed-add recovery test, now retargeted to assert it reaches the step it names). - Tests that faked a full budget by writing the record now record a finished recount that found it full (confirm_usage_for_test). New tests, each red with its fix removed (guards in check-guards-fail.sh): a_lost_usage_record_is_recounted_not_taken_as_zero, a_drifted_usage_record_is_recounted_before_refusing, a_large_store_is_recounted_in_pages_and_recounts_agree. Candidate rebuilt: 7U7cV3YRVPeR2NShQQskDLjrksFbcn7d4N4kkkCsV8Z2. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Fault seed 2404 (100 ops, unreadable keys) leaked an identity slot on fa3641a. Trace: a listed identity's record went unreadable; the user restored it from its keys; `write_over_unusable` removed the unreadable record, then the generation counter write was refused. The restore failed with the record gone, the index entry kept and no marker, so nothing could free the slot. It was not the documented create trade-off (no create, no marker). On 9c6bd97 the seed passed only because the fault draws shifted. `write_over_unusable` now writes over the stored value in place, after taking the generation, so a refused write leaves the store unchanged. An unreadable value's key is then counted a second time (the usage record runs one key high until a recount), never low. Pinned by a scripted replay of the two faults that mattered (`a_restore_refused_its_generation_keeps_the_unreadable_record`) and a store test refusing each write in turn; the remove-first guard in check-guards-fail.sh now reintroduces the removal. Candidate rebuilt. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The formatting change alone moved the candidate's bytes, so it is rebuilt with scripts/build-wasm.sh. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Review summary (Full tier + security + data-loss: UI switch-over to the key-holding delegate)Thirteen review rounds on this PR.
What changed because of review (highlights):
Final round: a last fault-harness finding (a seeded run leaked an identity slot) turned out to be a real bug: restoring over an unreadable record deleted it before taking a generation number, so a refused counter write stranded the slot. Fixed by overwriting in place, pinned by tests that replay the original faults as a script (not a seed), and re-checked clean. Follow-ups noted for the next PR:
Known and tracked: #367, an intermittent live-node failure where a folder click sometimes leaves the view on Inbox (about 1 in 86 runs; no data involved). Instrumentation is in place to pin it down on the next occurrence. Deferred by design:
Shipped bytes: none. The delegate is a committed candidate, nothing is published, and releases are frozen. [AI-assisted - Claude] |
Problem
The unified mail delegate (#364) holds identities, contacts and local state behind an origin-scoped, per-item compare-and-swap protocol, and does signing and decryption itself. The UI still talks to the two v0.2.x delegates (identity-management, mail-local-state), keeps identity seeds in page memory, and relies on a permission-overlay pump and empty-response acks.
Approach
Switch the UI to the unified delegate in layers, each compiling and tested:
Release freeze (
30066eb). The migration from the v0.2.x delegates lands in the next PR of this chain, so.release-freezemakesscripts/release.shrefuse to run until it does;scripts/release-freeze_test.sh(in CI) shows the refusal.Client (
7b0cba3,ui/src/mail_delegate/).Unknown, never "absent".Typed operations (
d1c1ae2,mail_delegate::ops).Continuewithout asking again.Tests pin interop with the UI's own v1 crypto both ways: the delegate decrypts what the v0.2 client encrypts, and the v0.2 client opens and verifies what the delegate seals.
Contacts (
9c8767a,2f712d2,2f99f82,0deea6d,ui/src/app/contact_store.rs). Loading runs in a root-scoped task; a scoped spawn panicked inside node_comms. Each loaded contact's inbox is primed (Cross-node send fails silently with 'missing contract parameters'; UI shows 'Sent' #191), as the reload path did before.Contactitem per contact, keyed by address.spawn_forevertask; a refused or unanswered write shows a toast.Revoke export permission (
a5b461b). Settings → Backup & restore gets "Remembered export permission", which sendsRevokeExportGrant.push_toastusesuse_context, so from aspawn_forevertask it panicked silently.push_toast_detacheduses the root's queue.ui/tests/export-permission.spec.tsruns it against the in-page delegate; it failed before the fix.Identities and crypto in the delegate (
0ffc1fe). The page holds no private key.node_comms, which delivers the delegate's answers). Whole-state updates keep what is already decrypted; a removal that lands mid-decryption wins.NodeActions (app/identity_store.rs).IdentitySettingsitem; the loader records them before any migration GET is dispatched.Identity lifecycle (
0ffc1fe,27857d7).Reply::CreatedIdentity, golden line appended, guard mutation incheck-guards-fail.sh; the delegate test checks both halves of the returned key), so sign-up keeps its backup step. Discarding at that step deletes the identity (the delegate asks).ImportIdentity{UserRestore}; rename isUpdateIdentity; delete loops onContinue.ExportRecoveryKey(ExportedKeyPanel); the key lives only in that panel's resource.Local state as items (
2c16e6e,ui/src/local_state/items.rs). Drafts, sent, kept and archived messages, read/deleted marks and settings are items owned by the identity's address; the v0.2.x local-state delegate is no longer used.blake3(ciphertext)[..16], so marks stay on the right message across reloads and reordering. Drafts and sent use their UUID bytes.u64message id rather than widening it across ~20 call sites: the first 8 bytes of the 16-byte item id, top bit cleared (local_state::RECEIVED_ID_LIMIT; the top half is reserved for folded own-Sent rows). Two of one identity's n messages share a UI id with probability about n²/2^64 (~5×10⁻¹² at 10,000 messages); writes use the full 16 bytes. Ids carry no time: nothing orders on them.Removals (
fec6fec). The permission pump that answered the node's own permission prompts, its policy helpers, the pending-recipient tracking it read, and the Settings card that edited saved decisions. The node asks the user now; the stored fields stay decodable but nothing acts on them.Deletion progress and detached toasts (
1dd39a8). Deleting a large identity shows "Deleting identityx… N items left", updated per page. Toasts pushed from detached tasks (local-state write failures, identity loading, migration landed, delete failure) go to the app root's queue instead ofuse_context, which panics outside a component.e2e step 3 (
0551137). Proves the mail delegate ran on the node from the node's own log: the first-run "Module cache miss — compiling" line naming the committed candidate's code hash. (A UIlog::infoline goes to the browser console only, never the gateway log.)Startup load, id partition, storybook (
ba8092c). Three regressions from 8 and 6:SENT_FOLD_ID_BASE(1 << 48), so every received message passed for a folded own-Sent row. Received ids now have the top bit cleared and folded rows use the top half. An audit of the other id uses found every ordering is by time (one stale "newest root_id" comment fixed).User, whose example identities the in-page delegate already held; the second create was refused as a conflict and theexpectpanicked before the story rendered. Existing example identities are reused.Offline deletion and CI coverage (
fb78336). The offline build deleted an identity from page memory only, so the in-page delegate kept it. Both builds now delete through one loop (identity_store::delete_with_progress).cargo make testalso runs the UI's library tests in the offline build, which CI never did, so the in-page delegate's tests (mail_delegate::local) now run there.Review round 1 hardening (
1dda377..8f39920):release.sh,publish-email,publish-container,publish-production) runsscripts/check-release-freeze.shfirst. The freeze test never runs a release.Status: in review
Transitional exposure until step C:
V1AftParamsreturns the v1 token-generator delegate's parameters, which carry the identity's ML-DSA signing seed, to code running in the Mail page. The page now asks for them only on the send path. The buffer the delegate returns is zeroized, but decoding it into stdlibDelegateParameters/Parametersand handing it to the node's client API makes plain copies that live across the send path's awaits and are freed, not wiped. Consent does not cover this op. Step C removes it, together with the anti-flood tokens, before any release, and the release freeze stops a publish before then.Review round 1 is fully addressed. The last items:
apply, and while a load runs it is journaled; the load is applied under the journal. A draft saved meanwhile stays, a Sent row deleted meanwhile stays deleted, a settings change keeps the fields it changed, and a deleted or renamed identity is not brought back.Op::ListDeletingIdentities(appended) lists identities whose approved deletion has not finished; Your identities offers "Finish deleting", which does not ask again. A deletion that may be partly done says so ("may be incomplete; it will finish when you retry") instead of "was not deleted", and a restore of such an identity says to finish the deletion first.v1-transition, and the local-state writes and load are tested against it. The UI's call sites still persist only in node builds (the offline store lives for one page and its example messages are not delegate items).local-delegatewithuse-nodeis a compile error.wire-v1LocalStateshape and about 20 accessors; delete and rename already move or drop the cached state, and the startup merge now honours both.Deferred to B3: the confirm-identity prompt for unconfirmed identities before send/publish (
ops::confirm_identityis ready; only the migration produces unconfirmed identities).Deferred to C: the AFT token-generator delegate's registration handling (
token_generator_managementand the v0.2.x identity-creation plumbing it drives). The node's empty answer to itsRegisterDelegateis a real answer, and it all goes with the anti-flood tokens.Review round 2 fixes (9c59c89..778c689):
alias~<address prefix>with a warning.push_toastfrom aspawn_forevertask (Dioxus root scope) falls back to the root queue instead of asserting.Review round 3 fixes (778c689..4790208), each with a test shown failing without it:
ListDeletingIdentities; the prune runs again on the next listing. Delegate change: guard mutation added, candidate rebuilt.announce_then_merge); the archive removal target throughstore_thenwith an identity switch mid-wait (DeferredRemoval::pick, which also turns an unloaded inbox into an error rather than a panic); and a live-node test that a renamed identity's draft and settings persist across reload (R10).Review round 4 fixes (4790208..1af39c9), each with a test shown failing without it:
~in the label (a contact namedbob~workis an ordinary contact); a clash label never overwrites an entry that already holds it; forgetting an identity forgets its seen drafts and recorded recipients.data-autosave="saved"(set once the delegate acknowledges what is on screen) instead of sleeping.Review round 5 fixes (1af39c9..9ea757f), each with a test shown failing without it:
fmail-addr:+address_of(vk)), and the inbox is resolved from the key at send time. "No longer in your address book" and "couldn't work out who this was addressed to" are told apart.ui/src: thread groups sort by time first, delegate paging reads a sorted index, and the removal payload is sorted on purpose.Testing
Round 5: UI tests 339 in the default build, 346 offline; clippy clean (
-D warnings) natively and on wasm32 for both builds. Red→green, by re-applying the old behaviour: the double-click guard; the ambiguity refusal (three address-book tests); contract-id recording (two); the binary-search removal; the page keeping a partly deleted identity; the bare clash label; no rename-back on removal. New live-node test: an identity left at the reveal step gets both contracts on the next load, and a further load finds them. No delegate or protocol code changed in this round.Round 4: UI tests 330 in the default build, 337 offline. The removal tests fail against the positional lookup; the recipient and relabel tests (address book) and the send-check and recorded-address tests (in-page delegate) all fail with their fix removed. These UI checks were run by hand;
check-guards-fail.shcovers the delegate and protocol.Round 1, last items (each shown failing against the code it guards): six startup-merge tests (removing the journal replay fails all six); V1AftParams offline equals the v0.2 client's encoding (fails with the feature off); five local-state persistence tests against the in-page delegate (archive-before-remove fails when the removal ignores the outcome); an interrupted deletion is listed and finishes without asking (delegate test plus a guard mutation) and the same through the in-page delegate; deletion and restore failure texts; a dropped call leaves no waiter. UI tests now: 309 in the default build, 310 offline. Delegate: 65 tests.
UI library tests (earlier rounds): 282 in the default build, 273 offline (
--no-default-features --features example-data,no-sync, now incargo make test). Offline runs exercise signing, decryption and deletion through the real (in-page) delegate. New in this round, each shown failing against the code it guards:Playwright (offline):
mail-delegate-identity.spec.ts: the key shown at creation is the one a later reveal exports, and leaves the page when the panel closes; a deleted identity's key restores it as new with the same fingerprint (red against the in-memory-only delete). Existing specs cover restore by key and JSON, the backup panel, delete confirmation and revoke.threads-storybook.spec.tspasses again.e2e-real-node: Persistence layer: sent/deleted messages don't survive refresh or session #286 and Reply fails when recipient added sender under a different local nickname #289 pass again; new: two tabs changing different settings keep both. Two tabs can't share a delegate offline (each page has its own), hence the live test.
Delegate: 64 tests with and without
v1-transition, includingthe_recovery_key_comes_back_on_creation_only; protocol golden test coversreply_created_identity.Candidate WASM (now
7fLRKWh2hrQkNctnDzBnVUxzn4nQcwcZ3NgJ3jN7L3mQ);check-delegate-candidate.sh,check-frozen-artifacts.sh,check-frozen-immutable.shandcheck-guards-fail.shpass.Strict clippy (
-D warnings): the workspace and the offline UI build.Release freeze:
scripts/release-freeze_test.sh.Round 7: host-fault runs
Every earlier round found another bug of one class: an outcome the host cannot report (a failed read looks exactly like an absent key; a write or a reply may or may not have landed) treated as success, failure or absence. No test could reach it, because the in-memory host never failed.
The harness (
delegates/mail-delegate/src/tests/faults.rs,env.rs):MemEnvcan now fail calls, seeded and deterministic: reads that miss, writes and removals the host refuses, per-call chance or the Nth call on a key. The host model is that a failed read is transient (an immediate re-read succeeds).FAULT_SEEDS,FAULT_OPS,FAULT_RATE); seeds that failed earlier are pinned.What it found. Before the fixes, 219 of 300 seeds broke an invariant. After them, none of 300 at a 6% fault rate, and none of 2000 × 80 ops at 6% or at 20%. The bugs:
Space::delreturned Ok on a miss while the key stayed. It now removes the key anyway, and Ok means gone.Deletedwhile its marker, which names the address, could remain. The retry now finishes it without a prompt.UI items from the same review (each with a test that fails without it):
user.identitiestoo, and a rename with no answer is judged by re-reading the identity.deletehelper re-read).Round 7, continued (live-node failures and the remaining should-fix items):
a51fd85on. The startup load lists identities, then prepares each one (a delegate round trip apiece) before announcing them, so its snapshot could land after a rename. Applying it replaced the entry with the same address, bringing the OLD alias back; the new alias then named no identity, and every draft, sent and settings write under it failed with "no identity" (autosave stuck atpending). A listing now never overwrites an entry the page holds at a newer delegate generation (Identity::upsert; testa_stale_identity_snapshot_does_not_undo_a_rename, red before). A failed autosave now shows asfailedwith a Retry instead of stayingpending(367b056).9571d95,93ccdc9).9099a7d,3dad39a).after_lost_create(904025c).Round 8: keys that stay unreadable, and the UI's identity bookkeeping
Delegate. freenet-core reports a secret it cannot read or decrypt as
"not found", on every read. The fault runs now model that too: one
present key at a time goes persistently unreadable
(
invariants_hold_while_a_key_stays_unreadable). Before the fixes, 34 of300 seeds broke an invariant; after, 0 of 2000 at 6% faults / 2%
blindness and at 20% / 10%.
written or the call fails), and
write_ifnever goes below the recordit replaces.
xm/): an index that reads asabsent once written is Busy, never rebuilt from nothing.
record that reads as absent; a delete removes the record whether or
not it reads; an approved deletion of an unreadable identity runs the
full cascade.
f) or an approved deletion(
d) licenses dropping a recordless entry, and onlydskips thedeletion prompt; a create's leftover marker licenses nothing. Items
get the same pending markers (
pi/).Internal(may have happened), never Busy/Conflict/NotFound.three tries at marking it failed all refused) was kept as a slot leak.
Round 9 frees it (below).
UI.
selecting the second acted as the first).
kept contact instead of deleting it.
resolve the sender by address, and a sender that is not routed is an
error the user sees, with the draft kept.
right; its live test was intermittent because it reloaded the page while
the close's save was still in flight, which ends the save with the page.
Evidence, the test repeated 6 times in CI: reloading at once, 2 of 6
lost the edit; waiting 5 s first, 6 of 6 kept it. A user who closes
compose and reloads at once hits the same window, so draft saves in
flight are now counted page-wide (
pending_saves), published asdata-pending-saveson the document element, and the page asks thebrowser to warn before leaving while any is pending (best effort in a
sandboxed frame). The close flush is built before its task runs, so a
sheet closed at once still flushes. The live test now waits for
data-pending-saves="0"before reloading: 6 of 6 passed with the fix,and 6 of 6 failed with the flush disabled.
a token call actually waits (60 s at most).
Also from round 8:
(X8): the page reclaims once it has seen the current state (a whole
state the node pushed, one fetched after a live delta, which the first
delta asks for, or two different fetched states). Two copies of the
same state count once.
contact run in order, and a save creates the contact again if a
deletion lands between its read and its put.
covers a refused delete on the read-miss path; a comment records the
cost of
delete's confirming re-read.cargo make test-e2e-real-nodetakes extra Playwright options throughE2E_PLAYWRIGHT_ARGS(e.g.--repeat-each=6 --grep=closes.is.saved).Nothing from round 8 is open.
Round 9: getting back from storage that stays unreadable
Round 8 made the delegate safe while a key stays unreadable; round 9
gives the user a way back.
Delegate and protocol (
Op::RepairIndex,FailureCode::Unreadable,appended with golden lines):
keys again (the same address means the same seeds), and can be deleted
after the usual prompt.
RepairIndexresets an index that cannot be read: the identity list,or one kind's list for one identity. It asks every time ("Repair" /
"Cancel"), is namespaced like every op, and is refused while the index
reads. Records stay stored; nothing can list them (the delegate never
lists the host), so identities come back by importing them again.
Unreadable, and acreate replaces it without touching its index entry.
write that does not grow the store is never refused, so a full store
can still be shrunk.
confirmed absent) frees its slot on the next listing. The fault runs
now require zero leaked slots at every rate; the one record allowed to
stay unlisted is the run's unreadable one, which the run restores by
import where it holds the keys.
unreadable-key) found two more, fixed and pinned: a deletion marker
that could neither be read nor removed was reported gone (a key the op
itself wrote must now be confirmed removed), and an index created but
left marked "may be absent" was rebuilt from nothing once it stopped
reading.
UI.
offers "Repair storage" (the consented
RepairIndex), then asks theuser to restore each identity from its recovery key.
deleting an unreadable item works; an unreadable draft still counts as
stored. The user is told once.
onto a contact's name is refused.
key they are stored under).
window; Send/Discard no longer fire a close-save of the removed draft.
Item lists other than the identity list are repairable through the
delegate, but the UI does not offer it yet: it would need to say which
list (drafts, sent, archive...) lost what.
Round 10
importing_keys_restores_a_listed_identity_whose_record_cannot_be_read). The harness now also tries to import an identity whose deletion has started, and fails the run if it comes back.https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
[AI-assisted - Claude]