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:
git pullmaster, read this doc + run the verify tool,- do their piece,
- append a dated entry to the Findings log below + update "Next action / whose turn",
- 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 legacyexpected-*dupe). - Flag = double gate: global
config/featureFlags.backendOccurrencesEnabledAND per-userusers/{uid}.backendOccurrencesEnabled.
Phases
| Phase | Owner | Status |
|---|---|---|
| 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:
- Mobile: fix the withdrawal-delete so ingestion never deletes backend occurrences from Firestore. This is the unblock, not a side-cleanup.
- Backend: (optional) trim the fan-out horizon from +90d toward the ~6-week app window + add a defensive re-assert. Minor — not the cause.
- Then backend re-authors near-term → both run the verify tool with
--watchacross 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 carrydeviceInfo(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 rollingnow+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.
- Sync verify — should PASS now.
set_backend_occurrences.py --manage <uid> <pid>a test protocol, thenverify_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_horizonre-stampssource:'expected'on docs the app's write-through stripped). So verify should read PASS + STABLE across a refresh cycle now, not FAIL. - #634 — render the 33 types (they're now REAL enum members). The earlier gap is fixed: the
33
BasisEventTypemembers (habitMeasureWeight, habitPhysicalTherapy, habitFitnessTest6MWT, …) are now inevent_type.darton 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. - #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. - 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,
typepresent. Deletion fingerprint = app window [−21,+45]. Phase 1b blocked on mobile withdrawal-delete fix. - 2026-08-10 · mobile: withdrawal-delete fix landed (
wt/hybrid43bb93cf4, 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 isbackendManaged.assign_protocolstamps it (rollout flagbackendManagedProtocols, 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 thebackendManaged→ skip-local-materialization guard, then joint--watchproof. - 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 → _writeEventDocmirrors EVERY locally-stored event back to Firestore, including ingested backend occurrences. The local round-trip stripssource(additive Dart field, absent from the Rust FFI event struct), so each mirrored occurrence lands back assource: none. The verify tool countssource=='expected'→ in-window docs vanish from the count. Confirmed the docs are NOT deleted: 45/45 in-windowcirc_athl_*still exist in Firestore, just source-stripped; app logsIngested … (local-only)and renders them. Survivors sit past +45d only because the app never ingests beyond its window, so those keepsource. Making the ingest local-only (ServiceLocalDB.storeLocalEvents, committed0d59007a6) was necessary but insufficient — the mirror fires on ANY local store. Proper fix (needs care — core sync file):_writeEventDocmust SKIP backend-managed occurrences. Natural signalsourceis 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-assertsourceon 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. Theverify_protocol_occurrences.pyFAIL is CONFIRMED to be only the source-label artifact (mirror re-writes backend docssource: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-assertsourceon 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).- Canonical occurrence id (item 1):
occurrenceDocIdnow branches on the parsed per-protocolbackendManagedflag → for a managed protocol a generated (gap-fill, beyond the backend horizon) occurrence keys by canonical{recurringEventId}_{YYYY-MM-DD}(zero-padded, matching yourstrftime('%Y-%m-%d')) instead of legacyexpected-{habitId}-{unixSec};occurrenceCreateFields.idinherits it, so a portal completion binds to (or pre-creates) the backend's doc instead of orphaning. Date isscheduledDate's LOCAL Y/M/D, deliberately NOT an instant→clinic-tz conversion: the portal fabricates each occurrence aslocalMidnight(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;occurrenceCreateFieldsalready stampsrecurringEventId: event.habitId,source:'expected').- ✅ LIVE ID-MATCH VERIFIED (2026-08-11, updated): backend re-authored George's canary (
op_circuit_athletic_recovery_1784948946,habitCircuit, habitcirc_athl) → 59 canonical occurrences now live. Ran the portal's exactoccurrenceDocId(managed branch) against every one: 59/59 matched, 0 mismatched (circ_athl_2026-08-10… allrid: circ_athl, recent onessource:'expected',type:'habitCircuit'). Both prior questions answered by the backend agent: (a)occurrence_key= habit id (backend stampsrecurringEventId = habitIdeven when the habit doc's own field was undefined) — the portal'sevent.habitIdkeying 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 legacyexpected-*). Item 1 is empirically confirmed to bind.
- ✅ LIVE ID-MATCH VERIFIED (2026-08-11, updated): backend re-authored George's canary (
- #634 measure types:
measurementFieldsFornow resolves allhabitMeasure*types via aMEASURE_HABIT_TO_CODEmap →BasisHealthTypestringType (habitMeasureWeight→wei,Waist→wai,HRV→hrvs,Spo2→oxy,Temperature→tem,Glucose→glu; BP omitted, composite), each with a proper capture UI (writes ahealthSummariesdatapoint: top-levelvalue+extracted[key]). Codes verified againstbasiscore/.../basis_health_type.dart. Rehab/lifestyle types (habitPhysicalTherapy, habitFitnessTest6MWT, …) render viagetActivityIcon's generic fallback (measure→Activity icon). OnlyhabitMeasureGlucoseexists in prod data today; the new types are ready for when they land. - #506 notes hierarchy: renders protocol-level (
protocol.descriptionalready + newprotocol.notes), activity-level (habit.notes, falling back tohabit.description), and item-level (per-exercise.note/.notes, per-supplement.note/.notes, plus supplement groupsuggestedUse) — in both the activity list and the detail sheet. Prod data confirms 8 habits carryh.notes(e.g. "Take 30-60 minutes before target bedtime…") and 31 carryh.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.
- Canonical occurrence id (item 1):
- 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 — the4bdd339fregression is gone). BUT managed-protocol COMPLETIONS don't persist: after 'mark complete',circ_athl_2026-08-10.completionStatusstaysunknown(onlycircuitStepCompletionswrote, 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 persistingcompletionStatus:completedfor a managed occurrence). This is Phase 2 (write-back cutover), which was deferred — and the globalbackendManagedProtocolsflag is currently ON in prod, so every new managed protocol has broken completion/adherence on-device. Recommend: flipbackendManagedProtocolsOFF (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 writessource = 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-10→completionStatus:'Completed',status:'Completed',habitId:'circ_athl',recurringEventId:'circ_athl',protocolId:'op_circuit_athletic_recovery_1784948946', freshcompletedAt. ✅
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:
- Reads the wrong store. Counts
clinicsv2/{clinic}/clinic_users/{uid}/eventsbyhabitId. That mirror carriescirc_athl_2026-08-10but withcompletionStatus:'unknown'— the user-doc completion ('Completed') never propagated to the clinic mirror (the user→clinic trigger didn't update it). The source of truth isusers/{uid}/events, where the completion definitively lives. - Casing. Counts
completionStatus in ('completed','autoCompleted')(lowercase). Canonical occurrences store the model's.stringType→'Completed'(PascalCase). Needs normalized/case-insensitive compare. - 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 →
adherencefield 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.overallto 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_clinicrecomputes 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.