Repository navigation
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
sanity
marked this pull request as ready for review
October 29, 2026 22:57
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
…mark retry - A draft discarded or sent in another tab is deleted from the delegate while this page may still be editing it. The page-local discard tombstone can't see that, so the next autosave recreated it (a sent draft would reappear and could be sent twice). The page now remembers the drafts it has seen stored (loaded, or saved here); a save that finds one of them missing writes nothing and returns `DraftSave::GoneElsewhere`. The compose sheet then stops autosaving, drops its local copy and says the draft was discarded or sent elsewhere; it can still be sent from the sheet. Test `a_draft_removed_in_another_tab_is_not_brought_back` covers a draft saved here and one only loaded here; red with the check disabled (Stored, expected GoneElsewhere), green with it. - `a_new_identity_under_a_deleted_alias_starts_empty` passed even with `forget_identity` a no-op: once the alias names no identity, `state_key` falls back to the bare alias, which is empty anyway. It now asserts on the old identity's address key; red with `forget_identity` stubbed out, green with it. - A read mark that failed for good (e.g. the identity is being deleted) was re-sent, and reported, on every render of the open message. Renders no longer re-send: the read-mark task retries a transient failure (no answer, Busy, Conflict) at most 3 times with a growing pause, and a failure that remains is reported once. Test `a_failing_read_mark_is_retried_only_while_transient_and_only_a_few_times`; red when a permanent failure is treated as transient (3 tries, expected 1). Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…inish-deleting button - Contacts and identities load in separate tasks, and contacts usually arrive first, so the load's `disambiguate` rarely saw the identities and `register_identity` then silently overwrote a stored contact holding the same alias. `register_identity` now moves such a contact to its clash label (`alias~<address>`, as `disambiguate` labels it) and says so; either load order ends with both present. Test `an_identity_arriving_after_a_same_named_contact_does_not_drop_it` covers both orders; red with the move disabled. - The suggested-alias fallback in `lookup` could still hand a bare alias to a contact that had been moved off it as ambiguous, or pick whichever of two contacts advertising one name matched first. It now resolves only a single match and never one moved to `alias~...`. Test `the_suggested_alias_fallback_never_resolves_an_ambiguous_alias`; red with either check disabled. - "Finish deleting" read its in-flight guard from a thread-local, which a render doesn't track, so the button didn't re-render when a deletion started or ended. The guard is mirrored into a `GlobalSignal` (`FINISHING_VIEW`) the button reads. Test `the_finish_button_state_is_a_signal_that_follows_the_claim` (in a real runtime); red without the sync on claim. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Listing unfinished deletions also drops recordless identity index entries. When that prune could not write the index, the whole `ListDeletingIdentities` call failed, so the UI could not offer "Finish deleting" at all. The prune is now housekeeping: its failure is ignored (it runs again on the next listing, which every startup does), and the listing skips entries it could not drop. The delegate has no log facility, and adding the host logger would add a new WASM import, so the failure isn't logged. Test `a_failed_prune_does_not_fail_the_deletion_listing`: red before the fix (Err(Internal)), green after. A guard mutation restoring the `?` is added to `check-guards-fail.sh`, and the existing prune guard is updated to the new line; `check-guards-fail.sh --no-wasm` passes all 34 checks. Candidate rebuilt with build-wasm.sh; `check-delegate-candidate.sh` confirms it reproduces (code hash ExXRgNTpCFks1S5BpQTonBFKF17vkFb2kGr8AahB46eR). Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
- R5: the startup sequence (start journaling, announce identities, list unfinished deletions, load, merge) is now one helper, `announce_then_merge`, and test `a_change_made_between_the_announcement_and_the_load_is_replayed` saves a draft at the announcement and one meanwhile and checks both survive the merge. Red with the journal started after the announcement (both lost), green as is. - R3: `DeferredRemoval::run` picks its inbox through `pick`, which also turns an inbox that isn't loaded into an error instead of an index panic. Test `a_removal_after_an_identity_switch_mid_wait_goes_to_the_original_inbox` switches identity inside the awaited write passed to `store_then` (as archive does) and checks the removal picks the original inbox. Red when `pick` ignores the bound target. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Live-node test: create an identity, rename it, then under the new alias save a draft and change the thread-view setting; after a reload both are back under the renamed identity. A unit test already covers the owner lookup (`writes_under_a_renamed_alias_still_persist`); this runs the whole path against a real node. Offline can't: its delegate lives in page memory, so a reload starts empty. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Inboxes are kept in the order their state arrived, while the removal picked `inboxes[active_id]`. With two or more identities an archive or delete could therefore go to another identity's inbox, match nothing there (message ids are ciphertext hashes) and leave the message in its own inbox. The removal now binds, when it is deferred, the inbox whose routing owner is the identity shown, as the folder view finds it. The tests list inboxes in arrival order with bob's first. Against the old positional lookup all three fail; with the fix they pass. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
- `address_choice` gives the address (the inbox contract id) of the one recipient a To-field entry names, or none when it names nobody or is ambiguous; `resolve_for_send` sends to the recorded address only and refuses, with a message, when the entry now names a different key or the recorded recipient is gone. - Contacts moved to a clash label are recorded as such, instead of being recognised by a `~` in the label: a contact the user named `bob~work` is an ordinary contact and its suggested alias still resolves. - Moving a contact to its clash label never overwrites an entry that already holds that label; it takes the next free one. Tests (red against the old behaviour, green now): a draft for contact "alice" is refused once an identity takes the name; an unaddressed draft with an ambiguous name asks the user; a removed recorded recipient is refused; a clash label never overwrites; a deliberate `bob~work` contact still resolves by its suggested alias. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…lsewhere - A stored draft carries the address of the recipient chosen for it (`to_address`, next to the v0.2.x draft fields). The compose sheet records it when the recipient is chosen (typing, picking a suggestion, or opening a prefilled sheet) and sends to it only. A draft from older data without one is resolved once when opened; if its name is ambiguous, sending asks the user to choose. - Before sending, the sheet checks that a draft this page saw stored is still in the delegate. If another tab sent or discarded it, the send is refused and the sheet says so; the banner no longer invites a send from here. - Forgetting an identity also forgets the drafts seen for it and their recorded recipients. - The compose sheet exposes `data-autosave="saved"` once the delegate has acknowledged what is on screen; the live-node rename test waits for it instead of sleeping two seconds. Tests (offline build, real in-page delegate; red with the check or the load of `to_address` removed): a draft sent in another tab is refused before sending, a fresh compose is not; a draft's recipient address survives a reload, an older draft loads with none. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Message ids are ciphertext hashes and the in-memory list is in arrival
order, so the single-message branch of remove_received_message, a binary
search by id, missed most messages: an archive or delete left the row in
the model, and offline nothing later cleaned it up. Both branches now
retain by id.
Audited the other ordering assumptions in ui/src: thread groups sort by
time before taking first/last, delegate paging uses the delegate's sorted
index, and the removal payload is sorted on purpose. No other
binary_search, partition_point or id-order assumption remains.
New test a_single_removal_finds_a_message_out_of_id_order: red with the
binary search ("message 0x9f00 is still there"), green with retain.
Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Send now checks the draft with the delegate before sending, a round trip during which a second click started its own check; if both answers landed together the message was sent twice. The first click claims the send (`claim_send`), the button is disabled and reads "Sending…" until it settles, and the claim is released whether the send went out, was refused or failed. The remaining two-tab window (both tabs pass the check within a couple of round trips) is documented at the check, with claim-before-send noted as the way to close it. New test a_second_send_click_while_one_is_in_flight_is_ignored: red with claim_send always granting, green with the guard. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…mbiguous A draft recorded its recipient as their inbox contract id, which depends on the inbox contract's code: a new inbox generation would refuse every saved draft as "no longer in your address book". It now records the recipient's identity address (address_of of the verifying key, `fmail-addr:<bs58>`), and the inbox is resolved from the key at send time. A recorded address that nobody has any more says so; one this build can't parse (or an older contract id whose inbox has moved) says it couldn't work out the recipient. The "now names a different key" check compares keys. The ambiguity refusal applied to anything typed or picked in the To field, so a name could become impossible to send to: your own identity that took a contact's name, or a new contact reusing a name two contacts had clashed on. Typed and picked names now resolve to the entry that holds them. Only a reopened draft that recorded no address and whose name has become ambiguous starts unresolved (`initial_choice`), and sending it asks the user to choose; typing or picking clears that. Also: - disambiguate gives clash labels that can't land on another stored contact's name, an identity's name or a label already given out, so no stored contact is dropped on insert (free_label_by). - removing one of two contacts that clashed on a name gives the other its name back without a reload, unless an identity holds it. Tests, each red against the old behaviour (shown by re-applying it): - a_typed_name_an_identity_took_from_a_contact_is_sendable, a_new_contact_reusing_a_clashed_name_is_sendable (old ambiguity refusal) - a_recorded_recipient_survives_a_change_of_inbox_contract (old contract-id recording) - an_unresolvable_recorded_recipient_says_so - removing_one_of_two_clashed_contacts_frees_the_name, a_clash_label_never_collides_with_another_stored_contact Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
A paged deletion that fails after its first page (or whose outcome is unknown) has already been marked deleting in the delegate, which then hides the identity and refuses to use it. The page kept it in the live identities and the identities list, so the user saw an identity that no longer worked next to its "Finish deleting" entry, and the stale row stayed after finishing until a reload. After a failed deletion, both builds now refresh the unfinished list and, if it names the identity, drop it from the page (live identities, list, signed-in identity, in-page state), by address. Finishing a deletion also drops it from the live identities. The success path uses the same drop_from_page. New test an_identity_whose_deletion_began_leaves_the_page: red when the identity is only listed as unfinished (the old behaviour), green now. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Verifies the claim that the #288 self-heal covers an identity created but never confirmed: create one, reload at the reveal step without pressing Continue, and require that the next load lists it, that the node's NotFound for both its inbox and its token-record contract triggers the re-PUT, that no re-PUT fails, and that a further load finds both instead of re-publishing them. Checked by reading first: startup registers both keys for routing before their GETs (InboxModel::load_all, AftRecords::load_all), and core answers an exhausted GET with ContractResponse::NotFound (op_ctx_task.rs), which reaches the OwnInbox / OwnAftRecord arms. The backup reminder in Settings already shows for an identity with no last_backup_at. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The app now removes a live identity by address; removal by alias is left to the tests that use it, so the lib build does not carry dead code. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…g key; reclaim stale marks Delegate (candidate rebuilt, ELRpWwzc...): - An identity index entry is pruned only when the deletion's own marker (`dl/<addr>`, written just before the record is removed) proves the record is gone. The host cannot tell a failed read from an absent key, so a single miss used to unindex a live private key. The record-gone paths of DeleteIdentity and the no-prompt "finish deleting" check are gated on the marker too. Importing keys that are present but unindexed indexes them again; re-creating an identity clears an old marker. Tests: a_record_that_fails_to_read_is_never_pruned_from_the_index, importing_a_present_but_unindexed_identity_indexes_it_again, a_reimported_identity_clears_its_old_deletion_marker; each has a guard mutation in check-guards-fail.sh. - consent: the empty-answer and decline arms are one arm. UI: - Contacts: a card whose signing key is already stored under another name (contact or own identity) is refused; the send check compares both key halves; a recorded key held by several entries (older data) resolves only to the entry the To field names. Red before: both new tests fail with the old insert and the old first-match pick. - A deleted identity leaves the address book in both builds (drop_from_page). Red before: a_dropped_identity_leaves_the_address_book. - Message marks: once per page and identity, on its first whole inbox state, flags of messages gone for good (not in the state, no kept or archived copy) are deleted, so they no longer pile up to the cap. mark_read writes the mark before the kept copy, so an oversized copy no longer costs the mark (red before: a_read_mark_is_kept_when_the_kept_ copy_is_refused). - A create with no answer re-lists identities: if it exists, the backup step continues with the key from a consented export; otherwise the user is told whether retrying is safe. Never a plain failure. - A node delegate error (no request id) that may be the mail delegate's ends the waiting calls at once as unknown, instead of a 30 s or 10 min wait; errors naming another delegate are ignored. - Discarding a just-created identity that fails tells the user; restoring keys already here says so under the stored name and offers a rename. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…rove the self-heal positively - A removal of more than MAX_REMOVAL_BATCH messages is signed and sent in batches, so no part is refused after the page already hid the messages (a_large_removal_is_signed_in_batches_within_the_limit). - Decryption that keeps failing tells the user the messages are not lost and how to retry, instead of only a log line. - The startup contacts load retries, then reports a failure: an empty address book no longer looks like having no contacts. - Listing unfinished deletions retries and returns `None` when it cannot be asked, never an empty list; after a failed deletion the identity then stays with a message rather than on a guess (an_unknown_deletion_state_keeps_the_identity_and_says_so). - Closing the compose sheet while a send is being checked tells the user the message was not sent and the draft is kept (OnCancel guard, a_send_cut_off_by_closing_the_sheet_is_reported). - live-node: the reveal-step self-heal test now asserts POSITIVELY that the next load finds the inbox (new release-visible "own inbox state loaded" line), reads later lines from a mark instead of discarding earlier ones, and checks for failed re-PUTs last, over the whole test. - Docs: AGENTS.md states CreateIdentity's one-time key return; the optimistic stashes are documented as mattering only to the startup merge; the 63-bit message-id grind is documented as accepted (a sender can only merge the marks of two of their own messages). Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The in-memory host never failed, so no test reached the class of bug every review round kept finding: an outcome the host cannot report (a failed read looks like an absent key; a write or a reply may or may not have landed) treated as success, failure or absence. - MemEnv gains seeded, deterministic faults: reads that miss, writes and removals the host refuses, per-call chance or the Nth call on a key. The host model: a failed read looks like absence but is transient (an immediate re-read succeeds). - tests/faults.rs drives create, import, rename, delete-with-Continue, item put/delete/get/list and identity listings over that host, with lost replies, then heals with faults off and checks: identity records and index entries agree (no unlisted key, no leaked slot), no marker that licenses a prune outlives its create or deletion, deleted identities leave nothing, every stored item is indexed under a stored owner and every entry names a stored item, no acknowledged write is lost or acknowledged delete undone, no generation is handed out twice. 300 seeds x 60 ops by default (FAULT_SEEDS / FAULT_OPS / FAULT_RATE). Before the fixes 219 of 300 seeds broke an invariant (red); after, 0 of 300 at 6%, 0 of 2000 x 80 ops at 6% and at 20%. Failing seeds are pinned. Fixes: - Space reads confirm a miss with an immediate re-read. One missed read of the generation counter reused a generation; of an index, rewrote it without its entries; of a record, let a create overwrite it or a prune unindex a live key. - Space::del removes a key that reads as absent, and `Ok` means gone. - A create writes the "entry may have no record" marker before claiming its index slot, so a create whose write and undo both fail leaves a slot the next listing frees; a stale marker beside a live record is cleaned by that listing. - A deletion answers Deleted only once its marker (which names the address) is gone too; the retry finishes it without a prompt. - Listing items drops index entries whose record is confirmed absent (left by a failed rollback), so a kind's cap cannot leak. - Dependencies build at opt-level 2 in dev/test (the runs generate keys thousands of times); the release WASM is unaffected. Guards: four new mutations against the fault runs, one against a store unit test (a delete removes a key whose reads fail); two mutations whose code moved are re-targeted. The candidate is rebuilt and reproduces. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
- `User::logged_id` indexed `identities` by the UserId and `set_logged_id` asserted it was a valid index, but ids come from a global counter (and an offline create used the list length), so after any identity left the middle of the list the wrong identity was selected or the page panicked. Identities are now found by id; an id no identity carries selects nothing. Ids start above the example data's fixed 0 and 1, and every new identity takes one from the counter. Test: remove the middle of three, select the others (fails on the positional lookup). - Identities in `user.identities` and the alias list are matched by address, not alias, when the delegate's list is applied: a rename changed the alias, so the old match missed. - One rename path for both builds (`identity_store::rename_identity`): on success it relabels the alias list, the inbox model, the identity in `user.identities` (which nothing updated before, so the open identity kept its old name) and the generation. A rename with no answer is judged by re-reading the identity: renamed, not renamed, or "may have been renamed; reload", never a plain "Couldn't rename". Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Listing items now frees an index entry whose record is gone, so the interrupted-delete test, which listed before retrying, passed even with the retry's own cleanup removed (the guard script caught it: the guard passed with the regression applied). The retry is now checked before any listing, and the listing's cleanup has its own test and guard. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The marks of messages gone for good were reclaimed on the first whole inbox state of a page, which can be stale or empty (a node-cached copy, a state just re-published by a migration or self-heal): every deleted or read mark of a live message missing from it was dropped, and the message came back. It also judged liveness from a snapshot taken before its listings, so a mark written meanwhile could go. - A message is live if ANY whole state this page saw held it. Empty states are not counted, and nothing is reclaimed until a second non-empty state has arrived. - Liveness is asked when each mark would go, so a state that arrives during the reclaim counts. - Marks written after the reclaim began are left alone (each mark write is counted before it is sent). - load()'s doc comment is back on load(). Tests: SeenStates (empty, first-only, union); a reclaim racing a mark write and a newly live message keeps both. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
A draft this page saw stored and then finds absent is treated as sent or discarded in another tab: the send is refused, autosave stops and the page drops it. That conclusion rested on a single read. The delegate now confirms a host miss itself; the send check and the autosave also re-read before believing it, so a failed read cannot cost the user a draft or a send. The in-page delegate's host gains a test hook that makes the next reads of a key miss. Test: two misses (enough to make one delegate answer "absent") neither refuse the send nor stop the save; fails with the send check's re-read removed. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
… consent calls A delegate error the node reports carries no request id, and execution or secret-access errors name no delegate either. Each such error ended every waiting mail call, even when the token generator (on the same connection) was the one that trapped, and even calls holding a consent prompt open, whose user's later answer then went to no one. - An error naming no delegate is attributed to the token generator while one of its requests is waiting, and ends no mail call then. - When it does end mail calls, calls that may be holding a consent prompt (exports, identity deletion) run to their own timeout. Tests: a held consent call keeps waiting while others end; a trap with a token-generator request in flight is not the mail delegate's. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…either
The startup load inserted stored contacts through the import check that
refuses a second entry for one signing key, so of two such contacts the
one loaded second vanished, only logged, and which one depended on load
order. If the forged card survived, a draft recorded for the real
address resolved to it and went to its encryption key.
- The load keeps every stored contact (`load_contact`); a new import
still refuses a second entry for a signing key.
- Two entries carrying one signing key with different encryption keys
are a conflict: `resolve_for_send` refuses to send to that key by any
name or recorded address ("one may be forged; verify, then delete the
wrong one"), until one is deleted. The load warns once. Same keys under
two names are no conflict.
- A stored contact the load still refuses (it carries one of your own
identities' keys) is now reported, not only logged.
This supersedes round 6's "settle by the name typed", which could still
pick the forged card; its test now pins the refusal.
Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
- delete_message removes the kept and archived copies before it marks the message deleted: a copy that failed to go no longer survives beside the mark, holding content the user asked to remove and pinning the mark against reclamation. A failure leaves the message visible and the deletion retryable. - The test for that found a second bug: the item `delete` helper took one "absent" answer as "already gone" and returned Ok while the item stayed. It now needs a second read to agree. Test: with the kept copy's reads failing, no copy survives beside a deleted mark (fails on either old behaviour). Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…d autosave says so Root cause of the intermittent live-node failure "a renamed identity's draft and settings persist across reload" (autosave stuck at `pending`): - The startup load lists the identities, then prepares each one (a delegate round trip apiece; the shared test node holds every earlier test's identities) before announcing them. A snapshot read after an identity was created could therefore land after the user renamed it. - `Identity::set_identities` (and `set_alias`) replaced the entry with the same address, so the snapshot's OLD alias came back in the alias list and in `user.identities`. The new alias then named no identity, `state_key` fell back to the bare alias, and every draft, sent and settings write under it failed with "no identity". Matching by address arrived in a51fd85, which is where the failures start (3 of 4 e2e runs since). - Both now go through `Identity::upsert`, which keeps an entry the page holds at a newer delegate generation (every rename bumps it) instead of overwriting it with an older record. Test `a_stale_identity_snapshot_does_not_undo_a_rename`: red before (the new alias resolved to nothing), green after; a newer snapshot still applies. A failed autosave also no longer leaves the compose sheet silently `pending`: `data-autosave` becomes `failed` and the sheet says the draft isn't saved, with a Retry (`a_failed_autosave_is_shown_as_failed_not_pending`). Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Root cause of the intermittent "an identity left at the reveal step gets
its contracts on the next load" failure ("after the self-heal the contracts
are found, not re-published again", adcac88 and ea51a92): the test waited
for the "not found on the network … re-PUTting to self-heal" lines and
reloaded at once. Those lines are logged before the PUT is even sent
(`create_contract` returns once the request is queued), so a reload that
beat the node's store cancelled it, and the next load rightly found
nothing and self-healed again. The app was correct; the test's ordering
assumption was not.
- The UI now logs a release-visible "own <inbox|AFT record> re-published
(key=…, alias=`…`) — self-heal stored (#288)" line when the node
acknowledges a self-heal PUT (tracked by instance id in
`SELF_HEAL_PUTS`).
- The test waits for both acknowledgements before the second reload. The
assertions after it are unchanged.
Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…mits, keep a failed publish Round-7 should-fix items 9, 11, 12 and 14 (#366 review). - 9: the startup identity listing and local-state load are retried (`with_retries`, 3 retries with growing pauses: a cold node often times out its first call) and, if they still fail, say to reload. An unknown unfinished-deletions listing at startup is reported instead of leaving "Finish deleting" silently missing. Test: `a_startup_load_is_retried_then_reported_with_a_reload_hint`. - 11: a lost create that is not listed yet is looked for again after a pause before it counts as "not created" (`find_lost_create`). Test: `a_lost_create_listed_late_is_still_found`. - 12: a write refused at a storage limit (contacts, or sent/kept/archived messages) says the limit was reached and that deleting makes room, in the contact and local-state failure toasts (`cap_reached_text`). Test: `a_full_address_book_is_reported_as_a_reached_limit`, which also pins the `CallError` text it reads. - 14: a failed publish of a new or restored identity no longer leaves it off the page until a reload: it is announced as loaded, so the load's GETs find its contracts missing and the #288 self-heal re-publishes them, and the user is told it is saved and being set up (`publish_or_recover`). Test: `a_failed_publish_says_the_identity_is_kept`. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Round-7 nit 16: the closing "not re-published again" check ran once the inbox was found, so a late answer for the AFT record could still log a self-heal after it. The UI now logs a release-visible "own AFT record loaded (key=…, alias=`…`)" when the record's state arrives, and the test waits for both it and "own inbox state loaded" before the check. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…s them Round-7 should-fix item 15. `OPTIMISTIC_SENT`, `PENDING_KEPT` and `OPTIMISTIC_ARCHIVED` held a full copy of every sent, read and archived message until a snapshot echo contained it, and with the mail delegate the only echo is the startup load, so they held them for the whole session. They guard against an older snapshot landing over the page's writes, which the startup load's journal now covers (every change made while it runs is replayed), so an acknowledged write needs no copy: `save_sent`, `mark_read` and `archive_message` drop theirs on success. Test `acknowledged_writes_drop_their_optimistic_copies` (offline build, real in-page delegate): fails with the drains removed (all three copies still held), passes with them. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Round-7 item 10. The delegate refuses a second live identity with the same alias atomically, so the identity `after_lost_create` finds can only be someone else's create if another tab of the same app created that same alias first; the user is then shown their own identity of exactly that name through the consented export. A per-create nonce would need a protocol and delegate change for that residual, so the reasoning is recorded at the call site instead. Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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).https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
[AI-assisted - Claude]