16 KiB
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/slogJSON. - SQLite via
modernc.org/sqlite, withjournal_mode=WAL,foreign_keys=ONandbusy_timeout. One writer connection plus a read pool. Queries generated bysqlc. - IDs are ULIDs (
oklog/ulid/v2). - Timestamps are stored in UTC; local calendar dates are
YYYY-MM-DDstrings in the household timezone. - Configuration is environment-only (
MEALQUEST_*). Secrets come only from files named by*_FILEvariables. 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, theX-MealQuest-CSRFheader. /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 writes oneai_runsrow when it ends, covering the repair turn too, with statusok,repaired,bad_outputorprovider_error; a prompt that cannot be rendered isbad_outputand a provider that cannot be built isprovider_error, both with no turns. The harness builds the provider itself from the settings and the decrypted API key (presets.FromSettings), so the key it redacts is always the key the provider sends. 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; the skill, provider and model columns are redacted the same way. Writing the row is bounded: a run whose work ended in time must write its row before the skill's deadline or it fails withllm.ErrTimeoutand its row records the timeout; a failed run's row has a further fixed bound, and if even that write fails the run returns the write error with its own and no row is stored. A stored row's status therefore always matches what the caller received.harness.Purgedeletes rows older thanharness.RunRetention(30 days), clearing thesuggestion_batchesandweek_plansreferences first. The harness does not call it: the scheduler's dailyai_runspurge (Feature 20) must callharness.Purgewithnow - RunRetention. - Concurrency guards (
harness.Guard, one per process):harness.Runtakes a scope key, and concurrent runs of one skill with one scope share a single in-flight run and its result, error included (single-flight); runs with different scopes are independent. A semaphore lets at mostharness.DefaultMaxCalls(2, set byNewGuard) LLM calls run at once; waiting for a slot honours the context. Each skill declares a timeout (harness.DefaultTimeout, 60 s, unless it needs another) bounding the whole run, slot waits and repair turn included; a run past it fails withllm.ErrTimeout. A caller whose context ends gets the context error; the shared run is cancelled, freeing its slot, once no caller waits for it. - Prompt layering (D-007): each skill version's prompt is a
text/templatefile embedded fromai/skills/prompts/(<skill>.v<version>.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.txtpins 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. ADeduperover 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
(
searxngorbrave, perrecipe_search_settings). - Fetching is SSRF-safe.
- Recipe data is parsed from schema.org
RecipeJSON-LD. - Photos are cached locally (
photos) and processed withgolang.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) | Calls harness.Purge to delete ai_runs rows past 30-day retention (Feature 20). |
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 pernotification_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.
- Logout (
POST /auth/logout) answers withClear-Site-Data: "storage", "cache", wiping the offline marker, the service worker and its caches; the worker leaves that request to the browser, as it does every network-only one.
| 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.