37 KiB
title, type, status, date, origin
| title | type | status | date | origin |
|---|---|---|---|---|
| WoT fetch via outbox model + PR | fix | completed | 2026-07-06 | PR https://github.com/vitorpamplona/amethyst/pull/3483 review comments (Vitor Pamplona, davotoula) |
WoT fetch via outbox model + PR #3483 review fixes
Overview
PR #3483 (branch feat/wot-shared-index-relays) adds Web-of-Trust badges + shared
Index Relays + amy wot verbs. Two reviewers flagged issues:
- Vitor (owner): stop broadcasting kind-0/kind-3 REQs to a static index-relay list. Use the outbox model: index relays discover each author's kind-10002, then kind-0/kind-3 REQs go to each author's declared write relays.
- davotoula: six correctness / perf / lifecycle bugs across
DesktopLocalCache,WoTService, andFeedMetadataCoordinator— some Desktop-scoped, most incommons/commonMainso Android inherits them the moment WoT gets wired there.
This plan lands both in a single PR revision:
- The outbox-model refactor for kind-0 / kind-3 fetching (Vitor's ask).
- All six correctness/perf/lifecycle fixes (davotoula's ask).
- A sweep confirming no production default references the dying
relay.damus.io.
The scope is intentionally larger than a normal review-fix cycle because the outbox refactor changes the same seams the bug-fixes touch — separating them would produce a churny diff.
Problem Statement
1. Index-relay broadcast is architecturally wrong for kind-0 / kind-3
Current flow (this branch):
Login (~350 follows) ─▶ Main.kt:1315
└── FeedMetadataCoordinator.loadKind3Batched(follows)
└── REQ kinds=[3] authors=[follows chunked/100]
to *every* index relay in
PreferencesIndexRelays.effective()
Semantics:
- Every kind-3 event is fetched from index relays whether or not the author publishes there.
- Users who only publish to their own outbox (increasingly common on modern Nostr) return no kind-3 — WoT signal is wrong (undercount).
- Index-relay operators absorb the entire follow set's worth of REQ authors, even when other relays hold the data.
- The same anti-pattern exists for kind-0 profile metadata (via
loadMetadataBatched) and insideamy wot sync(which reimplements the same broadcast inWotCommand.sync).
Vitor's directive (quoting review):
Kind 0 and 3 must be downloaded from the outbox relay (10002, write) of each user. Basically, find all 10002 events via index relays (purple pages, etc), then parse them all to find a list of relays per author, invert the map to get a list of authors per relay, then use that list to download posts, kind 0 and contact lists from each author.
2. Six correctness / perf / lifecycle bugs
| # | File:line | Symptom | Severity |
|---|---|---|---|
| 1 | DesktopLocalCache.kt:509-530 |
accountPubkey race → self kind-3 stamped lastContactListByAuthor before self-check; later relay retry rejected by createdAt <= prev; empty follow view; FollowAction.follow calls createFromScratch(...) and wipes real follow list |
P0 (data loss) |
| 2 | commons/wot/WoTService.kt:162-190 |
handleFollowSet sets myFollows before the MAX_FOLLOWS guard, guard doesn't return myFollows to empty, and Main.kt:1561 still calls loadKind3Batched when over the cap — CPU/memory blow-up the PR description promised was skipped |
P1 (perf regression on mega-follow accounts) |
| 3 | commons/wot/WoTService.kt |
No close()/dispose() → writer coroutine + Channel leak on account switch; leaks compound over long sessions |
P1 (leak) |
| 4 | commons/wot/WoTService.kt:37-49, 149-160 |
Doc claims "per-key subscriber isolation via Snapshot.withMutableSnapshot" — that's a mis-attribution. Per-key isolation is a SnapshotStateMap property, not a withMutableSnapshot property; the withMutableSnapshot on every op just batches writes. Any consumer that reads the map iteratively (size, keys) will invalidate on every mutation, which the comment claims won't happen. Future Android integrator will trust the comment. |
P2 (misleading docs → landmine) |
| 5 | commons/relayClient/assemblers/FeedMetadataCoordinator.kt:320-368 |
queuedKind3Pubkeys marks pubkeys sent, never reset on failure. If every index relay times out (mobile flake / cold-start), WoT stays empty for the entire session; loadKind3Batched will short-circuit thereafter. |
P1 (silent WoT-empty session) |
| 6 | commons/relayClient/assemblers/FeedMetadataCoordinator.kt:274, 338 |
eoseReceived: MutableSet written from per-relay Dispatchers.IO onEose callbacks with no sync → race can drop an EOSE, blocking on the full 5 s timeout instead of firing early. Low ceiling but pre-existing pattern that this PR duplicates. |
P2 (perf / responsiveness) |
Additional owner-flagged item:
relay.damus.ioshutting down end of month. Confirmed: no production default on this branch references it. Only commonTest fixtures do — leave those alone (they're wire-format fixtures, not runtime relay lists).
Proposed Solution
Outbox refactor: two-phase discovery
Replace the single "broadcast a kind-3 REQ to all index relays" flow with a
two-phase pipeline that reuses existing Quartz infrastructure. The pipeline
lives in commons/commonMain so Desktop, Android (future), and amy all
share it.
┌────────────────────────────────────────────────────┐
│ Phase 1 — kind-10002 discovery (index-relay REQ) │
│ inputs: pubkeys[], indexRelays[] │
│ emits: Map<HexKey, Set<NormalizedRelayUrl>> │
│ (author → declared write relays) │
│ │
│ • REQ kinds=[10002] authors=chunked-by-100 │
│ to every index relay. │
│ • Feed matching AdvertisedRelayListEvent into │
│ LocalCache (so future lookups skip the REQ). │
│ • Per-relay timeout (default 4s), NOT one global.│
└────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────┐
│ Phase 2a — RelayListRecommendationProcessor │
│ inputs: authorMap from Phase 1 │
│ emits: Set<RelayRecommendation> │
│ (relay → author set, minimal cover) │
│ │
│ Reuses Quartz's existing algorithm which: │
│ • builds relay → author set (transpose) │
│ • greedily picks most-popular relay, removes │
│ covered authors, repeats │
│ • second pass to ensure ≥2-relay coverage per │
│ author │
│ • filters onion/localhost per config │
└────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────┐
│ Phase 2b — per-relay kind 0 + kind 3 REQ │
│ For each RelayRecommendation: │
│ REQ kinds=[0,3] authors=[recommendation.users] │
│ with per-relay timeout, single subscription. │
│ Events flow into LocalCache via existing │
│ consume path. │
└────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────┐
│ Phase 3 — Fallback for authors without 10002 │
│ Authors in the input set that never returned a │
│ 10002 fall back to the current index-relay flow │
│ (REQ kinds=[0,3] authors=[fallbackSet] on index │
│ relays). Bounded; only fires when non-empty. │
└────────────────────────────────────────────────────┘
│
▼
onEose() → WoTService.markReadyOnce()
Global 2 s startup fallback in Main.kt stays as the outermost safety net.
Bug fixes (correctness first, always)
Fix 1 — DesktopLocalCache accountPubkey race. Make accountPubkey
either a constructor parameter or a required init that must resolve before
hydration starts. Reorder Main.kt so localCache.accountPubkey = account.pubKeyHex runs before localRelayStore.hydrate(localCache). Belt +
braces: inside consumeContactList, do not stamp lastContactListByAuthor
for events where event.pubKey == accountPubkey unless the self path
actually accepted the event. This eliminates the "poisoned stamp" for the
future relay retry even if a caller ever forgets to bind pubkey first.
Fix 2 — MAX_FOLLOWS guard bypass. Two-part fix:
- In
WoTService.handleFollowSet, when the follow set exceedsMAX_FOLLOWS, setmyFollows = emptySet()and flip adisabled: Booleanflag. BothhandleKind3and every future op must early-return ondisabled. - In the outbox driver's entrypoint (formerly
Main.kt:1561), consultWoTService.isDisabled(new StateFlow) orfollows.size <= WoTService.MAX_FOLLOWSbefore dispatching Phase 1. When over the cap: skip Phase 1 + 2 entirely and callmarkReadyOnce()immediately.
Fix 3 — WoTService.close(). Add:
private val supervisor = SupervisorJob(scope.coroutineContext[Job])
private val serviceScope = CoroutineScope(scope.coroutineContext + supervisor + writerDispatcher)
fun close() {
ops.close()
supervisor.cancel()
}
Call from account-switch (Main.kt clear path) and from DesktopIAccount
disposal. Add an internal AutoCloseable implement so callers can lean on
use { }.
Fix 4 — Correct the misleading comments. Rewrite WoTService KDoc to say:
Scores are exposed via a Compose-observable
SnapshotStateMap. Consumers that read a specific key (scores[pubkey]) recompose only when that key changes — this isSnapshotStateMap's per-key observation. Consumers that iterate the map or read its size will recompose on any mutation.Ops are serialized through a single-writer
Channel. Coalescing writes insideSnapshot.withMutableSnapshot { }batches state commits so a multi-key op emits a single Compose invalidation instead of one per key.
No behaviour change; the comment is the fix.
Fix 5 — queuedKind3Pubkeys retryable. Convert the current mark-on-send
set into mark-on-EOSE:
- Track
inFlight: MutableSet<HexKey>for de-duplication during a single call. - On successful EOSE (or per-relay EOSE), move pubkeys into
succeeded(unchanged behaviour: skip future REQs). - On global timeout with zero events for a pubkey, do not promote to
succeeded; keep them retryable on the nextloadKind3Batched/loadKind3ViaOutboxcall. - Cheap: same
Setmechanics, just gated by outcome instead of intent.
Fix 6 — Synchronise eoseReceived. Two options; pick (b):
- (a) Wrap in
Mutex/synchronized—synchronizedneeds a JVM-only path or anexpect/actual. - (b) Use a single-writer coroutine: replace the
MutableSet+CompletableDeferred<Unit>handshake with aChannel<NormalizedRelayUrl>( capacity = Channel.UNLIMITED)+ a launched consumer that increments a local counter and completes the deferred when it hitsindexRelays.size. Same shape, zero shared mutable state across dispatchers. KMP-clean.
Apply the same fix to both loadKind3Batched and loadMetadataBatched
because the pattern is duplicated.
Damus sweep
Grep of the current branch found relay.damus.io only in test fixtures
(FeedDefinitionSerializerTest, TorRelayEvaluationTest, RichTextParserTest,
ZapSplitResolverTest). None are production defaults. DEFAULT_INDEX_RELAYS
= {nos.lol, nostr.wine, noswhere, primal.net};
AmethystDefaults.DefaultIndexerRelayList = {purplepages, coracle, userkinds, yabu, nostr1}. Leave the test fixtures alone (they exercise URL parsing on
canonical example URLs — replacing them adds churn without protecting users).
Include a one-line status note in the PR description so Vitor sees "checked".
Technical Approach
Architecture — where each piece lives
Following the codebase-specific rule (commons/ARCHITECTURE.md): "protocol
in Quartz, business logic in commons, layouts in platform apps."
quartz/ (unchanged — reuse only)
nip65RelayList/AdvertisedRelayListEvent.kt — parser (existing)
nip65RelayList/RelayListRecommendationProcessor — transpose + cover (existing)
commons/commonMain/
wot/WoTService.kt — bug fixes 2/3/4
wot/OutboxDispatcher.kt — NEW (Phase 1-3 driver)
wot/OutboxRelayLoader.kt — MOVED from amethyst/,
Flow<Map<relay, authors>>
relayClient/assemblers/FeedMetadataCoordinator.kt — bug fixes 5/6 + calls
into OutboxDispatcher when
configured
desktopApp/jvmMain/
Main.kt — reorder localCache init,
call OutboxDispatcher
cache/DesktopLocalCache.kt — bug fix 1
— new consumeAdvertisedRelayList
path
cli/
commands/WotCommand.kt — amy wot sync via
OutboxDispatcher
OutboxDispatcher API (draft)
// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcher.kt
class OutboxDispatcher(
private val client: INostrClient,
private val scope: CoroutineScope,
private val indexRelays: () -> Set<NormalizedRelayUrl>, // lazy — respects settings updates
private val cache: OutboxCacheGateway, // interface, actual = DesktopLocalCache
private val perRelayTimeoutMs: Long = 4_000,
) {
data class Result(
val kind10002Received: Int,
val kind3Received: Int,
val kind0Received: Int,
val fallbackAuthors: Int,
)
/**
* Fetch kind-3 and kind-0 for [authors] via each author's declared write
* relays (NIP-65). Falls back to [indexRelays] for authors with no 10002.
*
* Suspending — returns after every phase EOSEs or times out. Callers
* that need "return immediately, mark ready later" should wrap in
* [scope.launch].
*/
suspend fun fetchKind0And3(authors: Set<HexKey>): Result
suspend fun fetchKind3Only(authors: Set<HexKey>): Result // WoT-specific
}
interface OutboxCacheGateway {
/** Returns the cached kind-10002 for [pubkey] if the local store already has one. */
fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent?
/** Called for every 10002 that comes back — cache should stash it. */
fun onOutboxDiscovered(event: AdvertisedRelayListEvent, relay: NormalizedRelayUrl)
/** Called for every kind-3 / kind-0 that comes back — cache should route through its consume path. */
fun onDiscoveredEvent(event: Event, relay: NormalizedRelayUrl)
}
DesktopLocalCache implements OutboxCacheGateway; amy gets a minimal
implementation that writes into its local store.
OutboxRelayLoader (moved from amethyst/) provides the live Flow-form for
reactive lookups; OutboxDispatcher uses it internally for the "check cache
first, only REQ what's missing" fast-path.
Reactivity for "new follow arriving"
Current code (Main.kt:1559) collects localCache.followedUsers and calls
loadKind3Batched(follows) on every change. The dedup set means the diff
(only new pubkeys) actually flows through.
Under the outbox model, the analogous flow is:
localCache.followedUsers.collect { follows ->
wotService.onFollowSetChange(follows, account.pubKeyHex)
if (wotService.isDisabled) { wotService.markReadyOnce(); return@collect }
launch {
val result = outboxDispatcher.fetchKind3Only(follows) // dedup inside
wotService.markReadyOnce()
}
}
fetchKind3Only internally consults inFlight + succeeded and only REQs
the diff. Test scenario "user follows one new person mid-session" trivially
covered because Phase 1 for a single-element authors set is a single index-
relay REQ, and Phase 2 is one per-outbox REQ.
amy wot sync under outbox
Replace the manual Filter/chunked/ctx.drain(...) block in
WotCommand.sync with:
val dispatcher = OutboxDispatcher(client, scope, ctx::indexRelays, AmyCacheGateway(store))
val result = dispatcher.fetchKind3Only(follows.toSet())
Output.emit("wot sync",
"10002=${result.kind10002Received} kind3=${result.kind3Received} " +
"fallback=${result.fallbackAuthors}")
The JSON schema for --json gains three new keys (kind10002_received,
fallback_authors, kind3_received) — additive, no rename.
Concurrency & KMP concerns
- All new code targets
commonMain. Nojava.util.concurrent, nosynchronized {}(needs jvmAndroid actual). Rely onChannel,Mutex,StateFlow, andSnapshot— all KMP-safe. Dispatchers.IOisn't KMP either; useDispatchers.Defaultin commonMain and let platform code override if needed.- Per-relay timeouts implemented via
withTimeoutOrNull(perRelayTimeoutMs)inside per-relay coroutines; overall EOSE gate uses aCompletableDeferred<Unit>that trips when either (a) all per-relay jobs complete or (b) the outerwithTimeoutOrNull(overallCap)fires.
Data flow: how discovered 10002s stop double-fetching
Every AdvertisedRelayListEvent received during Phase 1 goes through
OutboxCacheGateway.onOutboxDiscovered(event, relay) → the platform cache's
consume path. Next call for the same author checks cachedOutbox(pubkey)
before dispatching Phase 1, so we never REQ the same 10002 twice within a
session (or across sessions, if the local relay store persists the 10002 —
which it does, since kind-10002 events are indexed like any other event).
Implementation Phases
Phase 1 — Bug fixes (correctness first, self-contained)
Ship-blockers, no outbox dependency, land these commits first so a revert doesn't force rolling back the outbox refactor:
fix(desktop-cache): eliminate accountPubkey race in consumeContactList— reorder Main.kt so pubkey binds before hydrate; gatelastContactListByAuthorstamp inside self branch. Test:DesktopLocalCacheHydrationTest— reproduce the wipe by running hydration before pubkey bind, assert follow set survives relay retry.fix(wot): clear myFollows + set disabled flag when MAX_FOLLOWS exceeded— plus a Main.kt short-circuit before dispatching Phase 1. Test:WoTServiceTest.overCapDisablesEverything.refactor(wot): close()/dispose() + AutoCloseable, call from account-switch— Test:WoTServiceLifecycleTest.closeCancelsWriter.docs(wot): correct SnapshotStateMap isolation comments— comment-only.fix(coordinator): mark queuedKind3Pubkeys only on EOSE, allow retry on timeout— Test:FeedMetadataCoordinatorTest.timeoutRetryIsAllowed.fix(coordinator): single-writer EOSE aggregator (KMP-safe)— Test:FeedMetadataCoordinatorTest.eoseReadyUnderConcurrentCallbacksusing a fake client that fires EOSE from multiple dispatchers.
Success criteria phase 1: all six tests pass; ./gradlew :commons:jvmTest :desktopApp:jvmTest :cli:test green; ./gradlew spotlessApply clean.
Phase 2 — Outbox scaffolding (commons)
refactor(commons): move OutboxRelayLoader from amethyst/ to commons/commonMain— pure code motion; leave a re-export in the amethyst package to avoid Android build breaks. Test: existing AndroidOutboxRelayLoaderTest(if any) still passes.feat(commons): OutboxDispatcher two-phase kind-0/kind-3 fetcher— commonMain, plus jvmMain test that drives a fakeINostrClientthrough Phase 1/2/3 including the fallback path.feat(commons): OutboxCacheGateway interface + DesktopLocalCache impl— including a newconsumeAdvertisedRelayList(event, relay)inDesktopLocalCachethat mirrors the existingconsumeContactListpattern.
Success criteria phase 2: ./gradlew :commons:jvmTest green;
OutboxDispatcherTest covers "author with 10002", "author without 10002 →
fallback", "index relay times out on Phase 1", and "per-relay timeout on
Phase 2 doesn't cancel other relays".
Phase 3 — Cutover (Main.kt + amy)
feat(desktop): route WoT kind-3 fetch through OutboxDispatcher— Main.kt uses OutboxDispatcher; delete the directloadKind3Batchedcall. Preserve the 2 s startup fallback formarkReadyOnce.feat(desktop): also route stranger-avatar kind-0 through OutboxDispatcher— MetadataPreloader gets a hook that prefers outbox when a 10002 exists for the author.feat(cli): amy wot sync via OutboxDispatcher— rewrite the manual filter/drain inWotCommand.sync. Update its--jsonschema (additive).
Success criteria phase 3: manual testing sheet (Section: Test Plan)
passes end-to-end. ./gradlew test green.
Phase 4 — Documentation & PR description
- Update PR description's "Behaviour" section to reflect the outbox flow.
- Add a top-level "Damus relay: production defaults verified clean" line so Vitor doesn't have to look.
Alternative Approaches Considered
A. Do the outbox refactor in a follow-up PR. Rejected by user: the same files (WoTService, FeedMetadataCoordinator, Main.kt) also need the review fixes, so a two-PR split would double the churn in the same seams.
B. Skip Phase 1 (10002 discovery) and read the local cache only. Would break for cold-start accounts with no cached 10002s. Only works for "warm-cache" sessions, defeating Vitor's ask on first login.
C. Adopt AmethystDefaults.DefaultIndexerRelayList as the new index-relay
default. The current branch keeps {nos.lol, nostr.wine, noswhere, primal.net} for continuity. Adopting the Purple Pages / Coracle / etc. set
is a user-visible behaviour change deserving its own review. Deferred to a
follow-up ticket. Documented in PreferencesIndexRelays.kt:85-92 already;
no action here.
D. Use graperank as Vitor idly mused in the follow-up comment. Not
actionable in this PR — it's a musing about extending amy, not a review
change. Called out here so the item doesn't get lost, but leave for a
future ticket.
System-Wide Impact
Interaction Graph
Login
└─ Main.kt:1541 LaunchedEffect binds localCache.accountPubkey
└─ (Fix 1: must run BEFORE the block below)
└─ Main.kt:893 launch(Dispatchers.IO) { localRelayStore.hydrate(localCache) }
└─ per-event: localCache.justConsumeMyOwnEvent → consumeContactList
└─ before fix: stamps lastContactListByAuthor with null accountPubkey
└─ after fix: stamp only inside self-branch, or after ordering guarantee
Login (parallel)
└─ Main.kt:1559 collect(followedUsers) →
└─ WoTService.onFollowSetChange
└─ ops.trySend(FollowSet) → writerLoop → handleFollowSet
└─ Fix 2: MAX_FOLLOWS → clear myFollows + disabled=true, return
└─ if !disabled: OutboxDispatcher.fetchKind3Only(follows)
└─ Phase 1: REQ 10002 on index relays
└─ OutboxCacheGateway.onOutboxDiscovered → DesktopLocalCache.consumeAdvertisedRelayList
└─ Phase 2a: RelayListRecommendationProcessor.reliableRelaySetFor
└─ Phase 2b: per-relay REQ kind=[0,3] authors=[relay's users]
└─ OutboxCacheGateway.onDiscoveredEvent → LocalCache.consume path
└─ consumeContactList (fixed) → _contactListEvents.tryEmit
└─ WoTService.applyKind3 → handleKind3 → updateScore
└─ Phase 3: fallback for missing-10002 authors → index-relay REQ
└─ WoTService.markReadyOnce → _isReady.value = true → badge composables recompose
Account switch
└─ Main.kt:874 localCache.clear() → resets lastContactListByAuthor
└─ Fix 3: WoTService.close() → cancels writer, drops ops channel
└─ OutboxDispatcher scope cancels → in-flight REQs unsubscribe
Error & Failure Propagation
client.subscribefailure insideOutboxDispatcher→ swallowed at the per-relay coroutine level, logged, moves on. The overallwithTimeoutOrNullensures the caller never blocks past its budget.AdvertisedRelayListEvent.writeRelaysNorm()returning null (author has a 10002 but empty write list) → falls through to Phase 3 fallback.- Cache consume path errors (e.g. corrupt event) → existing
LocalCachebehavior; not new.
State Lifecycle Risks
- Between
WoTService.close()andOutboxDispatcherscope cancel there's a small window where a pending REQ EOSE could arrive at a torn-down service. Mitigation:OutboxCacheGateway.onDiscoveredEventandWoTService.applyKind3must be null-guarded against the "already-closed" state — WoTService's writerLoop naturally handles this (channel closed → loop exits). - Fix 1 requires Main.kt reordering; if the reorder is done wrong and pubkey
bind is later than hydration, the bug recurs silently. Test:
DesktopLocalCacheHydrationTest.regressionOrderingProtection.
API Surface Parity
amy wot sync and Desktop login both consume the same OutboxDispatcher,
so any protocol change propagates. Android is a future consumer — the
plan intentionally lives in commons/commonMain so wiring Android on top
is a Main.kt-equivalent + gateway impl.
Integration Test Scenarios
- Cold-start login, well-connected account (~350 follows, ~90% with 10002): Phase 1 completes, Phase 2 fetches only from write relays, Phase 3 kicks in for the ~10% no-10002 authors, WoT ready < 5 s, badges render.
- Cold-start login, ~4000-follow account: MAX_FOLLOWS trips → dispatcher
skipped,
markReadyOnce()immediately, no badges, no REQ traffic. - Cold-start login, all index relays unreachable: overall timeout fires,
markReadyOnce(); on nextfollowedUsersemission,inFlightis empty (thanks to fix 5) so a retry happens. - Mid-session follow: single-author
fetchKind3Only({newPubkey})uses cache hit ifcachedOutbox(newPubkey) != null, else does one Phase-1 REQ. - Account switch:
WoTService.close()runs; opening the same account again creates a fresh instance without leaking the previous writer coroutine. amy wot syncon a headless VM with only the OS event store: writes 10002 + kind 3 events to disk; second run ofamy wot get <hex>returns the correct hydrated score.
Acceptance Criteria
Functional
DesktopLocalCache.consumeContactListno longer stampslastContactListByAuthorfor the self path unlessaccountPubkeyis set and the event matches. Regression test exists.WoTServiceexposesisDisabled: StateFlow<Boolean>; caller (Main.kt) skips OutboxDispatcher when disabled.WoTServiceimplementsAutoCloseable; account-switch path callsclose().FeedMetadataCoordinator.loadKind3BatchedandloadMetadataBatchedretry on timeout (pubkeys not promoted tosucceeded).- Both
loadKind3BatchedandloadMetadataBatcheduse single-writer EOSE aggregation (noMutableSetshared across dispatchers). OutboxDispatcher.fetchKind3OnlyandfetchKind0And3exist in commons/commonMain with test coverage for the four scenarios in "Integration Test Scenarios".Main.ktlogin path usesOutboxDispatcherfor kind-3 seeding (WoT + follow-set metadata).amy wot syncusesOutboxDispatcher;--jsonoutput additively gainskind10002_received,kind3_received,fallback_authors.
Non-functional
./gradlew testgreen../gradlew spotlessApplyclean before commit.- No production default relay list on this branch references
relay.damus.io(already verified; keep it verified after refactor). - No use of
java.util.concurrent/ JVM-onlysynchronized {}incommons/commonMain/. - KDoc for
WoTServiceaccurately describes SnapshotStateMap per-key isolation.
Quality Gates
- Manual regression sheet covering integration scenarios 1-6 above.
amy wot sync --jsonsample output attached to PR description.- Follow-list-wipe regression covered by an automated test that hydrates a cached kind-3 before binding pubkey and asserts nothing is poisoned.
Success Metrics
- WoT badge coverage on real accounts (Vitor's expected win): jump from "index-relay-published authors only" to "any author with a 10002" — measured by running the desktop app before/after and diffing the badge count on a fixed follow-set.
- Zero follow-list-wipe reports in the two weeks after merge (davotoula finding 1 was worst-case data loss).
- Zero index-relay REQ traffic for kind-0/kind-3 authors that publish a 10002. Measurable by wireshark on a test build.
Dependencies & Prerequisites
- Quartz
AdvertisedRelayListEvent+RelayListRecommendationProcessor— already exist, reused verbatim. INostrClient.subscribe(subId, filters, listener)— already exists.DesktopLocalCacheneeds a newconsumeAdvertisedRelayList(event, relay)method — mirrors existingconsumeContactListstructure.OutboxRelayLoader— moved fromamethyst/tocommons/commonMain; Android continues to compile because it only depends on things already in commons/quartz.
No new third-party libraries introduced. No libs.versions.toml change.
Risk Analysis & Mitigation
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Outbox refactor changes badge counts on live user accounts unexpectedly | Med | Med | Keep the Phase-3 fallback path so no author gets worse coverage than today. Manual A/B test on maintainer's account before merge. |
RelayListRecommendationProcessor.reliableRelaySetFor picks pathologically many relays for a fragmented follow set |
Low | Low | Algorithm already caps by second-pass "at least 2 relays per author" rule. Add a hard MAX_RELAYS_PER_FETCH (say 40) as a belt-and-braces guard. |
| Concurrent EOSE handshake rewrite introduces a new bug | Low | High | Test FeedMetadataCoordinatorTest.eoseReadyUnderConcurrentCallbacks with a fake client firing EOSE from three dispatchers 1000× to catch ordering assumptions. |
| Bug fix 1 (Main.kt reordering) breaks another consumer that read localCache before accountPubkey bind | Med | Med | Grep for all localCache.accountPubkey reads; verify none pre-date the bind. If any, thread the pubkey through as a parameter. |
Adopting AutoCloseable on WoTService misleads callers into thinking it's use-scoped |
Low | Low | Comment on close() says "call from account-switch/dispose only; instance lives for the account session". |
amy wot sync --json schema change breaks downstream scripts |
Med | Low | Additive fields only, no renames. Document in cli/plans/* if a plan exists there. |
Resource Requirements
- One engineer, ~2-3 days including tests + manual regression.
- Test-relay access: can use
wss://nos.loland Purple Pages for real Phase 1 verification. - Access to a mega-follow test account (>2000 follows) to verify Fix 2.
- Access to an account with a well-populated 10002 network to verify Phase 2 does what we think.
Future Considerations
- Android wiring:
AndroidAppcurrently doesn't wire WoTService. When it does, it can lean on the sameOutboxDispatcher— expected diff is Main-equivalent + aLocalCachegateway. - Graperank scoring (Vitor's follow-up musing): if
amy wotgrows a scoring strategy plugin API,OutboxDispatcherremains unchanged; only the post-fetch aggregation layer insideWoTServicechanges. - Adopting
AmethystDefaults.DefaultIndexerRelayList: separate ticket. Deserves its own review because it's a user-visible behaviour change.
Documentation Plan
- Update this plan's status to
completedpost-merge; write a short "solutions" note if the accountPubkey race surprised us elsewhere. - Update PR description "Behaviour" section to reflect outbox path.
- Update
commons/ARCHITECTURE.md"where does my code go?" section with a one-line entry forOutboxDispatcher.
Sources & References
Origin
- PR review comments: https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892248528 (davotoula, bugs 1+2) https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892272000 (davotoula, bugs 3-6 impact on Android) https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892302009 (vitorpamplona, outbox directive) https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892686911 (vitorpamplona, Graperank musing — out of scope)
Internal References
- Kind-10002 parser:
quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt - Relay-cover algorithm:
quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt - Existing Android outbox loader:
amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt - WoT service (bugs 2-4):
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt - Feed metadata coordinator (bugs 5-6):
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt - Cache race (bug 1):
desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:509-530 - Main wiring:
desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:1541-1568, 764-768, 863-874 - amy WoT:
cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt - Index relay persistence:
commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt - Baseline plan the PR extended:
desktopApp/plans/2026-07-01-feat-desktop-wot-score-plan.md
External References
- NIP-65 (Relay List Metadata): https://github.com/nostr-protocol/nips/blob/master/65.md
- Original plan for the current PR:
docs/plans/2026-07-01-feat-wot-followups-search-badges-and-index-relays-plan.md
Related Work
- PR #3483 (this PR): https://github.com/vitorpamplona/amethyst/pull/3483
- Prior WoT badge PR (base for this branch):
feat/desktop-wot-score
Unanswered questions
- Per-relay timeout budget — 4 s picked from thin air. Real number?
- Should the fallback in Phase 3 also hit the account's own home/search
relays, matching Android's
pickRelaysToLoadUserscascade? Or index-only? - WoTService.close() called from account-switch — what's the canonical disposal hook on Desktop? DesktopIAccount teardown?
- amy wot sync
--jsonschema — isfallback_authorsthe right name, or match Android naming? - Should
OutboxDispatcherbe a per-account singleton (like WoTService) or short-lived per fetch? Leaning singleton for the dedup set. - Do we want to persist the "author has no 10002" fact so we skip Phase 1 for them on next login? Requires a small persistent map — worth it?
- Should Fix 1 (accountPubkey race) be split into its own hotfix commit before the outbox refactor lands, so backporters have a clean cherry-pick?