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

Canonical source: docs/claude/protocol-single-source-mobile-contract.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.

Protocol Single-Source — Mobile Feasibility & Contract Memo (#388/#398/#694)

Locked contract for the mobile side of backend-sole protocol-occurrence materialization. Verdict: feasible, no Rust rewrite, offline preserved — if write-backs are Firestore-SDK .set(merge) to the canonical {recurringEventId}_{clinic-tz date} doc id. Two client build-blockers: inbound ingestion + primary-id promotion. Evidence is file:line against hybrid/basiscore + hybrid/basishybrid (wt/hybrid, 2026-08-09).

Write-back mechanism (decided)

Firestore-SDK .set(merge:true) to users/{uid}/events/{canonical-id} — offline-queued in the SDK mutation cache, stable identity. Callables ONLY for validation/edit-scope (inherently online). Today's live path is already this shape: local SQLite update (service_localdb_user.dart:964) → event listener mirrors out via userRef.set(…, SetOptions(merge:true)) (service_health_firestore_writer.dart:984); the persist_user_doc callable is a 15s-timeout fallback only (:989).


Contract #1 — Occurrence row field list (the backend must write ALL of these)

A backend occurrence at users/{uid}/events/{rid}_{date} must carry every field the 5 load-bearing systems read, or that system regresses. BasisEventV1 (basiscore/lib/src/models/event/event.dart), serialized camelCase.

FieldTypeExampleRead by / breaks if missing
idString"circ_athl_2026-08-04"Must equal the canonical {rid}_{date} (see #2). Local row id today is random (service_recurring_events.dart:822) — promotion required.
recurrenceRule.recurringEventIdString"circ_athl"filterHabitsEvents match (service_recurring_events.dart:1372) AND notification gate (service_notifications_io.dart:1067 — reminders skip a row with null rid). Missing → not rendered as protocol occurrence AND no reminders.
protocolIdString"op_circuit_athletic_recovery_1784948946"filterHabitsEvents secondary match (:1373); protocol-vs-local carve-out (:1191). Missing → app may re-materialize it locally = duplicate (#694).
typeBasisEventType (string)"habitCircuit"day-ahead notif count (service_notifications_io.dart:595, e.type.isHabit); display icon/route. Must be a isHabit type.
start / endISO datetime"2026-08-04T09:00:00-05:00"day-memory window query (service_daymemory.dart:1048); duration = end−start (event.dart:474).
completionStatusenum (PascalCase string)"Unknown"Enum values: NotCompleted · Completed · Skipped · Unknown · AutoCompleted · CreateCompleted (completion_status.dart:52-82). Backend must write "Unknown" for a fresh occurrence — autocomplete (service_daymemory.dart:1057) + notifications (:1067) only act on Unknown; a wrongly-Completed row is silently skipped by both. No separate completed/completedAt scalar — completion IS this field (+ medicationCompletions/circuitStepCompletions sub-maps, see #5).
autocompletedByint?123456autocomplete-merge dedup (event.dart:315); leave null on a fresh occurrence — the app sets it when a wearable summary auto-completes it.
reminderbooltruemaster reminder toggle (event.dart:259); false = all reminders off for the row.
remindersList[{source:"basis", duration:{s:900}}]per-offset reminders (event.dart:203). Only source == basis + duration > 0 fire (service_notifications_io.dart:1075). duration = offset BEFORE start. Missing → no per-event reminders (day-ahead summary still works off type).
data (circuits)map {steps:[{basisType,label,durationMin,order,isRest,notes}]}circuit step rendering (event.dart:406).
workoutPreset/supplementPreset/medicationPreset/mealPresetmapactivity-detail rendering + med/supp lists. Present per activity kind.
locationIdString?"aaae…"clinic-tz resolution for the date key (see #2).

scheduledUntil is NOT on the occurrence row — it lives on BasisRecurringEventV1.scheduledUntil (event_recurring.dart:19), written from local materialization (service_recurring_events.dart:1030). Once the backend owns protocol materialization, the backend must advance this on the user's recurringEvents[] entry, else route_habits.dart:779 / widget_protocol_detailed.dart:167 read a stale horizon. This is a second write target (the user doc's recurringEvents array), separate from the occurrence row.


Contract #2 — Clinic-tz date: app CAN derive it identically. CONFIRMED, with one fallback to agree on.

  • ClinicLocation.timezone (IANA string, clinic.dart:3475) is available client-side on the clinic doc.
  • resolveDisplayTz(locationId, clinic) (basishybrid/lib/services/appointment_tz.dart:19-35) already resolves the tz: the event's location tz → else the clinic's first location with a tz → else tz.local. Uses package:timezone (tz.getLocation).
  • So the app CAN compute {rid}_{clinic-tz ISO date} and drop the ±1-day hedge — the ±1-day risk today is only because _occurrenceKey uses event.start's device-local, non-zero-padded day (rp_activity_route.dart:170), NOT because tz is unavailable.

The one thing to lock: the fallback when a clinic/location has NO timezone configured. resolveDisplayTz falls back to tz.local (device tz). Backend + mobile + basisflow-web must use the SAME fallback for the id to match byte-for-byte. Recommendation: fallback = clinic's primary-location tz, else UTC (not device tz). Then the id is device-independent. Format: zero-padded ISO YYYY-MM-DD. (Mobile currently emits non-zero-padded — this changes when the canonical id is promoted.)

BLOCKER only if clinics are allowed to have no location tz AND we pick device-tz fallback → then two devices produce different ids. Avoid by mandating a clinic-level tz or UTC fallback.


Contract #3 — Read/subscribe shape (what to provision)

Today there is no inbound path (gRPC service_sync_backend.dart is fully commented out, :1:592; only outbound Firestore mirror exists). New client work needed:

Recommended predicate: a Firestore listener on users/{uid}/events bounded by a rolling window on start: where('start', '>=', windowStart).where('start', '<=', windowEnd).orderBy('start') window ≈ [today−14d, today+30d], re-anchored daily. Single-field range → no composite index needed for the base query.

  • Do NOT filter by protocolId in the query (Firestore can't != null efficiently, and non-protocol events also live here). Pull the window, let filterHabitsEvents (service_recurring_events.dart:1364) split protocol vs local in-memory — it's already author-agnostic.
  • Ingest each doc → storeLocalEventsForUser preserving the Firestore doc id as the local SQLite row id (this is the primary-id promotion; today the writer round-trips the random local id service_health_firestore_writer.dart:981).
  • Incremental option (lower reads): a second listener on where('lastChanged','>',lastSync) for deltas — needs a single-field index on lastChanged (likely already present from the outbound mirror). Use the rolling-window listener for display coverage + the lastChanged delta for freshness.

The existing local-query consumers (day-memory queryEventsForUser, all 5 systems) are source-agnostic — they read localDB.queryEvents, so once ingested rows land in SQLite with the right fields (#1), everything downstream works unchanged.


Contract #4 — Migration handshake (dual-existence window = #694 today)

State today: legacy random-UUID occurrence rows AND backend expected-{rid} rows coexist with no reconciliation (different ids, the 24h merge buffer service_recurring_events.dart:1003-1013 only reconciles WITHIN local delete-then-rebuild, never across a foreign row). This is #694.

Ordered handshake (flag-gated, per-user):

  1. App, on seeing a canonical {rid}_{clinic-tz-date} row for a slot: stop local materialization for that protocol (the protocolId != null guard at the reschedule loop :1191), so it stops authoring competing UUID rows.
  2. App deletes its legacy UUID row for that slot ONLY when (a) a canonical row for the same {rid, clinic-tz-date} is present locally, AND (b) the legacy row has no un-synced completion. Un-synced safety: if the legacy row's completionStatus != Unknown, first migrate that completion onto the canonical row (Firestore .set(merge) to the canonical id) and confirm it is not hasPendingWrites before deleting the legacy doc. Never delete a row whose completion hasn't reached the server.
  3. Backend must NOT (a) materialize canonical rows for a user until that user's flag is on (else pre-flag duplication), nor (b) delete legacy UUID docs — local cleanup is the app's job (the app knows what's un-synced; the backend can't see pending local writes).
  4. Reconciliation precedence once ids are unified: lastChanged last-writer-wins via SetOptions(merge:true) on the shared canonical doc — works ONLY because both sides now write the SAME id.

Phased, flag-gated de-risking order (go/no-go per phase)

Phase 0 — canonical id + clinic-tz key (no behavior change). Promote _occurrenceKey to zero-padded clinic-tz ISO date; make it the identity everywhere circuits/meds already key. Unify the legacy med read (widget_section_medication.dart:232 reads only event.id) onto stable-first-with-legacy-fallback (mirror rp_activity_route.dart:328-335).

  • GO signal: circuit + med completions written by the redesign route are read back correctly on the legacy summary route; key matches basisflow-web byte-for-byte on a device whose tz ≠ clinic tz.

Phase 1 — inbound ingestion + doc-id-preserving storage (behind kFeatureBackendOccurrences, default OFF). Build the rolling-window listener (#3) → storeLocalEventsForUser preserving doc id. Backend materializes ONE pilot protocol for ONE test user. App still locally materializes in parallel (dedup by canonical id in the home feed so no user-visible doubling).

  • GO signal: the backend occurrence renders on Today with correct HR/completion/reminders for the pilot protocol; local + backend rows for the same slot collapse to one card (kills #694 for that protocol); offline completion on the backend row round-trips (airplane-mode → reconnect → server has it).

Phase 2 — retire local materialization for protocol habits (flag ON, per-user rollout). Add the protocolId != null guard at :1191; run the migration handshake (#4). Verify the 5 systems (scheduledUntil advance via backend, streaks, autocomplete, calendar, notifications) still work off backend rows.

  • GO signal: for a flagged user, protocol occurrences come solely from the backend, no duplicates, all 5 systems green, and NON-protocol custom habits still materialize locally (regression check).
  • NO-GO / rollback: flip the flag off → app resumes local materialization; backend rows become harmless duplicates the home dedup already collapses.

Phase 3 — cleanup. Backfill-delete stray legacy UUID protocol rows (the existing #694 duplicates) once all users are flagged and stable.


Verdict summary

  • #1 field list — LOCKED (table above; scheduledUntil is a second write target on the user doc).
  • #2 clinic-tz — CONFIRMED derivable client-side; lock the no-tz fallback (recommend clinic-primary-tz→UTC, not device).
  • #3 subscribe — rolling-window start-range listener, no composite index; id-preserving ingest is the new build.
  • #4 migration — app owns local cleanup with un-synced-completion guard; backend materializes per-flag and never deletes legacy.
  • Blockers: inbound ingestion + primary-id promotion (both Phase 1). No Rust rewrite; offline preserved.