Skip to main content
Synced from the repo — do not edit here

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/hybrid work.

All file:lines are in basishybrid/lib/services/service_health_firestore_writer.dart unless 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

  1. healthSummaries writes are SEPARATE and MUST keep running. The digest-write methods write ONLY to .collection('dailyDigest') (verify: lines 2095-2110, 2122-2140). The source-of-truth healthSummaries docs are written elsewhere (the onSummaryCreated/sync path). The server digest writer READS healthSummaries — if the app stops writing those, the server has no input. Only disable the dailyDigest writes; leave every healthSummaries write exactly as-is.
  2. 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).
  3. Clinic mirror: the server mirror (functions_health_mirror.py) mirrors healthSummaries to 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>.writer should be serverDigest/... (NOT updateDailySteps/upsertDailyMean/etc.). If you still see the app's writer strings, a digest write slipped through the flag.
  • healthSummaries for 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:

  1. Add a digest reader: stream/fetch users/{uid}/dailyDigest (last N days) into a typed model.
  2. 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).
  3. 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 (string YYYY-MM-DD) is the day key; lastSyncedAt is the freshness stamp.
  • "Last value" = the most recent localDate present (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.

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 healthSummaries writes — is explicitly guarded above. As long as only the two dailyDigest write 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).