# MealQuest architecture This mirrors the architecture section of `CONTEXT.MD` in the CTXD project `mealquest`, which is the source of record; if the two disagree, CTXD wins and this file should be updated. It describes the **planned** application. The repository today is the Feature 01 scaffold: `cmd/server`, `internal/config`, `internal/server` (`/health`), `internal/frontend` (embedded PWA with SPA fallback), the `web/` skeleton, `Makefile`, `Dockerfile` and CI. Decision numbers (D-NNN) refer to `DECISIONS.MD` in CTXD. ## Topology ``` PWA (phone / tablet / desktop) │ HTTPS ▼ Traefik v3.6 — 10.60.1.33 file route: mealquest-ccl.yml │ ▼ docker compose project "mealquest" — 10.60.1.14 ├── mealquest Go service + embedded PWA, host 8090 → container 8080 ├── searxng sidecar for recipe search; internal compose network │ only, no published port └── Litestream SQLite replication → Synology NAS 10.60.1.50 ``` One Go service serves the API, the auth flow, media and the PWA. Its SQLite database is replicated by Litestream to the NAS. Traefik terminates TLS. The service honours `X-Forwarded-For` and `X-Forwarded-Proto` only from peers listed in `MEALQUEST_TRUSTED_PROXY` (comma-separated IPs/CIDRs; production lists `10.60.1.33`); from any other peer they are ignored. The client is the rightmost `X-Forwarded-For` address that is not a trusted proxy. The resolved client IP is added as `client_ip` to every log line written while handling a request. The OIDC redirect URI and the Secure cookies are fixed by `MEALQUEST_PUBLIC_URL`, never by request headers, so sign-in works whichever peer the request arrives from. ## Repository layout | Path | Purpose | | --- | --- | | `cmd/server` | Entry point; wiring only. | | `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. | | `internal/server` | HTTP server, router, middleware. | | `internal/frontend` | Embedded PWA, SPA fallback. | | `internal/auth` | Authentik OIDC, sessions, CSRF. | | `internal/db`, `internal/store` | SQLite connections, sqlc queries. | | `internal/secretbox` | AES-256-GCM encryption for `_ct` columns. | | `internal/household`, `internal/dinners`, `internal/recipes` | Domain. | | `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. | | `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. | | `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. | | `internal/jobs`, `internal/scheduler` | Background jobs and schedules. | | `migrations/` | SQL migrations (no goose or golang-migrate). | | `web/` | React PWA. | | `deploy/` | Deployment files. | | `docs/` | Documentation. | | `design/` | Requirements and UI handoff (read-only reference). | ## Backend stack and conventions - Go 1.25, `CGO_ENABLED=0`, a single static binary. - Router: `go-chi/chi/v5`. Logging: `log/slog` JSON. - SQLite via `modernc.org/sqlite`, with `journal_mode=WAL`, `foreign_keys=ON` and `busy_timeout`. **One writer connection plus a read pool.** Queries generated by `sqlc`. - IDs are ULIDs (`oklog/ulid/v2`). - Timestamps are stored in UTC; local calendar dates are `YYYY-MM-DD` strings in the household timezone. - Configuration is environment-only (`MEALQUEST_*`). Secrets come only from files named by `*_FILE` variables. Missing or invalid config stops startup. - Error responses always have the body `{"error":{"code":"…","message":"…","retryable":false}}`. ## Authentication - Authentik OIDC; the backend is a **confidential client** (`coreos/go-oidc/v3`, `golang.org/x/oauth2`). - Session cookie `__Host-mealquest_session` (HttpOnly, Secure, SameSite=Lax). Sessions expire after 30 days idle or 180 days absolute. - Non-GET requests require the CSRF header `X-MealQuest-CSRF`. - Access requires membership of the Authentik group `mealquest-admin`. There is a **single role**: one shared household, no per-person profiles. ## Data model Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`). | Table | Holds | | --- | --- | | `household_settings` | One row: timezone, dinner_time (17:30), reminder_time (16:30), review_time (09:00), plan_draft day/time (Fri 09:00), suggestions day/time (Sun 08:00), chat_autosend day/time (Sat 08:00), onboarding_completed_at, updates_seen_at. | | `recipes` | A real recipe page: source_url (unique), site_name, title, total/prep/cook minutes, yield, raw_jsonld, fetched_at. | | `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. | | `recipe_steps` | recipe_id, position, text. | | `photos` | source_url, local_path, width, height, sha256, fetched_at. | | `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by. | | `suggestion_batches` | trigger, status, ai_run_id. | | `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. | | `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. | | `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. | | `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. | | `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. | | `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. | | `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. | | `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url, api_key_ct. | | `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. | | `chat_settings` | One row: webhook_url_ct, space_label, last_test_at, last_test_status. | | `notification_prefs` | event (`plan_ready` / `list_ready` / `day_of_reminder` / `new_suggestions` / `rotation_review`), push, chat. | | `push_subscriptions` | endpoint (unique), p256dh, auth, user_agent, failure_count, last_success_at. | | `notification_outbox` | event_key + channel (unique), payload, status, attempts, next_attempt_at, last_error. | | `jobs` | kind, status, payload, result (progressive), error, timestamps. | | `schedule_runs` | schedule, period_key (unique). | | `ai_runs` | skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in/out, error, redacted request/response (30-day retention). | | `sessions` | id_hash, subject, email, name, groups, csrf_token, created/last_seen/expires. | `recipes` holds a fetched recipe page; `dinners` holds the household's meal record and its lifecycle, optionally linked to a recipe and a photo. ## Dinner lifecycle ``` suggested (deck) ──Try it──▶ quest_log ──planned date passes──▶ tried │ ▲ │ │ └──────── explicit decision ─────┤ │ ├──▶ regular │ └──▶ archived └──Pass──▶ archived (name kept for dedupe) ``` From `tried`, an explicit decision moves the dinner to `regular`, to `archived`, or back to `quest_log`. Promotion to `regular` is **never automatic** (D-021). ## API surface - JSON API under `/api/v1`, using the error body above for every failure. It requires the session cookie and, for non-GET requests, the `X-MealQuest-CSRF` header. - `/auth/*` — the OIDC flow. - `/media/*` — cached photos. - `/health` — liveness. - Everything else serves the embedded PWA with SPA fallback. ## AI layer - **Provider interface** (`ai/llm`) with adapters for OpenAI-compatible, Anthropic and Ollama endpoints, called over plain HTTP — no vendor SDKs. - **Skill harness** (`ai/harness`): prompt layering, validation of the structured output, and **one repair retry**; if the repair also fails the run fails — no silent fallback. Every run that reaches the provider writes one `ai_runs` row when it ends, covering the repair turn too, with status `ok`, `repaired`, `bad_output` or `provider_error`. Request and response bodies are redacted before they are stored: the decrypted API key, Authorization and API-key header values, Google Chat webhook URLs and URLs with key-like query parameters are removed, inside JSON strings too. `harness.Purge` deletes rows older than `harness.RunRetention` (30 days), clearing the `suggestion_batches` and `week_plans` references first. - **Prompt layering** (D-007): each skill version's prompt is a `text/template` file embedded from `ai/skills/prompts/` (`.v.tmpl`). The system prompt is always, in order: [1] the admin persona (`ai_settings.system_prompt`), [2] the skill's instructions, [3] the household context block, [4] the output contract with the JSON Schema — the contract is always last. The user message comes from the skill's input. `checksums.txt` pins every template; changing one without a version bump fails the tests. - **Household context** (`harness.BuildContext`): layer 3 holds today's date and weekday in the household timezone, the Regulars, the Quest log, the last 60 passed Side quests and the plans of the last four ISO weeks. A character budget trims the oldest plans first, then the oldest passes; Regulars and the Quest log are never trimmed. - **Name dedupe** (`harness.Normalize`, `harness.Similar`, `harness.Deduper`): a dinner name is normalized by lowercasing, dropping combining marks and folding precomposed letters to their base letters through a table generated from Unicode data (`gen_folds.py`), so composed and decomposed spellings match, stripping punctuation, dropping filler words (easy, best, homemade, quick, simple, the, a, recipe, classic) and collapsing whitespace. Two names are the same dinner when the token-set (Jaccard) similarity of their words is at least 0.85. A `Deduper` over the Regulars, Quest log, pending suggestions and passed names rejects a suggestion that repeats any of them. ## Recipe grounding - Dinners are grounded in real recipe pages found through recipe search (`searxng` or `brave`, per `recipe_search_settings`). - Fetching is **SSRF-safe**. - Recipe data is parsed from schema.org `Recipe` JSON-LD. - Photos are cached locally (`photos`) and processed with `golang.org/x/image`. ## Scheduler Times are in the household timezone. The times of `day_of_reminder` (`reminder_time`) and `rotation_check` (`review_time`), and the day and time of `suggestions_weekly`, `plan_draft` and `shopping_chat_autosend`, are household settings in `household_settings` (`dinner_time` is also stored there); the table shows their defaults. `week_rollover` and the `ai_runs` purge are fixed and not configurable. | Schedule | When | Does | | --- | --- | --- | | `suggestions_weekly` | Sunday 08:00 | New batch of Side quests. | | `plan_draft` | Friday 09:00 | AI drafts next week's plan. | | `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. | | `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. | | `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. | | `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive. | | `ai_runs` purge | Daily (fixed) | Deletes `ai_runs` rows past 30-day retention. | Runs are recorded in `schedule_runs` and executed through `jobs`. ## Notifications - Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`, `rotation_review`; each can go to Web Push and/or Google Chat per `notification_prefs`. - Delivery goes through `notification_outbox`. - Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming webhook. ## Google Calendar - One-way push: the app is the source of truth; edits in Google Calendar do not come back. - Connected once with Google OAuth in Settings; one event per planned dinner. - **Deterministic event ids**; each plan day records its `calendar_event_id`. ## Frontend - React 19, TypeScript, Vite, React Router 7, TanStack Query 5, `vite-plugin-pwa`, `lucide-react` (stroke 2.75). - CSS Modules over `tokens.css`; no Tailwind, no Framer Motion, no hard-coded hex. Self-hosted Bagel Fat One and Figtree. - Tests: Vitest, Testing Library, jsdom. - Offline cache for the current plan, shopping list and recipes. | Route | Screen | | --- | --- | | `/` | Home | | `/onboarding` | Add regular dinners | | `/plan` | Weekly plan (Draft board) | | `/plan/shopping` | Shopping list (Supply run) | | `/plan/shopping/archive` | Supply archive | | `/discover` | Swipe suggestions (Side quests) | | `/quick` | Quick dinners | | `/cook-from-pantry` | Cook from what I have | | `/dinner/:id` | Recipe / dinner detail (Kitchen reading mode on tablet) | | `/settings` | Settings home | | `/settings/household` | Household settings | | `/settings/regulars` | Regular dinners | | `/settings/calendar` | Google Calendar connection | | `/settings/notifications` | Notifications and Google Chat | | `/settings/ai` | Admin · AI assistant | `/settings/household`, `/settings/regulars` and `/settings/calendar` come from CTXD; the other routes are defined in the UI handoff. ## Boundaries and gotchas - **Secrets never reach the client** — API keys, OAuth tokens and the Chat webhook stay server-side, encrypted at rest. - **The app is the source of truth for the plan**; Google Calendar is a one-way mirror. - **Never auto-promote to Regular** (D-021). - **Quick** means cooked in a crock pot **or** ≤ 30 minutes prep to table. - **Weeks run Monday–Sunday** in the household timezone. - **No silent fallbacks** — fail fast and report. - **iOS Web Push requires the PWA to be installed to the Home Screen**; show an install hint.