Canonical source: docs/claude/mobile-393-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.
#393 — Mobile one-source companion (handoff spec for the hybrid agent)
Context. The server-side digest writer (#381, deployed) is now the authority for
users/{uid}/dailyDigest: it re-resolves every wearable metric from all sources on every sync (vendor-collapse → eligibility → strength-default) and stamps_sources/_shadow. Web (basisflow-web #394) and the client portal (basisweb #391) now read that digest verbatim. The mobile app is the last surface that (a) still WRITES the digest with its own last-write-wins values, racing the server, and (b) DISPLAYS a local recompute instead of the server value. This closes both.Not urgent / not a data bug — the server already produces the correct value. This is the clean end-state. Do it in an isolated worktree (off master) so it doesn't collide with other
wt/hybridwork.All file:lines are in
basishybrid/lib/services/service_health_firestore_writer.dartunless noted. Read-only-verified 2026-07-16.
Part A — stop the app writing the digest (SMALL, SAFE, DO FIRST)
The whole benefit (server is sole digest author, race closed) comes from this part alone. It is low-risk because it's isolated to two methods and does not affect display.
Change: gate the two digest-write methods with an early return behind a feature flag.
_digestAppend(...)— line ~2085. Add at the top of the body (after the try):if (kServerOwnsDigest) return;_digestAppendMulti(...)— line ~2119. Same early return.
That's it. All ~30 call sites (lines 1357–1813: steps/rhr/hrv/spo2/sleep/nutrition/body/zones/ glucose/etc.) stay untouched — they become harmless no-ops. One flag, two lines.
Flag: add const bool kServerOwnsDigest = true; (a top-level const, or wire it to remote
config if you want a kill-switch). Ship it true.
🚧 GUARDRAILS — do NOT touch these
healthSummarieswrites are SEPARATE and MUST keep running. The digest-write methods write ONLY to.collection('dailyDigest')(verify: lines 2095-2110, 2122-2140). The source-of-truthhealthSummariesdocs are written elsewhere (theonSummaryCreated/sync path). The server digest writer READShealthSummaries— if the app stops writing those, the server has no input. Only disable thedailyDigestwrites; leave everyhealthSummarieswrite exactly as-is.- Do not delete the two methods or the call sites — the flag makes them no-op, which keeps the diff tiny and reversible (flip the flag to roll back instantly).
- Clinic mirror: the server mirror (
functions_health_mirror.py) mirrorshealthSummariesto the clinic path — unaffected, since healthSummaries writes continue.
Verify Part A (iOS sim, George's account GoOU3d2QcyRZMD83IhbQeIclpwh2)
- Trigger a health sync in the app.
- In Firestore, open a recent
users/{uid}/dailyDigest/{today}doc → every_sources.<field>.writershould beserverDigest/...(NOTupdateDailySteps/upsertDailyMean/etc.). If you still see the app's writer strings, a digest write slipped through the flag. healthSummariesfor today should still be populating normally.
Part B — mobile READS the server digest for display (LARGER, FOLLOW-ON)
Today display comes from the local SQLite recompute (Rust FFI analyzers) — the app has no
digest-read path at all (dailyDigest appears only in the writer). Part B makes the mobile UI
show the same server-resolved number as web/portal/Atlas. It's a real refactor of the metrics
display layer, so it can land after Part A.
Direction:
- Add a digest reader: stream/fetch
users/{uid}/dailyDigest(last N days) into a typed model. - Repoint the metrics/health display (the analyzer-backed screens —
service_health*.dart+ the Today/metrics UI) to prefer the digest value per metric per day, falling back to the local recompute when the digest lacks that day/field (offline + backfill gaps). - Keep SQLite as the offline cache — the digest read is best-effort; never block the UI on it.
Field contract (what #381 writes — read these verbatim):
- Scalars:
steps, rhr, hrvSdnn, hrvRmssd, spo2, sleepMinutes, exerciseMinutes, activeCalories, weight, dietaryCalories, proteinG, carbsG, fatG, waterMl, calories, glucose, maxHr, vo2Max, … - Zones:
zone1Minutes … zone5Minutes - Provenance:
_sources[field] = {device, at, writer}(winner) ·_shadow[field] = [{category,value}](losers, for a "source" picker / drill-down if you want to show it). localDate(stringYYYY-MM-DD) is the day key;lastSyncedAtis the freshness stamp.- "Last value" = the most recent
localDatepresent (date-deterministic — do NOT take highest-ever or first-encountered; that was the exact read-side bug fixed on web in #394).
Verify Part B
- On the sim, George's account: the app's metric numbers (RHR, steps, zone minutes, HRV) should MATCH what the staff platform (basisflow-web MetricsTab) and the client portal show for the same day — because all three now read the same digest field.
Recommended sequencing
Part A now (2-line flag + verify — closes the race, makes the server the sole author). Part B as a follow-on (display refactor). Ship A, confirm on sim, then schedule B.
Why this is safe to hand off
- Part A is 2 lines behind a flag; instant rollback (flip the flag).
- The one thing that could break it — disabling
healthSummarieswrites — is explicitly guarded above. As long as only the twodailyDigestwrite methods are gated, source data keeps flowing and the server keeps producing the correct digest.
Questions on the field contract or resolution semantics → ping the one-source owner (this session /
docs/claude/one-true-source-plan.md).