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 againsthybrid/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.
| Field | Type | Example | Read by / breaks if missing |
|---|---|---|---|
id | String | "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.recurringEventId | String | "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. |
protocolId | String | "op_circuit_athletic_recovery_1784948946" | filterHabitsEvents secondary match (:1373); protocol-vs-local carve-out (:1191). Missing → app may re-materialize it locally = duplicate (#694). |
type | BasisEventType (string) | "habitCircuit" | day-ahead notif count (service_notifications_io.dart:595, e.type.isHabit); display icon/route. Must be a isHabit type. |
start / end | ISO datetime | "2026-08-04T09:00:00-05:00" | day-memory window query (service_daymemory.dart:1048); duration = end−start (event.dart:474). |
completionStatus | enum (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). |
autocompletedBy | int? | 123456 | autocomplete-merge dedup (event.dart:315); leave null on a fresh occurrence — the app sets it when a wearable summary auto-completes it. |
reminder | bool | true | master reminder toggle (event.dart:259); false = all reminders off for the row. |
reminders | List | [{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/mealPreset | map | — | activity-detail rendering + med/supp lists. Present per activity kind. |
locationId | String? | "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 → elsetz.local. Usespackage: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_occurrenceKeyusesevent.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
protocolIdin the query (Firestore can't!= nullefficiently, and non-protocol events also live here). Pull the window, letfilterHabitsEvents(service_recurring_events.dart:1364) split protocol vs local in-memory — it's already author-agnostic. - Ingest each doc →
storeLocalEventsForUserpreserving the Firestore doc id as the local SQLite row id (this is the primary-id promotion; today the writer round-trips the random local idservice_health_firestore_writer.dart:981). - Incremental option (lower reads): a second listener on
where('lastChanged','>',lastSync)for deltas — needs a single-field index onlastChanged(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):
- App, on seeing a canonical
{rid}_{clinic-tz-date}row for a slot: stop local materialization for that protocol (theprotocolId != nullguard at the reschedule loop:1191), so it stops authoring competing UUID rows. - 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'scompletionStatus != Unknown, first migrate that completion onto the canonical row (Firestore.set(merge)to the canonical id) and confirm it is nothasPendingWritesbefore deleting the legacy doc. Never delete a row whose completion hasn't reached the server. - 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).
- Reconciliation precedence once ids are unified:
lastChangedlast-writer-wins viaSetOptions(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;
scheduledUntilis 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.