R-01: a Rotation check decided drop now records the dinner's name at the decision in rotation_reviews.name (migration 0014, a table rebuild as 0012 did for suggestions; checks dropped before it take their dinner's name). ListPassedNames and ListRecentPassedNames read that name instead of the mutable dinners.name, so renaming the archived dinner no longer lets the dropped dish back into a Side quest batch. A CHECK keeps a name on drops only. R-02: deciding on a dinner that has never had a Rotation check is now ErrReviewNotFound, 404 not_found; a dinner whose checks are all decided, such as a second decision, stays 409 no_pending_review. Tests: TestLaterBatchNeverResuggestsRenamedDrop (drop, rename, next batch still refuses the original name), TestMigrateRotationReviewName (0013 to 0014 upgrade and constraints), TestPostRotationReviewNeverChecked (404), and TestDecideReview/TestDecideReviewRefused updated. Maestro: a1887d1c-c405-478c-ac7d-8cb8b40d280d Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
702 lines
44 KiB
Markdown
702 lines
44 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 (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. |
|
||
| `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, 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 (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 each day's
|
||
`calendar_event_id`, and 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`. 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`, 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, and any other dinner 409 `dinner_not_plannable`.
|
||
- Rotation check (`plan.Service`, D-021). `MarkTried(date)`, which the
|
||
`rotation_check` schedule calls for the household-local day just past,
|
||
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.
|
||
|
||
## 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.
|
||
- **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 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.
|