# DECISIONS — IRIS Chat
Status markers: ✅ resolved · 🟡 direction set · 🔴 open. Never delete rows — superseded decisions are audit trail.

| # | Decision / question | Status | Date | Outcome & rationale |
|---|---|---|---|---|
| 86 | App icons: emoji replaced with our own built-in drawings so Android and iPhone look identical | ✅ | 2026-07-27 | Von, looking at the app, saw that the icons are "default to iPhone" while some doctors use Android. Correct diagnosis: every control was an **emoji character**, and an emoji is drawn by the phone's own font, not by us — so the same character is a different picture on iOS and Android, and three of them were actively broken on Android: the sign-out **power symbol (⏻) is not an emoji at all** and shows as a grey glyph or an empty box, the **mountain (⛰)** has no colour version so it fell back to a flat grey triangle (visible in Von's screenshot), and the **woman-doctor (👩‍⚕️)** is a joined multi-character emoji that splits into two glyphs on older Android. **Fix:** a small set of 12 hand-drawn SVG symbols defined once at the top of `<body>` in `app/iris_chat_app.html` (`ic-player`, `ic-chat`, `ic-request`, `ic-power`, `ic-flame`, `ic-wave`, `ic-mountain`, `ic-chart`, `ic-clip`, `ic-pin`, `ic-person`, `ic-medic`), referenced with `<use>`. They inherit the surrounding text colour (`currentColor`), so the header stays white-on-violet and the chips keep their own colours with no extra styling. Applied to the always-visible chrome: the Player pill, the three header buttons (feedback / request / sign out), the BLAZE·BREEZE·TERRA bars, the "my plays" button, the attach button, the Location quick-chips, and the three name-check buttons (Match / Co-Player / Location). **Nothing else changed** — no masking, extraction, crediting, or AI-prompt code was touched, and the emoji that appear inside chat sentences (🔥 📅 ✓ ⚠) were left alone; they are prose, not controls, and Android renders them. **Verified** by rendering the real header, bars, chips, composer and name-check card headless in Chrome at 1× and 2× — all 12 icons draw correctly at 64px, 14px and 12px. Phone bundle regenerated from the web app (`mobile/build-www.mjs`, in sync); **QA green — all 8 runnable suites pass** (masking 47/47, holds 109, attachments 11/11, requests 6/6, no-report-lost 27/27, e2e 6/6, stress 13/13, speed). **Still on emoji:** `pilot/iris_chat_tester.html` (the older demo/cheat-sheet page) and `prototype/iris_chat_prototype.html` (the behavioural reference) — cosmetic only, neither ships to a doctor's phone; sync them if Von wants the tester page to match. |
| 85 | POST-LAUNCH: an EMPTY roster degrades masking, a MISSING one falls back cleanly — the fallback only fires on exception | 🔴 | 2026-07-26 | Noted by Von from the Step 5b negative control. The masking's roster fallback (`try{ if(acRoster&&acRoster.hospital){ hosp=acRoster.hospital … } }catch(e){ /* seed */ }`) reverts to the compiled `HOSPITALS`/`PRODUCTS` seed only when reading `acRoster` THROWS (undefined / out of scope). If `acRoster` is PRESENT but EMPTY (`{hospital:[],product:[]}`) no exception fires, so the empty list is used and known products/hospitals stop matching — the negative control showed **29+4 failures** in that mode (identities still coded to LOC-PEND / initials — no name leak, but masking is degraded). So "roster fails to load" is safe (falls back), but "roster loads empty" silently degrades. **Post-launch hardening:** treat empty the same as missing — fall back to the seed when `acRoster.hospital`/`.product` is empty, not only when the read throws (e.g. `const hosp = acRoster?.hospital?.length ? acRoster.hospital : HOSPITALS`). Low risk in practice (the roster is compiled in, never actually empty); tracked for after pilot. |
| 84 | POST-LAUNCH: acRoster duplicates the compiled HOSPITALS/PRODUCTS seed — two copies of the same data | 🔴 | 2026-07-26 | Noted by Von during Step 5b. `acRoster` is initialised as a deep copy of the compiled seed (`{hospital:HOSPITALS.map(...),product:PRODUCTS.map(...)}`), so the masking roster data lives in two places in the same file — exactly the duplicate-that-can-drift we just eliminated between the web app and the phone bundle. Benign today (the copy is made at load from the seed, and the negative control confirmed `acRoster.hospital` is byte-identical to `HOSPITALS`), but the same data existing twice is a latent drift risk if one is ever edited without the other. **Post-launch cleanup:** collapse to a single source — have the masking read the seed directly, or make the seed the only literal and derive `acRoster` from it. Not urgent (no correctness bug today); tracked for after pilot. |
| 83 | Single source of truth for the doctor app: phone bundle GENERATED from the web app; prototype also made doctor-only (executes the #82 plan) | ✅ | 2026-07-26 | Carried out the #82 plan in committed steps. **5a — durable offline unified:** ported the phone bundle's persistent parked-reports queue into the web app so the two are byte-identical there (no-report-lost **27/27** against the web app; phone bundle untouched, still == commit 66df914). **5b — generator (`mobile/build-www.mjs`):** produces the phone bundle from the ONE source `app/iris_chat_app.html` via three context-anchored transforms (clean `<title>`, `config.js` include, two blank lines). Output is **byte-identical** to the known-good phone bundle at 66df914 (same git sha b6d3632) — the strict acceptance test. Safety rails, each proven by a negative test: every transform must match exactly once or it throws (structural drift fails loud); the output is scanned for admin credentials (`IRIS-ADMIN` / `pin:'0000'` / `role:'admin'` / `CARD_CREDENTIALS`) and it refuses to write if any appear; the default source is hard-coded, and the `IRIS_BUILD_SRC` test override warns loudly AND refuses to write the real `mobile/www`. **Verify command:** `node mobile/build-www.mjs --check` regenerates in memory and compares to the committed `mobile/www`, writing nothing (exit 0 = in sync, 1 = drift) — a CI / pre-ship drift gate. **Docs:** `mobile/README.md` (build steps + "www is GENERATED, never hand-edit"); of the four handoff docs, **ORIENTATION.md** and **FAQ.md** changed (METHOD / HOSTING unchanged). **5c — prototype:** made `prototype/iris_chat_prototype.html` doctor-only too (same trim as Step 2), so the demo `IRIS-ADMIN` / PIN 0000 credential is now gone from ALL files. Net: one place to edit the doctor app (`app/`), the phone build regenerated from it, drift structurally impossible; masking 47/47 + holds 109 verified against web app, phone bundle, and prototype throughout. Follow-ups: point the name-hiding regression at the shipping file once wired (small), and post-launch roster items #84/#85. |
| 82 | Step 4 — web app (now doctor-only) vs phone bundle: what still differs, and can the phone build be a straight copy | ✅ (executed via #83) | 2026-07-26 | After Step 2 made `app/iris_chat_app.html` doctor-only, it and the phone bundle `mobile/www/index.html` are very close (2730 vs 2766 lines; ~56 diff lines). **All remaining differences fall into three buckets:** (1) **Offline queue — the one functional gap.** The phone bundle has the DURABLE parked-reports queue (Step 4.5: `OUTBOX_KEY`/`loadOutbox`/`saveOutbox`/`PERSIST_PATHS`/`__flushing`, survives app close/restart, files exactly once); the web app still has the OLDER in-memory `OUTBOX=[]` that loses a parked report on restart. The phone is *ahead* here. (2) **Deploy header — two lines.** The phone bundle loads `<script src="config.js">` (the one value IT sets for the hosted proxy URL) and titles itself "IRIS Chat"; the web app has no config.js line and title "IRIS Chat — Prototype". (3) **Whitespace — two blank lines** left where the admin console was removed. The masking engine, AI prompt, chat render, and stock-note code are now IDENTICAL. **Can the phone build be a straight copy instead of a trim? Not yet — but the trim problem is solved** (Step 2 removed the admin console from the web app, so no admin surgery is needed anymore). **One blocker remains: the offline queue must be unified first** — a straight copy today would REGRESS the phone (lose durable offline → a parked report could be lost on restart). Correct direction: port the DURABLE queue (the phone's, the better one) INTO the web app. **After that**, the phone build = the web app + a two-line deploy header (set title, add config.js) — i.e. a trivial one-step generator, no hand-trimming, no drift. **Plan set: Step 5a** port durable offline into the web app (don't touch the phone bundle); **Step 5b** build the trivial generator whose acceptance test is that its output diffs against the known-good phone bundle (commit 66df914) in ONLY the title + config.js lines, and which fails loudly if its output contains any admin credentials. Open: what to do with the prototype file, which still carries the demo admin PIN (see below / a later step). |
| 81 | Admin Stock tab shows the doctor's OWN wording for an out-of-stock alert (masked, codes only) | ✅ | 2026-07-26 | Von: the Stock tab showed only the codes (OUT OF STOCK · PRAZ · SATURN 33) — he wants the doctor's **exact message** included as a reference for the precise wording and the doctor's tone, formatted so the row isn't crowded. **Confidentiality:** what's shown is the on-device **MASKED** message (the same codes-only text sent to the AI and shown in the doctor's own chat bubble) — never the raw text, so a real product/hospital/patient name can never reach the admin store. This is consistent with the existing masked `text` on supply follow-ups and feedback. **Built:** (1) **doctor app** (app + prototype) — `commitSend` now remembers the masked message (`state.lastMasked`); `handleStockReport` attaches it as `note` on the `/api/stock` POST. (2) **server** (`server/proxy.py`) — added `note` to `STOCK_FIELDS` so the masked wording is stored; the field whitelist still drops anything else (verified: a smuggled `patient` field was dropped, note kept). (3) **admin** (`admin/index.html`) — the alert row shows the note on its OWN line under the codes as a quiet italic quote with a left border (`.stock-msg`), roomy line-height, capped width — reads the tone without crowding; older alerts with no note render unchanged. Verified live end-to-end: JD28's "Good am. PRAZ out of stock at SATURN 33 — wala na since last week" (Taglish tone intact, product+Location as codes, no real name) appears under the OUT OF STOCK · PRAZ chips. QA green (all suites); all 7 admin proofs pass on a clean backend (15/15, 19/19, 44/44, 43/43, 17/17, 10/10, 9/9). **Note:** mobile/www (phone bundle) needs the same `state.lastMasked`/`note` doctor-side addition in its pending resync. |
| 80 | Clarify messages formatted as a scannable numbered list (readability for busy doctors) | ✅ | 2026-07-26 | Von: the assistant's multi-question clarify came back as one dense paragraph ("Two things before logging: (1)… (2)…"), hard for a busy doctor to read; he asked for line breaks / bullets while staying professional. **Two coordinated changes (app + prototype; no masking/extraction touched):** (1) **render** — free-text assistant lines (clarify/answer) now go through a new `aiProse()` helper that game-substitutes, HTML-escapes, then converts the assistant's line breaks to `<br>` (the chat bubble renders normal whitespace, so any newlines previously collapsed to one paragraph); (2) **prompt** — the `clarify` instruction now says to lay a multi-part question out as a short lead line, then each question on its OWN line as a numbered list with real `\n` breaks, one short plain question per line. Verified live in the app: "gave 2 kenz to Maria Santos 70yo at makati med" → "Almost ready to log this play. Two quick things: / 1. Were the 2 PRAZ units from inside the Location (I) or outside (O)? / 2. Just you on this Match, or was a Co-Player involved?" rendered as separate lines. QA green (live dialogue 13/13). **Note:** mobile/www (the phone bundle) is behind on the #76/#78/#80 doctor-prompt changes — flagged for a dedicated resync. |
| 79 | Masking: un-glue a comma with no following space so a known Location/name after it still converts (leak fix) | ✅ | 2026-07-26 | Von live test: "gave 2 praz to MC 70yo,makati med …" — the hospital **"makati med" reached the AI in the clear** (shown as plain text, not a code). Root cause: the on-device masker tokenizes on whitespace only, so "70yo,makati" (no space after the comma) collapsed into ONE token and the phrase matcher never saw "makati" as a word — so the "makati med"→NEPTUNE 21 alias never fired (and, being a *known* alias, it also wasn't caught by the preposition-triggered unknown-hospital net). Only happened with a missing space after the comma; "70yo, makati med" always worked (DECISIONS #61 relied on the spaced form). **Fix (all 3 engine copies — app, mobile/www, prototype; masking core otherwise untouched):** at the top of `maskText`, normalize `raw` by inserting a space after a comma/semicolon **only when a non-digit follows** (`/([,;])(?=[^\s\d])/`), so "70yo,makati"→"70yo, makati" is un-glued while a thousands separator like "1,000" is left intact. Verified: the glued case now masks to NEPTUNE 21 (node repro + live in the app, both "70yo,makati med" and "65yo,makati med"); surname-first patient forms ("for SANTOS,Maria,52"→SM-52 via the real send path) unaffected; **masking 47/47** (new scripted regression case added to `tests/masking_eval.mjs`), holds 109, full QA green. |
| 78 | Assistant asks all missing info in ONE message (not one question per turn); never re-asks what's stated | ✅ | 2026-07-26 | Von (testing #76): the assistant asked missing fields one at a time (source, then game mode = two messages), and in one case was about to re-ask "single or multiple" even though he'd already said "just me". He wants a single message containing every question needed to fill the gaps, and no re-asking of what's already answered. **Changed the system prompt (app + prototype), no masking/extraction touched:** the `clarify` action now says ASK FOR EVERYTHING STILL MISSING IN ONE MESSAGE — combine the open required fields (Location / Match / source / Game Mode) into one short numbered question the doctor answers in one go; the mandatory source question (#76) is folded into that same message instead of being a separate last turn; a field already stated or implied is NOT missing and must not be asked or "just to confirm"-ed (e.g. "just me"=Single, "referred by X — she started it"=Multiple+starter, "all from inside"=source H); if nothing is missing, go straight to confirm. The only field that may still need a follow-up turn is who STARTED an Avatar, which only arises after a Co-Player is named. Also clarified (fixing a splitting bug the live test caught): several Avatars for the SAME Match at the SAME Location = ONE play with multiple items[], a normal confirm — NOT a confirm_batch; batch is only for genuinely separate plays. **Bonus:** fewer clarify round-trips = fewer AI calls = lower cost. Verified live end-to-end: "gave 2 praz to Maria Santos 76 at Makati Med" → one message "Two things: (1) inside (I) or outside (O)? (2) just you or a Co-Player?"; answering "inside and just me" went straight to the confirm card (SINGLE), no re-ask. QA: masking 46/46 + all suites green, live dialogue eval 13/13 (the batch case is the flakiest under LLM variance but passes). The live e2e now actually runs each QA because the local web server proxies /api/messages to the backend (previously skipped when the harness was down). |
| 77 | Sales org restructured into linked Teams → Managers → Reps, with focus-filtered assignment | ✅ | 2026-07-26 | Von: the flat sales-rep list (one table, role field) should become a real org. **(1)** Reps report to specific managers; there is a dedicated **manager** table and a rep is tagged to a manager. **(2)** There are **teams** (a team engages doctors — "KASD" — or hospitals — "Hospital" — or possibly both later); a **team** table, with managers/reps tagged to a team. **(3)** The sales-tab **Add** should use the roster's floating dialog with related fields as dropdowns. **(4)** Move the Sales team tab after AI Cost. **Von's model choices (asked):** a rep's team + manager are set **directly on the rep** (not inherited), and a team's **focus filters assignment** — a doctor offers only doctor/both-focus people, a hospital only hospital/both. **Built (server + console only; doctor app + codes-only pipeline untouched):** server adds rtypes `team` (focus: doctors/hospitals/both) and `manager` (team), keeps `rep` (now team + mgr); roster add/edit passes `focus`+`team`; a new `resolve_sales_person()` resolves a doctor/hospital's `mgr` slot from the manager table and `rep1/2` from the rep table, so `resolve-reps`/`rep-coverage` (the by-rep dashboard) label managers vs reps correctly. Demo reseeded coherently: teams KASD(doctors)/HOSP(hospitals), managers MGR1→KASD & MGR2→HOSP, reps SR1/SR2→KASD/MGR1 & SR3/SR4→HOSP/MGR2; doctors assigned MGR1+KASD reps, hospitals MGR2+HOSP reps. Console: the Sales team tab is now three sub-views (Teams/Managers/Reps) with type-specific columns, a **floating Add dialog** (team=focus dropdown; manager=team dropdown; rep=team dropdown + reports-to-manager dropdown that **narrows to the chosen team**), inline edit with the same dropdowns, archive/restore, paging; the roster's Mgr/Rep assignment dropdowns are **filtered by the doctor/hospital's focus** (verified: doctor→MGR1+KASD reps only, hospital→MGR2+HOSP reps only). Reps remain internal staff, admin-only, never in the codes-only pipeline or the AI. Verified live end-to-end (add a rep via the dialog → stored with team+mgr; focus filtering; by-rep dashboard shows Manager/Rep roles). QA green (masking 46/46); all 7 proofs pass (assign/credentials 44/44, no doctor-name leak). |
| 76 | Assistant asks unit SOURCE (inside/outside the Location) + admin Reports shows it | ✅ | 2026-07-26 | Von (testing the local stack): the confirm card already had the inside/outside (I/O) split, but the assistant never **asked** — it silently defaulted every unit to inside (I) — and the admin console Reports tab had no column showing where the product came from, so back-office staff couldn't tell inside-supply from outside. **Audit first (House Rule 2):** the I/O data model already exists end-to-end in the doctor app (the confirm card's I/O/TOTAL columns, `unitsCompact`, and the AI vocabulary "source = I inside / O outside, stored H/OH"); the two real gaps were (a) the AI wasn't required to ask, so it assumed H, and (b) the backend's `sanitize_items` reduced each item to `{code, qty}`, dropping `source` before storage, so the console never received it. **Built (no masking logic rewritten):** (1) **AI prompt** (app + prototype) — added a NEVER-ASSUME rule for source: unless the player already stated it, the assistant MUST ask exactly one question before confirm ("Were the 6 PRAZ units from inside the Location (I), or from outside (O)?"), split an Avatar into two items when the player says some of each, and treat clear statements ("all inside"/"sa loob"=H, "outside"/"galing labas"=OH) as answered. Asked last, after Location/Match/Game Mode. (2) **Backend** (`server/proxy.py` `sanitize_items`) — carries a validated `source` ("H"/"OH" only; anything else dropped) through to storage. It is game-vocabulary, not confidential (no name, no free text), so it is allowed on the codes-only report. (3) **Admin Reports** (`admin/index.html`) — new **Source** column (Inside · I / Outside · O chips) per item row, and the same in the CSV export; old plays with no source default to Inside, matching the doctor card. **Confidentiality intact:** source is codes-only; the field whitelist still drops any smuggled name (verified). Verified live end-to-end: JD28 logged 6 OTEM answered "4 inside, 2 outside" → confirm card `OTEM6 (I4-O2)` → backend stored two items `{OTEM,4,H}` + `{OTEM,2,OH}` → admin Reports shows Inside · I (4) and Outside · O (2). QA green (masking 46/46, +all suites); all 7 console proofs pass (codes-only proof 15/15 with the new field). Doctor app + prototype both updated. |
| 75 | Sales-rep coverage: assign reps to doctors/hospitals + break the dashboard down by rep | ✅ | 2026-07-25 | Von: add assigned sales reps (Manager / Rep 1 / Rep 2) to the doctor and hospital rosters, pick them from a dropdown backed by a sales-team list, and break the dashboard down to rep level to see who's driving (or lagging on) doctor adoption. Von chose a **dedicated top-level "Sales team" tab** and **both** dashboard views (by doctor's reps and by location's reps). **Confidentiality:** sales reps are internal staff, not patients/prescribers — their list is an **admin-only roster area**; assignments are stored as rep **codes** on the doctor/hospital, and the by-rep dashboard resolves them the same sanctioned admin-only way as product categories (#44). Reps never enter the codes-only plays pipeline or the AI (invariant #1 intact). **Built:** (1) **Server** — `rep` added to ROSTER_TYPES; roster add/edit passthrough gains `role` + `mgr`/`rep1`/`rep2`; new admin-only, audited `GET /api/admin/resolve-reps` (bounded) and `GET /api/admin/rep-coverage` (full doctor/hospital→rep mapping so assigned-but-quiet doctors still count as coverage — the lagging signal); both return codes + rep names/roles only, never a doctor name. Demo sales team seeded (Manager Marco Cruz + reps Sofia/Ben/Rita) and assigned. (2) **Sales team tab** (top-level, admin-only) to add/edit/archive reps (code, name, role), managed via the roster endpoints with rtype=rep; a cached REPS list feeds the dropdowns. (3) **Doctor + Hospital roster** gain a **Sales team** column and Manager/Rep 1/Rep 2 **dropdowns** in the Add dialog + inline Edit (Manager slot lists managers, Rep slots list reps). (4) **Dashboard** — two admin-only cards: *doctor adoption* (per rep: active/total doctors with a **LAGGING** flag under 50%, plays, units) and *location coverage* (per rep: Locations, plays, units). **Production note:** the Sales team list is managed in-console now, shaped so a hosted build can sync it from an HR/CRM system. Verified live end-to-end (JD28+ANIKA active, others quiet → Marco 2/6 LAGGING, Sofia 2/3, Ben 1/3 LAGGING, Rita 1/3 LAGGING; units 9/9/6/3). Proof `prove_assign_and_credentials.mjs` **44/44** (rep endpoints admin-only, no doctor-name leak); all other proofs + QA green. Doctor app untouched. On branch `improve/gate-location-vocab`. |
| 74 | Issues ⇄ Review Queue linked: resolve a duplicate and the doctor's request in one click | ✅ | 2026-07-25 | Von: one real problem (a double entry) can appear in **two** tabs at once — auto-detected in **Issues**, and reported by the doctor as a "cancel/edit play" request in the **Review Queue** — and closing it touches **Reports**; how to resolve with minimal tab-hopping (and no half-resolution, e.g. voiding the duplicate but never replying to the doctor)? Von chose **smart links + one-click combined resolve** (over a full unified inbox). **Built (console only; reuses the existing play/void + request/resolve endpoints):** a shared `duplicatePlayerEras()` marks which Player|Era has an unresolved duplicate, so both tabs reference the same signal. **Review Queue corrections card** — a correction from a doctor who also has a detected duplicate shows **"possible duplicate flagged" + "View in Issues →"**. **Issues duplicate card** — surfaces that doctor's open correction(s) inline (**"The doctor also raised this: …"**) with a pre-checked **"reply & resolve when I void"** box; **"Keep this one — void the rest"** becomes **"…& reply to doctor"**, which voids the extra copy(ies) AND resolves the matching request with an auto-note (**"Cancelled the duplicate — kept R-x, voided R-y."**), notifying the doctor — one click, no tab-hopping. Unchecking the box voids only. **Matching is by Player + Era** (the request is free text with no hard play link) and the request text is shown in full so the admin confirms before it's replied to — a deliberate human check, not blind auto-linking. Verified live: one click voided R-30, kept R-29, resolved the doctor's request with the auto-note (audited `play_void` + `request_resolve`). All proofs + QA green. On branch `improve/gate-location-vocab`. |
| 73 | Action queues scale for the back office: open-by-default + filter + paging so nothing gets buried | ✅ | 2026-07-25 | Von asked how the console holds up with hundreds of items and how to keep work from being buried. The accumulating queues — **Review Queue**, the **play-corrections** card, and **Stock** — rendered **every** item (resolved history included) with no filter or paging, so at scale open work would sit under a long scroll. Fixed with one consistent treatment across all three (reusing the roster's segmented-toggle + pager patterns): **(1)** default to **Open** items with an **Open / All** segmented toggle; **(2)** a **text filter** (Player / name / text) with Filter + Clear; **(3)** **client-side paging** at 25/page with Prev/Next and a "Showing X–Y of Z" line; **(4)** header shows **"N open · M total"**; **(5)** the Review Queue also gets **type chips** (All / Unknown names / Territory) for triage. Shared helpers (`paginate`/`qStatusSeg`/`qFilterBox`/`qPager`/`qMatch`) + a per-queue view-state object + one set of delegated handlers. **Console only** — no server/data-path change. Verified live with 32 seeded reviews (29 open): opens on Open paged 25/page, All shows 32 across 2 pages, type + text filters narrow correctly, no errors. **Production note:** at thousands of items these reads should page **server-side** like the roster's `/api/admin/roster/list` (the GET endpoints currently return the full list); logged as the scale follow-up. Feedback/Issues tabs can adopt the same pattern next. On branch `improve/gate-location-vocab`. |
| 72 | Requests routed to where the work lives (Stock / Review Queue); play correct-or-cancel executed in Reports; Requests tab + admin to-dos removed | ✅ | 2026-07-25 | Von: instead of a standalone Requests tab, surface each request where the related work already is, and make "correct/cancel a play" actually executable. **Answered first:** a doctor request carries no hard link to a play (it's masked free text), so routing is by category. **Built (console + one server endpoint; doctor app + request storage untouched — doctors still read resolutions back via `/api/requests`).** **(1) Supply follow-up → Stock tab:** shown alongside the codes-only out-of-stock alerts, resolved-and-notified through the same reply-to-doctor loop (`request/resolve`). **(2) Correct/cancel a play → Review Queue:** a dedicated "Doctor requests — play corrections" card (masked text + reply note + an "Open Reports →" link), kept separate from the unknown-name/territory table. **(3) Execution — Von chose inline edit over void+refile:** the Reports tab now has per-play **Edit** and **Void**. Void (existing) cancels a play; new admin-only, audited **`/api/admin/play/edit`** corrects a play **in place** — Avatar codes + units + Location — and is **codes-only** (server `sanitize_items` keeps `{code,qty}` and drops anything else; a zero-qty Avatar is removed), so the Era report recomputes straight from it (invariant #1 + #7 intact). Both actions hide when the Era is locked. **(4) Removed** the Requests tab, its render, the admin **to-do** feature (Von's call to drop it), and the now-dead `req-filter`; kept the shared `req-resolve` loop for Stock + Review. Verified live end-to-end (supply follow-up resolvable in Stock; correction card + Open-Reports in Review; Edit R-27 units 4→2 audited "play 27: items, location"; Void set voided=true; Requests tab gone). Proof `prove_assign_and_credentials.mjs` **38/38** (edit corrects in place / zero-qty removal / codes-only / viewer 403); all other proofs + QA green. On branch `improve/gate-location-vocab`. |
| 71 | Resolving an unknown Location backfills the filed play and can assign the reporting doctor in one action | ✅ | 2026-07-25 | Von asked (testing): a single report at a Location that is both **unknown to the roster** and **not on the doctor's list** — is that one review item or two? **Answer: one.** The territory (off-assignment, #68) check deliberately **skips temporary/unknown Locations** (`LOC-PEND-*`) — you can't ask "is this doctor assigned here?" about a Location that doesn't exist in the roster yet — so an unknown Location raises exactly one item: "unknown Location, add it." Verified live (JD28 reported at "Manila Doctors Hospital" → one `hospital` review item, temp `LOC-PEND-1`; no separate territory flag). Von chose to **make that one resolution close the whole loop.** Now, resolving an unknown Location via **Add to roster**: (a) **rewrites the filed play(s)** off the temp code to the resolved Location code (server `_backfill_location`) — the Location analogue of the Co-Player back-fill, so this **completes the #66 "fast follow"**; the "pending review" badge clears; works for add-new and link-to-existing; and (b) a **pre-ticked "Also assign this Location to Player &lt;code&gt;"** box in the clean-up modal adds the Location to the reporting doctor's assignments (they clearly practise there), so it's not flagged off-assignment next time (`assign_to_player`, hospital items only, audited). Verified live end-to-end (resolving "Cebu Doctors Hospital" → CEBU 45 moved play R-32 off LOC-PEND-9 and assigned CEBU 45 to JD28; audit "added hospital CEBU 45 … [1 play(s) updated] [assigned to JD28]"). Proof `prove_assign_and_credentials.mjs` **33/33** (backfill + assign; and backfill-still-happens when the box is off); all other proofs green; QA green. `server/` + `admin/` only; doctor app untouched. On branch `improve/gate-location-vocab`. |
| 70 | Roster/review UX: pick Locations from the roster, access ID defaults to the alias, and a guided add + Add dialog with preview | ✅ | 2026-07-25 | Four ease-of-use refinements Von asked for after using #68/#69 (all **console only** — no server/data-path change; the roster/hospital reads use the existing admin-only, audited paged endpoint). **(1) Reassign picks from the roster.** The off-assignment "Reassign" (#68) was a free-text prompt for a Location code; now it opens a **dropdown of existing Locations** (name · code) so a mistype is fixed by choosing the real one — no typing. **(2) Assigning a doctor to Locations uses a picker.** The assigned-Locations editor (doctor add + edit) is now a **checkbox-chip picker** of the existing Locations (current ones pre-ticked) instead of comma-separated codes — same widget the review-queue "Add to roster" modal already used. Backed by a small shared `LOCATIONS` cache loaded once per admin sign-in. **(3) Access ID defaults to the alias (Von's call: "default to alias, editable").** Issuing a doctor's Access ID now pre-fills their alias/code — the identity they already recall — and stays editable if a doctor needs a different sign-in ID. **(4) Better add experience (Von chose "both").** A **guided inline** quick-add (name first; the code **auto-suggested** from the name and editable, with a live **"✓ available / ⚠ already used"** check; the Location picker for doctors; a **unit** field for products alongside category) **and** an **"Add with preview…" dialog** for all three roster types (labeled fields, the same pickers + dup-check, and a **live preview** of how the entry will read before saving). Product `unit` is now also editable in the roster edit row (was add-only). Verified live: reassign dropdown moved a play to SATURN 33; the Location picker saved a doctor's territories; access-ID prompt pre-filled the alias; typing "Dr. Grace Lim" suggested GL and flagged THOR as taken; the dialog previewed "GL · Dr. Grace Lim · MARS 05" and saved with that Location; product unit persists. No server change, so all confidentiality proofs still hold — 15/15, 19/19, 43/43, 17/17, 10/10, 9/9, and 27/27 (`prove_assign_and_credentials`); QA green (masking 46/46, live 13/13). **Layout follow-up (same day, Von):** the roster controls were crowded, so the **inline add form was removed** (the dialog is the only add path), the **Active/Archived toggle became a compact segmented control pinned to the table's top-right**, and a **single Add button** (renamed per type: **Add Doctor / Add Location / Add Product**) sits next to Filter — applied to all three roster tables. On branch `improve/gate-location-vocab`. |
| 69 | Roster (doctors): assigned Locations shown/editable + admin-issued access ID & PIN (set/reset only) | ✅ | 2026-07-25 | Two things Von asked for on the doctor roster (some of it was in the original prototype). **(1) Assigned Locations.** The doctor roster showed only code / real name / alternate names, not the Locations each doctor covers — even though the backend already stored them (`hospitals`) and the off-assignment check (#68) reads them. Added an **Assigned locations** column (the same middle-column slot Category uses for products): the doctor's Location codes as chips, **editable** in the edit row and settable on the add form (comma-separated codes). Backend add/edit already passthrough `hospitals`, so no server change. This closes the loop with #68 — the "Add to their Locations" disposition writes here and the change shows immediately. **(2) Admin-issued sign-in credentials.** The admin issues each doctor their access — but the console couldn't see or manage it. Added, admin-only + audited: an **Access ID** (the company card the doctor signs in with) shown in a column with Issue/Reset, **unique across doctors** (409 on collision); and a **PIN** that is **SET/RESET ONLY (Von's call)** — stored **hashed** (PBKDF2, like the admin-console passwords) and **NEVER returned to the browser or written to the audit detail**. A server-side `roster_public()` sanitiser strips the PIN salt/hash (and the seed-only `_demo_pin`) from every roster entry sent to the browser and derives a `pin_set` boolean; the console shows only ✓ set / — not set with Set / Reset / Clear. New `POST /api/admin/roster/credentials` (viewer 403, unauth 401). Two demo doctors (JD28, DAWN) seed **pre-issued** so both states show. **Boundary (called out in the console):** on the local test app the doctor app still signs in with its built-in demo cards; the hosted build authenticates against these. Verified live: set/reset flips `pin_set` with no salt/hash ever in the response; duplicate ID rejected; viewer/unauth blocked; audit shows "PIN set" with no value (0 rows contain the PIN). Guarded by **`admin/proof/prove_assign_and_credentials.mjs` 27/27** (shared with #68); QA green (masking 46/46, live 13/13); prior proofs 15/15, 19/19, 43/43, 17/17, 10/10, 9/9 on a throwaway backend. `server/` + `admin/` only; doctor app untouched. On branch `improve/gate-location-vocab`. |
| 68 | Off-assignment flag stays in the Review Queue, with real dispositions + honest display | ✅ | 2026-07-25 | Von noticed a Review-Queue row (RV-27: JD28 @ NEPTUNE 21) that looked like a valid, well-masked play and asked why it was in the queue. **Diagnosis:** it was the off-assignment / territory flag (VON-04) — a Player logged a play at a Location not on their assignment list — which had landed in the temporary-code review queue with only a bare "Mark handled", **and the console printed the Location CODE (NEPTUNE 21) in the red real-name column**, so a code was dressed up as a leaked name in the one column reserved for the sanctioned real name. Not a masking bug (the play was fine); a presentation + missing-action bug. **Von's direction:** keep it in the Review Queue, but give it real actions — **Add to their Locations** (the doctor does cover it), **Deny** (they don't), **Reassign** (mistyped Location). For **Deny, Von chose to void the play too** (drops out of report totals; reversible/restorable via the Issues void, #67). **Built (server `review/dispose`, kind `location-assign`):** `assign_add` adds the Location to the doctor's assigned Locations (codes only, #69); `assign_deny` voids the matching play(s); `assign_reassign` rewrites the play(s) to the correct Location code the admin supplies (refuses an unknown code). Plays matched by `play_ids` or player+location; every disposition audited. **Console:** these rows now render a plain "Player not assigned to this Location" note instead of the red real-name styling, carry the three buttons, and the queue intro explains the two kinds of item (unknown names vs. codes-only territory flags). Verified live: add → NEPTUNE 21 added to JD28 (and shows in the roster, #69); deny → play voided; reassign → play moved PLUTO 12→SATURN 33. Guarded by **`admin/proof/prove_assign_and_credentials.mjs` 27/27**; QA green. `server/` + `admin/` only; doctor app untouched. On branch `improve/gate-location-vocab`. |
| 67 | Review-queue columns + cross-tab reference numbers + duplicate resolution | ✅ | 2026-07-24 | Three things Von raised reviewing the Review Queue and Issues tabs. **(1) Columns.** The column labelled "Temp code" actually showed the **doctor** (JD28), hiding the real temp code. Fixed: **Temp code** now shows the genuine temporary code (PEND-N / LOC-PEND-N; "—" for older demo rows that never had one), and a new **Sent by** column shows the Player who typed it, so the back office can trace and clarify with that doctor. **(2) Cross-tab reference numbers (traceability).** Every review entry now carries a reference **RV-\<id\>** and every filed play **R-\<id\>**, both **clickable to jump between the Review Queue and the Reports tab** (and the target row flashes). The durable link is server-stamped: when a play is filed under a temporary code, the server records its id on the matching pending review item (`play_ids`), so the link survives the temp code later being rewritten to a permanent one (#66). The Reports row shows a back-link to the review entry it produced; the Issues tab shows each copy's R-ref (and any RV-ref). **(3) Duplicate resolution.** The Issues tab's only action was "Mark resolved," which did nothing to the plays. Now each possible-duplicate card lists every copy with its Ref and two real actions: **Keep all — not a duplicate** (stops flagging), or **Keep this one — void the rest**. **Void is reversible (Von's call):** the set-aside copies are **kept, never deleted**, drop out of the Era report / CSV / dashboards / duplicate detection **and** the doctor's own plays/totals/streak, and are listed in a **Voided plays** section with **Restore**. New admin-only, audited `POST /api/admin/play/void` (viewer 403, unauth 401). Verified live end-to-end: temp codes + Sent by render correctly; RV-178 ↔ R-179 jump both ways; keeping one copy voided the other (audited `voided play(s) 176`), removed it from the report totals, and Restore brought it back. Guarded by **`admin/proof/prove_trace_and_void.mjs` 9/9**; QA green (masking 46/46); prior proofs 15/15, 19/19, 43/43, 17/17, backfill 10/10 on a clean backend. Applied to `admin/`, `server/`, and the doctor bundle (`app/`, `mobile/www/`, `prototype/`) for the voided-play filter. On branch `improve/gate-location-vocab`. |
| 66 | Resolving a temporary Co-Player code closes the loop on already-filed plays | ✅ | 2026-07-24 | Von asked, testing the local stack: once a temporary Co-Player code (PEND-1) is resolved in the Review Queue, does the change show up in the admin Reports tab **and** on the doctor's own "my plays" window? **It did not** — a filed play stores its Co-Player as the temp code the device minted at send time, and resolving the review item created/linked the roster entry but never went back to the plays already filed, so the ⏳ "temporary, pending review" badge on the doctor's play never cleared, and the admin report kept showing PEND-1. **Fixed — the disposition now settles the filed plays too:** on **add**/**link**, the play's `other_md` is rewritten to the **permanent code** (still a code, never a name — the doctor stays codes-only; the badge clears because it's no longer PEND-shaped) with `coplayer_status:'resolved'`; on **not real** (Von's call), the slot is marked `coplayer_status:'removed'` and reads **"— not part of the game"** — the play is **kept, never deleted**. The back-fill is **scoped to the doctor who filed under that temp code** (match on player AND the temp code), so one doctor's PEND-1 can never touch another's — verified. New status-aware `coPlayerCell()` renders the three states (pending / resolved-code / removed) on the doctor's two plays tables; the admin Reports tab + CSV mirror it. Every disposition is **audited with the count of plays updated** (`added doctor ALFA… [1 play(s) updated]`). Verified live end-to-end (doctor JD28: PEND-1→**MIGS** chip, hourglass gone; PEND-2 rejected→"not part of the game"; admin Reports shows the same). Guarded by **`admin/proof/prove_coplayer_backfill.mjs` 10/10** (rewrite; codes-not-names; cross-doctor scoping; reject keeps + marks; audited). Applied to `app/`, `mobile/www/`, `prototype/`, `server/`, `admin/`; QA green (masking 46/46), proofs 5.5 15/15, 5.6 19/19, 5.6A 43/43, requests 17/17. **Known limit (noted):** a temp code is only unique within a doctor's session, so a doctor who left two DIFFERENT unknowns unresolved across sessions could share a PEND string; the real build carries the review id on the play to disambiguate. **Scope:** Co-Player only (what Von asked); the identical pattern for an unknown **Location** (LOC-PEND on the play) is a fast follow, not built here. On branch `improve/gate-location-vocab`. |
| 65 | Roster entries can be archived (reversible), not hard-deleted | ✅ | 2026-07-24 | Von asked where the "delete a roster" function was — there wasn't one (only Add + Edit, on both the console and the server). Deleting the code→name mapping has real consequences (the app stops recognising the name; a past report's code can no longer resolve; a doctor with a sign-in card would be locked out), so **Von chose Archive (reversible)** over a permanent delete. **Built:** an `archived` flag on the roster entry; the browse list shows **active** by default with an **Archived** view (`?archived=1`), and the Add form is hidden while viewing archived; a new **admin-only, audited** `POST /api/admin/roster/archive` toggles archive/restore (viewer 403, unauth 401). **Kept deliberately:** per-lookup resolution still finds an archived entry, so a **past report's code still resolves to its name**; and there is **no hard-delete endpoint** — the code↔name history is never lost. Console: an **Archive** button per row (plain-language confirm), an **Active / Archived** toggle, **Restore** in the archived view. Verified live end-to-end (archive an entry → it leaves the active list → appears under Archived → still resolves for a past report → restore → back on the active list), both actions in the audit log (`doctor NEWMD (Dr. Newcomer) -> archived / restored`). QA green; console proofs pass on a clean backend. On branch `improve/gate-location-vocab`. |
| 64 | Review queue: "Add to roster" restored to the original console's clean-up flow (#37) | ✅ | 2026-07-24 | Von, testing the local stack, found the review queue offering only **"Mark handled"** on real entries — no way to add the typed name to the roster. **Two causes, both fixed.** **(1) A kind-mapping gap (the actual bug).** Both the console and the server mapped only the older `unknown-md` / `unknown-location` / `unknown-product` spellings to a roster table, but the doctor app sends `md` and `hospital`. An unmapped kind silently fell through to the bare "Mark handled" fallback, so every genuine unknown name a doctor typed was unresolvable — it could be dismissed but never added. Both maps now cover the kinds actually sent. **(2) The disposition itself was a `prompt()` box** asking only for a code — it could not correct the spelling the doctor typed, and there was no way to tag a Location. **Ported the original console's roster clean-up modal (#37):** the admin corrects the real name, assigns the permanent code that replaces the temporary `PEND-N` / `LOC-PEND-N`, and for a doctor ticks the Locations they cover; save validates and shows the server's error in the modal instead of an alert. The Location chips come from the **same paged, admin-only, audited endpoint the Roster tab browses with** (one capped page — never a bulk code→name dump), so no new exposure was created. **Server-side gains:** `add_new` now refuses a code already in use (was a silent no-op that still marked the item added), accepts the corrected `name` and the optional `hospitals`, and — because the admin usually corrects the spelling — keeps the **raw typed form as an alternate name** and pushes it out on the existing `/api/roster` links channel, so the same wording the doctor typed is recognised on the phone from then on (the self-growing library, #39). Verified live on the local stack: "ace medical center" → **Ace Medical Center / VENUS 12**; "dr berwyn cruz" → **Dr. Berwyn Cruz / BERWYN**, tagged NEPTUNE 21, with `alts:["dr berwyn cruz"]` and the link on the doctor-app channel; every disposition in the audit log (`added doctor BERWYN = Dr. Berwyn Cruz (+1 alt)`). Proofs 5.5 15/15, 5.6 19/19, 5.6A 43/43, requests 17/17; QA green. **Not changed:** "Link to existing" still uses a simple prompt for the target code — deliberate, out of scope. On branch `improve/gate-location-vocab`. |
| 63 | Name-verify card: a Location option + game vocabulary on every button | ✅ | 2026-07-24 | Von, testing the local stack, caught two things on the "quick check" card the app shows when a leftover word might be a real name. **(1) A Location was not offered.** The card gave only Patient / Another doctor / Not a name, so a hospital the roster doesn't know yet had no correct answer — the doctor's only safe choice was "Not a name", which sends the typed hospital name in the clear. Added a **📍 Location** option that takes the SAME escalation an unknown Co-Player already takes (invariant #3): a temporary `LOC-PEND-N` code replaces the name in place, and the typed name goes to the back-office review queue as `kind:'hospital'` — the one sanctioned real-name channel (#47). It reuses `resolvePendingAlias('hospital',…)`, `queueReview`, and `hospChip`; **no new masking logic was written.** **(2) Real-world words on a display surface** (invariant #6 — the card is rendered text a colleague can read over the doctor's shoulder). "Patient" and "Another doctor" are on the banned list. Relabelled to the vocabulary of record: **Match** (Von chose this over "Username"), **Co-Player** (matches the confirm card's CO-PLAYER row and the card's own existing "Recorded as Co-Player" reply), **Location**. The question itself changed from "is X a person's name?" to "who or what is X?" now that a non-person answer exists. Applied to all three copies (`app/`, `mobile/www/`, `prototype/`). Verified live end-to-end on the local stack: the card renders the four options; tapping Location hid "ace" as `LOC-PEND-2` on screen and the typed name arrived in the backend review queue with only its whitelisted fields. QA green — masking 46/46, all nine suites pass. On branch `improve/gate-location-vocab` (main deliberately frozen while IT stands up the test host). |
| 62 | Deploy Step 5.7: temporary test hosting — self-host vs endorse to IT | 🟡 | 2026-07-23 | The hand-off so real-phone testing (Step 6) has something to reach. **Storage gate confirmed settled first** (Step 5.7 point 0): filed reports live in the managed DB (#56) the proxy writes and the console reads — clear to hand off. **Von chose: endorse to IT** (over self-host, or producing both). IT stands it up from the repo and returns two links; Von stays almost hands-off. **Produced (docs only, no app code):** (1) **`TEST_HOSTING.md`** — a temporary-test-host instruction sheet for IT: host the `/server` proxy (Python stdlib, no install; secrets `ANTHROPIC_API_KEY` + `IRIS_ADMIN_USERS`; `DATA_DB` on a persistent volume; HTTPS; `/health` check) → **Proxy URL**; host `admin/index.html` (single static file) pointed at the proxy via `?base=` / `window.IRIS_BACKEND_BASE` → **Admin console URL**; set `mobile/www/config.js` `IRIS_PROXY_BASE` to the proxy URL for the Step 6 build; plus the update loop and a done-checklist. (2) **`HOST_ENDORSEMENT.md`** — the plain note Von forwards to IT. **Two hard rules carried through both docs:** the API key is generated/held by IT and **never** sent in a message or the repo (a dedicated spending-capped test key); and **`IRIS_ADMIN_USERS` must be set with real accounts before the console is reachable** so the committed demo logins stop working (the last pre-doctor item). **Only the two links come back — never the key.** Temporary test host, not production (that's Step 7). Note: `server/README.md` still describes the original narrow proxy scope (AI call + review only); `TEST_HOSTING.md` is authoritative and notes this. **Status 🟡 pending the external half:** IT stands it up and returns the proxy + admin URLs, which unblock Step 6. On branch `deploy/step5.7-hosting-handoff` (docs; not merged — stopped per Von). |
| 61 | Requests queue — build a real one (Von's review before Step 5.7) | ✅ | 2026-07-23 | The standalone console's Requests tab was a locked placeholder (demo-only in the prototype). **Von: build a real queue = BOTH** — doctor-submitted requests AND admin back-office to-dos, in one tab. Doctor request **categories = two**: "correct/cancel a play" and "supply follow-up" (Von's pick; no general/roster categories). **Built end-to-end, confidentiality-first:** **(1) Doctor app (new "Raise a request" 📩 drawer, mirrors Feedback):** the doctor picks a category and types plain language; the text is masked **ON-DEVICE** by the same engine as a message (`maskRequestText`: extractMatch loop → maskText → gamifyWords → placeholder codes) — known Avatars/Locations become codes, and if a possible real **name** or an **unknown** entity is left over the send is **BLOCKED** with a nudge to rephrase, so a real name never leaves the device (unlike the Feedback channel, P2). Resolved requests are read back on sign-in ("📩 Update on your …", banned-word filtered) like the Stock loop. **(2) Backend (`server/proxy.py`):** `POST /api/requests` is OPEN (like `/api/stock`) but the whitelist drops any smuggled field and the server forces `source=doctor`/`status=open` and strips the admin-only resolution fields (a doctor can't set `resolvedBy`/status); `GET /api/requests` open (codes only + masked text) so the app reads back its resolutions; `POST /api/admin/request/{resolve,create}` are **admin-only + audited** (resolve writes the note the doctor reads back; create adds a to-do). **(3) Console:** Requests tab unlocked — real queue with source badges (Doctor/To-do), category, masked text + codes ref, filter, a "New to-do" composer, resolve-and-notify per row; viewer stays codes-only. **Reconcile stays a labeled placeholder** (needs the Data Team pull-through file; not a permissions lock — Von: keep as placeholder). **Verified live end-to-end** (doctor→admin→doctor): "any update on Kenzolin at Makati Med?" → stored as "PRAZ at NEPTUNE 21" (no real name), admin resolved → doctor saw "📩 Update on your supply follow-up: Restocked — delivery Friday"; a request naming a patient/unknown hospital was blocked on-device. **Proofs/tests:** `admin/proof/prove_requests.mjs` 17/17 (open doctor file; whitelist drops patient/secret/resolvedBy; admin-only resolve/create with unauth 401 / viewer 403; audited); `tests/request_masking.mjs` 6/6 (known→codes, lone name blocked, unknown blocked); QA green (46/46) + 5.6a proof 43/43. Doctor-app change applied to prototype + app copy + shipping mobile bundle. On branch `deploy/step5.6b-attachments-ocr`. **Boundary:** an unknown hospital/product in a request is blocked (strict-safe) rather than routed to the review channel — a real-build refinement. |
| 60 | Deploy Step 5.6B: attachments read for real (on-device OCR + native Excel/Word) | ✅ | 2026-07-23 | Made attachment reading real, replacing the prototype's stand-ins (photo = "type what it says"; Office files = "export to CSV"). **This CHANGES the doctor app.** **Two readers, both on-device:** (1) **Excel (.xlsx) / Word (.docx)** are ZIP packages — unzipped and text-extracted **inside the web view** with the browser's built-in `DecompressionStream` (zero third-party library, nothing uploaded); `.xlsx` → the first sheet rebuilt as a tab grid (shared + inline strings resolved), `.docx` → paragraph text; older binary `.xls`/`.doc` (OLE) are politely refused (re-save as .xlsx/.docx or CSV). (2) **Photo OCR** via a new committed Capacitor plugin `mobile/plugins/iris-ocr` — **Apple Vision** on iOS, **Google ML Kit** on Android — the image is read on-device and **never uploaded** (invariant #8); it pre-fills the existing confirm box (the human-in-the-loop tap-to-tag step stays), and in a plain browser (no native plugin) the doctor types the text — identical downstream. **THE CRITICAL RULE (met):** every reader funnels its extracted text through ONE choke point (`ingestExtractedText`) into the **existing** on-device masking/name-gate before anything else — a clean table → CODES via `rowToReport`; anything else → the message box → normal masked send (`handleSend` → net-3 gate). Raw text/image never leaves the device unmasked. **Masking engine REUSED, not touched** (House Rule 2) — the engine block is byte-for-byte unchanged, so the 46 name-hiding checks are unaffected. **Architecture note:** native OCR ships as committed plugin source (Swift/Vision + Kotlin/ML Kit) wired via `mobile/package.json` (`file:./plugins/iris-ocr`); it compiles on IT's machine during `npx cap sync` — same boundary as Step 4 (native projects build on IT's toolchain), so the native OCR is code-reviewed here but device-verified by IT. **Verified:** new Node regression `tests/attachment_masking.mjs` (11/11) builds a real .xlsx and .docx, reads them with the shipping reader, and asserts extraction + coded-routing + that extracted free-text is masked/gated; **QA green** (46/46 masking, full suite). **Verified live in the web view** (served the shipping `mobile/www` bundle, signed in JD28): a real .xlsx filed as codes only (`SATURN 33 · MS-52 · PRAZ6` / `JUPITER 08 · JR-60 · OTEM4`) with **no file upload** and **no real name in the rendered DOM**; a real .docx's text routed to the box and masked on send to "gave 6 PRAZ to MS 52 at SATURN 33". Applied to the shipping bundle + the `app/` copy + the reference `prototype` (identical web change). On branch `deploy/step5.6b-attachments-ocr`. **Stopped per Von — not continuing to Step 5.7.** |
| 59 | Admin roster: paginated browsing (vs search-only) | ✅ | 2026-07-23 | The Step 5.6A roster tab showed **nothing until you searched** — the strictest possible posture (the browser only ever received names you explicitly searched for), but real friction for back-office staff who need to see what exists. **Changed to paginated browsing:** opening the Roster tab loads the first page from the server (50 per page), with next/previous, sortable Code/Real-name columns, and the old search kept as a **filter on top**; applies to Doctors, Hospitals, Products. **Protection preserved:** a new admin-only `GET /api/admin/roster/list` returns exactly ONE page, **hard-capped server-side at `ROSTER_PAGE_SIZE`=50** — a client cannot request more, and an out-of-range page clamps to the last page, so the full code→name table can never come back in a single response (there is no dump-all endpoint; `/api/roster` still exposes only alternate-name links). Each page fetched on demand; the browser holds only the current page. **Every page view is audited** just like a name reveal — `roster_browse` records the rtype, page, the exact row **range**, the total, and the filter/sort — so browsing is as attributable as searching. Viewers are unaffected (codes only; `list` is admin-only, viewer 403). **Tradeoff accepted:** browsing surfaces up to a page of real names at once (and over a session, page by page, potentially the whole roster) where search-only surfaced only matched names — a deliberate, bounded relaxation for usability, mitigated by the hard per-response cap, admin-role gating, and a complete per-page audit trail. Supersedes the search-only behavior in [[#58]]. Confidentiality proof updated to assert the new rule (paged access allowed, bulk dump blocked, browse audited): `admin/proof/prove_step5_6a.mjs` 35/35; QA green (46/46); doctor app untouched. |
| 58 | Deploy Step 5.6A: admin write-back + roster + per-lookup name resolution | ✅ | 2026-07-23 | Restored the back office's ability to WORK the queues (the standalone console started read-only in 5.5; PROTOTYPE_VS_BUILD deferred list). **Every capability is admin-only and audit-logged, behind the Step 5.6 sign-in** (builds on [[#57]]). **Roster:** held server-side in the managed DB (kind `roster_entry`), **seeded from the prototype's DEMO roster** (fake doctors/hospitals/products — explicitly NOT real Bell-Kenz data). Managed via bounded **search / add / edit** (`/api/admin/roster/*`). **Per-lookup name resolution** (`POST /api/admin/resolve`, one code → one name, audited) powers "Reveal names" in Stock; the whole code→name table is **never** shipped to the browser — an empty search returns nothing, and `/api/roster` still exposes only alternate-name links (no roster map). This is how "roster management" (needs to see names) coexists with #57's "never bulk-download": management is search-driven, resolution is per-lookup, both audited. **Write-back actions:** review dispositions — add-to-roster, **link-to-existing** (self-growing alternate-name library, propagated to the doctor app via the existing `/api/roster` links channel so no doctor-app change is needed), reject, late-entry accept/roll, product-noted; **stock resolve-and-notify** (writes the resolution the doctor app already reads back); mark feedback reviewed; mark a duplicate issue resolved; **lock an Era**. **Roles unchanged:** viewer stays codes-only (names redacted server-side); writes require the admin role. **Invariants intact:** roster never ships to a doctor's phone; no real name ever reaches the LLM; the codes-only plays pipeline stays name-free (the sanctioned admin resolution is a separate, audited, per-lookup channel — QA confidentiality test wording refined to say so, not deleted). **Doctor app UNTOUCHED.** Verified live + `admin/proof/prove_step5_6a.mjs` 28/28; QA green (46/46). |
| 57 | Deploy Step 5.6: admin console auth + role model | ✅ | 2026-07-23 | The standalone admin console had **no login** (relied on host-level access) — not acceptable once it holds the roster and resolves real names (PROTOTYPE_VS_BUILD B2). Built a real front door BEFORE those powers (Steps 5.6A onward). **Auth:** username/password, salted **PBKDF2-SHA256** (200k rounds; passwords stored only as hashes, never in the clear), in-memory session tokens via Bearer header; the console renders nothing until signed in. **Roles (enforced SERVER-SIDE, not browser-hidden):** `viewer` = codes only — the review-queue typed **name** and feedback **text** are replaced with `🔒 admin only` by the server before the response leaves; `admin` = receives the sanctioned real names and an **Audit log** tab. **Audit log** = append-only table in the managed DB recording sign-in / login_failed / logout and every real-name **reveal** (actor + timestamp + what); admin-only to read. **No bulk roster download** — a name is resolved per-lookup server-side (the per-lookup resolution itself lands in 5.6A); no endpoint ships the code→name table. **Users** come from `IRIS_ADMIN_USERS` env (`user:pass:role,...`); if unset, demo `viewer`/`admin` are seeded with a printed warning to change them. **Endpoint split (keeps the doctor app untouched, House Rule 5):** the doctor-app endpoints (codes-only reads `/api/plays` `/api/stock` `/api/roster`, and all writes) stay OPEN and unchanged; only the console-only, real-name endpoints (`/api/review` `/api/feedback` `/api/ai_usage`) and the new `/api/admin/*` require a session. Verified live + `admin/proof/prove_step5_6.mjs` 19/19; QA green (46/46). |
| 56 | Deploy Step 5.5 point 1: where filed reports get stored (server-side) | ✅ | 2026-07-23 | **Von: a managed database.** Step 5.5 requires a real store the standalone admin console can read; the Step 2 proxy (`server/proxy.py`) only forwards the AI call and persists the whitelisted review channel to a local file — it does **not** persist filed plays, stock alerts, or feedback anywhere the console could read (confirmed in code: endpoints are `/api/messages` + `/api/review` only; filed reports live solely in the session-only local harness, wiped each session). Options surfaced to Von: (a) **managed database** — a standard hosted DB (e.g. the host's Postgres); the proxy writes each codes-only report to it and the console reads from it; durable, backed up, scales straight into production; (b) ready-made backend service (Firebase/Supabase) — fastest, own dashboards, but adds a vendor to clear with legal/DPA; (c) simple file on the proxy host — cheapest, matches the review-store pattern, but single-machine, no real backups, replace before rollout; (d) cheap-now-file / real-DB-later — file store for the Step 6 internal test then swap to a managed DB. **Von chose (a) — build it once, the real answer.** Constraint on the build (unchanged): everything stored stays **codes-only** (invariant #1); the ONLY sanctioned real-name fields are the existing back-office review-queue whitelist (DECISIONS #47). This realizes the "one shared backend" of DECISIONS #29 for production: the doctor app sends codes-only → the proxy/data-service writes them to the managed DB → the separate admin console reads them via an authenticated read path. Resolves blocker #8; unblocks the rest of Step 5.5 (build the standalone `/admin` tool wired to this store) and Step 5.7 (hosting the proxy + this DB). **Built 2026-07-23 (rest of Step 5.5):** the managed DB is `server/store.py` — a **SQLite local instance** (zero-dependency, Python stdlib; production points the same data pipeline at the hosted DB, same row shapes). The Step 2 proxy now persists codes-only plays/stock/feedback + the whitelisted review channel and serves them on GET endpoints; every report write is **field-whitelisted server-side** (play `items` reduced to `{code,qty}`), so a real name can't be stored on a codes-only report. The standalone `admin/index.html` reads them **as codes only** (no code→name lookup; real names only in the review-queue + feedback whitelisted fields). Proof `admin/proof/prove_step5_5.mjs` 15/15 + visual browser confirmation; masking suite 46/46. |
| 55 | Deploy Step 5: QA suite + surname-first name leak (fix vs record) | ✅ | 2026-07-23 | Built the full automated QA suite (`tests/qa.mjs`: no-report-lost, masking-holds fuzz, end-to-end, stress, speed; reuses the 46-case masking regression + live e2e; reads the real engine/queue from the shipping app; re-runs on every app/test edit via the project hook). The masking-holds fuzz **caught a real, pre-existing patient-name leak**: writing a patient **surname-first with a comma** ("Dela Cruz, Roberto, 40") masks the surname but leaves the given name "Roberto" in the clear — identical in the prototype and shipping app, so NOT a deploy regression. **Von's call: fix the leak first** (chosen over recording it as a known gap). Fix: the net-3 verify gate (`detectPossibleNames`) now also flags a name-like word sitting immediately after an already-extracted patient placeholder (`⟦PX:…⟧`) — the "SURNAME, Given" residue — so the doctor verifies it (blocked → replaced) before send. Never mis-masks (only asks); normal plays don't newly gate; masking suite still 46/46; applied to prototype + app + mobile bundle + a regression case. **Residual (still 🟡):** an *all-caps single surname* after a preposition ("for SANTOS, Maria") can still leak (the all-caps token is read as a code by the gate and isn't extracted). Tracked as a KNOWN GAP the suite prints every run; recommend a dedicated comprehensive surname-first masking task before pilot. Ties to blocker #4 (residual extraction gap). **RESOLVED 2026-07-23 (Von: fix it fully before Step 5.7 hosting; chose "auto-convert + ask backstop").** Root cause: the net-3 gate skipped **every** ALL-CAPS token as code-like (`/^[A-Z]+$/`), so a real all-caps surname ("SANTOS") in patient position was mistaken for a code and never masked/asked. Two-part fix, all on-device, masking engine extended (not the untouched golden path — the age/preposition nets are unchanged): **(1) Auto-convert** — new `extractSurnameFirst` recognizes the surname-first patient shape "SURNAME, Given[,] age" both **preposition-led** ("for SANTOS, Maria, 33" / "to Dela Cruz, Roberto 40") and **no-preposition at a clause start** ("SANTOS, Maria, 52 …", disambiguated by the second comma), folding BOTH names into one Match code on-device; guarded by a `nameLikeWord` check (rejects stopwords, products, Locations, codes) so a Location/number clause is never swallowed. **(2) Ask backstop** — the net-3 gate now skips only a **genuine** code (`isCodeLikeToken`: has a digit, a temp marker PEND/PX/LOC, a common ALL-CAPS medical abbreviation IV/IM/MG/STAT…, a known product/Location code, or ≤3-letter initials) — an all-caps surname is no longer skipped — and adds a `beforePatient` rule (an unmasked name-like word immediately before a `⟦PX⟧` placeholder = surname-first residue) so anything auto-convert misses is asked. Added a placeholder guard (never flag `⟦PX…⟧` itself) and common post-patient verbs (got/had/received/took…) to the skip list to avoid new nagging. **Verified:** masking suite **46/46** (no regression) + masking-holds **109** (was 106 — the previously-tracked gap case plus all-caps two-word surnames and the no-preposition form now assert the SURNAME is handled, and the KNOWN-GAP block was removed); full QA green; adversarial probe shows normal plays gain no false flags. **Verified live** (JD28): "2 prosec for SANTOS, Maria, 33" → on-screen "2 OTEM for SM-33", nothing real in the DOM. Applied to prototype + app copy + shipping mobile bundle. Blocker #4 (lone-name/surname residual extraction gap) is now closed. On branch `deploy/step5.6b-attachments-ocr`. |
| 54 | Deploy: store listing — update existing IRIS vs new listing | ✅ | 2026-07-22 | **Von: brand-new listing.** The deployment build ships as a **separate new app** on both stores, not an update to the existing (form-based) IRIS listing. Clean break from the old app. Consequence: existing doctors install a new app (staged rollout, keep old IRIS as fallback during the pilot per DEPLOY_PROMPTS), and the **app name must be settled before submission** (ties to open decision #22). Drives Step 7 (IT runbook → new-listing submission). Asked + answered at Step 1 of the deploy plan (`DEPLOY_PLAN.md`). |
| 53 | Deploy: admin console split from the doctor phone app | ✅ | 2026-07-22 | **Von: split it out.** The doctor phone app will carry only the chat UI + the on-device masking engine; the **admin console becomes a separate desktop web app** on the same backend (confirms the architecture already set in #28). Rationale: keeps real-name roster data + admin logic off doctors' phones (invariant #1), shrinks the shipped mobile app, cuts along a seam the code already has (the engine block is self-contained — the tests lift it by anchors). Implementation path: extract the masking engine into a shared module; doctor app imports it, no `CARD_CREDENTIALS`/roster; Step 4 wraps the **doctor app only**. Asked + answered at Step 1 of the deploy plan (`DEPLOY_PLAN.md`). |
| 52 | AI cost dashboard — real-time spend monitoring in the admin console | ✅ | 2026-07-23 | Von (COO) asked for a tab in the admin console to see, track, and monitor AI cost in real time. **Where the number comes from:** AI cost is token usage × per-model price, and the only place that sees every call's usage is the backend proxy (it holds the key). So the metering lives there — the harness `/api/messages` proxy now parses each Anthropic response and records the call's `usage` (input / output / cache-read / cache-write) + model + timestamp to an `ai_usage` log (`GET /api/ai_usage`); the hosted build meters at the same point. This is the reference for the token-cost controls in open item #5 / DECISIONS #30. **The tab (new "AI Cost", after Dashboards):** live spend this session, average cost per message, tokens in/out, **cache savings** (what cached reads would have cost at full price) with the % of input served from cache, a by-model breakdown, and a **projection to full scale** — scales the live average to 1,000 doctors × 1.5 reports/day × 4 messages ≈ 180,000 messages/month, and re-prices the *same observed usage* at Sonnet vs Haiku rates so the model-choice tradeoff (DECISIONS #50) is quantified from live data, not a guess. **Verified live:** two real app calls metered end-to-end — first call wrote the ~3.5K-token rulebook to cache, second call read it back (91% of input served from cache, ~10% price), session spend \$0.0178; projection read ~\$19.2K/yr at Sonnet and ~\$6.4K/yr at Haiku — landing squarely on the earlier hand estimate (Sonnet ~\$15–20K, Haiku ~\$5–8K) and confirming caching works. **Currency + projection refinement (Von, same day):** figures are shown in **pesos** at an **indicative FX rate** the admin can adjust in the tab (default ₱58/$1, clearly marked indicative — Anthropic bills USD). The projection is now **two side-by-side scenarios**: (1) **at current users** — the doctors on the roster now (with an "active this Era" sublabel), and (2) **at a target of N users** where N is a **manual input** (default 1,000). Both re-price the same live usage at Sonnet vs Haiku. Verified live: ₱ throughout, and editing the rate or the headcount recomputes every figure (e.g. 6 current users ≈ ₱6.7K/yr; 1,000 users ≈ ₱1.12M/yr Sonnet / ₱372K/yr Haiku at ₱58). **Analytics added (Von, same day):** (1) **cost-over-time trend** — an adaptive bar chart (buckets by day / hour / minute depending on span) so spend can be watched through time; (2) **"where the cost is coming from"** — three codes-only breakdowns, **by doctor (Player), by Location, and by function** (Log a play / Clarify round-trip / Stock alert / Q&A / …), each as a ranked bar with share of total; the by-function view separates productive logging spend from clarify-round-trip overhead. To attribute cost, metering moved from the proxy to the **client**, which tags each call with the Player alias, Location code, and function (all codes — invariant #1 holds); the proxy just forwards, and the hosted build meters at the authenticated proxy with the same context. (3) **Caching-efficiency answer in-product** (Von's question): a callout shows the cache hit rate and explains that caching is a structural saving already on — it gets *more efficient with volume* (steady traffic keeps the shared instruction sheet warm, so more calls read it cheaply and fewer pay to re-write it), trending toward a floor; the doctor's words + AI replies don't get cheaper, and the bigger future levers are fewer clarify round-trips and a cheaper model. Verified live: 4 calls across 2 doctors / 3 functions / 3 Locations attributed correctly, 75% cache reuse, trend + all three breakdowns rendered. **Trims (Von):** removed the caching explainer box from the tab (kept the cache-savings tile); the caching answer lives in the session record above, not the dashboard. **Model is a single constant:** the app's model was extracted from `callAI` into one named `AI_MODEL` constant (`claude-sonnet-4-6`) — switching to Haiku is a one-line change; the dashboard's model label is NOT hardcoded (it reads the model back from each call and prices it), so it auto-reflects the switch. **Action-driving upgrades (Von asked to build all 5):** (1) **Monthly budget + alert** — an admin-set budget with a green/amber/red status bar (spend-to-date + projected month-end vs budget); flips to red "Over budget" when the run-rate projects an overrun. (2) **Unit economics** — cost per active doctor and cost per report (a report ≈ several messages), the numbers you actually budget against. (3) **Clarify overhead** — the % of spend going to back-and-forth clarify turns, with the peso/yr saved by halving it at target scale (ties the on-device-extraction roadmap to money). (4) **Haiku saving lever** — a one-line "Haiku −₱X/yr (−67%)" so the model decision is a glance. (5) **Period selector** — All / Today / 7 days / 30 days / This month filtering every figure. Topped with an **executive health strip** (budget status + biggest saving lever + clarify overhead) so the "are we fine, and if not what's the one thing to do" answer is a two-second glance. Verified live: over-budget flips red, the period control re-labels and filters, and all levers compute. **Boundary:** budget + real periods need persisted cost history — the demo log resets per session (the clock for period/budget uses the real browser date); the hosted build keeps history so windows span weeks/months. **Boundaries:** the harness log is per-session (resets each run); the hosted build persists it and can add per-day trends, budgets/alerts, and per-doctor breakdowns. The projection is an estimate from a small live sample — real cost is lower once caching warms across many doctors sharing one cached rulebook. **Ported to the standalone admin console 2026-07-23 (Von's review before Step 5.7):** the console's AI Cost tab was a reduced codes-only core (tiles + 3 breakdowns); it now carries the FULL prototype dashboard — period selector, monthly budget + red/amber/green status, unit economics (cost per active doctor / per report), by-model table, cost-over-time trend (adaptive day/hour/minute buckets), codes-only attribution by Player/Location/function, and the Sonnet-vs-Haiku full-scale projection. Reads the backend's codes-only `ai_usage` log; **still codes only, no real name** (verified live). To make the time-based views (period/trend/budget) work regardless of client, the proxy now stamps a server-side receive time (`when`) on each usage record. Verified live signed in as admin (period filter 8→2 calls; a ₱5 budget flips to "Over budget"); QA green (46/46), admin proof 43/43. |
| 51 | Product out-of-stock reporting — separate admin Stock tab + notify-back loop | ✅ | 2026-07-22 | Von (COO) asked for the app to accept a doctor's report that a product is out of stock at a hospital, respectfully, and for the report to land in the admin console where the team resolves it and pushes an update back to the reporting doctor. **Placement decision:** a **separate "Stock" tab**, not a table under Concerns/Issues (Von's pick, recommended) — the alert has a two-way life cycle (report → resolve → notify) that the one-way Issues area doesn't, and a different team (supply chain) works its own queue. **Design (rides the existing on-device masking, invariant #1):** the doctor types plain language ("Prosec is out of stock at Makati Med"); the on-device masker converts the Avatar and Location to codes before anything leaves the phone, so the AI only ever sees "OTEM out of stock at NEPTUNE 21" and classifies it via a new `stock_report` action — never a play (no units/patient/Era crediting). A respectful "Supply alert" confirm card ("Send to team"/"Cancel") posts codes-only to `/api/stock`. The admin **Stock tab** maps the codes back to real product/hospital names for the supply team, and "Resolve & notify doctor" writes a short update onto the alert (keyed by the Player alias). On next sign-in the doctor sees "📦 Update on your supply alert — OTEM at NEPTUNE 21: …" (Avatar/Location as codes; the admin's free-text note is run through the banned-word output filter, invariant #6). **Verified live end-to-end (doctor→admin→doctor):** DAWN's typed report masked correctly in the bubble (no "Prosec"/"Makati" on screen), the assistant replied courteously, the backend stored codes-only (DAWN·OTEM·NEPTUNE 21), the Stock tab showed it as Prosec·Makati Medical Center·1 open, resolve pushed "Restocked — delivery Friday", and DAWN saw the update on sign-in. Guarded by a new e2e sample (`stock-out-of-stock`, leak-guarded) + `stock_report` added as a terminal action; full e2e **13/13**, masking **43/43**. **Boundaries (real build):** offline OS reports aren't queued yet (the confirm needs the backend); the admin's free-text update is filtered for banned category words but a real product/hospital name typed into the note isn't caught on-device — the hosted build runs the same server-side output filter (invariant #6). |
| 50 | Pilot model choice — Haiku vs Sonnet (cost + capability) | 🟡 | 2026-07-22 | Von (COO) is considering **Claude Haiku** for the hosted build to cut cost, and asked (a) the AI-token cost at 1,000 doctors and (b) whether Haiku can handle it. **Cost (with prompt caching already prototyped, DECISIONS #30):** priced against current rates — Haiku $1 in / $5 out per 1M tokens (cached instruction sheet ~$0.10/1M read); Sonnet 3× that ($3 / $15). Model = 1,000 doctors × 1–2 reports/day, ~4 assistant messages/report. **Haiku ≈ $400–$700/mo (~$5–8K/yr)**; **Sonnet ≈ $1,200–$1,900/mo (~$15–20K/yr)** — the $15–20K is *after* caching; caching discounts only the repeated instruction sheet (takes Sonnet from ~$22–30K → ~$15–20K), not the doctor's words or the AI's replies (output dominates on Sonnet). The lever to go lower is the model, not more caching. **The system was tested/built on Sonnet (claude-sonnet-4-6)** — prototype `callAI` and the harness both used it. **Live Haiku evaluation (2026-07-22):** ran the full sample library + a live doctor→admin session on `claude-haiku-4-5`. **Safety held perfectly** (masking is on-device, model-independent): masking suite **43/43**, and across every case **no real name leaked, no banned word on screen**. Everyday reporting worked live end-to-end (single play, shared/referral co-play → THOR, 2-play batch) — 4 plays reached the admin Reports as codes (4 lines/18u) and the unrecognized "Dr. Ken" short form correctly escalated to the back-office Review queue (PEND-1). **Two rough edges vs Sonnet, neither a leak:** (1) back-dating — Haiku asks one extra confirming question before logging ("which month is 'last month'?") where Sonnet logs directly (repeatable; the one sample-library FAIL, 11/12); (2) once re-asked for a patient age when the age was crammed onto the same line as "just me." Both are conversational, fixable with a small system-prompt nudge. **Direction:** Haiku is viable for the pilot and confirms the ~$5–8K/yr path; recommend a small prompt polish (back-dating + age-in-one-line) + a clean re-run before committing. Not yet decided — Von to choose Haiku (cheaper, slightly more back-and-forth) vs Sonnet (tested, ~3× cost). Harness runs the app at `localhost:8765`; `tests/e2e_plays.mjs` model is now env-selectable (`IRIS_MODEL`, defaults to sonnet). |
| 1 | Platform for doctor reporting | ✅ | 2026-07-06 | Standalone mobile app replacing IRIS. Full control of experience & security. |
| 2 | Where masking happens | ✅ | 2026-07-06 | Hybrid: deterministic on-device masking; server LLM sees masked text only. Preserves "company never receives patient identity" posture with full LLM parsing power. (Brief §4) |
| 3 | V1 scope | ✅ | 2026-07-06 | Capture + Query + Proactive nudges. Nudges attack the late-submission cascade. |
| 4 | Gamification | ✅ | 2026-07-06 | Two-sided: input = plain language/real names; display = full game vocabulary as a confidentiality (shoulder-surfing) layer + streaks/progress. Supersedes earlier "light gamification, no vocabulary" direction. (Brief §4a) |
| 5 | Back-office depth | ✅ | 2026-07-06 | Full pipeline incl. crediting engine — deterministic code only, parallel-run 1 cycle before go-live. (Brief §7) |
| 6 | PO / Area Sales data entry | ✅ | 2026-07-06 | Data Team monthly file upload with strict ingest validation. |
| 7 | Build path | ✅ | 2026-07-06 | React Native/Expo; COO prototypes in Claude Code, hands off to IT. |
| 8 | Photo input confidentiality | ✅ | 2026-07-06 | On-device OCR only; image in memory only, never stored/uploaded; no server-side fallback. Patient name in OCR text = tap-to-tag (human-in-the-loop). (Brief §4, v1.1) |
| 9 | Masking preview strip | ✅ | 2026-07-06 | Removed — masking is silent; chips appear only in the sent bubble. Preview duplicated content and explained the mechanism to onlookers. **Extended 2026-07-09 (tester VON-02):** this "never explain the mechanism on-screen" rule governs ALL doctor-facing copy, not just the preview strip. The welcome message and the input placeholder used to say the app converts real names to codes — reworded to game vocabulary only ("Tell me about your latest play in your own words"; "Type your play in your own words…"). **Resolved 2026-07-09:** first reworded the login shield line to a mechanism-free promise, then Von decided to **remove it entirely** — the sign-in screen no longer carries any confidentiality line. Also trimmed the login subtitle from "Sign in with your company-issued IRIS card. Your real name never leaves this screen." to just "Sign in with your company-issued IRIS card." Net rule: the sign-in screen stays minimal and says nothing about how confidentiality is handled. (A small "Guardian shield on" badge remains in the in-app header post-login — kept for now.) |
| 10 | Patient free-text handling | ✅ | 2026-07-06 | Silent in-place conversion via two deterministic nets (typo/Taglish age net + no-age name net). Supersedes: hard block → one-tap convert → silent. "Convert, don't reject." (Brief §4b) |
| 11 | Shared-surname ambiguity | ✅ | 2026-07-06 | Resolved by context, not dictionary: MD masks only on full name or honorific; lone surname = patient. No bare-surname alias variants ever. (Brief §4b-4) |
| 12 | AI role assumptions | ✅ | 2026-07-06 | Never-assume + logical-inference rules: alias appearing ≠ Co-Player; "just me" resolves the whole role chain. (Brief §4c) |
| 13 | Trusting LLM confirm payloads | ✅ | 2026-07-06 | Never. Client-side schema validation + normalization + one-shot auto-repair; card always renders buttons + ⚠ missing markers. (Brief §4d) |
| 14 | Confirmation UX | ✅ | 2026-07-06 | Confirm Game Play table (deck-familiar rows) + UNITS THIS PLAY sub-table (AVATAR/I/O/TOTAL) + Confirm & Submit posting "Confirmed ✓" as consent trail. |
| 15 | Pilot data policy | ✅ | 2026-07-06 | Pilot uses FICTIONAL patients only — no real patient data during stress-testing. (PILOT_PLAN §3) |
| 16 | Hosting for doctor pilot | 🔴 | — | Prototype AI bridge is Claude.ai-only. IT to deploy static page + backend proxy holding the API key (PILOT_PLAN §5). Blocks Wave 1. |
| 17 | DPA / legal posture confirmation | ✅ | 2026-07-22 | **Resolved (Von): legal/DPA posture is confirmed.** The on-device masking + alias-table approach is cleared for the pilot — no longer a Wave 1 blocker. (Brief §9-6) |
| 18 | Auth model (KASS creds + OTP vs alternatives) | ✅ | 2026-07-06 | Company-issued PIN/ID card (team, e.g. KASS, issues it — Von's team controls exactly who has access). Log in once per phone, then stay logged in — matches normal app behavior, fastest for daily use. **Implemented in prototype 2026-07-07:** card-ID + PIN sign-in gate in front of the app; a `role` on the card routes doctors to the chat and admins to the admin console; the signed-in card drives the Player alias (header badge + AI prompt). "Keep me signed in" is visual-only in the prototype (real per-phone persistence needs the hosted build; no on-device storage allowed in the artifact). |
| 19 | Multi-MD shift/referral dialogue patterns | 🔴 | — | Needs 3–5 worked examples with Data Team before parser handles share-matrix cases. (Brief §9-2) |
| 20 | Banned-word filter list incl. Tagalog | 🟡 | — | Finalize with sales/compliance. (Brief §9-9) **First slice built 2026-07-09 (tester VON-03):** on-device, typo-tolerant substitution of the two most common category families — hospital/clinic → Location, doctor/physician/md → Player — applied to the doctor's own message bubble AND the text sent to the AI, so the screen speaks game vocabulary only (invariant #6). `gamifyWords()` in the prototype; guarded by the automated checks. The full banned-word output filter (all real-world words, Tagalog, error messages, notifications) is still open and needs sales/compliance sign-off. |
| 21 | Residual extraction gap (lone name, no age/preposition) | 🟡 | 2026-07-06 | Accepted for prototype; Expo adds on-device name detector as net 3; measure miss rate in pilot. (Brief §9-10) |
| 22 | App name: IRIS 2.0 vs rebrand | 🔴 | — | Open. (Brief §9-7) |
| 23 | Repo location for IRIS Chat | ✅ | 2026-07-06 | New standalone private repo `iris-chat` (not a subfolder of Ai-PM-Project) — keeps history, issues, and IT handoff clean and independent. Setup in `CLAUDE_CODE_SETUP.md`. Live at `github.com/vsguarino/iris-chat`. |
| 24 | GitHub auth on this Mac | ✅ | 2026-07-06 | Fine-grained PAT (repo-scoped to `iris-chat`, Contents read/write) stored in macOS keychain via `credential.helper=osxkeychain`. Chosen after the SSH-key add form wouldn't submit in-browser. An ed25519 key was generated at `~/.ssh/id_ed25519` but is unused/not registered on GitHub — can be deleted or registered later if switching to SSH. |
| 25 | Showing co-player credit in a doctor's own report | ✅ | 2026-07-06 | Two separate views: a doctor's own logged plays stay in one clean list, untouched; a second, separate section/tab shows "credited to me by others" for plays where another doctor tagged them as co-player. |
| 26 | Screenshot leak-tracing watermark | 🟡 | 2026-07-06 | Von wants a hidden trace code so IT can identify who leaked a screenshot. Direction: faint, invisible-to-the-doctor pattern tied to the session, logged server-side only, never shown or transmitted elsewhere — must not become a second way to identify a doctor (would clash with the confidentiality-first design). Real-build item; not needed for the demo since there's no server/account yet. **Sanctioned anti-leak approach** (see #27). **Implemented in prototype 2026-07-07** as a faint, tiled, diagonal code (alias·YYMMDD, e.g. `JD28·260706`) applied on-device after sign-in, carrying the CODE only (never a real name). Note: the demo mark is *faint-but-visible* rather than fully invisible — simpler and still leak-traceable; open question for production is whether to keep visible-subtle or move to a truly invisible embed. |
| 28 | Admin console platform (mobile vs desktop web) | ✅ | 2026-07-07 | Admin console is a **separate desktop web app**, not part of the mobile app. Admin staff work at computers all day on wide-screen tasks (roster tables, dashboards, reconciling, downloading reports); the doctor app stays mobile. Benefits: fits the work, reinforces security (real names live only in the admin web tool on managed office machines, never on a doctor's phone), simpler to build/maintain than a mobile admin, clean role separation. Trade-off: two front-ends instead of one, sharing the same backend. Prototype's admin console reformatted to full-window desktop layout (sidebar nav, multi-column) 2026-07-07. **Made responsive 2026-07-09 (tester VON-05):** at desktop width (≥820px) it keeps the left sidebar + wide multi-column content; below that the sidebar collapses to a horizontal top tab-bar and content takes the full window, and wide tables scroll inside their card (overflow-x) instead of clipping the page — so it no longer looks cramped when the window isn't full-desktop width. **Full roster CRUD 2026-07-09 (tester VON-06):** the Roster (MDs, Hospitals, Products) and Doctor↔Location assignment tables now support add / edit / delete behind an "✎ Edit ⇄ ✓ Done" toggle — clean view is read-only; edit view shows inline row edit, delete, and add. Roster edits run on an editable copy of the masking dictionaries (`acRoster`); a hospital added here immediately becomes a selectable Location in assignments. Real build: single roster server-side, edits resync the on-device masking data. |
| 29 | Doctor↔admin connection architecture | ✅ | 2026-07-07 | One **shared backend** both apps talk to. Doctor app (mobile) converts names to codes on-device, then sends **codes only** to the backend (upholds invariant #1). Admin web app reads from the same backend to populate roster/dashboards/reports. The **roster** (the real-name↔code key) is the privileged admin data, held server-side and shown only in the admin tool — never pushed to doctor devices. Proven end-to-end in the prototype 2026-07-07 via a local shared backend (harness grown to `/api/plays`, `/api/roster`, `/api/review`): a doctor-logged play flowed through the backend and appeared in the admin dashboard. |
| 30 | Token-cost controls before pilot | 🟡 | 2026-07-07 | At scale (~1,000 doctors × 1–2 reports/day, several AI messages each) the app resends a ~2,000-token fixed instruction sheet on every message. Direction: the hosted backend proxy **must** include Anthropic **prompt caching** on that sheet (≈10% price on repeat reads), with the per-doctor alias + date moved **outside** the cached prefix so all doctors share one cache; plus trimmed conversation history and, only after passing the dialogue eval set, a possible cheaper model for simple turns. Build-before-pilot backend requirement (see PROJECT_STATUS open item #5). **Prototyped + verified 2026-07-09:** the system prompt is split into a static rulebook (`systemStatic()`, sent with `cache_control`) and a per-Player tail (`systemDynamic()` — alias, date, filed plays) sent after the cache breakpoint, so all Players share one cache and the date never busts it. Verified live in the local harness via the API usage fields: the 2,370-token rulebook clears the model's cacheable minimum, is written once (`cache_creation_input_tokens: 2370`) and then read on follow-ups (`cache_read_input_tokens: 2370`, ~10% price). Nothing confidential is cached — codes only (invariant #1). This is the reference structure for the real backend proxy. |
| 31 | Assistant tone | ✅ | 2026-07-09 | Always respectful, professional, and courteous — the app addresses physicians. No casual greetings or slang ("Hey", "Hi there", "Yo", exclamatory over-familiarity); get straight to the helpful point; address the player plainly as "Player <alias>". From tester finding VON-01. Implemented as an explicit TONE block in the system prompt (`systemPrompt()`), plus "friendly" softened to "courteous, professional" in the clarify instruction. To be covered by the Phase 2 dialogue eval. |
| 32 | Doctor↔Location assignment + off-assignment flag | ✅ | 2026-07-09 | Each doctor has a set of assigned/practicing Locations, held in the roster **server-side, per doctor** (a demo card field stands in for now). When a Player logs a play at a Location NOT in their assigned set, the play **still posts** (non-blocking, "convert don't reject") but a **deterministic on-device check** raises an admin-review flag — codes only (Player alias + Location code). The admin team validates manually and either **adds the Location to that Player** or **denies the play**; the record is adjusted per their disposition. The check is deterministic code at play-commit (`offAssignment()`), **NOT the AI** — the AI never learns where a doctor practices (invariants #1, #7). Prototyped + verified 2026-07-09: JD28 assigned SATURN 33/JUPITER 08 — a play at NEPTUNE 21 posts and appears in the review queue (doctor-app drawer + admin console); a play at SATURN 33 does not. Guarded by the automated checks. **Admin tools built 2026-07-09:** (1) disposition buttons on each off-assignment flag in the Review queue — "Add Location to Player" (adds it to that doctor's assignments and clears the flag) and "Deny play"; (2) per-doctor Location management. **Unified into one Doctors table 2026-07-09 (tester VON-07):** the old separate "Accredited MDs" roster and "Assignments" tab were merged into a single **Doctors** table (real name · alias · assigned hospitals) with add / edit / delete and per-doctor hospital tagging; **Hospitals** stays its own master list, and an inline "+ New hospital" adds to that master list then tags the doctor. Data model: one `acDoctors` record per doctor `{code, display, hospitals[]}` — the single source the off-assignment check and the admin console both read; the separate Assignments tab was removed. Verified live end-to-end (tag/untag, edit alias, delete, add doctor, add hospital → immediately taggable). |
| 27 | Press-and-hold to reveal real name behind a code | ✅ | 2026-07-06 | Rejected. Suggested as an anti-screenshot control on touch devices. Rejected because: (a) it does not prevent capture — phone screenshots are a button combo/gesture that works while a finger holds the screen, and screen recording or a second camera capture the reveal anyway; (b) it reintroduces the real name onto the display, defeating the app's core protection that a leaked screenshot shows only meaningless codes; (c) it requires storing real names on-device to be revealed, enlarging the confidential-data footprint. Sanctioned approach instead: keep codes-only display (already makes any leaked screenshot harmless) + the hidden trace watermark (#26) for deterrence/tracing. In practice the doctor rarely needs the real name shown — they just typed it, they know their own roster codes, and unrecognized ones are confirmed by code. |
| 33 | Duplicate ("double entry") play handling | ✅ | 2026-07-12 | Some doubles are legitimate (same drug, two patients / same patient twice), so the system **asks, never silently blocks** ("convert don't reject"). Two nets: (1) **on-device catch at confirm time** — before a play posts it is compared to this session's logged plays by identity (Location + Username + Era + Avatars×units, codes only via `playSig()`); on a match the assistant asks "second separate play, or the same one logged twice?" — "Separate" logs it (flagged `_dupOK` so it won't re-prompt), "Same one" cancels the log; (2) **back-office safety net** — the admin Issues tab scans logged plays (`duplicateIssues()`) and surfaces genuine repeats the phone can't see (cross-session/cross-device) under a live "Detected from logged plays" section, separate from the demo examples (which keep the SAMPLE DATA badge). Deterministic code, codes only (invariants #1, #7). Prototyped + verified live 2026-07-12. Real build: the same identity check runs server-side across devices. |
| 34 | Backdating / late (previous-month) reports | ✅ | 2026-07-12 | The system **accepts** missed prior-month data. A doctor states the month in plain language and the on-device parser assigns the right Era (`eraOf()`); the confirm card shows the target period ("2605 · May 2026") and a plain line stating which month it will count toward. **Fixed a real gap:** the stated month/Era is now sent to the backend (`commitPlay()` posts `era`+`month`) — previously it was parsed on the card but dropped on submit, so late plays filed to the current period. **Closed-month handling:** a play backdated to an Era earlier than the open one (`isBackdated()`) still posts but raises a **late-entry flag** in the admin Review queue with two dispositions — "Accept into Era ‹past›" (reopen that month) or "Roll into current ‹open Era›". **Bulk catch-up = conversational (revised 2026-07-12, per Von):** the first cut used a composer dropdown/toggle; replaced with a **fully conversational** flow. The doctor just says the period in plain words ("these are for last month", "back in May", "June plays") and the assistant sets each play's month and **carries it forward** to subsequent plays until told otherwise — a REPORTING PERIOD block in the system prompt (relative-term resolution + carry-forward). The dropdown, banner, and `LOG_PERIOD` override were removed. A required companion fix: time/period words (last, month, back, May…December, etc.) were added to the patient-extraction STOPWORDS so a phrase like "for last month" is no longer mis-masked as a patient code ("LM") — otherwise the period was eaten before it reached the assistant (regression case 11). Masking/codes untouched — this only changes which Era a play counts toward. Verified live end-to-end 2026-07-12: "these are for last month…" → confirm card shows Era 2606 · June 2026 with the backdated notice. |
| 35 | Multiple plays in one message | 🟡 | 2026-07-13 | Deferred to a real feature (Von's call 2026-07-12), **now being built (2026-07-13).** Origin: the app was built around ONE play per message; a doctor bundling several stressed it three ways, all caught first as graceful fallbacks (prose crash → resilient parsing; several concatenated objects → first-object-only; game-vocab field renames → key aliasing). **Experience chosen 2026-07-13 = review-all list** (Von picked it over step-through and auto-log+undo): the doctor sees all plays at once as a stacked, editable, per-play-checkable card and taps one "Submit all N" button — fastest while every play is still shown and checkable before anything logs (invariant #5). **Architecture:** the assistant returns every ready play inside ONE JSON object (`{"action":"confirm_batch","reports":[…]}` — the array lives inside one object, so the first-object parser hardening is preserved; we never re-allow several concatenated objects); on-device code validates and renders. `commitPlay` refactored into a bubble-less `recordPlay` shared by single-play and batch submit, so **each play in a batch runs the same deterministic checks** (off-assignment Location, backdated closed-month, duplicate). Incomplete or look-alike plays are marked and **block Submit-all** until fixed conversationally. Duplicate check extended to catch repeats **within** a batch (two identical plays in one message), not just against already-logged plays. **Slices:** ✅ 1 (batch action + review card + per-play checks) and ✅ 3 (intra-batch duplicate) done + verified live in a static harness; remaining: (2) polish conversational per-play edits, (4) regression case + docs. **Not yet verified with the live model** that it reliably emits `confirm_batch` under real dialogue — needs the API-key harness (full session test). Backend already accepts one coded play at a time; a batch is just a loop (no change). |
| 36 | Co-Player in the admin report + credit-sharing math | 🟡 | 2026-07-16 | The admin Era report now **displays** the Co-Player on a shared play (new Co-Player column, added to CSV) so a co-play no longer reads as solo — `reportRows()` carries `other_md`. But the **credit-sharing computation** (what the units DO when a co-player is credited — split, full-to-starter by the `role` field, shared to both, etc.) is **deferred to the Data Team** (Von's call 2026-07-16): it is deterministic crediting code that must come from their worked examples (invariant #7), not invented. Until then a shared play attributes all units to the logging Player and simply names the Co-Player alongside. Open item / blocker #6 in PROJECT_STATUS. |
| 48 | Net-3 name gate — verify a possible lone name instead of sending it (blocker #4) | ✅ | 2026-07-21 | Von: "if info is lacking like a lone-name, the system should ask to verify." Closes the residual extraction gap (brief §9 item 10 / blocker #4): a leftover word that could be a real person's name — a LONE first name, or a name right after a person-preposition ("juan had 2 prosec", "to rosa", "referred by ken") — that escaped NET 1 (age net, `extractMatch`) and NET 2 (2-word preposition net, `extractNameOnly`) was flowing to the AI unmasked. Now `detectPossibleNames` (in the lifted engine block, next to the other nets) flags such a word — trigger = seed first-name list OR sits right after a person-preposition; skips masked tokens, numbers, codes, stopwords, ordinary report words, product-name words, and prepositions themselves, so normal plays never trigger. When flagged, `gateNamesThenSend` **blocks send and asks the doctor who it is** — 🧑 Patient (→ initials on-device, AI asks the age), 👩‍⚕️ Another doctor (→ PEND alias + back-office review), or "Not a name" (send as is). Human-in-the-loop, entirely on-device; the word shows only during the verify step and the bubble neutralises after, so no real name persists on screen (invariant #6). Verified live: nothing reached the AI before the doctor answered (0 calls), "juan" sent as "J had 2 OTEM". **Guarded by the eval** — moved the detector into the lifted engine block and added 7 net-3 regression cases (writing them caught a real bug: "referred by ken" flagged "by"; fixed by skipping preposition words). Masking suite 36 → **43/43**. **Honest limit (still 🟡 in spirit):** this is a heuristic — a name that is NOT in the seed list AND not after a preposition (e.g. a lone uncommon surname mid-sentence) can still slip. It substantially mitigates the gap and errs toward asking; the real Expo build can swap in a fuller on-device name model behind the same gate. |
| 49 | Offline verification — report requests answer on-device + per-play send status (DECISIONS #46 workaround) | ✅ | 2026-07-21 | Von asked the workaround for the in-memory offline queue (a queued report could be lost if the app closes before reconnect): "the doctor will be able to verify by asking what were the recent reports." Built exactly that, and fixed a gap it exposed. **(A) Reports now answer on-device.** A stats/report request went through the AI (handleSend → the assistant returns `action:report`), so OFFLINE the query hit the "you're offline" branch and the doctor couldn't see their plays. Now `looksLikeReportRequest` detects the intent on-device and `showReport` renders it locally from the doctor's own plays (invariant #7 — the AI was only ever used to NOTICE the request) — so it works with no signal and returns instantly online too (fewer round-trips). The shortcut is skipped if the query carries a possible name (falls through to the full masked pipeline + net-3 gate). **(B) Per-play send status.** Each play carries a `pending` flag when recorded offline (`recordPlay`); the reports table gained a **Status column** — "⏳ waiting to send" vs the filed date — with a header summary ("N waiting to send"), so the doctor can verify exactly what filed vs what's still queued and re-file if needed. `flushOutbox` clears the flag when the queue sends on reconnect. Verified live: offline, "What are my plays this Era?" rendered the table with 0 AI calls and marked the un-sent play "waiting to send"; on reconnect the queue flushed, both plays reached the backend, and the status flipped to filed. Masking 43/43. Real-build note: the durable-storage requirement (DECISIONS #46) still stands — this verification path is the safety net, not a replacement for persisting the queue. |
| 47 | Confidentiality boundary — the back-office review channel is the sanctioned real-name exception | ✅ | 2026-07-21 | A code review flagged that unrecognized real names (an unknown Doctor/Location/Product the doctor typed) were being POSTed to `/api/review`, while the masking comment AND brief §4 both claimed "the raw name is never transmitted" — a real contradiction with invariant #1. Root cause: the review queue moved from a device-only note to the shared backend/admin console (so the self-growing name library #39, add-to-roster #14, and unknown-product report #44 could work), but nobody reconciled the spec. **Von's decision: keep the current behavior, make it clean** (of the three options — strict/never-transmit, protect-people-only, or keep-current-cleanly). Rationale: to ever add a new roster entity, a human in the back office must see what was typed; the entities in question (doctor/hospital/product) are roster data the back office is designed to hold, and the confidentiality guarantee that matters — patient identity, and the LLM/plays-store never seeing a real name — stays absolute. **What changed:** (1) `queueReview` now transmits a **field whitelist** (`kind/player/alias/name/location/era/when`) so no patient identity or free-text report content can ever ride along; (2) a precise comment reframes `/api/review` as the ONE sanctioned, access-controlled back-office channel — never the LLM path, never the codes-only plays store (an authenticated admin endpoint in the real build); (3) fixed the misleading masking comment that claimed the name stays on-device; (4) **reconciled the canonical spec** — brief §4 rewritten (unknown entity → temp code + back-office review, not the old "block send, never transmit"), and CLAUDE.md invariant #1 (states the back-office exception + patient-never-transmitted + whitelist) and #3 (temp-code-not-block, #39 superseded the original "block for unknown doctor names"). **Verified live:** "6 kenzolin at Saint Mary Clinic with Dr. Jimmy Sy to Maria Santos, 52" → `/api/review` received only `Saint Mary Clinic`→LOC-PEND-1 and `Dr. Jimmy Sy`→PEND-1 (whitelisted fields); the AI received "6 PRAZ at LOC-PEND-1 with PEND-1 to MS 52"; the plays store received no real name; the patient never entered the review channel. Masking suite 36/36. **Related open finding (not this fix):** the Feedback channel (`/api/feedback`) still sends the doctor's free-text unmasked — a doctor could type a name there; masking/guarding feedback is a separate follow-up (review finding P2). |
| 46 | Automatic offline handling (no manual toggle) | 🟡 | 2026-07-21 | Von: intermittent hospital signal — but offline must be an **automatic rule**, not a toggle the doctor operates. Model Von specified: doctor sends a report → app says "will send when back online" → on reconnect the app processes it and confirms to the doctor, with no action from them. Built exactly that. The app senses connectivity itself (`navigator.onLine` + `online`/`offline` events); `apiPost` is the single choke point — while offline (or on a mid-flight fetch failure) a backend write is **parked in an in-memory `OUTBOX`** instead of failing, and a **passive amber bar** (`refreshOfflineBar`, above the composer — status only, not a control) tells the doctor their saved reports will send automatically. On the `online` event, `flushOutbox` replays every parked write and posts a "✓ Back online — N reports filed automatically" note. **Confirmed plays** (single card, batch, and the attached-file path from #45) record on-device and queue their send; the card tag + narration read "saved · will file when back online." **A free-text play that needs the assistant** can't be *finished* offline (the assistant needs signal) — it's held (`state.pendingAI`) with a plain "your report is saved — I'll finish it when you're back online," and `flushOutbox` re-runs that turn through the assistant on reconnect (verified: it resumed with the next clarify question). The offline bar counts only doctor-facing reports (plays / a pending turn), not the internal admin flags that also ride the outbox. **Verified live end-to-end:** offline → attach file + submit (2 plays + 1 review flag parked; backend received 0) → reconnect auto-filed the 2 plays and confirmed to the doctor; a typed play offline was held and completed by the assistant on reconnect. Masking suite 36/36. **Prototype boundary (🟡):** the outbox is in-memory here (invariant #9); the real Expo build uses **durable device storage** so a queued report survives the app being closed, plus real connectivity detection (a reachability check, not just the OS online flag) and background sync. Ties to blocker #4/#5. |
| 45 | Attach a file or photo of a report (secretary tables + handwritten photos) | 🟡 | 2026-07-21 | Von: some doctors have a secretary tabulate their plays into a file, or prefer writing the report by hand and photographing it — the app should accept an attachment, not only typed text. Built as **one attach flow** (📎 button in the composer) feeding the existing multi-play review card. **Core rule (invariant #1/#8): everything happens on-device.** For a **file** (CSV/TSV/text table), the file is read in the browser; each column is mapped to a code by **direct dictionary lookup** — `resolveProduct`/`resolveHospital`/`resolveCoplayer` (fuzzy, reading the live admin roster) + `patientInitials` for the Match — and only the coded plays render in `renderBatchCard`. **Nothing is uploaded and nothing goes to the AI** — a clean table logs deterministically, so this path is **instant and works offline**. Column headers are matched by synonyms (`COLUMN_SYN`: hospital/location, product/avatar, qty/units, patient/username, age, month, coplayer/attending). Any field the app can't recognize (unknown product/hospital) leaves that play **"⚠ needs a fix" and blocks Submit-all** — an unrecognized real name is never sent; an unknown co-player gets a stable PEND alias (never the real name). A non-tabular text file drops into the message box as a note (masked + AI path). For a **photo**, the image is held in memory (data URL), shown locally, and **never uploaded**; the doctor confirms/types the text (on the real phone this is native on-device OCR — Apple Vision / ML Kit, #8; the type step is also the real app's messy-handwriting fallback + the tap-to-confirm-patient step), then the text runs through the same masking. Image discarded from memory on use/discard. **Verified live end-to-end (doctor→admin):** a 4-row file masked to codes with **zero network calls during parsing** and zero real names in any payload; an unknown product (Aspirin) blocked submit; a clean 2-row file submitted codes-only and appeared in the admin Reports (Era 2607 · DAWN · SATURN 33 · PRAZ 6). A photo's play reached the AI as "6 PRAZ at SATURN 33 to MS 52" — image bytes and real names never in any POST. Masking suite 36/36. **Prototype boundary (🟡):** this demo reads CSV/TSV/text tables + photos; **native Excel/Word parsing and real on-device OCR are Expo-build items** (the phone reads .xlsx/.docx and photos on-device the same way). The on-device masking of a *bulk* attachment raises the stakes on the residual lone-name extraction gap (blocker #4) — a secretary's free-text file must run net-3 before send. |
| 40 | One-tap quick-access Location chips, ranked by usage (tester VON-17) | ✅ | 2026-07-21 | The doctor composer shows the Player's **assigned Locations as one-tap chips** (beside the "What are my plays this Era?" button), so a doctor can start a play at a common Location without typing the hospital. Tapping a chip drops `at <CODE> ` into the composer and focuses it — the doctor just adds the product/units/patient. **Ranked by usage:** the chips are ordered by how often this Player has reported at each Location (most-used first; ties keep the admin-assigned order), capped at 4. Counts are seeded from the shared backend on sign-in (cross-session) and incremented on each logged play (`state.locFreq`, `renderQuickHosp()`), so the order self-tunes — a Location the doctor rarely uses sinks, a frequent one rises. Codes-only on screen (game vocabulary, invariant #6); the assigned set comes from the admin Doctors roster (#32). Required masking change: a **Location CODE now maps to itself** in the engine (`rebuildPhrases` adds each hospital's code as a recognized name), so a code entered directly — by the chip or typed — is recognized as that Location instead of being read as an unknown hospital (LOC-PEND). Verified live: DAWN sees 📍 PLUTO 12 / 📍 NEPTUNE 21; tapping PLUTO 12 → "at PLUTO 12 " → "6 kenzolin to Maria Santos 45yo" masks to "at PLUTO 12 6 PRAZ to MS-45"; after NEPTUNE 21 is used more, it moves to the front. Masking suite 35 → 36/36. |
| 39 | Doctor alternate names + a self-growing name library (tester VON-16) | ✅ | 2026-07-21 | Doctors get admin-managed **alternate names** (nicknames, initials, abbreviations) the app recognizes — the same mechanism as hospitals (#38). Co-player matching now reads the admin **Doctors roster** (`acDoctors`, each with a `names` list = real name + honorific variants + admin-entered `alts`) instead of a frozen dictionary, rebuilt via `rebuildPhrases()` on every edit and on sign-in. The signed-in doctor is **excluded** from co-player matching so their own name still resolves to "me" (#38), not their own alias. **Admin editing** mirrors hospitals: the Doctors roster Edit mode has an "Also recognized as" field. **The growth loop (the point):** a name the app doesn't know yet still goes through under a temporary code and lands in the **review queue**; there, besides "Add as new" and "Not real", each item now has a **quick-link** — a dropdown of existing doctors/hospitals + one "Add as alternate name" click that attaches the typed name to that existing entry (honorific stripped so the bare nickname matches), re-recognizes it immediately, and **persists it to the shared backend** as an accumulating `roster_links` list. On sign-in the doctor app and on refresh the admin console pull those links (`applyRosterLinks`), so a name one admin adds is recognized on every device — the library keeps building as more doctors use the system. Verified live cross-tab end-to-end: doctor names "Dr. Kenzo" (unknown → PEND-1) → admin links it to Dr. Ken Dela Cruz in two clicks → doctor signs in again → "with kenzo" now → THOR. Masking suite 31 → **35/35** (doctor-alternate recognition + self-exclusion cases). **Note:** an unknown *bare* name with no honorific/preposition trigger (e.g. "with Kenzie") still isn't captured to the queue — that's the pre-existing residual extraction gap (blocker #4); names written as "Dr./Dra X", "with Dr X", or "at X" are captured and can then be linked. |
| 38 | Patient initials, hospital abbreviations, and "attending is me" (tester VON-15) | ✅ | 2026-07-21 | Three fixes from a live test of a real report format ("JU 60/F UST Hospital Saptaz 2.25g 42 vials Attending: Dr. Daisy Tagarda"). **(1) Patient initials kept whole.** The Match code took only the first letter of each name word, trimming a doctor-typed abbreviation "JU" to "J-60". `patientInitials()` now keeps a short (2–3 char) all-caps token as-is (JU-60, AB-45) — that IS the patient's initials — while an ordinary name word ("Juan") still contributes just its first letter. Same privacy level (initials + age). **(2) Hospital alternate names, admin-managed.** A hospital the doctor abbreviates ("UST", "USTH") is recognized as its Location because the **admin can list alternate names / branch names per hospital** in the Hospitals roster (Edit → "Also recognized as"). The masker reads names from the editable roster via `rebuildPhrases()` (rebuilt on every roster edit), not a frozen dictionary; UST seeded as Location PLUTO 12 for the demo. Before this, an unknown hospital typed without "at/sa" stayed visible in the clear. **(3) "Attending is me" = single play.** When a doctor writes their own name as the attending physician, the app must treat it as the Player, not invent a Co-Player. `isSelfName()` compares the typed doctor name — on-device only — against the **signed-in doctor's own real name** (`USER_REALNAME`, set at login, never sent; the AI still sees only the alias). A match renders/sends as "me" (Game Mode SINGLE, no PEND). Ties the doctor app to the admin Doctors roster (each doctor's real name lives there). **Also:** added the PH honorific "Dra" (Doctora) to the doctor detector — it was missing, so "Dra <name>" for a real co-player would have leaked; and fixed the confirm card's hardcoded "You (JD28)" to show the actual signed-in alias. Demo doctor added: Dr. Daisy Tagarda (card IRIS-2608 / 5678, alias DAWN). Verified live end-to-end (doctor app → admin Reports: DAWN · PLUTO 12 · CAZ 42 · solo). Guarded by 6 new masking cases (now 31/31). |
| 44 | Product category in the roster + "product not in the game" handling | ✅ | 2026-07-21 | Von: the BLAZE/BREEZE/TERRA categories on the doctor's tracker weren't visible/editable in the admin product database (looked hardcoded), and each product should be assignable to a category; also, a product not in the roster should be rejected AND reported to admin. **(A) Category in the product DB:** each product already carried a `group` (IV/Inhaler/Nutra → 🔥 Blaze / 🌊 Breeze / ⛰ Terra); surfaced it as an editable **Category** column in the admin Products roster via a dedicated `productRows()` + `CATEGORIES` constant + `categoryLabel()`; category is a dropdown in Add product and in the inline editor; edit now also preserves the molecule alias. The doctor's on-time tracker (`updateProgress`) reads each product's `group`, so re-categorizing a product moves its units to the right bar. **(B) Unknown product:** new assistant action `{"action":"unknown_product","product":"<as typed>","message":...}` — the prompt tells it to never invent a code for an unrecognized product; the app shows the doctor a plain "not part of the game — can't be logged" line, logs **nothing**, and posts an `unknown-product` flag to the shared backend → admin **Review queue** row ("Product not in the game": Player code + typed product name + Mark-noted). Codes-only elsewhere; the typed product name is the doctor's own input and only travels to the back office (which sees real data). Verified live: admin re-categorized a product; doctor reported "Aspirin" → told it isn't part of the game, nothing logged, appeared in the Review queue as DAWN/Aspirin. Masking 36/36. On-time reporting nudge — **BUILT** (Von chose deadline countdown + streak + plain status line, not the per-doctor goal ring). One persistent bar above the composer (`updateNudge`, replacing the static "Era open · log by Aug 10" sys line): plain status ("‹Month› · N plays logged"), deadline countdown ("X days left to file" = 10th of following month via `filingDeadline`/`daysToDeadline`, colour escalating calm→amber(≤7)→red(≤3/due/closed)), and reporting streak ("N-month streak" when ≥2 consecutive months have plays, `reportingStreak`). All on-device from `myPlays()` (session + backend history), so count/streak span months; refreshes on sign-in, after history load, and after each logged play (via `updateProgress`); hidden on sign-out. Verified live: DAWN → "July · 2 plays logged · 20 days left to file · 🔥 3-month streak"; near-deadline red state renders. **Fix (Von's catch):** the countdown first measured from the frozen demo date `TODAY` (July 6) and read 35 days; now `daysToDeadline` measures from the REAL current date (date-only), so it's live and accurate — on July 21 it correctly reads 20 days to Aug 10. |
| 43 | Doctor feedback captured in the admin console (+ removed demo aids) | ✅ | 2026-07-21 | Von: the doctor app's demo scaffolding isn't needed, and "Send feedback" should actually reach the admin team. **Removed** the "Demo cheat sheet" button/drawer, the "Admin review queue (demo)" button/drawer (the review queue lives in the admin console now; `renderAdminQueue()` → no-op, `queueReview()` still POSTs flags to the backend), and the "Try: Gave 6 Kenzolin…" composer hint. **Feedback reworked** from copy-to-clipboard (paste into a pilot chat, captured nowhere) to a real submission: doctor picks a category (Something broke / **A name showed on screen** / Confusing / Suggestion / Praise), types a note, taps Send → POST `/api/feedback` on the shared backend with the Player alias only (never a real name; dropped the manual tester-code field). New admin-console **Feedback tab** (added to `ADMIN_TABS`, between Reports and Issues) lists Date · Player · Category · Message · Status with **Mark reviewed** (`feedbackDone`); the "name on screen" category is rendered as high-severity and row-highlighted red — the one report to treat as an immediate privacy issue. Backend gained GET/POST `/api/feedback` (under the store write-lock); `/test-app` skill updated. Verified live doctor→admin: DAWN sent a Suggestion + a "name on screen" report; both landed in the Feedback tab codes-only, safety one highlighted, Mark-reviewed clears the "new" count. Masking 36/36. Also removed the demo card IDs/PINs from the sign-in screen (per Von, same day) — dropped the "Demo doctor card / Demo admin card" hint and genericized the card-ID placeholder, so sign-in exposes no usable credentials. |
| 42 | Flexible doctor stats/reports — period + grouping, always a table | ✅ | 2026-07-21 | Von: a doctor might ask for MTD, YTD, a specific coverage date range, per hospital, or per product — and the output should be a **table**, not a sentence/paragraph. Built a deterministic on-device report engine (`parseReportQuery` + `buildReport`). The assistant still only detects a report request and returns `{"action":"report"}` (prompt broadened to cover every scope word); the SCOPE is parsed on-device from the Player's own words — no name data involved — and every total is computed by code (invariant #7, never the AI). **Periods:** this month/Era (default), MTD, YTD (all of the current year up to the open Era), a named month → its Era, or a date range (`from … to …`, matched on the play's filed date). **Grouping:** none = play-by-play detail table; hospital = per-Location summary (Plays, Units, totals); product = per-Avatar summary (Units, Plays, totals). Data source = the Player's prior plays loaded from the shared backend at sign-in (`loadMyPlays`) merged with the session's plays (`myPlays()`), so MTD/YTD/date-range span months, not just this session. The plain "this month, no grouping" view also appends the credited-to-me table (existing behavior). Verified live doctor→admin: 4 plays across May/June/July, 2 Locations, 3 products; "this month" = 2 plays/18u detail, "YTD per product" = CAZ 12 / PRAZ 11 / OTEM 8 = 31u, "per hospital YTD" = NEPTUNE 21 17u / PLUTO 12 14u, "for June" = 1 play/8u — each a correct table; admin Reports shows the same by Era. Masking 36/36. **Harness note:** the live test exposed a concurrent-write race in the session-only mini-server (threaded server, no lock → fast successive play POSTs clobbered each other and dropped plays); fixed with a `threading.Lock` around the store read-modify-write and baked into the `/test-app` skill. |
| 41 | Admin-issued doctor card + PIN (where login credentials live) | ✅ | 2026-07-21 | Von (COO) asked where the access card ID / PIN the admin team assigns to each doctor is stored. Previously they were hardcoded demo constants (`CARD_CREDENTIALS`) with no admin surface. Now the **card ID + PIN live on the doctor's roster record** (`acDoctors`: `card`, `pin`), so issuing access is part of managing a doctor. The Doctors roster gained an **Access column** — read view shows the card ID + "PIN set ✓" / "no PIN" / "not issued"; Edit mode has a card-ID input + a set/reset-PIN input. `doLogin` now authenticates doctors against `acDoctors` (matching card+pin); `CARD_CREDENTIALS` is **admin-only** (`IRIS-ADMIN`). A doctor with **no card issued cannot sign in**. **Privacy/governance (mirrors the real build):** the PIN is stored on the record but **never displayed back** — the admin can only set or reset it — and, like the rest of `acDoctors`, is **never sent to the backend or the doctor app** (the roster POST carries only `code`+`display`). In the real build the PIN is stored scrambled server-side and the doctor login verifies against the backend (admins set/reset, never read). Save validates: card ID unique across doctors + not reserved, new PIN 4+ digits, blank PIN keeps the existing one, blank card clears issuance. Verified live: Access column renders; issued IRIS-2609/4321 to THOR (persisted, exited edit); rewired `doLogin` signs DAWN in from the roster record (alias + on-device real name + locations); un-issued card / wrong PIN / unknown card all rejected. Masking suite 36/36. **Prototype caveat / next slice:** because the PIN deliberately isn't synced to the backend, a card issued in the admin window isn't visible to a *separate* doctor-window process — the issue→sign-in loop is complete within one running app; a shared-backend `/api/auth` endpoint (verify server-side, return alias/role only) is the real-build slice to make the cross-device handoff live in the demo. |
| 37 | Review-queue disposition of temporary codes (unknown Co-Player / Location) | ✅ | 2026-07-16 | A temp-code review item (a Co-Player or Location a doctor named that isn't on the roster yet) gets two admin actions: **Add to roster** or **Not in the game / Not a real Location** (leaves roster + reports untouched; the reporting doctor is informed the entity isn't part of the game). "Add to roster" does **not** promote the raw entry directly — it opens a **clean-up modal** where the admin corrects the real name spelling/format, assigns a proper Player alias (replacing the temporary PEND-N code), and optionally tags assigned Locations from the hospital master list (for a Location item: fix the hospital name + assign a Location code). Save validates name+alias present and alias/code unique, then writes the clean record to the roster (`acDoctors`/`acRoster`) and marks the item ✓ Added. Verified live 2026-07-16: PEND-1 "dr jimmy sy" → "Dr. Jimmy Sy", alias LOKI, tagged NEPTUNE 21 → correct roster entry. Ties to #32 (roster is the single source the off-assignment check reads). Note: the actual credit-sharing that follows an add is #36 (Data Team). |
