Skip to content

29 Design Decisions

Anubha Parashar edited this page Oct 10, 2026 · 1 revision

This page records the main architectural decisions behind HealthBook AI (internal code name: BabyCare) in a lightweight ADR form: the context that forced a choice, the decision as implemented, and its consequences. Each decision points to the code that carries it out, so it can be checked against the source rather than taken on trust. Most of them come from two constraints: clinical records must never claim more than the evidence shows, and the system runs on free hosting tiers.

Summary

# Decision Main module
1 Human verification before any structured fact api/workflows.py, api/review_validation.py
2 Separate prescription, schedule and administration tables api/models.py, api/care_models.py, api/lifetime_models.py
3 Provenance fields on every structured record api/main.py (safe_doc)
4 Application-layer Fernet encryption of clinical text and files api/crypto.py
5 Private bucket, RLS on, zero policies, service role on the server only api/storage.py, api/cloud.py, deploy/supabase/setup.sql
6 No-build vanilla JavaScript frontend web/
7 Render-pure frontend lifecycle with request de-duplication web/care.js
8 Store only hashes of session and link tokens api/security.py, api/password_reset.py, api/share.py
9 Generic forgot-password response api/password_reset.py
10 Local-first home camera api/camera_worker.py, api/camera_api.py
11 Opt-in vision with per-document consent api/providers.py
12 Generic push text; reminders only from confirmed plans api/push_web.py, api/worker.py, api/reminders.py
13 Free-tier deployment shaped around sleep and ephemeral disks render.yaml, api/cloud.py, api/guardrails.py
14 Migrations on startup with an encrypted pre-migration snapshot api/migrate.py

1. Human verification before facts

Context. OCR of handwritten cards and scanned discharge summaries is unreliable, and a wrong date or dose in a health record is worse than a missing one. Decision. Every extraction (local OCR or the optional vision provider) sets extraction_status='needs_review' and creates no records. POST /api/documents/{did}/review/confirm creates history only from rows the guardian included and marked checked_against_original. validate_review() checks all rows before anything is written and returns field-level errors (row, page, field, reason). Medicines start as draft; POST /api/medicines/{mid}/confirm requires checked_against_prescription: true, doctor-written instructions and a linked source document. Consequence. OCR extracted ≠ clinically verified. Entering data takes more steps, but nothing reaches reminders, exports or doctor links without a person's check. A confirmed review cannot be re-extracted over (HTTP 409).

2. Separate prescription vs administration tables

Context. "Was prescribed", "was scheduled" and "was given" are different facts; merging them would make reminders or schedules look like evidence of treatment. Decision. medicines holds the prescription (with course_type), reminders/notifications hold schedule and delivery, and administrations live in their own tables: medication_events (given/skipped, with source and an idempotency key), medication_administrations (daily journal; "never created from a schedule"), treatment_administrations and antibiotic_administrations. Antibiotic courses keep prescribed_*, planned_* and actual_* dates separately. Consequence. Prescribed ≠ Scheduled ≠ Given/Administered, and culture susceptibility ≠ antibiotic actually received. Analytics such as lifetime antibiotic exposure count only documented administrations and say "documented in available records".

3. Provenance fields

Context. A structured value is only as trustworthy as the original it came from, and clinicians need to see that original. Decision. Clinical tables carry source_document_id and source_page; safe_doc() rejects a document from another profile or a page outside the document. Lifetime rows carry origin (clinician_document or parent_report) and verification_status. Edits keep prior values in record_revisions and medicine_revisions. Consequence. Parent-entered and clinician-documented records stay distinguishable. Exports and shares label each statement (DOCUMENTED, PARENT REPORTED, OCR UNVERIFIED, PRESCRIBED, CONFIRMED ADMINISTERED, UNKNOWN) and link it to its appendix page.

4. Application-layer Fernet encryption

Context. Free-tier databases and buckets are operated by third parties; disk encryption alone does not protect against a leaked connection string or key. Decision. EncryptedText stores clinical text columns as enc: plus a Fernet token; every original is Fernet-encrypted before write_document(). Production requires BABYCARE_FERNET_KEY. Locally, file_crypt.key is created once, and the app refuses to generate a new key when encrypted rows or files already exist. Consequence. Database dumps and bucket objects are unreadable without the key; backups move ciphertext as stored (api/datasync.py). Dates, ids and statuses stay plaintext (so they can be queried), and losing the key makes the records unrecoverable.

5. Private bucket with RLS and zero policies

Context. Supabase exposes tables and storage to anon/authenticated keys by default, and a public bucket would leak originals. Decision. The API alone holds the service-role key. ensure_bucket() creates the bucket private and fails closed if it is public; setup.sql adds no storage.objects policies. harden_database() enables RLS on every table and revokes client-role privileges on every start, and production refuses to start if that fails. Consequence. No client can reach Supabase directly; all access goes through the API's authorization. Objects are named with random hex, never with patient data.

6. No-build vanilla JS frontend

Context. A single maintainer, a static host and a strict CSP (script-src 'self'). Decision. web/ is plain JavaScript files loaded with defer, no framework or bundler. The API can serve the same directory (local mode), or Firebase Hosting serves it with web/config.js providing apiBase. Consequence. Nothing to build in deploys; CI checks syntax with node --check and behaviour with Playwright. Shared state is global (S) and modules extend each other in load order, which needs discipline.

7. Render-pure frontend lifecycle with request dedupe

Context. A view that fetches while it renders can issue the same request many times per interaction, and the single free instance enforces a per-IP rate limit (600 API requests per minute) and may be cold-starting. Decision. In web/care.js, rendering never starts a request. dcEnsure() decides what the current view needs and calls dcLoad(key, path, apply), which returns the existing promise when the same key is in flight, caches results until invalidated, aborts in-flight requests on logout or profile change (generation counter, AbortControllers), and on HTTP 429 starts a single cooldown timer instead of retry loops. Consequence. One profile and one day means one request; tests/browser/request_storm.spec.js guards this. Mutations must invalidate cache prefixes explicitly (dcInvalidate).

8. Session-hash storage

Context. A database read must not be enough to impersonate a user. Decision. issue_session() returns a random token but stores only its SHA-256 in sessions; password-reset tokens and doctor-link tokens are also stored as hashes. Passwords use PBKDF2-HMAC-SHA256 with 390,000 iterations and a random salt. The web gets an HttpOnly, SameSite=Strict cookie (Secure in production); the mobile app gets a bearer token kept in expo-secure-store. Consequence. Sessions cannot be listed back as usable tokens. Cookie writes also need the X-Requested-With: BabyCare header (CSRF check).

9. Generic forgot-password response

Context. A reset form must not reveal which emails have accounts. Decision. POST /api/auth/password-reset/request always returns 202 with the same message; the email is sent as a background task so timing does not differ; per-email limit 3 per hour. Tokens are valid for 30 minutes, single use, delivered in the URL fragment, and completing a reset signs out every session. Consequence. No account enumeration through this endpoint; a user who mistypes an email gets no error.

10. Local-first camera

Context. Video of a home is far more sensitive than the activity summary a parent needs. Decision. api/camera_worker.py runs inside the home network, reads the stream locally and posts only derived movement/presence events with confidences, authenticated by a per-camera worker key. Stream URLs default to private-network hosts, and embedded credentials are split out and stored encrypted. Low-confidence posture events are dropped. Events become journal entries only after the guardian reviews them. Consequence. Raw video and audio never reach the API. Analysis quality is limited by what runs on a home machine.

11. Opt-in vision with per-document consent

Context. A remote vision model might transcribe handwriting better, but it sends identifiable pages to a third party. Decision. Off unless BABYCARE_VISION_URL, BABYCARE_VISION_MODEL and BABYCARE_AI_KEY are set (render.yaml leaves the URL empty). Each call needs share_document_with_provider: true and the exact configured HTTPS host, only the primary guardian can do it, it is limited to 10 pages and audited. The prompt forbids inferring administration, diagnosing or prescribing. Consequence. AI never prescribes or changes doses. Output is stored as needs_review, like OCR.

12. Generic push text, confirmed plans only

Context. Lock screens are visible to others, and a reminder is easy to mistake for a record. Decision. Reminders are reconciled only from confirmed plans. Expo and Web Push both send one sentence: "You have a confirmed care reminder. Open HealthBook AI to review it." Delivery state lives in notifications. Consequence. A notification/reminder is never proof a dose was given; the details are visible only inside the signed-in app.

13. Free-tier deployment

Context. The project runs at zero cost: Render free (sleeps, ephemeral disk), Supabase free (pooler limits, inactivity pause), Firebase Spark. Decision. Durable state lives only in Supabase; production refuses local storage without explicit opt-in; pg_cron wakes the API every 10 minutes through a key-protected tick; the rate limiter is in memory for the single instance; email uses HTTPS APIs because SMTP ports are blocked; the database pool is capped; scripts/cloud_backup.py gives user-held encrypted backups. Consequence. Cold starts can take up to a minute, and reminder delivery granularity is about 10 minutes.

14. Migrations on startup

Context. There is no separate release step on the free host, and self-hosters run the API and worker together. Decision. upgrade() runs on import under a file lock; on SQLite with data behind head it first writes an encrypted snapshot. Consequence. Every deploy is self-migrating; a failed migration stops startup rather than serving an inconsistent schema.

Implementation

api/workflows.py, api/review_validation.py, api/models.py, api/care_models.py, api/lifetime_models.py, api/crypto.py, api/storage.py, api/cloud.py, api/security.py, api/password_reset.py, api/share.py, api/providers.py, api/camera_worker.py, api/push_web.py, api/worker.py, api/migrate.py, web/care.js, render.yaml.

Related pages

Product Overview · System Architecture · OCR and Human Verification Workflow · Medicines and Administration Model · Encryption and Private Storage · Web Application Architecture · Limitations

Clone this wiki locally