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

Canonical source: architecture-improvements.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.

Basis platform — architecture improvements working file

Living document. Captures recurring weaknesses and the structural changes that would compound improvement across features.


I. Foundational issues (from prior session work)

1. Silent error swallowing

Pattern of try { ... } catch (_) {} throughout writers, backfills, slot generation, cross-booking checks. Every silent catch is a place the system fails invisibly. Most "broken but I don't know why" symptoms trace here. Ban bare catches; require explicit error propagation or structured logging with a known prefix.

2. Monolithic files

  • service_health_firestore_writer.dart — 6,000+ lines
  • functions_clinic.py — 14,000+ lines
  • schedule.dart — 4,000+ lines

No one can hold the picture. Tracing one feature touches a dozen line ranges. Split by domain pipeline — one file per metric/feature (workouts, sleep, labs, slots, cancel, etc.), each 200–500 lines.

3. Multiple parallel representations of the same data

Examples:

  • HRV RMSSD lives in TerraDaily container payload + hrvr short-code doc + dailyDigest.hrvRmssd
  • Max HR: mhr short-code doc + hrDaily.maxHr field
  • Cross-booking: MembershipType.crossBookingLimit/Period model fields + preferences/crossBooking + preferences/memberships.crossBookingRuleIdByMembershipId mapping
  • HR zones: per-zone docs + digest zone fields + Terra payload pre-bucketed

The "backfill" functions exist because of these gaps. Pick one canonical Firestore representation per metric. Delete the others.

4. No typed schema between writer and reader

basishybrid writes one shape, basisflow-web reads assuming another. Everything cast as Map<String, dynamic>. Field rename = silent break (renders empty). Single shared contract package (Dart + TS) that both sides import. Renaming a field becomes a compile error in both.

5. Hidden cross-cutting static state

ServiceHealthFirestoreWriter.suppressWrites, ServiceSourceProvider.suppressAnalytics, ServiceHealthDeviceSync.suppressSyncs — set true in a screen's initState, false in dispose. If dispose doesn't run (crash, force-quit, route stack quirk), the static stays true and writes silently stop. Replace with scoped context that dies with the screen.

6. No commit cadence

Local 1,462-line functions_clinic.py diff and 1,600-line writer diff ride deployed/shipped without git records. No bisect, no forensic recovery. At minimum: auto-tag the working tree on a snapshots/ branch before each deploy/IPA build.

7. Dead code that looks alive

  • _writeDailyDigestLegacy — 200+ lines, never called
  • _upsertDailyHrSummaryTerra — literal no-op

Cannot distinguish live from dead by reading. Delete or move to __archive__/.

8. Plugin pinned to moving branch

health: ref: dev in pubspec.yaml. Basis-Health/flutter-plugins:dev can update silently. Pin to commit hash; bump deliberately with notes.

9. Multi-writer per metric

aca is written by service_health_firestore_writer.dart line 1297 (per-sample HealthKit), line 1499 (Terra Daily), line 6136 (Apple workouts) — each with different semantics (increment vs set, different type codes). Whoever writes last wins. One canonical writer per metric type; other paths feed into it, not write directly.

10. State semantics encoded in null/empty checks

_availableSessions == null means loading, [] means loaded-empty, populated means data. The slot-empty bug today was exactly this — empty list got set when it should have been "load failed, retain previous." Sealed states: Loading | Empty | Loaded(data) | Error(msg).


II. New architectural goals

A. Metric parity: basishybrid ↔ basisflow-web (100% identical)

Root cause of parity breakage today is the three-headed pipeline (live writer / backfill / basisflow-web reader fallback) combined with the four representation paths (TerraDaily payload, short-code doc, dailyDigest field, raw healthSummary). Each consumer has its own resolve order. They drift.

Required architecture:

  • One canonical Firestore doc per (uid, day, metric). All writers write to it. All readers read it. Period.
  • Server-side resolver, not client-side. A Cloud Function (or scheduled compaction) reads all incoming raw signals (HealthKit per-sample, Terra payloads, manual entries, clinician entries) and produces the canonical doc. basishybrid and basisflow-web both read that doc — they never re-derive.
  • Schema versioned. Each canonical doc has a schemaVersion. Migrations are explicit.
  • Compaction is idempotent. Running it again on the same inputs produces the same output.

B. Multi-source data ingestion (Apple + Terra + meal scan + manual + clinician writes + dupes)

Today the system implicitly first-write-wins or last-write-wins depending on the writer. No explicit resolution.

Required architecture:

  • Raw layer: every signal is stored verbatim, tagged with (source, sourceId, deviceId, ingestedAt). Nothing is deleted. Steps from Apple Watch and from Oura via Terra both land here as separate rows.
  • Source preference policy per metric, configurable per-user (or with sensible defaults). Examples:
    • Steps: Apple Watch > Oura > Manual (because watch is more accurate during workouts)
    • Sleep: Oura > Apple Watch
    • Weight: Manual entry (latest) > scale sync
    • Active calories: prefer workout-attached value > daily summary
  • Resolver layer produces the canonical per-day value by applying the preference policy to the raw layer.
  • Conflict surface: when two sources disagree by >X%, log a structured event so clinicians can review.
  • Bidirectional writes: clinician writes (from basisflow-web) land in the raw layer with source='clinician' and feed the same resolver. basishybrid sees them on next read of the canonical doc.
  • Time-window dedup for overlapping samples: HR samples from Apple Watch + Oura at the same minute don't double-count; the resolver picks one per window.

C. Protocols & labs: correctness + audit trail

Currently:

  • Protocols stored in clinicsv2/{clinic}/clinic_users/{uid}/protocols/{id} AND users/{uid}/protocols/{id} mirror — drift possible
  • recurringEvents[] on user doc AND instance events — drift possible
  • FFI bridge strips fields (medicationPreset, supplementPreset, mealPreset) on basishybrid round-trips → mirror docs and user doc diverge silently
  • Lab data lives in users/{uid}/healthSummaries AND users/{uid}/labs (flattened) AND clinicsv2/{clinic}/clinic_users/{uid}/labs mirror
  • Changes are written direct; no version history

Required architecture:

  • Single source of truth per protocol/lab: mirrors are derived/cached, never edited directly.
  • All mutations through a Cloud Function that writes both the canonical doc AND an entry in users/{uid}/transactions with the diff. (We already have a transactions collection; use it consistently.)
  • Read the history: every protocol or lab edit can be rendered as a timeline.
  • Fix the FFI strip: either add protocolId, clinicId, habitId, medicationPreset, supplementPreset, mealPreset, workoutPreset to the Rust bridge struct, OR move presets to an out-of-band lookup that doesn't go through FFI (e.g. store on the protocol mirror, look up by protocolId at render time).
  • No client-side direct writes to canonical docs. Currently basisflow-web does setDoc in places — should be a callable that writes via the transaction path.

D. Android build — QR code reader replacement

Current blocker per user: QR scanner package is iOS-only.

Required architecture:

  • Abstract scanner behind a BasisQrScanner interface with platform implementations
  • iOS: keep current package
  • Android: use mobile_scanner or qr_code_scanner (both have Android support)
  • Test the QR flow on Android — check-in is the main use case; needs to round-trip a known QR payload

Beyond QR, before Android ships:

  • HealthKit replacement (Google Health Connect) — different permission model, different type mappings. Already partially in place (service_health_io.dart Android branch).
  • Push notifications (FCM is cross-platform — should work)
  • Background fetch (different APIs — already using a cross-platform package)
  • Apple Sign In / Apple Pay → Google equivalents or removed
  • App size: Android builds bloat — likely needs ProGuard/R8 tuning

III. Additional weaknesses

Identity fragility

OTP custom-token sign-in + Firebase Auth UID + clinic_users-by-email + multiple possible UIDs to check (we saw this in cancel_appointment with 4 different UID lookups). One user can have multiple Firebase Auth UIDs across re-installs and clinic invites. Single, canonical user identifier resolved server-side; clients should never have to "try multiple UIDs."

No feature flags / staged rollout

Every backend deploy is 100% global. Every IPA is global. No "enable for one clinic" or "5% rollout." Risk-of-change is high. Add a feature-flag layer (Firestore-backed is fine for v1). Reads default-off in production until flipped.

No coordinated deploy

basisflow-web (Firebase Hosting) + basisweb (Netlify) + basishybrid (App Stores) + basis-functions (Cloud Functions) all deploy independently. Frontend can ship expecting a backend field that's not deployed yet. At minimum: pre-flight check that runs on each frontend deploy and verifies the backend version it depends on is live.

Firestore as integration bus, no contract

Most cross-app data flow is via Firestore reads/writes. Schemas are implicit. Field rename = silent break. (Same as Foundational #4, but worth restating from the integration angle.)

Background work has no observability

Background fetch, FCM, HealthKit observers, Terra webhooks, Cloud Function cron jobs run async with weak signal. When they fail (we saw the webhook stub broken for months) no one notices until users complain. Each background job emits a structured success/failure event to a system/backgroundJobs collection. Dashboard surfaces gaps.

No environment separation

Terra dev IDs split-brained between prod and staging this session. pubspec.lock resolves to the same plugin commit for both. Backend secrets sometimes get crossed. Strict env namespacing; assertions at startup that all configured services point to the same env.

No schema migrations

Field rename or shape evolution leaves old docs in old shape forever. Readers have to handle both. A migrations framework that runs server-side (Cloud Function on schedule) and brings docs forward to the current schema.

No data retention / cleanup

healthSummaries accumulate forever (we saw 500+ docs in 30 days for one user). The Mar 6 = 118,275 kcal inflated docs are still there. Retention policy per collection. Archive or delete docs older than N days where applicable. Mark known-bad docs as archived: true.

Cost / quota blindness

RESOURCE_EXHAUSTED on persist_user_doc callable today, OOM on multiple functions, no budget alerts. Per-function memory tuning (every function at MB_512 is a known footgun per CLAUDE.md). Budget alerts wired to Slack.

Cache invalidation is ad-hoc

ServiceSourceProvider cache, ServiceDayMemory cache, file-based provider caches. When something changes, who invalidates what? Document the invalidation rules per cache. Better: prefer cache-keyed-by-content (so stale never serves wrong data).

No tests

No visible unit/integration/e2e tests in the writers, slot generation, cancel, protocol assignment paths. Every change is verified by you running a debug build and pasting logs. Even minimal unit tests for the canonical writers — "given TerraDaily payload P, expect doc set D" — would catch most regressions we discussed today.

Time/timezone handling

UTC vs local vs TZ-aware DateTime mixed across writers. Each writer derives day key inline. We discussed this around HR zones. Single dailyDayKey(BasisSummaryV1 s) → DayKey helper, used everywhere. Lint rule that fails on inline DateTime(year, month, day) in writer code.

Map<String, dynamic> at every Firestore boundary

Per CLAUDE.md: Firestore field types are never guaranteed at runtime. We coerce defensively but inconsistently. Wrap every Firestore read in a typed parser that fails loudly on shape mismatch.

Localization drift

1,747+ untranslated messages in ar/fr/de (visible in build output). CI fails the build if untranslated count grows.

Routing/navigation fragility

Logout race, RouteIntro guards bouncing back to home, Navigator scope confusion. Routing logic is scattered. A single AppRouter that owns post-auth navigation decisions; the rest of the app declaratively says "I want to be here," router enforces.


IV. Highest-leverage interventions

Picking the three with the biggest compounding effect:

  1. Stop swallowing errors. Replace every bare catch with a structured log (one line, known tag prefix, retryable flag). This single change makes every subsequent diagnosis 10× faster because the system tells you what's wrong instead of you guessing.

  2. Single canonical representation per metric (and per protocol/lab). Kill the parallel paths. Pick the doc shape. Migrate. Then the writer-vs-reader-parity problem evaporates because there's only one writer and one reader.

  3. Shared schema contract package. Dart + TS. Both apps import. Field rename = compile error in both. End of silent format drift.

If those three land, the remaining issues (commit cadence, dead code, feature flags, etc.) become tractable individually.