Dialogue·Friends — the contact layer
← room2

Friends — the contact layer, and the four rootsdesign · not built

WeChat's request-and-accept, studied and ported onto our invite-gated pool — plus the IA move it forces: the app's roots become a bottom bar of four (Chats · Friends · Discover · Me), which is where every proven messenger has put them. This page is gap 13 of Proven · Better · New, taken from a card to a build. Opened 2026-08-08.

1 · the one open door 2 · WeChat, studied 3 · three models 4 · ours 5 · the data shape 6 · personas 7 · the four roots 8 · mockups 9 · what flips 10 · build order

1 · The one open door

The app already has a philosophy about reach, and it is written down in two places. db.shares_room(): "no global user directory — you can only message people you already share a room with." db.contacts(): "an invite-gated app keeps discovery social." Both are enforced. The DM gate, the profile page and the people picker all read them.

Then there is one query that doesn't. db.users_not_in_room() (lib/db.py:502) returns the whole users table, and GET /api/rooms/{id}/invitable serves it to any member of any room. Its own docstring admits it: "Friend-scoping will narrow this later; for now it's the whole user table."

That one door is enough to void the other two, because being in a room together is what grants the rest:

any member of any one room /invitable users_not_in_room() = THE WHOLE USER TABLE add them by username shares_room() is now true — forever DM key PROFILE CONTACTS The rest of the gate is sound — the app-level _gate already refuses any /api/rooms/<id>/… path to a non-member, and /api/user/<uid>/profile is room-mate-scoped. The leak is not the walls. It is that anyone can open a door in one.
fig 1 · the chain — one un-narrowed query is the whole reach model
So the friend layer is not a new philosophy. It is the missing half of one the code already believes: my people persist beyond any room, and a stranger cannot pull me into one. Everything else here follows from closing that query.

2 · WeChat, studied

Sources at the foot of the page. The mechanics that matter, in the order a relationship passes through them.

MechanismWeChat's nameWhat it actually does
The pool微信号Closed. There is no browsable directory — a person is reachable only through a handle you already hold, a code you scanned, or a room you're both in.
Findability switches添加我的方式Every add-path is its own on/off toggle — WeChat ID, phone number, group chat, QR code, name card. Not one privacy level: six independent doors, each closable.
The verification message验证申请A short free-text note that rides the request, defaulted to your name and meant to be edited. This is the whole ceremony — one sentence of "who I am and why".
The approval toggle加我为好友时需要验证On by default. Off means anyone who finds you is added with no ceremony at all — the setting exists, and almost nobody turns it off.
Accept接受 / 忽略One tap. Ignore is the other button, and it is silent — the sender is never told no; the request simply never resolves.
The private rename备注At accept (or any time after) you set a remark name that only you see, plus tags, a description, a phone. The most under-copied feature in the whole app: it makes a contact list survive 500 people.
Tiered friendship仅聊天A friend can be marked chat-only — messages, calls and transfers, but no Moments, no status. A second citizenship inside "friend", set per person and changeable later.
Group ≠ friends群聊Law. Sharing a group never makes you contacts. You can talk in the room; adding each other from the member list is still a request.
Removing删除Unilateral and silent. The famous consequence: WeChat stores two directed edges, so the other side still thinks you're friends until a message bounces.
Blocking加入黑名单Also silent. Denies messages and Moments in both directions without ever saying so.
The second citizen公众号Official Accounts sit in the same Contacts tab under their own header — but the verb is follow, not friend: no approval, one-way, unfollow is silent. A whole parallel relationship type, filed beside people.
the detail everyone misses WeChat's request ceremony is one sentence long. There is no form, no reason-code, no category. The note is optional-in-practice and pre-filled. What makes the ceremony work is not its weight — it's that it is asymmetric in time: the asker spends three seconds, the answerer spends one tap, and the pool stays closed the whole time. Any design that makes the request heavier is copying the ritual and missing the mechanism.

3 · Three models, one axis

The axis is where the friction sits: before reach (WeChat), after reach (WhatsApp), or nowhere at all but reach is gated on reciprocation (Snapchat).

DimensionWhatsAppWeChatSnapchatOurs — proposed
The poolevery phone number on earth, openclosed; per-path findability switchesclosed-ish; username + Snapcodeinvite-gated accounts (unchanged)
Becoming friendsaddress-book sync — no ceremony, one-way is finerequest + note → accept; symmetricone-way add; "Added Me" list; no decline buttonrequest + note → accept, on the notifications rail
Cold DMallowed; block is downstreamforbidden until acceptedgated on reciprocationfriends ∪ room-mates — nothing else
Add pathsknowing the number is the pathhandle · QR · group member · name cardusername · Snapcode · Quick Addroom-mate's profile · personal link/QR · handle search behind a findable toggle
Group ≠ friendsn/alawn/akept as law
Private renamefrom your address book备注 — first-classdisplay-name overrideyes — a remark per friend, phase 4
Tiered friendno (privacy tiers instead)仅聊天nonot v1 — recorded, see §4
Removingdelete from address book; silentunilateral, silent, the bounceremove; silentunilateral, silent, no bounce
Decliningn/aIgnore — sender never toldno such button by designIgnore — sender never told
snap's third way, kept on file Snapchat's variant deletes the accept ceremony entirely: you add one-way, they see you in Added Me, and messaging unlocks only on reciprocation. Nobody ever has to reject anyone — "decline" isn't a button, it's the absence of an action. We take WeChat's note-carrying request instead, because in an invite-gated pool the note does real work (it carries which room we met in). But if the ceremony reads heavy in testing, Snap's variant is one deletion from this design, not a redesign: drop the Accept button, rename the section "Added me", and reciprocation becomes the accept. Its Quick Add — mutual-friend suggestions — is the cautionary half: it drove Snap's growth and earned the minor-safety retrofits that followed. We don't build it. Ever.

4 · Ours — the blend, and one correction

WeChat's ceremony on our invite-gated pool — request + one-line note → one-tap accept, symmetric while it lasts, unilateral silent removal — with WhatsApp's lightness where it is safe: inside a room you already share, the request arrives pre-loaded with its context and there is no interrogation wall.

The correction gap 13 needs

The gap-13 card records two rules that cannot both hold: "one symmetric friendship row per pair" and "unilateral remove, silent (the proven mercy)." With a single shared state, my removal rewrites your list — that is not unilateral, and the mercy it names is an artifact of WeChat storing two directed edges, not of symmetry. Three ways out:

OptionWhat you getWhat it costs
a · two directed edges
WeChat exactly
Removal touches only my edge; the other side keeps theirs. True unilateral.You must then build the bounce — a dead-letter path, a rejected-message bubble, and an error state a person discovers by being humiliated in it. WeChat's single most disliked behaviour.
b · one shared state
the card as written
Simplest possible table.Not unilateral. My housekeeping edits your list, and the same cell is written by two people — the exact shape room_members.status was split apart to avoid (v515).
c · one row, two per-side states
the pick
Each side owns its own cell. Removal writes only mine. The pair stops being live for both — so no bounce path can exist — and nobody is ever notified. Block folds into the same cell.Every read branches on lo/hi. Exactly what dm_pairs already does, and one helper hides it.
what we deliberately don't reproduce In WeChat, a removed friend doesn't know until a message bounces — they keep you in their list for months. We drop that. The other side's row simply stops being there, with no notice, no event and no explanation. It is the same mercy (nobody is ever told "X removed you") without the one machine that makes WeChat's version cruel.

Chat-only (仅聊天) — recorded, not built

WeChat's second tier is genuinely good and we have the surface it would govern (Vibes, the profile page, the persona feed). It is not v1: a tier is only meaningful once there's something behind it worth withholding, and until the "My friends" privacy tier of gap 11 exists there is nothing to withhold. The per-side state column has room for it — ok becomes ok | ok_chat — so adding it later is a value, not a migration.

5 · The data shape

One row per pair, keyed the way dm_pairs is keyed. Each side owns its own state cell — that single decision is what makes removal unilateral, decline silent, and block free.

-- lib/db.py · SCHEMA CREATE TABLE IF NOT EXISTS friendships ( user_lo INTEGER NOT NULL, -- smaller user id of the pair (dm_pairs' convention) user_hi INTEGER NOT NULL, lo_state TEXT NOT NULL, -- ok | pending | gone | blocked — LO's own cell, written only by LO hi_state TEXT NOT NULL, -- …and HI's lo_remark TEXT, -- 备注: LO's private name for HI (phase 4) hi_remark TEXT, asked_by INTEGER, -- who sent the live request note TEXT, -- the verification message, ~140 chars via TEXT, -- context: 'room:<id>' | 'link' | 'search' — renders as "via Flat-hunt crew" asked TEXT, -- ISO ts of the current request (drives the re-ask cooldown) since TEXT, -- stamped the first time both cells read ok PRIMARY KEY (user_lo, user_hi) ); CREATE INDEX IF NOT EXISTS idx_friend_lo ON friendships(user_lo, lo_state); CREATE INDEX IF NOT EXISTS idx_friend_hi ON friendships(user_hi, hi_state);

The predicate is one line, and it is the only thing the rest of the app ever asks:

friends(a, b) ⟺ lo_state == 'ok' AND hi_state == 'ok' may_reach(a, b) ⟺ friends(a, b) OR shares_room(a, b) — and NEITHER cell is 'blocked'

The state machine

a fresh ask flips the other cell back to pending — once per 7 days strangers NO ROW A asks + NOTE + VIA requested A: ok · B: pending B accepts friends A: ok · B: ok either removes parted one cell = gone B ignores → B's cell becomes gone. A is never told; A's screen keeps reading REQUESTED. blocked my cell = blocked Reachable from any state and never announced — the one state a re-ask cannot leave. It is not a fifth box on the line; it is a value in the same cell as the other four.
fig 2 · four states, and every transition writes exactly one cell
why the row survives a parting Keeping the row after gone buys three things a DELETE would throw away: the re-ask cooldown (a declined request cannot be re-sent for 7 days — the one anti-nag rule this design needs), the block that must outlive the friendship, and the fact that re-friending doesn't lose the remark you'd already written. A row per pair that ever interacted is a rounding error in an invite-gated pool.

The API

RouteDoes
GET /api/friendsThe Friends root in one read: {requests[], friends[], personas[]} — requests carry note + via + the requester's card.
POST /api/friends/ask{user_id, note, via}. Refuses if blocked, if the cooldown is live, or if the target isn't reachable by the path claimed.
POST /api/friends/acceptOne tap. Writes my cell to ok, stamps since, notifies the asker only.
POST /api/friends/ignoreWrites my cell to gone. Notifies nobody. Returns 200 either way — a decline must be indistinguishable from a slow answer.
DELETE /api/friends/{uid}Unilateral remove. Writes my cell. Silent.
POST /api/friends/{uid}/blockWrites my cell to blocked. Silent. Drops the DM key in both directions and denies future asks.
POST /api/personas/{slug}/addFollow — no approval. DELETE to unfollow. See §6.

On the rail. A request is a friend_request notice with an Accept verb — the noticeSpec pattern the rail already has, ckey=friend:<uid> so a re-ask collapses onto the same row instead of stacking. It is also our second organic push payload (gap 1 gives it the lock screen): the FarmVille neighbour bar, round two — the request is the come-back reminder.

6 · Personas are the second citizen class

The owner's ask — "show friends list and persona list in Friends" — lands on a slot WeChat already built and named. Official Accounts live in the Contacts tab, under their own header, with a different verb. Personas are ours:

A human friendA personaWeChat's precedent
The verbAdd friend → requestAdd — instant, no approval关注 (follow)
Symmetrysymmetric while liveone-way, alwaysone-way
Removingunilateral, silentunilateral, silent — there is no one to hurt取消关注
Reach it grantsDM beyond roomsa private chat, seating that personathe account's messages
Where it's filedthe A–Z listits own section above the A–Z list公众号, own header
the storage trap Personas have no DB rows by design — disk is the registry (prototype/personas/personas-built/). So the follow table is slug-keyed with no foreign key, the way vibes_likes and hidden_msgs already are, and the read path resolves each slug through run_room.persona_dir() and filters it through discoverable(slug, uid). A follow whose persona was deleted or hidden renders as nothing — never as a broken row, never as a 404.
CREATE TABLE IF NOT EXISTS persona_follows ( user_id INTEGER NOT NULL, slug TEXT NOT NULL, -- NO FK: disk is the registry added TEXT NOT NULL, PRIMARY KEY (user_id, slug) );

Seeding it costs nothing: the Me sheet already computes "Personas you've met" (meVoices). On first open of the Friends root, every met persona is offered as a one-tap add — the list is populated before the user has done any work, which is the only way a contact list ever survives its first screen.

7 · The four roots

The friend layer needs a home, and the app has run out of top-strip. Today's roots are Notes · Chats · Studio · Vibes in a header strip, with Me hidden behind a hamburger. That's five roots in two different places, one of them invisible. The move is the one every proven messenger made: four roots, at the bottom, always visible.

TODAY — 5 roots, 2 places, 1 of them invisible HEADER STRIP + A HAMBURGER Chatsstays a root Vibes→ Discover Studio→ Discover Notes→ Me MeBEHIND ≡ promoted to a root no friend layer at allthe new root PROPOSED — 4 roots, one bar, always visible BOTTOM BAR (FOOT OF THE PANE ON WIDE) 1 · Chats the list, the unread badge, the Call-the-panel pill — unchanged 2 · Friends requests on top · personas · then people, A–Z 3 · Discover everything that isn't yours yet — Vibes · Personas · Studio · Scan 4 · Me everything that is — Notes · Notifications · your code · preferences THE ≡ HAMBURGER IS DELETED — ITS ONLY JOB WAS OPENING ME
fig 3 · the remap — Notes lands in Me because that is where Favourites has always lived
TodayTomorrowWeChat's own slotWhy
Chats (header tab)Chats — root 1微信Unchanged. The list, the unread badge, the "Call the panel" pill.
Friends — root 2通讯录Requests on top · Personas · then people, A–Z with an index rail.
Vibes (header tab)Discover › Vibes发现 › 朋友圈Moments has never been a root. It is the first row of Discover, and always has been.
Studio (header tab)Discover › Studio发现 › 小程序A factory you visit occasionally is not a root. It's the strongest row in Discover.
the new-chat pickerDiscover › Personas发现 › 搜一搜Browsing the cast is discovery. Personas you've added live in Friends; the whole shelf lives here.
Notes (header tab)Me › Notes我 › 收藏Notes is saved lines from every chat — that is Favourites, and Favourites has lived under Me for a decade. Not a demotion; the proven slot.
Me (behind ≡)Me — root 4Promoted out of the hamburger. Hero · Notes · Notifications · Preferences · Admin.
≡ hamburgerdeletedIts only job was opening Me. The notification dot moves to the Me tab.

Three things that break, and how

1 · the pill collides with the bar The "Call the panel" capsule sits at the bottom of the landing today, exactly where the tab bar wants to be. Two bars of chrome is one too many. The resolution: at rest the pill rides above the bar, and only on the Chats root — Friends, Discover and Me have no composer. When the composer arms (focus), the tab bar slides out and the composer owns the bottom: the same beat every messenger plays when the keyboard comes up, so the bar costs nothing in the state where space is scarce. The trap: the reservation must be driven by the live --app-h / visualViewport machinery, never by a cached measurement of the bar it is making room beside — that is precisely the v657→v658 defect the emoji search strip already paid for.

Fallback on file, not the pick: if two stacked bars still read heavy on a 375px phone, the pill folds into a coral FAB above the bar (Material's proven shape).
2 · the back stack Roots don't register — that's the v525 owner call, and Chats/Vibes/Studio/Notes already obey it. The one migration: mePage is registered today as a back layer. Promoted to a root it must be de-registered, or Back from Me will return to Chats instead of exiting the app — which is exactly the bug v521 introduced and v525 reverted, arriving a second time through a different door.
3 · wide layout On wide the bar lives at the foot of the sidebar pane, not the window — the chat keeps the right side and the pane keeps its resizer. Same markup, same four buttons, one @media rule. No second breakpoint tree, and no left icon rail: we are not building a desktop app, we are building a phone app that survives a desktop.

Badges

Four roots, four counters, all of which already exist somewhere: Chats = the unread count · Friends = pending requests (new) · Discover = the Vibes dot and the Studio cook-dot, folded into one · Me = navNotifDot, moved off the dying hamburger.

8 · Mockups

375px. House palette, house type. The bar is the same four buttons on every screen.

1 · chats — the bar arrives
9:41● ● ●
Chats
F
Flat-hunt crew
老周: 那份对比表我明天发
3
M
Mary
ok — 我看完再说
09:12
Chestnut
Three ways to read that clause…
Tue
B
Book club · 4
Leo: 下周三行吗
Mon
›››Call the panel
Chats
Friends2
Discover
Me
2 · friends — requests · personas · people
9:41● ● ●
Friends
Search friends and personas…
New requests · 2
K
Kenvia Flat-hunt crew
"Mary 说你手里有那份贷款对比表——求一份"
Accept
S
Saravia link
"Leo gave me your link — the Tuesday reading group?"
Accept
Personas · 4
Chestnut
The clause, three ways.
R
Rams
Less, but better.
阿测
测试用,随叫随到。
Friends · 3
M
Mary
Renovating. Slow replies.
老周 (邻居)
看盘中,勿扰
L
Leo
#ACLMS
Chats
Friends
Discover
Me
3 · the ask — from a room-mate's profile
9:41● ● ●
Ken
K
Ken
@kenz
Two kids, one mortgage, no time.
Shared rooms
FFlat-hunt crew
Add Ken to your friends
Mary 说你手里有那份贷款对比表——求一份
VIA · FLAT-HUNT CREW  — they'll see where you met
Send request
Not now
4 · the + — three doors, no directory
9:41● ● ●
Friends
Search friends and personas…
New requests · 2
KKen
SSara
Add someone
Scan a code
My code & link
@Search by handle
Let people find me by handle
off — only your link and rooms reach you
5 · discover — everything not yours yet
9:41● ● ●
Discover
Vibes
Personas
Studio
Scan
From Vibes
Chestnut
"人人都说要读合同,没人说该读哪一句。"
Join
New in the cast
D
Dan Wang
Industry, seen from the factory floor.
P
Perel
The question under the question.
Chats
Friends
Discover
Me
6 · me — notes comes home
9:41● ● ●
C
CW
@cw · 87 days
Reading contracts so you don't have to.
Notes142 saved
Notifications3 new
My code & link
Preferences
Privacy & friendswho can add me
Last fortnight
Chats
Friends
Discover
Me
reading the mocks Requests sit on top and carry their context. "via Flat-hunt crew" is WeChat's grammar exactly — the note answers why, the via answers where we met, and between them an accept is one tap with nothing to work out. Ignore is the absence of a tap; there is no decline button and the sender is never told.

Personas are filed above people, in their own section with square avatars — the shape rule the app already uses to say "this is not a human". They arrive pre-populated from the personas you've met, so the list is never empty on first open.

The + has three doors and no directory. Scan, my code/link, handle search — and the findable toggle right there under them, off by default, so the one path that could be brute-forced is the one path you have to switch on yourself.

Me is where everything of yours lives — Notes (Favourites), Notifications, your code, preferences, and the new privacy row that owns who can add me to a room.

9 · What flips when it ships

Every call site that asks "can these two reach each other" today, and what it asks tomorrow. The migration is one predicate swap, plus one query that stops returning the world.

SurfaceTodayAfter
GET /api/rooms/{id}/invitablethe whole user tablefriends ∪ room-mates, minus anyone blocked either way. The door in fig 1, closed.
POST /api/dmshares_roomfriends ∪ room-mates, minus blocked. Room-mates keep the door they have — WeChat's group temp-chat is the proven precedent and it is already shipped behaviour.
GET /api/contactsroom-matesfriends first, then room-mates — same payload shape, so the people picker needs no change.
GET /api/user/{uid}/profileroom-mates onlyfriends ∪ room-mates. A non-friend room-mate sees the card; the "My friends" privacy tier of gap 11 becomes expressible for the first time.
POST /api/rooms/{id}/membersany usernamemust pass the same predicate — "who can pull me into a room" stops being an open door and becomes a setting.
db.shares_room()the reach predicatedemoted to one input of may_reach(). It stays; it stops being the whole answer.
blockdoesn't existbuildable — one cell value. This closes the debt the v515 duo-exit note recorded: "real prevention waits for block."
noticesa friend_request kind with an Accept verb, ckey per requester; the rail's third mirror gives it the lock screen.
the migration is quiet On the day it ships, nothing visibly narrows for anyone: every existing room-mate keeps every door they had. What changes is that the pool stops being enumerable — you can no longer reach someone you have never met. Which means Phase 1 can ship with no UI at all, and be verified by the smoke test alone.

10 · Build order

PhaseWhatWhy here
1The table + the predicate. friendships, may_reach(), and invitable narrowed. No UI.Closes fig 1's door on day one. Ships alone, breaks nothing, and every later phase is then additive.
2The Friends root's content — requests, accept/ignore, the list, personas-as-follows, the rail notice. Lives as a page off the Me sheet for now.The whole feature, testable, before the IA move. If the ceremony reads heavy, this is where we'd learn it and take Snap's fallback — cheaply.
3The tab bar. Four roots, the Notes→Me move, the pill/bar choreography, mePage de-registered from the back stack.The biggest UI risk in the design, and it touches every screen — so it ships alone, with the Style Guide synced in the same change.
4Add paths — profile "Add friend", personal link/QR, handle search behind findable (default off), the private remark.Needs the root to land in. The remark is what makes the list survive its 50th row.
5Block · who-can-add-me · the "My friends" privacy tier.The three things the relationship model exists to make possible.

House obligations

Never

Sources

docs/friends.html · opened 2026-08-08 · design only — nothing here is built yet; this page updates in the same change as each phase