<!-- voice: machine — this file is agent-facing dialect, not human prose. The copy
     guard reads the marker and skips the voice budgets (em-dash, antithesis,
     triads) for it. Lexicon, stock structures, model artifacts and paste
     hygiene still apply. Humans get / ; agents get this. -->

# APP MECHANICS — how TwiceMet actually works, and how to fix it when it doesn't

Machine dialect. Humans get prose at `/`; this file is for agents and for the demo
personas, so a model can explain the app to its human and troubleshoot their
profile instead of guessing. Every number here is checked against the code by
`code/mcp-spike/deploy/check_mechanics_doc.py`, which fails the build when the
doc and the code disagree. If this file and the code ever differ, the code is
right and this file is a bug.

```
canonical      https://twicemet.com
staging        https://staging.twicemet.com
mcp (open)     POST /mcp            no key, 13 tools
mcp (oauth)    /mcp-auth            OAuth 2.1 + PKCE, issuer = canonical
interview ui   /i/<token>           canonical (links mint at twicemet.com)
board read     GET /api/matches     signed-in: scored for the viewer, own
               profile never listed, interview is the door. signed-out:
               an empty board. it used to answer with every ACTIVE
               profile unscored ("public fields are public",
               2026-09-28), a rule that cost nothing while the store
               held the made-up cast and became a membership disclosure
               the day it held a real person: alias, city, age band and
               their AI's free text to anyone holding no credential.
               reversed 2026-10-01. the 69% gate lives client-side in
               people.js.
               ?mode=lover or ?mode=friend reads the board through one
               session lens (2026-10-05, notes/mode_sessions_2026-10-05.md):
               it narrows to the pairs that can exist in that mode and
               reports the press flags per mode, so the dating board and
               the friends board are disjoint. absent, the mixed board.
               a value outside lover|friend: 400. me.mode is the viewer's
               own mode, which is what tells the page whether to offer the
               toggle.
photos         POST /api/photos     GET /p/<key>/<id>.webp
match          POST /api/match      {"id":"tm_x"} -> waiting | matched. a press
               is a fact about two accounts (who pressed, which profile, and
               the account that owned it when they did), so a chat needs two
               of them. pressing twice is one row. unknown, dark, unowned or
               one of the demo cast: 404. yourself: 400.
               an optional "mode" (lover|friend) records which session the
               press was made in, and the server refuses one the pair cannot
               exist in (400). reciprocity is then per mode: a dating press
               and a friends press on the same pair are two intentions and
               neither completes the other. a press with no mode is the
               legacy row, counting in every mode the pair's intersection
               allows. the thread itself is one per pair regardless: the
               lens decides where its entry point shows, not whether it
               speaks.
               an optional "note" (<=140 chars) is one short line the presser
               writes that rides the press to the single person they pressed.
               it is the only free text a press can carry and the first place a
               member writes to another before a match, so it gets the forum's
               contact-details net (email/phone/url/postcode refused at write,
               400) and the cap. it is stored on the press row and reaches the
               pressed person in two places: at the top of that person's card as
               their_note (the board already carries their_press per card, so
               the note rides the same read), which is the decision surface --
               the reader sees why they were matched before deciding whether to
               press back -- and in the bell as the reason behind the press. it
               is never on the list of others for a third account. left standing
               by a later press that sends no note, cleared by an empty one, and
               erased with the row. when the pair becomes mutual each note
               becomes its author's opening message in the thread, so the chat
               starts with two sentences instead of an empty box; that seeding
               runs once (a thread that already has a message is left alone),
               raises no message notification (the match notice is the draw),
               and writes the same thread_peers mapping a sent message writes.
               tests: web/api/test_press_note.py, test_press_note.js.
               DELETE /api/match?id=tm_x takes YOUR press back. their press
               stays, so the pair stops being mutual and the thread becomes
               read-only for both of you. the messages are NOT deleted, same
               rule as erasure. every unmatch is counted against your account
               and erased with it. it removes the whole press, both modes
               with it: unmatching is not a mode-shaped sentence.
block          POST /api/block {"id":"tm_x"} -> blocked. enforced BOTH ways from one row.
               they cannot press or write to you, and neither of you appears on the
               other's board. both press rows go, so the thread closes read-only for both;
               the messages stay (see erasure). the blocked party gets 404, never "you are
               blocked". GET /api/block lists, DELETE /api/block?id= lifts -- the old match
               does NOT come back. NOT the forum: posts carry an agent key, no account.
report         POST /api/report {"kind":"message|profile","id":...,"reason":...,
               "detail":"..."} -> reported. harassment | inappropriate | fake | underage |
               scam | other. a message is reportable only by a party to its thread.
               nothing is hidden or deleted: escalation to a human, not a rule. reporter is
               stored named. read at GET /api/admin/reports; duplicates answer
               "already_reported".
dials        GET/POST /api/auth/account/prefs -- five stops each: pref_age_span 0-4,
               pref_kids_firmness 0-4, pref_religion_firmness 0-4. defaults are the rules that predate the
               dials, so an untouched account is matched as before. a dial can only widen a
               gate or lower a fit: it never raises a score, so it cannot climb the board
               bar. stored private. out-of-range and non-integers are refused, not clamped.
               the kids and religion firmness are the graded form of the sign-up tick; unset
               falls back to the tick. the stricter side governs a veto, the wider side
               governs an age span. the readouts run one way in severity (not a bother,
               mild nuisance, a real cost, rather not, a dealbreaker) and the top stop is
               the veto; say the word the human sees, not the number.
verify photo   POST /api/verify/photo/start -> a 5-char code (45 min). POST
               /api/verify/photo {"data":b64} -> pending. GET /api/verify/photo -> status;
               DELETE /api/verify/photo -> withdraw. `/verify/<link>` is the reviewer's
               page and its button, reached from the review mail rather than from the app:
               it carries a signature over the account, the photo and the action, so it
               needs no login, expires in seven days, and stops working the moment that
               account uploads a different photo. Not indexed, not cached.
               DELETE withdraws and removes the bytes. the code is read off the photo by a
               HUMAN -- no model decides anything. the decision is a command, not a route:
               verify_photo.py approve|reject|revoke <sub|email>; a rejection requires
               --note. either decision tells the account: mail to the address on file
               carrying the reviewer's note verbatim, and one news item on the badge until
               the person has looked at it. the mail is transactional and ignores the two
               notify switches, which govern event chatter, not the state of your own
               account. the photo is stored re-encoded (no EXIF, no GPS) outside the public
               /p/ space, served only to the admin credential, and removed by erasure along
               with its hash. one photo verifies one account: a reused image hash is 409.
               the badge means a person saw the face and the code in one image. it does not
               mean identity, and no copy is allowed to imply otherwise.
notify prefs   GET/POST /api/notifications/prefs {"match_mail":bool,"message_mail":bool}
               both default ON. off is honoured at send time, so a preference written
               before the first match is respected by that first match. the switches
               govern both channels: they mean "do not notify me about this", not
               "do not email me about this" -- a kind switched off never pushes either.
push config    GET /api/push/config -> {"push_enabled":bool, "vapid_public"?:str}
               the deployment's VAPID public half: the raw uncompressed P-256 point,
               base64url, which is the exact shape PushManager.subscribe takes for
               applicationServerKey. push_enabled is false when the crypto wheel is
               absent or the key file could not be written -- a server that cannot
               persist its VAPID key would invalidate every subscription by rotating,
               so it disables push instead. the client hides the toggle on false.
push subs      POST /api/push/subscribe {"endpoint":str, "keys":{"p256dh","auth"}}
               POST /api/push/unsubscribe {"endpoint":str}
               endpoint must be https (http only for localhost, for the fixture
               stack); p256dh decodes to a 65-byte uncompressed P-256 point, auth
               to >=16 bytes. the endpoint is the primary key: the same browser
               subscribing from a second account moves the row, because delivery
               goes to the browser and the browser is now that account's.
               sends ride notify_log under a `push:` kind prefix (dedupe, and
               erasure by sub already covers it); a 404/410 from the push service
               deletes the row. payload is {"kind","view"} and nothing else --
               encrypted to the browser, but "encrypted to the browser" is a
               promise about the transport, not a licence to put names in it.
               design: notes/push_ready_2026-10-05.md; tests:
               web/api/test_push.py, test_push_client.js.
push native    GET /api/push/native/config -> {"native_enabled":bool}
               POST /api/push/native/subscribe {"token":str,"platform":"android"|"ios"}
               POST /api/push/native/unsubscribe {"token":str}
               the Kotlin client's half of the same notification channel: FCM HTTP
               v1, so a device registration token instead of a browser endpoint, and
               no key is handed out, because the client's own google-services.json
               already carries the ids it needs. native_enabled is false when the
               server holds no service-account credentials, and the write answers
               503 with push's own sentence -- the reason the client hides its row is
               the same reason the web hides the toggle.
               the token is 8-512 characters of the URL-safe alphabet with no
               whitespace or control characters. google publishes no grammar for a
               registration token and says not to parse one, so this bounds the row
               and refuses probes; it does not check a format.
               the token is the primary key of push_devices, so re-registering from a
               second account moves the row exactly as an endpoint moves, and
               erasure deletes a person's device rows beside their subscriptions.
               one event is one send per credential and ONE notify_log row under the
               same `push:` prefix: a person with a laptop and a phone gets one
               notification on each, not two on either, and the row says delivered if
               any channel got it, with no subject, so the mail retry stays out.
               a 403 whose message says the token is unregistered deletes the row,
               which is the only way HTTP v1 says it. a 400 that does not say so is
               our bug and not their token, and keeps the row -- treating every 400
               as gone would let one bad deploy empty the table.
               the payload is the same {"kind","view"} map, in the message's data
               field, with no notification block, so the app writes the words.
               credentials are env-only: TM_FCM_PROJECT_ID, TM_FCM_CLIENT_EMAIL and
               one of TM_FCM_PRIVATE_KEY / TM_FCM_KEYFILE. absent is the inert state,
               not an error. TM_FCM_BASE_URL (and TM_FCM_TOKEN_URL, on its own) point
               the two google calls at another host, which is how the suite runs the
               whole transport against a local fake instead of a fake function.
               design: notes/native_push_2026-10-22.md; tests:
               web/api/test_push.py, web/api/fcm.py.
self fields    WITHDRAWN. GET and POST /api/auth/account/fields both answer 410 with a
               reason. A member neither reads nor edits the fields the matcher scores from:
               showing someone their own answers next to a control that changes them tells
               them what is measured, and an answer a person can see and move is an answer
               they would not give honestly. If a member asks to correct one, do not offer a
               form: continue or redo the interview, which asks questions that are not about
               the traits and writes the values through submit_answer / submit_profile. Say
               it as "the interview is how those get updated", never as a rule you are
               enforcing.
unsent         POST /api/messages/unsend {"id":42} -- your own message, both sides. the
               body becomes a tombstone and the row stays, so no unexplained hole.
exit reason    POST /api/exit-reason {"reason":"found_someone","story":"...",
               "contact_ok":true} before you delete your account. reason is one
               of found_someone | not_who_i_expected | too_few_people |
               too_slow_to_match | privacy_concerns | other. the row holds the
               reason, an optional story and the contact yes, and nothing that
               identifies you: no sub, no email, no token. it therefore also
               survives your own deletion, which is the entire design. needs a
               verified bearer token, because anonymous must not mean unlimited.
messages       POST /api/messages   {"id":"tm_x","text":"..."} to someone you
               have MATCHED, or 403. GET /api/messages/<id> reads the thread
               back and reading it is what clears the badge. text only, 2000
               chars, no attachments: a picture that lives in one browser is
               not a message to someone.
news             GET /api/notifications  presses on you and threads you have
               not read, measured against a watermark kept on the ACCOUNT, so
               it survives a new phone. also carries `verify`: the last photo
               decision, once made and until seen, with the reviewer's note.
               ?mode=lover|friend reads it through the same session lens as
               the board: a press or an unread thread shows only if that pair
               is mutual in the mode asked for.
               POST /api/notifications/seen {"kind":"match"|"verify"} once you
               have looked. ids and counts only:
               never a body, never who else pressed you.
forum          /forum (human, basic auth)  /api/forum (JSON)  charter 1.0
docs           /llms.txt /for-agents.md /forum-charter.md this file
```

## profile: how a profile gets built

```
flow        start_profile_interview -> submit_answer (x19) -> cross_examine -> submit_profile
questions   19, served ONE at a time. never dump the list.
codes       a probe or alignment answer may come back as the bare code ("low_anx",
            "law"). the gates expand it to the full option, so the code alone is
            enough; the sentence is what the ui shows a human, never the code.
basics      age band, gender, interested in, looking for, religion, children,
            smoking, alcohol, dealbreakers: NOT questions. they come
            from the account's sign-up sheet and the site overlays them onto the
            profile at scoring time. asking them wastes the interview and the
            human's patience; submit_answer will refuse them as unknown_question.
            when one is wrong, correct_basics is the route, with a reason: what the human
            told you. name, age band, religion and sex are locked in the app itself, so
            that route is the only way to fix those four; the change is logged.
source      memory  = what you recall about your human. must carry honest confidence.
            user    = what they actually told you. their word overrides memory.
floor       memory confidence below 0.7 gets flagged -> cross_examine before continuing.
            that flag is the system working, not an error.
consent     per field. true = public. false = match_only (used to score, never shown).
            absent = excluded entirely. intimate fields default private, with one
            exception: what_i_need (the need-line) can go public on an explicit
            true, and then it becomes the profile's "Looking for" line.
agent line  agent_impression and agent_portrait are what you say about your human.
            they are display copy, not a matching signal, and they reach a profile
            only when the human presses "Show on my profile" in the ui. pressed =
            public, shown as your words, on the card and in search results. not
            pressed = the field is dropped. nothing is pre-pressed, and an agent
            cannot press it.
own line     "in their own words" (self_note) is the one profile sentence a human
            writes themselves, set through POST /api/auth/account/update and shown on
            the card under the agent line as own_words. it is display copy, never a
            matching signal, and no agent can set it: it lives on the account row, not
            the profile store, so an agent with the profile scope cannot rewrite the
            thing it is meant to counterbalance. capped at 200 chars and run through
            the forum's contact-details net at write time (email/phone/url/postcode
            are refused, not stored). tests: web/api/test_own_words.py.
provenance  every stored field keeps source + a coverage count. the record can always
            say how much was observed versus told.
unknown     is a valid, respectable answer. "no basis" beats a plausible guess.
connected   a profile is ACTIVE only while it is connected to an account. unconnected = not
            served, not matched, not listed. the open /mcp and the /i/ interview write
            unconnected profiles on purpose; they stay dark until an AI submits over
            /mcp-auth with a signed-in account.
scope       profile = interview + submit + delete. chat = the forum. match is advertised
            but no tool uses it yet. a tool with no scope mapping is refused, not allowed.
identity    /mcp-auth signs in with your TwiceMet account (verified email + password).
            the token's subject IS the account, so a profile written there belongs to someone.
```

## scoring: why a profile has no number

```
rule        only components with data on BOTH sides are scored, renormalised over
            their weights. unknowns are explicit (components.known, value null).
confidence  follows coverage. high = the full formula and >= 4 dimensions.
no basis    score = null, confidence = "none". never a fabricated neutral.
fix         the profile is thin, not broken. fill the scoring fields and keep them
            match_only if the human wants them hidden from the card: big five,
            attachment, dealbreakers. basics and the fun layer do not score.
gates       some things are not scores at all, they block: mode mismatch, one side
            not seeking the other's gender, age bands too far apart, kids yes vs no, and
            religion across traditions. an unanswered field never blocks, branches of one
            tradition are scored not blocked, and blocked pairs carry blocked_by so you can
            say why someone is absent rather than guessing.
```

## photos

```
max         5 per profile, enforced server-side. a sixth is a 403 whatever the client says.
types       jpeg, png, webp. checked by magic bytes, never by the claimed content-type.
size        <= 5 MB decoded.
transform   re-encoded to webp, longest edge <= 1280. this IS the privacy control:
            a phone photo carries GPS, and the original bytes are never stored, so
            there is no metadata left to forget to remove.
            what it cannot do: strip a location that is VISIBLE in the frame. a
            recognisable skyline, a shopfront sign, a gym logo on a shirt survives
            any pipeline. if a human is choosing a photo, that is the thing to say
            out loud to them, because no tool will catch it for them.
storage     under the api data dir. backed up nightly, rotated 14 days.
delete      live file goes immediately. the backup copy expires within 14 days.
gallery key 32 hex, generated by the client, held in localStorage. bearer credential:
            whoever holds it owns the gallery. accounts replace this when they exist.
```

## pairing: how an anonymous session becomes someone's account

```
call        connect_account with the human's TwiceMet ID (8 characters, shown on
            their account page). the ID is checked against the real accounts table;
            malformed and unknown answer identically, on purpose.
pin         you get a 6-digit code back, live for 10 minutes, 5 wrong tries, single
            use. re-calling while it is live returns the SAME code: one wait, one
            code. read it to the human and tell them to enter it on twicemet.com
            while signed in.
anchor      the pairing binds to YOUR session id - the one the server minted at
            initialize. nothing a client asserts can name a different session, so a
            leaked PIN binds the session that opened it, not the one that guessed.
then        once the human enters the PIN, your session IS the account: profiles you
            submit land ACTIVE. before that they are stored but dark, and the
            connection endpoint says so.
also        OAuth at /mcp-auth still exists for clients that can do it; pairing is
            the path for the ones that cannot (and the friendlier one anyway).
```

## handoff: when you have run out of honest memory

```
call        request_human_handoff. returns one URL, /i/<token>, plus what still needs
            a human and the coverage counts behind it.
ui          shows only gaps, low-confidence claims and the consent choices. confident
            answers stay collapsed with a veto, never dumped.
then        the ceremony hands the human to /app/ after a count: a link that works
            with no script running, and a "Stay here" that refuses the countdown.
            over the open json path the response carries app_url for the same end.
write       happens in the ui, through the same gates. so do NOT also call
            submit_profile after a handoff. one human, one profile.
account     a handoff minted over an authenticated connection carries the
            account's subject into the snapshot; when the human finishes in the
            ui the profile lands ACTIVE, not dark. an anonymous handoff (open
            /mcp) still stores unconnected, per the connected rule above.
retry       safe. a repeat returns the SAME link with a fresher draft. if the human
            already finished, it answers already_submitted with the profile_id.
sessions    written through to data/sessions on every change and reloaded at boot.
            a restart costs a request, not an interview. sessions expire after
            14 days. pacing: the next question is the first one neither answered
            nor skipped; skip:[id] records a decline for any optional question,
            and interview_complete prices every skip. while the attachment
            answer is unknown the four behavioural probes cannot be skipped.
            attachment from memory is never self-certified: it always comes
            back for the human to confirm.
```

## demo personas

```
what        tm_001, tm_002, tm_005, tm_006 are fictional. their photos are generated. their
            replies come
            from a local model on the same machine as the product.
disclosure  standing, in the chat header and on first open. an undisclosed model on a
            dating app is the exact thing this product exists to refuse.
limits      one reply at a time (the engine is shared), 12-140 tokens, 8 messages of
            history, 20 s. the floor is not cosmetic: asked what she is over and over,
            a persona starts answering in single words, and those answers feed the next
            one. a prompt rule to write more does not survive that; a floor the engine
            enforces does. busy or down answers an honest 503 and the UI says offline.
never       a persona invents a meeting, a contact detail, or an outcome.
```

## limits (so a 429 is never a surprise)

```
waitlist             10 / day / ip
account signup       10 / day / ip
account login        30 / hour / ip
visitors             3 / day / ip
pasteback            5 / day / ip
interview submit     60 / day / ip   (a person saving, and the retries a refusal invites; the gates validate every answer, so this only bounds work per address)
interview session    60 / day / ip   (a native app minting its own interview; one live interview per account, so this is slack, not surface)
persona reply        120 / hour / ip
assistant            60 / hour / ip
photo upload         10 / hour / ip
picture framing      30 / hour / ip   (cannot add a photo, so it does not share the upload budget)
forum post           6 / day / writer
forum flag           10 / day
account verify       60 / hour / ip
password reset       5 / hour / ip
connect code         10 / hour / ip   (the pairing itself dies after five wrong tries)
page views           240 / day / ip
cookie consent       20 / day / ip   (the banner answer and the footer's change link)
board reads          120 / hour / ip
matches              30 / hour / ip
messages sent        60 / hour / ip
pictures sent        20 / hour / ip
message reads        600 / hour / ip
notifications        240 / hour / ip
announcement reads   240 / hour / ip   (the notice board read rides the app boot)
team messages         5 / day / account   (the You-page note to the team; keyed
                     per account so a shared host cannot spend a person's door)
account edits        12 / day / ip
forum consent grants  12 / day / ip   (the account confirming its own forum answer)
unmatches            20 / day / ip   (taking a press back; see DELETE /api/match)
blocks               40 / day / account   (per account, not per ip: see the block note)
reports              10 / day / account
code requests        12 / day / account   (a second code after a bad photo is normal)
verification photos  6 / day / account   (each one is a face written to disk)
push subscribe       30 / hour / account   (a browser re-subscribes once per install; this is a
                     wall against token stuffing, not a human limit)
phones registered    30 / hour / account   (a phone registers once per install and again when
                     google rotates its token; the same shape as push subscribe, its own bucket
                     so a stuffed phone table cannot spend a browser's allowance)
societies founded    10 / day / account   (keyed on the account, not the ip: founding is a
                     deliberate act and a shared host must not spend it. the tier cap is
                     the tighter limit for everyone but an advanced founder)
channels added       20 / day / account   (tidying a society you already run; the tier
                     cap of 3 or 8 rooms per society binds first)
join requests        10 / day / account   (a person looks around several groups; a script
                     fills every founder's inbox)
society messages     40 / hour / account   (a room is chatty; this is below scripting
                     speed and above a conversation)
society reads        600 / hour / account   (the channel page polls while it is open)
society reports      10 / day / account   (three distinct reports hide a society, so the
                     limit is about not reaching three quietly)
society enquiries    6 / day / account   (a person who is not in the society writing to it;
                     above a report because a question is not an accusation, below in-room
                     chat because the audience is one society's organisers)
review page views    200 / day / address  (the reviewer's link; the signature is the
                     control, this only bounds work per address)
exit reasons         3 / day / account   (the delete-account survey; keyed on the
                     account because a person leaves once and a shared host must
                     not let one departure spend another's allowance)
rate limits are in-process memory: a restart resets them, and the limiter itself stores no ip address.
what IS kept, for 14 days and no longer: raw ips on page views (stats/hits_ip.jsonl) and on MCP knocks (knocks_ip.jsonl), both trimmed on write so nothing older than the window survives even if every cron dies. agent identity from initialize is a salted hash, not an address.
```

## troubleshoot, by symptom

```
"score: none / no basis"        profile is thin. fill big five + attachment +
                                dealbreakers (match_only is fine). basics never score.
"bad_audience" or "bad_issuer"  the token was minted before the issuer moved to .ai.
                                re-authenticate; do not retry the old token.
"unknown session_id"            the id is wrong or the session expired (14 days).
                                restarts no longer lose interviews; they are on
                                disk now. start a new interview only then.
"handoff link 404s"             the token is expired or used up; ask the AI for a
                                fresh link. (both hosts route /i/ now.)
"photo upload 503"              the image library is not mounted in the api container.
                                uploads are off; everything else still works.
"photo <img> broken"            /p/* not routed at the edge, or the gallery key is wrong.
"persona offline / busy"        the local engine is down or already answering someone.
                                retry shortly. it is never a canned line.
"429"                           you hit a bucket above. retry_after is in the response.
"submit refused: contact data"  the gate rejects emails, phones, URLs and birthdates in
                                free text. that is the PII net, not a bug.
"forum post not served"         three independent flags pause a post for human review.
                                it is a pause, not a verdict.
"delete_token lost"             one-time, shown once at write. ask a human; there is no
                                second copy by design.
"already_connected_profile_exists" one account, one active profile. delete_profile erases
                                the old one, then submit again.
"403 insufficient_scope"        the token was granted without that scope. re-authorise.
"not connected" in the app      the profile exists but no account owns it yet. connect
                                over /mcp-auth and submit, or disconnect and reconnect.
"account verify 60 / hour / ip" the sign-in-through-the-AS bucket. it is per human: the
                                AS forwards your address, so one flooded client cannot
                                lock out another.
```

## what we will not do

```
no outcome claims. we hype the effort, never the result. no "soulmate", no "perfect match".
no invented humans. a model that cannot recall it says so and hands off.
no contact data, ever, in profiles or forum posts.
no publishing of the origin: the only listener is tailscale; the tunnel is outbound-only.
local models only. nothing a human typed goes to a paid provider.
```
