215 lines
10 KiB
Markdown
215 lines
10 KiB
Markdown
# 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 follow `X` (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 `ready` flag (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) >= minScore`
|
||
and 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
|
||
|
||
```rust
|
||
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):
|
||
|
||
```rust
|
||
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`):
|
||
|
||
1. **Hydrate from the local database** on creation and on
|
||
`StateEvent::SignerChanged`:
|
||
- own kind 3 (newest event, `limit(1)` like `Directory::track` and
|
||
`query_chat_rooms` do) -> `follows`;
|
||
- applied immediately so the dialog has data before the relays respond.
|
||
2. **Stay fresh with NIP-77** (`WebOfTrust::sync`, no persistent subscription):
|
||
- `client.sync(Filter::new().kind(Kind::ContactList).authors(follows + self))`
|
||
reconciles with every relay in the pool and downloads only the contact
|
||
lists the local database is missing (negentropy items come from the
|
||
database, direction is down-only);
|
||
- after each reconcile, the newest stored list per covered author is applied
|
||
to the graph and a `Ready`/`Updated` event marks what changed;
|
||
- triggers: hydrate, a 15-minute timer (Amethyst's D10 cadence), and a
|
||
follow-set change; when a reconcile grows the follow set (fresh install),
|
||
it re-syncs once more to cover the new authors.
|
||
3. **Rules ported from `WoTService`**:
|
||
- exclude the follower and `self_key` from every ingested list;
|
||
- on follow-set change, un-credit removed followers from the reverse index;
|
||
- `MAX_FOLLOWS = 2000` disables the service (clear state, emit ready);
|
||
- `MAX_TAGS_PER_LIST = 5000` caps one follower's ingested list;
|
||
- reset everything on sign-out / account switch.
|
||
|
||
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. Relays without NIP-77 fail their reconcile (logged); the graph
|
||
then reflects whatever the database already holds.
|
||
|
||
## 3. Consumers (in order)
|
||
|
||
### Phase 1 — screening dialog (`crates/workspace/src/dialogs/screening.rs`)
|
||
|
||
Status: **implemented**. The `WebOfTrust` registry lives in `crates/person/src/wot.rs` and
|
||
is initialized in `person::init`.
|
||
|
||
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 a `Directory`.
|
||
- 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
|
||
|
||
Status: **partially implemented**. Request rooms whose members are all outside
|
||
the web of trust show a `SPAM` badge in the sidebar
|
||
(`Room::is_spam` + `WebOfTrust::is_outside`, rendered in
|
||
`crates/workspace/src/sidebar/rows.rs`; the sidebar subscribes to `WoTEvent` so
|
||
badges update when the graph loads). The profile dialog shows the score as a
|
||
"Web of Trust" field (`crates/workspace/src/dialogs/profile.rs`, other users
|
||
only). The mutual-count caption below is still open and will follow a
|
||
different flow.
|
||
|
||
Where request rooms are rendered, show the mutual count for the sender:
|
||
|
||
- `crates/workspace/src/sidebar/inbox.rs` (`requests_rows`) and
|
||
`crates/workspace/src/panels/greeter.rs` (`load`): one caption
|
||
`"N mutual contacts"` under the request preview, via the room's other member.
|
||
- Subscribe to `WoTEvent::Updated` where `PersonEvent` is already subscribed so
|
||
rows re-render when the graph changes.
|
||
|
||
### Phase 3 — opt-in auto-trust (behind a setting)
|
||
|
||
Status: **implemented** (Phase 2 skipped for now; it will follow a different
|
||
flow).
|
||
|
||
The payoff: strangers vouched for by the user's contacts can skip screening.
|
||
|
||
- `pub wot_auto_trust: bool` in `setting_accessors!` in
|
||
`crates/settings/src/lib.rs` (enabled out of the box: `Default` is `true` and
|
||
a `#[serde(default = "default_true")]` fallback covers settings files saved
|
||
before the field existed) and a "Web of trust" toggle in
|
||
the preferences screen (`crates/workspace/src/dialogs/settings.rs`).
|
||
- The rule lives in one predicate — `Room::auto_trusted(me, cx)` in
|
||
`crates/chat/src/room.rs`: the setting is on and every member other than the
|
||
current user is inside the web of trust (`is_in_network`, so followed
|
||
senders always pass and strangers need at least one mutual contact).
|
||
It's applied at every point a room's kind is decided so the states can't
|
||
drift: `ChatRegistry::add_room` (incoming first message),
|
||
`ChatRegistry::new_message` (live promotion next to the existing
|
||
I-sent-it rule), and `ChatRegistry::extend_rooms` (rooms loaded from the
|
||
database, so the split survives a restart).
|
||
|
||
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, and negentropy keeps them current.
|
||
|
||
## 5. Testing
|
||
|
||
- Unit tests for the pure graph logic in `wot.rs` (no `cx` needed): 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`; use `cx.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.kt
|
||
- `PeopleOrder.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.md` on main.
|