# Accounts, profiles and badges Status: FROZEN contract for the workstreams that implement it. Where this document and an implementation disagree, this document wins, or the document is corrected in the same change. ## Why this is delicate Veiled has no accounts today. Your identity is a keypair on your device, and the server holds nothing that identifies you. Email recovery, profiles and badges each ask the server to hold something about a person, so every decision here is about what the server learns, what it is allowed to read, and what we tell the user about both. Four rules govern everything below: 1. **Opt in, never default on.** A server that does not configure email collects no addresses and its clients say so. A user is never asked for an address to join or to chat. 2. **The server stores what it must and no more.** It stores an address only when the host has enabled email, because sending mail to an address needs the address. Everything that can be encrypted on the client is encrypted on the client. 3. **Nothing is claimed that is not true.** If recovery cannot restore message history, the interface says exactly that before the user relies on it. No security theatre. 4. **Badges are server-issued facts**, not decoration: a badge exists because the server observed the thing, and it carries the date it was issued. ## 1. Email, per server, off by default `veiled.toml`: ```toml [email] enabled = false # off unless the host turns it on mode = "recovery" # "verify-only" stores a hash and cannot recover; "recovery" stores the address smtp_url = "" # smtp://user:pass@host:587, or empty to refuse email features from = "veiled@example.com" verify_ttl_s = 86400 # 24 hours recovery_ttl_s = 900 # 15 minutes link_base = "" # absolute base for the links in mail, e.g. https://veiled.example.com ``` `link_base` was added while implementing this document, because the link in a mail has to be absolute and the server otherwise has nothing to build it from: `EMAIL_SET` arrives over the WebSocket and a recovery request over HTTP. When `link_base` is empty the mail link falls back to the `Host` header of the connection that asked, and a host should therefore set it: a value a client supplies should not decide what address appears in a mail. When neither is available, the server refuses with a plain reason instead of sending a link that does not work. The mail path speaks the smallest useful subset of SMTP: `EHLO`, opportunistic `STARTTLS` (only with a system certificate bundle to check the relay against, never downgraded silently), `AUTH PLAIN` when the URL carries credentials, and plain SMTP to a local relay. It never uses a third party mail service and never depends on a vendor account. Trust anchors are read from the usual system bundle, and a host that wants a specific one can point `VEILED_SMTP_TLS_ROOTS` at it; when that is set it is the only source, so "trust exactly this" is obeyed. A relay that offers STARTTLS and cannot be verified is refused rather than sent to in the clear, and the refusal reaches the client as `email_disabled` with the relay's own words. - `mode = "verify-only"`: the server keeps `sha256(address || server_id)` and a domain hint, so it can issue the `email_verified` badge and can never email the person later. Recovery is unavailable and the interface says so. - `mode = "recovery"`: the server keeps the address, which is what makes recovery possible. The client warns, in plain words, that the host of this server will be able to see the address. - Email features refuse to run with no `smtp_url`, and the client reports "this server does not send email" rather than a dead button. The same applies to an enabled section with no usable `from` address, and the reason published says which piece is missing. - The `hint` field of `EMAIL_STATE` follows the mode, because the two modes keep different things: in `recovery` mode it is the address with its middle masked (`s******t@example.com`), and in `verify-only` mode it is the domain alone (`example.com`), which is exactly what that mode stores. One address binds to one user id. Changing it requires a fresh verification. Verified state is per user, not per device. Verifying again with a new link does not issue a second `email_verified` badge: the badge table's unique index on `(user_id, badge_id)` makes a second issue a no-op. ## 2. Recovery, and its honest limit Flow, all over HTTP because the link arrives in a mail client: 1. `POST /api/recovery/request` `{"address": "..."}`. Always answers identically whether or not the address is known, so the endpoint cannot be used to test who has an account here. 2. If it is known, mail a link with a single-use token. The server stores `sha256(token)`, the user id, an expiry (`recovery_ttl_s`) and a used flag. Tokens are never stored in the clear. 3. The link opens `/recover` on that server, which asks for a passphrase if the user has an identity backup, then `POST /api/recovery/redeem` `{"token", "ed25519_pub", "x25519_pub", "proof"}` where `proof` is the same construction as `IDENT_SET` field 4 bound to a fresh server nonce, so a captured token cannot be replayed onto another connection or another key. 4. On success the server rebinds that user id to the new keys, keeps the display name, role and badges, and records an audit row. Two things this flow had to pin down, because a frozen body shape cannot carry them and stateless HTTP has no `WELCOME` to draw a challenge from: - **The challenge.** `IDENT_SET` field 4 is bound to the `WELCOME` nonce, and the redeem body is exactly `{token, ed25519_pub, x25519_pub, proof}` with no nonce field, so the challenge has to be derivable by both sides from what they hold: `challenge = BLAKE2b-256("veiled-recovery-challenge-v1" || server_id(32) || sha256(token))`, and `proof = Ed25519(ed25519_sk, "veiled-ident-v1" || server_id || challenge || x25519_pub || ed25519_pub)`. It is fresh per token and the token is single use, so a proof cannot be replayed on another connection, and both new public keys are in the signed message, so a captured proof cannot be pointed at another key pair. Holding the token is what allows a redeem at all: the proof shows possession of the new private keys, it does not keep a thief out. The proof is checked *before* the token is spent, so an honest mistake with the keys does not cost the one link the user has. - **Which keys move.** The rebind replaces `ed25519` and `x25519` on the user row and touches nothing else: the id, the display name, the role, the badges and the verified address stay. The old Ed25519 key is no longer this account, so a device still holding it is refused and, if it registers again, it does so as a new user. Sessions that were authenticated with the old key are told plainly and closed, because they can no longer prove who they are. A pending, not yet verified, address can still be recovered by whoever controls that address, which is the same thing verification proves; the mail itself is the proof. **What recovery does not do, and the client must say before the user relies on it:** message history stays unreadable unless the user stored an identity backup. History and channel keys are sealed to the old device key, and there is no escrow, because escrow is the thing that would let the host read everything. Recovery restores who you are, not what you said. ## 3. Identity backup, so recovery can actually restore history Optional, client-encrypted, off by default: - `IDENT_BACKUP_PUT` uploads one blob: `salt(16) || nonce(24) || XChaCha20-Poly1305(key, plaintext)` where `key = Argon2id(passphrase, salt, m=64MiB, t=3, p=1, out=32)`. Plaintext is the identity file, the same bytes `--export` already writes. - Cap: 64 KiB. The server stores it as an opaque blob, cannot read it, and never sees the passphrase. It is fetchable by a session that owns that user id (`IDENT_BACKUP_GET`), or by a valid recovery token before it is redeemed, which is what the new device does after the user clicks the link: `POST /api/recovery/backup {"token"}` answers `{"ok":true,"blob":""}` or `{"ok":false,...}` with a plain reason. Reading the backup does **not** spend the token; only the redeem does. This route is an addition made while implementing this document: the document says the blob is fetchable by an unredeemed recovery token, and HTTP is the only path a new device has to it before it has an identity. - Deleting it is a first-class action, and deleting it means recovery can no longer restore history. The interface says so. `IDENT_BACKUP_DELETE` (opcode 213, added while implementing) removes it and is answered with `IDENT_BACKUP` with field 1 absent; `IDENT_BACKUP_GET` answers the same way when there is nothing stored, so a client can tell "deleted" from "never there" by the audit log on the server side and by its own action on the client side. ## 4. Profiles Per user, server-held, visible to members of that server: | Field | Rules | | --- | --- | | `display_name` | 1 to 32 characters, may not be empty, trimmed, unique is NOT required | | `status_id` | `online`, `idle`, `dnd`, `invisible`, `offline`. `invisible` is presented to others as `offline` | | `status_text` | 0 to 64 characters, the custom line, free text, rendered as text never as markup | | `about` | 0 to 280 characters, plain text, newlines collapsed | | `avatar` | one image, see below, or none | Presence is real: `status_id` is the user's chosen state and is distinct from the transport being connected. A user who disconnects shows `offline` regardless of choice. How the field rules are enforced, and what the server answers: - Every rule above is enforced **on the server**, because a rule only a client enforces is not a rule. A refused field refuses the whole `PROFILE_SET` with error code 8 and a plain `detail` naming the field and the limit; nothing is truncated and nothing is stored. This is the case the document calls out: a 300 character `about` is refused, and the previously stored `about` is left exactly as it was. - `about` and `status_text` are collapsed before they are measured and stored: every run of whitespace becomes one space and the ends are trimmed, so `"line one\nline two"` is stored and served as `"line one line two"`. - An absent field in `PROFILE_SET` keeps its stored value, so a client that only wants to change one thing sends one thing. - `display_name` is **one value on the server**, shared with `IDENT_SET` field 1: the user row is where the roster and every profile read it from, and `PROFILE_SET` writes the same place. The most recent write is what members see, which is why a client that reconnects sends the name it holds. If the two ever disagree, that is a client bug, not a server one. - The server answers a `PROFILE_SET` with `PROFILE` carrying the caller's own entry, so the client can see what was stored rather than assume what it sent. A name change is also announced to the other members as `USER_EVENT` op 7. - `AVATAR_PUT` has no id field, so the **server** generates the avatar id and returns it in the profile. A client chosen id would let one client claim another's avatar. The mime must be an image type, and the blob is capped at the documented 32 KiB of WebP plus the 24 byte nonce and 16 byte tag; anything larger is refused rather than stored. - `PROFILE_GET` with an empty list means everyone connected. It answers with one entry per user asked for, including users who are not connected, because a profile is stored and presence is separate. A user id that does not exist is simply not in the answer. ### Avatars, encrypted to members Images are encrypted so the server holds bytes it cannot render: 1. The client resizes and re-encodes to WebP, at most 128x128, at most 32 KiB after encoding. 2. It generates a random 32-byte image key and encrypts with XChaCha20-Poly1305. 3. It wraps that image key to every member of the server it knows, using the same `crypto_box_seal` construction already used for channel keys, and uploads the wrapped entries with the blob. 4. A member unwraps with their own X25519 key and decrypts locally. A user who joins later receives the wraps through the same relay path as channel keys (`KEY_REQ`). The relay path, spelled out, because "the same relay path" needed a concrete shape: `AVATAR_GET` answers with `AVATAR` carrying the ciphertext and, when the server holds one for that caller, the caller's own wrap. When it holds no wrap for the caller and the caller is not the owner, the server relays the request to the other connected members as `AVATAR_GET` with additive field 2 (`from`) set to the requester's user id, exactly the way `KEY_REQ` field 3 is relayed. A holder answers with `AVATAR_WRAP_PUT` (opcode 212, added while implementing this document; the table above had no opcode for it) carrying `1 user_id` of the avatar's owner and `2 wraps`. Only a session that holds a wrap for that avatar may add wraps for anyone, so a stranger cannot advertise an image key it does not have, and the server delivers `AVATAR` with the new wrap to each user named. The request is not echoed back to the requester, and `error 10` (`no_key`) is sent only when there is nobody connected to ask, which mirrors `KEY_REQ` exactly. A user with no avatar gets the deterministic initials tile the client already draws, so nothing looks broken and no default image is fetched from anywhere. ## 5. Badges Server-issued, with an issue date, visible to members. Built-in ids: | id | label | issued when | | --- | --- | --- | | `email_verified` | Email verified | the address for that user verifies | | `og` | OG | the user id is <= `og_limit` (default 100) at first join | | `admin` | Admin | the user holds role admin or owner | | `host` | Host | the user is the owner of this server | - `og_limit` is `[badges] og_limit = 100` and a host can change it, including to 0 to stop issuing. - A badge is not removable by the user; a user may **hide** one from their profile (`hidden_badges` in the profile), and hidden badges are still issued and still counted. - Badges are per server. There is no global account, so there is no portable badge, and the interface must not imply one. When each badge is issued, in the implementation's terms: - `og` is decided when a user first authenticates, which is their first join as far as the server is concerned, against the `og_limit` in force at that moment. Issuing is idempotent, so a second connection changes nothing, and a database that predates this feature issues `og` on the next connect of each user within the limit rather than waiting for a re-registration. A limit of 0 issues none. - `admin` and `host` follow the role: issued at the same first authentication and again when a role is granted, so a user promoted to admin is marked then rather than at the next connect. - Nothing ever unissues a badge, including a demotion. A badge records that this server observed the thing and the date it observed it; that a user was an admin for a while is a fact about this server's past, not a claim about their current role. Hiding one changes nothing at all: it is a profile preference, and the badge is still issued, still served in `PROFILE`, and still counted. - `BADGE_LIST` is pushed to a session right after `AUTH_OK`, and each `PROFILE` entry carries that user's badges, so a client can label a profile without asking per user. Badges are per server and the labels say so: `host` reads "this user is the owner of this server". ## 6. Wire, all additive, `PROTO_VERSION` stays 1 Unknown opcodes are ignored by clients that do not implement them, which is what makes this additive. Every frame is a CBOR map whose key 0 is the opcode. | Opcode | Direction | Fields (key 1+) | | --- | --- | --- | | 200 `PROFILE_SET` | client to server | 1 display_name, 2 status_id, 3 status_text, 4 about, 5 avatar_id, 6 hidden_badges (array of text) | | 201 `PROFILE_GET` | client to server | 1 user_ids (array of uint), empty means everyone connected | | 202 `PROFILE` | server to client | 1 profiles (array of maps: id, display_name, status_id, status_text, about, avatar_id, badges, hidden_badges) | | 203 `AVATAR_PUT` | client to server | 1 mime, 2 blob, 3 wraps (array of maps: to, wrapped) | | 204 `AVATAR_GET` | client to server | 1 user_id | | 205 `AVATAR` | server to client | 1 user_id, 2 mime, 3 blob, 4 wrapped | | 206 `EMAIL_SET` | client to server | 1 address | | 207 `EMAIL_STATE` | server to client | 1 state (`unset`, `pending`, `verified`, `disabled`), 2 hint (masked address or empty) | | 208 `IDENT_BACKUP_PUT` | client to server | 1 blob | | 209 `IDENT_BACKUP_GET` | client to server | | | 210 `IDENT_BACKUP` | server to client | 1 blob, or absent when none is stored | | 211 `BADGE_LIST` | server to client | 1 badges (array of maps: id, label, description, issued_at, user_id) | | 212 `AVATAR_WRAP_PUT` | client to server | 1 user_id (the avatar's owner), 2 wraps (array of maps: to, wrapped) | | 213 `IDENT_BACKUP_DELETE` | client to server | (no fields) | Additions to the rows above, all additive fields or answers on existing opcodes, `PROTO_VERSION` stays 1 because an unknown opcode and an unknown field are both ignored: - `PROFILE_SET`: an absent field keeps its stored value. The answer is `PROFILE` (202) carrying the caller's own entry, and a name change is announced to others as `USER_EVENT` op 7. - `AVATAR_GET`: field 2 `from` (uint) is server to client only, and is present on a relayed request exactly like `KEY_REQ` field 3. - `EMAIL_STATE`: field 3 `detail` (text) is additive and carries the plain reason a state is `disabled`. The document says an unavailable feature must say why, and the state enum alone cannot. - `BADGE_LIST` is pushed after `AUTH_OK`, not only on request: there is no request opcode for it in the table above, so the server sends it when a session can use it. - `AVATAR_WRAP_PUT` (212) and `IDENT_BACKUP_DELETE` (213) were added while implementing: the document requires a relay path for late members and a first class delete, and the frozen table had no opcode for either. HTTP, all on the control port: | Route | Method | Body | Answer | | --- | --- | --- | --- | | `/api/recovery/request` | POST | `{"address"}` | always `{"ok":true}` | | `/api/recovery/redeem` | POST | `{"token","ed25519_pub","x25519_pub","proof"}` | `{"ok":true,"user_id"}` or an error code | | `/api/recovery/backup` | POST | `{"token"}` | `{"ok":true,"blob":""}` or an error code | | `/api/email/verify` | POST | `{"token"}` | `{"ok":true}` or an error code | | `/api/email/state` | GET | | `{"state","hint"}` for the caller's session | | `/verify` | GET | `?token=` | the page the mail link opens | | `/recover` | GET | `?token=` | the page the recovery link opens | There is deliberately **no challenge route**. The challenge in section 2 is derived by both sides from the server id and the token, so a client never asks a server for a nonce and never has to trust one it is handed. An implementation that added `POST /api/recovery/challenge` looked correct on both sides and broke recovery against every real server, because the server derives instead of issuing. If you are adding a recovery client, derive; do not add the route. Bytes in these bodies are **base64**, except the server id: `POST /api/recovery/backup` answers `blob` as base64, and `POST /api/recovery/redeem` takes `ed25519_pub`, `x25519_pub` and `proof` as base64. The server id in `GET /api/info` and in `WELCOME` is **hex**, and it is the same value in both, so a page can read it from either and compare. The two new rows are additions made while implementing this document. `/api/recovery/backup` is the path section 3 requires (a valid token can fetch the blob before it is redeemed), and `/api/info` gains the fields below so a client can say why a feature is not there. `/api/email/state` answers for "the caller's session", and HTTP has no session of its own: the caller identifies itself with the per session media token it was handed in `AUTH_OK` field 3, as `?token=` or an `Authorization: Bearer` header. Without one the answer is `auth_required` with a plain detail rather than a guess. A wire client uses `EMAIL_STATE` over the socket, which is session bound already. `/api/info` gains, all additive: ```json {"email_enabled": false, "email_mode": "recovery", "email_usable": false, "recovery_enabled": false, "og_limit": 100} ``` Refusals over HTTP use the same shape as the wire: `{"ok":false,"code":N,"error":"", "detail":""}`, and the documented codes are: | Code | Name | Meaning | | --- | --- | --- | | 11 | `email_disabled` | email is off, or on and unusable (no `smtp_url`, no `from`, or the relay refused) | | 12 | `email_token` | a verification or recovery token is unknown, already used, or expired | | 13 | `recovery_disabled` | recovery is not available here: email off, or `verify-only`, or no address stored | | 14 | `no_identity_backup` | no identity backup is stored for that account | The body of a POST is one JSON object with a `Content-Length`; anything else is refused with a sentence. A semantic refusal is an HTTP 200 with `ok:false` and a code, so a client that reads the body is never misled by a transport status; a body that is not JSON is a 400. Rate limits are server-side and mandatory: at most one mail per address per 60 seconds, at most 10 per hour per address, and at most 30 per hour per source. Exceeding them refuses politely and records an audit row. Two consequences the implementation had to respect: `/api/recovery/request` still answers `{"ok":true}` when it is rate limited, because that answer must not depend on anything about the address, and the refusal is recorded in the audit log instead; and the mail limits are keyed on the address *hash*, not the address, so a `verify-only` server keeps no plaintext address even in memory. Tokens are stored hashed and single use, and asking for a new link retires the previous unused one for that user and kind. ## 7. Storage New tables, additive migrations, never a destructive rewrite: - `user_email(user_id, address, address_hash, hint, verified_at, created_at)` with a unique index on `user_id` and on `address_hash`. - `email_token(token_hash, user_id, kind, expires_at, used_at, created_at)`. - `identity_backup(user_id, blob, updated_at)`. - `profile(user_id, status_id, status_text, about, avatar_mime, avatar_id, hidden_badges, updated_at)`. - `avatar(user_id, avatar_id, mime, blob, created_at)` plus `avatar_wrap(user_id, avatar_id, to_user, wrapped)`. - Clearing an avatar is `PROFILE_SET` field 5 with an empty `avatar_id`, and a clear is what an empty value means everywhere in this document: there is no separate "none" value to invent. - `badge(user_id, badge_id, issued_at)` with a unique index on `(user_id, badge_id)`. Notes from the implementation, none of which change what is stored: - The unique index on `user_email.user_id` is the primary key itself; a second `UNIQUE INDEX` on the same column would be a duplicate. `address_hash` has the named index the document asks for, and it is what enforces one address per user. - `badge`'s primary key is `(user_id, badge_id)`, which is the unique index that makes issuing a badge twice impossible. - `hidden_badges` is stored as the built-in ids separated by commas. Hiding is a preference, so it lives in the profile row and never in the badge table. - Only `sha256(token)` is stored, never a token; only `sha256(address || server_id)`, and in recovery mode the address as well, because sending mail later needs it. - The migration is `0003_accounts` and is additive: it creates new tables and new indexes and touches nothing else, so it runs unchanged on a database that already holds users, messages and channel keys. Running it twice is a no-op, and the tests run it against both a fresh database and one built at the previous schema version with data in it. ## 8. Interfaces must not overclaim - **Fingerprints are obscured by default.** Identity fingerprints, server identity fingerprints and certificate fingerprints are long-lived identifiers, so anywhere the interface would print one it prints a stable-width mask instead, with no partial exposure (never the first or last group of the real value). One deliberate click reveals it, and the preference is per device, defaulting to hidden, and stated in settings. Hidden means not rendered at all, not merely painted over. Copying a fingerprint still works without revealing it first, and says what it copied. This must not break out-of-band verification: where the interface asks a user to compare a fingerprint against another source, the comparison stays available behind that one click. - The privacy page gains a short section: linking an address is optional, it is what makes recovery possible, and on a server with `mode = "recovery"` the host can read that address. On `verify-only` the host holds only a hash. - The recovery screen states, before the user acts, that history comes back only if an identity backup exists. - Badges say what they are: issued by this server, on this server, and not portable. What the server itself does, so the client is not the only place honesty has to live: - The page a recovery link opens states, before the user acts, that recovery restores who you are and not what you said, that history comes back only with an identity backup, and it then answers for *this* account whether one is stored. It also states that the old device key stops being this account. It does not offer to hold a passphrase or generate keys, and says so, because those happen on the device. - The recovery mail says the same thing before the user even reaches the page, including whether this account has a backup, because the mail is what the user reads first. - The page a verification link opens states what verifying does and does not change about what the server can read, and that a badge is issued by this server and is not portable. - A disabled feature says which piece is missing: email off, no `smtp_url`, no usable `from` address, or a relay that refused. The sentence goes to the client in `EMAIL_STATE` field 3 and in the error's `detail`, and the same facts are published by `/api/info`. - Every refusal, on the wire and over HTTP, carries a documented code and a plain sentence. Nothing is refused with a bare status or a code a client has to guess at. ## 9. Accounts, optional, in the shape TeamSpeak uses A device-key identity stays the default and keeps working with no account. An account is an extra, per server, that lets the same person come back as themselves on another machine. ```toml [accounts] enabled = false # off unless the host turns it on min_password = 12 login_per_user_per_15min = 5 ``` An account is a username plus a password, held by that server. What the server stores is a verifier, never the password and never anything reversible into it: - `verifier = Argon2id(password, salt, m=64MiB, t=3, p=1, out=32)` with a per account random salt, stored as `salt || verifier`. - Login never sends the verifier. The client computes it, then sends `HMAC(verifier, server_nonce || username)`, so a captured login cannot be replayed on another connection, and the stored value never crosses the wire. Coming back on a second device works through the vault in section 3, with one change: when an account exists, the vault key is derived from the account password with its own domain separated salt, so logging in on a new machine recovers the same keys and therefore the same history. If there is no vault, login may bind a fresh key pair to that user id using the same proof construction as recovery, and the interface says plainly that history will not come back. Rules: - An account is never required to join, chat, or be recognised. A user with no account is a first class participant, exactly as today. - An account grants no privilege by itself. Roles and badges are attached by whoever administers the server, as they are now. - Usernames are per server. There is no global account and nothing carries between servers. - Deleting the account removes the account record and the vault, and leaves the device identity usable. Deletion is a first class action and says what it costs. - Login attempts are rate limited per username and per source, and a refusal never reveals whether the username exists. Disclosures the interface must carry: - The server holds a verifier that anyone who steals the database can attack offline. That is why the Argon2id parameters are high, and it is the honest reason to prefer a long password. - The vault is encrypted under a key derived from the password, so a weak account password weakens the vault as well. - The host can see the username, when it was created, and when it was last used. Wire, additive like everything else: `220 ACCOUNT_REGISTER` (username, salt, verifier, ed25519, x25519, proof), `221 ACCOUNT_LOGIN` (username, login_proof), `222 ACCOUNT_STATE` (state, username), `223 ACCOUNT_BIND` (new keys, proof over the nonce). Tests: register then log in from a second device and prove the same user id comes back with the vault decrypting to the same keys; a wrong password is refused and rate limited; a login proof replayed against a different nonce is refused; an account deleted leaves the device identity working; with accounts disabled the frames are refused rather than half honoured. ## 10. Tests that must exist - Email: an unverified address gets no badge; verifying issues `email_verified` once, not twice; a used or expired token is refused; `verify-only` mode never stores the address in the clear. - Recovery: a request for an unknown address answers exactly like a known one; a redeemed token cannot be redeemed twice; a token bound to one key pair cannot be replayed onto another; after recovery the user keeps their id, name, role and badges; with a backup the keys come back and old channel keys still open; without one they do not. - Avatars: a member decodes the image; a non-member sees only ciphertext; a user who joins later gets the wrap; the server log and database contain no decodable image. - Badges: the first `og_limit` users get `og` and the next does not; hiding a badge keeps it issued. - Profiles: name and status survive a reconnect, `invisible` is presented as `offline` to others, and a 300 character `about` is refused rather than truncated. ## 11. What changed while implementing this document Everything above marked "added while implementing" is listed here in one place, because this file is frozen and each addition is a decision the next reader should be able to find: 1. `[email] link_base`, because a link in a mail must be absolute and nothing in the document said where it comes from (section 1). 2. The exact recovery challenge construction, because the frozen redeem body has no nonce field and stateless HTTP has no `WELCOME` (section 2). 3. What a rebind does to the old key and to sessions holding it, because "rebinds that user id to the new keys" left it open (section 2). 4. `POST /api/recovery/backup`, because section 3 requires a token-holder to be able to fetch the blob and the route table had no route for it (section 6). 5. `AVATAR_GET` field 2 and `AVATAR_WRAP_PUT` (212), because section 4 requires the wrap relay and the frozen table had no opcode or field for it (sections 4 and 6). 6. `IDENT_BACKUP_DELETE` (213), because section 3 requires a first class delete and the table had no opcode for it (sections 3 and 6). 7. `EMAIL_STATE` field 3 `detail`, because section 8 requires an unavailable feature to say why and the four state values cannot (section 6). 8. How `/api/email/state` identifies "the caller's session" over sessionless HTTP, and the addition of the account fields to `/api/info`, so a client can say why a feature is not there (section 6). 9. Error codes 11 to 14, because the wire codes 1 to 10 do not describe an unavailable email feature, a spent token, an unavailable recovery, or a missing backup (section 6). 10. The hint's shape in each mode, and the timing of `og` issuance including the case of a database that predates the feature (sections 1 and 5). 11. The rule that a `PROFILE_SET` answer is the stored profile, and that the display name is one value shared with `IDENT_SET` (section 4). 12. The storage notes in section 7, including that the primary keys are the unique indexes the document asks for. 13. Corrections made after the two halves were checked against each other, which the published document caused and should carry: - the recovery challenge is **derived, never issued** (section 2), so there is no `/api/recovery/challenge` route. A client that added one passed its own tests against a matching stub and failed against every real server; - deleting an identity backup is `IDENT_BACKUP_DELETE` (213), and must not be an empty `IDENT_BACKUP_PUT`, which section 3 requires a server to refuse as a mistake. A client that used an empty blob was silently told its blob was invalid while the stored backup stayed where it was; - `IDENT_BACKUP_DELETE` answers with `IDENT_BACKUP` and no blob, so a client can report a confirmed delete rather than a hopeful one; - clearing an avatar is an empty `avatar_id` (sections 4 and 7), and the recovery bodies are base64 while the server id is hex (section 6).