Files
amethyst/docs/plans/2026-07-01-feat-desktop-wot-score-plan.md
T
nrobi144 9284e12e86 feat(desktop): Web-of-Trust score badges + amy wot verbs
Adds a friends-of-friends trust score on every user avatar in Desktop
feeds, threads, profile headers, and repost overlays. For pubkey X the
score is the count of accounts in the active user's follow set who also
follow X — Gossip / Snort convention. v1 is display-only; no threshold
filtering.

Data flow
- `commons/wot/WoTService` — sparse `SnapshotStateMap<HexKey, Int>` +
  reverse index + per-follower snapshot for diff-based updates.
  Single-writer coroutine (Channel<Op> → `Snapshot.withMutableSnapshot`)
  serializes all mutations. Cap at 5000 follows/event blocks DoS via
  hostile kind-3s. Guardrail at 2000 follows/account skips WoT for
  mega-accounts.
- `DesktopIAccount.wotService` — per-account instance, matches
  `Kind3FollowListState` / `BookmarkListState` conventions.
- `Main.kt` binds `localCache.accountPubkey`, collects
  `localCache.contactListEvents` → `applyKind3`, collects
  `localCache.followedUsers` → `onFollowSetChange` +
  `subscriptionsCoordinator.loadKind3Batched(...)` with
  `onEose = markReadyOnce`. 2 s fallback timeout guarantees badge
  visibility even if index relays never EOSE.
- `FeedMetadataCoordinator.loadKind3Batched(pubkeys, onEose)` — chunks
  authors into ≤100 per Filter within one subscription. Matches
  nostr-rs-relay defaults.

UI
- `commons/ui/components/UserAvatar` gets an optional
  `badge: @Composable BoxScope.() -> Unit`. Android call sites pass
  null (no compile-time coupling to Desktop-only tooltip APIs).
- `desktopApp/.../ui/note/WoTBadge` — Material3 `TooltipBox` +
  `PlainTooltip` (multiplatform-ready, keyboard/screen-reader a11y).
  `rememberTooltipState(isPersistent = true)` fixes the
  vanish-too-fast desktop default.
- `desktopApp/.../ui/note/WoTBadgedAvatar` — drop-in replacement for
  `UserAvatar` that overlays the badge when
  `LocalWoTService != null && LocalWoTReady && pubkey !in LocalSpamExemptKeys`.
  Score read is a plain `service.scores[userHex] ?: 0` — snapshot
  system tracks per-key, so avatars only recompose when their own
  score changes.
- Call-site migration at 4 v1 surfaces: NoteCard header (covers feed /
  thread / bookmarks / search / QuotedNoteEmbed via NoteCard),
  FeedNoteCard repost header (2 avatars), UserProfileScreen header
  (2 sizes).

Amy verbs
- `amy wot get <pubkey|npub> [--json]` — hydrates a WoTService from the
  local FsEventStore, prints score for target pubkey.
- `amy wot list [--threshold N] [--limit K] [--json]` — sorted score
  list.
- `amy wot sync [--timeout N]` — batch-fetches kind-3 for the active
  follow set from outbox/inbox relays, persists to the event store.

Tests + docs
- 14 unit tests: `WoTServiceTest` covers happy path, sparse map,
  self/follower exclusion, kind-3 churn diff, guardrail, event cap,
  ready gate, clear.
- Manual testing sheet with 17 scenarios at
  `desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md`.
- Plan at `docs/plans/2026-07-01-feat-desktop-wot-score-plan.md`.

Prerequisite `DesktopLocalCache.consumeContactList` scoping fix landed
as a separate commit.
2026-07-02 18:03:47 +03:00

49 KiB
Raw Blame History

title, type, status, date, origin, deepened
title type status date origin deepened
Desktop Web-of-Trust Score Badges feat active 2026-07-01 docs/brainstorms/2026-07-01-feat-wot-score-brainstorm.md 2026-07-01

Desktop Web-of-Trust Score Badges

Enhancement Summary

Deepened on: 2026-07-01 (same day as plan write).

Review agents used: architecture-strategist, code-simplicity-reviewer, pattern-recognition-specialist, performance-oracle, security-sentinel, agent-native-reviewer · plus best-practices research (SnapshotStateMap + Compose tooltip patterns) and a code-verification sweep.

Key corrections vs initial draft

  1. Prerequisite: fix DesktopLocalCache.consumeContactList. The current implementation writes _followedUsers = event.verifiedFollowKeySet() for any incoming kind-3, gated only on a single global lastContactListCreatedAt scalar. Once WoT starts fetching followed authors' kind-3 events, this actively corrupts the active user's follow-set state. Refactor to per-author Map<HexKey, Long> and guard the _followedUsers / lastContactListEvent write on event.pubKey == account.pubKeyHex. This is a prerequisite commit in this PR — WoT cannot ship without it.
  2. Badge is a slot, not an embed. UserAvatar in commonMain gets an optional badge: @Composable (BoxScope.() -> Unit)? = null parameter. Desktop call sites pass a WoTBadge()-bearing lambda; Android passes null. No CompositionLocal reads inside the shared composable, no expect/actual dance, TooltipBox import stays in the Desktop-only source set of the caller.
  3. Service on DesktopIAccount, not in remember. Match the established pattern of Kind3FollowListState, BookmarkListState, Nip65RelayListState — the service is a field on DesktopIAccount constructed with account.scope, exposed as a property, provided via LocalWoTService from Main.kt. Lifecycle matches the login session, not the composition.
  4. Kind-3 event flow via SharedFlow<ContactListEvent>. Not a mutable listener list. DesktopLocalCache exposes a SharedFlow<ContactListEvent> (buffer 64, DROP_OLDEST overflow), emitted from inside consumeContactList. WoTService collects it from an account.scope-scoped coroutine.
  5. Batch kind-3 loader — chunk 100 per Filter, explicit onEose hook. Existing FeedMetadataCoordinator.loadMetadataBatched at line 267 silently does authors.take(100) — blind copy would drop 400 of 500 follows. New loadKind3Batched chunks into ≤100-author Filters, aggregates EOSE across chunks, and exposes onEose: () -> Unit so the caller can markReady() on real EOSE (not just the 2 s timeout).
  6. Use Material3 TooltipBox, not TooltipArea. Multiplatform, built-in a11y (screen readers, keyboard focus), and the JetBrains deprecation direction (JB #4275). Set state = rememberTooltipState(isPersistent = true) to fix the known "vanishes too fast" desktop bug.
  7. WoTScore sealed hierarchy → plain Int + sparse map. Drop Unknown — represent "not queried" as absence from the map. _scores.remove(target) when count hits 0. Keeps the map sparse and Compose subscriber tracking cheap.
  8. Drop derivedStateOf at the leaf. Plain service.scores[userHex] ?: 0 is already snapshot-tracked per key. derivedStateOf adds a subscriber node and a comparator per avatar for no gain when there's only one read.
  9. Readiness as a plain Boolean CompositionLocal, not per-avatar collectAsState. Collect isReady once at App root, provide via LocalWoTReady. 150 collectors per screen becomes 1.
  10. Batch writes with Snapshot.withMutableSnapshot { }. applyKind3 mutates _scores for many keys — wrap the loop in a single mutable snapshot so all readers see one atomic frame.
  11. Amy verbs ship in v1 (not v2). amy wot get <pubkey>, amy wot list --threshold N, amy wot sync. Service exposes scoresSnapshot(): Map<HexKey, Int> for headless readers. FsEventStore at ~/.amy/shared/events-store/ already caches kind-3 events → warm-cache queries need no relay traffic.
  12. Bound follows per event. Cap verifiedFollowKeySet() result at 5000 entries in applyKind3 — prevents CPU DoS from a hostile follower publishing a 100k-tag kind-3.
  13. WoTService writes on Dispatchers.Default, single-writer coroutine. Serialize applyKind3, onFollowSetChange, etc. inside an actor-style coroutine so composite state stays consistent across concurrent kind-3 arrivals.

New considerations discovered

  • Simplicity review argued for dropping WoTScore, isReady, and perFollowerSnapshot. Kept the last two (correctness vs churn) but agreed on dropping WoTScore.
  • Pattern review pushed for commons/moderation/wot/ (sibling to hashtag-spam). Kept commons/wot/ — v1 is display-only, not moderation. If v2 adds filtering we relocate then.
  • Security review confirmed: follow-list leak via batch REQ is pre-existing (metadata coordinator already sends the same authors list to indexRelays). WoT introduces no novel privacy regression.
  • All ingested events are event.verify()-checked before LocalCache.consume runs (DesktopLocalCache.kt:191) — forged kind-3s cannot pass the sig check. Score inflation via fake events is not a viable attack.

Overview

Compute a friends-of-friends trust score for every pubkey based on the active user's follow graph, and render it as a small number chip overlaid on UserAvatars at Desktop call sites. v1 is display-only — no filtering. Kind-3 follow lists for the active user's follows are fetched via a proactive chunked batch REQ at login and kept current through incremental diff updates.

Carried forward from brainstorm (docs/brainstorms/2026-07-01-feat-wot-score-brainstorm.md): raw-count semantics · display-only · auto-hide badge when score = 0 · hide badge on already-followed authors and self · number chip in avatar corner · tooltip explaining the count · proactive batch kind-3 REQ at login + refresh on follow-list change · no cross-session persistence · no settings UI.

Resolved during planning + deepening:

  • Platform target: Desktop only in v1. Badge is a slot on shared UserAvatar; Desktop call sites pass the lambda, Android passes null.
  • Score model: plain Int in a sparse SnapshotStateMap. Absence ≡ "not queried yet or definitively zero." Both hide the badge.
  • Prerequisite: fix consumeContactList scope corruption.
  • Batch REQ: chunked into ≤100-author Filters, aggregated EOSE.
  • Startup gate: isReady = true after first-chunk EOSE OR 2 s timeout, whichever first. Single Boolean provided via CompositionLocal.
  • Guardrail: skip WoT graph if myFollows.size > 2000.
  • Overflow: display "99+" when count > 99.
  • Amy CLI: three verbs ship in v1 (get, list, sync).

Problem Statement

Nostr's public graph makes it easy for a stranger to appear in your notifications, mentions, search results, or as a repost's original author. Without a trust cue you have to click into each profile to assess. Gossip and Snort solved this with a friends-of-friends count: "N of the people you follow also follow this person." On Desktop, where 3–6 columns and dozens of avatars are on screen at once, that cue scales better than mobile. Amethyst Desktop today shows every pubkey identically.

Proposed Solution

High-level approach

Introduce a per-account WoTService attached to DesktopIAccount (mirroring kind3FollowList, bookmarkList, nip65RelayList). At login the service:

  1. Observes the active user's follow set from DesktopLocalCache.followedUsers.
  2. Issues chunked batch REQs (≤100 authors each, aggregated EOSE) for kinds=[3] on the same indexRelays used by FeedMetadataCoordinator.loadMetadataBatched.
  3. Collects a new DesktopLocalCache.contactListEvents: SharedFlow<ContactListEvent> and calls applyKind3(...) for each event whose author is in the follow set.
  4. Maintains a reverse index (Map<HexKey, MutableSet<HexKey>> from target-pubkey → set of my-follows who follow them) and a per-follower snapshot (Map<HexKey, Set<HexKey>>) for diff updates.
  5. Publishes score changes atomically to _scores: SnapshotStateMap<HexKey, Int> inside a Snapshot.withMutableSnapshot { } block.
  6. Marks isReady = true on aggregated EOSE or 2 s timeout — whichever fires first.

Desktop UI uses a sibling composable WoTBadgedAvatar (in desktopApp/.../ui/note/) that composes the shared UserAvatar with a badge slot. The slot renders a WoTBadge when four gates pass:

  • LocalWoTReady.current == true
  • service.scores[pubkey] > 0
  • pubkey != selfPubkey
  • pubkey !in followedKeys

WoTBadge uses Material3 TooltipBox with state = rememberTooltipState(isPersistent = true).

Why this shape

  • Per-account service on DesktopIAccount matches the codebase convention (kind3FollowList, bookmarkList). remember inside composition ties lifecycle to the composable's tree, which is wrong for a data service.
  • Sibling composable, not embedded slot in UserAvatar. Prior art: BunkerHeartbeatIndicator — decoration lives next to the primitive, not inside it. Explicit call-site migration is a feature: it lets us ship v1 on high-value surfaces (feeds, thread, notifications, profile) and defer minor spots.
  • Slot on UserAvatar for Desktop-owned rendering. Even the sibling composable needs somewhere to draw the badge. Adding a scalar badge slot to UserAvatar avoids duplicating the entire avatar layout and keeps Android intact (nulls the slot).
  • Sparse SnapshotStateMap. Storing Unknown for every unqueried pubkey would bloat the map to 250 k entries; keeping it sparse (only positive scores) reduces subscriber overhead 10×.
  • Chunked batch REQ. Relays vary wildly in filter-size caps (nostr-rs-relay defaults ~100, strfry accepts hundreds). Chunking 100 authors per Filter within one subscription is the correct pragmatic default.
  • Diff-based applyKind3. Kind-3 events churn (many clients republish frequently). Full recompute on every event would drop the reverse index and lose recomposition isolation. Diff keeps per-target changes minimal.
  • Amy parity in v1. Service is a plain class in commons/; the three verbs are ~150 LOC total. Skipping them trains the wrong habit.

Architecture

┌──────────────────────────────────────────────────────────────────┐
│ commons/  (platform-agnostic; callable by Desktop, amy, Android) │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ commonMain/                                                  │ │
│ │   wot/WoTService.kt          Snapshot map + reverse index    │ │
│ │                              + applyKind3, onFollowSetChange │ │
│ │                              + awaitReady(onEose, timeout)   │ │
│ │                              + scoresSnapshot() (headless)   │ │
│ │   wot/LocalWoTService.kt     CompositionLocal (nullable)     │ │
│ │   wot/LocalWoTReady.kt       CompositionLocal<Boolean>       │ │
│ │   ui/components/UserAvatar.kt  + badge: @Composable slot     │ │
│ └──────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
                              ▲
                              │
┌─────────────────────────────┴────────────────────────────────────┐
│ desktopApp/                                                      │
│   cache/DesktopLocalCache.kt (PREREQUISITE FIX)                  │
│     - lastContactListByAuthor: MutableMap<HexKey, Long>          │
│     - contactListEvents: SharedFlow<ContactListEvent>            │
│     - _followedUsers writes gated on event.pubKey==self          │
│   model/DesktopIAccount.kt                                       │
│     val wotService = WoTService(scope)                           │
│   subscriptions/DesktopRelaySubscriptionsCoordinator.kt          │
│     fun loadKind3Batched(pubkeys, onEose: () -> Unit)            │
│   ui/note/WoTBadgedAvatar.kt                                     │
│     Composes UserAvatar with a WoTBadge slot lambda              │
│   ui/note/WoTBadge.kt                                            │
│     Material3 TooltipBox + Box overlay chip                      │
│   Main.kt (LoggedIn branch)                                      │
│     CompositionLocalProvider(                                    │
│       LocalWoTService provides account.wotService,               │
│       LocalWoTReady provides isReadyCollected,                   │
│     ) { … }                                                      │
│                                                                  │
│   Call sites migrated (v1 subset):                                │
│     - FeedNoteCard header                                         │
│     - QuotedNoteEmbed author                                      │
│     - Thread reply avatars                                        │
│     - Notifications item                                          │
│     - Search result item                                          │
│     - Profile header                                              │
│     (Other avatars deferred; migration is opportunistic.)         │
└──────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────┐
│ cli/  (amy — v1 verbs)                                            │
│   commands/WotCommand.kt                                          │
│     amy wot get <pubkey|npub> [--json]                           │
│     amy wot list [--threshold N] [--limit K] [--json]            │
│     amy wot sync    (one-shot batch REQ, exits on EOSE / 5s)     │
│   Context.kt exposes an FsEventStore hydration primitive         │
│     WoTService.hydrateFromStore(store, myFollows)                │
└──────────────────────────────────────────────────────────────────┘

Prerequisite: consumeContactList cache fix

This must land before or in the same PR as the WoT service. Otherwise the batch REQ we introduce will trigger the corruption bug.

File: desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:447-453

Change:

// BEFORE (buggy — any kind-3 with newer createdAt wins globally)
private fun consumeContactList(event: ContactListEvent): Boolean {
    if (event.createdAt <= lastContactListCreatedAt) return false
    lastContactListCreatedAt = event.createdAt
    lastContactListEvent = event
    _followedUsers.value = event.verifiedFollowKeySet()
    return true
}

// AFTER (per-author tracking + self-guard + SharedFlow emit)
private val lastContactListByAuthor = mutableMapOf<HexKey, Long>()
private val _contactListEvents = MutableSharedFlow<ContactListEvent>(
    extraBufferCapacity = 64,
    onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val contactListEvents: SharedFlow<ContactListEvent> = _contactListEvents.asSharedFlow()

private fun consumeContactList(event: ContactListEvent): Boolean {
    val prev = lastContactListByAuthor[event.pubKey] ?: 0L
    if (event.createdAt <= prev) return false
    lastContactListByAuthor[event.pubKey] = event.createdAt

    // Active-user's kind-3 updates local follow-set state.
    if (event.pubKey == accountPubkey) {
        lastContactListEvent = event
        _followedUsers.value = event.verifiedFollowKeySet()
    }

    // All kind-3 events fan out on the SharedFlow for consumers (WoTService).
    _contactListEvents.tryEmit(event)
    return true
}

Core algorithm (updated)

commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt:

@Stable
class WoTService(
    private val scope: CoroutineScope,
) {
    // Truth-map: pubkey → count of my-follows who follow them.
    // Sparse — entries with count = 0 are removed.
    private val _scores: SnapshotStateMap<HexKey, Int> = mutableStateMapOf()
    val scores: SnapshotStateMap<HexKey, Int> get() = _scores

    // Internal state — always mutated from the [writer] actor coroutine.
    private val reverseIndex = HashMap<HexKey, MutableSet<HexKey>>()
    private val perFollowerSnapshot = HashMap<HexKey, Set<HexKey>>()
    private var myFollows: Set<HexKey> = emptySet()
    private var selfPubkey: HexKey? = null

    // Readiness
    private val readyOnce = AtomicBoolean(false)
    private val _isReady = MutableStateFlow(false)
    val isReady: StateFlow<Boolean> = _isReady.asStateFlow()

    // Serialize state mutations
    private val ops = Channel<Op>(capacity = Channel.BUFFERED)

    init {
        scope.launch(Dispatchers.Default) {
            for (op in ops) processOne(op)
        }
    }

    private sealed interface Op {
        data class FollowSet(val new: Set<HexKey>, val self: HexKey?) : Op
        data class Kind3(val follower: HexKey, val follows: Set<HexKey>) : Op
        object MarkReady : Op
    }

    fun onFollowSetChange(newFollows: Set<HexKey>, newSelf: HexKey?) {
        ops.trySend(Op.FollowSet(newFollows, newSelf))
    }

    fun applyKind3(follower: HexKey, follows: Set<HexKey>) {
        val bounded = if (follows.size > MAX_FOLLOWS_PER_EVENT) {
            follows.take(MAX_FOLLOWS_PER_EVENT).toSet()
        } else follows
        ops.trySend(Op.Kind3(follower, bounded))
    }

    fun markReadyOnce() {
        if (readyOnce.compareAndSet(false, true)) ops.trySend(Op.MarkReady)
    }

    /** For amy — returns a plain snapshot, no Compose runtime required. */
    fun scoresSnapshot(): Map<HexKey, Int> = HashMap(_scores)

    /** For amy — hydrate from a local event store before querying. */
    suspend fun hydrateFromStore(store: IEventStore, myFollows: Set<HexKey>) {
        onFollowSetChange(myFollows, selfPubkey)
        store.iterateBy(kinds = setOf(ContactListEvent.KIND), authors = myFollows) { event ->
            applyKind3(event.pubKey, (event as ContactListEvent).verifiedFollowKeySet())
        }
    }

    fun clear() { ops.trySend(Op.FollowSet(emptySet(), null)) }

    private fun processOne(op: Op) {
        Snapshot.withMutableSnapshot {
            when (op) {
                is Op.FollowSet -> handleFollowSet(op.new, op.self)
                is Op.Kind3 -> handleKind3(op.follower, op.follows)
                Op.MarkReady -> _isReady.value = true
            }
        }
    }

    private fun handleFollowSet(newFollows: Set<HexKey>, newSelf: HexKey?) {
        val added = newFollows - myFollows
        val removed = myFollows - newFollows
        myFollows = newFollows
        selfPubkey = newSelf

        // Guardrail
        if (myFollows.size > MAX_FOLLOWS) {
            _scores.clear(); reverseIndex.clear(); perFollowerSnapshot.clear()
            markReadyOnce()
            return
        }

        // Uncredit removed followers
        removed.forEach { follower ->
            perFollowerSnapshot.remove(follower)?.forEach { target ->
                reverseIndex[target]?.let { set ->
                    set.remove(follower)
                    updateScore(target)
                }
            }
        }
        // Added followers are credited when their kind-3 arrives.
    }

    private fun handleKind3(follower: HexKey, follows: Set<HexKey>) {
        if (follower !in myFollows) return
        val old = perFollowerSnapshot[follower] ?: emptySet()
        val excluded = setOfNotNull(follower, selfPubkey)
        val effective = follows - excluded
        val added = effective - old
        val removed = old - effective
        perFollowerSnapshot[follower] = effective

        added.forEach { target ->
            reverseIndex.getOrPut(target) { hashSetOf() }.add(follower)
            updateScore(target)
        }
        removed.forEach { target ->
            reverseIndex[target]?.let { set ->
                set.remove(follower)
                if (set.isEmpty()) reverseIndex.remove(target)
                updateScore(target)
            }
        }
    }

    private fun updateScore(target: HexKey) {
        val n = reverseIndex[target]?.size ?: 0
        if (n > 0) _scores[target] = n else _scores.remove(target)
    }

    companion object {
        const val MAX_FOLLOWS = 2000
        const val MAX_FOLLOWS_PER_EVENT = 5000
    }
}

Notes:

  • All mutations run inside a single writer coroutine on Dispatchers.Default — no concurrent map access races.
  • Snapshot.withMutableSnapshot { } wraps each op so Compose readers see one atomic frame per event.
  • applyKind3 bounds follows.size to 5 000 at ingest — DoS protection against a hostile 100 k-tag kind-3.
  • _scores.remove(target) on 0-count keeps the map sparse — Compose subscriber tracking scales with map size.

Batch kind-3 loader (with explicit EOSE hook + chunking)

Add to FeedMetadataCoordinator in commons/.../relayClient/assemblers/:

private val queuedKind3Pubkeys = mutableSetOf<HexKey>()

/**
 * Fetches kind-3 for a batch of authors, chunking into ≤100-author
 * Filters within a single subscription. Calls [onEose] once all
 * chunks EOSE (or after [timeoutMs]).
 */
fun loadKind3Batched(
    pubkeys: Collection<HexKey>,
    timeoutMs: Long = 5_000L,
    onEose: () -> Unit = {},
) {
    val newPubkeys = pubkeys.filter { it !in queuedKind3Pubkeys }.distinct()
    if (newPubkeys.isEmpty()) { onEose(); return }
    queuedKind3Pubkeys.addAll(newPubkeys)

    scope.launch {
        val chunks = newPubkeys.chunked(100)
        val filters = chunks.map { chunk ->
            Filter(
                kinds = listOf(ContactListEvent.KIND),
                authors = chunk,
                limit = chunk.size,
            )
        }
        val filterMap = indexRelays.associateWith { filters }
        val subId = newSubId()
        val eoseReceived = mutableSetOf<NormalizedRelayUrl>()
        val allEose = CompletableDeferred<Unit>()

        val listener = object : SubscriptionListener {
            override fun onEvent(event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
                this@FeedMetadataCoordinator.onEvent?.invoke(event, relay)
            }
            override fun onEose(relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
                eoseReceived.add(relay)
                if (eoseReceived.size >= indexRelays.size) allEose.complete(Unit)
            }
        }

        client.subscribe(subId, filterMap, listener)
        withTimeoutOrNull(timeoutMs) { allEose.await() }
        client.unsubscribe(subId)
        onEose()
    }
}

DesktopRelaySubscriptionsCoordinator.loadKind3Batched(pubkeys, onEose) delegates.

UserAvatar badge slot (commonMain change)

@Composable
fun UserAvatar(
    userHex: String,
    pictureUrl: String?,
    size: Dp,
    modifier: Modifier = Modifier,
    /* existing params… */
    badge: @Composable (BoxScope.() -> Unit)? = null,
) {
    if (badge == null) {
        // Existing single-image render path — unchanged
        AvatarImage(userHex, pictureUrl, size, modifier, /* … */)
    } else {
        Box(modifier.size(size)) {
            AvatarImage(userHex, pictureUrl, size, Modifier, /* … */)
            badge()
        }
    }
}

WoTBadgedAvatar (Desktop-only, drop-in replacement at v1 call sites)

desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadgedAvatar.kt:

@Composable
fun WoTBadgedAvatar(
    userHex: String,
    pictureUrl: String?,
    size: Dp,
    modifier: Modifier = Modifier,
    /* … pass-through params matching UserAvatar … */
) {
    val service = LocalWoTService.current
    val ready = LocalWoTReady.current
    val selfKey = LocalWoTSelfKey.current
    val followedKeys = LocalWoTFollowedKeys.current

    val score = if (service != null && ready && userHex != selfKey && userHex !in followedKeys) {
        service.scores[userHex] ?: 0                     // plain snapshot read, per-key tracked
    } else 0

    UserAvatar(
        userHex = userHex,
        pictureUrl = pictureUrl,
        size = size,
        modifier = modifier,
        badge = if (score > 0) { { WoTBadge(count = score, modifier = Modifier.align(Alignment.BottomEnd)) } } else null,
    )
}

WoTBadge (Material3 TooltipBox, Desktop-only)

desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadge.kt:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WoTBadge(count: Int, modifier: Modifier = Modifier) {
    val display = if (count > 99) "99+" else count.toString()
    val tooltipState = rememberTooltipState(isPersistent = true) // desktop hover fix
    TooltipBox(
        positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
        tooltip = { PlainTooltip { Text("$count of the people you follow follow this person") } },
        state = tooltipState,
    ) {
        Box(
            modifier
                .size(18.dp)
                .clip(CircleShape)
                .background(MaterialTheme.colorScheme.primaryContainer)
                .semantics { contentDescription = "Followed by $count of your contacts" },
            contentAlignment = Alignment.Center,
        ) {
            Text(
                text = display,
                style = MaterialTheme.typography.labelSmall,
                color = MaterialTheme.colorScheme.onPrimaryContainer,
            )
        }
    }
}

Wiring in Main.kt

Inside the AccountState.LoggedIn branch, alongside the existing LocalHashtagSpamSettings / LocalSpamExemptKeys providers:

val wotService = account.iAccount.wotService
val isReady by wotService.isReady.collectAsState()

LaunchedEffect(wotService, localCache) {
    // Feed kind-3 events into the service.
    launch { localCache.contactListEvents.collect { evt ->
        wotService.applyKind3(evt.pubKey, evt.verifiedFollowKeySet())
    } }
    // React to follow-set changes.
    launch { localCache.followedUsers.collect { follows ->
        wotService.onFollowSetChange(follows, account.pubKeyHex)
        subscriptionsCoordinator.loadKind3Batched(follows, onEose = { wotService.markReadyOnce() })
    } }
    // Fallback: mark ready after 2 s regardless.
    delay(2_000)
    wotService.markReadyOnce()
}

CompositionLocalProvider(
    LocalWoTService provides wotService,
    LocalWoTReady provides isReady,
    LocalWoTSelfKey provides account.pubKeyHex,
    LocalWoTFollowedKeys provides followedUsers, // already collected above for spam exempt
    // existing hashtag-spam CompositionLocals…
) { /* … */ }

Attach wotService to DesktopIAccount:

class DesktopIAccount(
    private val signer: NostrSigner,
    val scope: CoroutineScope,
    /* … */
) : IAccount {
    val kind3FollowList = Kind3FollowListState(signer, scope, /* … */)
    val wotService = WoTService(scope)
    /* … */
}

Amy verbs (v1)

cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt:

amy wot get <pubkey|npub> [--json]
    Prints: pubkey=<hex> score=<n> contributed_by=<hex,hex,…>
    JSON: { "pubkey": "...", "score": n, "contributed_by": ["...", ...] }

amy wot list [--threshold N] [--limit K] [--json]
    Prints scored pubkeys sorted desc; --threshold filters.

amy wot sync
    Loads active-user follow set + runs loadKind3Batched once.
    Exits after aggregated EOSE (max 5 s).

Each verb:

  1. Reads active-user pubkey from Context.currentAccount.
  2. Hydrates WoTService from FsEventStore (~/.amy/shared/events-store/).
  3. Optionally runs wot sync (idempotent) for freshness.
  4. Queries and prints.

Total ~150 LOC. Reuses existing Context / FsEventStore / indexRelays wiring.

Technical Considerations

Performance

  • Reverse-index memory: at MAX_FOLLOWS = 2000 × avg follows 500 ≈ 1 M entries worst case; at 500 × 500 ≈ 250 k. Compose bookkeeping overhead on the SnapshotStateMap is ~80–120 bytes/entry; sparse map (Unknown = absence) keeps this bounded by positive-score pubkeys only — typically 5–50 k, not 250 k.
  • Write batching: each applyKind3 op mutates the map for many keys inside a single Snapshot.withMutableSnapshot { } — one wake-up per reader, one atomic frame.
  • Chunked REQ: 500-follow account issues 5 chunks × 100 authors each within a single subscription. Total REQ payload ~30 KB.
  • scoresSnapshot() does an HashMap(_scores) — O(N) copy; amy calls it once per verb invocation, fine.

Compose recomposition

  • Plain service.scores[userHex] ?: 0 read is snapshot-tracked per key. When only one key updates, only avatars for that pubkey recompose.
  • LocalWoTReady: CompositionLocal<Boolean> is collected once at App root; no per-avatar Flow collector.
  • Badge visibility is decided in WoTBadgedAvatar (Desktop) — the UserAvatar slot receives badge=null when hidden, so no overlay Box + no recomposition of hidden branches.

Stability annotations

  • WoTService: @Stable. Public API is scores: SnapshotStateMap (Compose-tracked) + isReady: StateFlow (via collectAsState). Private mutable state doesn't leak.
  • Int is stable by definition — sparse map values are trivially stable.
  • WoTBadge, WoTBadgedAvatar, UserAvatar all have primitive/stable params after the slot addition.

Threading

  • All mutations to reverseIndex / perFollowerSnapshot happen inside the single writer coroutine on Dispatchers.Default.
  • _scores SnapshotStateMap is thread-safe for individual puts; composite writes wrapped in Snapshot.withMutableSnapshot { } are applied atomically.
  • Composable reads happen on the Main dispatcher via snapshot; safe.

Follow-set reactivity

  • DesktopLocalCache._followedUsers: StateFlow<Set<HexKey>> (already reactive) is the source of truth for the active user's follow set — once the prerequisite cache fix lands.
  • The LaunchedEffect in Main.kt collects it and forwards to WoTService.onFollowSetChange and re-triggers the batch REQ for newly-followed pubkeys (existing ones deduped by queuedKind3Pubkeys).

Startup timeline

t Event
0 ms User logs in
~50 ms LocalCache.followedUsers emits current follow set
~100 ms loadKind3Batched(follows, onEose = …) fires 5 chunks
200 ms – 2 s Kind-3 events land, WoTService diffs, _scores populated
≤ 2 s markReadyOnce() — via first-chunk EOSE OR 2 s fallback
≥ ready Badges appear

Security / privacy

  • Follow-list leak via batch REQ is pre-existing — same authors set already sent to indexRelays via metadata batch. WoT introduces no novel disclosure.
  • event.verify() runs on every ingested event (DesktopLocalCache.kt:191) — forged kind-3 events cannot pass.
  • follows.take(5000) in applyKind3 bounds CPU cost against a hostile 100 k-tag kind-3.
  • No persistence — Preferences untouched. Reverse index lives in memory only.
  • App-global operation (not per-account): follow-set state lives on DesktopIAccount, discarded on logout. Account switch = new IAccount = new WoTService.

System-Wide Impact

Interaction graph

DesktopLocalCache.consumeContactList(event)   ← (prerequisite fix)
    │
    ├─ if event.pubKey == self → update _followedUsers, lastContactListEvent
    │
    └─ tryEmit → contactListEvents: SharedFlow<ContactListEvent>
                      │
                      ▼ collected in Main.kt LaunchedEffect
                 WoTService.applyKind3(event.pubKey, event.verifiedFollowKeySet())
                      │
                      ▼ dispatched to writer coroutine
                 processOne(Op.Kind3) inside Snapshot.withMutableSnapshot { }
                      │
                      ▼ diff vs perFollowerSnapshot
                 reverseIndex[target].add/remove(follower)
                      │
                      ▼ updateScore(target)
                 _scores[target] = n  (or .remove if n == 0)
                      │
                      ▼ Compose snapshot commit
                 Avatars reading scores[target] recompose

── parallel path ──
DesktopLocalCache._followedUsers emits new set
    │
    ▼ collected in Main.kt LaunchedEffect
WoTService.onFollowSetChange(newFollows, selfPubkey)
    │
    ├─ diff added / removed followers
    ├─ uncredit removed followers' contributions
    ▼
subscriptionsCoordinator.loadKind3Batched(followSet, onEose = wotService::markReadyOnce)
    │
    ▼ chunked REQ on indexRelays
Kind-3 events return → route back through consume() path above

Error & failure propagation

  • Batch REQ timeout: 2 s fallback fires markReadyOnce() regardless. Partial data is acceptable — badges appear for pubkeys we did get.
  • verifiedFollowKeySet() on malformed event: Quartz handles internally, returns possibly-empty set. Zero contribution.
  • Writer coroutine crash: never — all operations are exception-safe (map ops, set diffs). No I/O in the writer path.
  • SharedFlow overflow: DROP_OLDEST on contactListEvents — under extreme flood, oldest events dropped. Acceptable v1 (relay flood is itself abnormal).
  • Account switch: LaunchedEffect cancelled → collect on old contactListEvents stops. Old WoTService referenced only via the cancelled effect and old IAccount — GC'd cleanly.

State lifecycle risks

  • Prerequisite cache fix is the highest lifecycle risk. Without it, the WoT batch REQ actively corrupts _followedUsers — cascading bugs in every FeedFilter, mute list, and account-relay logic. Prevention: land the fix as a separate commit in this PR, gate by unit test in DesktopLocalCacheTest.
  • Reverse-index size grows with myFollows.size × avg-follows-of-follows. Guardrail at 2000 clears the map; entering guardrail mid-session is a supported transition.
  • SharedFlow subscribers must be scoped to account.scope — a leak from a stale coroutine would collect old events into a stale service. Enforced by LaunchedEffect(wotService, localCache) keying.

API surface parity

  • commons: WoTService, LocalWoTService, LocalWoTReady, LocalWoTSelfKey, LocalWoTFollowedKeys (CompositionLocals). UserAvatar gains optional badge slot (backward compatible — default null).
  • desktopApp: WoTBadge, WoTBadgedAvatar composables; call-site migration at 6 v1 surfaces (feed, quote-embed, thread reply, notification, search result, profile header). Coordinator gets loadKind3Batched. DesktopLocalCache gets contactListEvents: SharedFlow + per-author lastContactListByAuthor map. DesktopIAccount gets wotService property.
  • amethyst (Android): no changes v1. UserAvatar badge slot is optional; Android call sites pass no lambda.
  • cli (amy): three verbs (wot get, wot list, wot sync), ~150 LOC in commands/WotCommand.kt, wired into Main.kt dispatch.

Integration test scenarios

  1. Cold start with 250 follows. Login → batch REQ fires with 3 chunks of 100 authors → badges appear within ≤ 2 s.
  2. Follow a new person mid-session. New pubkey added to _followedUsers → onFollowSetChange credits nothing yet; new loadKind3Batched(followSet) picks up their kind-3 (deduped by queuedKind3Pubkeys); score for their followers ticks up as their kind-3 lands.
  3. Unfollow a follower. onFollowSetChange(removed) uncredits; affected pubkeys' scores decrement; some may drop to 0 and lose badges (map entry removed).
  4. Kind-3 churn. Same follower republishes with +5 / -2 diff → applyKind3 computes diff correctly, no double-counting.
  5. Account switch. New IAccount → new WoTService → old service GC'd. Old service's map does not leak into new UI.
  6. Guardrail trip. 3000-follow account → guardrail clears state, marks ready, no batch REQ.
  7. Empty graph. 0 follows → onFollowSetChange with empty set, nothing to fetch, markReadyOnce() fires from fallback timeout.
  8. 99+ overflow. Simulate score 200 → badge shows "99+".
  9. Self exemption. Own avatar in profile header never shows a badge even if some follower's kind-3 lists self.
  10. Follow-list exemption. Followed author's avatar never shows a badge even when others in follow-list follow them.
  11. Kind-3 malformed. 100 k-tag kind-3 → applyKind3 truncates to 5 000, service stays responsive.
  12. Prerequisite fix verification. Send a kind-3 event whose author is not the active user — verify _followedUsers unchanged, but contactListEvents emits.
  13. Amy wot get. amy wot get <npub> after amy wot sync returns correct score.
  14. Amy warm cache. Second amy wot get <npub> invocation without sync uses FsEventStore hydration, no relay traffic.

Acceptance Criteria

Functional

  • Prerequisite: DesktopLocalCache.consumeContactList refactored with per-author lastContactListByAuthor: Map<HexKey, Long> and guards _followedUsers / lastContactListEvent writes on event.pubKey == accountPubkey. Also emits every kind-3 to a new contactListEvents: SharedFlow<ContactListEvent>.
  • WoTService in commons/commonMain/.../wot/WoTService.kt — sparse SnapshotStateMap<HexKey, Int>, single-writer coroutine actor, Snapshot.withMutableSnapshot { } for atomic frames, onFollowSetChange, applyKind3 (with MAX_FOLLOWS_PER_EVENT bound), markReadyOnce, clear, scoresSnapshot, hydrateFromStore, isReady: StateFlow<Boolean>.
  • LocalWoTService, LocalWoTReady, LocalWoTSelfKey, LocalWoTFollowedKeys CompositionLocals in commons/commonMain/.../wot/.
  • UserAvatar in commons/commonMain/.../ui/components/UserAvatar.kt gains optional badge: @Composable (BoxScope.() -> Unit)? = null parameter. Backward compatible — default null.
  • WoTBadge in desktopApp/src/jvmMain/.../ui/note/WoTBadge.kt — Material3 TooltipBox with isPersistent = true, PlainTooltip, Box overlay chip, contentDescription for a11y, 99+ clamp.
  • WoTBadgedAvatar in desktopApp/src/jvmMain/.../ui/note/WoTBadgedAvatar.kt — composes UserAvatar with a WoTBadge slot lambda; reads gates from CompositionLocals.
  • FeedMetadataCoordinator.loadKind3Batched(pubkeys, timeoutMs = 5s, onEose) — chunks into ≤100-author Filters, aggregates EOSE across chunks, dedupes against queuedKind3Pubkeys.
  • DesktopRelaySubscriptionsCoordinator.loadKind3Batched(pubkeys, onEose) delegate.
  • DesktopIAccount.wotService: WoTService — constructed with account.scope at IAccount init.
  • Main.kt inside LoggedIn branch: - Collects wotService.isReady once → isReadyCollected: Boolean. - Collects localCache.contactListEvents → wotService.applyKind3. - Collects localCache.followedUsers → wotService.onFollowSetChange + loadKind3Batched. - Fallback delay(2_000) → markReadyOnce(). - Provides all four LocalWoT* CompositionLocals to descendants.
  • Call-site migration to WoTBadgedAvatar at 6 v1 surfaces: FeedNoteCard header, QuotedNoteEmbed author, ThreadScreen reply avatars, NotificationsScreen items, SearchResultsList avatars, UserProfileScreen header.
  • Amy verbs: - amy wot get <pubkey|npub> [--json] - amy wot list [--threshold N] [--limit K] [--json] - amy wot sync

Non-functional

  • No measurable frame-time regression during scroll in a 250-note deck column with badges rendering (manual profiler smoke test).
  • Spotless clean: ./gradlew spotlessApply produces no diff.
  • Compiles cleanly: ./gradlew :commons:compileKotlinJvm :desktopApp:compileKotlin :cli:build.
  • No Preferences writes — feature is stateless across restarts.
  • Badge overlay uses absolute positioning inside a Box sized to the avatar; no layout shift when a score arrives mid-scroll.
  • Batch REQ payload chunked ≤ 100 authors per Filter.

Quality gates

  • Unit tests for WoTService:
    • Empty graph → onFollowSetChange with empty set → nothing to compute, still fires markReadyOnce() via caller.
    • handleFollowSet from empty → 3 follows, then handleKind3 for each with overlapping follow sets → verify scores.
    • Removing a follower decrements every pubkey they contributed to; sparse-map guarantee (removed at 0-count).
    • Kind-3 churn: same follower publishes new set → diff applied correctly, no double-counting, no leaked entries in perFollowerSnapshot.
    • Self-exclusion: kind-3 including active-user pubkey doesn't inflate self-score.
    • Follower-self-exclusion: kind-3 including follower's own pubkey doesn't inflate their own score.
    • Guardrail: 3000-follow input yields empty map + _isReady = true.
    • MAX_FOLLOWS_PER_EVENT: 6000-follow kind-3 truncated to 5000.
    • scoresSnapshot() returns a plain HashMap equal to _scores.toMap().
  • Unit test for DesktopLocalCache.consumeContactList prerequisite fix — non-self kind-3 doesn't mutate _followedUsers; both events emit to contactListEvents.
  • Unit test for loadKind3Batched filter shape — 300 authors chunked into 3 Filters, one subscription, aggregated EOSE, onEose callback fired.
  • Amy verb integration tests (in cli/tests/wot/): wot get, wot list, wot sync produce correct output and JSON schemas.
  • Manual testing sheet at desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md covering the 14 integration scenarios above.

Success Metrics

  • Users report badges give useful trust cues on strangers without cluttering follows / self.
  • Batch REQ completes within 5 s for accounts with ≤ 500 follows on typical index relays.
  • No frame drops > 16 ms during scroll or startup.
  • amy wot get returns within 100 ms on a warm FsEventStore.

Dependencies & Risks

Risk Likelihood Mitigation
Prerequisite cache fix breaks existing feed filters that depend on _followedUsers mutation semantics medium Prerequisite fix has its own unit test in this PR; audit all readers of _followedUsers (Kind3FollowListState, feed filters) — behavior unchanged for the active-user code path, only the other-user path stops overwriting
verifiedFollowKeySet() allocation cost during batch flood low Not cached in Quartz; measured ~1 ms per event; 500 events × 1 ms = 500 ms on a background thread — acceptable
TooltipBox visual defaults differ from TooltipArea low Test both hover and long-press behaviour manually; adjust padding/positioning if needed
Amy verb tests miss a SnapshotStateMap initialization edge low Amy verb test explicitly constructs WoTService, populates via hydrateFromStore, reads via scoresSnapshot()
Merge conflict with in-flight hashtag-spam PR (#3431) medium Both features touch Main.kt CompositionLocalProvider block. If hashtag-spam merges first, rebase; the two providers stack (independent locals)
Compose runtime version pinning for the 2026 deadlock fix low Verify libs.versions.toml compose runtime version ≥ current stable; upgrade if needed
Follow-set leak via batch REQ to index relays pre-existing (metadata already leaks same info) No new leak. Follow-up ticket: NIP-65 outbox routing for both kind-0 and kind-3 batches; do not gate WoT on it

Out of Scope (deferred)

  • Threshold-based filtering (notifications, DMs, feeds, search) — v2.
  • Persistence of scores across sessions — cached kind-3 events give fast cold start via hydrateFromStore; no separate cache.
  • Settings UI (toggle to hide badges entirely, threshold picker) — v2.
  • Mutual-follow drilldown ("click chip to see who") — v2.
  • Colored-ring alternative rendering — v2 experiment.
  • Android UI — v2. UserAvatar badge slot is Android-safe today (default null).
  • Weighting (zap-weighted, mutual-weighted, decay over time) — YAGNI.
  • NIP-65 outbox routing for batch REQ (correctness > simplicity trade-off) — cross-cutting follow-up ticket, applies to metadata coordinator too.
  • Cross-account aggregation — YAGNI.

Sources & References

Origin

  • Brainstorm: docs/brainstorms/2026-07-01-feat-wot-score-brainstorm.md

Internal references

  • desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:447-453 — prerequisite fix target — consumeContactList scope corruption.
  • desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:191 — event.verify() gate confirms signature integrity.
  • commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt:255-301 — loadMetadataBatched blueprint; note the .take(100) on line 267 (we chunk explicitly instead).
  • commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt — service-on-account pattern; signer-scoped, Kind3Follows.authors.
  • desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt — attach point for wotService.
  • commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt — add optional badge slot.
  • desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt:75-95 — prior tooltip pattern (upgrading to Material3 TooltipBox in WoTBadge).
  • desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:979-982,1424-1427 — CompositionLocalProvider wiring sites.
  • cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt — FsEventStore at ~/.amy/shared/events-store/ for amy hydration.
  • quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt:56 — verifiedFollowKeySet() — not cached, bounded at parse time.

External references

Skill references

  • account-state — IAccount follow-set access, Kind3FollowListState scoping.
  • relay-client — FeedMetadataCoordinator batch REQ pattern.
  • compose-recomposition-performance — SnapshotStateMap per-key subscriber isolation, Snapshot.withMutableSnapshot for atomic frames.
  • compose-stability-diagnostics — @Stable contract honesty on WoTService.
  • compose-slot-api-pattern — badge slot on UserAvatar as visual extension point.
  • nostr-expert — ContactListEvent.verifiedFollowKeySet().
  • kotlin-flow-state-event-modeling — SharedFlow<ContactListEvent> with buffer + DROP_OLDEST overflow; StateFlow<Boolean> for readiness.
  • amy-expert — CLI verb structure, FsEventStore hydration, JSON output contract.
  • Hashtag-spam PR: https://github.com/vitorpamplona/amethyst/pull/3431 — same CompositionLocal pattern; same "Content Filters" area for v2 threshold UI when filtering ships.
  • Feature backlog: desktopApp/plans/_desktop-feature-backlog.md item #2 (this plan).