Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoTQod4fuXsXXzMUB1KwD7
35 KiB
Web of Trust network index (NIP-85 kind 30382, ~300k users)
Status: implemented 2026-10-07 (phases 1–5 except public chats; see §7).
1. Goal
Know, in under a microsecond and without a network call, whether a pubkey is inside the logged-in user's extended network and how much the user's trust provider trusts them. The answer gates:
- DMs: the Known vs New Requests split.
- Notifications: the existing Curated filter (
TopFilter.Selected). - Replies, and possibly public chat messages (later phases).
- The rank / follower-count badges, which today come from a per-profile relay subscription.
The source is the user's NIP-85 trust provider: one kind 30382 card per member of the network, all signed by the provider key named in the user's kind 10040. The first provider we onboard is Brainstorm (GrapeRank), from a new Web of Trust settings screen. That screen also sets the minimum accepted score, default 5.
2. What we measured (2026-10-07)
All numbers come from the live wss://scores.brainstorm.world. That is the relay real
Brainstorm 10040s point at, and the one Brainstorm's web app uses
(VITE_NIP85_RELAY_URL). Two real per-user provider keys were tested.
| Fact | Value |
|---|---|
| Relay | strfry, max_limit 100,000, NIP-45 COUNT and NIP-77 negentropy supported |
Older relay nip85.brainstorm.world |
max_limit 500, negentropy supported. Some 10040s in the wild (fiatjaf's fixture) still point here. |
| Cards per user | 154,755 and 149,609 |
| Card size | ~520 B of JSON. Tags: d, rank, followers, reporters, muters, client, hops |
| Cold download | 93 MB / 34 s (155k cards, 2 REQs of 100k, from a datacenter). gzip shrinks the stream ~3.3×. |
| Shared timestamps | 80,577 cards of one user carry the same created_at, because the provider publishes in batches. |
| Churn | Provider A: 9,704 cards re-signed in 3 days, 25,779 in 30 days. Provider B: all 155k re-signed today. |
| Deletions | Provider A has 169,317 kind-5 events, each carrying many a tags (30382:<P>:<target>). |
| Rank (0–100) | ≥ 5: 77,270 / 154,755 and 75,283 / 149,609, so about half the network passes the default. Provider B also has 749 rank-0 cards. |
| Event-object heap | 300k card-shaped events ≈ 254 MB (~850 B each), before Note/User/flows |
| Lookup, 310k keys (JVM) | HashSet<String> 0.1–0.25 µs but ~14 MB plus the strings. Sorted LongArray with hex parse ~0.4–0.5 µs at 2.5 MB. |
| Index file load (JVM) | 6.3 MB file, read and decoded in 14–40 ms |
| Signature check | ~75 µs per event on JVM, 1–2k events/s parse+verify on a phone (quartz/plans/2026-07-02-nostrclient-receiver-perf.md). That is minutes for 300k. |
Phase 0 on the JVM: download → verify → build → save (2026-10-07)
quartz/src/jvmTest/.../prodbench/WotNetworkIndexBenchmark.kt (opt-in with
-PprodRelayBench=1) runs the real Quartz stack: NostrClient + fetchAllPages, Schnorr
verify, the D5/D6 index layout, and the D11 files. Observer
460c25e6…065c, whose 10040 names rank provider 7d7ffd72…9377 on
wss://scores.brainstorm.world. Run from a 4-core cloud container.
| Stage | Result |
|---|---|
Download (3 pages, DRAINED) |
5.2 s, 151,831 cards, ~29k cards/s |
| Heap if events are held | 84.6 MB (556 B per parsed card), which the one-pass pipeline avoids |
| Verify every signature (4 cores) | 3.2 s, 0 invalid, ~48k verifies/s (83 µs per verify per core) |
| Build index (sort, dedupe, drop rank 0) | 0.39 s, 151,153 entries; 77,671 with rank ≥ 5 |
| Save index + ids files | 0.23 s; index 3.3 MB, ids 6.0 MB |
| Load + decode index | 34 ms first, 17 ms best |
| Lookup (hex read + binary search) | ~400 ns |
| One pass (verify while downloading, no events held, then build + save) | 8.5 s total: download done at 7.9 s, last verify at 8.0 s |
Notes:
- Paging by
untilworked here (DRAINED), because this relay'smax_limitis 100k. - At 300k, expect roughly double: about 15–20 s on this hardware.
- The build stage used a boxed sort; a primitive sort will be faster.
- Not measured yet: a phone. Signature checks dominate the CPU there (the perf plan puts parse plus verify at 1–2k/s), so expect minutes, not seconds. That run is still needed for D9b/D9c.
How Brainstorm publishes (read from NosFabrica/brainstorm_server,
app/message_queue_tasks/upload_nostr_events.py):
- Score runs. Scores are recomputed on a schedule (default
schedule_interval_seconds = 604800, 7 days) or on demand. In steady state only changed scores are published; an occasional full sync re-asserts every card above the cutoff. - Removal. When someone falls out of the network, Brainstorm first publishes a rank-0 card with no
hops, then a kind-5 deletion byatag. The zero card is there "so that even if the relay rejects kind 5, the score is effectively zeroed out". - Provider keys are per user.
authors=[P]returns exactly that user's network and nothing else.
3. What exists today (survey)
| Piece | Where | Reuse? |
|---|---|---|
| 10040 parsing, provider slots | commons/.../model/trustedAssertions/TrustProviderListState.kt (liveUserRankProvider, liveUserFollowerCount), with local backup in AccountSettings.backupTrustProviderList |
Reuse as is |
| Per-profile rank/followers | UserCardsCache.rankFlow / followerCountStrFlow, read at commonsUI/.../AccountUserObservers.kt:470,485. Cards are fetched per profile by UserCardsSubAssembler and stored as AddressableNotes in LocalCache. |
Migrate (D16) |
| Relay ops | fetchAllPages (streams, pages by until, reports UNPAGEABLE), negentropyReconcile + NegentropyLocalIndex, count, ParallelEventVerifier, AdaptiveRelayLimiter in quartz/.../relay/client/accessories/ |
Reuse |
| Compact hex keys | Hex.readLong(hex, offset), Hex.toLong128 in quartz/utils/Hex.kt. SeenIds uses the same 128-bit-prefix idea for event ids. |
Reuse |
| Per-account files | AccountCacheState.createAccount makes filesDir/accounts/<pubkey>/ (:275); deleteAccountFiles removes it on logoff; okio platformFileSystem; atomicWrite pattern in FileCordnStores.kt |
Reuse |
| Synced settings | AccountSyncedSettings (NIP-78 kind 30078 AmethystSettings) |
Add minTrustScore |
| Foreground signal | ForegroundTracker.isForeground (Android) |
Trigger for small updates |
| Background work | WorkManager (NotificationCatchUpWorker, ScheduledPostWorker, which resolves the account from accountsCache) |
Weekly full check |
| DM split | ChatroomListKnownFeedFilter.kt:70-78 / ChatroomListNewFeedFilter.kt:47-56 (incremental path :167-173). Known = a sender is followed, or I sent a message. |
Extend |
| Notifications | NotificationFeedFilter.kt:712 author gate; Curated = TopFilter.Selected |
Extend |
| Push notifications | Renderers apply only account.isAcceptable(note) and never the notification TopFilter |
Decide (D18) |
| Replies | ThreadLevelCalculator.kt:109-114 sorts into tiers (thread author, me, follows, rest) |
Extend later |
| Friends-of-friends | commons/wot/WoTService.kt: Desktop-only badge, in memory, not persisted |
Independent, no change |
| 10040 editing UI | None in the app; only amy graperank register |
New screen |
There is no Android event store. LocalCache lives only in memory, and every event received
on the shared client is filed into it by CacheClientConnector
(AppModules.kt:873: EventCollector(client) { cache.justConsume(...) }).
4. Decisions
Each has a recommendation (Rec.) or a recorded decision. The ones still marked ❓ are open; §6 logs what was decided.
D1. Which provider feeds the index
Use the 10040 entry whose service is 30382:rank (liveUserRankProvider): its pubkey P
and relay hint R. That is one provider, with no multi-provider merge (NIP-85 doesn't
specify one). Brainstorm is only the onboarding path; any NIP-85 provider listed under
30382:rank works the same way.
Rec. A change of P or R (new 10040, re-registration) throws the index away and
starts a fresh cold sync. The file header records (P, R) so a mismatch is detected on load.
D2. Membership rule
accepted(pk) = pk == me || pk ∈ follows || (indexReady && rank(pk) >= minScore)
- Follows always pass, so a stale provider never demotes someone you follow.
- No card means outside the network. Brainstorm only publishes above its cutoff.
- A rank-0 card counts as removed (Brainstorm's removal marker), as does a card deleted by kind 5.
- Mutes, blocks and spam flags still win over everything (
isHiddenis checked first, as today). hopsis stored but not used to gate (D7).
D3. Before the index is ready
When the index is missing, still downloading, or there is no rank provider, every surface
behaves exactly as today. Otherwise the first sync would empty the DM list and
notifications for minutes. "Ready" means a complete snapshot for the current (P, R) is loaded.
"Missing" and "not loaded yet" are different states:
- Missing (no file, or a
(P, R)mismatch): let everything through, as today. - On disk but not loaded yet (a process just started, e.g. by a push): wait for the load, bounded by a timeout, before deciding. Push filtering depends on this (D11, D18); otherwise every notification that wakes a cold process would skip the filter.
D4. Use LocalCache? No.
- Size. 300k cards ≈ 254 MB for the event objects alone, plus 300k
AddressableNotes and theUserobjectsabout.cards()creates, against ~6 MB for the index. - Nothing persists. LocalCache is in memory only on Android, so it would have to re-download every start.
- The shared client files everything.
CacheClientConnectorputs every event on the shared client into LocalCache, so the sync must use its own client (D8).
LocalCache keeps what it has today: the account's own contact cards (encrypted
nicknames, signed by the account, UserAssertionsState) and any 30382 that arrives
incidentally.
D5. Key width in the index
| Option | Bytes / entry | Risk |
|---|---|---|
| 64-bit prefix | 8 | Grinding a key whose first 64 bits match some member is roughly 2^64 / 300k ≈ 2^46 key generations. Our estimate: about a day of GPU vanity grinding, which buys a spammer a place in Known. |
| 128-bit prefix | 16 | Not feasible to grind; random collisions are effectively zero |
| Full 256 bits | 32 | No gain over 128 for this purpose |
Rec. 128-bit: two longs per entry, sorted, via Hex.readLong(pk, 0) / Hex.readLong(pk, 16)
(no allocation).
D6. In-memory structure
An immutable NetworkSnapshot:
keys LongArray(2N) sorted by (hi, lo) — binary search, ~19 steps at 300k
rank ByteArray(N) 0..100 (clamped; unsigned read)
hops ByteArray(N) 0..127, -1 = unknown
followers IntArray(N)
provider, relay, syncCursor, lastFullCheck, version
- Published as
StateFlow<NetworkSnapshot?>. Each update builds a new snapshot and swaps it in atomically; readers never lock. - About 6.3 MB at 300k. Alternatives considered:
- Open-addressing hash: same size, O(1), but needs a rehash on load and gives no sorted merge.
HashSet<String>: 7–8× the memory.
- ❓ D6a. Also memoize the two prefix longs on
Userso hot paths skip the hex read? Rec. Not in v1:Hex.readLongis cheap and allocation-free. Revisit if profiling says otherwise.
D7. Which tag values to keep
Keep rank (the gate), followers (the badge), and hops (cheap, useful for UI such as
"3 hops away").
Decided: don't store reporters / muters. Brainstorm publishes these counts of people
in your network who reported or muted someone; they would cost 2 × 4 B × N ≈ 2.4 MB. The
file format is versioned, so this can be revisited without a migration.
D8. Relay client for the sync
A second NostrClient with the same websocketBuilder, so Tor routing and the relay
blocklist still apply. It is not wrapped by CacheClientConnector, is created for a sync
and disconnected afterwards. This also keeps the bulk sync from using up the shared
client's subscription slots. Desktop already does this for its kind-10050 indexer client
(Main.kt:1039).
D9. Cold download
- Read NIP-11 for
R:max_limit,max_subscriptions, negentropy support. - Path A (preferred when
max_limit≥ ~100k):fetchAllPages(R, {kinds:[30382], authors:[P]}), streaming into a growing set of arrays. Events are never held. - Path B (when A ends
UNPAGEABLE): if one second holds more cards thanmax_limit(likely onnip85.brainstorm.world), usenegentropyReconcilewith an empty local side to list the ids, then fetch them in batches of 500 by id. The perf plan measured fetch-by-id at up to 30–40k/s on a 33k-event corpus. - Sort, deduplicate by d-tag (newest
created_atwins), drop rank 0, write the file (D11), swap the snapshot in. - Every event must have
kind == 30382,pubkey == P, and a 64-hex d-tag. Anything else is dropped.
D9a. Signature checks — decided: check every signature. A relay that inserts forged
cards could otherwise make strangers "known". Use ParallelEventVerifier on low-priority
threads, so its backlog limit slows the socket instead of growing memory. This is roughly
2.5–5 min of CPU on a phone for the cold sync, once; small updates and the full check (D12)
verify too. A failed signature drops that event.
❓ D9b. Resuming an interrupted cold download. A 70–180 MB download on mobile can be killed.
Rec. v1 restarts from scratch but writes a "pages done" checkpoint (the until cursor
plus partial arrays to a .partial file), so Path A can resume. That is cheap to add because
fetchAllPages reports each page boundary (onNewPage).
❓ D9c. Network policy. The cold sync is 50–180 MB. Rec. Run it on any network when the user starts it from the settings screen (they are looking at a progress bar). Background cold syncs (e.g. after a provider change on another device) wait for an unmetered network.
D10. Small updates
The trigger is the app coming to the foreground (ForegroundTracker), plus right after the
user asks Brainstorm to recompute. It runs when the last update is more than 15 minutes
old. Brainstorm recomputes weekly, but other providers may compute on demand, and with the
pre-check below a check that finds nothing costs two COUNTs (~30 ms each once connected).
- Anything new?
COUNT {30382, authors:[P], since: cursor}against the cards held at or after the cursor (entries + tombstones), plusCOUNT {5, authors:[P], since: cursor+1}against 0. Both match → stop: "nothing new". Counting the cursor's own second catches a batch that was still arriving at the last sync (providers sign a whole run with onecreated_at). - Otherwise NIP-77 reconcile the window
since: cursor - 3600against the ids held in that window and fetch only the missing ids; then fetch kind 5since: cursor + 1. - Relays without NIP-77 or COUNT: fetch 30382 + 5 strictly newer than the cursor.
- Merge: upsert cards (newest per d-tag); rank 0 or a kind-5
atag30382:P:<target>removes the entry. - Write the files, swap the snapshot in, and advance the cursor to the newest
created_at.
First version (fixed): a plain since: cursor - 3600 fetch. Because a batch-publishing
provider puts every card in that hour, each update re-downloaded all 155k cards (~93 MB).
Revised 2026-10-12: no kind 5s on NIP-77 relays. A provider's deletions can outweigh its
cards many times over: scores.brainstorm.world held 341,569 kind 5s (380 MB, ~9.4k of them
with ~199 a tags each) against 151,831 cards (70 MB), and none of them named a card the
relay still served. The cold download also fetched them all, which on a phone over Tor never
finished. Now:
- The cold download fetches no kind 5: the relay serves only the cards it still holds.
- Step 1's deletion COUNT is replaced by
COUNT {30382, authors:[P]}against every card held (heldTotalin the header, format version 5): a card the relay stopped serving lowers it. - Step 2 reconciles all cards, not the window, and drops the ids only we hold. Measured on that relay: 151k ids reconciled in ~40 KB down, ~26 KB up, ~3 s.
- Kind 5s are fetched only in step 3 (relays without NIP-77), where they are the only signal.
- Under default privacy settings the provider's relay was a "new" relay, so the sync went over Tor; PR #4437 counts the relays in the user's 10040 as their own.
D11. On disk
A single file per account: filesDir/accounts/<pubkey>/wot/network-v1.bin. It lives inside the
account directory, so the existing deleteAccountFiles cleans it up on logoff.
magic "AWOT" | u16 version | 32B provider | relay (u16 len + utf8)
| i64 syncCursor | i64 lastFullCheck | i32 N
| keys N×16 | rank N×1 | hops N×1 | followers N×4
- Written with okio to a temp file and then moved into place (
atomicWrite), so a crash leaves the old file or the new one, never half. - Read with one
readByteArrayand decoded onDispatchers.IOas soon as theAccountis created, including accounts loaded only for push. 14–40 ms on JVM; phase 0 measures it on a phone. - Loading must be fast because push filtering waits on it (D3). Consumers call a
suspending
awaitLoaded(timeout); a cold process that a notification wakes pays the load once. - If phase 0 shows the decode is too slow on low-end phones, the fallback is a jvmAndroid
actualthat memory-maps the file and binary-searches the mappedLongBufferdirectly: no decode, the OS pages it in on demand. The format above already allows this (fixed-width sections, big-endian). - A bad magic, version, or
(P, R)mismatch means the file is ignored and a cold sync is scheduled.
A second file network-ids-v1.bin holds (eventId 32B, createdAt i64) per entry, about
12 MB, then the tombstones: (subject 16B, eventId 32B, createdAt i64) for each card the
relay still serves that does not count: a rank-0 or kind-5-deleted card, an older version the
relay kept beside the newest (Brainstorm's relay holds a few), or a d tag that is not a
lowercase pubkey (keyed by its event id, so it can never supersede a real card). It is kept (decided) and read only by syncs, never at
startup. It is written together with the index, so the two always describe the same set.
Tombstones make updates and full checks treat those cards as known instead of fetching them
again (format version 2; a version 1 file triggers a fresh download).
D12. Weekly full check
Purpose: catch deletions or rank-0 cards we missed (a relay outage, an update window overflow).
- Cheap check first.
COUNT {kinds:[30382], authors:[P]}againstN + removedSinceLastFull. If they match, stop. - Otherwise run
negentropyReconcileagainstnetwork-ids-v1.binthrough aNegentropyLocalIndexadapter: fetch the ids we need, drop the ids only we have. - On Android this is a WorkManager periodic job (unmetered, battery not low) that resolves
the account from
accountsCachelikeScheduledPostWorker.
Decided: keep the ids file and use negentropy; no periodic full re-download.
D13. Multiple accounts
Several Accounts can be live at once: background notifications load every writable
account.
- Every live account with a rank provider loads its index (~6 MB each), so push filtering works for all of them.
- Only the active account runs the foreground update.
- The weekly worker goes through every account with a rank provider.
D14. Settings model
| Setting | Where | Why |
|---|---|---|
| Provider (P, R) | the 10040 itself (already on relays, backed up locally) | One source of truth, interoperable |
minTrustScore (default 5) |
AccountSyncedSettings (NIP-78) |
A preference that should follow the user across devices |
| Brainstorm session token | memory only | Expires after 60 min (AUTH_ACCESS_TOKEN_EXPIRE_MINUTES); re-login just asks for one signature |
| Sync state | the index header | Local by nature |
D14a. No separate switch — decided. WoT filtering is on whenever the 10040 has a
30382:rank provider. Removing the provider (from the settings screen) turns it off.
Consequence to handle in the release: users who already have a 10040 from elsewhere will see DM Known/New, Curated notifications and replies change after the update, once their first sync finishes. The settings screen should show "Filtering by , minimum score N" and the sync progress, so the change can be explained and undone.
D15. Brainstorm onboarding (from brainstorm_server and Brainstorm-UI sources)
Base URL https://api.brainstorm.world; the NIP-85 relay is wss://scores.brainstorm.world.
GET /authChallenge/{pubkey}returns{data:{challenge}}.- Sign a kind 22242 with tags
["t","brainstorm_login"]and["challenge", c], thenPOST /authChallenge/{pubkey}/verifywith{signed_event}, which returns{data:{token}}. Login also creates the user's per-user service key on the server. - Authenticated calls send the header
access_token: <token>. POST /user/graperanktriggers a score run. It needs at least one follow, is throttled to once per 30 min, and can return 429 when a tier's quota is used up.GET /user/historyreturnsta_pubkey(the service keyP) andlast_time_calculated_graperank.- Sign a kind 10040 that merges into the existing list (keep other entries, public and
private) the entries
["30382:rank", P, R]and["30382:followers", P, R], usingTrustProviderListEvent.add. Publish to the outbox relays. - Once
last_time_calculated_graperankis set (poll/user/history, or pollCOUNTuntil it is above 0), start the cold sync (D9).
Details of this step:
- D15a. Public or private 10040 entries — decided: both. Reading already handles both
(
liveTrustProviderListdecrypts the private side). Onboarding offers the choice, with public as the default: Brainstorm's web app recognises only public entries (declaresTrustProvider), and other clients can only use public ones. Private hides which provider you use, at the cost of that interop.TrustProviderListEvent.add(..., isPrivate)already supports both. - D15b. An existing 10040 points at another rank provider. Rec. Warn and ask before replacing it, as Brainstorm's UI does. Never replace silently.
- D15c. Signers. Two signatures (22242, 10040) go through the normal
NostrSigner, so NIP-55 and NIP-46 signers work. Each call is a prompt for users who approve manually. - D15d. Free vs paid tiers.
brainstorm_serverhas billing tiers and manual-run quotas (enforce_manual_quota). The UI should surface 403/429 as "recomputed recently / quota reached", not as a failure. - D15e. Any NIP-85 provider — decided. The index, sync and filtering work with any
provider listed under
30382:rank. Two ways to set one up:- Manual (any provider): enter or paste the provider's service pubkey and relay; we write the 10040 entries. This needs no knowledge of the provider's API.
- Guided sign-up, one adapter per provider: NIP-85 does not specify how to create an
account with a provider or obtain its service key, so each provider's sign-up is its own
integration behind a small interface, e.g.
TrustProviderOnboarding { login(); requestScores(); serviceKey(); relay }. Brainstorm (above) is the first adapter; others are added one by one as their APIs are known.
- D15f. The provider writes the 10040 rows — decided (2026-10-08). A provider knows which
key serves which tag, so the rows come from it, not from us. Brainstorm's
GET /setup/{pubkey}(no token) returns exactly the rows its site publishes, e.g.30382:rank|followers|reporters|muters|hopsunder the per-user key plus a bare30392row for Trusted Lists; the guided sign-up publishes those, merged into the user's list (rows of the same names and the old score provider replaced, everything else kept). When/setupfails, it falls back torank+followersunderta_pubkey, what brainstorm.world has always published. A survey of 138 kind 10040s on four big relays found 112 naming Brainstorm, nearly all taggedclient: Brainstorm: users mostly set it up on the site. So each provider also names a website page (setupUrl, for Brainstorm/setup/activate) where the user can do it there; Amethyst then reads the list from the relays. - D15g. Fallbacks — decided (2026-10-08). For when the provider's setup goes wrong, or a
provider has no adapter: set up by hand (a row editor: a tag name from a hardcoded list
30382:rank|followers|hops|reporters|muters, a key and a relay per row; arankrow is required; "keep private" lives here), or copy someone's view: copy another user's public rows (npub or NIP-05) and see the network as they do, since their provider computes the cards for them. A provider can name its own observer (houseObserver; Brainstorm's is_@brainstorm.world), offered as "Brainstorm's default view". Copied rows land in the editor for review; nothing is published until saved. "Stop using" removes every row the rank provider's key serves.
D16. Rank and follower count move to the index
- Rec.
rankFlowandfollowerCountStrFlowread the snapshot (snapshotFlow.map { it.rank(user) }) when the index is ready andPis the provider for that slot. Brainstorm registers the same key forrankandfollowers. - Otherwise they fall back to today's per-profile path, so users without WoT see no change.
- When the index serves both slots,
UserCardsSubAssemblerstops askingRfor provider cards for each profile on screen, and keeps fetching only the account's own cards (nicknames). - Someone outside the network shows no rank. That matches today, since the provider has no card for them.
D17. Refreshing feeds when the snapshot or threshold changes
DM Known/New and the notification feed are additive filters; they won't re-check old items on their own.
- Rec. Expose
snapshotVersion: StateFlow<Long>combined withminTrustScore. - Their view models force a full refresh when either changes, debounced about 500 ms.
- The notification filter already rebuilds when
feedKey()changes (it includes the TopFilter code), so addingminTrustScoreand a WoT-ready flag to that key may be enough there.
D18. Consumers, one by one
| Surface | Change | Phase |
|---|---|---|
| DM Known | senderIntersects(follows) || hasSentMessagesTo || (rankProvider != null && activeSenders.any { accepted }). New stays its exact opposite, including the incremental path at :167-173. Group DMs: any accepted sender makes the room Known, matching how follows work today. |
4 |
| Notifications, Curated | Gate the author (NotificationFeedFilter.kt:712): isAuthorInFollows || accepted(author) when followList() is Selected and a rank provider exists. Zaps are already handled: notifAuthor resolves a ZapReceiptEvent to the zap request sender, including private zaps (:615-621). |
4 |
| Push notifications | Decided: apply Curated filtering to push too. Today push applies only account.isAcceptable. When the account's notification filter is Curated, the push path (EventNotificationConsumer / the renderers) uses the same author check as the in-app feed, after awaitLoaded (D3). Ideally both call one shared predicate so they cannot drift. |
4 |
| Replies | Decided: collapse replies from outside the network behind "N more replies from outside your network", expandable on tap. Follows, me and the thread author are never collapsed. Sorting tiers stay as they are. | 5 |
| Public chats / live chat | Not decided. Phase 5, revisit after replies ship. | 5 |
D19. Where code lives
| Piece | Module / package |
|---|---|
NetworkSnapshot (arrays, lookup, merge, binary codec) |
quartz/.../nip85TrustedAssertions/users/index/. Pure NIP-85 utility, no UI, KMP. |
Sync (cold, update, full check) as INostrClient extensions |
quartz/.../nip85TrustedAssertions/users/index/ beside it |
WebOfTrustNetworkState (load, save, schedule, StateFlow, accepted()) |
commons/.../wot/, owned by Account |
TrustProviderOnboarding interface + manual setup |
commons/.../wot/onboarding/ |
| Brainstorm adapter (HTTP client, sign-up flow) | commons/.../wot/onboarding/brainstorm/ |
| Settings screen, strings | commonsUI (strings in composeResources, per CLAUDE.md) |
| WorkManager job, foreground trigger | amethyst/ shim |
amy trust sync | status | check | setup |
cli/, a thin layer over the above |
| Desktop | wired through DesktopIAccount once the one-UI move reaches it; the core is already shared |
5. Phases
- Measure first. (JVM part done, see §2; phone still pending.)
amy wot syncagainst a real ~300k network: wall time, bytes, signature-check time, peak heap. Repeat on a mid-range phone through a debug button, and measure the cold index load there (decides whether D11 needs the memory-mapped fallback, and settles D9b/D9c). - Index + storage + sync (quartz/commons) with unit tests:
- file format round trip;
- merge, rank-0 and kind-5 removal;
- the shared-timestamp
UNPAGEABLE→ negentropy fallback; (P, R)mismatch.
- Account wiring + D16: rank and follower badges served from the index; drop the per-profile provider subscriptions.
- Web of Trust settings: current provider and sync status, the min-score slider with a live "N of M people pass" count, manual provider setup, and the Brainstorm adapter (D14, D15).
- Consumers: DM Known/New, Curated notifications in-app and in push (D18).
- Collapse out-of-network replies; then revisit chats (D18).
6. Decisions log
Settled on 2026-10-07:
| # | Decision |
|---|---|
| D9a | Check every signature (cold sync, updates, full check). |
| D11 / D12 | Keep the ids file; the weekly full check uses negentropy against it. |
| D14a | No separate switch: filtering is on whenever a 30382:rank provider exists. |
| D15a | Support public and private 10040 entries; onboarding offers both, public by default. |
| D7 | Don't store reporters / muters. |
| D18 push | Apply Curated filtering to push notifications; this is why the index must load fast (D3, D11). |
| D18 replies | Collapse replies from outside the network. |
| D15e | Accept any NIP-85 provider. Manual setup for any; guided sign-up via per-provider adapters, since provider account creation isn't specified. Brainstorm first. |
| D15f | The provider writes the 10040 rows (Brainstorm GET /setup/{pubkey}), here or on its site (setupUrl). |
| D15g | Hand-written rows and copying another user's rows (or a provider's house observer) are the fallbacks. |
Still open: D6a (memoize prefixes on User), D9b (resuming a cold sync), D9c (network
policy), D18 public chats. Phase 0 measurements should settle the first three.
7. Implementation notes (2026-10-07)
| Piece | Where |
|---|---|
| Index, builder, codec | quartz/.../nip85TrustedAssertions/users/index/TrustNetwork{Index,Builder,Codec}.kt (11 unit tests) |
| Sync (cold / update / full check) | .../users/index/TrustNetworkSync.kt; live run in WotNetworkIndexBenchmark.productionSync: 151k cards in 6.5 s cold, 1.2 s update, 2.9 s full check |
| Per-account state | commons/.../wot/network/TrustNetworkState.kt, owned by Account.trustNetwork; files in accounts/<pubkey>/wot/ |
| Verdict | Account.trustNetworkVerdict / isOutsideTrustNetwork / isKnownChatroom |
| Minimum score | AccountSyncedSettings.security.minTrustScore (synced, default 5) |
| Consumers | DM Known/New filters + DM push (isKnownChatroom); Curated notifications in-app (NotificationFeedFilter) and push (EventNotificationConsumer); collapsed out-of-network replies (ThreadFeedView); rank/follower badges from the index (UserCardsCache), which also drops the per-profile provider subscription |
| Onboarding | commons/.../wot/onboarding/ (TrustProviderOnboarding, BrainstormOnboarding, OkHttp transport) |
| Settings screen | commonsUI/.../settings/wot/WebOfTrustScreen.kt (Route.WebOfTrust, in Settings › Account), render-tested in both themes |
| Android | dedicated sync client + metered check (AppModules/AccountCacheState), foreground trigger, daily TrustNetworkSyncWorker |
Behaviours added during implementation:
- A cold download that returns zero cards does not activate filtering (a provider still computing a new user's scores has published nothing yet); the screen says so and retries.
- The index on disk is used before the 10040 resolves at startup (5 s grace), so a push that wakes the process is filtered.
- The provider's rank-0 cards are kept as tombstones in the ids file (D11), so neither the update nor the full check fetches them again (678 per check for the test account before).
- Update cost, live (Brainstorm, 154,755 cards): "nothing new" in 0.8–0.9 s for a whole
amy trust syncincluding JVM start and connect; the two COUNTs take ~30 ms after a 130–430 ms connect. The full check after a re-download needed 11 cards. Covered byTrustNetworkSyncTestagainst a local geode relay (batch arriving mid-sync, re-rank, rank-0 removal, kind-5 deletion).
Added later: amy trust sync | status | check | setup (named trust because amy wot is the
deprecated alias of fof), a thin layer over TrustNetworkState.syncNow / explain and the
extracted withScoreProvider. Live: 151k cards in 10.9 s, update 1.2 s, full check 2.6 s.
Added 2026-10-08:
- Cards seen between syncs (
TrustNetworkState.offer): profiles on screen ask the provider for their cards again, and a card newer than the sync cursor wins over the index until the next sync downloads it. Lookups read it first; feeds re-run only when it moves someone in or out (verdictRevision). - Public chats (NIP-28, live-stream chat, ephemeral chats, NIP-29 groups): consecutive
messages from outside the network collapse into one quiet row, "3 messages from outside your
network · Show", with no names, pictures, text or times (
OutsideNetworkChat.kt, opt-in viaLocalChatCollapseOutsideNetwork). Not applied to private chats (Known / New Requests), Concord (members-only) or geohash chats (throwaway keys would collapse nearly everything).
Structure after the 2026-10-08 cleanup (commons/.../wot/):
| Piece | Job |
|---|---|
TrustVerdicts |
One frozen snapshot of everything a decision depends on (network, cards seen since the sync, minimum score, user, follows) and the single rule, explain. Account.trustVerdicts publishes a new one only when an answer can change; feeds and screens key on it. Account.currentTrustVerdicts() is the live one for one-off checks (push). |
TrustNetworkStore |
The two files (network-v2.bin, network-ids-v2.bin); deletes the v1 names. |
TrustNetworkSource |
The relay (RelayTrustNetworkSource), or a fake in TrustNetworkSchedulingTest. |
TrustNetworkState |
Loads, matches the provider, schedules syncs (on one confined dispatcher), keeps the between-syncs cards. Outcomes are typed (TrustNetworkOutcome, TrustNetworkProblem). |
curatedHidesByTrust |
The one list of Curated exemptions, used in-app and by push. |
TrustProviderListState.resolvedRankProvider |
The provider once the 10040 is read and decrypted; replaces the 5 s grace timer. |
WebOfTrustViewModel |
Guided sign-up and manual provider entry, out of the composable. |
Not done: Nests lobby and minichat (own message lists), Desktop wiring (its DesktopIAccount
does not use the commons Account yet), and the phone measurements for phase 0.