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

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

Protocol single-source — cross-agent handoff

Purpose. The coordination channel for the protocol single-source cutover (#398/#673) between the backend/main agent (owns basis-functions, master) and the mobile agent (owns basishybrid/basiscore, wt/hybrid). Instead of relaying prose through George, both agents:

  1. git pull master, read this doc + run the verify tool,
  2. do their piece,
  3. append a dated entry to the Findings log below + update "Next action / whose turn",
  4. commit (backend → master; mobile → its branch, and copy the log entry here on master when it merges).

George's role is a one-line trigger — "sync the handoff doc + run the verify tool" — not a transcriptionist.


The objective arbiter — run this first, always

python3 basis-functions/tools/verify_protocol_occurrences.py <uid> --watch 120

Reports coverage (holes in today→+6wk?), stability (churn over the watch window), and provenance (backend-authored vs app-written deviceInfo; canonical vs legacy id; type present). The DATA arbitrates — do not relay interpretations the tool already answers. George's test uid: GoOU3d2QcyRZMD83IhbQeIclpwh2 (his own account; canary-safe).

Staged-rollout lever (backend): python3 basis-functions/tools/set_backend_occurrences.py --check | --user-on <uid> | --global-on | --regenerate <uid>


The contract (what the backend emits into users/{uid}/events)

  • id = {recurringEventId}_{YYYY-MM-DD} (clinic-local date via _resolve_booking_tz(clinic.locations, None) → clinic-primary tz → UTC; never per-occurrence location, never device).
  • recurringEventId on every occurrence + recurrenceRule.recurringEventId set.
  • source = 'expected' (ingestion predicate).
  • type (mobile reads this) + eventType (web) — both emitted.
  • presets (workout/supplement/medication/circuitPreset), name, start, end, protocolId, habitId, clinicId, lastChanged, reminders[], program linkage (programId/programLineId/linkedAppointmentId).
  • No status/completionStatus — missing = "not yet done"; write-backs own completion.
  • Window: now → +90d (fan-out policy). Canonical-only in the user mirror when the flag is on (no legacy expected-* dupe).
  • Flag = double gate: global config/featureFlags.backendOccurrencesEnabled AND per-user users/{uid}.backendOccurrencesEnabled.

Phases

PhaseOwnerStatus
0 — backend foundation (canonical emit, flag, preservation)backend✅ deployed
0 — mobile groundwork (occurrence_key, legacy-med read, program fields)mobile✅ done
1a — mobile inbound ingestion (read users/{uid}/events → SQLite)mobile✅ code-complete
1b — retire local getInstances (backend = sole producer)mobile🚫 BLOCKED — see below
2 — write-back cutover + staged prod flip (#697)both⏳ after 1b

OPEN — the Phase-1b blocker (as of 2026-08-10)

Symptom (verify tool): coverage FAIL — 0 canonical occurrences in today→+6wk; full range Sep 25 → Nov 8.

Root cause (evidence, not opinion): the survivors start at Sep 25 = today + 45d + 1, exactly the upper edge of the app's ingestion window [−21d, +45d]. Everything inside the app window was deleted; everything outside survived. ±45d is a mobile constant — no backend/scheduled job knows it. → the deleter is the mobile app's withdrawal-delete, not backend far-future churn. The backend authored today-forward correctly (verified 83 August occurrences earlier 2026-08-10, stable 55s) and the app then removed the in-window ones over the following hours.

Agreed plan:

  1. Mobile: fix the withdrawal-delete so ingestion never deletes backend occurrences from Firestore. This is the unblock, not a side-cleanup.
  2. Backend: (optional) trim the fan-out horizon from +90d toward the ~6-week app window + add a defensive re-assert. Minor — not the cause.
  3. Then backend re-authors near-term → both run the verify tool with --watch across a scheduled-refresh cycle (hours, not 55s) → if coverage PASS + STABLE → Phase 1b proceeds.

Do NOT re-author George's near-term until the withdrawal-delete fix is in — it will just be deleted again (we'd be feeding the deleter).

SCOPE: net-new single-source only (George, 2026-08-11) — no migration

Existing protocols stay pure local-materialization, untouched. Only NEWLY-assigned protocols become single-source, via a per-protocol flag backendManaged: true stamped at assignment. Backend authors a managed protocol's occurrences; the app skips local materialization for it → nothing to dedupe/delete. This deletes the whole coexistence problem (deleter #2 reschedule is moot — a managed protocol never materializes locally).

Next action / whose turn — OPTION (A) chosen: server keeps managed activities out of the app's list

The mobile guard hit a wall (the app's local DB drops any new per-activity flag). Agreed option (A), server-side, so the app needs no guard at all: ✅ Backend (main agent) — DONE + deployed (2fae9e6bb, 3cbc1ccbf): per-protocol gate + backendManaged stamping + authoring, AND a managed protocol's habits are never written into users/{uid}.recurringEvents[] (the app's local-materialization source) — assign_protocol skips the append, update_protocol prunes on edit, and --manage prunes an existing protocol retro-fit. So the app's engine never sees a managed protocol's activities → nothing to rebuild, nothing to dedupe, true single source. Offline contract test green. ➡️ Mobile (hybrid agent) — VERIFY only (no guard to build): flag a test protocol with set_backend_occurrences.py --manage <uid> <pid> (it stamps managed, prunes recurringEvents, authors occurrences), then confirm on-device that the protocol renders purely from ingested backend occurrences (no local materialization, no double). If it holds, Phase 1b is done from the app side. ➡️ Then both: verify_protocol_occurrences.py <uid> --watch across a refresh cycle → PASS + STABLE ⇒ Phase 1b done. ℹ️ George's live account stays pure-local (his protocols have no backendManaged flag; global rollout flag OFF).

Deferred to 1b/2 (flagged, not blocking)

  • Ingest write-through (storeLocalEventsForUser) echoes backend docs back to Firestore — redundant, and echoed docs would carry deviceInfo (surfacing as "app-written" in the verify tool's provenance). Not destructive. Make ingest local-only in 1b/2.
  • Backend horizon trim ✅ DONE (f00a18f98): fan-out is now a strict rolling now+56d (8wk), capped earlier by scheduledUntil — no far-future tail. Daily refresh rolls it forward + re-emits missing in-window days (daily near-term re-assert).

Consolidated MOBILE pass — NOW THE CRITICAL PATH (updated 2026-08-11, flag is ON)

The global rollout flag config/featureFlags.backendManagedProtocols is ON — newly-assigned protocols are now managed (single-source) in prod. They render correctly on the provider side and on any phone with this new build, but on the current (old) app a managed protocol shows EMPTY (no ingestion). So shipping this IPA is what makes managed protocols actually work on phones. git pull master first — all backend + basiscore changes below are already on master.

  1. Sync verify — should PASS now. set_backend_occurrences.py --manage <uid> <pid> a test protocol, then verify_protocol_occurrences.py <uid> --watch. Today's fixes landed: the provider-double (managed writes canonical to BOTH paths, no legacy) and the source self-heal (refresh_protocol_horizon re-stamps source:'expected' on docs the app's write-through stripped). So verify should read PASS + STABLE across a refresh cycle now, not FAIL.
  2. #634 — render the 33 types (they're now REAL enum members). The earlier gap is fixed: the 33 BasisEventType members (habitMeasureWeight, habitPhysicalTherapy, habitFitnessTest6MWT, …) are now in event_type.dart on master (indices 214–246, matching the backend ints) + a CI drift gate prevents recurrence. So they now PARSE (no more unknown-drop). Confirm they render — generic fallback is fine; measurement types ideally get a log-a-value affordance.
  3. #506 — render the notes hierarchy client-visible: protocol-level (protocol.description/ .notes), activity-level (habit.notes), item-level (per-exercise/per-supplement .note). Backend cascade fills activity notes when the coach picks "apply to all"; app just displays the three levels.
  4. Completions write to the CANONICAL occurrence (NOT a separate doc) — in THIS build, not later. Confirmed on George's data: the app currently saves circuit step-completions (and completions in general) to its own UUID doc (4bdd339f…, source: io.basishealth.app) instead of the canonical occurrence {recurringEventId}_{zero-padded YYYY-MM-DD}. So "done" doesn't bind to the occurrence → the provider/coach view won't see it completed. For a managed protocol, point all write-backs (completion / skip / circuit steps / logged values) at the canonical id. This is single-source's WRITE direction and must ship in the same IPA — otherwise marking activities done silently fails across surfaces.

Then ship the IPA. All four above are the gate to managed protocols working on phones — render AND completions. (No per-user readiness flag / no app-side guard needed — George chose a straight global flip.)

Findings log (append-only, newest last)

  • 2026-08-10 · backend: Phase 0 deployed; 1a contract confirmed (source/type/program fields, canonical-only mirror, create-only refresh). Built verify_protocol_occurrences.py + this doc to end the prose relay.
  • 2026-08-10 · backend: verify tool on George → coverage FAIL (0 in-window, range Sep25→Nov8), all 76 backend-authored, type present. Deletion fingerprint = app window [−21,+45]. Phase 1b blocked on mobile withdrawal-delete fix.
  • 2026-08-10 · mobile: withdrawal-delete fix landed (wt/hybrid 43bb93cf4, analyze clean) — removed docChanges are observe-only; app never mirror-deletes backend-owned docs. Confirmed same root cause as the empty-Today (the delete also removed local materialized activities sharing the canonical id). Fix NOT yet on-device. Flagged write-through follow-up. Verify tool: FAIL + STABLE (nothing left in-window).
  • 2026-08-10 · backend: acknowledged. HOLDING re-authoring until the fixed build is confirmed live on George's device. Next = George build+install; then backend re-authors + both --watch.
  • 2026-08-11 · backend: Net-new per-protocol gate BUILT + DEPLOYED (2fae9e6bb). Retired the per-user occurrence gate; the fan-out now authors canonical occurrences iff the protocol is backendManaged. assign_protocol stamps it (rollout flag backendManagedProtocols, default OFF) + authors; update/regenerate/refresh read+pass it. --manage/--unmanage <uid> <pid> in the tool for the manual test protocol. Flag-ON contract test proves managed→canonical (id/rid/rule/source/type, no status), unmanaged→none. Ball's with mobile: build the backendManaged → skip-local-materialization guard, then joint --watch proof.
  • 2026-08-11 · mobile: ROOT CAUSE of the whole "near-term deleted" saga found — it was never a deletion. The app's global event-mirror ServiceHealthFirestoreWriter.onEventCreated → _writeEventDoc mirrors EVERY locally-stored event back to Firestore, including ingested backend occurrences. The local round-trip strips source (additive Dart field, absent from the Rust FFI event struct), so each mirrored occurrence lands back as source: none. The verify tool counts source=='expected' → in-window docs vanish from the count. Confirmed the docs are NOT deleted: 45/45 in-window circ_athl_* still exist in Firestore, just source-stripped; app logs Ingested … (local-only) and renders them. Survivors sit past +45d only because the app never ingests beyond its window, so those keep source. Making the ingest local-only (ServiceLocalDB.storeLocalEvents, committed 0d59007a6) was necessary but insufficient — the mirror fires on ANY local store. Proper fix (needs care — core sync file): _writeEventDoc must SKIP backend-managed occurrences. Natural signal source is FFI-stripped, so key off has recurringEventId but its recurring-event is absent from user.recurringEvents (pruned at managed assignment) → skip mirror. Risk: could wrongly skip transient/orphan events — wants review. Alternative (backend): re-assert source on refresh, or verify tool shouldn't treat source-strip as absence. Ship-gate note: the USER behavior (managed protocol renders once from ingested occurrences) is likely CORRECT already — the verify FAIL is a source-label artifact, not a user-facing deletion. Recommend deciding mirror-skip (mobile) vs source-reassert (backend) before calling the gate.
  • 2026-08-11 · mobile — ON-DEVICE CONFIRMED ✅: Managed op_circuit (backendManaged, pruned from recurringEvents, 57 authored) renders correctly on George's phone: once for today + once for yesterday as a missed activity, no duplication, correct completion state — purely from ingested backend occurrences (no local materialization). Net-new single-source USER BEHAVIOR is proven working on-device. The verify_protocol_occurrences.py FAIL is CONFIRMED to be only the source-label artifact (mirror re-writes backend docs source:none), NOT a user-facing defect. Ship-gate spirit met (managed protocol renders once + holds); the LETTER (verify PASS) needs the source-strip resolved. Decision to main agent: mobile mirror-skip (delicate core-sync change) vs backend re-assert source on the refresh cycle (simpler, backend-side, also fixes the write-amplification). Recommend backend re-assert if the refresh already rewrites these docs — then the app's strip is self-healing and verify passes each cycle.
  • 2026-08-11 · basisweb (portal): All three portal items done in hybrid/basisweb/app/portal/protocols/[id]/page.tsx (feat/basisweb), tsc-clean, verified against prod protocol docs (tools/verify-protocol-notes-measures.js, 87 assignments / 444 habits).
    1. Canonical occurrence id (item 1): occurrenceDocId now branches on the parsed per-protocol backendManaged flag → for a managed protocol a generated (gap-fill, beyond the backend horizon) occurrence keys by canonical {recurringEventId}_{YYYY-MM-DD} (zero-padded, matching your strftime('%Y-%m-%d')) instead of legacy expected-{habitId}-{unixSec}; occurrenceCreateFields.id inherits it, so a portal completion binds to (or pre-creates) the backend's doc instead of orphaning. Date is scheduledDate's LOCAL Y/M/D, deliberately NOT an instant→clinic-tz conversion: the portal fabricates each occurrence as localMidnight(D)+ruleTime(<24h), so its local Y/M/D already == the intended calendar day D == the clinic-local day you author (a day label is tz-independent). Converting the local-anchored instant into the clinic tz would shift it ±1 day whenever viewer-tz ≠ clinic-tz — so I don't fetch clinic tz at all for this. recurringEventId = event.habitId (the portal's existing convention; occurrenceCreateFields already stamps recurringEventId: event.habitId, source:'expected').
      • LIVE ID-MATCH VERIFIED (2026-08-11, updated): backend re-authored George's canary (op_circuit_athletic_recovery_1784948946, habitCircuit, habit circ_athl) → 59 canonical occurrences now live. Ran the portal's exact occurrenceDocId (managed branch) against every one: 59/59 matched, 0 mismatched (circ_athl_2026-08-10 … all rid: circ_athl, recent ones source:'expected', type:'habitCircuit'). Both prior questions answered by the backend agent: (a) occurrence_key = habit id (backend stamps recurringEventId = habitId even when the habit doc's own field was undefined) — the portal's event.habitId keying is correct and binds. (b) circuit step-completions writing to a separate UUID doc is the Phase-2 mobile write-back cutover, not a portal bug — occurrence-completion and circuit-step docs are intentionally separate (as they already are for legacy expected-*). Item 1 is empirically confirmed to bind.
    2. #634 measure types: measurementFieldsFor now resolves all habitMeasure* types via a MEASURE_HABIT_TO_CODE map → BasisHealthType stringType (habitMeasureWeight→wei, Waist→wai, HRV→hrvs, Spo2→oxy, Temperature→tem, Glucose→glu; BP omitted, composite), each with a proper capture UI (writes a healthSummaries datapoint: top-level value + extracted[key]). Codes verified against basiscore/.../basis_health_type.dart. Rehab/lifestyle types (habitPhysicalTherapy, habitFitnessTest6MWT, …) render via getActivityIcon's generic fallback (measure→Activity icon). Only habitMeasureGlucose exists in prod data today; the new types are ready for when they land.
    3. #506 notes hierarchy: renders protocol-level (protocol.description already + new protocol.notes), activity-level (habit.notes, falling back to habit.description), and item-level (per-exercise .note/.notes, per-supplement .note/.notes, plus supplement group suggestedUse) — in both the activity list and the detail sheet. Prod data confirms 8 habits carry h.notes (e.g. "Take 30-60 minutes before target bedtime…") and 31 carry h.description; item-level .notes are 0 today (your "apply to all" cascade hasn't populated stored docs) — the reads are correctly-keyed + additive, so they light up the moment the cascade writes them.
  • 2026-08-11 · mobile — IPA RETEST ⚠️ NOT READY for managed: On the new build (all merged + #698 completion-binding + #634 measure): render + verify PASS, and the completion-binding fix works (George's circuit step-completions landed on canonical circ_athl_2026-08-10, no UUID — the 4bdd339f regression is gone). BUT managed-protocol COMPLETIONS don't persist: after 'mark complete', circ_athl_2026-08-10.completionStatus stays unknown (only circuitStepCompletions wrote, via the direct Firestore path). Result: circuit shows 0% adherence + adherence missing on the plan detail page — same root cause. 0 ingests this session, so it's NOT re-ingest clobber; it's the write-back path itself (updateEventForUser→mirror not persisting completionStatus:completed for a managed occurrence). This is Phase 2 (write-back cutover), which was deferred — and the global backendManagedProtocols flag is currently ON in prod, so every new managed protocol has broken completion/adherence on-device. Recommend: flip backendManagedProtocols OFF (new protocols → local materialization, where completion+adherence work) until Phase-2 write-back is built + retested; ship this IPA for everything else (enum types, measure, notes, guest, badge, completion-binding). Also note: the mirror writes source = e.deviceInfo?.sourceId (service_health_firestore_writer.dart ~5043) — that's the source-strip mechanism (a completion write with null deviceInfo → source:none), relevant to the write-back design.

2026-08-11 · mobile — WRITE-BACK NOW WORKS; adherence is a BACKEND-ONLY fix (hand to backend agent)

Supersedes the "completions don't persist" note above. The mobile write-back is now fixed and device-verified. _persistOccurrenceStatus (rp_activity_route.dart) writes the completion directly to the canonical occurrence with the correct PascalCase status. Confirmed live:

  • users/GoOU3d2QcyRZMD83IhbQeIclpwh2/events/circ_athl_2026-08-10completionStatus:'Completed', status:'Completed', habitId:'circ_athl', recurringEventId:'circ_athl', protocolId:'op_circuit_athletic_recovery_1784948946', fresh completedAt. ✅

So the ONLY remaining defect is server-side adherence compute — NOT a mobile change. Owner: backend agent.

Where it breaks (functions_clinic.py::_generate_protocol_summary, ~L1945)

Three independent defects, any one zeroes the result for a backendManaged protocol:

  1. Reads the wrong store. Counts clinicsv2/{clinic}/clinic_users/{uid}/events by habitId. That mirror carries circ_athl_2026-08-10 but with completionStatus:'unknown' — the user-doc completion ('Completed') never propagated to the clinic mirror (the user→clinic trigger didn't update it). The source of truth is users/{uid}/events, where the completion definitively lives.
  2. Casing. Counts completionStatus in ('completed','autoCompleted') (lowercase). Canonical occurrences store the model's .stringType'Completed' (PascalCase). Needs normalized/case-insensitive compare.
  3. Never runs for ACTIVE protocols. Only fires (a) lazily on a past protocol with no summary, and (b) on status→ended/stopped/deleted (~L3127). An active protocol never computes adherence → adherence field absent → mobile detail renders no component AND the listing pill falls to a broken local fallback.

Proven fix (validated offline against George's real data → 33%)

Read from users/{uid}/events where protocolId == pid, keep only canonical ids (regex _\d{4}-\d{2}-\d{2}$ — excludes the ~97 legacy UUID twins), keep only elapsed (start <= now, excludes ~56 future dates), group by habitId, count normalized completionStatus in {completed, autocompleted}. For George: 3 elapsed canonical occurrences (Aug 8/9/10), 1 completed → 33%. (Naive count over all 156 protocolId docs = double-counted twins + future = garbage; must filter to canonical+elapsed.) Keep the existing clinic-by-habitId path as the fallback for legacy (non-backendManaged) protocols so they don't regress.

Trigger for active protocols

Hook a lean adherence recompute into the existing sync_user_event_to_clinic trigger (functions_event_mirror.py:235, already fires on every users/{uid}/events/{id} write): when the written doc has a protocolId and a completion-status change, recompute + merge just the adherence field onto both protocol docs (don't run the full AI summary on every completion). Do NOT gate on if proto_doc.get('summary') — that skip must not block adherence recompute.

Acceptance (backend agent verifies)

  • Completing the managed circuit flips users/{uid}/protocols/{pid}.adherence.overall to non-zero (33% for the current state) within the recompute window.
  • Mobile listing pill shows that %; mobile protocol detail renders its adherence component (gated on data['adherence']).
  • Legacy protocols' adherence unchanged.

Tracked on #697 (full write-up appended there too). Mobile side of the single-source thread is complete — the IPA can ship independently of this backend fix.

2026-08-11 follow-ups for backend agent (after adherence went live at 33%)

  • Denominator too small (expected-count). Current compute counts only materialized canonical occurrences (George: 3 → 1/3 = 33%). For a 7-week DAILY protocol that started Jul 25 the honest denominator is all elapsed scheduled days (~18 → ~6%). Derive the expected count from the recurrence rule over [scheduledFrom, min(now, scheduledUntil)], not just from docs that happen to exist. Otherwise adherence reads artificially high whenever backend occurrence coverage lags the protocol start.

  • Confirm the recompute trigger is event-wired, not a one-shot. It ran once (generatedAt 15:52). Verify it recomputes on every future completion (hook on sync_user_event_to_clinic / the user-events write trigger), so adherence stays live as George completes more.

  • 2026-08-11 · backend (resolution of the above handoff): #697 adherence for managed protocols FIXED + deployed (spec by hybrid agent). Reads user-path canonical occurrences (not the clinic mirror), case-insensitive completion match ('Completed'), and sync_user_event_to_clinic recomputes the summary live on a completion (was end-only). Verified on George's op_circuit → 33% (3 elapsed, 1 completed), matching the agent's number. Legacy protocols keep the clinic-by-habitId path.