This commit is contained in:
2026-10-20 14:36:56 +07:00
parent 0f0216051f
commit d64da17c5c
6 changed files with 70 additions and 314 deletions
-214
View File
@@ -1,214 +0,0 @@
# 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.