Skip to content

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

Merged
sanity merged 124 commits into
mainfrom
ui/mail-delegate
Oct 30, 2026
Merged

sanity merged 124 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).

Round 8: keys that stay unreadable, and the UI's identity bookkeeping

Delegate. freenet-core reports a secret it cannot read or decrypt as
"not found", on every read. The fault runs now model that too: one
present key at a time goes persistently unreadable
(invariants_hold_while_a_key_stays_unreadable). Before the fixes, 34 of
300 seeds broke an invariant; after, 0 of 2000 at 6% faults / 2%
blindness and at 20% / 10%.

  • The generation counter is kept in two copies (read as the larger, both
    written or the call fails), and write_if never goes below the record
    it replaces.
  • Every index has an existence marker (xm/): an index that reads as
    absent once written is Busy, never rebuilt from nothing.
  • A create never writes over, or rolls back the index entry of, a listed
    record that reads as absent; a delete removes the record whether or
    not it reads; an approved deletion of an unreadable identity runs the
    full cascade.
  • Markers are typed. Only a failed create (f) or an approved deletion
    (d) licenses dropping a recordless entry, and only d skips the
    deletion prompt; a create's leftover marker licenses nothing. Items
    get the same pending markers (pi/).
  • A failure after an op already wrote something is reported as
    Internal (may have happened), never Busy/Conflict/NotFound.
  • A recordless entry under a create's marker (record write, rollback and
    three tries at marking it failed all refused) was kept as a slot leak.
    Round 9 frees it (below).

UI.

  • New and imported identities get page-unique ids (all were id 0, so
    selecting the second acted as the first).
  • Resolving a contact key conflict rewrites the one stored record to the
    kept contact instead of deleting it.
  • A deleted identity is unrouted and its page bookkeeping cleared; sends
    resolve the sender by address, and a sender that is not routed is an
    error the user sees, with the draft kept.
  • Closing the compose sheet saves its last edit (X7). The close fix was
    right; its live test was intermittent because it reloaded the page while
    the close's save was still in flight, which ends the save with the page.
    Evidence, the test repeated 6 times in CI: reloading at once, 2 of 6
    lost the edit; waiting 5 s first, 6 of 6 kept it. A user who closes
    compose and reloads at once hits the same window, so draft saves in
    flight are now counted page-wide (pending_saves), published as
    data-pending-saves on the document element, and the page asks the
    browser to warn before leaving while any is pending (best effort in a
    sandboxed frame). The close flush is built before its task runs, so a
    sheet closed at once still flushes. The live test now waits for
    data-pending-saves="0" before reloading: 6 of 6 passed with the fix,
    and 6 of 6 failed with the flush disabled.
  • Keyless node errors are attributed to the token generator only while
    a token call actually waits (60 s at most).

Also from round 8:

  • Stale message marks are reclaimed when updates arrive only as deltas
    (X8): the page reclaims once it has seen the current state (a whole
    state the node pushed, one fetched after a live delta, which the first
    delta asks for, or two different fetched states). Two copies of the
    same state count once.
  • A settings change that removes a map key persists the removal (X10).
  • Removing a contact and adding it back keeps it (X11): writes for one
    contact run in order, and a save creates the contact again if a
    deletion lands between its read and its put.
  • Nits: the conflicting-keys toast says sending is refused; a new test
    covers a refused delete on the read-miss path; a comment records the
    cost of delete's confirming re-read.
  • cargo make test-e2e-real-node takes extra Playwright options through
    E2E_PLAYWRIGHT_ARGS (e.g. --repeat-each=6 --grep=closes.is.saved).

Nothing from round 8 is open.

Round 9: getting back from storage that stays unreadable

Round 8 made the delegate safe while a key stays unreadable; round 9
gives the user a way back.

Delegate and protocol (Op::RepairIndex, FailureCode::Unreadable,
appended with golden lines):

  • An identity whose record cannot be read is restored by importing its
    keys again (the same address means the same seeds), and can be deleted
    after the usual prompt.
  • RepairIndex resets an index that cannot be read: the identity list,
    or one kind's list for one identity. It asks every time ("Repair" /
    "Cancel"), is namespaced like every op, and is refused while the index
    reads. Records stay stored; nothing can list them (the delegate never
    lists the host), so identities come back by importing them again.
  • A listed item that cannot be read is reported as Unreadable, and a
    create replaces it without touching its index entry.
  • Deletion bookkeeping may use a small reserve past the budget, and a
    write that does not grow the store is never refused, so a full store
    can still be shrunk.
  • A create that never finished (only its marker left, the record
    confirmed absent) frees its slot on the next listing. The fault runs
    now require zero leaked slots at every rate; the one record allowed to
    stay unlisted is the run's unreadable one, which the run restores by
    import where it holds the keys.
  • A wider sweep (2000 seeds x 80 ops at 6%, 10% and 20%, transient and
    unreadable-key) found two more, fixed and pinned: a deletion marker
    that could neither be read nor removed was reported gone (a key the op
    itself wrote must now be confirmed removed), and an index created but
    left marked "may be absent" was rebuilt from nothing once it stopped
    reading.

UI.

  • When the identity list keeps reading as unreadable, Your identities
    offers "Repair storage" (the consented RepairIndex), then asks the
    user to restore each identity from its recovery key.
  • Unreadable settings load as defaults and the next save replaces them;
    deleting an unreadable item works; an unreadable draft still counts as
    stored. The user is told once.
  • A rename re-keys the identity's own address-book entry, and a rename
    onto a contact's name is refused.
  • A draft discarded during the startup load keeps its tombstone.
  • The startup contact load keeps contacts the page changed meanwhile.
  • Delivery states waiting for a Sent item are forgotten by address (the
    key they are stored under).
  • A late answer to an expired token call no longer ends a newer call's
    window; Send/Discard no longer fire a close-save of the removed draft.

Item lists other than the identity list are repairable through the
delegate, but the UI does not offer it yet: it would need to say which
list (drafts, sent, archive...) lost what.

Round 10

  • Sends across a reload (Persistence layer: sent/deleted messages don't survive refresh or session #286): a queued send now counts as unfinished page work until its inbox update is answered, failed or swept, so leaving the page warns. Sent rows that an earlier page session left Pending, with nothing in this page waiting on them, are marked failed after the startup load, with one notice that they may still have arrived. They are not resent: re-sealing makes a new ciphertext, so a message that did go out would arrive twice.
  • Deleting an identity: an approved deletion records its approval before it touches the record, so a restore or migration import can't undo it if the record stops reading mid-deletion. Only a deletion marker counts as approval, so a create's leftover marker can't make an identity undeletable. A list that can't be read no longer blocks the deletion: it is forgotten, and everything else is removed, keys included.
  • Unreadable data: a contact that can't be read can be replaced and removed. One of an identity's lists that can't be read loads as empty, and "Your identities" offers to repair it; the consent prompt names the list and its owner. Migration markers that can't be read stay unknown and are never written back.
  • Contacts during the startup load: a write queued behind another for the same contact is journalled when it is made, so the load can't bring back a removed contact.
  • Store: a key that reads as absent gets three removal attempts before its delete counts as done (fault seed 1284 had a deleted contact come back). A key that can't be read and whose removal is refused every time can't be told from an absent one through the host API.
  • Fault check: the one record allowed to be stored but unlisted is the record that was unreadable during the run. That is intended: an unreadable record can't be told from an absent one, and importing its keys restores it (asserted by importing_keys_restores_a_listed_identity_whose_record_cannot_be_read). The harness now also tries to import an identity whose deletion has started, and fails the run if it comes back.
  • Guards: new guards for each round-10 delegate fix, and a restored guard for failed-create slot reclamation.

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 11 commits October 30, 2026 05:40
…age state

- Y6: a rename re-keys the identity's own address-book entry (the new
  name sends to it, the old one no longer does), and renaming onto a
  contact's name is refused. The page's list takes the new name first;
  if it refuses, nothing else is relabelled and the rename is reported
  as unknown.
- Y7: the startup merge keeps draft tombstones it cannot confirm (the
  journal already replayed the discard), so a late save of a draft
  discarded during the load is still refused.
- The startup contact load is journalled: a contact saved or removed
  while it ran keeps the page's state (a removal is not brought back).
- Delivery states waiting for a Sent item are forgotten by the deleted
  identity's address, the key they are stored under (by alias it matched
  nothing).
- Token-call answers come in send order: a late answer to an expired call
  no longer ends a newer call's window.
- Send and Discard also move the close-flush's edit marker on, so closing
  the sheet does not fire a save of the draft just removed. (No test: the
  discard tombstone already turned that save into a no-op, so nothing
  observable changes.)

Tests, each red with its fix removed:
- a_rename_moves_the_address_book_entry_to_the_new_name
- a_draft_discarded_during_the_startup_load_keeps_its_tombstone
- a_contact_changed_during_the_startup_load_keeps_the_pages_state
- a_late_answer_to_an_expired_token_call_does_not_end_a_newer_one
- a deleted identity's bookkeeping test now keys delivery states by
  address, as save_sent does (it seeded alias keys, mirroring the bug)
Plus a_contact_name_is_not_free_for_an_identity.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…ranch

The index's first present read is the failed add's own undo, so blinding
it from the third read left the branch under test reading it fine: the
test passed with or without the fix (CI's guard run caught it). Blinded
from the second read, it fails with the old branch and passes with the
fix. Also formats recovery.rs, which CI's rustfmt step rejected.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The live-node suite runs with retries: 0, so the config's
trace: "on-first-retry" never fires, and only some tests attach a console
listener. A failure that happens only in CI left nothing to diagnose. Every
context a test opens (the fixture's and browser.newContext()'s) now records
console lines, page errors and a trace, attached on failure only.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
One test starts its own traces, so starting another on every context
failed with "Tracing has been already started". On failure every page now
attaches a screenshot and the app frame's HTML, alongside the console.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Body-only attachments are kept by a reporter, and CI uploads only
test-results/, so the screenshots and DOM never reached the artifact. They
are now written as files. The page also logs the state key a Sent row is
kept under, so a Sent folder that shows nothing can be told apart from a
row that was never written.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
A failing #286 run showed the app restarting right after Send, with no
console line saying why. The evidence now includes each frame navigation
with its URL, renderer crashes, dialogs and page closes.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
A failing #286 run reloaded right after Send, while the Sent copy's write
was still in flight; the reload ended the task and the copy was gone after
reload. Only draft saves were counted as pending, so the page could not
hold the reload off and the test could not wait for it.

Every item write (put, merged put, delete) now holds a pending guard until
the delegate answers, and Send counts the send and the Sent save from the
moment they are scheduled, before their tasks first run. The send is
counted until its token is requested; the inbox update after the token's
answer is not. The live-node evidence now also snapshots each page when
its context closes, since tests close theirs before afterEach runs.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The test reloaded within a second of Send; when the Sent copy's write was
still in flight the reload ended it, and the copy was missing afterwards.
That loss is real for a user who reloads that fast, so the test now waits
for the page's own pending-write count instead of racing it.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Listening for dialogs turns off Playwright's own handling, so the
evidence listener left the app's leave-page prompt unanswered and every
reload with a write in flight hung. It now lets that prompt through and
dismisses others, as Playwright does with no listener.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The #286 live-node test sometimes finds the view back on Inbox after
clicking Sent, with no navigation or error. The folder lives in
UserInbox's context, so a remount resets it. These lines show when the
view mounts, why app() switched branch, and every folder click. The
folder buttons also get stable keys.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…286)

A rare failure left the view on Inbox after the click, with the pointer
over Sent and no overlay, navigation or console error; it surfaced only
as a missing Sent card 30 s later. Assert the selection itself, so the
next occurrence fails at the click, beside the console lines that show
whether the click reached the app and whether the view re-mounted.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
sanity added 16 commits October 30, 2026 08:12
…l rows a closed page stranded

A reload after the token was requested ended the page with the message
possibly never handed to the node, and no leave-page warning: the send was
counted only until its token request. The Sent row then stayed Pending
forever, because the stale-send sweep works from the page's in-memory ack
queue, which a reload empties.

- Each queued pending-ack entry now holds a `pending_saves` guard, so the
  send counts as unfinished page work from the Send click until its inbox
  update is answered, failed or swept (at most 90 s). Red on f8e65d3:
  a_queued_send_counts_as_pending_until_it_resolves_286 (left 0, right 1).
- After the startup merge, Sent rows still Pending that no send in this
  page waits on (left by a session that ended mid-send) are marked Failed,
  with one notice that they may still have arrived. Resuming them is not
  safe: re-sealing makes a new ciphertext, so a send that did go out would
  arrive twice.
- When the recipient's inbox key can't be derived, the row is failed and
  stored Failed at once instead of "staying Pending".

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…; only a deletion marker stops a delete

- An approved multi-step deletion kept its approval only in the record's
  `deleting` flag until the last step wrote the `d` marker. If the record
  then stopped reading, a restore or migration import of the same keys
  rewrote it with `deleting: false`, undoing the deletion. The `d` marker is
  now written first, before the record is marked (and re-written on resume
  if it does not read). Test
  a_restore_cannot_undo_an_approved_deletion_whose_record_went_unreadable,
  red without the change ("UserRestore undid an approved deletion"); guard
  mutation added.
- The DeleteIdentity precheck and delete_identity_now tested "no marker"
  where they meant "no deletion marker", so a create's leftover marker
  (`s`) beside an unreadable record made the identity undeletable
  (NotFound). Both now test `!= Some(MARKER_DELETING)`; a leftover create
  marker with no record and no entry is cleared. Test
  a_spent_create_marker_does_not_stop_deleting_an_unreadable_identity, red
  without the change; guard mutation added.
- `Space::del` on a key that reads as absent retried its removal only
  once more, then trusted a re-read. A key whose reads keep failing and
  whose removal was refused twice was reported gone while it stayed, so a
  deleted contact came back once it read again (fault seed 1284 at 100
  ops / blind 60, red without the change, pinned). It now tries three
  times, as for a key known present. A key that cannot be read and whose
  removal is refused every time is indistinguishable from an absent one
  through the host API; the comment says so.
- Fault harness: a deletion left unfinished is checked with
  ListDeletingIdentities and, once certainly started, any import of it
  must be refused; `check()` fails if a started deletion is still listed
  after the next page. A started deletion whose finishing reply was lost
  is recognised from storage, so a later restore is not flagged.

Candidate rebuilt: 9U3oziYn8hLZTqWXchbCDH3fVGZxxeiWpYg5W2Pwg1vE (reproduces).

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…ueued contact write when it is made

- Contacts: a stored contact that could not be read (`Unreadable`) failed
  every save and every remove, and the listing skipped it, so the user
  could neither fix nor delete it. A save now writes it with no expected
  generation, which the delegate takes as "replace what cannot be read"
  (the user is told once), and a remove deletes it without comparing a
  generation. Tests saving_a_contact_that_cannot_be_read_replaces_it and
  removing_a_contact_that_cannot_be_read_deletes_it; on the old code the
  `?` on the read returned the error.
- Markers: `load_markers` already returns any failure, `Unreadable`
  included, as an error that `prepare` treats as unknown (plans nothing,
  writes nothing; pinned by unreadable_markers_plan_nothing_and_write_nothing).
  Documented at the function.
- Contact-load journal: a write queued behind an earlier one for the same
  contact was journalled only when it started, possibly after the startup
  load had read the contact, so the load could bring back a contact removed
  while it ran. `queue_write` now journals at enqueue time. Test
  a_removal_queued_during_the_load_is_journalled_at_once, red with the
  journal call removed ("the load brought back a contact removed while it
  ran").

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…and the UI offers to repair it

- Delegate: `delete_step` failed on an item index it could not read, so
  the approved deletion could never finish and the identity's keys stayed.
  An unreadable list cannot be walked (nothing lists the host's keys), so
  the deletion now forgets it (`reset_index`, removal needs no read) and
  removes everything else, the record with its keys included. The items
  that list named stay as unreachable bytes; documented at the code. Test
  an_unreadable_item_index_does_not_block_deleting_the_identity, red
  before ("partly done, retry: an index record could not be read");
  guard mutation added.
- Delegate: the repair prompt for one list now names it and whose it is
  ("Mail's list of drafts of \"alice\""), asserted in the existing test.
- UI: a list that still answers Busy after retries loads as empty and is
  recorded, instead of failing the whole local-state load; "Your
  identities" offers "Repair" for each such list (new testids
  fm-id-repair-list / -note). Test
  an_unreadable_list_loads_empty_and_is_offered_for_repair; a non-Busy
  failure still fails the load.

Candidate rebuilt: 5eEvwUhHNEqWQJhjDEEcwfM5iwfCWcPKKgk95gBXpd3P (reproduces).

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…-marker test for what it checks; log no address per send

- Restored a mutation guard for "a failed create marks its slot free to
  reclaim" (removed in round 9 with no replacement), retargeted to the
  current reclamation code, with a new test
  a_failed_create_marks_its_slot_free_to_reclaim. Shown failing with the
  `f` marker dropped from the reclaim condition.
- Renamed a_create_marker_beside_an_unreadable_record_licenses_nothing to
  a_create_marker_beside_an_unreadable_record_never_skips_the_prompt_and_stays_restorable
  and corrected its doc: a `c` marker beside an absent record does let a
  listing unlist the identity (it looks like a create that never
  finished); the test checks that the deletion still asks and that the
  identity stays restorable by import and is listed once it reads.
- The fault check accepting the one unreadable record as stored but
  unlisted is intended: nothing can tell an unreadable record from an
  absent one, and the restore path (import its keys) recovers it, which
  importing_keys_restores_a_listed_identity_whose_record_cannot_be_read and
  the test above assert.
- Comment at the slot reclamation: another tab's create cannot be in
  flight there (core runs a delegate's messages one at a time).
- local_save_sent no longer logs the identity's address and the message id
  on every send.

Candidate rebuilt: 144Faev4S225vWCbPQ2kyqGZS5E1MXvqN4rqQYEG2eQT (reproduces).

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
check-drift on 07e9b78 failed two guards. "a delete removes a key that
reads as absent" named the old two-attempt line. "a deletion answers
Deleted only once its marker is confirmed removed" relied on a fault seed
whose marker removal was refused twice, which the three-attempt `del` now
survives, so the guard passed with its regression applied. It now targets
a_deletion_is_not_reported_done_while_its_marker_may_remain, which blinds
the marker and refuses its removal three times: passes as written, fails
with del_written swapped for del. Candidate unchanged (144Faev4...).

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The guard "re-creating an identity clears its deletion marker" replaced a
line ending in `{` with `if false`, dropping the brace, so the mutated
delegate failed to compile and the script reported "failed, but not as
tests:a_reimported_identity_clears_its_old_deletion_marker". The mutation
is now `if false {`: the create skips clearing its marker, and exactly the
named test fails.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Corrupt identity records:
- An identity record that reads but does not decode used to fail
  `all_identities` with Internal, so one damaged record hid every
  identity. `load_identity` now treats it like an unreadable record: left
  out of listings, deletable after asking, and restored by importing its
  keys, which writes over it (same address, same seeds).
- `Status` gains `unreadable_identities` (absent when zero, so the earlier
  encoding is unchanged; new golden line `reply_status_unreadable`), and
  the UI tells the user, once per load, how many identities could not be
  read and that their recovery keys restore them.

Storage usage:
- Removing a key that cannot be read (`del`, `del_written`,
  `remove_if_present`) now counts one key fewer.
- Writing over an unusable value (`Space::write_over_unusable`, used by
  restores and by replacing an unreadable item) removes it first, so its
  key is counted once rather than twice.
- The bytes of a value the host cannot read cannot be known: nothing else
  records sizes, and the host reports such a value as absent. They stay
  counted. The usage record is an upper bound throughout (a failed put
  already over-counts), so the error only costs this origin budget, never
  lets it exceed one, and arises only from values the host failed to read.
  A per-key size table would double every write and could itself become
  unreadable; a recount would have to walk every key the delegate tracks
  and still could not size the values it is meant to account for.

Fault harness: these changes shift the fault RNG stream, and pinned blind
seed 51 then reached a documented limit no earlier seed had: an approved
deletion that finds an item list unreadable forgets the list and leaves its
items as unreachable bytes (`delete_step`). The checks now excuse exactly
the items of the list that was the unreadable key, and nothing else.

Guards: new mutations for each fix; the "delete removes a key that reads as
absent" guard is retargeted to the rewritten condition.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Fault-harness seeds 765 and 934 (20% faults, one key unreadable) left
the contact index naming an item that was never stored, with no marker
licensing its removal.

Root cause: emptying an index removed its record, setting the
existence marker to `p` first and back to `l` if the removal was
refused. Both could be refused. The old entries then stayed under `p`,
where a record that fails to read is taken for an empty index: a
failed create's cleanup saw no entry and dropped the item's marker,
and once the record read again it listed the dead entry for good.

Reading a record under `p` as empty instead only moves the problem to
the marker key (seed 152 under that attempt: the marker went
unreadable and the dead body read as live). So no index record may
hold an entry its writer did not commit, whichever key fails to read:

- An emptied index keeps its record, written as the empty list; it is
  never removed by a removal that can be refused half-way. Only an
  identity's deletion (`delete_step`, which now removes the record
  before its marker) and `reset_index` remove indexes.
- A new index is created as the empty list (`p`, record, `l`), and the
  entry is added only once `l` is set. A record under `p` is always the
  empty list.

Tests, each red on the old code:
- an_emptied_index_reads_the_same_whether_its_record_reads_or_not
  (old: listed `a` while readable, nothing while unreadable)
- a_record_under_a_pending_marker_never_lists_an_entry
- seeds 765 and 934 pinned in PINNED_BLIND_RUNS
plus two guard mutations in check-guards-fail.sh.

The candidate is rebuilt (7Ta6WmPPhxmbvJpAc19YujXp1hsDbLTzJoj6MdzG44nv,
reproducible). The harness gains FAULT_DUMP=1 to print the store when
a check fails.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
`cargo make test` runs the unreadable-key harness at its 6% default.
Seeds 765 and 934 only fail at higher rates, where several refusals in
a row are common, and were found by hand outside CI. This step runs
2000 seeds x 80 ops at a 20% fault rate with unreadable keys, in
release (about 45 s to build and 10 s to run on a dev machine; debug
takes ~15x longer per seed).

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
modules/antiflood-tokens/interfaces was the one crate in
scripts/check-excluded-crates.sh without the one-line `[workspace]`
table AGENTS.md says every excluded crate carries. Without it, cargo
run in that directory searches upward past the root that excludes it;
in a worktree nested inside another checkout it reaches that checkout's
Cargo.toml and refuses ("current package believes it's in a workspace
when it's not"), which stopped `cargo make test` there.

The crate keeps its own Cargo.lock (unchanged), so nothing it builds
changes: check-frozen-artifacts and check-wasm-builds pass.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
"an index that cannot be read may hold a failed create's entry" mutated
put_item to drop a failed create's marker when the index cannot be
read. After the previous commit write_index adds an entry only once
the index is live (`l`), in one write that lands or changes nothing,
so a failed index_add never leaves its entry and the mutation changes
no outcome: the guard passed with it applied. The guarantee is guarded
by "a new index lists nothing until it is live"; the scenario test
still runs, renamed to what it now checks
(a_failed_add_to_an_unreadable_index_never_leaks_its_slot). The
defensive branch in put_item stays.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The startup sweep marked every Pending Sent row this page was not
sending as Failed. The in-flight set is per page, so a send still in
flight in another open tab was marked Failed in the shared store, and
the terminal guard then refused that tab's Delivered: a delivered
message stored as Failed, with a notice inviting a resend.

- Only rows older than the stale-send timeout are swept, by `sent_at`.
- A swept row is stored as presumed failed (`presumed_failed`, omitted
  when false, so other rows keep their bytes). A Delivered replaces a
  presumed Failed; a Failed a send actually saw stays terminal (#288).

Tests: a_recent_pending_row_from_another_tab_is_not_stranded fails with
the age check removed; the presumed-override unit test and
the_sending_tabs_delivery_wins_over_a_presumed_failure (against the
in-page delegate) fail with the override disabled. All pass with the fix.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
…n origin

The usage record was trusted absolutely. When it read as absent (an
unreadable record) it was taken as zero, so the origin could write past
its budget for good. And it only ever drifted up (a counted write the host
then refused, the bytes of an unreadable key on removal, a phantom key from
replacing an unreadable value), which could leave an origin permanently at
CapReached with nothing stored to delete.

- A usage record that reads as absent, or does not decode, is unknown:
  the next write recounts what is stored first. Deletes leave an unknown
  record alone.
- A write the record would refuse recounts first, unless a finished
  recount already found exactly that usage (a store really full is not
  recounted on every refused write).
- The recount walks every key the delegate can name: bookkeeping, every
  index and its marker, prompts, identity records and markers, listed
  items and their pending markers (`store::pending_item_rel`, moved from
  handlers). A key that must exist but cannot be read counts as
  UNREADABLE_VALUE_BYTES. It reads RECOUNT_STEP keys per call and resumes
  where the last call stopped (Busy until done); it is idempotent.
- The "upper bound throughout" doc was false and is rewritten.
- Fault harness: a new invariant, never stuck at CapReached after
  healing. Requests now carry distinct ids: a call may also refuse an
  earlier request's pushed-out prompt, and with one shared id that
  refusal looked like a second response to this request (seed 1386 at
  3000 seeds hit it once the recount shifted the fault draws).
- Stale comments fixed (handlers.rs index removal order; the failed-add
  recovery test, now retargeted to assert it reaches the step it names).
- Tests that faked a full budget by writing the record now record a
  finished recount that found it full (confirm_usage_for_test).

New tests, each red with its fix removed (guards in check-guards-fail.sh):
a_lost_usage_record_is_recounted_not_taken_as_zero,
a_drifted_usage_record_is_recounted_before_refusing,
a_large_store_is_recounted_in_pages_and_recounts_agree.

Candidate rebuilt: 7U7cV3YRVPeR2NShQQskDLjrksFbcn7d4N4kkkCsV8Z2.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
Fault seed 2404 (100 ops, unreadable keys) leaked an identity slot on
fa3641a. Trace: a listed identity's record went unreadable; the user
restored it from its keys; `write_over_unusable` removed the unreadable
record, then the generation counter write was refused. The restore
failed with the record gone, the index entry kept and no marker, so
nothing could free the slot. It was not the documented create
trade-off (no create, no marker). On 9c6bd97 the seed passed only
because the fault draws shifted.

`write_over_unusable` now writes over the stored value in place, after
taking the generation, so a refused write leaves the store unchanged.
An unreadable value's key is then counted a second time (the usage
record runs one key high until a recount), never low.

Pinned by a scripted replay of the two faults that mattered
(`a_restore_refused_its_generation_keeps_the_unreadable_record`) and a
store test refusing each write in turn; the remove-first guard in
check-guards-fail.sh now reintroduces the removal. Candidate rebuilt.

Claude-Session: https://claude.ai/code/session_01Xxpwzvt4ucaGfbeCr77Jns
The formatting change alone moved the candidate's bytes, so it is
rebuilt with scripts/build-wasm.sh.

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

sanity commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Review summary (Full tier + security + data-loss: UI switch-over to the key-holding delegate)

Thirteen review rounds on this PR.

  • Rounds 1–2: Codex plus blind Claude lenses (security/key custody, data-loss/concurrency, code-first/testing).
  • Rounds 3–11: an adversarial re-check of each fix round, plus an independent whole-PR bug hunt.
  • Codex ran in rounds 1, 2 and 5. In the other rounds it was out of quota (now until Nov 4), so per policy additional Claude lenses substituted, and the substitute whole-PR lens was told to look specifically for the bug class Codex had been finding.
  • The last whole-PR pass found no data-loss, key-leak or stuck-UI issue. The last re-check found the storage protocol clean; its two remaining items are fixed in the final commits, which I reviewed.

What changed because of review (highlights):

  • Release freeze: .release-freeze gates release.sh and every publish task. The test override needs an explicit test flag, and the freeze test can't reach a real release.
  • Key custody: the page holds no identity seeds. The anti-flood token generator's seed is fetched only on the send path, zeroized and redacted, and goes away with the anti-flood tokens later in the chain. Recovery keys are shown once at creation; later reveals need consent.
  • Data safety:
    • archive waits for the stored copy before the inbox removal;
    • drafts record the recipient's key, not an alias;
    • drafts sent or discarded in another tab are refused;
    • in-flight writes warn before leaving the page;
    • startup merges replay changes made during the load;
    • deletes can't be undone by racing writes;
    • identity state and routing are keyed by address, not alias.
  • Storage robustness:
    • a seeded fault-injection harness drives the delegate with transient and persistent read misses and refused writes, checking invariants after healing (index ↔ record ↔ marker consistency, no leaked slots, no lost acknowledged writes, never stuck at the storage cap);
    • it found and drove fixes for over a dozen bugs no reviewer had spotted;
    • CI runs a 2,000-seed sweep at a 20% fault rate on every PR.
  • Recoverability: unreadable records restore from recovery keys; a consent-gated repair rebuilds an unreadable list; unreadable items can be replaced; deletes work at the storage cap; usage recounts instead of blocking.
  • Guards: scripts/check-guards-fail.sh runs every guard against a mutation and requires its named test to fail. Two vacuous guards were caught and fixed along the way.

Final round: a last fault-harness finding (a seeded run leaked an identity slot) turned out to be a real bug: restoring over an unreadable record deleted it before taking a generation number, so a refused counter write stranded the slot. Fixed by overwriting in place, pinned by tests that replay the original faults as a script (not a seed), and re-checked clean.

Follow-ups noted for the next PR:

  • three stale doc comments in store.rs about the transient over-count (bytes also run high, not only keys);
  • a guard for an under-count mutation in write_over_unusable.
    Neither affects correctness: the recount corrects the over-count, and CI's candidate check pins the binary to this source.

Known and tracked: #367, an intermittent live-node failure where a folder click sometimes leaves the view on Inbox (about 1 in 86 runs; no data involved). Instrumentation is in place to pin it down on the next occurrence.

Deferred by design:

  • the migration of v0.2.x delegate data, and the confirm-identity prompt for migrated identities (next PR in the chain);
  • removal of the anti-flood tokens (later in the chain).
    The release freeze keeps main unreleasable until migration lands.

Shipped bytes: none. The delegate is a committed candidate, nothing is published, and releases are frozen.

[AI-assisted - Claude]

@sanity
sanity merged commit 7e4251e into main Oct 30, 2026
6 checks passed
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