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.
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:
Sources at the foot of the page. The mechanics that matter, in the order a relationship passes through them.
| Mechanism | WeChat's name | What 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 axis is where the friction sits: before reach (WeChat), after reach (WhatsApp), or nowhere at all but reach is gated on reciprocation (Snapchat).
| Dimension | Snapchat | Ours — proposed | ||
|---|---|---|---|---|
| The pool | every phone number on earth, open | closed; per-path findability switches | closed-ish; username + Snapcode | invite-gated accounts (unchanged) |
| Becoming friends | address-book sync — no ceremony, one-way is fine | request + note → accept; symmetric | one-way add; "Added Me" list; no decline button | request + note → accept, on the notifications rail |
| Cold DM | allowed; block is downstream | forbidden until accepted | gated on reciprocation | friends ∪ room-mates — nothing else |
| Add paths | knowing the number is the path | handle · QR · group member · name card | username · Snapcode · Quick Add | room-mate's profile · personal link/QR · handle search behind a findable toggle |
| Group ≠ friends | n/a | law | n/a | kept as law |
| Private rename | from your address book | 备注 — first-class | display-name override | yes — a remark per friend, phase 4 |
| Tiered friend | no (privacy tiers instead) | 仅聊天 | no | not v1 — recorded, see §4 |
| Removing | delete from address book; silent | unilateral, silent, the bounce | remove; silent | unilateral, silent, no bounce |
| Declining | n/a | Ignore — sender never told | no such button by design | Ignore — sender never told |
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:
| Option | What you get | What 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. |
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.
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.
The predicate is one line, and it is the only thing the rest of the app ever asks:
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.| Route | Does |
|---|---|
| GET /api/friends | The 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/accept | One tap. Writes my cell to ok, stamps since, notifies the asker only. |
| POST /api/friends/ignore | Writes 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}/block | Writes my cell to blocked. Silent. Drops the DM key in both directions and denies future asks. |
| POST /api/personas/{slug}/add | Follow — 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= 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.friend:<uid>
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 friend | A persona | WeChat's precedent | |
|---|---|---|---|
| The verb | Add friend → request | Add — instant, no approval | 关注 (follow) |
| Symmetry | symmetric while live | one-way, always | one-way |
| Removing | unilateral, silent | unilateral, silent — there is no one to hurt | 取消关注 |
| Reach it grants | DM beyond rooms | a private chat, seating that persona | the account's messages |
| Where it's filed | the A–Z list | its own section above the A–Z list | 公众号, own header |
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.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.
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 | Tomorrow | WeChat's own slot | Why |
|---|---|---|---|
| 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 picker | Discover › 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 4 | 我 | Promoted out of the hamburger. Hero · Notes · Notifications · Preferences · Admin. |
| ≡ hamburger | deleted | — | Its only job was opening Me. The notification dot moves to the Me tab. |
--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.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.@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.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.
375px. House palette, house type. The bar is the same four buttons on every screen.
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.
| Surface | Today | After |
|---|---|---|
| GET /api/rooms/{id}/invitable | the whole user table | friends ∪ room-mates, minus anyone blocked either way. The door in fig 1, closed. |
| POST /api/dm | shares_room | friends ∪ 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/contacts | room-mates | friends first, then room-mates — same payload shape, so the people picker needs no change. |
| GET /api/user/{uid}/profile | room-mates only | friends ∪ 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}/members | any username | must 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 predicate | demoted to one input of may_reach(). It stays; it stops being the whole answer. |
| block | doesn't exist | buildable — one cell value. This closes the debt the v515 duo-exit note recorded: "real prevention waits for block." |
| notices | — | a friend_request kind with an Accept verb, ckey per requester; the rail's third mirror gives it the lock screen. |
| Phase | What | Why here |
|---|---|---|
| 1 | The 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. |
| 2 | The 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. |
| 3 | The 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. |
| 4 | Add 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. |
| 5 | Block · who-can-add-me · the "My friends" privacy tier. | The three things the relationship model exists to make possible. |
t()/tn(), and python lib/i18n_audit.py runs before commit — the smoke test gates missing keys and the static ratchet.docs/style-guide.html in the same change — a new bottom bar is a component, and the Style Guide is the canonical UI reference.python lib/smoketest.py: a test that a non-friend, non-room-mate cannot appear in invitable, open a DM, or read a profile.lib/db.py:502 (users_not_in_room) · lib/db.py:609 (shares_room) · lib/db.py:629 (contacts) · lib/run_room.py:32195 (/invitable) · gap 13