Repository navigation
fix(profiles): restore verified names from the device cache - #3382
Conversation
In a community with verified profiles, people showed their self-chosen kind-0 name after every launch or reconnect until the verified-profile sync answered. The device profile mirror kept only kind 0, and the offline view before connecting had no verified-profile authority. Save held kind 30382 heads in the same mirror, and restore them as cached before the restored kind-0 names publish. A restore re-checks each record's signature and the current authority, never replaces a subject a sync already answered, and fits within the cache limit without evicting checked heads. The usual sync then confirms or removes each one. Save the authority with the startup record so the offline view shows them too, and keep saved heads in the mirror while a session has no authority. Co-authored-by: Don Quixote <63cd3327ad95151b455bbdb2c6f19a704759e7ae4327b3d4917086b280de697d@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Franco Sola <[email protected]>
fsola-sq
left a comment
There was a problem hiding this comment.
🤖 Review of a17ed87 using the gh-pr-read skill.
Two recommended blockers: the cached startup authority does not follow an observed disable, and disabling verification can overwrite newer saved heads with the original startup copy. Both are reproduced with real signed events and a controlled clock. There is also one optional startup publication gap below.
Scope and ownership are clear, but correctness is below the repository's 9/10 bar until the two persistence defects are fixed.
Validation: the full existing Vitest suite passed locally at this head (1,082 files, 21,283 tests; 25 skipped). Three additional offline regression probes fail on the cases described below. No desktop or browser UI validation was performed. This review does not establish the remaining app or human acceptance checks.
| savedAt: now(), | ||
| relayAuthor: transport.relayAuthor, | ||
| events: discovery.savedEvents(), | ||
| verifiedProfiles: directory.queries.verifiedAuthority?.(), |
There was a problem hiding this comment.
🤖 [P2] Persist an authority change without waiting for channel discovery. This value is written only by saveDiscovery(), while a verified-directory change schedules only saveProfiles(). After the five-minute discovery refresh returns null, the live directory correctly disables verified names, but the startup record still holds the old authority until another channel-discovery write. On the next offline startup, restoreLocal() trusts that old value and enables the cached overlay again. I reproduced a session observing the disable, then a new cached session showing Alice instead of its kind-0 Self-chosen name. Persist the last successfully observed authority, including a disable, when it changes; preserve the saved roster's existing timestamp rather than renewing its lease merely to update this field.
There was a problem hiding this comment.
🤖 Fixed in 3b3b08b. The authority is now its own startup field (verifiedProfiles). saveProfiles writes it whenever the observed authority changes, and writes null on a disable. It no longer rides on saveDiscovery, so the saved roster and its savedAt stay as they were. restoreLocal reads the new field. Covered in startup.test.ts ("keeps the newest saved verified name and records a disable without renewing the roster"), which also checks that the roster savedAt is unchanged.
| ...directory.recent(PROFILE_CACHE_BASE), | ||
| ...(directory.queries.verifiedAuthority?.() | ||
| ? directory.verifiedHeads(PROFILE_CACHE_BASE) | ||
| : savedVerified), |
There was a problem hiding this comment.
🤖 [P2] Preserve the latest heads when verification becomes disabled. savedVerified is populated only by the initial disk read and never follows live head updates. Reproduction: restore signed name A, receive and save a newer signed name B, then let discovery disable verified names. This branch selects the original A, and the following profile save overwrites B on disk. My regression probe confirms that the mirror's event ID changes back to A after the disable. Thus the next restore can show an obsolete name even though this device already learned and persisted its replacement. Preserve the latest retained/saved verified heads through disable; use the initial disk copy only when no newer directory answer exists. Cover a rename followed by disable.
There was a problem hiding this comment.
🤖 Fixed in 3b3b08b. savedVerified now follows every mirror save while an authority is present. After a disable, the mirror keeps the last saved heads, not the startup copy. The same test covers a rename to B followed by a disable: the mirror still holds B.
| /** Verified heads an earlier session saved, shown as cached until checked. */ | ||
| restoreVerified(saved: readonly RelayEvent[]) { | ||
| verified?.restore(saved); | ||
| }, |
There was a problem hiding this comment.
🤖 Optional [P3]: publish a verified-only restore before session handoff. verified.restore() queues the profile projection with setTimeout, and the subsequent directory.accept(names) forces a synchronous projection only when kind-0 heads change. With a mirror containing a valid 30382 and no kind 0, a controlled-clock probe finds the subject absent from session.profiles.snapshot() after await owner.restore(); it appears after the zero-delay flush. Consider making the restored overlay available before handing off the session and adding a verified-only startup case. This proves a read-model gap; I did not test whether it produces a visible frame in the app.
There was a problem hiding this comment.
🤖 Fixed in 3b3b08b. restoreVerified now runs the pending verified flush at once (flushVerified), so restored heads publish before restore() returns. Added a verified-only startup case.
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 +1 to the two P2s already on here, both hold up on a separate read of the source. the saved authority only gets rewritten alongside channel discovery, so a disable seen by a quiet session never reaches the next offline startup. and savedVerified is only the startup copy, so once verification is disabled the next mirror save rolls newer heads back to it. agree a verified-only startup case is worth adding for the P3 too. one nonblocking note inline.
| const events = [ | ||
| ...directory.recent(PROFILE_CACHE_BASE), | ||
| ...(directory.queries.verifiedAuthority?.() | ||
| ? directory.verifiedHeads(PROFILE_CACHE_BASE) |
There was a problem hiding this comment.
🤖 nonblocking: in a verified community this adds up to 1024 more 30382s to the mirror, roughly 0.5 to 1 MB by my estimate. the startup store is one 8 MB budget shared by every community, and prune() drops profile mirrors first, so someone in a few large communities can lose a whole mirror (back to self-chosen names for everyone in it) sooner than today. probably fine, but worth a line in the tradeoffs, or a smaller cap on the verified half if it shows up
There was a problem hiding this comment.
🤖 Agreed. I added this to the tradeoffs in the description. A full mirror drop falls back to the names main shows today. I left out a smaller limit for now; if this shows up, it is a one-line follow-up.
Save the verified-profile authority as its own startup field whenever the observed authority changes, including a disable, so a cached session never re-enables names a live session saw withdrawn. The saved channel list and its timestamp stay as they were. Keep the last mirrored verified heads, not the startup copy, while a session has no authority, so a disable cannot roll a newer saved name back. Publish restored verified heads before restore returns, so a mirror of verified names alone is shown as soon as the session is. Co-authored-by: Don Quixote <63cd3327ad95151b455bbdb2c6f19a704759e7ae4327b3d4917086b280de697d@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Franco Sola <[email protected]>
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 Three additional persistence/ordering findings at 3b3b08b. The prior authority-disable and already-saved-head rollback triggers are fixed; these comments cover distinct remaining triggers.
| if (closed || !enabled || !current) return; | ||
| const restored = events.flatMap((event) => { | ||
| const subject = verifiedProfileSubject(event, current); | ||
| return subject && !checked.has(subject) ? [{ subject, event }] : []; |
There was a problem hiding this comment.
🤖 [P2] Preserve reconciliation precedence across disconnect during restoration. With the profile-mirror read delayed, signed kind-0 replay and profiles-route establishment automatically start verified sync. An empty EOSE removes the name, but disconnect clears checked; releasing the older disk head then restores that removed name as cached and writes its old 30382 back into the mirror. This reproduces through production session callbacks with modeled transport/persistence; the identical sequence without disconnect passes. Keep known absence authoritative against pending disk restoration separately from connection freshness, and test deferred mirror read → empty sync → disconnect → release, asserting both the projected name and persisted event IDs. This is not a native/live-relay reproduction.
There was a problem hiding this comment.
🤖 Fixed in bc77df6. The directory now keeps an answered set: the subjects a sync answered under the current authority. A disconnect clears checked status, but it does not clear this set, so restore skips those subjects. The set clears only on an authority pubkey change, an eviction or clear().
Tests, both fail without the fix:
verified-profile.test.ts: "keeps a sync's removal over a restored head after a disconnect".startup.test.ts: "keeps a removal a sync answered over a late mirror read after a disconnect". It uses a deferred mirror read, then an empty sync, a disconnect and the release. It asserts that no name is projected and that the persisted mirror is empty.
Same caveat as yours: modeled transport and persistence, not a live relay.
There was a problem hiding this comment.
[P2] Preserve confirmed removals when verification is disabled
Agree with the remaining nonblocking race noted in the latest review: if a full sync removes a cached head and discovery disables verification before the one-second save, held is empty but savedVerified still contains the removed head. The fallback keeps the old signed name on disk, so a later restart with the authority enabled can restore it as cached again. The delayed-mirror-read variant has the same gap. Should the disabled-state mirror merge reconcile confirmed absences before retaining the saved fallback, with coverage for removal followed by disable?
Posted by Codex (gpt-6.1-sol) on behalf of kylez
| @@ -1417,7 +1423,18 @@ export function createChannelStore( | |||
| clearTimeout(profileSave); | |||
| profileSave = undefined; | |||
| if (!persistence?.writeProfiles || !profilesRestored || disposed) return; | |||
There was a problem hiding this comment.
🤖 [P3] Persist authority independently of a successful profile-mirror read. restoreProfiles returns from its readProfiles catch before setting profilesRestored, so a read timeout/transaction error leaves this gate closed for the session. A subsequent observed disable therefore never writes verifiedProfiles: null, even if startup writes would succeed; an earlier saved authority can survive into the next cached launch. A production probe rejecting readProfiles before a reconnect disable leaves the authority patch undefined instead of null. Move the authority comparison/write before the profile-restoration/writeProfiles gate, or give it an independent listener path.
There was a problem hiding this comment.
🤖 Fixed in bc77df6. saveProfiles now compares and writes the authority before the profilesRestored / writeProfiles gate. A failed mirror read still blocks mirror writes, but a disable still reaches the startup record. Test: startup.test.ts "records a disable when the mirror could not be read". It fails without the fix.
| savedAuthority = undefined; | ||
| }); | ||
| } | ||
| if (authority) savedVerified = directory.verifiedHeads(PROFILE_CACHE_BASE); |
There was a problem hiding this comment.
🤖 [P3] Retain heads received during the debounce before disable. If a newer head arrives and discovery disables within the one-second save debounce, this save sees no authority and selects the older savedVerified copy, although the directory still retains the newer head. Removing the mirror wait before disable from the rename regression reproduces an older persisted event ID. After re-enable across restart, the stale cached name can remain until sync. Prefer retained directory heads when available and use the saved copy only as the fallback; cover rename followed immediately by disable.
There was a problem hiding this comment.
🤖 Fixed in bc77df6. Without an authority, the save now takes the heads the directory still holds. Each one replaces the saved copy at the same address (relay pubkey and d tag). The saved copy is kept only for subjects with no held head. Test: startup.test.ts "saves a rename that a disable follows before the mirror is written". It covers a rename followed at once by a disable, with no wait for the mirror, and fails without the fix.
- A subject a sync answered keeps that answer across a disconnect, so a late mirror read cannot restore a removed verified name. - The saved authority is written even when the mirror read failed, so a later disable still reaches the next cached launch. - After a disable, heads the directory still holds replace their older saved copies in the mirror. Co-authored-by: Don Quixote <63cd3327ad95151b455bbdb2c6f19a704759e7ae4327b3d4917086b280de697d@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Franco Sola <[email protected]>
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 Approved with one nonblocking P3 follow-up.
The previous delayed-restore/disconnect, failed-mirror-read authority persistence, and rename-before-disable triggers are fixed. Regression checks exercise production session behavior, and mutations of the fixes fail their corresponding tests. Cached startup and reload checks passed in Chromium and WebKit; CI is green at bc77df6. Live verified-profile session checks use modeled transport/persistence, so they do not establish native or real-relay behavior.
Nonblocking P3 — src/features/relay/store.ts:1440: if a complete empty sync removes a saved head and discovery disables before the one-second persistence debounce expires, the fallback merge retains that removed record. A restart with the same authority re-enabled can show the stale name as cached until sync. Authority is correctly null while disabled. Follow up by preserving conclusive absence in the saved mirror, with removal → immediate disable → restart/re-enable coverage for persisted event IDs and displayed names.
kazemek21
left a comment
There was a problem hiding this comment.
Kyle reviewed the PR and the Nexus review-tracker notes and approves, with C1 as nonblocking feedback in the existing thread.
Posted by Codex (gpt-6.1-sol) on behalf of kylez
* origin/main: (153 commits) feat(agents): preview where interrupted agents were working before resume (#3494) perf(messages): close the editor and delete dialog on submit (#3493) feat(messages): quote selected message text into the composer (#2392) perf(toast): scope docked clearance to the toast host (#3489) fix(unread): undo exact captured ordinary reads conditionally (#3473) fix(solo): keep navigation responsive during chat deletion (#3488) fix(channels): keep custom thread tab names until the tab closes (#3458) perf(relay): verify cached history in the native host (#3426) fix(settings): refine claude and typesafe key setup dialogs (#3391) Update bundled help for Bestie and agent settings (#3194) feat(profiles): share and show a local time zone (#3332) Revert "feat(link-previews): unfurl web links below messages by default" (#3480) Show disabled plugin chats in Solo (#3456) perf(messages): host one context menu per message list (#3465) test(claude): keep the quit-release test copy above the minimum version (#3477) fix(profiles): restore verified names from the device cache (#3382) fix(mentions): show addressed agents first in Solo audience picker (#2856) perf: count member access once and scope find highlights to painting (#3464) perf(messages): prepare a message once for its renderer and link previews (#3466) fix(composer): speed up long pastes and paste office text as text (#3441) ... # Conflicts: # docs/plugin-authoring-guide.md
…when-ready * origin/main: (21 commits) fix(agents): report a conversation wake that fails before its turn starts (#2192) fix(usage): read Claude transcripts over 50 MB a page at a time (#3498) feat(agents): preview where interrupted agents were working before resume (#3494) perf(messages): close the editor and delete dialog on submit (#3493) feat(messages): quote selected message text into the composer (#2392) perf(toast): scope docked clearance to the toast host (#3489) fix(unread): undo exact captured ordinary reads conditionally (#3473) fix(solo): keep navigation responsive during chat deletion (#3488) fix(channels): keep custom thread tab names until the tab closes (#3458) perf(relay): verify cached history in the native host (#3426) fix(settings): refine claude and typesafe key setup dialogs (#3391) Update bundled help for Bestie and agent settings (#3194) feat(profiles): share and show a local time zone (#3332) Revert "feat(link-previews): unfurl web links below messages by default" (#3480) Show disabled plugin chats in Solo (#3456) perf(messages): host one context menu per message list (#3465) test(claude): keep the quit-release test copy above the minimum version (#3477) fix(profiles): restore verified names from the device cache (#3382) fix(mentions): show addressed agents first in Solo audience picker (#2856) perf: count member access once and scope find highlights to painting (#3464) ...
…n-on-github * origin/main: (21 commits) fix(agents): report a conversation wake that fails before its turn starts (#2192) fix(usage): read Claude transcripts over 50 MB a page at a time (#3498) feat(agents): preview where interrupted agents were working before resume (#3494) perf(messages): close the editor and delete dialog on submit (#3493) feat(messages): quote selected message text into the composer (#2392) perf(toast): scope docked clearance to the toast host (#3489) fix(unread): undo exact captured ordinary reads conditionally (#3473) fix(solo): keep navigation responsive during chat deletion (#3488) fix(channels): keep custom thread tab names until the tab closes (#3458) perf(relay): verify cached history in the native host (#3426) fix(settings): refine claude and typesafe key setup dialogs (#3391) Update bundled help for Bestie and agent settings (#3194) feat(profiles): share and show a local time zone (#3332) Revert "feat(link-previews): unfurl web links below messages by default" (#3480) Show disabled plugin chats in Solo (#3456) perf(messages): host one context menu per message list (#3465) test(claude): keep the quit-release test copy above the minimum version (#3477) fix(profiles): restore verified names from the device cache (#3382) fix(mentions): show addressed agents first in Solo audience picker (#2856) perf: count member access once and scope find highlights to painting (#3464) ...
* origin/main: (31 commits) Let plugin panels name their tab with panels.useTabTitle (#3251) fix(agents): report a conversation wake that fails before its turn starts (#2192) fix(usage): read Claude transcripts over 50 MB a page at a time (#3498) feat(agents): preview where interrupted agents were working before resume (#3494) perf(messages): close the editor and delete dialog on submit (#3493) feat(messages): quote selected message text into the composer (#2392) perf(toast): scope docked clearance to the toast host (#3489) fix(unread): undo exact captured ordinary reads conditionally (#3473) fix(solo): keep navigation responsive during chat deletion (#3488) fix(channels): keep custom thread tab names until the tab closes (#3458) perf(relay): verify cached history in the native host (#3426) fix(settings): refine claude and typesafe key setup dialogs (#3391) Update bundled help for Bestie and agent settings (#3194) feat(profiles): share and show a local time zone (#3332) Revert "feat(link-previews): unfurl web links below messages by default" (#3480) Show disabled plugin chats in Solo (#3456) perf(messages): host one context menu per message list (#3465) test(claude): keep the quit-release test copy above the minimum version (#3477) fix(profiles): restore verified names from the device cache (#3382) fix(mentions): show addressed agents first in Solo audience picker (#2856) ...
Problem
In a community that publishes verified profiles (NIP-FI, relay-signed kind 30382), people show their self-chosen kind-0 name after every launch or reconnect, until the verified-profile sync answers. There are two causes:
saveProfiles/restoreProfilesinstore.ts) keeps only kind 0, so restored names are always the self-chosen ones.restoreLocalinservice.ts) has no verified-profile authority, so it cannot show verified names at all.Change
verified-profile-directory.ts:heads(count)returns the held heads for the mirror.restore(events)loads heads that an earlier session saved, ascachedstatus.verifiedProfileSubject);store.ts:verifiedProfiles,nullonce disabled) whenever it changes. The saved channel list and its timestamp stay as they were.profile-directory.ts: a restore publishes the restored heads at once, so a mirror of verified names alone shows beforerestore()returns.service.ts: the offline session gets the saved authority (savedVerifiedProfileAuthority), so it shows saved verified names ascached.docs/buzz-help.md: one line about cached verified names after a restart.Relay reads: no new reads for subjects that also have a saved kind 0. The existing kind-0 restore
ensurealready syncs those subjects, andrestoreruns first, so the two merge into one schedule.Non-goals:
Tradeoffs
cacheduntil the sync answers.prune()drops profile mirrors first. So a person in several large communities can lose a whole mirror sooner than today. In that case the names fall back as they do onmain. If this shows up, a smaller limit for the verified records is a simple follow-up.Validation
New tests:
verified-profile.test.ts: restore checks, forged and foreign records, a sync's earlier answer (kept across a disconnect), a complete sync that removes a restored head, no authority, the limit with checked heads.startup.test.ts: live restore publishes no self-chosen name, then rechecks; the mirror and startup record save heads and authority; a session that cannot check keeps the saved heads; a rename then a disable keeps the newest name, writesnull, and keeps the roster timestamp; a mirror of verified names alone publishes beforerestore()returns; a late mirror read after a sync's removal and a disconnect restores nothing; a disable is saved when the mirror read failed; a rename followed at once by a disable saves the newer head.startup-service.test.ts: the offline view with and without a saved authority.Fail-then-pass: each new test fails without its fix. Each review-fix test fails without its fix.
vitest run(full): 1082 files passed, 21289 tests passed, atbc77df63. Biome andtsc --noEmitare clean.Agent reviews found the limit/eviction defect, the stale saved authority, the disable rollback, and the verified-only publish gap. All of them are fixed in this PR.
Human test by the author on 2026-11-13, at
bc77df63, in the desktop app against a verified community: verified names showed at once as cached after a relaunch, then rechecked. The author skipped the agent app run.buzz-review-completed
🤖 Generated with Claude Code