Removes machinery that ships but is unreachable from any live path:
- WindowLoadTracker's REQ-aware backstops (silence + connect-grace). All
three live-tail managers construct it with the default tracksReqSends =
false, so onReqSent/reqSentAt/silencedOut/connectStalled and the
onAbandoned reporting could never fire. Only WindowLoadTrackerSilenceTest
exercised them, so it goes too. accountedFor collapses to "settled".
- trackingListener's onEachEvent param — no caller ever passed it.
- UntilLimitPager.isArmed() / activeRelays() — only the unit test called
them; the pager uses armedRelays() in production.
- Orphaned string chats_load_entire_history, left behind when the
"load entire history" button was removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move isRenderableRepost() (and its test) from amethyst ui/dal into
commons/ui/feeds so both platforms use one implementation, then point
desktop's isFeedNote() at it.
The DM trail showed REQ + CLOSED/NOTICE/OK-fail but was blind to the entire
NIP-42 handshake — incoming AUTH challenges hit `else -> {}`, the AUTH send is
not a REQ so onSent skipped it, and the auth OK(true) was filtered out by the
OK(fail)-only guard. So a CLOSED 'auth-required' followed by a re-REQ was
unattributable: you couldn't tell whether the relay challenged, whether we
signed+sent AUTH, whether it was accepted, and whether the re-REQ was the
post-auth syncFilters or just a blind retry.
Add three lines under the DMPagination tag (DM relays only, debug):
- `AUTH challenge <- relay '<challenge>'` (incoming AuthMessage)
- `AUTH -> relay (id ...)` (our AuthCmd reply; id remembered)
- `AUTH accepted|REJECTED <- relay '...'` (the OK matched to that auth id)
With these, the post-CLOSED sequence reads end to end: challenge → AUTH → accept
→ re-REQ (→ events/EOSE), so an auth-walled DM relay's load can be confirmed or
pinned to the exact link that breaks.
Adds WindowLoadTrackerIdleTest, the one WindowLoadTracker backstop that had no
coverage (silence and connect-grace are already pinned by
WindowLoadTrackerSilenceTest):
- idle completes a window when a relay streams stored events but never EOSEs,
once the stream goes quiet for idleTimeout (the "every relay settled" path can
never finish such a relay).
- the idle gate holds the window open while a still-pending relay has never been
heard from, so a slow connect isn't mistaken for a quiet stream; once that
relay delivers anything and goes quiet, idle then completes it.
Real-time tests with a short idleTimeout, matching the silence suite's style.
Audit follow-up.
- Remove the 12 history-card strings (chats_history_older / all_caught_up /
reached_start / relay_sync / subtitle{,_no_date} / waiting / relays_title /
relay_back / incomplete{,_sub} + the chats_history_relays plural) from
amethyst's default strings.xml: they moved to commons composeResources with
the card and have zero remaining amethyst references. They were branch-new and
not yet translated, so removing the default key is a clean, orphan-free delete.
Kept chats_history_proto_* (still the card's protocolName) and chats_reply_*.
- Extract the "does this relay's reached cursor fall in this gap" check, which
was duplicated (with off-by-one-prone >/<= boundaries) between marker placement
(RelayWindowLimitMarkers) and the paging driver (RelayWindowLimitSentinels),
into a single pure reachedFallsInGap(); both now call it so they can't disagree
about which gap a cursor lives in. Add RelayReachMarkerTest pinning every
boundary (newer strictly >, older inclusive <=, null ends).
- Fix a garbled comment in BackwardRelayPager.onSilenced.
The relay-reach markers and the history status card now live in commons/ui/feeds
(shared Android + Desktop), with the card taking an injected date formatter.
Update the component map's UI section accordingly.
Phase 2 (UI), stage 2. Move DmHistoryLoadingCard + its tap-through per-relay
dialog + the historySubtitle/incompleteSubtitle helpers out of amethyst into
commons/commonMain (com.vitorpamplona.amethyst.commons.ui.feeds), so the same
"older history / all caught up / some relays didn't respond" boundary card can
back any per-relay BackwardRelayPager feed on Android and Desktop.
The card's localized date formatting (SimpleDateFormat/Locale) can't live in
commons commonMain (it targets iOS/linux/macOS, no java.*), so it's injected as
a formatReachDate: (epochSeconds) -> String lambda — the platform that renders
the card supplies its native formatter, no i18n regression. Android passes
formatHistoryReachDate (new HistoryDateFormat.kt). The dialog's per-relay reach
now uses that same month-precision formatter (was "MMM d, yyyy", now "MMM yyyy")
— a negligible cosmetic change.
Its ~11 strings move to commons composeResources, including the module's first
<plurals> (chats_history_relays). The three Android call sites (ChatroomView,
ChatroomListFeedView, LoadingReplyNote) now import the shared card/helpers and
pass the formatter; no behaviour change. The old amethyst string copies are left
in place (harmless, separate resource namespace) for a later cleanup pass.
Phase 2 (UI), stage 1. Move the per-relay reach markers — RelayReachState,
RelayReach, RelayWindowLimit, RelayWindowLimitSentinels (the hoisted,
visibility-driven paging driver), RelayWindowLimitMarkers, and RelayReachMarker
— out of amethyst into commons/commonMain (com.vitorpamplona.amethyst.commons.ui.feeds)
so the Desktop chats UI (and any feed) can render the same in-stream paging
progress, not just Android.
De-Android-ified: the one Android string (chats_history_relay_sync) becomes a
CMP composeResources string in commons; the two theme constants (DividerThickness,
HalfPadding) are inlined (0.25.dp / padding(5.dp)) so the shared component carries
no app-theme dependency. Logic is otherwise byte-identical. The Android
conversation + rooms-list views now import the shared version; no behaviour change.
The per-relay paging primitives moved to quartz (nip01Core/relay/client/paging,
jvmAndroid source set) and the three history managers now delegate their shared
bookkeeping to BackwardRelayPager. Update the architecture note and the
component map (new toolkit location + the two new quartz tests) accordingly.
Step 3+tests of the pagination generalization. The gift-wrap, conversation
NIP-04, and rooms-list NIP-04 history managers each reimplemented the same
per-relay cursor / in-flight / stall / exhausted / display-flow bookkeeping;
they now delegate all of it to the shared BackwardRelayPager and keep only
what is genuinely theirs: building the protocol's REQ filters, the relaysFor
lookup, and forwarding subscription callbacks. ~550 lines of duplicated logic
removed; the public API (loadingMore/exhausted/relayCount/stalledCount/
reachedBack/relayProgress, advance/advanceAll) is unchanged, so the UI is
untouched. Behaviour-preserving.
Tests (quartz jvmAndroidTest):
- BackwardRelayPagerTest drives the engine's callbacks directly and pins the
logic that backed the bugs in this branch: empty page -> done -> caught up;
a CLOSED / cannot-connect relay -> stalled -> exhausted-but-INCOMPLETE
(stalledCount > 0, not "all caught up"); re-advance clears a stall; the
reached cursor is the deepest across relays; a done relay won't re-advance;
advanceAll arms only not-done relays; switching the active key repoints the
display flows and restores a backgrounded key's terminal state.
- UntilLimitPagingRelayTest drives a real NostrClient against the in-process
relay (geode) to pin the wire contract the design rests on: a backward
until+limit walk returns each event exactly once (no re-download), newest
first, capped at the limit, with an empty page + EOSE as the gap-proof stop.
Step 1+2 of generalizing the DM history pagination into a reusable toolkit.
Move the four transport-agnostic primitives out of the amethyst module into
quartz's jvmAndroid source set, new package
`nip01Core.relay.client.paging`: UntilLimitPager, PerRelayLoadTracker,
WindowLoadTracker, RelayPagingProgress. They were already pure Kotlin (no
Android deps); jvmAndroid keeps their java.util.concurrent / @Synchronized
concurrency without a KMP-atomics rewrite, while making them visible to
amethyst, desktop, and quartz's jvmAndroidTest (geode in-process relay) for
the integration tests to come.
Add BackwardRelayPager<K>: the generic per-relay backward-pagination engine
that collapses the ~80%-identical pager+tracker+status+exhausted bookkeeping
the three DM history loaders each reimplement. It owns the cursors, in-flight
+ silence tracking, stalled set, pinned floor, and the display StateFlows
(relayProgress / exhausted / reachedBack / relayCount / stalledCount); the
caller supplies only the filter builder, the subscription wiring, and a
relaysFor(key) lookup. Not yet wired into the managers — that swap is step 3.
Pure relocation + new component; no behavior change. UntilLimitPagerTest and
WindowLoadTrackerSilenceTest stay in amethyst (they use JUnit) with explicit
imports added, and both still pass against the relocated classes.
The doc had drifted from the implementation on two material points:
- "Update 3" claimed the rooms-list and gift-wrap *history* managers still use
the round model. They were both migrated to the per-relay until+limit model
(commits 60b8629a, 9f0ecd54); all history paging is now per-relay. The round
model (WindowLoadTracker) survives only as the live-tail completion barrier.
- The rooms-list "stall-gate" it described was removed (commit 98fb8720); the
rooms list now pages to exhaustion off marker visibility like the convo.
Restructured so the current architecture is authoritative and up front (two
layers, the two completion models and where each lives, the marker/sentinel
driver, per-relay NIP-04 filter scoping, terminal-state split, reply
placeholder, diagnostics, Tor self-heal), with a component map and review
notes. The superseded time-slice and round-model history are kept clearly
labelled under "Design evolution (historical)".
The terminal state of both DM history cards equated two very different things:
a relay that genuinely bottomed out (empty page = done) and one that merely
stalled (auth CLOSE / offline / 15s silent). `exhausted` flips true when every
relay is done OR stalled, and the card rendered that unconditionally as "All
caught up" — so a chat whose messages sit on a stalled relay claimed completion
while a lot of history was still unfetched. The reply placeholder had the same
flaw and, worse, collapsed to a bare 👀 (post_not_found_short) that told the
user nothing.
Split the terminal state by stalledCount:
- caughtUp (all done): keeps "All caught up" and the lingering collapse.
- incomplete (>=1 stalled): "Some relays didn't respond · N unreachable",
error-coloured glyph, stays put (no collapse).
Both cards are tappable into the existing per-relay popup, so the user can see
exactly which relays stalled (… in error colour) vs reached the bottom (✓).
The reply placeholder now says "Couldn't find this message" with an honest
subtitle — "N relays unreachable · tap to see which" when stalled, or "Searched
every relay · tap to see" when it genuinely isn't in history — instead of 👀.
The card summarized progress but hid the per-relay detail that relayProgress
already carries. Make the card tappable: it opens a popup listing every relay
with its state (✓ done · … stalled · ↓ reaching) and how far back it has paged
('back to <date>'), deepest-reaching first. Passes each loader's relayProgress
through to the card; empty progress keeps the card non-interactive.
The card's count was loadTracker.count() = relays in-flight, which drops to 0
the moment they all stall — so historySubtitle hit its relayCount<=0 guard and
showed just the bare protocol tag ('NIP-17' / 'NIP-04'), with no hint of how
many relays it was waiting on.
Expose a stalledCount (not-done relays that can't be reached right now) from all
three history loaders and render it on the paused card as 'waiting on N relays'
when nothing is in flight. Active fetching still shows 'N relays'; a parked-but-
reachable protocol still shows just the tag (it isn't waiting on anything —
it resumes on scroll).
The demand-driven rewrite dropped the round model's 'load done: all relays'
completion line, leaving no aggregate signal for 'this is as far as history
goes right now'. Log one per protocol on the exhausted false->true edge, with
the done vs stalled relay breakdown, restoring that diagnostic.
The per-relay window-limit sentinel placed its advance() effect inside the
gap row that currently hosts the marker, so its identity rode that row. Any
feed reorder (a live DM bumping a room, or a slow relay dribbling a history
page) moved the gap to a different row, tore the keyed LaunchedEffect down and
recreated it, and re-fired advance() on a static screen — re-arming stalled/
auth relays into a 15s silence-watchdog storm and risking an unprompted
page-back for delivering relays.
Hoist the driver above the list: RelayWindowLimitSentinels now holds one
stable effect per non-done limit (keyed by lim.key) that watches the
LazyListState and fires advance() only when the marker's gap is among the
currently visible rows AND it just scrolled into view OR its reached cursor
moved (a page landed). A reorder that keeps the marker on the same side of the
fold changes neither, so it no longer re-pages. RelayWindowLimitMarkers is now
pure UI. Wired in the rooms list (inline) and the conversation (via a new
sentinels slot on ChatFeedView).
Verified on device: static rooms list drops from a perpetual ~15s re-fire/
silence storm to 2 silence events and only legit cursor-moved advances, while
demand-driven paging (page back while the marker is visible) is preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PerRelayLoadTracker dropped the loading flag the instant in-flight emptied, so
a relay paging page-after-page (each page settles, then the marker fires the
next) flipped loading true->false->true every page — and the card's icon hard-
cut spinner -> paused-glyph -> spinner, a visible blink per page.
Let loading=false linger ~600ms after the last page settles; cancel the linger
the moment the next page starts. Back-to-back pages now show a steady spinner;
the card still flips to paused/done once paging genuinely stops.
The floor (now - 7d) was recomputed on every call, so it drifted forward
over time. For a relay that hadn't delivered yet (reachedUntil == null),
reachedUntilFor returned that drifting floor, so its marker's reached cursor
kept changing, the sentinel's LaunchedEffect(reachedUntil) key changed, and
it re-fired advance() — observed as rooms.nip04.history re-advancing a silent
vitor.nostr1.com every ~15s, with the retry's 'until' moving NEWER each time.
Pin the floor once per account/conversation for the session (matching the
pre-rewrite behavior) in all three history loaders, so an un-delivered relay
parks at a stable cursor and its sentinel fires once.
Also add diagnostics to catch this class of bug quickly: one log line per
marker sentinel fire (key + reachedUntil — a loop repeats the same key, a
drift shows a moving cursor) and one per advanceAll (empty-feed bootstrap).
The per-relay sentinel keyed its LaunchedEffect on (reachedUntil, state).
The state component meant every REACHING<->STALLED transition restarted the
effect and re-issued advance(), so a flaky or auth-walled relay's reconnect/
timeout churn re-paged the window on a completely static screen (observed:
vitor.nostr1.com + auth.nostr1.com re-REQing on every ping timeout / auth
CLOSE with no user interaction).
Key the sentinel on reachedUntil ALONE — a landed page is the only thing
that should pull the next one. A stalled relay now parks until its cursor
moves or its marker is scrolled back into view (one retry, not a loop).
Also: the empty-feed bootstrap now triggers on FeedState.Empty only (not the
transient Loading that navigation flashes through) and is debounced, so
re-opening Messages / a conversation that already has content no longer kicks
a one-round advanceAll across every relay.
Audit follow-ups to the demand-driven paging change:
- UntilLimitPager.onEose: require the reached cursor to move strictly
older each page. A misbehaving relay that returns events but none older
than already reached would otherwise pin the cursor and the on-screen
sentinel would re-request the same window forever; treat it as the bottom.
- updateStatus (gift-wrap + rooms-list NIP-04): only write the shared
display StateFlows for the foreground account, mirroring the existing
exhausted guard, so a background account's late EOSE can't clobber the
on-screen relay count / reached-back / per-relay markers.
- Add UntilLimitPagerTest covering the requested/reached split, park-on-
EOSE, done-on-empty, the strict-older guard, and per-relay independence.
Replace the proactive walk-to-exhaustion with on-demand, per-relay paging
driven by on-screen window-limit markers, so history loads only while the
user is looking at a relay's frontier and a spam-dense relay never floods.
UntilLimitPager: split the cursor into 'requested' (drives the REQ; moves
ONLY on advance()) and 'reached' (oldest delivered; for markers). Leaving
requested untouched on EOSE is what parks a relay — no auto-continuation.
PerRelayLoadTracker (new): tracks which relays have a page in flight (for
the spinner) with a silence watchdog to park relays that accept a REQ then
go quiet. Replaces the window/round bookkeeping for the history loaders.
All three history loaders (gift-wrap account-wide, rooms-list NIP-04,
conversation NIP-04) rewritten: onEose parks instead of self-continuing;
advance(relay) steps one relay one page; advanceAll() bootstraps the
empty/initial boundary; exhausted = every relay done or stalled.
UI: RelayWindowLimitMarkers renders each relay's marker at its reached
depth AND acts as its load sentinel — while the marker is composed
(on/near screen) it pulls that relay's next page (LaunchedEffect keyed on
the reached cursor, so it keeps going page after page while visible) and
stops when the marker scrolls off or the page fills the screen. Wired into
both the conversation (gap markers) and the rooms list (interleaved
between rooms); a BootstrapHistoryWhenEmpty drives advanceAll while the
feed has nothing to scroll.
Removes the round/give-up machinery's last users: drop loadMore/
loadEverything and the scroll-driven WidenHistoryWhen.
Three names didn't describe their behavior:
- removeFromCache → unlinkAndRemove: the method's main job is unlinking the
note from every referrer (parents, channels, the report/card/status/poll
indexes), not just evicting it from the map; the old name only captured
the last step.
- removeAllChildNotes → clearChildLinks: it clears only THIS note's forward
child collections and returns them — it does not touch the children's
replyTo and does not remove anything from the cache. The old name sounded
more aggressive than detachFromChildren(), which is actually the
both-directions op.
- Note.removeOnchainZap(source) → removeOnchainZapBySource(source): too easy
to confuse with removeOnchainZapForSource(txid, pubkey), which is the
verification-verdict removal with anti-spoof guards. The new name matches
its inner helper (innerRemoveOnchainZapBySource) and disambiguates the two.
Pure rename: no behavior change. Test names/comments updated to match.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
The gift-wrap history loader exposed only relayCount + reachedBack, so the
conversation's in-stream marker trail showed NIP-04 relays paging back but
never NIP-17 ones. Give AccountGiftWrapsHistoryEoseManager the same
relayProgress map (per-relay reachedUntil / done / stalled) the
per-conversation NIP-04 loader publishes, refreshed on every page, stall,
CLOSE, and cannot-connect.
Move the shared RelayPagingProgress data class out of the NIP-04 UI-package
assembler into service/relayClient/eoseManagers so the gift-wrap manager
can produce it without the service layer depending on a UI package.
ChatroomView now merges both protocols' progress into the gap markers, each
contributing only while it is still paging; a relay that serves both (the
DM inbox relays) collapses to one marker. Markers hide once both protocols
are exhausted.
Convert ChatroomListNip04HistorySubAssembler from the round-based model to
the per-relay-independent one, matching the per-conversation NIP-04 loader
and the gift-wrap history loader. A single loadMore opens one window
spanning the whole walk; each relay continues itself on its own non-empty
EOSE (beginRound([relay]) + invalidateFilters), and silent/unreachable
relays are marked stalled (kept open) with exhaustion coming from the
WindowLoadTracker's silence + connect-grace backstops (tracksReqSends=true).
The rooms list now pages both protocols identically.
This was the last user of the round-tally + give-up machinery, so remove it
from UntilLimitPager: roundEventCount(), onClosed()/giveUp() and the
givenUp/closedStreak cursor state + GIVE_UP_AFTER_CLOSES. activeRelays now
excludes only done relays. Delete UntilLimitPagerGiveUpTest (tested the
removed give-up path; abandonment is now the WindowLoadTracker's job, which
its own test covers).
Also drop the now-dead loadEverything/autoLoadAll and the no-progress retry
(no callers; with independent paging one loadMore already walks each relay
to its bottom, and the tracker's backstops cover cold starts).
The gift-wrap history loader was round-based: loadMore asked every active
relay together and the next page only went out after the whole round
settled, so one slow or auth-walled relay throttled the cadence and fast
relays idled until the laggards finished. The per-conversation NIP-04
loader already pages each relay independently; this brings NIP-17 to the
same model.
Now a single loadMore opens one window spanning the whole walk, and each
relay continues itself the instant it EOSEs a non-empty page
(pager.beginRound([relay]) + invalidateFilters, which the sub layer diffs
so only the advanced relay re-REQs). Fast relays race to the bottom while
slow ones catch up in the background. The WindowLoadTracker switches to
tracksReqSends=true with an onAbandoned handler that marks silent/
unreachable relays stalled (kept open, still trying) rather than giving up
on them - exhaustion comes from the window settling via the tracker's
silence + connect-grace backstops, which also removes the need for the old
no-progress retry loop.
Drop the now-dead loadEverything/autoLoadAll (no callers; with independent
paging one loadMore already walks each relay to its bottom). Pin the
history floor per window to keep un-advanced relays' filters stable across
the per-EOSE invalidateFilters, and keep reachedBack monotonic over all
relays.
deleteNote() (NIP-09) and removeFromCache() (prune) had drifted into two
near-duplicate "detach a note from the cache" routines. Removal really has
two halves — (1) unlink the note from everything that points AT it, and
(2) handle the note's OWN children — and only the second half differs
between the paths. removeFromCache() already implemented half (1)
completely, so deleteNote() now delegates to it and keeps only its two
delete-specific responsibilities: tearing down gift-wrap hosts and
severing (but keeping) its children via detachFromChildren().
This also fixes a real leak the duplication was hiding. computeReplyTo()
has no ReportEvent branch, so a report note's replyTo is empty and the
report→target link lives only in the explicit reported* index handling.
The old deleteNote() only undid reportedAuthor(), so deleting an
event-level report (reportedPost / reportedAddresses) left the reported
note's `.reports` map holding the removed report note — a partial deletion
that leaked the shell and risked a duplicate Note for the same id.
Delegating to removeFromCache() (author + post + addresses, all
idempotent) closes that gap.
Net behavior change is the report-leak fix only; the redundant
TorrentCommentEvent case is dropped because the torrent target is already
in replyTo (and removed via removeNote), and its @Suppress("DEPRECATION")
goes with it. Adds KDoc to both methods documenting the two-halves model.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
Reframe from a forward-looking plan diff to the current architecture, and fix
the onAuthenticated signature to (event): Boolean (pubKey was dropped). All
structural elements already matched the merged code.
The unloaded-reply loader matched the history card's chrome but dropped
its status line. Surface the same detail: which protocol, how many relays
it's still asking, and how far back it has paged - reusing the card's
historySubtitle so the inline loader and the oldest-end card read
identically.
The auth set is already a per-RelaySession instance field (one session per
connect(), fresh policy per connection), so two connections never share auth
state. Add a regression test: authenticate different pubkeys on two connections
of the same server and assert each scope holds only its own — no union leak.
deleteNote() removed the target from its parents, gatherers, and the cache
map, but never cleared its own child collections nor dropped itself from
its children's replyTo. That left a partial deletion: every child kept the
removed shell alive through replyTo (a leak), and a reply resolved later
via computeReplyTo would getOrCreateNote a *second* Note for the same id —
breaking the one-Note-per-id invariant.
Adds Note.detachFromChildren(), which clears the note's forward child
collections (via removeAllChildNotes) and severs this note from each
child's replyTo (keeping any other parents). deleteNote() now calls it
before notes.remove(), so once the note leaves the map nothing points at
the dead shell. Orphaned replies become roots, which is correct once their
parent is hard-deleted from the cache.
Adds detachFromChildren coverage to NotePruningReferenceTest.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
pubKey was always event.pubKey (the NIP-42 signer is the authenticated
identity), so the parameter was pure redundancy. onAuthenticated(event) and
authorize(event) now read event.pubKey directly; the engine still commits
cmd.event.pubKey. Removes the now-unused HexKey imports from IRelayPolicy and
PolicyStack.
Authentication state (who is logged in on a connection) is connection scope,
not a policy decision. It was stored on FullAuthPolicy, which forced the
AuthScopedPolicy marker, the PolicyStack union, and a downcast in
RequestContext just to route it back out as scope.
Now the engine owns it: RelaySession holds the authenticatedUsers set behind
the (now public) requestContext; the data plane and policies read it through
RequestContext. The policy stays pure decision —
- onConnect(scope, send): a per-connection policy captures the read-only scope
to gate on; shared singletons ignore it.
- onAuthenticated(): Boolean: the policy's vote on whether to record the
verified pubkey (default false, so blind-accept policies never record an
unverified identity). FullAuthPolicy runs authorize() then votes true.
- RelaySession.handleAuth performs the single, engine-side commit after the
whole chain approves and a verifying policy votes to record.
FullAuthPolicy keeps all auth logic (challenge, accept(AuthCmd), gating,
authorize) and gains a protected authenticatedUsers accessor over the scope for
subclasses (restricted content / filter rewrite). Deletes AuthScopedPolicy,
PolicyStack.authenticatedUsers, and the RequestContext downcast.
removeFromCache() relied solely on note.inGatherers to detach a pruned
note from its channels, while deleteNote() additionally resolved the
channel via getAnyChannel() and removed the note there too. inGatherers
is normally authoritative (Channel.addNote always calls addGatherer), so
this is defensive rather than a confirmed live leak — but it closes the
divergence so both removal paths detach channels identically. Guards
against any future consume path that adds a note to a getAnyChannel-
resolvable channel without the gatherer link: otherwise the note would
linger in the channel's notes map after leaving the cache, leaking it and
letting a relay echo mint a duplicate Note with the same id.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s