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.
room you still see below is a real identifier quoted accurately, never a word a user reads.
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 chat. 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 chat 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 chat 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 chat; 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 ∪ chat-mates — nothing else |
| Add paths | knowing the number is the path | handle · QR · group member · name card | username · Snapcode · Quick Add | chat-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}/follow | Friend a persona — no approval. DELETE to unfriend. ⚠ v892 gave this route a UI rather than a second endpoint: 「follow」in the code and「friend」in the UI are one act. 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 | Friend — instant, no approval (v892) | 关注 (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 chats | a private chat, seating that persona | the account's messages |
| Where it's filed | the A–Z list | the same A–Z list (v723) | 公众号, own header |
| Where the rest live | — | All persona, the root's one sub-page (v892) | 公众号 › 更多 |
favourites table is retired (rows left in place; nothing reads them) and the Fav pill goes with it.
② The follow table becomes the ONLY source of the persona half. v724 read follows ∪ everyone you had met, and that was right at the time for exactly one reason: there was no friend button anywhere in the app, so the follow half was empty by construction and「met」was the only signal available. All persona is that button. So the union is retired — a contact list you cannot curate is a log, and being seated beside someone in one chat is not a decision to keep them.
users.friends_backfilled stays as an unread column (dropping it would rewrite every existing DB to save nothing), and rows the conversion already wrote are left alone: they are ordinary follows now, and unfriending them works.
_notify_build_end, where the build's owner is known for certain, and it follows-never-fails — a contact row is not worth losing a persona over. Driven end-to-end in the smoke test through the real /api/build/start route with the admin fake-build fixture.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. v892 made this literal: the seeding is the backfill above, and it writes rather than offers.
Friends holds the personas you friended; All persona holds every one you could. It is a canonical sub-page — a .drw with the shared ‹, a plain .drw-body so the column default reaches it, registered with the back stack — and its body is 「Add to panel」's body with the one thing that made that page a picker taken out: no tick, because nothing is being selected, and no footer. Same search row, same A–Z / Popularity / Recent / Studio pills (sharing the picker's sort state), same persona rows the Friends list builds.
The Studio door beside it (v1038 → v1046 → v1061). The Persona studio row moved into Friends at v1038 and took All persona's geometry at v1046 — the 40px squircle lead, one label column, one 60px height — while keeping its gold tile and white pen. v1061 (owner:「make it the similar circular icon like that of All persona, but depict a persona being built」) finishes the pairing: the door sits on the same soft ground as All persona, and its glyph is All persona's own figure in teal with the far shoulder left open and a gold pencil at the open end — gold, the colour that has always said Studio, now inside the drawing as the tool doing the making. Six glyphs were mocked (half-drawn · pencil · corner brackets · three-quarter ring · sparkles · dotted body); the two dotted ones lost their dots at row size, the pencil read at every size. Same change: on wide, the row now wears the coral current-mark while the Studio page is open on the right, exactly as the All persona door and a profile row do. Two lessons rode along: a door that sweeps on open (openDrawer, the v868 left-pane click) rewrites class on pages that are already shut, and a mutation observer that cleared the mark on「profile not open」wiped a fresh mark a microtask later — a close is an open→shut transition, not a rewrite; and the row's click is wired far from the list, so the mark is one extra listener on the static node, which travels with it between its parking slot and the list.
| Surface | What the row's TRAILING SLOT holds |
|---|---|
| Add to panel (picking) | ✓ the tick |
| All persona (browsing) | ★ if already a friend — gold, see below |
| Friends (your contacts) | nothing |
| A persona's profile (deciding) | ★ at the hero's right edge — the one pressable star (v893) |
The Studio pill is gone from both All persona and Add to panel; a friend pill takes the right edge of the sort band on each. It cycles three ways — All → ★ Friends → ☆ Others — because those are three answers to one question and only one can be true at a time. It sits right because it is a different KIND of control from the three pills beside it: those order the list, this one shortens it. It resets on every open: a filter you did not set is a filter that makes a full library look empty.
「Add time」on a persona's page is now the last friend time, not the card's created. That was when the persona was made — a fact about the persona — where a contact page's Add time has always been a fact about the reader: when this entered my contacts. A persona you have not friended has no add time and the row says「—」rather than lying with a build date. ⚠ persona_follow had to stop being INSERT OR IGNORE: it keeps the original row, so a persona friended, dropped and friended again would report its first add time forever, and「last friend time」would quietly mean「first」. It upserts the timestamp now, and a smoke-test check holds it.
PFRIEND, in the main scope — and every surface reads it: the badge, the pill, the row menu, the profile toggle. The one write path is setPersonaFriend, which mutates the set synchronously before the request leaves (so the star goes in the same frame — owner: the star should be immediately removed) and rolls back if the server refuses. Optimism is safe here precisely because a persona always says yes: the only real failure is the network. ⚠ Only the unqueried read feeds the set — adopting a search response would announce that every persona which failed to match「darwin」is no longer a friend, which is the same class of bug one layer down.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, then ONE roster — personas and people filed together, A–Z, with an index rail (v704: full height · the whole alphabet always · drag to scrub). v723 (owner): personas are no longer a block of their own. They are the same object here — someone you hold, whose page you open, whom you can write to — and a「Personas · N」heading made them a category you look up rather than a name you scan for, filing them all under P in effect whatever they are called. Now Ada Lovelace sits between Ada and Alice. The ROW still says which kind it is (a persona wears its card colour, a person wears their face); the chevron went with the split, since a person's row never had one and opens a page just the same. The Friends search follows: one roster by name, not personas-then-people. v724 — and the persona half now has a SOURCE. It read the follow table alone, and nothing in the app has ever written to that table: there is no follow button, so every user's persona half was empty by construction (owner:「I don't see any persona in enovy's friend's list」— enovy followed none, because nobody can). A persona cannot accept a request, so the human rule has no analogue; what does have one is HAVING TALKED. A persona seated in a chat of yours is someone you have met, which is what a contact list is for. Explicit follows are still unioned in, so a follow door stays meaningful if one is built. ⚠ discoverable() still rules — a private persona someone else seated beside you keeps working in that room and stays out of this list: seating survives, discovery does not. v892/v893 (owner) — the layout is now: a frozen SEARCH ROW · the KIND pills (All | People | Persona) · the「All persona」door · the A–Z roster. Fav went because favourites are cancelled and Studio moved to All persona, which is where an un-friended persona can now be found at all; People and Persona came back at v893 as three positions of one question rather than four independent switches — and「All」is a position, which is the difference from v847, where clearing a filter meant tapping the lit pill again and a filter you clear by re-pressing is one you can leave on without noticing. ⚠ The door's lead is the only one in the list that is not a disc: every other lead stands for exactly one contact, this one stands for the whole shelf, so it is a squircle carrying two figures in two colours — a persona wears ONE card colour, so a single-colour figure would read as one more persona. The follow door v724 said「stays meaningful if one is built」was built, and building it retired the union — see §6. |
| 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. |
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 left icon rail: we are not building a desktop app, we are building a phone app that survives a big screen.
Today our wide layout has one right pane and it is always the chat: the left pane's tabs swap the list, the conversation on the right stays put. WeChat's fold layout does something different, and all three reference screenshots show it — tapping a root replaces the right pane too. Contacts puts a person's profile there. Discover puts the section you picked. Me puts Favourites.
| Root | Left pane — the list | Right pane — the detail |
|---|---|---|
| Chats | the chat list | the open conversation (today's arrangement, unchanged) |
| Friends | requests · personas · people A–Z | the selected profile — hero, details, shared chats, Message |
| Discover | the four rows: Vibes · Personas · Studio · Scan | whichever row is selected, full height |
| Me | your hero + the settings rows | Notes · Notifications · Preferences — whichever is open |
The left pane keeps its resizer. The existing drag handle and double-click-to-reset stay; the fold's near-even split is simply where the default lands when the window is that wide. Nothing about this needs a fold to be worth having — it is the same layout on a laptop, just with a narrower left column.
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.
Three panes-of-two, drawn against the owner's Galaxy Z Fold reference. The constants across all three: the tab bar sits under the left pane only, the right pane carries its own back-chevron header, and each root brings its own right pane.
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 ∪ chat-mates, minus anyone blocked either way. The door in fig 1, closed. |
| POST /api/dm | shares_room | friends ∪ chat-mates, minus blocked. Chat-mates keep the door they have — WeChat's group temp-chat is the proven precedent and it is already shipped behaviour. |
| GET /api/contacts | chat-mates | friends first, then chat-mates — same payload shape, so the people picker needs no change. |
| GET /api/user/{uid}/profile | chat-mates only | friends ∪ chat-mates. A non-friend chat-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 chat" 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, persona_follows, may_reach(), and invitable narrowed. No UI. | Shipped 2026-08-09. Fig 1's door is shut — and it took TWO changes, not one: /invitable now serves the caller's own reach, and POST …/members enforces the same predicate, because that route took any username and would have made the narrowed picker cosmetic. 26 smoke-test checks, the load-bearing ones being refusals. |
| 2 ✓ | The Friends page — requests carrying their note and context, accept/ignore, the list, personas-as-follows, the rail notice. A page off the Me sheet, as planned. | Shipped 2026-08-09. One door came forward from phase 4: Add friend on a chat-mate's profile — phase 2 is untestable without some way to create a request, and it is the one add-path that needed no new machinery. The link, the code, handle-search and the note composer stay in phase 4, so a request sent from a profile today carries its context but no message. |
| 3 ✓ | The tab bar. Four roots, Notes→Me, Vibes+Studio→Discover, the pill and the ≡ deleted, mePage de-registered. | Shipped 2026-08-09, Style Guide synced in the same change. §7b (the per-root right pane) is NOT in it — see below. |
| 4 ✓ minus scan | Add paths — the personal link (rotatable), handle search behind findable (default on since v680), the request composer with its note, and the private remark. | Shipped 2026-08-09. The two doors differ on purpose: a link is something you handed out, so holding it IS the permission; handle search is something a stranger can guess, so it is the one path behind a switch — handle-only (a display name would let you trawl for people by first name), two characters or more, and blocked accounts subtracted. Two of those numbers were wrong on the day and were corrected at v680–v681: the switch defaulted off, so nobody was ever in the index and every search returned nothing — over-cautious, and a mis-copy of WeChat, where the ID switch is ON and it is the phone number people close (a phone number is enumerable in a way a handle is not). It defaults on now, with a one-shot backfill for accounts that predate the flip — one-shot, not a boot-time update, or it would silently re-open the switch for anyone who closed it deliberately. ⚠ And「defaults on」was only half-true until v899 (user-caught: a brand-new account could not be found by its own -ish ID). v680 wrote the flip into the CREATE TABLE, but a column default only ever applies to the table it was declared on — and no database that already existed got its column from there. They got it from the ADD-COLUMN migration, whose decl still said DEFAULT 0. So the switch was ON by default on a database created from scratch after v680, and OFF on every real one, the box included. The backfill is what hid it: it flipped everyone alive at that moment to 1 and marked itself done, so the breakage starts at the next account and nowhere else — and the smoke test, which runs on a fresh database, asserted「ON by default」and was green throughout. The birth path names the column now (one constant, FINDABLE_AT_BIRTH), so no table default decides it either way, and a lint refuses any ADD-COLUMN default that contradicts the CREATE TABLE it shadows. Accounts already born off are not mass-flipped — that is the very re-opening the one-shot rule exists to prevent — so they stay as they are until their owner turns the switch on. And the floor was three characters, which hid real handles: @CW is two. Scan is still not built: no QR reader exists in the repo, and the link makes it optional. |
| 5 ✓ | Block (with an unblock list) · who can add me to a chat · the "My friends" profile tier. | Shipped 2026-08-09. The add-policy belongs to the person being added, never the person adding. Below the profile tier a viewer gets the thin card — a name, a handle, a face, and none of the about line, presence or shared chats: not an error, and not an empty page. An unknown policy string reads as open, so a stale client can never silently narrow someone's settings. |
| 6 ✓ v892 | The friend/unfriend redesign (owner) — favourites cancelled · the frozen search row · the All persona sub-page · the follow table becomes the persona half's only source · Unfriend in every row's held menu. | Shipped 2026-08-16. The change that makes the rest cohere is the smallest-looking one: ONE axis. With favourites gone,「friend」is the only thing a contact list records, and everything else follows — the four filter pills lose their reasons, the un-friended personas need somewhere to live (All persona), and unfriending finally does something, because the「met」union that used to undo it is retired. See §6 for the migration, §6b for the page and the ★. |
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-chat-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