Skip to content

feat: switch the UI to the unified mail delegate - #366

Open
sanity wants to merge 76 commits into
mainfrom
ui/mail-delegate
Open

sanity wants to merge 76 commits into
mainfrom
ui/mail-delegate

Conversation

@sanity

@sanity sanity commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

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:

  1. Release freeze (30066eb). The migration from the v0.2.x delegates lands in the next PR of this chain, so .release-freeze makes scripts/release.sh refuse to run until it does; scripts/release-freeze_test.sh (in CI) shows the refusal.

  2. Client (7b0cba3, ui/src/mail_delegate/).

    • Request ids are random, and each call waits for the one response carrying its id. A call with no answer is Unknown, never "absent".
    • Two backends:
      • The node. It registers the candidate delegate once per page load with the pinned v0.2 cipher/nonce, then sends.
      • Offline. The delegate's own code runs in the page over an in-memory store, so offline tests exercise the real protocol rules.
  3. Typed operations (d1c1ae2, mail_delegate::ops).

    • Identities: create/import/update/confirm, plus paged delete, which repeats on Continue without asking again.
    • Items: paging, and a read-merge-write that retries a lost compare-and-swap.
    • v1 crypto: decrypt, seal, authority and removal signing (hashes ascending), and the v1 AFT parameters.
    • Exports, and grant revocation.

    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.

  4. 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.

    • One Contact item per contact, keyed by address.
    • The address book updates immediately, and writes persist from a spawn_forever task; a refused or unanswered write shows a toast.
    • Contacts load at startup.
  5. Revoke export permission (a5b461b). Settings → Backup & restore gets "Remembered export permission", which sends RevokeExportGrant.

    • Fixes toasts pushed from detached tasks: push_toast uses use_context, so from a spawn_forever task it panicked silently. push_toast_detached uses the root's queue.
    • ui/tests/export-permission.spec.ts runs it against the in-page delegate; it failed before the fix.
  6. Identities and crypto in the delegate (0ffc1fe). The page holds no private key.

    • Sending seals once in the delegate; the assignment hash is taken from that same ciphertext, which is the one stored.
    • Inbox states are decrypted by the delegate in a task (never in node_comms, which delivers the delegate's answers). Whole-state updates keep what is already decrypted; a removal that lands mid-decryption wins.
    • Removals (ascending hashes), settings changes, and new, self-healed or migrated inbox states are signed by the delegate. A migrated inbox advertising another encryption key is no longer re-signed (the delegate signs only with its own key); it is reported instead.
    • Delegate work that the node loop needs (listing identities, migration markers, token-generator parameters, initial inbox state) runs in tasks that hand results back as NodeActions (app/identity_store.rs).
    • Migration markers (Deliberate inbox/AFT WASM bump → per-identity state migration (future) #213/Inbox migration: improvements for robustness #251) live in each identity's IdentitySettings item; the loader records them before any migration GET is dispatched.
  7. Identity lifecycle (0ffc1fe, 27857d7).

    • Create: the delegate creates the identity and returns its recovery key once (Reply::CreatedIdentity, golden line appended, guard mutation in check-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).
    • Restore (key or JSON) is ImportIdentity{UserRestore}; rename is UpdateIdentity; delete loops on Continue.
    • Backup modal and Settings reveal the key through the consented ExportRecoveryKey (ExportedKeyPanel); the key lives only in that panel's resource.
    • Offline builds seed the example identities in the in-page delegate.
  8. 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.

    • Received-message ids are blake3(ciphertext)[..16], so marks stay on the right message across reloads and reordering. Drafts and sent use their UUID bytes.
    • Deviation: the UI keeps a u64 message 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.
    • Each write returns on the delegate's acknowledgement. The WriteTracker count and the old delegate's empty-response-as-ack handling are gone; "Deleting…" / "Archiving…" resolve on the write's own answer, and the write is never raced against a timer (dropping it could leave a multi-step write half done).
    • Settings merge field by field, nested, over compare-and-swap, so two tabs changing different settings keep both.
    • Local state loads after the identities are announced, outside the node loop, and is merged into what the page already holds (see 12); delivery-state writes triggered from the node loop run detached (awaiting the delegate there would deadlock).
  9. 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.

  10. Deletion progress and detached toasts (1dd39a8). Deleting a large identity shows "Deleting identity x… 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 of use_context, which panics outside a component.

  11. 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 UI log::info line goes to the browser console only, never the gateway log.)

  12. Startup load, id partition, storybook (ba8092c). Three regressions from 8 and 6:

    • Persistence layer: sent/deleted messages don't survive refresh or session #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, dropping one created in between. 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.
    • Reply fails when recipient added sender under a different local nickname #289 (live node: "Add to address book" never shown). Hash ids sat above 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).
    • Offline storybook. 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.
  13. 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 test also 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.

  14. Review round 1 hardening (1dda377..8f39920):

    • Releases: every production publish path (release.sh, publish-email, publish-container, publish-production) runs scripts/check-release-freeze.sh first. The freeze test never runs a release.
    • Archive: the inbox removal waits for the archived copy's acknowledgement.
    • Key material: loading fetches none. Token-generator delegates are registered at first use. Recovery keys and restored seeds are held in wiped memory, and restore input is size-capped.
    • Correctness: rename takes the delegate's ack, decryption retries entries past the reply budget, consented calls outwait the delegate's prompt, and unreadable migration markers are never overwritten.
    • Concurrency: a failed or cancelled delegate registration is retried, a racing write never undoes a delete, read marks are sent once, and an early delivery state is kept. A deleted identity's cached state doesn't pass to a new identity under the same alias.

Status: in review

Transitional exposure until step C: V1AftParams returns 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 stdlib DelegateParameters/Parameters and 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:

  • Startup merge: every change to the local-state snapshot goes through one 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.
  • Interrupted deletion: 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.
  • Offline coverage: the in-page delegate runs with 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).
  • Hardening: a dropped call's waiter is removed and a reply with no waiter is not decoded; local-delegate with use-node is a compile error.
  • Not changed: the local-state cache stays keyed by alias. Re-keying it by address would change the frozen wire-v1 LocalState shape and about 20 accessors; delete and rename already move or drop the cached state, and the startup merge now honours both.
  • No change needed, now tested: an identity created but never confirmed (the tab closed in between) has its inbox and AFT record re-published by the existing own-contract self-heal on the next load (First send to a recipient hangs recipient node, all contract GETs fail #288): the node answers the load's GETs NotFound and the own-contract arms re-PUT them. A live-node test reloads at the reveal step and checks this (round 5). Its token-generator delegate is registered on first send, and Settings shows the "not backed up" banner until its key is saved.

Deferred to B3: the confirm-identity prompt for unconfirmed identities before send/publish (ops::confirm_identity is ready; only the migration produces unconfirmed identities).

Deferred to C: the AFT token-generator delegate's registration handling (token_generator_management and the v0.2.x identity-creation plumbing it drives). The node's empty answer to its RegisterDelegate is a real answer, and it all goes with the anti-flood tokens.

Review round 2 fixes (9c59c89..778c689):

  • Identity-scoped local state, item owners and the early-delivery stash are keyed by the identity's address, not its alias. Writes after a rename persist, and finishing an old identity's deletion never touches a new identity with the same alias. Rename no longer moves state.
  • A contact is persisted only after the address book accepts it. Stored contacts whose aliases clash, with each other or with your identity, all load as alias~<address prefix> with a warning.
  • An archive's inbox removal targets the inbox it was made in, even if you switch identity while the archive is being confirmed.
  • Decryptions from a superseded inbox state are dropped, in-flight decryptions are not repeated, and per-message failure codes are kept: unreadable messages are not asked for again, and a failed call is retried with backoff.
  • Journaling starts before identities are announced, so changes made while unfinished deletions are listed are replayed.
  • A late autosave or read mark never recreates a discarded draft or a deleted message's kept copy. This uses the page's discard tombstone and the stored deleted flag (the flag covers read marks in other tabs; drafts across tabs are covered in round 3). Delegate tombstones were not added, because they need their own pruning index.
  • An identity-index entry left by a half-finished deletion is pruned when deletions are listed and before a new identity is stored.
  • "Finish deleting" runs one attempt at a time. (Read-mark retries are reworked in round 3.)
  • push_toast from a spawn_forever task (Dioxus root scope) falls back to the root queue instead of asserting.
  • An unanswered prompt: the node's empty answer at its 60 s timeout already ends the waiting call. It is now reported as unanswered rather than declined.

Review round 3 fixes (778c689..4790208), each with a test shown failing without it:

  • A draft discarded or sent in another tab is not brought back by this page's autosave (a sent draft could otherwise reappear and be sent twice). The page remembers the drafts it has seen stored; a save that finds one missing writes nothing, and the compose sheet stops autosaving and says so.
  • A read mark is never re-sent by renders. The read-mark task retries a transient failure (no answer, busy, conflict) up to 3 times with backoff; a failure that remains is reported once.
  • An identity arriving after a stored contact with the same alias moves the contact to its clash label instead of overwriting it, whichever loads first. The suggested-alias fallback no longer resolves an ambiguous alias (a relabelled contact, or two contacts suggesting one name).
  • "Finish deleting" re-renders when a deletion starts or ends: its state is a signal.
  • A failed prune of recordless identity-index entries no longer fails ListDeletingIdentities; the prune runs again on the next listing. Delegate change: guard mutation added, candidate rebuilt.
  • Tests that were missing or vacuous: forgetting a deleted identity is asserted on its address key; the startup journal order (announce_then_merge); the archive removal target through store_then with 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:

  • An archive's or delete's inbox removal finds the inbox by the identity that owns it, as the folder view does, never by its position in the inbox list (which is the order inbox states arrived). With two or more identities the old lookup could pick another identity's inbox, match nothing there, and leave the message in its own inbox. The tests list inboxes in arrival order with the other identity's first.
  • A draft records the address of the recipient chosen for it (since round 5, the recipient's identity address rather than their inbox contract id), and is sent to that address only. If the To field has since come to name a different key (an identity created later under a contact's name) or the recorded recipient was removed, the send is refused with a message and the user chooses again. A draft from older data without a recorded address is resolved once when opened; an ambiguous name asks the user.
  • Before sending, a draft this page saw stored is checked against the delegate: if another tab sent or discarded it, the send is refused, so it cannot be sent twice. The banner no longer offers to send it from here.
  • Relabelled contacts are tracked explicitly, not recognised by a ~ in the label (a contact named bob~work is an ordinary contact); a clash label never overwrites an entry that already holds it; forgetting an identity forgets its seen drafts and recorded recipients.
  • The live-node rename test waits for the sheet's 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:

  • One send at a time per compose sheet. Send checks the draft with the delegate first, and a second click during that round trip could send the message twice. The first click claims the send; the button is disabled and reads "Sending…" until it settles. Two tabs sending the same draft within a couple of round trips can still both pass the check; that window is documented at the check, with claim-before-send (a compare-and-swap delete, undone if the send fails) as the way to close it.
  • Typed or picked names are never refused as ambiguous. The refusal applied to fresh typing and autocomplete picks, so a name could become impossible to send to (your identity that took a contact's name; a new contact reusing a name two contacts had clashed on). They now resolve to the entry that holds the name. Only a reopened draft with no recorded address whose name has become ambiguous starts unresolved, and sending it asks the user to choose.
  • Drafts record the recipient's key, not their inbox contract. The inbox contract id depends on the inbox contract's code, so a new inbox generation (step C) would have refused every saved draft. A draft now records the recipient's identity address (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.
  • Messages are removed by id without assuming the list is sorted. A single removal binary-searched a list kept in arrival order with hash ids, so most archives and deletes left the row in the model. Audited the other ordering assumptions in ui/src: thread groups sort by time first, delegate paging reads a sorted index, and the removal payload is sorted on purpose.
  • An identity whose deletion began leaves the page. After a deletion that failed past its first page, the delegate hides and refuses the identity; both builds now drop it from the page (by address, signing it out if open) when it is listed as unfinished, so only "Finish deleting" remains and nothing is left after finishing.
  • Clash labels can't land on another stored contact's name, an identity's name or a label already given out (a contact was silently dropped on insert). Removing one of two contacts that clashed on a name gives the other its name back without a reload, unless an identity holds it.

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.sh covers 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 in cargo 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:

    • a startup load replaces only the identities it read and keeps a settings change made meanwhile (red against the wholesale replace);
    • received ids stay below the folded-row range (red without the mask);
    • a thread is ordered by time even with anti-chronological ids;
    • a large identity reports what is left after each page until it is gone (red when progress is not reported).
  • 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.ts passes 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, including the_recovery_key_comes_back_on_creation_only; protocol golden test covers reply_created_identity.

  • Candidate WASM (now 7fLRKWh2hrQkNctnDzBnVUxzn4nQcwcZ3NgJ3jN7L3mQ); check-delegate-candidate.sh, check-frozen-artifacts.sh, check-frozen-immutable.sh and check-guards-fail.sh pass.

  • 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):

  • MemEnv can 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).
  • Seeded runs drive create, import, rename, delete-with-Continue, item put/delete/get/list and the identity listings, with lost replies. They then heal with faults off and check:
    • identity records and index entries agree (no unlisted key, no leaked slot);
    • no marker that licenses a prune outlives its create or deletion;
    • a deleted identity leaves nothing;
    • every item is indexed under a stored owner, and every entry names a stored item;
    • no acknowledged write is lost and no acknowledged delete undone;
    • no generation is handed out twice.
  • The default is 300 seeds × 60 ops (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:

  • A missed read could destroy data. 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. The store now confirms every miss with an immediate re-read.
  • Space::del returned Ok on a miss while the key stayed. It now removes the key anyway, and Ok means gone.
  • A create whose write and undo both failed leaked an identity slot for good. The "entry may have no record" marker is now written before the slot is claimed.
  • A deletion answered Deleted while its marker, which names the address, could remain. The retry now finishes it without a prompt.
  • An item index entry whose record was gone held a cap slot. Listing now frees it.

UI items from the same review (each with a test that fails without it):

  • Identities are selected by id, not by position, so removing one from the middle no longer selects the wrong identity or panics.
  • A rename now reaches user.identities too, and a rename with no answer is judged by re-reading the identity.
  • Message marks are reclaimed only after a second non-empty inbox state, against every message any state held, and never for a mark written mid-reclaim.
  • One lost read no longer makes a draft "gone elsewhere" (the send check, autosave and the item delete helper re-read).
  • A delegate error naming no delegate is the token generator's while one of its requests is in flight, and never ends a call holding a consent prompt open.
  • Stored contacts sharing a signing key are all kept on load, and mail to that key is refused until the user deletes the wrong one. This supersedes round 6's "settle by the name typed".
  • A deleted message's copies go before its "deleted" mark.

Round 7, continued (live-node failures and the remaining should-fix items):

  • "A renamed identity's draft and settings persist across reload" failed 3 of 4 e2e runs from a51fd85 on. 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 at pending). A listing now never overwrites an entry the page holds at a newer delegate generation (Identity::upsert; test a_stale_identity_snapshot_does_not_undo_a_rename, red before). A failed autosave now shows as failed with a Retry instead of staying pending (367b056).
  • "An identity left at the reveal step gets its contracts on the next load" failed twice. The test reloaded as soon as the self-heal was logged, which happens before the PUT is sent, so a reload could cancel it and the next load rightly self-healed again. The UI now logs when the node acknowledges a self-heal PUT, and when it loads the AFT record; the test waits for both acknowledgements, then for both contracts to be found, before its checks (9571d95, 93ccdc9).
  • Startup loads (identities, local mail state) retry, then say to reload; an unknown unfinished-deletions listing is reported. A lost create not listed yet is re-checked before "not created". A reached storage limit is named in the failure toast. A failed publish keeps the identity on the page and lets the self-heal publish its contracts. Acknowledged writes drop their optimistic copies of message bodies (9099a7d, 3dad39a).
  • Not changed: a lost create is still matched by alias. The delegate refuses duplicate live aliases atomically, so the only mismatch is another tab of the same app creating that same alias first, and the user is then shown their own identity of that name through the consented export. The reasoning is at after_lost_create (904025c).

https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns

[AI-assisted - Claude]

sanity added 17 commits October 29, 2026 14:48
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
sanity marked this pull request as ready for review October 29, 2026 22:57
sanity added 12 commits October 29, 2026 18:10
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
sanity added 30 commits October 29, 2026 20:50
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant