9.3 KiB
Web of Trust — implementation plan
A plan for scoring strangers by the user's follow graph, based on how Amethyst implements web of trust. The goal is to make the screening dialog and the chat requests list more useful: "3 of your contacts follow this person" instead of a bare npub, and a way to let trusted strangers skip screening.
1. How Amethyst does it
Amethyst (vitorpamplona/amethyst) has two independent WoT layers:
Layer 1 — follow-graph score (WoTService), in
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt:
- For every pubkey
X, the score is the number of people the user follows who also followX(friends-of-friends, computed only from kind 3 contact lists). - The user's own follow set and each followed user's kind 3 feed the graph; the
service keeps a reverse index
target -> {followers}and derives scores from set sizes. - Purely in memory, rebuilt on start; a single writer coroutine serializes updates via an op channel and diffs each follower's list on republish, so a changed kind 3 only touches the entries it added/removed.
- Guardrails: WoT is disabled when the user follows more than
MAX_FOLLOWS(2000) accounts, and each ingested contact list is capped (MAX_FOLLOWS_PER_EVENT, 5000) to bound hostile publishers. - A
readyflag (set on first EOSE) tells the UI when scores are usable. - Consumers sort people follows-first, then by score (
PeopleOrder.kt) and show a desktop badge.
Layer 2 — NIP-85 trust-provider index, in
commons/.../wot/network/ and quartz/.../nip85TrustedAssertions/users/index/:
- The user names a "trust provider" (e.g. Brainstorm/GrapeRank) in a kind 10040 event; the provider publishes a signed rank card (kind 30382) for every member of the user's extended network (~150–300k events).
- The client downloads the whole set once, verifies every signature, and stores a compact binary index (~6 MB: 128-bit key prefixes + rank/hops/followers arrays) with cursor-based incremental sync (NIP-45 COUNT + NIP-77 negentropy).
- The gate is
accepted(pk) = pk == me || pk in follows || rank(pk) >= minScoreand it drives the DM Known/New split, the "Curated" notification filter, push filtering, and collapsing out-of-network replies. Exemptions live in one predicate (TrustVerdicts.kt/CuratedTrustFilter.kt) so in-app and push can't drift: zaps always pass, chats follow the DM rule, everything else is gated on network membership.
What we take, what we skip
| Amethyst piece | Take? |
|---|---|
| Friends-of-friends score, reverse index, diffing, caps, ready flag | Yes — this is the whole feature for coop |
| Follows-first ordering helper | Only if we ever list non-contacts; not needed now |
| NIP-85 provider onboarding, kind 30382 index, negentropy sync, binary files | No — massive sync + storage complexity for a DM client |
| One shared exemption predicate per surface | Yes as a principle: gate requests in one place |
Layer 2 exists because Amethyst gates a 300k-user social graph. Coop gates incoming DMs; the follow graph (one hop of contact lists) is enough signal and needs no new events, relays, or storage formats.
2. Design: WebOfTrust in the person crate
One new logical component, one new file: crates/person/src/wot.rs, re-exported
from crates/person/src/lib.rs and initialized in person::init as a global
entity, exactly like PersonRegistry.
State
pub struct WebOfTrust {
/// The signed-in user's contacts (latest kind 3)
follows: HashSet<PublicKey>,
/// The current user, excluded from credit
self_key: Option<PublicKey>,
/// Per-follower cached follow set, for diffing on republish
lists: HashMap<PublicKey, HashSet<PublicKey>>,
/// Reverse index: target -> contacts who follow them
reverse: HashMap<PublicKey, HashSet<PublicKey>>,
/// Disabled when the follow set exceeds MAX_FOLLOWS
disabled: bool,
ready: bool,
tasks, subscriptions, ...
}
API (all reads are plain methods; writes go through the entity):
pub fn score(&self, key: &PublicKey) -> usize // reverse[key].len()
pub fn is_in_network(&self, key: &PublicKey) -> bool // follows || score >= 1
pub fn mutuals(&self, key: &PublicKey) -> Vec<PublicKey> // who vouches, for the dialog
pub fn is_ready(&self) -> bool
pub fn is_disabled(&self) -> bool
Events: WoTEvent::Updated and WoTEvent::Ready, emitted after hydration and
on every graph change; consumers subscribe like they do to PersonRegistry.
Data flow — reuses the existing pattern end to end
Same shape as PersonRegistry::new (background listener -> flume channel ->
entity update -> cx.emit):
- Hydrate from the local database on creation and on
StateEvent::SignerChanged:- own kind 3 (newest event, authors sorted by
Reverse(created_at)likeDirectory::trackdoes) ->follows; - one batch query
Filter::new().kind(Kind::ContactList).authors(follows)-> dedupe per author keeping the newest -> seedlists/reverse.
- own kind 3 (newest event, authors sorted by
- Stay fresh: one persistent subscription with a dedicated
SubscriptionId(addWOT_CONTACTSbesideDEVICE_GIFTWRAP/USER_GIFTWRAPinstate::constants), filterkind(Kind::ContactList).authors(follows). The background listener accepts any kind 3 whose author is infollows(don't couple to subscription ids), diffs it against the cached list, and updates the reverse index. Re-issue the subscription when the user's own contact list changes. - Rules ported from
WoTService:- exclude the follower and
self_keyfrom every ingested list; - on follow-set change, un-credit removed followers from the reverse index;
MAX_FOLLOWS = 2000disables the service (clear state, emit ready);MAX_TAGS_PER_LIST = 5000caps one follower's ingested list;- reset everything on sign-out / account switch.
- exclude the follower and
No persistence: the graph rebuilds from the LMDB cache on every start, the same
way ChatRegistry rebuilds rooms. No new relay events, no settings file, no new
dependencies.
3. Consumers (in order)
Phase 1 — screening dialog (crates/workspace/src/dialogs/screening.rs)
Replace check_wot's database query (which only sees contact lists that happen
to be cached, so "mutual contacts" is usually empty) with the registry:
mutual_contacts = wot.mutuals(&public_key)— the people the user follows who follow the stranger, which is the correct WoT semantic and matches Amethyst's score.- The existing "Mutual contacts" dialog keeps working unchanged: it already
renders a
Vec<PublicKey>through aDirectory. - When
wot.is_disabled()or not ready, fall back to the current query so the dialog degrades to today's behavior.
Phase 2 — requests list badge
Where request rooms are rendered, show the mutual count for the sender:
crates/workspace/src/sidebar/inbox.rs(requests_rows) andcrates/workspace/src/panels/greeter.rs(load): one caption"N mutual contacts"under the request preview, via the room's other member.- Subscribe to
WoTEvent::UpdatedwherePersonEventis already subscribed so rows re-render when the graph changes.
Phase 3 — opt-in auto-trust (behind a setting)
The payoff: strangers vouched for by the user's contacts can skip screening.
-
Add
pub wot_auto_trust: booltosetting_accessors!incrates/settings/src/lib.rs(defaultfalse) and a toggle in the existing preferences screen (crates/workspace/src/dialogs/settings.rs). -
When enabled, apply it in one place —
ChatRegistry::new_messageincrates/chat/src/lib.rs, next to the existing promotion rule — so in-app and any future consumer can't drift (Amethyst's lesson fromTrustVerdicts):if this.kind == RoomKind::Request && (message.rumor.pubkey == public_key || (AppSettings::get_wot_auto_trust(cx) && wot.is_in_network(&message.rumor.pubkey))) { this.set_ongoing(cx); }Group requests stay conservative: every member must be in the network (or already a contact) to promote, mirroring how
is_contactworks today.
Deliberately not planned: sorting contacts (the new-chat dialog lists only follows already), notification feeds (coop has none), muting/spam scoring.
4. Non-goals
- NIP-85 trust providers (kind 10040/30382), Brainstorm onboarding, rank thresholds — revisit only if coop ever needs a network-scale gate.
- Persisting the graph or multi-hop (3+) scores — kind 3s are one LMDB query away on restart.
- Background jobs / WorkManager equivalents — hydration is two local queries.
5. Testing
- Unit tests for the pure graph logic in
wot.rs(nocxneeded): seeding, per-follower diffing, follow-set removal un-crediting, self/follower exclusion, both caps and the disabled transition. - One GPUI test for hydration ordering (newest kind 3 wins) using
TestAppContext; usecx.background_executor().timer(...)for any waits per the project guidelines.
6. References
WoTService.kt— the model this plan ports: https://github.com/vitorpamplona/amethyst/blob/main/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.ktPeopleOrder.kt,CuratedTrustFilter.kt,TrustVerdicts.kt— consumers and the shared-predicate pattern.- Design doc for Amethyst's NIP-85 index (the layer we skip, with the reasoning
and measurements):
commons/plans/2026-10-07-wot-network-index.mdon main.