Correct architecture doc to match the CTXD architecture section
CI / ci (pull_request) Successful in 24s
CI / ci (push) Successful in 23s
Independent review Not signed off — 0 high, 3 medium, 2 low (#7)

Replace the data model table with the CTXD tables and columns (recipes
are fetched recipe pages; dinners hold the meal record and lifecycle),
fix the dinner lifecycle (Pass leads to archived; tried can return to
quest_log), add the session cookie attributes and expiry, require the
session cookie and CSRF header on /api/v1, make the ai_runs purge daily,
use the handoff routes plus the CTXD settings routes, and remove details
not supported by CTXD, the requirements or the UI handoff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 00:16:58 -04:00
co-authored by Claude Opus 5.5
parent 998898c549
commit 37f6200250
+77 -61
View File
@@ -25,8 +25,8 @@ docker compose project "mealquest" — 10.60.1.14
└── Litestream SQLite replication → Synology NAS 10.60.1.50 └── Litestream SQLite replication → Synology NAS 10.60.1.50
``` ```
One container serves the API, the auth flow, media and the PWA. SQLite lives One Go service serves the API, the auth flow, media and the PWA. Its SQLite
on the host volume and is continuously replicated by Litestream to the NAS. database is replicated by Litestream to the NAS.
## Repository layout ## Repository layout
@@ -44,9 +44,9 @@ on the host volume and is continuously replicated by Litestream to the NAS.
| `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. | | `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. |
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. | | `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. | | `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
| `migrations/` | SQL migrations (embedded; no goose/golang-migrate). | | `migrations/` | SQL migrations (no goose or golang-migrate). |
| `web/` | React PWA. | | `web/` | React PWA. |
| `deploy/` | Compose, Traefik route, Litestream config. | | `deploy/` | Deployment files. |
| `docs/` | Documentation. | | `docs/` | Documentation. |
| `design/` | Requirements and UI handoff (read-only reference). | | `design/` | Requirements and UI handoff (read-only reference). |
@@ -67,10 +67,11 @@ on the host volume and is continuously replicated by Litestream to the NAS.
## Authentication ## Authentication
- Authentik OIDC; the backend is a **confidential client** (authorization - Authentik OIDC; the backend is a **confidential client**
code flow handled server-side with `coreos/go-oidc/v3` and `x/oauth2`). (`coreos/go-oidc/v3`, `golang.org/x/oauth2`).
- Session cookie `__Host-mealquest_session` (Secure, HttpOnly, path `/`). - Session cookie `__Host-mealquest_session` (HttpOnly, Secure,
- Unsafe requests require the CSRF header `X-MealQuest-CSRF`. 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`. - Access requires membership of the Authentik group `mealquest-admin`.
There is a **single role**: one shared household, no per-person profiles. There is a **single role**: one shared household, no per-person profiles.
@@ -80,47 +81,55 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| Table | Holds | | Table | Holds |
| --- | --- | | --- | --- |
| `household_settings` | Household name, timezone, preferences. | | `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` | Dinners: name, method, minutes, difficulty, source, lifecycle status. | | `recipes` | A real recipe page: source_url (unique), site_name, title, total/prep/cook minutes, yield, raw_jsonld, fetched_at. |
| `recipe_ingredients` | Ingredients with quantities per recipe. | | `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
| `recipe_steps` | Ordered steps per recipe. | | `recipe_steps` | recipe_id, position, text. |
| `photos` | Cached recipe photos. | | `photos` | source_url, local_path, width, height, sha256, fetched_at. |
| `dinners` | Dinner records and lifecycle state. | | `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` | One AI suggestion run. | | `suggestion_batches` | trigger, status, ai_run_id. |
| `suggestions` | Side quests with reason ("Why this?") and Try it / Pass status. | | `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. |
| `week_plans` | One plan per Monday-start week; draft or synced. | | `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
| `plan_days` | The dinner for each day of a week plan. | | `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. |
| `rotation_reviews` | Rotation check answers ("Keep it in rotation?"). | | `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. |
| `shopping_lists` | One Supply run per week; frozen into the Supply archive after the week. | | `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. |
| `shopping_groups` | Items grouped by dinner. | | `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. |
| `shopping_items` | Items, quantity, needed / have-it. | | `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
| `ai_settings` | Provider, model, temperature, system prompt, API key (`_ct`). | | `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url, api_key_ct. |
| `recipe_search_settings` | Recipe search configuration (searxng). | | `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. |
| `integrations_google` | Google Calendar OAuth tokens (`_ct`) and calendar id. | | `chat_settings` | One row: webhook_url_ct, space_label, last_test_at, last_test_status. |
| `chat_settings` | Google Chat webhook URL (`_ct`). | | `notification_prefs` | event (`plan_ready` / `list_ready` / `day_of_reminder` / `new_suggestions` / `rotation_review`), push, chat. |
| `notification_prefs` | Per-event push / Chat toggles, reminder time. | | `push_subscriptions` | endpoint (unique), p256dh, auth, user_agent, failure_count, last_success_at. |
| `push_subscriptions` | Web Push subscriptions. | | `notification_outbox` | event_key + channel (unique), payload, status, attempts, next_attempt_at, last_error. |
| `notification_outbox` | Pending notifications for delivery. | | `jobs` | kind, status, payload, result (progressive), error, timestamps. |
| `jobs` | Durable background job queue. | | `schedule_runs` | schedule, period_key (unique). |
| `schedule_runs` | Record of scheduled runs (idempotency). | | `ai_runs` | skill, skill_version, provider, model, temperature, status, latency_ms, tokens_in/out, error, redacted request/response (30-day retention). |
| `ai_runs` | Every AI skill call: inputs, outputs, validation, timing. | | `sessions` | id_hash, subject, email, name, groups, csrf_token, created/last_seen/expires. |
| `sessions` | Server-side sessions. |
`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 ## Dinner lifecycle
``` ```
suggested ──Try it──▶ quest_log ──cooked──▶ tried ──Rotation check──▶ regular suggested (deck) ──Try it──▶ quest_log ──planned date passes──▶ tried
│ └────────────────────▶ archived │ ▲ │
└──Pass──▶ declined (not suggested again) │ └──────── explicit decision ─────┤
│ ├──▶ regular
│ └──▶ archived
└──Pass──▶ archived (name kept for dedupe)
``` ```
Promotion to Regular is **never automatic** (D-021): it happens only when the From `tried`, an explicit decision moves the dinner to `regular`, to
household answers "Keep it in rotation?". `archived`, or back to `quest_log`. Promotion to `regular` is **never
automatic** (D-021).
## API surface ## API surface
- JSON API under `/api/v1`, using the error body above for every failure. - JSON API under `/api/v1`, using the error body above for every failure.
- `/auth/*` — OIDC login, callback, logout. It requires the session cookie and, for non-GET requests, the
`X-MealQuest-CSRF` header.
- `/auth/*` — the OIDC flow.
- `/media/*` — cached photos. - `/media/*` — cached photos.
- `/health` — liveness. - `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback. - Everything else serves the embedded PWA with SPA fallback.
@@ -129,24 +138,23 @@ household answers "Keep it in rotation?".
- **Provider interface** (`ai/llm`) with adapters for OpenAI-compatible, - **Provider interface** (`ai/llm`) with adapters for OpenAI-compatible,
Anthropic and Ollama endpoints, called over plain HTTP — no vendor SDKs. Anthropic and Ollama endpoints, called over plain HTTP — no vendor SDKs.
- **Skill harness** (`ai/harness`): each skill (suggestions, week draft, - **Skill harness** (`ai/harness`): prompt layering, validation of the
ingredient ideas, quick ideas, archive post titles, …) layers its prompt — structured output, and **one repair retry**; if the repair also fails the
system prompt from `ai_settings`, skill instructions, household context — run fails — no silent fallback. Every call is recorded in `ai_runs`.
then validates the structured output against the skill's schema. An invalid
response gets **one repair retry**; a second failure is an error, never a
silent fallback. Every call is recorded in `ai_runs`.
## Recipe grounding ## Recipe grounding
- Recipes are grounded in real pages found via the `searxng` sidecar. - Dinners are grounded in real recipe pages found through recipe search
- Fetching is **SSRF-safe** (public addresses only, size and time limits). (`searxng` or `brave`, per `recipe_search_settings`).
- Fetching is **SSRF-safe**.
- Recipe data is parsed from schema.org `Recipe` JSON-LD. - Recipe data is parsed from schema.org `Recipe` JSON-LD.
- Photos are downloaded, processed (`golang.org/x/image`) and cached locally; - Photos are cached locally (`photos`) and processed with
the client never hot-links external images. `golang.org/x/image`.
## Scheduler ## Scheduler
Times are in the household timezone. Times are in the household timezone; the defaults below are set in
`household_settings`.
| Schedule | When | Does | | Schedule | When | Does |
| --- | --- | --- | | --- | --- | --- |
@@ -155,15 +163,16 @@ Times are in the household timezone.
| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. | | `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. |
| `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. | | `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. |
| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. | | `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. |
| `week_rollover` | Monday 00:05 | Freezes last week's list into the Supply archive, rolls the plan. | | `week_rollover` | Monday 00:05 | Freezes last week's Supply run into the Supply archive. |
| `ai_runs` purge | Periodic | Deletes old `ai_runs` rows. | | `ai_runs` purge | Daily | Deletes `ai_runs` rows past 30-day retention. |
Runs are recorded in `schedule_runs` and executed through `jobs`. Runs are recorded in `schedule_runs` and executed through `jobs`.
## Notifications ## Notifications
- Events: plan ready, list ready, day-of reminder, new suggestions; each can - Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`,
go to Web Push and/or Google Chat per `notification_prefs`. `rotation_review`; each can go to Web Push and/or Google Chat per
`notification_prefs`.
- Delivery goes through `notification_outbox`. - Delivery goes through `notification_outbox`.
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming - Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
webhook. webhook.
@@ -172,9 +181,10 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
- One-way push: the app is the source of truth; edits in Google Calendar do - One-way push: the app is the source of truth; edits in Google Calendar do
not come back. not come back.
- OAuth once in Settings; one event per planned dinner. - Connected once with Google OAuth in Settings; one event per planned
- **Deterministic event ids** derived from the plan day, so re-pushing dinner.
updates the same events instead of creating duplicates. - **Deterministic event ids**; each plan day records its
`calendar_event_id`.
## Frontend ## Frontend
@@ -188,18 +198,24 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
| Route | Screen | | Route | Screen |
| --- | --- | | --- | --- |
| `/` | Home | | `/` | Home |
| `/onboarding` | Add Regulars | | `/onboarding` | Add regular dinners |
| `/plan` | Weekly plan (Draft board) | | `/plan` | Weekly plan (Draft board) |
| `/plan/shopping` | Supply run | | `/plan/shopping` | Shopping list (Supply run) |
| `/plan/shopping/archive` | Supply archive | | `/plan/shopping/archive` | Supply archive |
| `/discover` | Swipe Side quests | | `/discover` | Swipe suggestions (Side quests) |
| `/quick` | Quick dinners | | `/quick` | Quick dinners |
| `/cook-from-pantry` | Cook from what I have | | `/cook-from-pantry` | Cook from what I have |
| `/dinner/:id` | Recipe detail / Kitchen reading mode | | `/dinner/:id` | Recipe / dinner detail (Kitchen reading mode on tablet) |
| `/settings` | Settings home | | `/settings` | Settings home |
| `/settings/household` | Household settings |
| `/settings/regulars` | Regular dinners |
| `/settings/calendar` | Google Calendar connection |
| `/settings/notifications` | Notifications and Google Chat | | `/settings/notifications` | Notifications and Google Chat |
| `/settings/ai` | Admin · AI assistant | | `/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 ## Boundaries and gotchas
- **Secrets never reach the client** — API keys, OAuth tokens and the Chat - **Secrets never reach the client** — API keys, OAuth tokens and the Chat