Schema
Protocol uses three PostgreSQL schemas:
person- user identity, health profile, preferences (one row per user)supplements- shared inventory plus a per-user journal and contextpublic- OAuth token storage only (not user-facing data)
All migrations live in supabase/migrations/ and are applied by the deploy workflow.
person schema
Section titled “person schema”Per-user demographics and profile data. Every table here is user-scoped with RLS enabled.
person.users
Section titled “person.users”The canonical identity record. One row per human.
| Column | Type | Notes |
|---|---|---|
id | text PK | Short slug (john, jane). Not a UUID - intentionally human-readable |
google_email | text UNIQUE | Must match the Google account used at OAuth sign-in |
display_name | text | Shown in LLM responses |
sex | text | 'm' or 'f' (enforced by CHECK) - drives sex-specific lab reference ranges |
date_of_birth | date | Used for age-gated recommendations |
person.health_profiles
Section titled “person.health_profiles”One row per user (user_id is both PK and FK to person.users with ON DELETE CASCADE). All list-shaped fields are jsonb arrays validated by CHECK constraints and Pydantic on read.
| Column | Type | Purpose |
|---|---|---|
conditions | jsonb[] | Current medical conditions [{name, status, notes}] |
family_history | jsonb[] | Hereditary risks [{condition, relative}] |
substances | jsonb[] | Caffeine / alcohol / other [{name, frequency, notes}] |
diet_notes | text | Free-form eating patterns |
activity_notes | text | Free-form exercise notes |
safety_checks | jsonb[] | Topics the LLM must verify before recommending |
methodology_notes | text | Preferred health framework |
health_priorities | jsonb[] | Ranked goals |
person.preferences
Section titled “person.preferences”One row per user - locale, units, and communication style.
| Column | Default | Constraint |
|---|---|---|
language | 'en' | ISO 639-1 (two lowercase letters) |
units | 'metric' | metric or imperial |
currency | 'USD' | ISO 4217 (three uppercase letters) |
date_format | 'YYYY-MM-DD' | One of seven allowed patterns |
location | - | Free-form text - city, region, country |
occupation | - | Free-form text - user's job or profession |
communication | - | Free-form text - communication style |
supplements schema
Section titled “supplements schema”supplements.inventory
Section titled “supplements.inventory”Shared across all users. A single household keeps one catalog so that two family members on the same "Jamieson Vitamin D3 1000 IU" can reference the same inventory_id. No RLS - reads are broadly allowed; writes go through MCP tools.
| Column | Type | Notes |
|---|---|---|
id | integer PK | Generated identity |
name | text | - |
brand | text | - |
category | text | e.g. vitamin, mineral, herb |
form | text | e.g. capsule, softgel, powder |
dosage_per_unit | text | e.g. "500 mg", "1000 IU" |
features | text[] | Optional tags, e.g. {timed release,micronized}. Default {} |
url | text | Optional product URL |
UNIQUE (name, brand) prevents duplicate catalog entries.
supplements.journal - SCD Type 2
Section titled “supplements.journal - SCD Type 2”The core protocol history. Every regimen change closes a row (sets ended_at) and opens a new one linking back via replaces_id. This is what lets you reconstruct the exact protocol on any past date.
| Column | Type | Notes |
|---|---|---|
id | integer PK | Generated identity |
user_id | text | FK to person.users(id) ON DELETE CASCADE |
inventory_id | integer | FK to supplements.inventory(id) |
time_blocks | text[] | Non-empty subset of {morning, lunch, evening, any} |
dosage | text | Free-form, e.g. "500 mg" |
frequency | text | Free-form, e.g. "twice daily" |
started_at | date | Defaults to CURRENT_DATE |
replaces_id | integer | FK to supplements.journal(id). NULL for first entries |
replacement_reason | text | Required when replaces_id is set |
ended_at | date | NULL means active |
end_reason | text | Optional, requires ended_at |
Invariants enforced by CHECK constraints and triggers:
ended_at >= started_atif presentend_reasonrequiresended_atreplaces_idandreplacement_reasonare both-or-neither- Unique active entry per user+supplement - partial unique index
(user_id, inventory_id) WHERE ended_at IS NULLprevents two concurrent rows for the same thing - Closed rows are immutable -
immutable_closedtrigger rejects any UPDATE on a row whereended_at IS NOT NULL - Replacement chain integrity -
require_chaintrigger enforces thatreplaces_idpoints to a closed row for the sameinventory_id, and that you cannot insert a second "first" entry for a supplement that already has history
supplements.context
Section titled “supplements.context”One row per (user_id, inventory_id) - the purpose the user takes this supplement for. Separate from journal because purpose rarely changes while dosage and timing do.
| Column | Type | Notes |
|---|---|---|
id | integer PK | Generated identity |
user_id | text | FK to person.users(id) ON DELETE CASCADE |
inventory_id | integer | FK to supplements.inventory(id) |
purpose | text[] | Non-empty array, e.g. {bone health,immune support} |
UNIQUE (user_id, inventory_id) - one context entry per user per supplement.
public.google_oauth
Section titled “public.google_oauth”Backing store for FastMCP's OAuth flow (access tokens, refresh tokens, PKCE state). RLS is enabled but no policies are defined - only the postgres superuser (the MCP server itself) should ever read this table. anon and authenticated roles are explicitly revoked in the migration.
| Column | Type | Notes |
|---|---|---|
collection | text | Part of composite PK - groups token records by type |
key | text | Part of composite PK - identifies the specific token |
value | jsonb | Token payload |
ttl | double precision | Time-to-live in seconds |
created_at | timestamptz | - |
expires_at | timestamptz | Indexed for expiry lookups |