Canonical source: docs/claude/mobile-one-source-confirmations.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.
Mobile one-source-of-truth — confirmations + work needed (hybrid agent)
From: main integration session · For: hybrid agent (basishybrid, wt-hybrid) Context: the backend one-source work for wearables and labs has landed (deploy-held). This is the single checklist of what mobile needs to do / confirm so we can close everything that isn't purely blocked on an app-store build. For each item: [CONFIRM] = tell us it's already done + show the artifact, or [BUILD] = please implement.
For each, "code-closeable" = committed + verified in the sim on George's real account
(uid GoOU3d2QcyRZMD83IhbQeIclpwh2) per the acceptance bar. The app-store release is the
separate deploy-held step — don't block the code milestone on it.
A. WEARABLES — mobile stops writing the digest (#393)
Backend state (live): users/{uid}/dailyDigest/{localDate} is now server-written and
authoritative (#381 deployed) — vendor-collapse → eligibility-gate → strength-default
resolution. The app must stop competing with it.
[CONFIRM] 1 — the app no longer WRITES the digest.
- Which file/function used to write
dailyDigest(a sync/summary service)? Show it's now removed or behind a kill-flag. - Checkable:
grep -rn "dailyDigest" basishybrid/lib basiscore/lib→ no.set(/.update(/.doc(...).setwriting todailyDigest.
[CONFIRM] 2 — display READS the server digest.
- The Today/Trends metrics + HR-zone cards read the
dailyDigestfields written by the server, not a local recompute from SQLite.
[CONFIRM] 3 — branch + verification.
- Branch (
wt/hybrid?), committed y/n, and a sim screenshot on George's account showing the metrics match what basisflow-web shows (same headline value per metric).
Note: zones stay app-owned (decided) — do NOT switch zones to a server value; just make sure the app isn't also writing the digest. Exempt zones from the "read server value" gate.
Closeable when: 1–3 confirmed. Deploy-held part: the app build.
B. LABS — three separate items
Backend contract every lab doc now carries (via import_lab_values + Junction, deploy-held):
| field | use |
|---|---|
verdict | "in_range" | "out_of_range" — authoritative, drive the badge from this |
verdictSource, refRangeLow, refRangeHigh | provenance + our reference bounds (canonical unit) |
vendorInterpretation | vendor's own label (Junction "normal") — audit only, do NOT display/trust |
analyteKey | canonicalized to ONE key per lab (alias map below) |
B1. [BUILD] Read the persisted verdict (D3)
Full spec: docs/claude/mobile-D3-lab-verdict-handoff.md. In short:
Labmodel (basiscore/lib/src/models/summary/subtypes/lab.dart) currently hasminRangeValue/maxRangeValue/isBelowMinRangebut no verdict. Addverdict(String?),refRangeLow(double?),refRangeHigh(double?).- Badge precedence: clinic-custom → persisted
verdict→ local min/max recompute (local only as fallback for un-backfilled docs). PreferrefRangeLow/Highover vendor min/max for the shown range. Never rendervendorInterpretation. - Verify (needs D1 deployed first): a doc with
verdict: out_of_rangerenders red/flagged, not "in range." Until D1 deploys you can code it + unit-test against a hand-set doc.
B2. [CONFIRM] Scan/manual entry routes through the backend gate (#385)
- Mobile manual + scan lab-save must call the backend
import_lab_valuescallable (so it gets canonicalization + unit-normalization + verdict), not a direct Firestorelabswrite. - Checkable: the save path (
service_lab_analysis.dart/ lab entry flow) invokes the callable. - If it's still a direct write → this is [BUILD]: route it through the callable.
B3. [CONFIRM] Key canonicalization aligned with the backend alias map
basishybrid/lib/services/canonical_lab_key.dartmust matchbasis-functions/functions/generated/lab_analyte_aliases.json(45 aliases).- Rules that MUST hold: 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 Mg ≠ serum Mg; urine albumin ≠ serum). - Ideally generate the Dart map from that JSON so it can't drift; at minimum reconcile it.
- Confirm which analytes mobile currently collapses vs the 45-entry map (diff).
C. BENCHMARKS — mobile divergence (investigation → decision)
Web now generates its lab benchmark table from the registry
(lib/lab-benchmarks-generated.ts ← lab_registry.json). Mobile still hand-maintains its own
Dart thresholds → mobile ≠ web. Before we can one-source it, we need to know the shape.
[CONFIRM] 1 — where mobile's benchmark thresholds live.
- Point us at the file(s) mobile uses for lab in/out-of-range thresholds AND for wearable metric thresholds (steps/sleep/RHR/HRV/zones). Hardcoded Dart map(s)? A generated file?
[CONFIRM] 2 — feasibility of generation.
- Could mobile consume a generated Dart benchmarks file emitted from
lab_registry.json(the way web readslab-benchmarks-generated.ts), replacing the hand-maintained tables? Any blocker (build-runner, package boundary, offline needs)?
Closeable when: we have the file list + a yes/no on the generation path. Then we decide who emits the Dart file. (No mobile code change required for this step — just the answers.)
What we need back (summary)
- A (#393): confirm digest-write removed + display reads server digest + sim proof. [mostly CONFIRM]
- B1 (D3): build verdict read (spec in the D3 doc). [BUILD]
- B2 (#385): confirm scan/manual uses the backend callable, or route it there. [CONFIRM/BUILD]
- B3 (keys): confirm
canonical_lab_key.dartmatches the 45-entry alias map. [CONFIRM] - C (benchmarks): name the mobile benchmark file(s) + say if a generated Dart file is viable. [CONFIRM]
Reply per item with the artifact (code snippet / grep result / sim screenshot / file path) so we can close the code milestones and isolate what's only waiting on the app build.
C — RESOLVED: backend now emits the Dart benchmark files (hybrid: wire them in)
The "who emits it" question is answered — the backend generators now write two generated Dart files into basiscore from the same registries web uses. Mobile stops hand-maintaining benchmark thresholds and consumes these:
basiscore/lib/src/models/summary/lab_benchmarks_generated.dart—kGeneratedLabBenchmarks(Map<String, GeneratedLabBenchmark>, in/out-of-range fromlab_registry.json, gender variants).basiscore/lib/src/models/summary/metric_benchmarks_generated.dart—kGeneratedMetricBenchmarks(Map<String, MetricBenchmark>, full scheme a/b/c/d + optimal tiers + age/gender variants + aperiodfield, frommetric_benchmarks.json). Includes the corrected age×gender VO2max, new weekly Zone 2 / Zone 5, and FFMI.
Both are regenerated + CI-drift-gated by the backend (generate_lab_benchmarks.py /
generate_metric_benchmarks.py). DO NOT hand-edit them — values change in the JSON.
Work needed (mobile)
- Wire
kGeneratedLabBenchmarks+kGeneratedMetricBenchmarksinto the existingTrendType → BenchmarkType → BenchmarkHelpers.findBenchmarkForTypepath, replacing the hand-maintained Dart thresholds (clinic_benchmarks.dart/trend_type.dart). Clinic-custom benchmarks still override; these are the defaults. period == 'weekly'(zone minutes): before comparing, sum the trailing 7 days of the metric (zone2Minutes/zone5Minutes) — the benchmark declares the window; the evaluator aggregates. Daily metrics compare the day's value as today.- FFMI is a DERIVED metric — compute
fatFreeMass / heightM²wherefatFreeMass = weight × (1 − bodyFat%)(height-normalized to 1.8m:ffmi + 6.1×(1.8 − heightM)). Body-fat% =labTotalBodyFat. Surface it as a metric so the benchmark applies. (Most accurate source = DEXA lean mass when #500 lands.) - Key reconciliation: confirm the benchmark keys (
zone2Minutes,zone5Minutes,ffmi,vo2, etc.) match theTrendTypekeys mobile looks up; if they differ, add the mapping (don't rename the generated keys — those are the shared contract).
Verify
On George's account in the sim: a metric with an age/gender band (e.g. VO2max) shows the correct in/optimal thresholds for his age+sex; a weekly metric (Zone 2) evaluates against the 7-day sum, not a single day.