Finding Your People Without a Central Directory
Finding Your People Without a Central Directory
The Problem: Social Graphs Don't Federate for Free
Identity bridging and community attestations solve "who are you." They don't solve "who do you know." A federated network has no central address book — your contacts might live on your PDS, on a friend's self-hosted instance, or on a completely different OpenFederation deployment. Without a shared directory, three things get hard fast:
- Finding people. How do you resolve
@carlos.hackneyfc.exampleto an identity if that handle lives on a PDS you've never talked to? - Managing relationships. Contacts, pending requests, and blocks all need to be revocable and visible to both sides — without either side trusting a third-party server to enforce it.
- Discovery without surveillance. "People you may know" is useful. It's also exactly the kind of feature that turns a private social graph into a public one if nobody asked first.
OpenFederation's contact graph, shipped in v1.1.0 (2026-04-29), addresses all three — using the same principle as the identity bridge layer: records are the interface, and trust derives from repo signing.
Contacts as Signed Records, Not Server State
A contact isn't a row in someone's database that only the server can see. It's a signed ATProto record, written to the same repos that hold posts and profiles:
net.openfederation.contact.request— lives on the requester's repo.{subject: did, note?: string, createdAt}.net.openfederation.contact.contact— written to both repos once a request is accepted.{subject: did, tags?: string[], acceptedAt}.net.openfederation.contact.block— lives on the blocker's repo only.{subject: did, createdAt}.
POST /xrpc/net.openfederation.contact.sendRequest
{
"subject": "did:plc:coach789",
"note": "Met at the U-16 tournament last week"
}
Accepting a request creates the contact record on both parties' repos in one operation and deletes the request. Rejecting just deletes it. Removing a contact deletes the caller's record and best-effort deletes the counterpart's — "best-effort" because the counterpart's repo might live on a PDS you have no write access to.
Requests, Blocks, and Withdrawal
| Endpoint | What it does |
|---|---|
| contact.sendRequest | Send a request; blocked in either direction → 403 Blocked |
| contact.respondToRequest | accept or reject a pending request |
| contact.withdrawRequest | Cancel your own pending outgoing request |
| contact.removeContact | End an accepted contact relationship |
| contact.block / contact.unblock | Block a DID; blocking also tears down any existing contact or pending request in either direction |
| contact.list / listIncomingRequests / listOutgoingRequests / listBlocks | Paginated reads, with displayName/avatarUrl populated from the subject's profile record when one exists |
Blocking is deliberately more than a flag: it actively removes the relationship it's blocking. A block that left a stale contact record behind — one that still granted whatever contact-gated permissions exist elsewhere — would be a block in name only.
Mutual Contacts and Friend-of-Friend: Opt-In by Default
The two riskiest endpoints in this release are also the most useful ones: finding mutual contacts, and surfacing friend-of-friend suggestions.
GET /xrpc/net.openfederation.contact.listMutualContacts?subject=did:plc:coach789
listMutualContacts only works if subject is already an accepted contact of the caller — otherwise it 404s. You can't use it to fish for overlap with a stranger's contact list.
GET /xrpc/net.openfederation.contact.listFriendOfFriends
This one is more powerful — it walks the caller's contacts' contacts and ranks candidates by mutual count — which is exactly why it's gated by a per-user opt-in, fof_discovery, defaulting to false. Only DIDs that have explicitly turned discovery on are eligible to be surfaced, and existing contacts and blocked DIDs (in either direction) are excluded from the results regardless.
This is the same posture as the identity bridge's approach to trust: don't make the useful thing the default thing when the useful thing is also the thing someone didn't consent to.
Cross-PDS Handle Resolution
A club's forum might have members whose handles resolve on a PDS you've never made a request to. Rather than build a bespoke contact.resolveSubject endpoint, cross-PDS resolution extends the standard com.atproto.identity.resolveHandle endpoint:
- Check local users/communities first.
- On a miss, look for a DNS TXT record at
_atproto.<handle>containingdid=...— the same convention ATProto itself uses for handle verification. - Fall back to
https://<handle>/.well-known/atproto-did. - Both lookups race under a 3-second timeout, and results cache for an hour.
The result: any client resolving handles gets working cross-PDS lookups for free, without needing its own resolver logic, and without OpenFederation needing a federated directory service at all — DNS and well-known files already are one.
Notifications, Quietly
Sending a contact request creates a notification for the recipient (category: 'contact-request'), fire-and-forget — a failure to notify never blocks the request itself. notification.list, notification.unreadCount, and notification.markRead round out a small, general-purpose notification system that other features (forum replies, moderation actions) can hang off the same category/payload shape without a redesign.
Architecture Principles
- Records are still the interface. Contacts, requests, and blocks are ATProto repo records like everything else — they federate, sync, and revoke using existing infrastructure.
- Delete is still revoke. No
revokedbooleans on any of these record types. - Privacy-sensitive discovery defaults to off. Friend-of-friend suggestions require an explicit opt-in; mutual-contacts lookups require an existing relationship.
- Extend the protocol's own conventions. Cross-PDS resolution reuses ATProto's DNS TXT / well-known handle verification pattern instead of inventing a new one.
What This Enables
- A club's roster tool can resolve every member's handle even when members joined from different PDS instances.
- A user can see which of their contacts also know a given person, without either party's full contact list being exposed.
- Friend suggestions surface only for people who chose to be discoverable — everyone else stays invisible to graph-walking by default.
- Blocking someone doesn't leave a dangling contact record that a client with stale logic might still honor.
Follow the work
Occasional, meaningful updates only.