Data Model

PostgreSQL (EU/Frankfurt). Read directly from backend/app/integrations/models.py and backend/alembic/versions/ on origin/main (July 2026) — 15 tables, all owned by users.id with ON DELETE CASCADE for GDPR erasure. This supersedes the older docs/architecture/diagrams/03-er-model.md ERD, which draws a different, unshipped shape (a profiles table with goals/body_params, a subscriptions table) — see the note at the bottom.

The nutrient-column convention (read this first)

Every nutrient-bearing table (meals, meal_memory, daily_summaries, period_summaries, user_targets) declares a <nutrient>_min/<nutrient>_max column pair for exactly the 32 keys in core/nutrients.py's NUTRIENT_KEYS — one canonical, ordered enumeration, no per-story subsets. A nutrient-drift guard test fails the build if any table is left behind when a nutrient is added. This is why the model is "one tier, columns only" (a rejected nutrition_extensions JSONB rare-extras tier was dropped 2026-07-04) — Statistics can chart, aggregate, or target any nutrient without a JSONB side channel.

energyproteincarbsfatfibersodiumcalciumironvitamin_cvitamin_db12potassiumphosphorusmagnesiumzinciodineseleniumvitamin_avitamin_evitamin_kb1b2b3b5b6b7folatesugarsaturated_fatmono_fatpoly_fatomega3

32 nutrients × 2 (min/max) = 64 numeric columns per table, listed once here and referenced as [NUTRIENT_KEYS] below instead of repeated 64 times per table.

TableColumn nullabilityWhy
meals, meal_memorynullableA still-estimating draft has no numbers yet
daily_summaries, period_summariesNOT NULL, default 0A day/week with only pending meals is 0/0 + a pending counter — never a gap
user_targetsmostly nullable; some sides always null by designsodium_min, sugar_min, saturated_fat_min, mono_fat_min have no lower target zone; fiber_max, potassium_max, mono_fat_max, omega3_max have no upper cap

Every table with these columns also carries per-nutrient CHECK (x_min <= x_max) constraints (inverted-range guard), and meals/meal_memory add plausibility backstops (energy_max <= 8000, protein_max <= 500) so bad data can't reach the DB even if application code slips.

Entity-relationship diagram of the emealia schema: users at the center, with one-to-one links to user_profiles, user_targets and usage_accounts, and one-to-many links to consents, meals, meal_memory, ai_jobs, daily_summaries, period_summaries, daily_context_tags, usage_user_days and activity_context; meals links one-to-many to ai_jobs.
Full ERD, generated from assets/diagrams/data-model-erd.mmd — verified against backend/app/integrations/models.py (July 2026). The tables below remain the detailed column-level reference.

Tables

users

The identity anchor. Story 0.12 (timezone) + 7.1a (social identity). Every other owned table FKs to users.id.

ColumnTypeNotes
iduuid PK
timezonestring(64), nullIANA tz; captured lazily at first meal log/onboarding, not row creation
auth_providerstring(32), nulle.g. google. NULL for anonymous/demo users
provider_subjectstring(255), nullProvider's stable subject id — the account key, paired with auth_provider, never email
emailstring(320), nullProvider-verified display address only — not the identity key (can change; Apple relay alias)
created_at, updated_attimestamptz

Constraints: (auth_provider IS NULL) = (provider_subject IS NULL) (registered has both, anonymous has neither) · UNIQUE(auth_provider, provider_subject) — makes claim-or-create race-safe and enforces "one Google identity = one emealia user."

user_profiles

Story 6.9. Currently owns only Today-nutrient visibility — not the full goals/body-params/dietary-preferences shape some concept docs describe; that richer profile is planned, not yet columns.

ColumnTypeNotes
user_iduuid PK, FK → users.id CASCADE
localestring(12), null
today_nutrientsjsonb, NOT NULL default []CHECK array type + CHECK length ≤ 10 (the Today band personalization cap)
created_at, updated_attimestamptz

consents

Story 0.13. Append-and-withdraw log, not a mutable flag.

ColumnTypeNotes
iduuid PK
user_iduuid FK CASCADE
typeenum consent_type
versionstring(32)Which consent copy version was agreed to
granted_attimestamptz
revoked_attimestamptz, null

Constraints: revoked_at IS NULL OR revoked_at >= granted_at · partial unique index (user_id, type) WHERE revoked_at IS NULL — "does the user consent now?" always has exactly one answer.

usage_accounts trial model

One row per user's AI-estimation entitlement. This is the authoritative trial model — credit-based, not the clock-based model described in older docs.

ColumnTypeNotes
user_iduuid PK, FK CASCADE
subscription_statusenum, default TRIALING
stripe_customer_id, stripe_subscription_idstring, null, uniqueNULL throughout the free trial — Stripe only enters once the user upgrades
trial_started_attimestamptz, null
trial_credits_grantedint, default 0Snapshotted at first reservation from admin.toml [usage] trial_credits (currently 30) — later config changes affect new trials only
trial_credits_usedint, default 0
created_at, updated_attimestamptz

Constraints: trial_credits_used <= trial_credits_granted — this single CHECK is the trial gate at the DB level.

usage_user_days / usage_global_days

Cost-cap guardrails, independent of the trial. Per-user daily cap (admin default 20/day) and a hard global daily cap (admin default 13,333/day, story 1.16) that pauses the service calmly (no red, no numeric cap shown) rather than let a runaway loop burn budget.

TableKey columns
usage_user_daysid PK, user_id FK, local_date, ai_calls_reserved — unique on (user_id, local_date)
usage_global_daysutc_date PK, ai_calls_reserved

processed_webhook_events

Story 8.2b. Stripe webhook idempotency — the primary key is the dedupe mechanism.

ColumnType
stripe_event_idstring(255) PK
received_attimestamptz

user_targets

Story 6.8. One active target-zone row per user, output of the pure BuildTargets function — recalculated and upserted on every profile change, never on meal log or read. Frontend renders ideal-zone bars only from stored numbers, never computes on the fly.

ColumnTypeNotes
iduuid PK
user_iduuid FK CASCADE, unique
tierstring(20)CHECK IN ('personalized','reference') — reference = missing body params, substituted from versioned regional averages
localestring(12)
calculated_attimestamptz
[NUTRIENT_KEYS]_min/_maxnumeric, mostly null32 nutrients — see convention above

daily_summaries

Story 1.1b. The precomputed backing for Today's bands — RSS-aggregated (root-sum-square uncertainty) as meals are saved, so BuildDailyView reads one row instead of re-summing every meal.

ColumnTypeNotes
iduuid PK
user_iduuid FK CASCADE
local_datedateUnique on (user_id, local_date)
[NUTRIENT_KEYS]_min/_maxnumeric, NOT NULL default 0
pending_countint, default 0A still-estimating meal adds 0 and bumps this — bands show "still estimating," never a gap
updated_attimestamptz

period_summaries

Story 9.1. One row per user per Mon–Sun week — the average logged day (mean of each nutrient's daily bounds), for Statistics. Not yet exposed via an API route.

ColumnTypeNotes
iduuid PK
user_iduuid FK CASCADE
period_start, period_enddateperiod_start = the week's Monday; unique on (user_id, period_start) → idempotent recompute
days_countedint, default 0
[NUTRIENT_KEYS]_min/_maxnumeric, default 0
updated_attimestamptz

meals core table

Story 1.2a onward. One logged meal — the user's own words plus the AI's honest nutrient ranges. Created as a draft by LogMeal, filled by the ai_jobs worker, RSS-aggregated into daily_summaries on Save.

ColumnTypeNotes
iduuid PK
user_iduuid FK CASCADE
logged_attimestamptz
local_datedate
raw_texttextThe user's own words — covered by the 90-day pseudonymization job
client_queue_iduuid, nullIdempotency for offline auto-commit on reconnect; unique on (user_id, client_queue_id)
confidence_levelenum
revision_count, version, reestimate_countint, default 0each >= 0
titlestring(TITLE_MAX_CHARS=60), nullAI-generated; the length constant is shared with the prompt snippet + response validator
ai_tagstring(TAG_MAX_CHARS=24), null
meal_typeenum, NOT NULL default otherbreakfast·brunch·lunch·dinner·snack·beverage·other — user-selected, never AI-derived
statusenum, default draftdraft→logged lifecycle; a draft is excluded from summaries/history until Save
assumptionsjsonb, default []List of {text_en, text_de, amount, dominating}
meal_sizetext, null
portion_sizestring(100), null
productsjsonb, default []Portion/product breakdown (Epic 18.1)
[NUTRIENT_KEYS]_min/_maxnumeric, nullableNULL until the AI estimate lands

Plausibility backstops: energy_min >= 0 AND energy_max <= 8000, protein_min >= 0 AND protein_max <= 500.

meal_memory

Stories 5.4/5.6. A confirmed dish remembered in the user's own words — repeat logging with zero AI calls once a dish crosses 3 confirmations. Same nutrient columns as meals (a reuse is a direct copy, one modeling paradigm for Statistics). Not a promise that the AI "learns" — an implementation detail for repeat logging and bounded recommendation context.

ColumnTypeNotes
iduuid PK
user_iduuid FK CASCADE
canonical_texttextUnique per (user_id, canonical_text); PII — covered by the 90-day pseudonymization job too
keywordstext, default ''For the "from history" keyword match
times_confirmedint, default 0
title, ai_tag, meal_size, confidence_level, assumptionssame shape as mealsestimate snapshot
created_at, updated_at, last_confirmed_attimestamptz
[NUTRIENT_KEYS]_min/_maxnumeric, nullable

ai_jobs

Story 1.15a. One async estimation job per meal. Transition logic lives in core/ai_job.py; this row only persists state.

ColumnTypeNotes
iduuid PK
user_iduuid FK CASCADE
meal_iduuid FK → meals.id CASCADE
statusenum, default pendingAiJobStatus — the same vocabulary the polling contract exposes
attempt_countint, default 0
next_attempt_attimestamptz, nullIndexed together with status for the worker's poll query
input_snapshotjsonb, NOT NULLKeeps the worker idempotent + auditable
output_snapshotjsonb, null
created_at, updated_attimestamptz

daily_context_tags

Story 2.9. Optional Day Context tags — a day with no context has no rows. For later Statistics aggregation, not interpreted medically.

ColumnTypeNotes
iduuid PK
user_iduuid FK CASCADE
local_datedate
tagenum day_context_tagUnique on (user_id, local_date, tag)
created_attimestamptz

activity_context

Story 2.10. Optional movement context — a calm informational turnover range only. Never feeds Today bands, meal budgets, streaks, or reminders. Epic 18 (activity as first-class context) is explicitly draft/do-not-implement — this table is the entire shipped surface.

ColumnTypeNotes
iduuid PK
user_iduuid FK CASCADE
local_datedateUnique on (user_id, local_date)
raw_texttext
turnover_min, turnover_maxnumericCHECK turnover_min <= turnover_max, >= 0
created_attimestamptz

Relationships (summary)

users 1──* meals 1──* ai_jobs
users 1──1 user_profiles
users 1──1 user_targets
users 1──1 usage_accounts
users 1──* usage_user_days
users 1──* consents
users 1──* meal_memory
users 1──* daily_summaries
users 1──* period_summaries
users 1──* daily_context_tags
users 1──* activity_context
usage_global_days                (no user FK — global row per UTC date)
processed_webhook_events         (no user FK — Stripe event id is the whole row)

Every FK is ON DELETE CASCADE from users.id — a GDPR erasure of the user row removes every owned table's data in one transaction, with two exceptions requiring an explicit job: the 90-day pseudonymization of meals.raw_text/meal_memory.canonical_text runs on a schedule, independent of deletion.

Roadmap — planned tables not yet built

TablePurposeStory
sessionsAccess+refresh session split, rotation/revocation7.4a/7.4b
daily_suggestionsPersisting coaching output (currently generated on the fly, not stored)4.5

Conflict with docs/architecture/diagrams/03-er-model.md

Stale reference diagram

The Mermaid ERD in the emealia-app repo's docs/architecture/diagrams/03-er-model.md was authored early (story 0.12/0.13 era) and describes a planned shape that diverged as the schema actually shipped: it shows a profiles table with goals/body_params/dietary_preferences columns (the real user_profiles only has today_nutrients + locale so far), a subscriptions table with trial_ends_at (the real table is usage_accounts with credit columns, no such table exists), and is missing period_summaries, usage_user_days/usage_global_days, processed_webhook_events, and activity_context entirely. This page (data-model.html) reflects the actually-shipped schema and should be treated as authoritative; the emealia-app diagram needs a refresh (flagged in the doc-agent report, not fixed here per this agent's edit scope).