# DEPLOY_PLAN.md — IRIS Chat

**What this is:** the "look before touching" reading of the *actual current repo*, produced at Step 1 of `DEPLOY_PROMPTS.md`. Nothing was changed to write it. It is the map we build from in Steps 2–7.

**Source of truth for everything below:** `PROJECT_BRIEF_IRIS_CHAT.md` and `DECISIONS.md`. This plan does not change any design — it records what is there.

**One-line state:** the prototype is one self-contained HTML file holding *both* the doctor app and the admin console. All name-hiding happens on the phone. The only things that leave the device are the AI call and a handful of `/api/*` backend calls — and today those only work inside Claude.ai or against the throwaway local test server. Making it hostable is almost entirely about changing *where those calls point*, not what the app does.

---

## (a) Every place the app sends data outward

All outbound traffic is one of two kinds: **the AI call** (one path) and **backend `/api/*` calls** (several). Every backend write funnels through one function, `apiPost` (line 1090), which is also the offline park-and-file choke point (see the offline note below). Reads go through `apiGet` (line 1079).

| # | What | Where in code | What it carries | Where it goes **today** | Where it must point **once hosted** |
|---|---|---|---|---|---|
| 1 | **AI call** (the LLM) | `callAI()` → `fetch('https://api.anthropic.com/v1/messages')` (line 1688) | **Masked text only** — codes, never a real name (invariant #1). System prompt = cached rulebook + per-Player tail. | Direct to Anthropic. **Keyless** — works only inside Claude.ai (its bridge injects auth); fails anywhere else. In the local harness this URL is rewritten to `/api/messages`. | The hosted **backend proxy** (Step 2). One config value = the proxy URL. This is the single real change to the app (Step 3). |
| 2 | **Back-office review channel** | `queueReview()` → `apiPost('/api/review', safe)` (line 1151) | The **one sanctioned real-name exception** (DECISIONS #47): an unknown doctor/hospital/product name the doctor typed, **field-whitelisted** (`kind/player/alias/name/location/era/when` — line 1150). Never a patient, never free-text. | Local harness store. | An **authenticated admin endpoint**, separate from the codes-only pipeline. Keep the whitelist rule intact. |
| 3 | **Plays store** (filed reports) | `apiPost('/api/plays', …)` (line 1502) | Codes only — Player/Location/patient-initials/items/Era. | Local harness store. | Hosted backend (codes-only data pipeline). |
| 4 | **Stock alerts** (out-of-stock, #51) | `apiPost('/api/stock', …)` (line 2008) | Codes only — Player/Avatar/Location/Era. | Local harness store. | Hosted backend. |
| 5 | **Doctor feedback** (#43) | `apiPost('/api/feedback', item)` (line 1210) | Player alias + category + **free-text note**. ⚠️ The note is **sent as typed** — a doctor could put a real name in it (open finding **P2**). | Local harness store. | Hosted backend **+ apply the masking/banned-word filter to the note** (server-side in production). |
| 6 | **AI-cost metering** (#52) | `apiPost('/api/ai_usage', …)` (line 1741) | Codes-only context — token counts, model, Player/Location/function. No real name. | Local harness store. | In production the **proxy itself** meters at the same point (it already sees every call). |
| 7 | **Admin roster writes** | `apiPost('/api/roster', …)` (lines 3203, 3224, 3313) | Roster entries / alternate-name links. **Real names** — this is admin-console (back-office) traffic, not the doctor app. | Local harness store. | Authenticated admin endpoint (admin console, not the phone). |
| 8 | **Reads** (`apiGet`) | `/api/plays`, `/api/roster`, `/api/review`, `/api/feedback`, `/api/stock`, `/api/ai_usage` (lines 1250, 1265, 1801, 2383, 2516) | Pulls history/roster/queues on sign-in and admin refresh. | Local harness store. | Hosted backend; the roster read stays admin-scoped. |

**Attachment feature (#45) — no new outbound path.** File attachments are parsed and masked **entirely on-device with zero network calls**; the resulting plays post through the normal plays path (#3). A photo's confirmed text goes through the AI call (#1). Images never leave the device. So wrapping only needs to wire the *camera/file pickers* to native equivalents (Step 4) — no new destination.

**Offline park-and-file (#46, #49) — wraps the backend calls, not the AI call.** `apiPost` is the choke point: when offline, every backend write (plays, review, stock, feedback, ai_usage) is parked in an **in-memory `OUTBOX`** (line 1088) and replayed by `flushOutbox` (line 1114) on reconnect. The **AI call is not wrapped** — a free-text turn that needs the assistant is held in `state.pendingAI` and re-run on reconnect. **Deploy consequence:** the queue is in-memory (invariant #9), so a report can be lost if the app closes before reconnect. The real build needs **durable device storage** for the queue — this is exactly what the Step 5 "no report lost" test must prove, and the biggest reliability item to carry.

---

## (b) Is the AI call keyless today? — Yes

`callAI()` (line 1688) sends **no API key and no auth header** — only `Content-Type`. It authenticates solely via the **Claude.ai artifact bridge**, which injects credentials when the page runs inside Claude.ai. Outside Claude.ai the call fails (documented in CLAUDE.md and brief §9-12). The local harness works around this by rewriting the URL to `/api/messages`, where the Python server adds `x-api-key` from a local `.apikey` file. **The key exists in exactly one place today (the local `.apikey`) and never in the repo or the app** — the posture Step 2 must preserve, moving the key to a server environment variable.

---

## (c) The existing session-only local test server (reuse in Step 2)

There is already a working "middleman" to build Step 2 *from*, not reinvent:

- **File:** `local_app.py`, a small Python (`http.server`) mini-server, lives in the **session scratchpad** (not the repo — session-only, rebuilt each session via the `/test-app` skill, which is the source of truth for its shape).
- **What it does:**
  1. Serves the prototype from `proto/index.html` (a copy with the AI URL rewritten to `/api/messages`).
  2. **AI proxy:** `POST /api/messages` forwards the request body to Anthropic with `x-api-key` read from `.apikey` + `anthropic-version` header, and returns the response verbatim. **The key is read only from that file; request bodies are not logged** (`log_message` is silenced).
  3. **Shared backend:** GET/POST for `/api/plays`, `/api/roster`, `/api/review`, `/api/roster_links`, `/api/feedback`, `/api/stock`, `/api/ai_usage` over a JSON `store.json`, with a **`threading.Lock`** around every read-modify-write (a concurrent-write race once dropped plays — fixed and baked into the skill).
- **Binds** `127.0.0.1:8765`.
- **Gap vs. current prototype:** the copy on disk from the last session predates `/api/stock` and `/api/ai_usage`; the `/test-app` skill already specifies both, so a fresh rebuild is complete.

**Step 2 = harden this into a small `/server` proxy in the repo:** one endpoint that takes already-masked text and forwards it with the key from an **environment variable** (not a file), prompt caching preserved, plus the review channel if we host it here — never masking, never logging content, `.env.example` with a placeholder, short README.

---

## (d) The admin-console question (recommended answer)

**Today they are one file.** `iris_chat_prototype.html` contains both the doctor app and the admin console. Which one you see is decided at login:

- `doLogin()` (line 2367): an **admin** card (`CARD_CREDENTIALS`, e.g. `IRIS-ADMIN`) calls `openAdmin()`, which reveals `<div id="adminConsole">` (line 391); a **doctor** card routes to the chat.
- The admin console is a large block (~lines 2417–3300+): `ADMIN_TABS`, all the `renderAdmin*` functions, the roster (real-name↔code key), review queue, dashboards, reconcile, reports, stock, feedback, AI-cost tabs.
- **Real names live only in the admin console.** The doctor side is codes-only by design.

**Recommendation: split them — the doctor phone app must NOT carry the admin console.** Reasons: (1) it keeps real-name roster data and admin logic off doctors' phones, reinforcing the core confidentiality posture (invariant #1); (2) it matches the already-locked architecture — DECISIONS #28 says the admin console is a **separate desktop web app**; (3) it shrinks the shipped mobile app.

**How to separate cleanly (low-risk):** the name-hiding **engine is already a self-contained block** — the tests lift it by the anchors `const PRODUCTS=[` … `/* ===== STATE ===== */`. That same block becomes a **shared module** imported by both front-ends. Then: **doctor app = shared engine + chat UI** (no admin code, no `CARD_CREDENTIALS`, no roster); **admin console = separate desktop web build** on the same backend. For Step 4 (wrapping into phone apps) we ship the **doctor app only**. This is a clean cut along a seam the code already has.

---

## (e) Which tests already protect us, and what's missing

**Already in `tests/` (strong where it counts — the leak guard):**

- **`masking_eval.mjs` — the safety net. 46 cases, currently 46/46.** Lifts the **real** engine straight out of the prototype each run (never a private copy) and asserts, on **every** case, that **no real name appears in the text the app would send**. Needs no AI, runs instantly. Includes a self-test that proves the leak detector actually has teeth. This is the check that must gate every future change.
- **`sample_plays.mjs` + `e2e_plays.mjs` — end-to-end, 13 samples, 13/13.** Replays real typed messages through masking **and the live assistant** (via the harness proxy), checking each function (play understood, codes right, confirm/batch produced, period/co-player handled) and re-checking no-real-name-sent on every message. Model-selectable via `IRIS_MODEL` (Sonnet/Haiku).

**Missing for a deployable build (these become Step 5):**

1. **No "no report lost" test** — the highest-priority gap, because the offline queue is in-memory. Nothing yet proves every report arrives **exactly once** across app-close / restart / flickering signal. Ties directly to the durable-storage requirement.
2. **No proxy test** — nothing yet proves the `/server` middleman forwards correctly, reads the key **only** from the environment, and **never logs content**.
3. **No automated end-to-end into the admin console** — "doctor files → admin receives" is currently verified by hand only.
4. **No stress / malformed-input test** — the pounding that found the original six bugs isn't automated.
5. **No speed measurement** — on-device masking time and AI round-trip aren't timed/flagged.
6. **Engine is lifted by text-slice anchors** — fine for the HTML prototype, but fragile. When the masking is re-architected into **structured segments** for React Native (finding **R1**), the 46-case eval must be **ported as a merge gate** on the new engine.

---

## Carry-over items already on record (context for later steps, not new findings)

- **P2** — feedback note sent unmasked (row #5 above).
- **P4** — sign-out doesn't clear `state.pendingAliases` / `pendingCounters` / `lastUserMsg`; on a shared phone, names doctor A typed can linger into doctor B's session. Add to the logout wipe.
- **R1** — masking threads HTML strings and mutates them by regex; in React Native this must become structured segments (biggest thing that won't transplant unchanged).
- **Open decisions not blocking Step 1:** model choice Sonnet vs Haiku (DECISIONS #50), co-player credit-sharing math (needs Data Team, blocker #6), app name (DECISIONS #22).

---

## Two decisions only you can make — ANSWERED (Von, 2026-07-22)

1. **Admin console → SPLIT IT OUT.** The doctor phone app carries only the chat + on-device masking engine; the admin console becomes a separate desktop web app on the same backend. Step 4 wraps the **doctor app only**. (Matches DECISIONS #28.)
2. **Store listing → BRAND-NEW LISTING.** Publish as a separate new app on both stores, not an update to the existing IRIS listing. Step 7 (IT runbook) submits a new listing. Note: this ties to the still-open app-name decision (DECISIONS #22) — a new listing needs its name settled before submission.

*Prepared at Step 1 (look-before-touching). No app code was written or changed. Branch: `deploy/step1-look-before-touching`.*
