Canonical source: docs/claude/mobile-D3-lab-verdict-handoff.md. This page is generated by docs/scripts/sync-handbook.mjs. Edit the source file in the repo; changes appear here on the next build.
D3 — Mobile: read the persisted lab verdict (hybrid agent handoff)
Owner: hybrid agent (wt-hybrid, Flutter). Part of: labs one-source epic #377 / #392.
Why now: George's iPhone-sim screenshot showed Albumin values 33 and 58 g/dL labeled
"In range" — impossible for serum albumin. Root cause is on mobile: it computes the
in/out-of-range badge from the vendor's range (Junction shipped albumin 0–100 g/dL →
everything "in range"), and never reads the verdict the backend now computes.
What the backend now does (DONE, deploy-held — you can rely on this contract)
Every lab doc written to users/{uid}/labs (and the clinic mirror) now carries our own
verdict, computed once from the canonical registry, sex-aware:
| field | meaning |
|---|---|
verdict | "in_range" | "out_of_range" — authoritative, use this for the badge |
verdictSource | e.g. "core:adult", "generated-default:male" — provenance |
refRangeLow / refRangeHigh | our reference bounds, in the doc's canonical unit |
vendorInterpretation | the vendor's own label (Junction's "normal") — audit only, do NOT trust |
minRangeValue / maxRangeValue | vendor-supplied range — may be garbage; fallback only |
Written by import_lab_values (PDF/manual) and the Junction persist path
(functions_junction._persist_junction_results_to_labs). Existing/historical docs are
backfilled by basis-functions/tools/backfill_lab_verdicts.py (stamps verdict +
canonical analyteKey). So after deploy + backfill, every doc has verdict.
Analyte keys are also canonicalized to ONE key per lab (alias map — see below), so
labAlbuminSerum / raw LOINC forms are rewritten to labAlbumin.
Your task (mobile)
- Parse the new fields on the Lab model —
basiscore/lib/src/models/summary/subtypes/lab.dartcurrently hasminRangeValue/maxRangeValue/isBelowMinRangebut no verdict. Add:verdict(String?),refRangeLow(double?),refRangeHigh(double?). Keep them optional. - Change the in/out-of-range badge to PREFER the persisted verdict. Wherever the "In
range" / out-of-range status is computed for a Lab (grep the trends-detail + day-memory
render paths; the badge is driven off
minRangeValue/maxRangeValue/isBelowMinRangetoday), use this precedence:- clinic-custom benchmark, if you support it on mobile (clinician override)
verdictfield (out_of_range→ red/high-or-low,in_range→ green) ← NEW, authoritative- fall back to the current local min/max compute ONLY when
verdictis null (pre-backfill docs)
- Show OUR range, not the vendor's. For the displayed reference range, prefer
refRangeLow/refRangeHighoverminRangeValue/maxRangeValue. Never rendervendorInterpretation. - Align
canonical_lab_key.dartwith the backend alias map. Mobile already collapses keys (good — that's why the 11 albumin points show as one card). Make it match the backend exactly:basis-functions/functions/generated/lab_analyte_aliases.jsonis the source of truth (45 aliases). Two must-keep rules from that file:- Serum/plasma/whole-blood variants collapse to the base (
labAlbuminSerum→labAlbumin,labCalcium_Serum→labCalcium,labMercury_WholeBlood→labMercury, …). - Cellular / RBC / urine / stool variants stay SEPARATE (RBC magnesium ≠ serum magnesium; urine albumin ≠ serum albumin). Do NOT collapse those. Ideally generate a Dart alias map from that JSON so it can't drift; at minimum reconcile the keyword map against it.
- Serum/plasma/whole-blood variants collapse to the base (
How to verify (George's real account, uid GoOU3d2QcyRZMD83IhbQeIclpwh2)
After the backend deploy + backfill, on the sim: open Albumin. The two junk values
(33, 58 g/dL) must show out-of-range (not "In range"), the four real values
(4.7–5.0) in range, all in ONE card, reference shown as 3.5 – 5.0 g/dL.
Do NOT
- Do not recompute verdicts on mobile when
verdictis present — that reintroduces divergence (the whole point is compute-once). Local compute is a fallback for un-backfilled docs only. - Do not touch basisflow-web or basis-functions — those halves (D1 backend, D2 staff web) are done.