Appearance
ADR-0032 · Chat and platform identity
Status: 🟢 Accepted · Decided: 2026-09-05 (product owner) · Supersedes: nothing · Amends: the launch decision D-v in the consumer plan (in-app person-to-person messaging is now a modelled destination rather than permanently a WhatsApp redirect) · Constrains: ADR-0028's URL namespace, because every account now consumes an address.
The decision, in one sentence
You are reachable because you gave someone a way to reach you — not because they know your number.
The locked model
| Layer | What it is | Value | Who can see it |
|---|---|---|---|
| Principal | users.id · workspaces.id | opaque uuid | nobody |
| Credential | phone via WhatsApp OTP, Google | proves you are the principal | you |
| Address | the slug, auto-assigned to every account | qrsetu.com/anjali-yoga | whoever you give it to |
| Discovery | businesses indexed; people never | name · category · area | everyone, businesses only |
| Reach | capability — QR, link, scan, or a write-only invite | — | — |
| Attention | a first message to a person is a request | — | — |
| Roster | your own contacts | name-searchable | you alone |
| Conversation | between principals | — | its participants |
Context
Chat was specified but never built: no Edge Function writes conversations or messages (QRS-1015), so the identity model was still free to choose. The owner asked for a decision durable for 5–10 years across consumers, merchants, business-to-business and personas not yet designed, with security and privacy non-negotiable, adoption friction minimised, and complexity avoided.
Three models were debated: mobile number as identity, a minted numeric QR Setu ID, and slug-based identity, plus hybrids.
Decision
D1 · The phone number is a credential. It is never an address, a key, or a column in public.users
Measured: public.users today holds display_name · avatar_media_id · locale · timezone · primary_context · status and no phone column at all. That accidental property is now a rule.
Why, and the reasons compound:
- It contradicts the product's own promise.
phoneNois aprivate-tier biodata field that no share can ever release, enforced twice inget_public_biodataand measured by mutation. Chat handing the same number out through a different door would make that enforcement theatre. - ⚠ It defeats blocking. Under phone-as-address a blocked sender buys another SIM and is back. Under capability reach, blocking is defeated only by re-acquiring the capability, which requires the victim's cooperation. One model makes blocking permanent by construction; the other makes it a speed bump.
- It would print a phone number on a sticker. The growth mechanic is a QR code on a card. If the address is a number, the QR encodes a number.
- It cannot express a business. A number identifies a person; a vendor is a workspace several staff answer for. This is exactly why WhatsApp needed a separate Business product.
- Number recycling is an India-specific hazard. Under phone-identity the new holder inherits the social graph and can impersonate. (⚠ They could still OTP into the account — a credential problem this ADR does not solve; see Risks.)
- ⚠ And it would be factually unreliable today.
signInWithOtpcreates the account when the code is sent, not verified (QRS-998), so a phone directory would confidently report accounts that are abandoned OTP shells.
D2 · The slug is the address, and every account gets one at creation
Measured: handle_new_user contains zero references to slug, and on Dev 2 of 10 users hold an address. A person with no address cannot be reached by any capability, so consumer-to-consumer would be broken before it started.
Auto-assign at account creation; one free change to a chosen name. The address is universal, permanent, distinguishes person from business (slugs.owner_kind), releases cleanly when an account ends, and is already what every QR encodes and every card prints.
⚠ This is a backend guarantee, not a screen change. The design's Home still shows claiming; it becomes choose a better address rather than get one. The copy reconciliation is flagged, not invented.
⚠ It consumes the root namespace, which ADR-0028 makes flat and finite. Person addresses are minted in a shape that cannot collide with a desirable business name.
D3 · Businesses are discoverable. People are not
A merchant wants to be found — that is the marketplace. A consumer does not, and it is a standing owner constraint that consumer data is never indexed. Two functions already enforce this and must not be weakened: resolve_slug_status answers reserved for a person's address so that "the platform holds this word" and "a person exists at this name" are indistinguishable, and resolve_slug_owner_kind collapses person, reserved, released and never-claimed into one bucket.
D4 · Reach is by capability, with a write-only invite
QR, link, scan — or type an address or a phone number to invite. The answer never varies:
Type a number or an address → an invitation is sent → you are told "invitation sent", always, whether or not an account exists.
Nothing is learned, so there is no oracle. If the recipient is on QR Setu it arrives in-app; if not, as an SMS or WhatsApp invite. This gives the phone's familiarity without the phone's exposure — the user types exactly what they expect to type.
D5 · A first message to a person is a request; a first message to a business is not
⚠ Capability does not imply trust. The biodata basic link is forwardable by design, so a stranger can legitimately hold one; the same is true of a screenshotted card. Reachability gating alone would let anyone down that chain open a thread with a family.
The asymmetry falls straight out of D3: a card exists to be messaged, so making a customer's first message a request the vendor must accept would break the money loop.
⚠ Measured: no request, pending or accepted state exists anywhere in the chat schema.conversation_states carries pin, favourite, archive, mute and manual-unread, and nothing else.
D6 · A conversation is between principals; a principal is a user or a workspace
Measured: conversations.workspace_id and consumer_user_id are both NOT NULL, so consumer↔consumer and business↔business are unrepresentable, and only one of the four communication flows has a model.
Assessment and migration approach: chat: a conversation is between PRINCIPALS.
D7 · The roster is the answer to "search"
⚠ WhatsApp has no directory. What feels searchable there is your own device address book, which you built yourself. QR Setu's equivalent is your own contacts — the Party primitive named in the platform model and never built. That, not identity, is the actual gap behind "how does a yoga trainer add their students".
Searching your own contacts by name is searching your own data: no oracle, no privacy question, and no architectural decision.
D8 · No numeric alias
Rejected. A minted number would be a third public identifier beside the uuid and the slug — a second thing to keep unique, print, display, support and explain.
The motivation was sound: a Latin-ASCII slug is awkward to say aloud in Marathi. But reading an address aloud is a merchant marketing need (a hoarding, a shop sign), not a consumer identity need — people show a screen, send a link, or scan. If it ever ships it is a business vanity alias, never a second identity for people.
⚠ Measured cost of the deferral: slugs_one_active_per_user is a UNIQUE index on user_id, so a second address per owner needs a kind column. One migration. Cheap, but not free.
Consequences
| Area | Consequence |
|---|---|
| Chat initiation | C→M scan or tap the card · M→C reply in thread, or a roster contact, or an invite · C→C link, QR or invite, arriving as a request · B→B either public address |
| Number exposure | Zero by default. A number is disclosed only when its owner acts, exactly as biodata release already works |
| Blocking | Per-relationship, and permanent by construction |
| Spam | An unsolicited first message needs a capability and survives a request gate |
| Number change | ⚠ The largest durability win. Under phone-identity, changing your number is a migration event visible to everyone. Here it is a settings change: no thread breaks, no card reprints, nothing anyone holds goes stale |
| Multiple devices | Identity is the uuid, sessions are per-device. WhatsApp took years over this precisely because the phone was the identity |
| Account recovery | Phone plus a second credential, so a lost number is not a lost account |
| Deep links / QR | Unchanged — the slug is already what a code encodes |
| APIs | A stable uuid plus a stable handle. A phone as the user key would put PII in every request path, log and webhook payload |
| Search scalability | A bounded business index; people are not indexed at all |
| Authorization | Unchanged and relationship-scoped. ⚠ A phone-based model would have tempted policies written against a mutable identifier |
| Data model | The phone must never become a foreign key |
Alternatives rejected
Mobile number as identity or address — see D1. Its one real merit is familiarity, and D4 recovers most of that without the exposure.
A minted numeric QR Setu ID — see D8.
Instagram's model (globally searchable people, gated only at the inbox) — it gates attention, not reachability, which is why request folders fill with spam: sending is free and only reading is gated. Being findable is Instagram's product; it is not ours. The request folder itself is adopted (D5); the global people search is not.
Risks and trade-offs, accepted knowingly
- ⚠ No "who is already on QR Setu". A real affordance, deliberately given up. It is recoverable later through private set intersection; removing phone-as-address after a million conversations and printed cards is not. The asymmetry decided this: discovery can be added, exposure cannot be taken back.
- ⚠ Number recycling can still take an account. This ADR removes the identity consequence, not the credential one. A dormancy re-verification rule is owed and is tracked separately.
- Auto-assigned addresses consume a finite root namespace (ADR-0028).
- Contacts is new product surface, not plumbing — and it is what makes the roster case work.
- If QR Setu ever wants to be a general social messenger, address-book import is the proven cold-start mechanism and this model forecloses it. That is a different product and should be chosen deliberately, not drifted into.
Schema placement
⚠ Chat stays in public. It does not get a domain schema, and that is ADR-0031 applied rather than overridden. That ADR's decidable line is "public holds what MORE THAN ONE product surface depends on", and its migration names this exact case: "chat is the same trap from the other side, since conversations carries BOTH workspace_id and consumer_user_id, so it is shared and stays in public."
The locked model strengthens that reasoning rather than weakening it. A principal-based conversation spans consumers, merchants and future business-to-business — it is maximally shared, which is the definition of a public object. ADR-0031's open-context vocabulary is also closed to biodata and meetings, amendable only by amending that ADR, and nothing here justifies doing so.
Naming follows the existing convention: shared core entities keep short names in public (conversations, messages, conversation_participants), and no chat_ prefix is added, because a prefix that repeats what the table already says is the noise QRS-436 exists to prevent.
Status of the model
Locked. Any change to D1–D8 is an amendment to this ADR, not a design decision inside a feature.