This commit is contained in:
2026-10-20 08:44:32 +07:00
parent a22aecc694
commit 80f4bc387e
3 changed files with 237 additions and 49 deletions
+201
View File
@@ -0,0 +1,201 @@
# 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, authors sorted by `Reverse(created_at)` like
`Directory::track` does) -> `follows`;
- one batch query `Filter::new().kind(Kind::ContactList).authors(follows)`
-> dedupe per author keeping the newest -> seed `lists`/`reverse`.
2. **Stay fresh**: one persistent subscription with a dedicated
`SubscriptionId` (add `WOT_CONTACTS` beside `DEVICE_GIFTWRAP`/`USER_GIFTWRAP`
in `state::constants`), filter `kind(Kind::ContactList).authors(follows)`.
The background listener accepts any kind 3 whose author is in `follows`
(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.
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.
## 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 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
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)
The payoff: strangers vouched for by the user's contacts can skip screening.
- Add `pub wot_auto_trust: bool` to `setting_accessors!` in
`crates/settings/src/lib.rs` (default `false`) and a toggle in the existing
preferences screen (`crates/workspace/src/dialogs/settings.rs`).
- When enabled, apply it in one place — `ChatRegistry::new_message` in
`crates/chat/src/lib.rs`, next to the existing promotion rule — so in-app and
any future consumer can't drift (Amethyst's lesson from `TrustVerdicts`):
```rust
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_contact` works 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` (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.