harness.BuildContext reads the household context in one read transaction: today and its weekday in the household timezone, Regulars (cuisine, method, minutes, favorite), 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, never Regulars; a budget too small for what is never trimmed is an error. Maestro task 2f54cec9-ec41-42af-b8ee-9e24b8c63057 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
258 lines
13 KiB
Markdown
258 lines
13 KiB
Markdown
# 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 call is recorded in `ai_runs`.
|
||
- **Prompt layering** (D-007): each skill version's prompt is a
|
||
`text/template` file embedded from `ai/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.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.
|
||
|
||
## 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.
|