Files
MealQuest/docs/ARCHITECTURE.md
2026-10-08 09:49:26 -04:00

59 KiB
Raw Permalink Blame History

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 (the page's JSON-LD), yield, raw_jsonld, fetched_at; and its grounding: photo_id, method, difficulty, minutes_total (normalized prep to table).
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, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (pending / grounded / failed / manual), grounding_error (why grounding failed), grounding_revision (raised whenever a dinner is renamed, so only the latest ground_dinner job may write it and no candidate offered before the rename grounds it), updated_by.
suggestion_batches trigger, status, ai_run_id.
suggestions dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), 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, revision (raised by every swap and stored draft).
plan_days plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected).
rotation_reviews dinner_id + plan_date (unique), status, decision (keep / drop / not_made), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by.
shopping_lists week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (current / archived), sent_to_chat_at, frozen_at.
shopping_groups / shopping_items One group per plan day with ingredients: dinner_id, date, position (by date). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by shopping.Generate from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged.
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 (the SearXNG instance), api_key_ct and api_key_last4 (Brave).
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 (suggested 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, behind the session cookie. GET /media/photos/{id}-{400|1200}.jpg serves a stored size of a photo with a photos row, with Cache-Control: private, max-age=31536000, immutable; anything else is 404.
  • GET/PATCH /api/v1/settings/recipe-search — provider, base_url and the Brave key (api_key, write-only: responses carry only api_key_set and api_key_last4; an empty string removes it). The result must be usable: SearXNG needs a base_url, Brave a key. Responses also carry the read-only default_base_url, the deployment's MEALQUEST_SEARXNG_URL (or null), which the client pre-fills while no base_url is saved; only a PATCH of base_url stores a URL. POST /api/v1/settings/recipe-search/test searches "chicken tikka masala" and returns {query, count}, 409 not_configured, or 502 search_failed with the provider's error.
  • GET/PATCH /api/v1/settings/ai (aisettings.Service) — provider, base_url, model, temperature, system_prompt, plus the read-only api_key_last4 and key_verified_at. PATCH requires a preset name as provider, temperature 0–1 in 0.1 steps, a non-empty model and a system_prompt of at most 8000 characters; a fixed-URL preset's base_url must be empty or its own. Provider and base_url cannot change while a key is stored (remove the key first), so a stored key is never sent to an endpoint it was not stored for. PUT /api/v1/settings/ai/api-key takes {api_key} (write-only, at least 8 characters, sealed with the secret box; an empty string removes it) and clears key_verified_at; a preset without a fixed base_url takes a key only when its base_url passes secureurl.Check. POST /api/v1/settings/ai/verify calls Provider.Verify and records key_verified_at; GET /api/v1/settings/ai/models returns {models: [{id, display_name}]} live from the provider, with the key redacted from every id and display_name. Both work before a model is chosen (presets.ForAccount), answer 409 not_configured when the settings cannot build a provider, and 502 with the provider's typed error (auth_failed, rate_limited, bad_output, provider_error; 504 timeout) when the call fails. POST /api/v1/settings/ai/reset-prompt restores household.DefaultPersonaPrompt (CTXD PROMPTS.MD §4). No response holds the key. Service.HarnessConfig reads the settings for each skill run, so a saved change applies to the next run.
  • Dinners (dinners.Service). POST /api/v1/dinners adds a Regular from {name} or {name, recipe_url} (an https:// link, kept in recipe_url for grounding and never returned in a dinner response, as it may carry a personal token) with status and origin regular and grounding_status pending, answering 201 with the dinner. The name is always the household's: no part of a link is ever used as a dinner's name, and a link without a name is 400. A name is trimmed, at most 120 characters and must keep a word after harness.Normalize, which sets normalized_name. A name harness.Similar to an unarchived dinner's, or a link one already has (the source_url of the recipe it is grounded in, else its pasted recipe_url), is 409 duplicate_dinner; archived names may be added again. GET /api/v1/dinners?status=regular|quest_log returns {dinners: [...]} ordered by normalized name, each with photo_url (/media/photos/{photo_id}-400.jpg, or null) and grounding_status; any other status is 400. The Quest log (quest_log) is the dinners accepted and not yet scheduled: one on the plan for the household's current week (by its timezone) or a later week is left out. PATCH /api/v1/dinners/{id} edits name (deduplicated against the other unarchived dinners), method (stovetop / oven / crockpot / grill / other), minutes_total (1–1440) and difficulty (easy / medium / hard); invalid values are 400 invalid_dinner and change nothing. DELETE /api/v1/dinners/{id} sets status archived and keeps the row; POST /api/v1/dinners/{id}/favorite toggles is_favorite. Every write records the session's subject as updated_by; an unknown id is 404.
  • Dinner grounding. POST /api/v1/dinners also enqueues a ground_dinner job and answers with its grounding_job_id without waiting for it (GET /api/v1/jobs/{id} reports it); if the job cannot be enqueued it still answers 201, with grounding_status failed (grounding_error) and grounding_job_id null. The job grounds the dinner's recipe_url directly with GroundURL, else its name with Ground (see Recipe grounding), and sets grounding_status grounded with the recipe's recipe_id, photo_id, method, minutes_total and difficulty, clearing the pasted recipe_url (the recipe replaces it), or failed with grounding_error — also the job's failure code — one of no_recipe_found, recipe_unusable (the pasted link), search_not_configured, search_failed, ai_failed, interrupted (the server stopped) or grounding_error. Each job carries the dinner's grounding_revision from when it was enqueued; renaming a dinner raises the revision, and renaming a pending one enqueues a job for the new name, clearing its recipe_url when the normalized name changes. A job for a dinner archived, no longer pending or at another revision does no work, and a job only writes a dinner still unarchived, pending and at its revision, so a fix-up made meanwhile wins and an earlier job for an old name — queued or running — never decides the outcome. Archiving or renaming a dinner also stops its running job where it is, so no further search, page fetch, photo download or model call is made for it. A method, minutes_total or difficulty a member saved while the dinner was pending is kept; grounding fills only those left empty. At boot, after the job runner fails the jobs a previous process left running, every unarchived dinner still pending with no ground_dinner job for its revision queued or running is set failed with interrupted, so no dinner stays pending with nothing left to ground it. GET /api/v1/dinners/{id}/recipe-candidates searches for the dinner's name and returns {candidates: [{candidate_id, title, site_name, photo_url, minutes}]}, at most 3, ranked as Ground ranks them; it fetches pages but downloads no photo and runs no model, so photo_url is set only for a page already stored. candidate_id is an opaque id the server resolves to the page it last offered that dinner (kept in memory, so a restart means fetching the candidates again) at the grounding_revision it was searched at, so a candidate offered before any rename is refused even if the name is changed back; the page's URL, which may carry a token, is never returned or cached. An unknown dinner is 404, recipe search not set up 409 not_configured and a search failure 502 search_failed; any other failure, such as a stored recipe that cannot be read, is 500, logged with a fixed reason and the dinner id only — never a candidate's URL or dependency error text. POST /api/v1/dinners/{id}/ground takes exactly one of {candidate_id}, {recipe_url} (both grounded with GroundURL, setting grounded) or {manual: {ingredients[], steps[], minutes, method}} (1–100 ingredient lines, up to 100 steps, each non-blank and at most 500 characters; minutes 1–1440). A manual recipe is stored as typed — no model runs — as a recipes row whose source_url is mealquest:manual:{recipe_id} and whose raw_jsonld is a schema.org Recipe built from it, and sets manual with its method and minutes, no photo and the dinner's difficulty unchanged. Either clears the dinner's pasted recipe_url: the recipe chosen replaces it. It answers 200 with the dinner; an invalid body is 400, an unknown dinner 404, a page that cannot ground it 422 ungroundable (the dinner is unchanged), recipe search not set up 409 not_configured, a search or model failure 502 search_failed / ai_failed (only the reason code is logged, never the provider's text), and grounding a candidate or link that runs past 90 seconds (fetch, photo and normalize_recipe, all in the request) 504 ground_timeout, leaving the dinner unchanged. An archived dinner is 409 dinner_archived. A candidate or link is written only if the dinner is still as it was before grounding began: one renamed or grounded some other way meanwhile is 409 ground_conflict, and one archived meanwhile 409 dinner_archived, either leaving the dinner unchanged. A method, minutes or difficulty a member edits meanwhile is kept over the recipe's. Any other failure grounding a candidate or link, or recording it on the dinner, is 500, logged with a fixed reason and the dinner id only — never the link, which can carry a personal token, or dependency error text.
  • Side quest suggestions (suggest.Service). POST /api/v1/suggestions/batches creates a manual batch and enqueues its suggestion_batch job, answering 202 {batch_id, job_id, trigger, status} without waiting; while a batch is pending or running it answers 202 with that batch instead, so at most one runs at a time (the check, the batch and its job share one write transaction). The job reads the household context, the deck's names and every declined name, runs suggest_side_quests for 5 Side quests (scope: the batch id), then grounds the survivors in the model's order with Grounder.GroundMany, as many as are still wanted at a time, until 5 are kept or none are left. A Side quest no recipe grounds is dropped; each round's grounded ones are stored before the next — a dinner with status suggested, origin side_quest, grounding_status grounded with the recipe's recipe_id, photo_id, method, minutes_total and difficulty and the model's cuisine, and a pending suggestion with the name it is suggested under, the reason and cited_regular_ids — unless, since the job read its input, a dinner it repeats was added, a suggestion it repeats passed, or a Regular it cites renamed or archived (checked in the storing transaction). The job publishes {batch_id, stage, suggestion_ids, dropped} as it goes (suggesting, grounding, done). It fails with no_regulars, ai_failed (the skill or normalize_recipe failed; no Side quest is made up instead, D-017), search_not_configured or search_failed, and the batch is set failed; Side quests stored before the failure stay in the deck. If recording the batch's end fails, its job fails; the job runner retries a job's final status write with backoff until it lands, each attempt bounded at 5 seconds; at shutdown an attempt that fails or times out is not retried, and the next start fails the job as interrupted. Recording the batch's end is bounded the same way (one attempt, 5 seconds, made even during shutdown), so a busy writer cannot hold a stopping worker; a batch whose end timed out is left running with its job. Each time a batch is started, before the active batch is selected, every batch still pending or running with no job queued or running (the process stopped, or recording its end failed) is set failed, so it does not block the next. GET /api/v1/suggestions/deck returns {suggestions: [{id, dinner_id, name, photo_url, difficulty, method, minutes_total, cuisine, reason, cited_regular_ids, status, dinner_status, decided_at, created_at}], active_batch, failed_batch}: the pending suggestions whose dinner is still suggested, oldest first; the batch pending or running ({batch_id, job_id, trigger, status}, or null); and the newest batch when it failed (same shape, or null; a later batch, done or under way, supersedes the failure), read in one snapshot, so a client that did not start the batch can follow its job and learns of a failure it was not following. At most one of the two is set. POST /api/v1/suggestions/{id}/accept (Try it) sets the suggestion accepted and moves its dinner to quest_log; POST /api/v1/suggestions/{id}/decline (Pass) sets it declined and archives the dinner; every later batch, and the household context's passed list, reads the suggestion's own name as declined, so renaming the archived dinner changes nothing. Both record the session's subject as the dinner's updated_by and answer 200 {suggestion, replenish_job_id}. Repeating a decision changes nothing and answers 200; the opposite decision is 409 suggestion_decided, a suggestion whose dinner left the deck some other way 409 dinner_not_suggested, and an unknown id 404. A decision that leaves fewer than 5 in the deck starts a replenish batch, or joins the batch already pending or running, and returns that batch's job id either way (replenish_job_id is null when the deck keeps 5 or more). The batch and its job are written in the decision's own transaction, so a decision is never recorded without the replenish it calls for; when they cannot be written, neither is the decision, and a retry decides again.
  • Weekly plans (plan.Service). {weekStart} is a household-local Monday (YYYY-MM-DD); any other value is 400 invalid_week. GET /api/v1/plans/{weekStart} returns {id, week_start, week_label, status, ai_note, calendar_synced_at, updated_at, days}, or 404 when the week has no plan; days are the seven days Monday first, each {date, dinner: {id, name, photo, minutes, method, origin}, is_new, start_by}. is_new means the dinner was planned from the Quest log; start_by is the household's dinner time on that date minus minutes_total, computed on read, for a crock-pot dinner with known minutes, else null. POST /api/v1/plans/{weekStart}/draft takes {confirm?} and answers 202 {jobId} for a draft_week job, or for the one already queued or running for the week, so a week has at most one draft in flight. A confirmed draft while the week's job was asked for without confirm is 409 draft_running and enqueues nothing. The job runs the draft_week skill on every Regular and every Quest log dinner no other plan for the current or a later week has, with the household context and last week's dinners, and stores the week: a new plan is draft; a redraft keeps the calendar_event_id of each day whose dinner stays the same and clears it for the others; a synced or changed plan becomes changed. Drafting over a synced or changed plan needs {"confirm": true}, else 409 confirm_required; a plan synced while an unconfirmed draft runs is left as it is and the job fails confirm_required. The job reads the plan's revision before draft_week runs; if the plan was edited (a swap, or another draft stored) by the time the draft is stored, the plan is left as it is and the job fails plan_changed. The pool is read again in the same transaction that stores the draft; if a dinner the draft planned has left it meanwhile (archived, tried, or a Quest log dinner stored on another current or later week's plan, as by a draft of another week), nothing is stored and the job fails pool_changed, which drafting again resolves. A pool under seven dinners fails the job not_enough_dinners, draft_week failing ai_failed. PUT /api/v1/plans/{weekStart}/days/{date} takes {dinner_id}, a Regular or Quest log dinner, swaps the day, recomputes is_new, clears the day's calendar_event_id, moves a synced plan to changed, and answers 200 with the plan. A date outside the week is 400 invalid_date, an unknown dinner or a week with no plan 404, any other dinner 409 dinner_not_plannable, and a dinner already on another day of the week 409 dinner_already_planned.
  • Rotation check (plan.Service, D-021). This release provides the MarkTried service method and the rotation review read and decide APIs below. The rotation_check schedule that is to call MarkTried for the household-local day just past is planned (Feature 20, the scheduler) and not wired yet: nothing in production calls MarkTried, so no Rotation check opens automatically. MarkTried(date) moves every quest_log dinner planned on that date, on a plan of any status, to tried and opens a pending rotation_reviews row for it; a dinner already checked for that date is left as it is, so it is idempotent. GET /api/v1/rotation-reviews returns {reviews}, the pending checks whose dinner is still tried, oldest plan_date first, each {dinner: {id, name, photo}, plan_date}. POST /api/v1/dinners/{id}/rotation-review takes {decision}: keep makes the dinner a Regular, drop ("Not for us") archives it and not_made ("We didn't make it") returns it to the Quest log; its origin is kept. The check is closed with decided_at and the session's updated_by, and the answer is {dinner, plan_date, decision, dinner_status, decided_at, updated_by}. Any other decision is 400 invalid_decision, an unknown dinner or a dinner never checked 404 not_found, a dinner whose checks are all decided (including a second decision) 409 no_pending_review, and one no longer tried 409 dinner_not_tried. Keep is the only path from tried to Regular. A drop records the dinner's name on the check (rotation_reviews.name), and that name joins the passed names (ListPassedNames) the Side quest dedupe and household context use, so it is never suggested again, even after the dinner is renamed.
  • /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 one ai_runs row when it ends, covering the repair turn too, with status ok, repaired, bad_output or provider_error; a prompt that cannot be rendered is bad_output and a provider that cannot be built is provider_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 with llm.ErrTimeout and 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.Purge deletes rows older than harness.RunRetention (30 days), clearing the suggestion_batches and week_plans references first. The harness does not call it: the scheduler's daily ai_runs purge (Feature 20) must call harness.Purge with now - RunRetention.
  • Concurrency guards (harness.Guard, one per process): harness.Run takes 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 most harness.DefaultMaxCalls (2, set by NewGuard) 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 with llm.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/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.
  • 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.
  • Side quest suggestions (skills.SuggestSideQuests, skill suggest_side_quests v1): asks for twice the Side quests needed, at the household temperature, with the household context plus the pending Side quests. Malformed replies (bad JSON, a missing field, a bad enum, an empty name) get the one repair turn. Well-formed Side quests that break a rule are dropped, not repaired, and listed in Dropped with the rule: one that repeats a Regular, Quest log dinner, pending Side quest, any passed name (the whole history, not only the 60 in the context) or an earlier Side quest of the batch; one citing no Regular or a name that is not a Regular; and one whose reason is over 180 characters or names none of its cited Regulars. Cited names are rewritten as the household writes them. Grounding each survivor in a recipe is the caller's next step.
  • Supply run titles (skills.TitleArchive, skill archive_title v1): from the names of the week's planned dinners in day order, writes {title}, the Supply run's post title in the Supply archive, at the household temperature with no household context. A title must be 1–48 characters on one line, with no emoji and no surrounding quotation marks; a reply breaking a rule gets the one repair turn, never a truncation. shopping.Generate (and GeneratePushed), on storing a list that is still untitled — a new list, or one a failed title left untitled — enqueues a title_supply_run job in the generation's transaction and returns without waiting for it, so neither Push to Calendar nor POST /shopping/{weekStart}/generate waits for a model call; the list reads untitled until the job stores its title. The job's scope is <weekStart>@<plan revision>/<sha256 of the dinner names in order>, so runs share a title only for the same names (a renamed dinner changes the names, not the revision). It writes shopping_lists.title only while the list is untitled and not frozen. A title is written once; a remake keeps it and enqueues no job. A failed title never fails the generation: the list stays untitled (the UI shows the week label), the failure is logged as a warning and recorded in ai_runs, the job fails title_failed, and the week's next generation tries again. shopping.Freeze titles a list still untitled once more before freezing it (see Supply archive), through the same skill, scope and conditional write.

Supply archive (Feature 17)

shopping.Freeze(weekStart) moves a week's Supply run into the Supply archive: status becomes archived and frozen_at is set, after which generating the list or changing an item is 409 archived. It freezes only a week that has ended in the household's time zone (its Monday is before the Monday of the household's current week), so a week not yet ended is ErrWeekNotEnded and a week with no list ErrListNotFound; freezing a frozen list does nothing. A list still untitled is titled first with archive_title from the week's plan, as Generate titles it; a title that cannot be written is logged and the list is frozen untitled, when its week label stands in for good. Titling and freezing are ordered by a per-service RWMutex: a title_supply_run job holds it shared across reading the list, the archive_title run and the title write, and Freeze holds it exclusively from reading the list to freezing it. So a freeze waits for a job in flight to store its title or fail for good; when the job failed, the freeze finds the list still untitled and makes its own archive_title attempt before freezing. A job that starts during a freeze waits for it, then finds the list titled (that title stands) or frozen untitled (the job fails title_failed), and stores nothing. The week_rollover schedule is to call Freeze for last week.

Recipe grounding

  • Dinners are grounded in real recipe pages found through recipe search (searxng or brave, per recipe_search_settings).
  • Fetching is SSRF-safe (recipes.Fetcher): https:// only, at every redirect hop. Its dialer resolves the host once and refuses unless every answer is public — every non-globally-reachable IPv4 special-purpose range (loopback, RFC 1918, link-local, carrier-grade NAT, documentation TEST-NETs, benchmarking 198.18/15, multicast, reserved and more) is refused, IPv4-mapped forms included, and IPv6 must be global unicast (2000::/3) outside documentation, 6to4, Teredo and other special ranges — then dials the address it checked, so a rebinding DNS answer cannot swap in a private one. Keep-alives and environment proxies are off, so every request dials and checks afresh. At most 3 redirects; a body over 2 MB is an error, never truncated; 10 s for the whole fetch; a descriptive User-Agent; requests to one host at least 1 s apart, counted from requests actually sent (an abandoned wait holds no slot); 2xx pages cached by URL for an hour (64 pages, oldest evicted), and callers get their own copy of a cached body.
  • Recipe data is parsed from schema.org Recipe JSON-LD (recipes.Extract): a small targeted scan of the page's application/ld+json scripts and og:site_name (no HTML parser dependency), finding the Recipe node at a script's top level, in a top-level array or in an @graph, with @type a string or an array of terms or absolute schema.org IRIs (https://schema.org/Recipe). An image or publisher given as an {"@id": ...} reference resolves to the node with that @id anywhere in the page's JSON-LD. The largest image (by ImageObject width × height; the first of equals) is resolved to an absolute URL; instructions — one string of lines, or a list of strings, HowToSteps and HowToSections — are flattened in order; ISO-8601 prep/cook/total times become minutes, total derived as prep + cook when only those are given; the site name is the publisher's, else og:site_name, else the host. A page with no Recipe is ErrNoRecipe, with two is ErrManyRecipes; a shape schema.org does not allow — such as a non-ISO-8601 duration like "20 minutes" or an empty one, or an image @id no node has — is ErrMalformedRecipe, never guessed at.
  • Recipe search (recipes.SearchProvider: Search(ctx, query, n) → []Candidate{URL, Title, Snippet}, n 1–20) appends " recipe" to a query without that word. SearXNG is called at {recipe_search_settings.base_url}/search?q=…&format=json&categories=general — directly, not through the Fetcher, since this configured endpoint may be on the internal network; Brave calls the Brave Web Search API with the key decrypted from recipe_search_settings.api_key_ct (recipes.NewSearchProvider). Redirects are not followed; 401/403 is ErrSearchRefused, 429 ErrSearchRateLimited, other non-2xx a SearchStatusError, and a body that is not the provider's JSON (SearXNG without a results array; Brave without "type": "search" or with a web object lacking results — Brave omits web when nothing matched), over 1 MB or with a result lacking an absolute http(s) URL ErrSearchResponse.
  • Ranking (recipes.Rank) fetches and extracts the top 5 candidates concurrently, rejects any whose recipe lacks ingredients or an image, and orders the rest by the token similarity of the recipe's title to the dish name (harness.Similarity, 0–1) plus a quarter for each of ingredients, image, steps and total minutes; ties keep search order. No unranked candidate is dropped silently: when some are ranked, they are returned with ErrCandidatesUnranked; when none are, the error is ErrNoUsableRecipe. Either is joined with each unranked candidate's URL and reason (fetch failure, no valid recipe, missing ingredients or image), so the caller decides per item what a failure means.
  • Ingredient lines are normalized by the normalize_recipe v1 skill (skills.NormalizeRecipe), always at temperature 0 whatever the household's setting. From the title, raw ingredient lines, steps and JSON-LD times it returns {ingredients: [{name, qty, unit, note}], method, difficulty, minutes_total}. Validation requires exactly one ingredient per input line, in order (a name matching another line but not its own is out of order), each with a name; a mismatch gets the harness's repair turn. Code-derived values win: a positive JSON-LD total is minutes_total; "slow cooker" or "crock pot" in the title or a step makes the method crockpot; a difficulty the model leaves null is derived — hard over 10 steps or 90 minutes, easy at 5 steps or fewer and 45 minutes or less, else medium, a crockpot's minutes not counting.
  • Photos are cached locally (photos) and processed with golang.org/x/image. recipes.PhotoCache.DownloadPhoto downloads through the Fetcher's FetchImage (JPEG, PNG, WebP or GIF content types, 8 MB cap, not cached), decodes the image (a GIF's first frame) and refuses a decoded image under 300 px on its short side, recording nothing. CachePhoto then records it in the caller's transaction: it scales the image so its long side is 1200 px and 400 px, and stores each as JPEG quality 82 at $MEALQUEST_DATA_DIR/media/photos/{id}-{size}.jpg. Images are deduplicated by the SHA-256 of the downloaded bytes; the writer's single connection serialises transactions, so one image is never recorded twice. Files written for a transaction that rolls back are removed by the caller (RemovePhotoFiles).
  • Grounding (recipes.Grounder) ties these together. Ground(ctx, dishName, query) searches for the top 5 candidates and ranks them all, stored and new alike: a candidate whose source_url is already stored is scored on its stored recipe (title, ingredients, steps, times, photo source) and its page is never refetched; every other candidate is fetched and extracted. Ranking alone decides between them. It then tries each ranked candidate best first. A stored one is returned as it is, so a stored recipe is reused only when it ranks above every new candidate that grounds. A new one is grounded in this order: (1) DownloadPhoto — the download and validation step of photo caching — (2) normalize_recipe, then (3) one transaction that runs CachePhoto (scales and writes the photo files and records the photos row) and stores the recipe, ingredients and steps (an existing source_url is updated in place); files written for a rolled-back transaction are removed. It returns Grounded{recipe_id, photo_id, method, minutes_total, difficulty, source_url, site_name}. A candidate whose photo fails or whose model output is unusable (llm.ErrBadOutput) is passed over; when none grounds, the error is a typed *recipes.ErrUngroundable listing every reason (no candidates, Rank's per-candidate reasons, photo or normalize failures). Failures that are not the candidates' — search, model provider, database — are returned as they are. GroundURL(ctx, url) does the same for one pasted URL. Candidates(ctx, dishName, query, n) returns the first n of the candidates Ground would try, without grounding any. Search failures wrap recipes.ErrSearchFailed and model provider failures recipes.ErrNormalizeFailed. GroundMany grounds items 3 at a time and returns one result per item, so one failure never affects its siblings. A recipe is usable only with ingredients, an image and, if given, a positive total time.

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 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.
  • internal/gcal owns the connection, as a confidential OAuth web client (MEALQUEST_GOOGLE_CLIENT_ID, MEALQUEST_GOOGLE_CLIENT_SECRET_FILE, both required at boot) over plain REST — no Google SDK. Every route is under the signed-in /api/v1:
    • GET /api/v1/integrations/google/connect redirects to Google's consent screen with scopes calendar.events and calendar.calendarlist.readonly, access_type=offline, prompt=consent and PKCE. Its state travels in the signed __Host-mealquest_google flow cookie (the same auth.FlowCookie mechanism as the login cookie), bound to the session by a hash of the session's CSRF token. The service also keeps each started connect's state in memory until the cookie expires; the callback consumes it, so a replayed cookie is refused (invalid_state), and a restart mid-connect means connecting again.
    • GET /api/v1/integrations/google/callback (the redirect URI: MEALQUEST_PUBLIC_URL's origin plus this path) checks the state and session, exchanges the code, refuses a response with no refresh token (reconnect_required) or without both scopes, seals the refresh token with secretbox into integrations_google.refresh_token_ct, sets connected_at and redirects to /settings/calendar. A reconnect keeps the chosen calendar and clears last_sync_error.
    • GET /api/v1/integrations/google answers {status, calendar_id, calendar_name, connected_at, last_sync_error}, where status is not_connected, connected or reconnect_required (connected, but Google refused the grant and its refresh token was cleared); absent values are null. It never holds a token.
    • DELETE /api/v1/integrations/google (CSRF-checked) revokes the token at Google, then deletes the row and, in the same transaction, forgets every pushed event (see Push to Calendar); a token Google reports as invalid_token counts as revoked, and any other revoke failure keeps the connection and its pushed events. A connection that requires a reconnect has no token to revoke and is deleted the same way.
    • GET /api/v1/integrations/google/calendars trades the stored refresh token for an access token and reads every page of the Calendar API's users/me/calendarList, answering [{id, summary, primary, accessRole}] for the calendars whose accessRole is writer or owner, in Google's order. Nothing else from Google's response is passed on.
    • PUT /api/v1/integrations/google/calendar {calendar_id} (CSRF-checked) lists the calendars the same way and stores calendar_id and calendar_name (the calendar's summary) only when the id is among them, answering {calendar_id, calendar_name}; any other id is 422 unknown_calendar and leaves the stored choice as it was. A calendar chosen earlier that has since been deleted or become read-only drops out of the listing and cannot be chosen again; the stored choice stays until the household chooses another.
    • Listing and choosing answer 409 not_connected when nothing is connected, and fail on Google as every Calendar call does (below).
    • Token failures, the same for listing, choosing and pushing:
      • When Google refuses the grant — invalid_grant on the token refresh, or HTTP 401 from the Calendar API to the access token just refreshed — the connection becomes reconnect required, under the account lock: refresh_token_ct is cleared and last_sync_error set to "Google access was revoked or expired — reconnect", and the call answers 409 google_reconnect_required. Later calls answer the same without contacting Google, until a reconnect stores a new token (and clears last_sync_error) or a disconnect deletes the row. The chosen calendar and every stored calendar_event_id are kept: the pushed events are still on the chosen calendar, so the invariant below holds, and the first push after reconnecting updates them in place.
      • HTTP 429 and 5xx from the token endpoint or the Calendar API are retried at most twice (three tries), waiting 0.5 s then 1 s, still under the account lock. If the last try fails the same way the call answers 503 google_unavailable with retryable: true; nothing is cleared or disconnected. An event insert retried after Google took it is answered 409 and updated, so a retry never adds a second event.
      • Any other Google failure is 502 google_failed.
    • Connect's exchange-and-store, disconnect's read-revoke-delete and every use of the stored token (listing and choosing calendars) hold one lock across the Google requests and the database write: Google revokes the whole grant, so a revoke overlapping a reconnect would otherwise leave a stored token Google has already revoked.
    • No response, log line or error ever holds a token or Google's response text.
  • Push to Calendar: POST /api/v1/plans/{weekStart}/push-calendar (CSRF-checked, plan.Service.Push writing through gcal.Service.PushEvents) answers 200 with the plan as synced. Each day is one event in the chosen calendar: summary Dinner: {name}, from the household dinner_time on that date for 60 minutes, as dateTime with the household timeZone (so its UTC instant follows a DST change within the week), and a description of the dinner's name, Start by {time} for a crock-pot dinner of known minutes, and {MEALQUEST_PUBLIC_URL}/dinner/{id}.
    • Deterministic event ids: lowercase unpadded base32hex of SHA-256 of mealquest|{weekStart}|{date} — 52 characters of a–v and 0–9, within Google's id rule. Each event is inserted; a 409 (the id exists, even as an event deleted in Calendar) updates it instead, with status confirmed, so pushing again never adds a second event.
    • A push writes the days whose calendar_event_id is empty (never pushed, swapped, or redrafted to another dinner). When none is, it writes every day again, which puts back anything changed in Calendar.
    • A stored calendar_event_id is always an event on the chosen calendar. Choosing a calendar other than the stored one, and disconnecting (which leaves none chosen; connecting again chooses none either), clear every day's calendar_event_id in the same transaction as the choice or the delete, and edit each plan that had one (revision raised, synced → changed), so the next push writes the whole week to the calendar then chosen and a push that read such a plan before is refused (409 plan_changed). A reconnect while connected keeps the chosen calendar, so it keeps the event ids. Nothing else clears the choice: a grant Google refuses clears only the stored token (see Token failures), so the event ids stay on the calendar still chosen.
    • Only a push that wrote all its events is recorded, in one transaction: the plan becomes synced with calendar_synced_at, and each day written stores its event id. A push that failed part way records nothing and leaves the status as it was; pushing again updates the events already written. The record runs under the account lock, in the same hold as the event writes, so the chosen calendar cannot change between them. A plan swapped or redrafted while it was being pushed, or whose events another calendar choice cleared (its revision moved), is not recorded: 409 plan_changed.
    • Errors: 409 not_connected, 409 no_calendar (connected, no calendar chosen), 409 calendar_unavailable (the chosen calendar was deleted — Google 404 — or is no longer writable — 403 requiredAccessLevel; choose another), 409 google_reconnect_required, 503 google_unavailable (retryable) and 502 google_failed (see Token failures), 404 when the week has no plan, 400 invalid_week. The push holds the account lock across its Google requests and its record, like listing and choosing.
    • The week's Supply run (Feature 17) is made from a pushed plan: after a successful Push, the handler runs shopping.GeneratePushed for the week at the plan revision Push pushed, and the 200 answer carries supply_run: {status, error}. status is generated; archived when the week's Supply run is in the Supply archive and was left as it was; or failed — error plan_changed (retryable, logged as a warning) when the plan was edited between the push and generation, so no Supply run is made from a plan Calendar does not show and pushing again makes it; or error supply_run_failed (retryable, logged as an error) when it could not be made, and POST /api/v1/shopping/{weekStart}/generate makes it again. Generation checks the revision inside its own transaction, and never fails or undoes the push — Calendar already shows the plan.

Supply run API (Feature 17)

  • GET /api/v1/shopping/{weekStart} returns {weekStart, label, title, status, sent_to_chat_at, needed_count, total_count, groups: [{dinner: {id, name}, date, day, items: [{id, name, qty, needed}]}]}: label is the week label (shown while title is null), day the plan day's weekday ("Monday"), qty null when the recipe gives none, needed_count the items still needed and total_count all items. 404 when the week has no Supply run, 400 invalid_week.
  • POST /api/v1/shopping/{weekStart}/generate makes or remakes the list through shopping.Generate and answers with the same shape plus skipped: [{date, dinner_id, dinner_name, reason}]. 404 when the week has no plan, 409 archived when its list is in the Supply archive.
  • PATCH /api/v1/shopping/items/{id} takes {needed} (a required boolean; it sets the flag, it does not flip it) and answers {id, needed, needed_count, total_count} with the list's counts after the change. 404 for an unknown item, 409 archived when the list is in the Supply archive, 400 invalid_request for a bad body.
  • GET /api/v1/shopping/archive?by=week|month|year returns the Supply archive {by, periods: [{key, heading, posts: [{weekStart, label, title, dinnerCount, itemCount, onHandCount, dinners, current}]}]}: the Supply run of every week up to the household's current week, newest first, grouped by the Monday's ISO week (key "2026-W41", heading the week label), month ("2026-10", "October 2026") or year ("2026"). The Monday is a household-local date, so the week of Monday 28 December is in December. label is the week label ("Week 41 · Oct 5 – 11"; the UI sets it in capitals), title null while untitled, dinners the list's dinners in day order and dinnerCount their number, itemCount all items, onHandCount the items marked "have it", and current true for the household's current week. 400 invalid_request for any other by. A frozen list is read through GET /api/v1/shopping/{weekStart} with status archived.

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 with Clear-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.

First run: while household_settings.onboarding_completed_at (read from GET /api/v1/settings/household) is null, every route redirects to /onboarding. Onboarding is a full screen outside the app shell, in three steps: the welcome (/onboarding), the Regulars (/onboarding/regulars, mock 1h, adding and archiving through the dinners API optimistically; Next waits for three saved Regulars, and a common pick is not offered once a Regular has a name harness.Similar to it — the PWA's copy of the name rules in web/src/dinners/names.ts folds letters with web/src/dinners/letterFolds.json, which gen_folds.py writes from the same table as the server's, and runs the same cases, web/src/dinners/testdata/dedupe_cases.json, as the server's) and notifications (/onboarding/notifications: what MealQuest notifies about, the iOS install hint, "Turn on notifications" and "Maybe later"). "Maybe later", and "Turn on notifications" once notifications are enabled, send PATCH /api/v1/settings/household with {"onboarding_completed": true}; the server sets onboarding_completed_at to the time of that update (keeping the first time if it is already set; the field itself is read-only and false is refused) and the app goes Home. Completing needs at least three Regulars (household.MinRegulars); with fewer the server answers 400 invalid_settings naming onboarding_completed and changes nothing. Step 3 can be reached with fewer — by its URL, or after another member removes a Regular — so it then shows the server's reason with an "Add Regulars" button back to step 2. Notification permission is never asked for on page load; until Feature 19 wires Web Push, "Turn on notifications" asks for nothing and says plainly that notifications are not on yet, offering "Finish setting up" (which completes onboarding as above) instead of a silent success. Offline on the saved pages the settings cannot be read (the service worker never caches /api/v1/settings), so the saved pages open as they are.

Regular dinners (/settings/regulars) lists the Regulars from GET /api/v1/dinners?status=regular, each with its photo, "method · minutes", favorite heart and grounding badge ("Finding recipe…" while pending, "Recipe found" when grounded or manual, "Needs a recipe" when failed). The add field takes a name or a pasted https:// recipe link. Tapping a Regular opens an edit sheet (name, method, minutes, difficulty; remove archives it after a confirmation); tapping one that needs a recipe opens "Fix recipe" (pick one of the recipe candidates, paste a link or type the ingredients and steps in). Adds, removals and the heart show at once and are undone if the server refuses them. While any Regular is pending, the list is read again every 3 seconds (not while a change is in flight), and polling stops once none is.

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.