{"service":"xoseen-ai","title":"Mobile API implementation guide","narrative":"docs/MOBILE_API_WORKFLOW.md","baseUrl":{"summary":"Auto-detected in mobile/src/config.ts; overridable on the Login screen.","apiPort":8042,"targets":[{"target":"Expo web","baseUrl":"http://<page host>:8042","how":"location.hostname"},{"target":"Physical phone (Expo Go)","baseUrl":"http://<LAN IP>:8042","how":"Metro hostUri"},{"target":"Android emulator","baseUrl":"http://10.0.2.2:8042","how":"host alias"},{"target":"iOS simulator","baseUrl":"http://localhost:8042","how":"fallback"}],"helpers":["mlUrl(base, 'rank')          -> <base>/ml/rank","absoluteUrl(base, photoUrl)  -> resolves base-relative photo paths"],"cleartextWarning":"Both platforms block cleartext HTTP by default: iOS needs NSAllowsArbitraryLoads, Android needs usesCleartextTraffic. Dev only — remove both once the host has TLS."},"auth":{"scheme":"HMAC-SHA256 over user_id.expiry, issued by POST /web/profile","ttl":"7 days (XOSEEN_TOKEN_TTL_SEC)","transports":{"http":"Authorization: Bearer <token>","websocket":"?token=<token> query parameter"},"policy":["Token present -> its subject MUST match the acted-on userId, else 401 invalid_token. Enforced ALWAYS, even in demo mode.","Token absent -> allowed, UNLESS XOSEEN_REQUIRE_AUTH=1, then 401 auth_required."],"knownGap":"ApiClient.attempt() in mobile/src/api/client.ts sends only Content-Type and Accept — never Authorization. POST /ml/feedback calls authorize_user, so enabling XOSEEN_REQUIRE_AUTH=1 will 401 every like/pass until the client attaches the token."},"errors":{"envelope":{"error":{"code":"viewer_not_found","message":"Viewer not found."}},"advice":"Branch on `code`, never on message text.","codes":[{"status":400,"code":"invalid_cursor","when":"malformed pagination cursor"},{"status":401,"code":"invalid_token","when":"token subject != acted-on user"},{"status":401,"code":"auth_required","when":"no token and XOSEEN_REQUIRE_AUTH=1"},{"status":403,"code":"verification_required","when":"profile before verification"},{"status":404,"code":"viewer_not_found","when":"unknown viewer"},{"status":404,"code":"not_found","when":"unknown viewer or candidate"},{"status":413,"code":"file_too_large","when":"upload over 12 MB"},{"status":422,"code":"invalid_request","when":"body failed validation"}],"retry":"Only idempotent GETs retry (3 attempts, exponential backoff + jitter, capped 4s) and only on network_error / timeout / status >= 500. POST is never auto-retried; the server is idempotent anyway (first_time guards)."},"workflow":[{"order":0,"id":"capability","title":"Capability check","status":"implemented","goal":"Learn which ranker is live and confirm the backend is reachable.","next":"Always first. A failure here is what should surface 'can't reach server, check the base URL' — not a cryptic error deeper in the flow.","endpoints":[{"method":"GET","path":"/ml/health","auth":"none","purpose":"Which ranker is serving, model version, rollout percentage.","response":{"status":"ok","servingRanker":"rollout","modelVersion":"v0001","rolloutPct":0.25,"shadow":false},"notes":["servingRanker is rule_based | learned | rollout — drives the ModelBadge."]},{"method":"GET","path":"/ml/viewers","auth":"none","purpose":"The 'who am I' picker: seed personas + onboarded users.","response":{"viewers":[{"id":"usr_x","displayName":"Levi","cityArea":"Manhattan"}]},"notes":["Demo affordance. Real apps replace this with Stage 2."]}]},{"order":1,"id":"verification","title":"Identity verification","status":"not_implemented","goal":"Turn a real person + real ID into a trust decision.","next":"A `verified` decision (plus trustScore) is what Stage 2 consumes. `manual_review` is NOT failure — poll status and show a pending state.","endpoints":[{"method":"POST","path":"/verify/verification/verify-live","auth":"none","contentType":"multipart/form-data","purpose":"Store the uploaded images and run the real verification pipeline.","request":{"user_id":"text (required)","id_document":"file (required)","selfie":"file (required)","profile_photos":"file[] (optional, repeat the field)","dob":"text ISO YYYY-MM-DD (optional — FALLBACK ONLY)","doc_type":"text (default state_id)","assistive_context":"text (default none)"},"response":{"userId":"usr_x","decision":"verified","trustScore":82.0,"reasons":[],"reverifyBy":"2027-02-01","ageOver18":true,"ageDisplayed":29},"notes":["decision is verified | manual_review | rejected.","12 MB per-file cap enforced during the streamed read -> 413. Compress with expo-image-manipulator; never send raw camera output.","dob is a FALLBACK. With OPENAI_API_KEY set the DOB is OCR'd from the ID and a typed DOB that disagrees is flagged to review.","Native camera needs no HTTPS — the getUserMedia restriction is browser-only. Use expo-image-picker / expo-camera.","Only `verified` + `trustScore` cross into matching. The DOB never leaves the verifier."]},{"method":"GET","path":"/verify/verification/status/{user_id}","auth":"none","purpose":"Poll a pending decision after manual_review.","response":{"userId":"usr_x","decision":"verified","trustScore":82.0,"reasons":[]}}]},{"order":2,"id":"profile","title":"Profile creation → token","status":"not_implemented","goal":"Create the user as BOTH a viewer and a discoverable candidate, and receive the signed token that authenticates every later write.","next":"Store the token. Everything user-scoped (feedback, chat, blind date) depends on it.","endpoints":[{"method":"POST","path":"/web/profile","auth":"user (on edit only)","purpose":"Create or update a profile; returns the user token.","request":{"userId":"usr_x","displayName":"Ada","ageDisplayed":29,"trustScore":82.0,"intent":"long_term","interests":["hiking","jazz"],"cityArea":"Manhattan","ageMin":25,"ageMax":45,"maxDistanceKm":60.0,"gender":"woman","seeking":["man","nonbinary"],"asexual":false,"asexualOnly":false},"response":{"ok":true,"viewerId":"usr_x","token":"usr_x.1767225600.<sig>"},"notes":["CREATING is open (signup); OVERWRITING requires that user's token — this closes profile-takeover via userId reuse.","trustScore is IGNORED when a verification decision exists — the verifier is authoritative. Self-declared trust was a bypass.","With XOSEEN_REQUIRE_AUTH=1 and no verification on file -> 403.","intent must be in config.INTENT_LEVELS; gender/seeking in config.GENDERS; ageMin <= ageMax. An unknown enum silently empties the feed, so validate client-side.","Pass `seeking` OR an `orientation` label (straight/gay/lesbian/bi/pan/everyone) and the server derives it. Omit both = open to all.","An EDIT preserves like/pass/BLOCK history — blocked users cannot reappear via a profile re-submit."]}]},{"order":3,"id":"feed","title":"The ranked feed","status":"implemented","goal":"Get a verified, safety-filtered, explainable page of candidates.","next":"Feed the cards to the swipe UI. Stage 4 explains one; Stage 5 acts on one.","endpoints":[{"method":"POST","path":"/ml/rank","auth":"none","purpose":"ML-ranked, PII-safe, cursor-paginated feed.","request":{"viewerId":"usr_x","limit":20,"cursor":null},"response":{"candidates":[{"candidateId":"usr_9","displayName":"Thea","ageDisplayed":30,"cityArea":"Williamsburg","verifiedBadge":true,"sharedInterests":["hiking","jazz"],"explanation":"Verified - shares hiking, jazz - same intent - nearby","score":0.7712,"photoUrl":"/app/photos/usr_9.jpg","bio":"..."}],"nextCursor":"eyJvIjoyfQ=="},"notes":["limit is 1..100 (422 outside).","nextCursor is OPAQUE — pass it back verbatim, never parse it. null means end of feed.","photoUrl is base-relative -> run through absoluteUrl().","Every candidate is ALREADY verified, safety-filtered and mutually orientation-compatible. Do NOT re-filter client-side.","The response is a PII-safe allow-list: no exact location, DOB, legal name or ID. A missing field is by design.","The cursor re-ranks per page, so under churn a candidate can skip or repeat — dedupe by candidateId when appending."]}]},{"order":4,"id":"explain","title":"Explainability","status":"implemented","goal":"Show WHY a candidate ranked where they did.","next":"Optional but the product differentiator — render before the user swipes.","endpoints":[{"method":"POST","path":"/ml/explain","auth":"none","purpose":"Per-candidate sub-score breakdown (what the model saw).","request":{"viewerId":"usr_x","candidateId":"usr_9"},"response":{"explanation":"Verified - shares hiking, jazz - same intent - nearby","breakdown":{"trust":0.92,"intent":1.0,"interests":0.8354,"texting_style":0.9625,"location":1.0,"p_like_back":0.49,"final":0.6452}},"notes":["All sub-scores are [0,1]. Render as ScoreBar; `final` as ScoreRing.","CAVEAT: with the learned ranker at 25% rollout, `final` can pin to 1.0 for treatment-bucket viewers (v0001 trained on synthetic events saturates against the realistic seed). Don't assume a spread."]},{"method":"GET","path":"/ml/weights","auth":"none","purpose":"The live compatibility weights, to show each term's contribution.","response":{"compatibility":{"trust":0.3,"intent":0.25},"pLikeWeight":1.0}}]},{"order":5,"id":"feedback","title":"Like / pass","status":"implemented","goal":"Record the decision and detect a mutual match.","next":"matched: true -> open chat (Stage 6). This is ALSO the training signal.","endpoints":[{"method":"POST","path":"/ml/feedback","auth":"user","purpose":"Record like/pass; feeds the live learning loop.","request":{"viewerId":"usr_x","candidateId":"usr_9","action":"like"},"response":{"ok":true,"matched":true},"notes":["action is like | pass (regex-enforced, else 422).","Requires a token when XOSEEN_REQUIRE_AUTH=1 — see the client gap.","IDEMPOTENT: repeats never double-count training events.","Every call publishes a UserEvent that becomes a label for the next model. A test-mode like pollutes real training data — use a throwaway viewerId."]}]},{"order":6,"id":"chat","title":"Real-time chat","status":"not_implemented","goal":"The payoff for a match. Currently a dead end in the client.","next":"Terminal stage of the core loop.","endpoints":[{"method":"WS","path":"/ws/chat","auth":"user (query param)","purpose":"Live chat with pre-delivery safety scanning.","request":{"query":"?room=<room>&user=<userId>&name=<display>&token=<token>","send":{"text":"hi"}},"response":{"type":"message","room":"match:a:b","from":"a","name":"Ada","text":"hi","ts":1767225600.0},"notes":["ROOM ID MUST BE SORTED: `match:${[a,b].sort().join(':')}`. Miss the sort and each peer derives a different room, sits alone, and it fails SILENTLY — no error, just no messages.","match:<a>:<b> / blind:<a>:<b> admit ONLY those two users.","A bad room or mismatched token closes with code 4401 — treat as re-authenticate, not a network blip. Do not reconnect-loop.","Receive frames: history, message, blocked, pubkey, enc, capture_alert.","A `blocked` frame is sender-only: the message was NEVER delivered. Don't render the bubble until the echo arrives.","E2EE (`enc`) is optional and app-side; the server relays ciphertext blind. Trade-off: E2EE messages CANNOT be safety-scanned.","Native can genuinely block screenshots via expo-screen-capture — the one place mobile beats the web SPA."]}]},{"order":7,"id":"assist","title":"AI suggestions & blind date","status":"not_implemented","goal":"Additive engagement features. Build last.","next":"None — leaf features.","endpoints":[{"method":"GET","path":"/web/suggestions","auth":"none","purpose":"Conversation openers from shared interests + intent.","request":{"query":"?viewerId=usr_x&candidateId=usr_9"},"response":{"ok":true,"starters":["..."]},"notes":["DRAFTS ONLY — never auto-send. Each draft is itself safety-scanned. Built from interests, never appearance."]},{"method":"POST","path":"/web/reply-suggest","auth":"none","purpose":"Reply drafts given conversation history.","request":{"user":"usr_x","history":[]},"response":{"ok":true,"suggestions":["..."]}},{"method":"POST","path":"/web/blind/next","auth":"user","purpose":"Identity-hidden top match.","request":{"viewerId":"usr_x"},"response":{"available":true,"candidateId":"usr_9","cityArea":"Williamsburg","ageDisplayed":30,"sharedInterests":["jazz"],"score":0.71},"notes":["Deliberately omits displayName and photoUrl. Don't call /ml/explain and render the name from elsewhere — that defeats the feature."]},{"method":"POST","path":"/web/blind/reveal","auth":"user","purpose":"Mutual reveal; a strong positive learning signal.","request":{"viewerId":"usr_x","candidateId":"usr_9"},"response":{"ok":true,"displayName":"Thea"}}]}],"buildOrder":[{"step":"1","task":"Token support in ApiClient","why":"Everything else depends on it, and it is a live 401 risk the moment XOSEEN_REQUIRE_AUTH is enabled."},{"step":"2","task":"Profile creation (Stage 2)","why":"Unlocks real users instead of the seed persona picker."},{"step":"3","task":"Verification (Stage 1)","why":"Gates profile creation once auth is hardened."},{"step":"4","task":"Chat (Stage 6)","why":"matched: true is currently a dead end in the UI."},{"step":"5","task":"Suggestions / blind date (Stage 7)","why":"Additive."}],"stageCount":8,"endpointCount":14}